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` |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue