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
|
|
@ -292,11 +292,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-162 | **NARROWED 2026-08-09 (Opus review of `92ea3977`, finding F1) — the "Buy All" half of this row CLOSES.** `VendorUiController.BuyAllButtonPressed` now ports all four of retail's client-side pre-send guards (pyreal affordability `pc:204017`, alt-currency affordability `pc:204032`, container-slot capacity `pc:204053`, item-slot capacity `pc:204067`) — see `ComputeBuyTransactionValue`/`ComputeBuySlotsNeeded`/`CountPlayerContents`, each guard returning with staging fully intact and retail's own exact notice string (`"You don't have enough money"` at `0x007b57b4`, `"You must empty some slots in your backpack first"` at `0x007b5750`, both byte-recovered). The container-vs-item slot CLASSIFICATION this port uses (`ItemType.Container` instead of retail's bitfield/capacity test) is its own new, narrower approximation — filed separately as AP-168 rather than folded in here. Only `TryBuy`'s single-item Buy path (Items tab's own Buy button, and the Buying tab's "Buy Item") remains WITHOUT a client-side pre-check — the risk/oracle columns below now describe that one remaining case, not both. **EXTENDED 2026-08-09 (Slice 6b) — the same omission now also covers "Buy All".** `ItemInteractionController.TryBuyAll` (the batched-send path `VendorUiController.BuyAllButtonPressed` calls) sends unconditionally too, without porting retail's `pc:204017/204032/204053/204067` affordability/pack-capacity pre-checks for the MULTI-item case either — the same latency-not-correctness tradeoff this row already documents for the single-item path, extended rather than duplicated into a second row; retiring this row should port both the single- and batched-send pre-checks together. **Filed 2026-08-09, Slice 6.3 (buy wire + button).** Retail's `BuySingleItem` (`pc:201661`) performs TWO client-side pre-checks before ever sending `CM_Vendor::Event_Buy`: (a) an affordability check against `this->m_totalValue` (pyreal) or `shopVendorProfile->trade_num - m_last_sale` (alt-currency), showing a LOCAL string via `ECM_UI::SendNotice_DisplayStringInfo` and returning without sending anything on failure (`pc:201686-201717`); (b) a pack/container-capacity pre-check (`pc:201730-201746`) mirroring the server's own check. acdream's `ItemInteractionController.TryBuy` sends unconditionally once the shared use/inventory gate is free — no client-side affordability or capacity check runs before dispatch. Every refused purchase pays a full round-trip (send → server rejects → `UseDone`/`GameEventInventoryServerSaveFailed`) instead of failing instantly and silently client-side. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `src/AcDream.App/UI/ItemInteractionController.cs` (`TryBuy`) | The research doc's own open question 1 (`docs/research/2026-08-08-slice6-vendor-transactions-research.md`) recommends deferring this: the server is authoritative either way (ACE re-validates both affordability and capacity server-side — `Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`), so omitting the client pre-check is a LATENCY/UX gap, not a correctness one — a refused purchase still fails cleanly, just one round-trip later than retail. | A player attempting to buy something they cannot afford or have no room for sees the failure arrive after a network round-trip instead of instantly; against a well-behaved ACE server no purchase can succeed that retail's pre-check would have blocked, so no transaction outcome differs — only its latency. Retiring this row means porting `BuySingleItem`'s two pre-check branches (`pc:201686-201746`) into `TryBuy` before dispatch. | `gmVendorUI::BuySingleItem` pc:201661/0x004C2820 (affordability pc:201686-201717, capacity pc:201730-201746); `Vendor.BuyItems_ValidateTransaction` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:431-571`); `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §D point 4, Open question 1 |
|
||||
| AP-163 | **REVIEW CORRECTION 2026-08-09 (Opus review of `97cf8738`, finding F1):** this row's ownership discipline is now COMPLETE on both halves, not just the add-time collision guard described below. The retire pass (`OnVendorTransition`'s loop over guids missing from the new `ApproachVendor` snapshot) previously deleted ANY such guid unconditionally — a plain bug, not a documented divergence, since buying a UNIQUE vendor item re-containers that SAME guid into the buyer's own pack (`Player_Commerce.cs:86-108`) BEFORE the post-buy refresh that drops it from the shop's own list arrives; the old retire pass would have stripped the just-purchased item straight back out of the buyer's inventory. **The exact rule now enforced:** each owned guid remembers the vendor id it was registered under (`Dictionary<uint,uint>`, guid -> vendorId), and the retire pass calls `ClientObjectTable.Remove` ONLY when the live object's CURRENT `ContainerId` still equals that recorded vendor id; when it differs (or the object is already gone), the tracking entry is dropped silently and the object itself is left completely untouched — the SAME skip-not-clobber discipline the add-time collision guard below already used, now applied symmetrically on the way out. This is a bug fix, not a new divergence, and does not change this row's still-open scope: retail's actual `ClientObjMaintSystem`/`CObjectMaint` collision behavior on a guid collision remains untraced. **Filed 2026-08-09, Slice 6.1 (shop-item materialization).** `VendorShopItemMaterializer` registers each `ApproachVendor` shop item into `ClientObjectTable` keyed by its own server guid. ACE's `UniqueItemsForSale` (`Vendor.cs:34,638`) can list the EXACT guid a player last held (an item sold to this vendor keeps its original guid), so a guid collision against an existing, differently-owned `ClientObjectTable` entry is a real, if rare, possibility. No retail behavior for this exact case was traced (retail's `ClientObjMaintSystem`/`CObjectMaint` guid-keyed registration internals were not decompiled for this pass). acdream's policy is a conscious, conservative default: a guid this materializer did NOT itself add to the table on a previous cycle is treated as owned by something else and is left completely untouched — never overwritten, never later removed by this class. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`OnVendorTransition`'s collision guard) | Skip-not-clobber is the safe default absent a traced retail mechanism: silently reparenting a live entity's or another container's item into the vendor's `ContainerId` would corrupt real ownership state (equipment tracking, burden, radar) for a guid this code does not own, which is strictly worse than a single shop row's status-bar/appraisal projection staying blank. The vendor list itself is unaffected either way — `VendorUiController` reads display fields straight off `VendorShopItem`, never through `ClientObjectTable`. | If retail's actual behavior differs (e.g. it always overwrites, or a real `UniqueItemsForSale` collision is more common than assumed), the one colliding shop row's status-bar/appraisal projection stays stale/blank instead of showing the vendor listing — a narrow, single-row display gap, never a corrupted non-vendor object. Retiring this row requires tracing retail's `ClientObjMaintSystem` registration behavior on a guid collision, which was out of scope for this pass. | No direct retail citation traced this pass — `Vendor.cs:34,638` (`UniqueItemsForSale`, ACE) establishes the collision is POSSIBLE, not what retail does about it; `docs/research/2026-08-08-slice6-vendor-transactions-research.md` (task brief: "study how ACE guids vendor stock and state your collision policy with evidence") |
|
||||
| ~~AP-164~~ | **RETIRED 2026-08-09 (Opus review of `92ea3977`, finding F4).** `VendorProfile::InqAcceptability`'s non-sellable bitfield check (`(*(uint8_t*)((char*)arg2->_bitfield)[3] & 1) != 0`, `pc:005d1aa7`, byte 3 bit 0 — bit 24, `0x01000000`, of `PublicWeenieDesc`'s packed flags) is now ported end to end. `PublicWeenieFlags.Retained` (`src/AcDream.Core/Items/ItemInteractionPolicy.cs`) names the bit; `VendorSellAcceptability.Evaluate` takes it as a new `publicWeenieBitfield` parameter and ORs it into the SAME `WrongType` outcome the type-mask mismatch produces, matching retail's own OR'd branch exactly. The row's three original claims are each corrected by this fix, not merely superseded: the bit WAS already threaded onto `ClientObject` (`ClientObject.PublicWeenieBitfield`, `ClientObject.cs:241`, populated by `ObjectTableWiring.ToWeenieData`'s `PublicWeenieBitfield: s.ObjectDescriptionFlags` mapping) — the claim that no `PublicWeenieFlags` member was named at `0x01000000` was true only because the member had never been added, not because the underlying data was missing; and the "unclear whether `ApproachVendor`'s wire shape carries this flag for a player-owned pack item" question was moot from the start — the drag-to-sell flow always operates on the PLAYER'S OWN pack item (arrived via ordinary `CreateObject`/`EntitySpawn`, never `ApproachVendor`, which only describes the VENDOR'S stock), and that path already carried the field. Nothing is left unmodeled. | `src/AcDream.Core/Items/VendorSellAcceptability.cs` (`Evaluate`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` (`PublicWeenieFlags.Retained`) | — | — | `VendorProfile::InqAcceptability` `pc:484768-484797`/`0x005d1a90`, bitfield test at `pc:005d1aa7`; `acclient.h:6456` (`BF_RETAINED = 0x1000000`) |
|
||||
| AP-165 | **Filed 2026-08-09, Slice 6b (buy staging).** Retail's "Buying" tab `Buy Item`/`Clear Item` removal-amount rule (`gmVendorUI::HandleButtonClicks` cases `0x100000c9`/`0x100000cb`, `pc:203989-204010`/`204080-204094`) tests the selected item's own `pwd._maxStackSize` (the item TYPE's stack ceiling) to decide `RemoveProfileFromList`'s amount argument (`-1` full removal vs `1` partial decrement). `VendorShopItem` does not carry a `MaxStackSize` wire field (only `DescStackSize`, the item's CURRENT authored per-stack quantity, already threaded through for pricing), so `VendorUiController.BuyStagingRemovalAmount` substitutes `DescStackSize` as the stackability test instead. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`BuyStagingRemovalAmount`) | The two fields agree for every case that matters in practice — `DescStackSize <= 1` implies `MaxStackSize <= 1` — and can only disagree for a vendor stocking a single unit of an otherwise-stackable item TYPE. | In that narrow case, `Buy Item`/`Clear Item` would decrement the staged quantity by one instead of clearing the whole entry outright — a minor UI residue (a stale partial-quantity row stays staged) with no wire or money-safety consequence, since a subsequent Buy Item/Clear Item click on the same row converges it to zero anyway. | `gmVendorUI::HandleButtonClicks` cases `0x100000c9`/`0x100000cb`, `pc:203989-204010`/`204080-204094`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` §Q3 |
|
||||
| AP-166 | **Filed 2026-08-09, Slice 6b/6c (staging presentation).** Two cosmetic gaps in the Buying/Selling tabs: (1) their own per-row count/price text and purse-total text (`m_buyListText`/`m_buyPurseText`, D0 ids `0x100000C7`/`C8`; `m_sellListText`/`m_sellPurseText`, `0x100000D0`/`D1`) are not wired — a staged row renders as an icon cell only (matching the Items tab's own icon-strip shape), with no adjacent per-row label; (2) a successful Sell Item/Sell All/Clear Item does not port retail's cross-panel `gmVendorUI::VendorItemSetSellState` — the player's OWN inventory panel highlight that marks an item as "pending sell" is neither shown on stage nor cleared on unstage. | `src/AcDream.App/UI/Layout/VendorUiController.cs` | The underlying staging/transaction mechanics (add, remove, batched Buy All/Sell All, `InqAcceptability` gating) are fully functional without these presentation details; the Items tab's shared name/cost text pair already shows price/quantity for whichever row is currently selected (including a staged one), and the shared system-message channel already surfaces rejection feedback. | A player cannot see a NON-selected staged row's price/quantity at a glance (only the currently-selected row's name/cost renders, via the Items tab's shared text pair — there is no per-row label on the Buying/Selling strips themselves), and a pending-sell item shows no visual cue back in the main inventory panel while staged. | `VendorBuyUI::VendorBuyUI` `pc:199717`; `VendorSellUI::VendorSellUI` `pc:199753` (purse/list text element construction); `gmVendorUI::VendorItemSetSellState` (call sites `pc:204107`/`204133`); `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4 D0 tree |
|
||||
| ~~AP-165~~ | **RETIRED 2026-08-08 (grand-gate re-gate finding R1).** `VendorShopItem` now carries `MaxStackSize` (threaded from `PublicWeenieDescBody.StackSizeMax`, itself already parsed but previously never forwarded to the vendor domain type or the wire), so `VendorUiController.BuyStagingRemovalAmount` reads it directly — `(item.MaxStackSize ?? 1) > 1 ? -1 : 1` — a byte-exact port of `gmVendorUI::HandleButtonClicks` cases `0x100000c9`/`0x100000cb` (`pc:203989-204010`/`204080-204094`, testing `eax->pwd._maxStackSize > 1`), no longer a `DescStackSize` substitute. The SAME sibling call site `gmVendorUI::InqListSlotCount` (`pc:200052`, `eax->pwd._maxStackSize <= 1`) — previously approximated with `DescStackSize` in `VendorUiController.ComputeBuySlotsNeeded`'s stackable test, undocumented — is corrected the same way in the same commit. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`BuyStagingRemovalAmount`, `ComputeBuySlotsNeeded`) | — | — | `gmVendorUI::HandleButtonClicks` cases `0x100000c9`/`0x100000cb`, `pc:203989-204010`/`204080-204094`; `gmVendorUI::InqListSlotCount` `pc:200038-200065`/`0x004c0c10` |
|
||||
| AP-166 | **Filed 2026-08-09, Slice 6b/6c (staging presentation).** **NARROWED 2026-08-08 (grand-gate re-gate finding R3) — the text half CLOSES.** The Buying/Selling tabs' own staged-count/total-value text (`m_buyListText`/`m_sellListText`, D0 ids `0x100000C7`/`0x100000D0`) and purse-total text (`m_buyPurseText`/`m_sellPurseText`, `0x100000C8`/`0x100000D1`) are now wired (`VendorUiController.UpdateBuyTransactionText`/`UpdateSellTransactionText`), updating on every staging change and on every player money change. Retail's exact literals were recovered: the pyreal-path strings are byte-verbatim — `"Buying %d %s worth %hsp"` / `"Selling %d %s worth %hsp"` (the Sell literal is directly legible in the decompiled body of `VendorSellUI::UpdateTransactionValue`, `pc:202458`; the identically-shaped Buy literal is mis-attributed by the decompiler to a bogus vtable-slot symbol at its own call site, `pc:202290`, so it was recovered instead by reading the retail binary's own data segment directly at VA `0x007b58bc`, cross-confirmed byte-for-byte against the Sell literal's own VA `0x007b5930`) and `"You have %hsp"` (directly legible at both `VendorBuyUI::UpdateTotalValue` `pc:202366` and `VendorSellUI::UpdateTotalValue` `pc:202495`, byte-identical). The alt-currency PURSE literal `"You have %d %s."` is also directly legible (`VendorBuyUI::UpdateTotalValue`, `pc:202344`) and ported; the alt-currency LIST-line construction is a faithful extrapolation of the confirmed pyreal shape, NOT independently byte-verified — this one narrow piece remains open under this row (a rare trade-note-vendor case). The row's SECOND original gap — a successful Sell Item/Sell All/Clear Item does not port retail's cross-panel `gmVendorUI::VendorItemSetSellState` (the player's OWN inventory panel highlight marking an item "pending sell") — is UNCHANGED, still open. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`BuildTransactionListText`, `BuildPurseText`, `UpdateBuyTransactionText`, `UpdateSellTransactionText`, `ComputeSellTransactionValue`, `OnObjectMoneyChanged`) | The alt-currency LIST-line residual is narrow (a rare trade-note vendor) and the pyreal path — the live-evidence report's own case — is now byte-exact; the pending-sell inventory highlight is a separate, unrelated mechanism this pass did not attempt. | An alt-currency vendor's Buying/Selling LIST line may not match retail's exact wording (the purse line does, and the pyreal path's LIST line does); a pending-sell item still shows no visual cue back in the main inventory panel while staged. | `VendorBuyUI::VendorBuyUI` `pc:199717`; `VendorSellUI::VendorSellUI` `pc:199753` (purse/list text element construction); `VendorBuyUI::UpdateTransactionValue` `pc:202170-202300`; `VendorBuyUI::UpdateTotalValue` `pc:202304-202376`; `VendorSellUI::UpdateTransactionValue` `pc:202380-202468`; `VendorSellUI::UpdateTotalValue` `pc:202472-202504`; `gmVendorUI::VendorItemSetSellState` (call sites `pc:204107`/`204133`); acclient.exe (Sept 2013 EoR, PDB-paired) data segment VA `0x007b58bc`/`0x007b5930`; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4 D0 tree |
|
||||
| AP-167 | **Filed 2026-08-09, Opus review of `92ea3977`, finding F6 (Sell Item's SellSingleItem port).** Retail's `gmVendorUI::SellSingleItem` (`pc:201808-201881`, `0x004c2b40`) gates its whole stack-split/send branch behind an OUTER check: if the selected item is container-capable (a bitfield bit this port does not currently decode, ORed with nonzero `_itemsCapacity`/`_containersCapacity`) AND it currently holds contents, `SellSingleItem` refuses with a distinct notice (`RecvNotice_SkillAdvancementClassChanged`'s literal string, not yet recovered) and never reaches the stack-split check or the send at all — matching `InqAcceptability`'s own "a non-empty container always accepted" bypass being the WRONG direction for a DIRECT single-item sell of the CONTAINER itself. `VendorUiController.SellItemButtonPressed` does not port this outer branch — it goes straight to the stack-split check for every selected item, container or not. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`SellItemButtonPressed`) | The scope of the review finding (F6) was the stack-split refusal and the literal amount-1 send, both fully ported; the container-emptiness branch is a distinct, separately-gated retail mechanism this pass did not trace far enough to port (the exact bitfield bit and the refusal string are both still unrecovered). The server remains authoritative regardless — a client-side accept here is a UX gap, not a wire-safety one. | Selecting a non-empty container (a bag with items still inside it) and pressing "Sell Item" directly would, in retail, refuse locally with a distinct message; this port instead falls through to the ordinary stack-split check (which a non-stackable container passes trivially) and sends the sell — the actual sale's server-side fate for a non-empty container is untraced (ACE may reject, merge, or drop the contents; not investigated here). | `gmVendorUI::SellSingleItem` `pc:201808-201881`/`0x004c2b40` (outer container-emptiness branch `pc:201818-201829`); `docs/research/2026-08-08-slice6b-vendor-completion-research.md` |
|
||||
| AP-168 | **NARROWED 2026-08-08 (grand-gate finding G1) — the player's-OWN-pack half (`CountPlayerContents`) is FIXED; only the shop-stock half (`ComputeBuySlotsNeeded`) remains approximated.** Live testing surfaced the risk this row already predicted: "Buy All" false-blocked a container purchase while the player visibly had free container slots. Root cause was NOT the theoretical corner case originally described here — it was that the old dual-heuristic (`ItemType.Container` bit OR nonzero `ItemsCapacity`/`ContainersCapacity`) could over-classify an ordinary non-container object as an occupied container slot, undercounting free space. `CountPlayerContents` now reads `ClientObject.ContainerTypeHint` first — retail's actual wire `ContainerProperties` (`Item_ServerSaysContainId` 0x0022's `ContainerType`; also carried by `ContentProfile`/`PlayerDescription`'s per-entry container-kind byte), already threaded onto every owned object by `InitializeInventoryManifest`/`ApplyConfirmedServerMove`/`ReplaceContents` and already used for this identical question by `ClientObjectTable.IsContainerListMember` — falling back to `ItemType.Container` alone (the capacity-field legs were dropped) only for the rare object that never received a hint. This matches retail's real `_itemsList`/`_containersList` bucketing (`ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` @0x0058beb0/0x0058bec0 just report already-bucketed `IDList` lengths; the bucketing happens once, at insert time, in `ServerSaysContainID` @0x0058be40, from that same wire field) rather than reconstructing it from the item's own type/capacity fields. Original text: **Filed 2026-08-09, Opus review of `92ea3977`, finding F1 (Buy All's client pre-send capacity guard).** Retail's `gmVendorUI::InqListSlotCount` (`pc:200038-200065`, `0x004c0c10`) classifies each staged item as needing a CONTAINER slot or an ITEM slot by testing a bitfield bit (a decompiler string-misattribution artifact not yet decoded) ORed with the item's own nonzero `_itemsCapacity`/`_containersCapacity`. `VendorUiController.ComputeBuySlotsNeeded`/`CountPlayerContents` approximate this with `(item.ItemType & ItemType.Container) != 0` instead — correct for the ordinary case (an authored backpack/pouch DOES carry the `Container` type bit) but not byte-identical for the theoretical case of a non-`Container`-typed item that still authors nonzero pack/side capacities (or vice versa, a `Container`-typed item with zero capacity of its own, e.g. a locked/sealed decorative chest never meant to be carried). | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ComputeBuySlotsNeeded`, `CountPlayerContents`) | `VendorShopItem`'s wire shape (Slice 5's deliberately narrow browse-scope subset) genuinely does not carry `PublicWeenieBitfield`/`ItemsCapacity`/`ContainersCapacity`/`ContainerProperties` the way `ClientObject` does for an ordinary `CreateObject`/membership-sourced item, so `ComputeBuySlotsNeeded` (the shop-stock side, staged-but-not-yet-owned items) cannot read a wire-truth hint the way the fixed `CountPlayerContents` (the already-owned side) now does; extending the DTO was out of scope for this fix. The server remains authoritative and re-validates real pack-space regardless (`Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`) — the residual failure mode stays UX/latency, not correctness. | A vendor selling a `Container`-typed item with zero authored capacity (rare/decorative) would still be misclassified as needing a container slot instead of an item slot, or vice versa for a non-`Container`-typed item that DOES author capacity (also rare) — the pre-check could still reject a purchase retail's own guard would have allowed, or allow one retail would have blocked, purely on the CLIENT side for the SHOP-STOCK item being bought; the player's-OWN-pack accounting that drives the free-slot count is no longer the source of that risk. | `gmVendorUI::InqListSlotCount` `pc:200038-200065`/`0x004c0c10`; `ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` `0x0058beb0`/`0x0058bec0`; `ACCWeenieObject::ServerSaysContainID` `0x0058be40`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` |
|
||||
| AP-169 | **Filed 2026-08-08, grand-gate finding G2 (vendor toolbar split-slider absent live).** Retail's `gmToolbarUI::HandleSelectionChanged` reads `eax_5->pwd._stackSize` (`pc:198688`/`198744`/`198774`/`198791`) uniformly for both owned-inventory and vendor-owned selections — the same `PublicWeenieDesc::_stackSize` field an ordinary `CreateObject` carries. The user's local ACE server never populates that field for a vendor browse-list row: `Vendor.LoadInventoryItem` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:144-172`) builds the listing `WorldObject` via `WorldObjectFactory.CreateNewWorldObject` and sets only `wo.VendorShopCreateListStackSize` (our `VendorShopItem.StackSize`, the packed ItemProfile "how many for sale" dword) — it never calls `wo.SetStackSize(...)`, so `PublicWeenieDesc`'s own conditional `StackSize` field (our `VendorShopItem.DescStackSize`, what `GameEventApproachVendor.cs:60`'s `obj.SerializeGameDataOnly` walks) comes back null on the real wire for every vendor listing, live-verified with a materializer-level reproduction test. `VendorShopItemMaterializer.ToWeenieData` now prefers `DescStackSize` when the wire genuinely carries it (retail-faithful first — a real retail server, or a future ACE fix, populating `_stackSize` normally is honored unchanged), falling back to the packed `StackSize` supply-count field (clamped positive) when it doesn't — the field ACE DOES reliably send. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`ToWeenieData`, `ResolveDisplayStackSize`) | This is an ACE-server-constraint adaptation, not a retail redesign: retail's own client-side read (`pwd._stackSize`) is unchanged and still tried first; only the fallback source differs, and only because the live server this port talks to (the user has no other AC server available) never fills the field retail's own server apparently does. The packed supply-count field is the only OTHER wire-carried "how many" signal `VendorShopItem` has. | The packed field's `-1` "unlimited supply" sentinel has no bounded per-row purchase cap in `VendorShopItem`'s current wire shape (no `_maxStackSize` carried), so an unlimited-supply vendor stack still falls through to the safe non-splittable default (slider absent, single-unit buy) rather than a genuine retail-faithful per-row cap — a SMALLER residual than the pre-fix "no vendor stack ever shows a slider" defect, but not zero. A finite-quantity vendor stack (the common case, matching the live gate's arrows/trade-notes report) now shows the slider correctly. | `gmToolbarUI::HandleSelectionChanged` `pc:198635-198930`/`0x004bf380` (stack-size reads at `pc:198688`/`198744`/`198774`/`198791`); `Vendor.LoadInventoryItem` `Vendor.cs:144-172`; `GameEventApproachVendor.cs:52-61` |
|
||||
| AP-169 | **Filed 2026-08-08, grand-gate finding G2 (vendor toolbar split-slider absent live).** **CORRECTED 2026-08-08 (re-gate finding R1) — the G2 fallback story was itself wrong; the fix below replaces it.** Retail's `gmToolbarUI::HandleSelectionChanged` reads `eax_5->pwd._stackSize` (`pc:198688`/`198744`/`198774`/`198791`) uniformly for both owned-inventory and vendor-owned selections — the same `PublicWeenieDesc::_stackSize` field an ordinary `CreateObject` carries. The user's local ACE server never populates that field for a vendor browse-list row: `Vendor.LoadInventoryItem` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:144-172`) builds the listing `WorldObject` via `WorldObjectFactory.CreateNewWorldObject` and sets only `wo.VendorShopCreateListStackSize` (our `VendorShopItem.StackSize`, the packed ItemProfile "how many for sale" dword) — it never calls `wo.SetStackSize(...)`, so `PublicWeenieDesc`'s own conditional `StackSize` field (our `VendorShopItem.DescStackSize`) comes back null on the real wire for every vendor listing. The G2 fix's fallback — the packed `StackSize` supply-count field — did NOT survive live re-testing: a standard vendor listing (e.g. a Prismatic Taper) has UNLIMITED stock (`StackSize == -1`), so the fallback produced nothing usable and the bar stayed hidden, matching the live re-test report exactly (bare "Prismatic Taper", no bar, no count). The live retail screenshot that triggered the re-test 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: a real retail server evidently authors `_stackSize == _maxStackSize` for an unlimited-supply "one full stack" browse listing. `VendorShopItem` now carries `MaxStackSize` (threaded from `PublicWeenieDescBody.StackSizeMax`, itself already parsed but previously never forwarded); `VendorShopItemMaterializer.ToWeenieData` and `VendorUiController.ResolveBuyQuantity` both now resolve through the single shared `VendorSplitPolicy.ResolveAuthoredStackSize(descStackSize, maxStackSize)` helper — `DescStackSize` when the wire genuinely carries it (retail-faithful first, still honored unchanged for any server that DOES populate it), else `MaxStackSize` (the field ACE DOES reliably send), else 1. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`ToWeenieData`); `src/AcDream.Core/Items/VendorSplitPolicy.cs` (`ResolveAuthoredStackSize`); `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ResolveBuyQuantity`) | This is an ACE-server-constraint adaptation, not a retail redesign: retail's own client-side read (`pwd._stackSize`) is unchanged and still tried first; only the fallback source differs, and only because the live server this port talks to (the user has no other AC server available) never fills the field retail's own server apparently does. `MaxStackSize` is the value a real retail server would have put in `_stackSize` for this exact case (an unlimited-supply full-stack listing), not an arbitrary substitute. | A vendor stocking a bounded but non-unit quantity WITHOUT ACE ever populating `DescStackSize` shows a ceiling of the item's max stack size rather than its actual bounded stock count — the client no longer enforces "you can't buy more than what's in stock" via the slider ceiling itself; the server remains authoritative and rejects an over-large Buy regardless (a latency/UX gap, not a correctness one, matching this project's established pattern for client-side pre-checks — see AP-162). | `gmToolbarUI::HandleSelectionChanged` `pc:198635-198930`/`0x004bf380` (stack-size reads at `pc:198688`/`198744`/`198774`/`198791`); `Vendor.LoadInventoryItem` `Vendor.cs:144-172`; `GameEventApproachVendor.cs:52-61`; live retail screenshot evidence (2026-08-08 re-gate) |
|
||||
| AP-170 | **Filed 2026-08-08, grand-gate finding G3 (out-of-range vendor Use lost silently).** Retail's `ItemHolder::UseObject @ 0x00588A80` has no client-side range check and sends Use immediately regardless of distance — this port's ORIGINAL `RequestUse` faithfully mirrored that shape. Live testing against the user's local ACE server showed it does not hold: walking to a vendor and using it from out of range plays the vendor's cosmetic greeting (a distance-only reaction, independent of Use) but never opens the shop panel — `ApproachVendor` never arrives. ACE's `Player.HandleActionUseItem` (`references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-215`) explains why: an out-of-range target routes through `CreateMoveToChain(item, (success) => TryUseItem(item, success))` (`Player_Move.cs:37-96`), which polls every 0.1s for the player to reach `WithinUseRadius` and only then calls `ActOnUse` — it does not teleport or server-move the player; it waits for the CLIENT's own walk to land, and a Use that arrives before that poll ever starts observing an in-range player is simply never followed by the vendor's `ApproachVendor` send (`Vendor.ActOnUse`'s own doc comment: "the player will have been commanded to move using `DoMoveTo` before `ActOnUse` is called... it should be assumed that the player is within range" — a precondition our immediate send violated). `SelectionInteractionController.RequestUse` now arms the out-of-range case on the SAME arrival-gated shape `SendPickup`'s close-range (turn-only) branch already used (`RuntimeInteractionTransactionState.TryArmPostArrivalUse`/`TryResolveUseApproachCompletion`, mirroring `TryArmPostArrivalPickup`/`TryResolveApproachCompletion` field-for-field) — the wire Use dispatches only once the local approach naturally completes. An already-in-range Use (a turn at most, or no approach concept applies) is unaffected and still sends immediately, matching ACE's own "already within use distance" synchronous callback. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`RequestUse`, `HandleApproachCompletion`, `HandleUseApproachCompletion`, `CancelPendingApproach`, `OnEntityHidden`, `OnEntityRemoved`); `src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs` (`RuntimePendingUse`, `TryArmPostArrivalUse`, `TryResolveUseApproachCompletion`, `TryCancelPendingUse`) | This is an ACE-server-constraint adaptation, not a retail redesign: retail's REAL server walks the player itself before the target's `ActOnUse` ever sees the request, so the client's immediate send never races anything there. ACE does not do this for a player-initiated Use — it only polls and waits — so arming on arrival is required for correctness against the only server this port can test against, not a stylistic preference. | An interaction path that still calls `TryDispatchUse` directly without going through `RequestUse`'s approach gate (none identified at this fix) would keep the original race. The armed reservation is a live busy-count reference until arrival/cancellation resolves it; `ResetCore` releases it unconditionally on any reset/dispose so a teardown that runs without a preceding `CancelPendingApproach()` (e.g. a headless/no-window host with no `SelectionInteractionController`) cannot leak it. | `ItemHolder::UseObject` `0x00588A80`; `Player.HandleActionUseItem` `Player_Use.cs:176-215`; `Player.CreateMoveToChain`/`MoveToChain` `Player_Move.cs:37-153`; `Vendor.ActOnUse` `Vendor.cs:223-266` |
|
||||
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
|
||||
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -88,13 +88,15 @@ public sealed class WorldSelectionQueryTests
|
|||
uint objectDescriptionFlags = 0u,
|
||||
ushort instance = 1,
|
||||
float scale = 1f,
|
||||
Quaternion? rotation = null)
|
||||
Quaternion? rotation = null,
|
||||
float? useRadius = null)
|
||||
{
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, instance) with
|
||||
{
|
||||
ItemType = (uint)type,
|
||||
Useability = useability,
|
||||
ObjectDescriptionFlags = objectDescriptionFlags,
|
||||
UseRadius = useRadius,
|
||||
};
|
||||
Runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = Runtime.MaterializeLiveEntity(
|
||||
|
|
@ -360,6 +362,71 @@ public sealed class WorldSelectionQueryTests
|
|||
Assert.Equal(2f, approach.TargetHeight);
|
||||
}
|
||||
|
||||
/// <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 range/approach
|
||||
/// purposes (<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 a VENDOR target
|
||||
/// specifically). ACE's server-side acceptance test
|
||||
/// (<c>WorldObject_Use.cs:47-55</c>, <c>IsWithinUseRadiusOf</c>) reads
|
||||
/// the SAME wire field, falling back to 0.6f only when the object
|
||||
/// genuinely doesn't author one.
|
||||
/// <para>
|
||||
/// A prior CLIENT-SIDE item-type heuristic (3m for any
|
||||
/// <see cref="ItemType.Creature"/>, 2m for a "large object" flag
|
||||
/// combination) ignored the wire field entirely. For a typical vendor
|
||||
/// NPC (Creature-typed, authoring a much tighter UseRadius than 3m)
|
||||
/// this made the client's own local "arrived" test satisfied well
|
||||
/// outside ACE's real acceptance zone: <see cref="AcDream.Core.Physics.Motion.MoveToManager"/>
|
||||
/// would stop walking and <see cref="SelectionInteractionController.RequestUse"/>
|
||||
/// would dispatch Use the instant this too-loose local threshold was
|
||||
/// crossed, long before the player was ever within ACE's own
|
||||
/// poll-based <c>WithinUseRadius</c> check — reproducing the exact
|
||||
/// "Use lost silently" defect the AP-170/G3 arrival-gate fix was
|
||||
/// meant to close, just from a different angle (a too-loose LOCAL
|
||||
/// arrival threshold, not a missing arrival gate).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApproachUsesTheCreatureTargetsOwnWireAuthoredUseRadius()
|
||||
{
|
||||
var h = new Harness();
|
||||
// A vendor NPC: Creature-typed, wire-authored UseRadius DIFFERENT
|
||||
// from both the old 3m heuristic AND the 0.6f no-wire-value
|
||||
// fallback, so this test actually discriminates "read the wire"
|
||||
// from either the retired heuristic or a coincidental default match.
|
||||
h.Add(Target, new Vector3(2f, 0f, 0f), ItemType.Creature, useRadius: 1.5f);
|
||||
|
||||
Assert.True(h.Query.TryGetApproach(Target, out InteractionApproach approach));
|
||||
|
||||
Assert.Equal(1.5f, approach.UseRadius);
|
||||
// 2m > 1.5m: genuinely out of range, must arm a walk — not the old
|
||||
// heuristic's false "already close enough" (2m <= 3m).
|
||||
Assert.False(approach.IsCloseRange);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Companion to <see cref="ApproachUsesTheCreatureTargetsOwnWireAuthoredUseRadius"/>:
|
||||
/// when the wire genuinely carries no UseRadius (retail's zeroed-struct
|
||||
/// default / ACE's <c>wo.UseRadius == null</c> case), the fallback is
|
||||
/// ACE's own flat 0.6f default (<c>WorldObject_Use.cs:50</c>,
|
||||
/// <c>useRadius ?? 0.6f</c>) — not a Creature-specific guess.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApproachFallsBackToAceDefaultUseRadiusWhenTheWireOmitsIt()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Add(Target, new Vector3(2f, 0f, 0f), ItemType.Creature);
|
||||
|
||||
Assert.True(h.Query.TryGetApproach(Target, out InteractionApproach approach));
|
||||
|
||||
Assert.Equal(0.6f, approach.UseRadius);
|
||||
Assert.False(approach.IsCloseRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickupAndUseabilityPreserveIndependentRetailGates()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -674,7 +674,8 @@ public class SelectedObjectControllerTests
|
|||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// G2 (grand-gate finding): C4 passed by manually hand-setting
|
||||
/// G2 (grand-gate finding), CORRECTED for R1 (2026-08-08, register
|
||||
/// AP-169). C4 passed by manually hand-setting
|
||||
/// <c>ClientObject.StackSize</c> directly, bypassing BOTH the real
|
||||
/// <see cref="VendorShopItemMaterializer"/> that populates it from a
|
||||
/// live <c>ApproachVendor</c> snapshot AND the real
|
||||
|
|
@ -685,12 +686,25 @@ public class SelectedObjectControllerTests
|
|||
/// production's <c>RuntimeInventoryState</c> constructor) ingests the
|
||||
/// shop item into the SAME <see cref="ClientObjectTable"/>, and only
|
||||
/// THEN is the item selected — closing the gap the C4 harness left open.
|
||||
/// <para>
|
||||
/// R1 correction: the G2 fix's own live re-test showed its
|
||||
/// packed-supply-count fallback (this test's ORIGINAL fixture: packed
|
||||
/// <c>StackSize=100</c>, a bounded finite quantity) does not survive a
|
||||
/// STANDARD retail listing, which has UNLIMITED stock (packed
|
||||
/// <c>StackSize=-1</c>) — the fallback produced nothing usable and the
|
||||
/// live retail screenshot that triggered the re-test showed the real
|
||||
/// operand is the item's authored <see cref="VendorShopItem.MaxStackSize"/>
|
||||
/// (1000 for a Prismatic Taper), not any supply count. This fixture now
|
||||
/// matches that exact ACE-realistic shape: unlimited packed supply, no
|
||||
/// <c>DescStackSize</c> (ACE never sets it — see the class doc above),
|
||||
/// <c>MaxStackSize=1000</c> from the wire.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void G2_VendorStackSelection_ThroughRealMaterializer_ShowsSplitSlider()
|
||||
{
|
||||
const uint vendorGuid = 0x70000011u;
|
||||
const uint arrowsGuid = 0x60009011u;
|
||||
const uint taperGuid = 0x60009011u;
|
||||
|
||||
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
||||
var objects = new ClientObjectTable();
|
||||
|
|
@ -730,41 +744,46 @@ public class SelectedObjectControllerTests
|
|||
&& vendorCandidate.ContainerId == vendor.VendorId
|
||||
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type));
|
||||
|
||||
// G2 root cause: a REAL ACE vendor listing, byte-for-byte. ACE's
|
||||
// Vendor.LoadInventoryItem (Vendor.cs:144-172) builds the browse-list
|
||||
// WorldObject via WorldObjectFactory.CreateNewWorldObject and sets
|
||||
// ONLY wo.VendorShopCreateListStackSize = stackSize ?? -1 (the "how
|
||||
// many available" packed dword — our VendorShopItem.StackSize) —
|
||||
// it NEVER calls wo.SetStackSize(...), so the per-item
|
||||
// G2 root cause, R1-corrected: a REAL ACE vendor listing,
|
||||
// byte-for-byte. ACE's Vendor.LoadInventoryItem (Vendor.cs:144-172)
|
||||
// builds the browse-list WorldObject via
|
||||
// WorldObjectFactory.CreateNewWorldObject and sets ONLY
|
||||
// wo.VendorShopCreateListStackSize = stackSize ?? -1 (the "how many
|
||||
// available" packed dword — our VendorShopItem.StackSize) — it
|
||||
// NEVER calls wo.SetStackSize(...), so the per-item
|
||||
// PublicWeenieDesc's own conditional StackSize field (our
|
||||
// DescStackSize — retail's pwd._stackSize, what
|
||||
// GameEventApproachVendor.cs:60's SerializeGameDataOnly walks) comes
|
||||
// back null on the real wire. StackSize=100 (packed "100 for sale"),
|
||||
// DescStackSize=null (ACE never sets it) is what a real vendor
|
||||
// listing looks like, NOT the DescStackSize=100 shape used above.
|
||||
// back null on the real wire. A STANDARD listing (unlimited stock,
|
||||
// packed StackSize=-1) is the common case the live re-test actually
|
||||
// hit — matching the retail screenshot's Prismatic Taper, not a
|
||||
// bounded-supply item like the original fixture's "100 arrows".
|
||||
// MaxStackSize=1000 IS reliably populated by ACE (an ordinary
|
||||
// weenie property), which is the field the toolbar ceiling now
|
||||
// resolves through — see VendorSplitPolicy.ResolveAuthoredStackSize.
|
||||
vendor.Apply(
|
||||
vendorGuid,
|
||||
new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""),
|
||||
new[]
|
||||
{
|
||||
new VendorShopItem(
|
||||
arrowsGuid, StackSize: 100, WeenieClassId: 5u, Name: "Arrow",
|
||||
ItemType: (uint)ItemType.MissileWeapon, IconId: 200u, Value: 100,
|
||||
DescStackSize: null, PluralName: "Arrows"),
|
||||
taperGuid, StackSize: -1, WeenieClassId: 5u, Name: "Prismatic Taper",
|
||||
ItemType: (uint)ItemType.SpellComponents, IconId: 200u, Value: 100,
|
||||
DescStackSize: null, MaxStackSize: 1000, PluralName: "Prismatic Tapers"),
|
||||
});
|
||||
|
||||
// The item is now materialized (ClientObjectTable.Ingest ran inside
|
||||
// VendorState.Apply's Changed dispatch) BEFORE selection, exactly
|
||||
// like a real click on VendorUiController's item list.
|
||||
Assert.NotNull(objects.Get(arrowsGuid));
|
||||
Assert.Equal(100, objects.Get(arrowsGuid)!.StackSize);
|
||||
Assert.NotNull(objects.Get(taperGuid));
|
||||
Assert.Equal(1000, objects.Get(taperGuid)!.StackSize);
|
||||
|
||||
selection.Select(arrowsGuid, SelectionChangeSource.Vendor);
|
||||
selection.Select(taperGuid, SelectionChangeSource.Vendor);
|
||||
|
||||
var slider = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(SelectedObjectController.StackSizeSliderId));
|
||||
Assert.True(slider.Visible);
|
||||
Assert.Equal(100u, splitQuantity.Maximum);
|
||||
Assert.Equal(1000u, splitQuantity.Maximum);
|
||||
|
||||
controller.Dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,6 +188,11 @@ public sealed class VendorUiControllerTests
|
|||
public readonly UiButton BuyAllButton;
|
||||
public readonly UiButton BuyClearItemButton;
|
||||
public readonly UiButton BuyClearListButton;
|
||||
// R3: the Buying/Selling tabs' own staged summary text.
|
||||
public readonly UiText BuyListText = new();
|
||||
public readonly UiText BuyPurseText = new();
|
||||
public readonly UiText SellListText = new();
|
||||
public readonly UiText SellPurseText = new();
|
||||
// Slice 6c: "Selling" tab staging widgets.
|
||||
public readonly UiItemList SellingList = new();
|
||||
public readonly UiButton SellItemButton;
|
||||
|
|
@ -280,11 +285,15 @@ public sealed class VendorUiControllerTests
|
|||
BuyingPage.AddChild(BuyAllButton);
|
||||
BuyingPage.AddChild(BuyClearItemButton);
|
||||
BuyingPage.AddChild(BuyClearListButton);
|
||||
BuyingPage.AddChild(BuyListText);
|
||||
BuyingPage.AddChild(BuyPurseText);
|
||||
SellingPage.AddChild(SellingList);
|
||||
SellingPage.AddChild(SellItemButton);
|
||||
SellingPage.AddChild(SellAllButton);
|
||||
SellingPage.AddChild(SellClearItemButton);
|
||||
SellingPage.AddChild(SellClearListButton);
|
||||
SellingPage.AddChild(SellListText);
|
||||
SellingPage.AddChild(SellPurseText);
|
||||
|
||||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||
{
|
||||
|
|
@ -312,6 +321,10 @@ public sealed class VendorUiControllerTests
|
|||
[VendorUiController.SellAllButtonId] = SellAllButton,
|
||||
[VendorUiController.SellClearItemButtonId] = SellClearItemButton,
|
||||
[VendorUiController.SellClearListButtonId] = SellClearListButton,
|
||||
[VendorUiController.BuyingListTextId] = BuyListText,
|
||||
[VendorUiController.BuyingPurseTextId] = BuyPurseText,
|
||||
[VendorUiController.SellingListTextId] = SellListText,
|
||||
[VendorUiController.SellingPurseTextId] = SellPurseText,
|
||||
});
|
||||
|
||||
Window = RetailWindowFrame.Mount(
|
||||
|
|
@ -1199,6 +1212,36 @@ public sealed class VendorUiControllerTests
|
|||
Assert.Equal(25, h.Buys.Single().Amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R1 gate-finding fix, task item 5 (2026-08-08): an UNLIMITED-STOCK
|
||||
/// item with no wire <c>DescStackSize</c> (the real ACE shape for a
|
||||
/// browse-list row — see <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/>'s
|
||||
/// doc comment) but a real <see cref="VendorShopItem.MaxStackSize"/>
|
||||
/// (1000, matching the live retail screenshot's Prismatic Taper) must
|
||||
/// still let the player buy an arbitrary quantity up to that ceiling —
|
||||
/// the slider isn't merely decorative. This harness doesn't mount
|
||||
/// <c>SelectedObjectController</c> (production's toolbar owns split
|
||||
/// seeding), so it stands in for "the toolbar showed a ceiling of 1000
|
||||
/// (MaxStackSize) and the player dragged to 300" by seeding
|
||||
/// <see cref="Harness.SplitQuantity"/> directly with that SAME ceiling.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuyButton_Press_UnlimitedStockItemUsingMaxStackSizeCeiling_SendsTheSliderQuantity()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(
|
||||
StackedItemGuid, -1, 3u, "Prismatic Taper", (uint)ItemType.SpellComponents, 300u, 1000,
|
||||
DescStackSize: null, MaxStackSize: 1000),
|
||||
});
|
||||
h.SplitQuantity.Reset(1000u, initialValue: 300u);
|
||||
|
||||
h.BuyButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(300, h.Buys.Single().Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuyButton_Press_NonStackedItem_IgnoresAStaleSliderFromAPreviouslySelectedStackableItem()
|
||||
{
|
||||
|
|
@ -1443,7 +1486,7 @@ public sealed class VendorUiControllerTests
|
|||
{
|
||||
new VendorShopItem(
|
||||
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
|
||||
DescStackSize: 100),
|
||||
DescStackSize: 100, MaxStackSize: 100),
|
||||
});
|
||||
h.SplitQuantity.Reset(5000u, initialValue: 4990u);
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
|
@ -2477,4 +2520,109 @@ public sealed class VendorUiControllerTests
|
|||
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// R3 — the Buying/Selling tabs' own staged-count/total-value/purse text
|
||||
// (grand-gate live evidence: retail's Buying tab shows "Buying 2 items
|
||||
// worth 422p" / "You have 23p"; the strings are recovered byte-verbatim
|
||||
// — see VendorUiController.BuildTransactionListText/BuildPurseText).
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void BuyingTabSummaryText_TracksStagedCountValueAndPlayerPurse()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
// Before staging: zero staged count/value, still grammatically
|
||||
// plural ("0 items"), purse text is live regardless of staging.
|
||||
Assert.Equal("Buying 0 items worth 0p", GetText(h.BuyListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
|
||||
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
||||
// ComputeBuyTransactionValue: sellRate 1.5 * value 500 * quantity 1
|
||||
// = 750, ceil(750 - 0.1) = 750.
|
||||
Assert.Equal("Buying 1 item worth 750p", GetText(h.BuyListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
|
||||
|
||||
h.BuyClearListButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal("Buying 0 items worth 0p", GetText(h.BuyListText));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3: a money change with no staging change at all must still repaint
|
||||
/// the purse line — retail's own purse text always reads the LIVE
|
||||
/// holding, with no "did staging change" gate (see
|
||||
/// <c>VendorUiController.OnObjectMoneyChanged</c>'s doc comment).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuyingTabPurseText_UpdatesOnAPlayerMoneyChangeAloneWithNoStagingChange()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
|
||||
|
||||
var bundle = new PropertyBundle();
|
||||
bundle.Ints[(uint)PropertyInt.CoinValue] = 42;
|
||||
h.Objects.UpsertProperties(Harness.PlayerGuid, bundle);
|
||||
|
||||
Assert.Equal("You have 42p", GetText(h.BuyPurseText));
|
||||
// Selling's purse text shares the SAME live holding read.
|
||||
Assert.Equal("You have 42p", GetText(h.SellPurseText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SellingTabSummaryText_TracksStagedCountValueAndPlayerPurse()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
|
||||
Assert.Equal("Selling 0 items worth 0p", GetText(h.SellListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.SellPurseText));
|
||||
|
||||
h.Controller.HandleDropRelease(
|
||||
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
|
||||
// ComputeSellTransactionValue: SellProfile's BuyPrice 1.0 * value
|
||||
// 100 * quantity 1 = 100, floor(100 + 0.1) = 100.
|
||||
Assert.Equal("Selling 1 item worth 100p", GetText(h.SellListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.SellPurseText));
|
||||
|
||||
h.SellClearListButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal("Selling 0 items worth 0p", GetText(h.SellListText));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two staged units of the SAME stackable row: the count is the STAGED
|
||||
/// QUANTITY total (2), not the row count (1) — matching the live
|
||||
/// evidence screenshot's "Buying 2 items worth 422p" exactly (a single
|
||||
/// stacked row, quantity 2).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuyingTabSummaryText_CountsStagedQuantityNotRowCountForAStackedItem()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(
|
||||
StackedItemGuid, -1, 3u, "Prismatic Taper", (uint)ItemType.SpellComponents, 300u, 1000,
|
||||
DescStackSize: 100),
|
||||
});
|
||||
h.SplitQuantity.Reset(100u, initialValue: 2u);
|
||||
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
||||
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||
// ComputeBuyTransactionValue: perUnit = value 1000 / DescStackSize
|
||||
// 100 = 10; sellRate 1.5 * perUnit 10 * quantity 2 = 30,
|
||||
// ceil(30 - 0.1) = 30.
|
||||
Assert.Equal("Buying 2 items worth 30p", GetText(h.BuyListText));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,21 +117,28 @@ public sealed class VendorShopItemMaterializerTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// G2 (grand-gate finding): a REAL ACE vendor listing carries
|
||||
/// R1 gate-finding fix (2026-08-08, register AP-169 correction,
|
||||
/// replacing the retired <c>Apply_NoDescStackSize_FallsBackToPackedSupplyCount</c>
|
||||
/// G2 test below). A REAL ACE vendor listing for an UNLIMITED-STOCK
|
||||
/// stackable item (e.g. a Prismatic Taper) carries
|
||||
/// <c>DescStackSize=null</c> (ACE's <c>Vendor.LoadInventoryItem</c> never
|
||||
/// calls <c>wo.SetStackSize</c> on the browse-list WorldObject) and only
|
||||
/// the packed <see cref="VendorShopItem.StackSize"/> supply-count field
|
||||
/// (<c>VendorShopCreateListStackSize</c>) names a real quantity. Before
|
||||
/// the fix, <c>ToWeenieData</c> read only <c>DescStackSize</c>, so
|
||||
/// <see cref="ClientObject.StackSize"/> came back 1 for every vendor
|
||||
/// item — the toolbar split slider (which gates on
|
||||
/// <c>stackSize > 1</c>) never appeared for ANY vendor stack. This
|
||||
/// pins the fallback: no <c>DescStackSize</c>, packed
|
||||
/// <c>StackSize=100</c> -> <c>ClientObject.StackSize</c> resolves to
|
||||
/// 100, not 1.
|
||||
/// calls <c>wo.SetStackSize</c> on the browse-list WorldObject) AND the
|
||||
/// packed <see cref="VendorShopItem.StackSize"/> supply-count field at
|
||||
/// its UNLIMITED sentinel (<c>-1</c>) — so neither wire "how many"
|
||||
/// signal names a usable ceiling. The G2 fix's packed-supply-count
|
||||
/// fallback (the ORIGINAL version of this test) produced nothing usable
|
||||
/// for exactly this case — a live re-test confirmed the toolbar split
|
||||
/// bar stayed hidden, matching the live retail screenshot report (see
|
||||
/// AP-169's corrected story). This pins the CORRECT fallback:
|
||||
/// <see cref="VendorShopItem.MaxStackSize"/> — the item TYPE's authored
|
||||
/// stack ceiling, which ACE DOES reliably populate (an ordinary weenie
|
||||
/// property, not an instance-specific "how many for sale" count) — no
|
||||
/// <c>DescStackSize</c>, packed <c>StackSize=-1</c> (unlimited),
|
||||
/// <c>MaxStackSize=1000</c> -> <c>ClientObject.StackSize</c> resolves
|
||||
/// to 1000, not 1 and not the (nonsensical, unbounded) packed field.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Apply_NoDescStackSize_FallsBackToPackedSupplyCount()
|
||||
public void Apply_UnlimitedStockNoDescStackSize_FallsBackToMaxStackSize()
|
||||
{
|
||||
var vendor = new VendorState();
|
||||
var objects = new ClientObjectTable();
|
||||
|
|
@ -140,21 +147,27 @@ public sealed class VendorShopItemMaterializerTests
|
|||
vendor.Apply(VendorGuid, default, new[]
|
||||
{
|
||||
new VendorShopItem(
|
||||
ItemA, StackSize: 100, WeenieClassId: 1u, Name: "Arrow",
|
||||
ItemType: (uint)ItemType.MissileWeapon, IconId: 0x1234u, Value: 100,
|
||||
DescStackSize: null),
|
||||
ItemA, StackSize: -1, WeenieClassId: 1u, Name: "Prismatic Taper",
|
||||
ItemType: (uint)ItemType.SpellComponents, IconId: 0x1234u, Value: 100,
|
||||
DescStackSize: null, MaxStackSize: 1000),
|
||||
});
|
||||
|
||||
Assert.Equal(100, objects.Get(ItemA)!.StackSize);
|
||||
ClientObject item = objects.Get(ItemA)!;
|
||||
Assert.Equal(1000, item.StackSize);
|
||||
Assert.Equal(1000, item.StackSizeMax);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G2 companion: the packed field's -1 "unlimited supply" sentinel has
|
||||
/// no bounded per-row purchase cap in the wire shape today, so it must
|
||||
/// fall through to the safe non-splittable default (1) rather than
|
||||
/// literally propagating -1 (which would read as a huge unsigned
|
||||
/// "stack size" to <see cref="SelectedObjectController"/>'s
|
||||
/// <c>stackSize > 1</c> gate).
|
||||
/// Sabotage-adjacent control: the SAME unlimited-stock listing but with
|
||||
/// <see cref="VendorShopItem.MaxStackSize"/> ALSO absent (neither wire
|
||||
/// "how many" signal usable at all) still degrades to the safe
|
||||
/// non-splittable default (1) rather than literally propagating the
|
||||
/// packed field's <c>-1</c> sentinel (which would read as a huge
|
||||
/// unsigned "stack size" to <c>SelectedObjectController</c>'s
|
||||
/// <c>stackSize > 1</c> gate). Proves
|
||||
/// <see cref="Apply_UnlimitedStockNoDescStackSize_FallsBackToMaxStackSize"/>'s
|
||||
/// 1000 result comes from the MaxStackSize field specifically, not from
|
||||
/// some other code path that would resolve to 1000 regardless.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Apply_UnlimitedSupplySentinel_FallsBackToNonSplittableDefault()
|
||||
|
|
@ -163,7 +176,7 @@ public sealed class VendorShopItemMaterializerTests
|
|||
var objects = new ClientObjectTable();
|
||||
using var materializer = new VendorShopItemMaterializer(vendor, objects);
|
||||
|
||||
vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Bread") }); // StackSize: -1, DescStackSize: null
|
||||
vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Bread") }); // StackSize: -1, DescStackSize: null, MaxStackSize: null
|
||||
|
||||
Assert.Equal(1, objects.Get(ItemA)!.StackSize);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue