fix(vendor): evidence-based pass — max-first stack ceiling; the local player resolves never-animated MoveTo targets
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

Both chains pinned by the live [vendor-diag] run (vendor-diag.log)
after three code-reading rounds each failed:

The split bar: ACE serializes descStackSize=1 for EVERY browse row
(live wire, log 343-348) — the R1-era "ACE never populates desc"
claim is retracted with the line quoted. Retail's vendor sites read
pwd._maxStackSize directly (four sites, incl. UpdateItemsList
@0x004c1ea0 stamping min(remaining, _maxStackSize));
ResolveAuthoredStackSize flips to max-first for its vendor-only
consumers. Taper ceiling 1000, scarab 100, seed 1 for exempt.
Pricing still reads the desc (per-1 values on ACE).

Walk-to-use: the local player's getObjectA seam was bound to
TryGetPhysicsHost, which resolves only INSTALLED physics hosts — a
never-animated vendor has none, so TargetManager.SetTarget got null,
the MoveToObject armed with zero nodes, and UseTime never dispatched.
The log's natural=False completions were the user's own movement keys
(retail-correct input-edge cancels); attempt 4 worked because the
greeting animation had installed a host. RuntimePhysicsState gains
the retail CObjectMaint::GetObjectA seam (bound canonical resolver
with installed-host fallback); the graphical host binds the SAME
lazy-minimal-host resolver every remote already uses — whose own doc
comment names this exact never-animated hazard. The reservation
release was already correct (2b premise refuted with evidence); the
production-wiring invariants are now pinned by four new tests
including the pre-fix pathology as a permanent sabotage control.

AP-169 rewritten a second time, honestly. The [vendor-diag] probe
family (ACDREAM_DUMP_VENDOR) lands env-gated for future live triage.

Clean-room complete solution: 11,536 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 17:17:04 +02:00
parent d003449bb4
commit 02b735ba4a
21 changed files with 1139 additions and 102 deletions

View file

@ -296,7 +296,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| 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-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-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-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).** **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-169 | **Filed 2026-08-08, grand-gate finding G2 (vendor toolbar split-slider absent live). CORRECTED 2026-08-08 (re-gate finding R1). CORRECTED AGAIN 2026-08-08 (live vendor-diag evidence) — both earlier stories mis-identified the operand; this row now records the third and evidence-pinned shape.** The G2 fix fell back to the packed ItemProfile supply-count dword (unusable: a standard listing has UNLIMITED stock, `-1`). The R1 fix preferred the wire `PublicWeenieDesc::_stackSize` (`VendorShopItem.DescStackSize`) on the claim that ACE never populates it for a browse row — the live vendor-diag run REFUTED that claim: ACE serializes `descStackSize=1` for EVERY browse row (`[vendor-diag] ApproachVendor wire-item[...] descStackSize=1 stackSizeMax=100`), so desc-first resolved every vendor stack to 1 and the split bar never appeared (`ApplySelection ... failingPredicate=stackSize<=1u stackSize=1`). The named decomp settles what retail actually reads at its VENDOR-owned quantity sites: `pwd._maxStackSize` DIRECTLY — `VendorItemsUI::UpdateItemsList` (`0x004c1ea0`, `pc:201085-201133`) displays each browse row's quantity as `min(remaining, _maxStackSize)` (plain `_maxStackSize` for an unlimited listing, via `VendorSubUI::SetObjectStackSize`); `gmVendorUI::InqListSlotCount` (`0x004c0c10`, `pc:200052`) classifies rows on `pwd._maxStackSize <= 1`; the Buy cases (`gmVendorUI::HandleButtonClicks` `0x100000c9` @`pc:203996` / `0x100000cb` @`pc:204086`) gate the stackable-buy path on `pwd._maxStackSize > 1`. `VendorSplitPolicy.ResolveAuthoredStackSize(descStackSize, maxStackSize)` is therefore **max-first** (desc fallback, then 1), consumed only by the vendor-owned paths (`VendorShopItemMaterializer.ToWeenieData`, `VendorUiController.ResolveBuyQuantity`); player-inventory stacks never route through it. Matches the live retail screenshot ("1000 Prismatic Tapers", ceiling 1000 = the taper's authored max stack size). The toolbar-side `gmToolbarUI::HandleSelectionChanged` does read `pwd._stackSize` (`pc:198688`/`198744`/`198774`/`198791`) — on a REAL retail server the two agree for a browse row (the vendor UI stamps the displayed stack from `_maxStackSize`); against ACE (desc always 1) the `_maxStackSize` operand is the one that carries retail's meaning. | `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 on the toolbar leg only: retail's vendor UI reads `_maxStackSize` literally (ported as-is); the toolbar seed's `_stackSize` read is satisfied through the materialized `ClientObject.StackSize`, which this resolution stamps from `_maxStackSize` exactly as retail's own `UpdateItemsList` stamps the displayed stack — not an arbitrary substitute. | A vendor stocking a bounded but non-unit quantity shows a ceiling of `min` semantics only on a real retail server; against ACE the client-side slider ceiling is the authored max stack size, not the bounded stock count — the server remains authoritative and rejects an over-large Buy regardless (a latency/UX gap, not a correctness one — see AP-162). If ACE ever starts serializing a REAL per-listing `_stackSize` (not the constant 1), the max-first preference would hide it; the desc fallback fires only when no authored ceiling exists. | `VendorItemsUI::UpdateItemsList` `0x004c1ea0` `pc:201029-201133`; `gmVendorUI::InqListSlotCount` `0x004c0c10` `pc:200052`; `gmVendorUI::HandleButtonClicks` `pc:203996`/`204086`; `gmToolbarUI::HandleSelectionChanged` `pc:198688-198791`; live vendor-diag wire capture + live retail screenshot (2026-08-08) |
| 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-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-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` | | 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` |

View file

@ -509,6 +509,20 @@ internal sealed class SessionPlayerCompositionPhase
d.PlayerIdentity, d.PlayerIdentity,
dormantLiveEntities, dormantLiveEntities,
d.Options.DumpLiveSpawns ? d.Log : null); d.Options.DumpLiveSpawns ? d.Log : null);
// 2026-08-08 vendor-approach root cause: the local player's
// publication-chain host resolves moveto/sticky targets through
// RuntimePhysicsState.ResolveObjectTableHost. Bind the graphical
// host's canonical CObjectMaint::GetObjectA stand-in — the SAME
// lazy-minimal-host resolver every remote host's GetObjectA uses
// (LiveEntityMotionRuntimeController.ResolvePhysicsHost, reached
// through the deferred motion-bindings seam so this closure stays
// valid across session routes). Without it the local player's
// MoveToObject against a never-animated NPC/static object never
// received TargetManager.AddVoyeur's immediate initial snapshot and
// sat armed-but-inert forever (no nodes, no movement, no natural
// completion).
live.LiveEntities.Physics.BindObjectTableHostResolver(
guid => d.MotionBindings.ResolvePhysicsHost(guid));
// C3c: the graphical first-entry drive controller — walks every // C3c: the graphical first-entry drive controller — walks every
// initial-Create residence through its Runtime conductor with the // initial-Create residence through its Runtime conductor with the
// production prepared-collision source, the live movement-skill // production prepared-collision source, the live movement-skill

View file

@ -1,4 +1,5 @@
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.Core.Items;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion; using AcDream.Core.Physics.Motion;
@ -60,6 +61,14 @@ internal sealed class PlayerInteractionMovementSink(
Height = approach.TargetHeight, Height = approach.TargetHeight,
}; };
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] BeginApproach guid=0x{approach.Target.ServerGuid:X8} "
+ $"movementType={movement.Type} distanceToObject={parameters.DistanceToObject} "
+ $"canCharge={parameters.CanCharge} target=0x{movement.ObjectId:X8}");
}
// PerformMovement cancels at its head. Do it explicitly before the // PerformMovement cancels at its head. Do it explicitly before the
// intent is armed so cancellation of the preceding move cannot clear // intent is armed so cancellation of the preceding move cannot clear
// the new request; the internal second call is then a retail no-op. // the new request; the internal second call is then a retail no-op.

View file

@ -173,10 +173,17 @@ internal sealed class SelectionInteractionController
// Use. Equipped-child picking makes that click reachable, so the gate // Use. Equipped-child picking makes that click reachable, so the gate
// ships with it. // ships with it.
if (useImmediately && !_query.IsWieldedByPlayer(guid)) if (useImmediately && !_query.IsWieldedByPlayer(guid))
{
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] route=dblclick-world PickAndStoreSelection guid=0x{guid:X8} enqueue=Activate");
}
EnqueueIdentityBound( EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Activate, RuntimeQueuedInteractionKind.Activate,
guid, guid,
requireLiveEntity: true); requireLiveEntity: true);
}
} }
/// <summary> /// <summary>
@ -205,6 +212,11 @@ internal sealed class SelectionInteractionController
_toast?.Invoke("Nothing selected"); _toast?.Invoke("Nothing selected");
return; return;
} }
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] route=keyboard-use UseCurrentSelection guid=0x{selected:X8} enqueue=Use");
}
EnqueueIdentityBound( EnqueueIdentityBound(
RuntimeQueuedInteractionKind.Use, RuntimeQueuedInteractionKind.Use,
selected, selected,
@ -260,10 +272,22 @@ internal sealed class SelectionInteractionController
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid); bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
bool useable = ownedByPlayer || _query.IsUseable(serverGuid); bool useable = ownedByPlayer || _query.IsUseable(serverGuid);
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] RequestUse entry guid=0x{serverGuid:X8} ownedByPlayer={ownedByPlayer} useable={useable}");
}
if (useable if (useable
&& _query.TryGetApproach(serverGuid, out InteractionApproach approach) && _query.TryGetApproach(serverGuid, out InteractionApproach approach)
&& !approach.IsCloseRange) && !approach.IsCloseRange)
{ {
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=approach-armed useRadius={approach.UseRadius} isCloseRange={approach.IsCloseRange}");
}
// Genuinely out of range (a real walk, not just a turn) — // Genuinely out of range (a real walk, not just a turn) —
// mirror SendPickup's arrival-gated shape: arm the transaction // mirror SendPickup's arrival-gated shape: arm the transaction
// on the approach token BEFORE the movement starts (so a // on the approach token BEFORE the movement starts (so a
@ -283,7 +307,17 @@ internal sealed class SelectionInteractionController
token.ControllerLifetime, token.ControllerLifetime,
token.ApproachGeneration), token.ApproachGeneration),
out _); out _);
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] TryArmPostArrivalUse guid=0x{serverGuid:X8} armed={armed} approachToken=({token.ControllerLifetime},{token.ApproachGeneration})");
}
}); });
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] BeginApproach-result guid=0x{serverGuid:X8} started={started} armed={armed} stopDistance={approach.UseRadius} target=0x{approach.Target.ServerGuid:X8}");
}
if (!started || !armed) if (!started || !armed)
{ {
// Release whatever got captured (or the caller's own // Release whatever got captured (or the caller's own
@ -306,6 +340,11 @@ internal sealed class SelectionInteractionController
// — keep retail's immediate send; ACE's own "already within use // — keep retail's immediate send; ACE's own "already within use
// distance" branch (Player_Move.cs:65-87) calls back synchronously, // distance" branch (Player_Move.cs:65-87) calls back synchronously,
// so there is no arrival gap to race here. // so there is no arrival gap to race here.
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=immediate-dispatch ownedByPlayer={ownedByPlayer} useable={useable}");
}
RuntimeInteractionDispatchResult result = RuntimeInteractionDispatchResult result =
_transactions.TryDispatchUse( _transactions.TryDispatchUse(
serverGuid, serverGuid,
@ -314,6 +353,11 @@ internal sealed class SelectionInteractionController
reservation, reservation,
_transport, _transport,
out uint sequence); out uint sequence);
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] RequestUse guid=0x{serverGuid:X8} TryDispatchUse verdict={result} seq={sequence}");
}
if (result == RuntimeInteractionDispatchResult.NotInWorld) if (result == RuntimeInteractionDispatchResult.NotInWorld)
_toast?.Invoke("Not in world"); _toast?.Invoke("Not in world");
if (result == RuntimeInteractionDispatchResult.Dispatched) if (result == RuntimeInteractionDispatchResult.Dispatched)
@ -545,6 +589,22 @@ internal sealed class SelectionInteractionController
RuntimePendingUse pending, RuntimePendingUse pending,
bool accepted) bool accepted)
{ {
if (VendorDiagnostics.DumpVendorEnabled)
{
// Diagnostic-only re-query — TryGetApproach is a pure read with
// no side effects, so an extra call here (gated off in
// production) cannot change RequestUse's own dispatch outcome.
string distanceText = "n/a";
if (_query.TryGetApproach(pending.ServerGuid, out InteractionApproach diagApproach))
{
float dx = diagApproach.Target.Entity.Position.X - diagApproach.Player.Position.X;
float dy = diagApproach.Target.Entity.Position.Y - diagApproach.Player.Position.Y;
distanceText = MathF.Sqrt(dx * dx + dy * dy).ToString("F2");
}
Console.WriteLine(
$"[vendor-diag] HandleUseApproachCompletion guid=0x{pending.ServerGuid:X8} accepted={accepted} playerToTargetDist={distanceText}");
}
if (!accepted) if (!accepted)
{ {
pending.Reservation?.CancelBeforeDispatch(); pending.Reservation?.CancelBeforeDispatch();

View file

@ -567,8 +567,16 @@ internal sealed class WorldSelectionQuery
/// within ACE's real UseRadius. /// within ACE's real UseRadius.
/// </remarks> /// </remarks>
private float GetUseRadius(uint serverGuid) private float GetUseRadius(uint serverGuid)
=> _liveEntities.TryGetSnapshot(serverGuid, out var spawn) {
&& spawn.UseRadius is > 0f bool haveSpawn = _liveEntities.TryGetSnapshot(serverGuid, out var spawn);
? spawn.UseRadius.Value bool fromWire = haveSpawn && spawn.UseRadius is > 0f;
: DefaultUseRadius; float radius = fromWire ? spawn.UseRadius!.Value : DefaultUseRadius;
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] GetUseRadius guid=0x{serverGuid:X8} radius={radius} "
+ $"source={(fromWire ? "wire" : "fallback-0.6")}");
}
return radius;
}
} }

View file

@ -509,6 +509,11 @@ public sealed class ItemInteractionController : IDisposable
switch (mode) switch (mode)
{ {
case InteractionModeKind.Use: case InteractionModeKind.Use:
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] route=target-mode-click OfferPrimaryClick(Use) guid=0x{targetGuid:X8}");
}
ClearTargetMode(); ClearTargetMode();
accepted = ActivateItem(targetGuid); accepted = ActivateItem(targetGuid);
break; break;
@ -541,6 +546,11 @@ public sealed class ItemInteractionController : IDisposable
/// </summary> /// </summary>
public bool UseSelectedOrEnterMode(uint selectedObjectId) public bool UseSelectedOrEnterMode(uint selectedObjectId)
{ {
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] route=toolbar-or-queued-use UseSelectedOrEnterMode guid=0x{selectedObjectId:X8}");
}
if (selectedObjectId != 0) if (selectedObjectId != 0)
return ActivateItem(selectedObjectId); return ActivateItem(selectedObjectId);
return _interactionState.EnterUse(); return _interactionState.EnterUse();
@ -640,6 +650,11 @@ public sealed class ItemInteractionController : IDisposable
public bool ActivateItem(uint itemGuid) public bool ActivateItem(uint itemGuid)
{ {
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] entry:ActivateItem guid=0x{itemGuid:X8} isTargetModeActive={IsTargetModeActive}");
}
if (itemGuid == 0) return false; if (itemGuid == 0) return false;
if (IsTargetModeActive) if (IsTargetModeActive)
@ -966,6 +981,11 @@ public sealed class ItemInteractionController : IDisposable
/// </summary> /// </summary>
public bool ExecuteConfirmedUse(uint objectId) public bool ExecuteConfirmedUse(uint objectId)
{ {
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] route=confirmed-use ExecuteConfirmedUse guid=0x{objectId:X8}");
}
if (objectId == 0u || (_requestUse is null && _sendUse is null)) if (objectId == 0u || (_requestUse is null && _sendUse is null))
return false; return false;
if (!EnsureInventoryRequestReady()) if (!EnsureInventoryRequestReady())

View file

@ -346,6 +346,13 @@ public sealed class SelectedObjectController : IRetainedPanelController
? $"{stackSize} {objectName}" ? $"{stackSize} {objectName}"
: objectName; : objectName;
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] ApplySelection guid=0x{g:X8} stackSizeOperand={stackSize} "
+ $"objectName={objectName ?? "null"} builtLabel={_currentName ?? "null"}");
}
// ── 3. Selection overlay: brief flash (retail container ObjectSelected // ── 3. Selection overlay: brief flash (retail container ObjectSelected
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ────────────── // = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
SetOverlayState(stackSize > 1u SetOverlayState(stackSize > 1u
@ -367,10 +374,23 @@ public sealed class SelectedObjectController : IRetainedPanelController
// starting VALUE differs, not the ceiling. // starting VALUE differs, not the ceiling.
if (stackSize > 1u) if (stackSize > 1u)
{ {
uint seed = _isVendorSplitExempt(g) ? 1u : stackSize; bool vendorSplitExempt = _isVendorSplitExempt(g);
uint seed = vendorSplitExempt ? 1u : stackSize;
_splitQuantity.Reset(stackSize, initialValue: seed); _splitQuantity.Reset(stackSize, initialValue: seed);
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true; if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true;
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true; if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true;
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=true "
+ $"isVendorSplitExempt={vendorSplitExempt} maxSplitSize={stackSize} seed={seed}");
}
}
else if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=false "
+ $"failingPredicate=stackSize<=1u stackSize={stackSize}");
} }
// ── 4. Health: query, and show the meter only if real health is already known. // ── 4. Health: query, and show the meter only if real health is already known.

View file

@ -1401,15 +1401,18 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
/// this one — otherwise the CURRENT slider value via /// this one — otherwise the CURRENT slider value via
/// <c>ItemHolder::GetObjectSplitSize</c> (<c>0x00586F00</c>). /// <c>ItemHolder::GetObjectSplitSize</c> (<c>0x00586F00</c>).
/// <para> /// <para>
/// R1 gate-finding fix (2026-08-08, register AP-169 correction): the /// R1 gate-finding fix, CORRECTED at the 2026-08-08 live-evidence
/// ceiling this gates on is <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/> /// re-fix (register AP-169, second correction): the ceiling this gates
/// — <see cref="VendorShopItem.DescStackSize"/> when the wire carries /// on is <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/> —
/// it, else <see cref="VendorShopItem.MaxStackSize"/> — the SAME /// <see cref="VendorShopItem.MaxStackSize"/> FIRST (retail's own
/// resolution <see cref="VendorShopItemMaterializer"/> uses to seed /// vendor-owned quantity operand: the Buy cases at
/// <see cref="ClientObject.StackSize"/> (and therefore the toolbar /// <c>pc:203996</c>/<c>204086</c> gate on <c>pwd._maxStackSize &gt; 1</c>
/// slider's own ceiling, <c>SelectedObjectController</c>). Using a /// literally), falling back to <see cref="VendorShopItem.DescStackSize"/>
/// narrower source here than the visible slider would let the slider /// — the SAME resolution <see cref="VendorShopItemMaterializer"/> uses
/// show a ceiling of 1000 while every Buy still sent quantity 1. /// 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> /// </para>
/// </summary> /// </summary>
private uint ResolveBuyQuantity(VendorShopItem item) private uint ResolveBuyQuantity(VendorShopItem item)

View file

@ -394,6 +394,13 @@ public static class GameEventWiring
registrar.Register(GameEventType.ApproachVendor, e => registrar.Register(GameEventType.ApproachVendor, e =>
{ {
var p = VendorApproach.TryParse(e.Payload.Span); var p = VendorApproach.TryParse(e.Payload.Span);
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] ApproachVendor(0x0062) inbound parsed={p is not null} "
+ $"vendorGuid={(p is null ? "n/a" : $"0x{p.Value.VendorGuid:X8}")} "
+ $"itemCount={(p is null ? "n/a" : p.Value.Items.Count.ToString())}");
}
if (p is null) return; if (p is null) return;
var profile = new VendorShopProfile( var profile = new VendorShopProfile(
@ -411,6 +418,14 @@ public static class GameEventWiring
for (int i = 0; i < shopItems.Length; i++) for (int i = 0; i < shopItems.Length; i++)
{ {
VendorApproach.ItemProfile item = p.Value.Items[i]; VendorApproach.ItemProfile item = p.Value.Items[i];
if (VendorDiagnostics.DumpVendorEnabled && i < 5)
{
Console.WriteLine(
$"[vendor-diag] ApproachVendor wire-item[{i}] guid=0x{item.ItemGuid:X8} "
+ $"name={item.Desc.Name ?? "null"} "
+ $"descStackSize={(item.Desc.StackSize is { } ds ? ds.ToString() : "null")} "
+ $"stackSizeMax={(item.Desc.StackSizeMax is { } sm ? sm.ToString() : "null")}");
}
shopItems[i] = new VendorShopItem( shopItems[i] = new VendorShopItem(
item.ItemGuid, item.ItemGuid,
item.StackSize, item.StackSize,
@ -502,6 +517,12 @@ public static class GameEventWiring
registrar.Register(GameEventType.UseDone, e => registrar.Register(GameEventType.UseDone, e =>
{ {
uint? err = GameEvents.ParseUseDone(e.Payload.Span); uint? err = GameEvents.ParseUseDone(e.Payload.Span);
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] UseDone(0x01C7) inbound parsed={err is not null} "
+ $"err={(err is null ? "n/a" : $"0x{err.Value:X4}")}");
}
if (err is null) return; if (err is null) return;
Console.WriteLine($"[use-done] err=0x{err.Value:X4}"); Console.WriteLine($"[use-done] err=0x{err.Value:X4}");
onUseDone?.Invoke(err.Value); onUseDone?.Invoke(err.Value);

View file

@ -0,0 +1,27 @@
using System;
namespace AcDream.Core.Items;
/// <summary>
/// TEMPORARY diagnostic-probe owner for two live-only regressions that have
/// each survived two green-tested fixes: Chain A (a far-click walk-to-use
/// approaches a vendor, the vendor plays its cosmetic greeting, but the shop
/// window never opens) and Chain B (selecting a splittable vendor stack
/// shows the bare item name with no quantity slider). Every probe line is
/// prefixed <c>[vendor-diag]</c> and gated on <see cref="DumpVendorEnabled"/>
/// so the family is a true no-op when the flag is unset.
/// </summary>
/// <remarks>
/// Read once from <c>ACDREAM_DUMP_VENDOR=1</c> at process start, per Code
/// Structure Rule 5 (one static diagnostic-owner class per subsystem, no
/// per-call-site <see cref="Environment.GetEnvironmentVariable"/> reads).
/// Lives in <c>AcDream.Core</c> — the one project every call site (App,
/// Core.Net, Runtime) already references — so a single flag instance is
/// shared across the whole probe family regardless of which layer observes
/// it first.
/// </remarks>
public static class VendorDiagnostics
{
public static bool DumpVendorEnabled { get; } =
Environment.GetEnvironmentVariable("ACDREAM_DUMP_VENDOR") == "1";
}

View file

@ -44,35 +44,57 @@ public static class VendorSplitPolicy
: authoredStackSize is { } size && size > 0 ? size : 1; : authoredStackSize is { } size && size > 0 ? size : 1;
/// <summary> /// <summary>
/// Grand-gate finding R1 (2026-08-08, register AP-169 correction): the /// Grand-gate finding R1, CORRECTED at the 2026-08-08 live-evidence
/// retail-faithful "how big is one stack of this item" answer for a /// re-fix (register AP-169, second correction): the retail-faithful
/// VENDOR-owned selection, used everywhere retail reads /// "how big is one stack of this item" quantity operand for a
/// <c>PublicWeenieDesc::_stackSize</c> for splitting/seeding purposes /// VENDOR-owned selection/listing.
/// (<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> /// <para>
/// Prefers <paramref name="descStackSize"/> (the wire's own /// <b>The operand is <paramref name="maxStackSize"/> FIRST.</b> Retail's
/// <c>PublicWeenieDesc::_stackSize</c> — retail-faithful FIRST, honored /// own vendor-owned quantity sites read
/// unchanged if a real retail server or a future ACE fix ever populates /// <c>PublicWeenieDesc::_maxStackSize</c> DIRECTLY, never the instance
/// it). ACE never sets it for a vendor browse-list row /// <c>_stackSize</c>: <c>VendorItemsUI::UpdateItemsList</c>
/// (<c>Vendor.LoadInventoryItem</c> never calls <c>wo.SetStackSize</c>), /// (<c>0x004c1ea0</c>, <c>pc:201085-201133</c>) displays each browse
/// so this falls back to <paramref name="maxStackSize"/> — the item /// row's quantity as <c>pwd._maxStackSize</c> (clamped by the packed
/// TYPE's authored stack ceiling, which ACE DOES populate (an ordinary /// supply count only when that count is finite —
/// weenie property, not an instance-specific stack count) and which a /// <c>SetObjectStackSize(this, obj, min(remaining, _maxStackSize))</c>;
/// real retail server evidently uses AS <c>_stackSize</c> for an /// an unlimited <c>0xffffffff</c> listing shows plain
/// unlimited-supply "one full stack" browse listing (the live retail /// <c>_maxStackSize</c>); <c>gmVendorUI::InqListSlotCount</c>
/// screenshot: a Prismatic Taper listing shows "1000 Prismatic Tapers", /// (<c>0x004c0c10</c>, <c>pc:200052</c>) classifies list rows on
/// 1000 being the taper's authored max stack size, not any bounded /// <c>pwd._maxStackSize &lt;= 1</c>; and the Buy button cases
/// supply count). Finally falls back to 1 (non-splittable) when neither /// (<c>gmVendorUI::HandleButtonClicks</c> cases <c>0x100000c9</c> at
/// field is available. See the register, AP-169. /// <c>pc:203996</c> / <c>0x100000cb</c> at <c>pc:204086</c>) gate the
/// stackable-buy path on <c>pwd._maxStackSize &gt; 1</c>.
/// </para>
///
/// <para>
/// <b>Why desc-first was wrong (the live evidence).</b> The R1 version
/// of this method preferred <paramref name="descStackSize"/> on the
/// theory that ACE never populates it for a browse row. The 2026-08-08
/// vendor-diag live run refuted that: ACE serializes
/// <c>descStackSize=1</c> for EVERY browse row (`[vendor-diag]
/// ApproachVendor wire-item[...] descStackSize=1 stackSizeMax=100`), so
/// desc-first resolved every vendor stack to 1 and the toolbar split
/// slider never appeared (`ApplySelection ... failingPredicate=
/// stackSize&lt;=1u stackSize=1`). Max-first matches both retail's own
/// operand and the live retail screenshot ("1000 Prismatic Tapers",
/// ceiling 1000 = the taper's authored max stack size).
/// </para>
///
/// <para>
/// <paramref name="descStackSize"/> remains the fallback when no
/// authored ceiling exists, then 1 (non-splittable). This helper is
/// consumed ONLY by the vendor-owned paths
/// (<c>VendorShopItemMaterializer.ToWeenieData</c> and
/// <c>VendorUiController.ResolveBuyQuantity</c>); player-inventory
/// stacks read their own wire <c>ClientObject.StackSize</c> and are
/// deliberately untouched by this rule. See the register, AP-169.
/// </para> /// </para>
/// </summary> /// </summary>
public static int ResolveAuthoredStackSize(int? descStackSize, int? maxStackSize) => public static int ResolveAuthoredStackSize(int? descStackSize, int? maxStackSize) =>
descStackSize is { } desc && desc > 0 maxStackSize is { } max && max > 0
? desc ? max
: maxStackSize is { } max && max > 0 : descStackSize is { } desc && desc > 0
? max ? desc
: 1; : 1;
} }

View file

@ -58,22 +58,21 @@ public readonly record struct VendorShopItem(
// matching retail's own zeroed-struct default of 0 for the same case // matching retail's own zeroed-struct default of 0 for the same case
// (see VendorPricing.PerUnitValue's <= 0 guard). // (see VendorPricing.PerUnitValue's <= 0 guard).
int? DescStackSize = null, int? DescStackSize = null,
// Grand-gate finding R1 (2026-08-08, register AP-169 correction): the // Grand-gate finding R1, corrected at the 2026-08-08 live-evidence
// item TYPE's authored stack ceiling (retail PublicWeenieDesc:: // re-fix (register AP-169, second correction): the item TYPE's authored
// _maxStackSize, wire AcDream.Core.Net.Messages.PublicWeenieDescBody. // stack ceiling (retail PublicWeenieDesc::_maxStackSize, wire
// StackSizeMax). Retail's OWN InqListSlotCount (pc:200038-200065) and // AcDream.Core.Net.Messages.PublicWeenieDescBody.StackSizeMax). This is
// the Buying tab's Buy Item/Clear Item removal rule // retail's PRIMARY vendor-owned quantity operand, read literally by
// VendorItemsUI::UpdateItemsList (0x004c1ea0, pc:201085-201133 — each
// browse row displays min(remaining, _maxStackSize), plain
// _maxStackSize for unlimited supply), InqListSlotCount (pc:200052),
// and the Buying tab's Buy Item/Clear Item removal rule
// (gmVendorUI::HandleButtonClicks cases 0x100000c9/0x100000cb, // (gmVendorUI::HandleButtonClicks cases 0x100000c9/0x100000cb,
// pc:203989-204010/204080-204094) read THIS field literally, never // pc:203996/204086) — never the instance _stackSize. The live ACE wire
// DescStackSize, to decide whether an item is stackable at all. It also // (vendor-diag 2026-08-08) carries descStackSize=1 for every browse
// serves as this port's retail-faithful SUBSTITUTE for the toolbar's // row, so any desc-first read collapses every vendor stack to 1 — see
// splitSize/maxSplitSize seed (gmToolbarUI::HandleSelectionChanged, // VendorSplitPolicy.ResolveAuthoredStackSize (max-first, desc
// pc:198688/198744/198774/198791 — reads PublicWeenieDesc::_stackSize, // fallback) and the register (AP-169).
// 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, int? MaxStackSize = null,
// Review finding F5 (Slice 5.4 review): PublicWeenieDescBody already // Review finding F5 (Slice 5.4 review): PublicWeenieDescBody already
// carries these three (IconOverlayId/IconUnderlayId/UiEffects) — see // carries these three (IconOverlayId/IconUnderlayId/UiEffects) — see

View file

@ -193,33 +193,43 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(transport); ArgumentNullException.ThrowIfNull(transport);
sequence = 0u; sequence = 0u;
RuntimeInteractionDispatchResult verdict;
if (serverGuid == 0u) if (serverGuid == 0u)
{ {
reservation?.CancelBeforeDispatch(); reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.Rejected; verdict = RuntimeInteractionDispatchResult.Rejected;
} }
if (!transport.IsInWorld) else if (!transport.IsInWorld)
{ {
reservation?.CancelBeforeDispatch(); reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.NotInWorld; verdict = RuntimeInteractionDispatchResult.NotInWorld;
} }
if (!ownedByPlayer && !useable) else if (!ownedByPlayer && !useable)
{ {
reservation?.CancelBeforeDispatch(); reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.NotUseable; verdict = RuntimeInteractionDispatchResult.NotUseable;
} }
if (!transport.TrySendUse(serverGuid, out sequence)) else if (!transport.TrySendUse(serverGuid, out sequence))
{ {
reservation?.CancelBeforeDispatch(); reservation?.CancelBeforeDispatch();
return RuntimeInteractionDispatchResult.Rejected; verdict = RuntimeInteractionDispatchResult.Rejected;
}
else
{
reservation?.MarkDispatched();
_lastUseSourceId = serverGuid;
_lastUseTargetId = 0u;
IncrementRevision();
verdict = RuntimeInteractionDispatchResult.Dispatched;
} }
reservation?.MarkDispatched(); if (VendorDiagnostics.DumpVendorEnabled)
_lastUseSourceId = serverGuid; {
_lastUseTargetId = 0u; Console.WriteLine(
IncrementRevision(); $"[vendor-diag] TryDispatchUse guid=0x{serverGuid:X8} ownedByPlayer={ownedByPlayer} useable={useable} verdict={verdict} seq={sequence}");
return RuntimeInteractionDispatchResult.Dispatched; }
return verdict;
} }
public bool TryDispatchTargetedUse( public bool TryDispatchTargetedUse(
@ -623,6 +633,11 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
if (_pendingUse is not { } current if (_pendingUse is not { } current
|| current.ApproachToken != approachToken) || current.ApproachToken != approachToken)
{ {
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] TryResolveUseApproachCompletion no-match approachToken=({approachToken.ControllerLifetime},{approachToken.ApproachGeneration}) natural={natural} hasPendingUse={_pendingUse is not null}");
}
pending = default; pending = default;
return false; return false;
} }
@ -630,6 +645,11 @@ public sealed class RuntimeInteractionTransactionState : IDisposable
_pendingUse = null; _pendingUse = null;
pending = current; pending = current;
IncrementRevision(); IncrementRevision();
if (VendorDiagnostics.DumpVendorEnabled)
{
Console.WriteLine(
$"[vendor-diag] TryResolveUseApproachCompletion guid=0x{pending.ServerGuid:X8} natural={natural} accepted={natural}");
}
return natural; return natural;
} }

View file

@ -296,9 +296,24 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
minterpMaxSpeed: () => motion.GetAdjustedMaxSpeed(), minterpMaxSpeed: () => motion.GetAdjustedMaxSpeed(),
curTime: () => controller.SimTimeSeconds, curTime: () => controller.SimTimeSeconds,
physicsTimerTime: () => controller.SimTimeSeconds, physicsTimerTime: () => controller.SimTimeSeconds,
getObjectA: id => _physics.TryGetPhysicsHost(id, out var host) // 2026-08-08 vendor-approach root cause: this seam is retail's
? host // CObjectMaint::GetObjectA — it must resolve ANY in-world object
: null, // so TargetManager.SetTarget's add_voyeur can deliver the
// immediate initial snapshot that a deferred MoveToObject/
// TurnToObject needs before it queues a single node (UseTime's
// object-move gate stays closed until that first
// HandleUpdateTarget). The previous binding went straight to
// _physics.TryGetPhysicsHost, which answers only entities whose
// host is ALREADY installed (remote-motion-bound movers) — a
// never-animated NPC/static target resolved to null, add_voyeur
// never ran, and the armed approach sat inert (no nodes, no
// movement, no completion) until user input or the 10 s
// staleness timeout cancelled it. ResolveObjectTableHost routes
// through the host-bound canonical resolver (the SAME
// lazy-minimal-host lookup every remote host's GetObjectA uses)
// and falls back to the exact installed-host lookup when no
// resolver is bound (no-window hosts).
getObjectA: _physics.ResolveObjectTableHost,
// C3c: the [autowalk-target]/[autowalk-end] probes moved here // C3c: the [autowalk-target]/[autowalk-end] probes moved here
// with controller construction (previously App-side in // with controller construction (previously App-side in
// PlayerModeController.BuildControllerAndCamera); they stay on // PlayerModeController.BuildControllerAndCamera); they stay on

View file

@ -191,6 +191,7 @@ public sealed class VendorShopItemMaterializer : IDisposable
} }
} }
int diagIndex = 0;
foreach (VendorShopItem item in currentItems) foreach (VendorShopItem item in currentItems)
{ {
bool ownedAlready = _ownedGuids.ContainsKey(item.ItemGuid); bool ownedAlready = _ownedGuids.ContainsKey(item.ItemGuid);
@ -206,7 +207,19 @@ public sealed class VendorShopItemMaterializer : IDisposable
continue; continue;
} }
_objects.Ingest(ToWeenieData(item, transition.VendorId)); WeenieData materialized = ToWeenieData(item, transition.VendorId);
if (VendorDiagnostics.DumpVendorEnabled && diagIndex < 5)
{
Console.WriteLine(
$"[vendor-diag] materialize[{diagIndex}] guid=0x{item.ItemGuid:X8} "
+ $"descStackSize={(item.DescStackSize is { } ds ? ds.ToString() : "null")} "
+ $"maxStackSize={(item.MaxStackSize is { } ms ? ms.ToString() : "null")} "
+ $"resolvedStackSize={(materialized.StackSize is { } rs ? rs.ToString() : "null")} "
+ $"resolvedStackSizeMax={(materialized.StackSizeMax is { } rm ? rm.ToString() : "null")}");
diagIndex++;
}
_objects.Ingest(materialized);
nextOwned[item.ItemGuid] = transition.VendorId; nextOwned[item.ItemGuid] = transition.VendorId;
} }
} }
@ -249,25 +262,30 @@ public sealed class VendorShopItemMaterializer : IDisposable
/// appeared for ANY vendor stack. /// appeared for ANY vendor stack.
/// </para> /// </para>
/// <para> /// <para>
/// <b>The G2 packed-supply-count fallback (retired 2026-08-08).</b> An /// <b>The G2 packed-supply-count fallback (retired 2026-08-08) and the
/// earlier fix fell back to <see cref="VendorShopItem.StackSize"/> (the /// R1 desc-first preference (retired the same day, live evidence).</b>
/// packed ItemProfile "how many for sale" dword) when /// 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 — /// <c>DescStackSize</c> was absent. That did not survive live testing —
/// a standard vendor listing (e.g. a Prismatic Taper) has UNLIMITED /// a standard vendor listing (e.g. a Prismatic Taper) has UNLIMITED
/// stock (<c>StackSize == -1</c>), so the fallback produced nothing /// stock (<c>StackSize == -1</c>), so the fallback produced nothing
/// usable and the bar stayed hidden, matching the live report exactly /// usable and the bar stayed hidden. The R1 replacement preferred
/// (bare "Prismatic Taper", no bar, no count). The live retail /// <c>DescStackSize</c> on the theory that ACE never populates it for a
/// screenshot showed "1000 Prismatic Tapers" with the bar visible and a /// browse row; the 2026-08-08 vendor-diag live run refuted THAT too —
/// ceiling of 1000 — 1000 being the taper's authored MAX STACK SIZE, not /// ACE serializes <c>descStackSize=1</c> for every browse row, so
/// any bounded supply count. This now prefers <c>DescStackSize</c> when /// desc-first resolved every vendor stack to 1 and the bar stayed
/// present (retail-faithful first, forward-compatible with any server /// hidden again. The operand retail's own vendor UI reads is
/// that DOES populate it), falling back to /// <c>PublicWeenieDesc::_maxStackSize</c> directly
/// <see cref="VendorShopItem.MaxStackSize"/> — the item TYPE's authored /// (<c>VendorItemsUI::UpdateItemsList</c> <c>0x004c1ea0</c>,
/// stack ceiling, which ACE DOES populate (an ordinary weenie property) /// <c>pc:201085-201133</c>: each row displays
/// and which a real retail server evidently uses AS <c>_stackSize</c> /// <c>min(remaining, _maxStackSize)</c>, plain <c>_maxStackSize</c> for
/// for an unlimited-supply "one full stack" browse listing. See /// unlimited supply; <c>gmVendorUI::InqListSlotCount</c>
/// <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/> and the /// <c>pc:200052</c> and the Buy cases <c>pc:203996</c>/<c>204086</c>
/// register, AP-169. /// gate on the same field), matching the live retail screenshot
/// ("1000 Prismatic Tapers", ceiling 1000 = the authored max stack
/// size). <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/> is
/// therefore max-first with <c>DescStackSize</c> as the fallback. See
/// the register, AP-169.
/// </para> /// </para>
/// <para> /// <para>
/// Every other field <see cref="VendorShopItem"/> doesn't carry /// Every other field <see cref="VendorShopItem"/> doesn't carry

View file

@ -1240,6 +1240,57 @@ public sealed class RuntimePhysicsState : IDisposable
return false; return false;
} }
/// <summary>
/// The host-installed retail <c>CObjectMaint::GetObjectA</c> stand-in
/// consumed by <see cref="ResolveObjectTableHost"/>. Retail resolves ANY
/// in-world object here — every <c>CPhysicsObj</c> can answer
/// <c>add_voyeur</c>, so a moveto/sticky can target a never-animated
/// static object and still receive the immediate initial target snapshot
/// (<c>TargetManager::AddVoyeur</c> → <c>SendVoyeurUpdate(Ok)</c>). This
/// state's own <see cref="TryGetPhysicsHost"/> is narrower: it answers
/// only entities whose incarnation-stable host is ALREADY installed
/// (remote-motion-bound movers, prior resolve targets), so a moveto
/// bound to it alone arms but never initializes against a host-less
/// target — the 2026-08-08 vendor-approach root cause. The graphical
/// host binds its canonical lazy-resolving object-table lookup
/// (<c>LiveEntityMotionRuntimeController.ResolvePhysicsHost</c>, the
/// SAME resolver every remote host's <c>GetObjectA</c> already uses,
/// including the TS-49 Hidden withholding), which installs a minimal
/// position-only host into this state's canonical ownership on first
/// resolve. Host-scoped, not session-scoped: the binding survives
/// session resets (the bound resolver reads its own live session state
/// per call); last bind wins so a host can rebind across routes.
/// A no-window host that never binds keeps the exact-installed-host
/// behavior via the fallback below.
/// </summary>
private Func<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost?>?
_objectTableHostResolver;
/// <summary>Binds (or replaces) the canonical object-table host
/// resolver — see <see cref="ResolveObjectTableHost"/>. Pass null to
/// clear back to the exact-installed-host fallback.</summary>
public void BindObjectTableHostResolver(
Func<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost?>? resolver)
{
EnsureNotDisposed();
_objectTableHostResolver = resolver;
}
/// <summary>
/// Retail <c>CObjectMaint::GetObjectA(id)</c> for consumers composed
/// inside Runtime (the local player's publication-chain host): the bound
/// canonical resolver when the host installed one, else the exact
/// installed-host lookup (<see cref="TryGetPhysicsHost"/>).
/// </summary>
public AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolveObjectTableHost(
uint serverGuid)
{
EnsureNotDisposed();
if (_objectTableHostResolver is { } resolver)
return resolver(serverGuid);
return TryGetPhysicsHost(serverGuid, out var host) ? host : null;
}
public bool ClearRemoteMotion(RuntimeEntityRecord record) public bool ClearRemoteMotion(RuntimeEntityRecord record)
{ {
EnsureNotDisposed(); EnsureNotDisposed();
@ -2120,6 +2171,7 @@ public sealed class RuntimePhysicsState : IDisposable
_localPlayerCreateObserved = false; _localPlayerCreateObserved = false;
CellCommitted = null; CellCommitted = null;
_collisionGenerationCommittedObservers.Clear(); _collisionGenerationCommittedObservers.Clear();
_objectTableHostResolver = null;
_disposed = true; _disposed = true;
} }

View file

@ -0,0 +1,428 @@
using System.Numerics;
using AcDream.App.Interaction;
using AcDream.App.UI;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.Selection;
using AcDream.Core.World;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
namespace AcDream.App.Tests.Interaction;
/// <summary>
/// 2026-08-08 vendor-approach root fix, full PRODUCTION wiring (the live
/// vendor-diag evidence: an out-of-range vendor Use armed its approach and
/// then sat inert — the player never walked, the hourglass lingered, and
/// every attempt resolved <c>natural=False</c> only when manual input
/// cancelled it). Unlike <c>SelectionInteractionControllerTests</c> (fake
/// movement sink, the dead <c>OnNaturalMoveToComplete</c> entry point),
/// these tests drive the REAL chain end to end:
/// <c>SelectionInteractionController.RequestUse</c> → the REAL
/// <c>PlayerInteractionMovementSink</c> → the REAL
/// <c>PlayerMovementController</c>/<c>MovementManager</c>/<c>MoveToManager</c>
/// → the REAL <c>EntityPhysicsHost</c>/<c>TargetManager</c> voyeur
/// round-trip resolved through the REAL
/// <c>RuntimePhysicsState.ResolveObjectTableHost</c> seam → the REAL
/// <c>PlayerApproachCompletionState</c> mailbox →
/// <c>SelectionInteractionController.DrainOutbound</c>.
///
/// The root cause lived in the host's <c>getObjectA</c> seam: bound to the
/// exact-installed-host lookup, a never-animated NPC target resolved to
/// null, <c>TargetManager.SetTarget</c>'s <c>add_voyeur</c> never delivered
/// the immediate initial snapshot, and the deferred <c>MoveToObject</c>
/// never queued a node. The fix routes the seam through
/// <c>RuntimePhysicsState.ResolveObjectTableHost</c> with the graphical
/// host's canonical resolver bound
/// (<c>SessionPlayerComposition</c> → <c>BindObjectTableHostResolver</c>).
/// </summary>
public sealed class ProductionUseApproachWiringTests
{
private const uint Player = 0x5000_0001u;
private const uint Vendor = 0x7C95_B01Cu;
private const uint OtherVendor = 0x7C95_B01Du;
private const uint Cell = 0x0101_0001u;
private sealed class Query : IWorldSelectionQuery
{
public Dictionary<uint, InteractionApproach> Approaches { get; } = new();
public uint? PickAtCursor(bool includeSelf) => null;
public uint? PickAt(float mouseX, float mouseY, bool includeSelf) => null;
public void BeginLightingPulse(uint serverGuid) { }
public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
{
localEntityId = 101u;
return true;
}
public bool IsCurrent(uint serverGuid, uint localEntityId) => true;
public string Describe(uint serverGuid) => $"Target {serverGuid:X8}";
public bool IsCreature(uint serverGuid) => true;
public bool IsHostileMonster(uint serverGuid) => false;
public bool IsAttackableTarget(uint serverGuid) => false;
public ClosestCombatTarget? FindClosestHostileMonster() => null;
public bool IsUseable(uint serverGuid) => true;
public bool IsPickupable(uint serverGuid) => false;
public bool IsWieldedByPlayer(uint serverGuid) => false;
public bool IsWieldedPositionState(uint serverGuid) => false;
public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null;
public bool TryGetApproach(uint serverGuid, out InteractionApproach approach)
=> Approaches.TryGetValue(serverGuid, out approach);
}
private sealed class Transport : IRuntimeInteractionTransport
{
private uint _sequence;
public bool IsInWorld => true;
public List<uint> Uses { get; } = new();
public bool TrySendUse(uint serverGuid, out uint sequence)
{
sequence = ++_sequence;
Uses.Add(serverGuid);
return true;
}
public bool TrySendPickup(
uint itemGuid,
uint destinationContainerId,
int placement,
out uint sequence)
{
sequence = ++_sequence;
return true;
}
}
private sealed class Harness : IDisposable
{
public readonly RuntimeEntityObjectLifetime RuntimeLifetime = new();
public readonly Query Query = new();
public readonly Transport Transport = new();
public readonly PlayerApproachCompletionState Completions = new();
public readonly IPlayerApproachCompletionSink CompletionLifetime;
public readonly SelectionState Selection = new();
public readonly ClientObjectTable Objects = new();
public readonly InventoryTransactionState Inventory;
public readonly RuntimeInteractionTransactionState Transactions;
public readonly ItemInteractionController Items;
public readonly SelectionInteractionController Controller;
public readonly PlayerMovementController MovementController;
public readonly MoveToManager MoveTo;
public readonly Dictionary<uint, EntityPhysicsHost> TargetHosts = new();
public Harness(bool bindObjectTableResolver)
{
CompletionLifetime = Completions.BeginControllerLifetime();
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Type = ItemType.Creature,
});
Objects.AddOrUpdate(new ClientObject
{
ObjectId = Vendor,
Name = "Archmage",
Type = ItemType.Creature,
Useability = ItemUseability.Remote,
});
Objects.AddOrUpdate(new ClientObject
{
ObjectId = OtherVendor,
Name = "Other Archmage",
Type = ItemType.Creature,
Useability = ItemUseability.Remote,
});
// ── The real local movement graph, wired the same way
// RuntimeLocalPlayerPhysicsPublicationState.Prepare wires the
// production one: MoveToManager seams onto the controller's
// body, target seams onto the player's own EntityPhysicsHost,
// and the host's getObjectA onto the REAL RuntimePhysicsState
// object-table seam under test. ─────────────────────────────
var controller = new PlayerMovementController(new PhysicsEngine());
controller.SeedPlacementForTest(Vector3.Zero, Cell, Vector3.Zero);
MovementController = controller;
EntityPhysicsHost playerHost = null!;
MoveTo = new MoveToManager(
controller.Motion,
stopCompletely: () =>
_ = controller.StopCompletelyAtPhysicsObjectBoundary(),
getPosition: () => controller.CellPosition,
getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw),
setHeading: (heading, _) =>
controller.Yaw = MoveToMath.YawFromHeading(heading),
getOwnRadius: static () => 0.48f,
getOwnHeight: static () => 1.835f,
contact: static () => true,
isInterpolating: static () => false,
getVelocity: static () => Vector3.Zero,
getSelfId: static () => Player,
setTarget: (context, target, radius, quantum) =>
playerHost.SetTarget(context, target, radius, quantum),
clearTarget: () => playerHost.ClearTarget(),
getTargetQuantum: () =>
playerHost.TargetManager.GetTargetQuantum(),
setTargetQuantum: quantum =>
playerHost.TargetManager.SetTargetQuantum(quantum));
playerHost = new EntityPhysicsHost(
Player,
getPosition: () => controller.CellPosition,
getVelocity: static () => Vector3.Zero,
getRadius: static () => 0.48f,
inContact: static () => true,
minterpMaxSpeed: static () => null,
curTime: static () => 0d,
physicsTimerTime: static () => 0d,
// THE seam under test — the production publication binding.
getObjectA: RuntimeLifetime.Physics.ResolveObjectTableHost,
handleUpdateTarget: info =>
controller.Movement.HandleUpdateTarget(info),
interruptCurrentMovement: () =>
controller.Movement.CancelMoveTo(
WeenieError.ActionCancelled));
MoveTo.StickTo = (target, radius, height) =>
playerHost.PositionManager.StickTo(target, radius, height);
MoveTo.Unstick = playerHost.PositionManager.UnStick;
controller.MoveTo = MoveTo;
// PlayerModeController.BuildControllerAndCamera's exact
// completion bindings (the production mailbox publishers).
MoveTo.MoveToComplete = error =>
{
if (error == WeenieError.None)
CompletionLifetime.PublishNaturalCompletion();
else
CompletionLifetime.PublishCancellation(error);
};
MoveTo.MoveToCancelled = error =>
CompletionLifetime.PublishCancellation(error);
if (bindObjectTableResolver)
{
// SessionPlayerComposition's bind, with the lazy App
// resolver stood in by a per-guid minimal-host table (the
// shape LiveEntityMotionRuntimeController.ResolvePhysicsHost
// produces for a never-animated entity).
RuntimeLifetime.Physics.BindObjectTableHostResolver(
guid => TargetHosts.GetValueOrDefault(guid));
}
Inventory = new InventoryTransactionState(Objects);
Transactions = new RuntimeInteractionTransactionState(Inventory);
SelectionInteractionController? selectionController = null;
Items = new ItemInteractionController(
Objects,
Transactions,
new InteractionState(),
() => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
sendExamine: _ => { },
groundObjectId: () => 0u,
placeInBackpack: (item, container, placement) =>
selectionController!.SendPickup(item, container, placement),
requestUse: (guid, reservation) =>
selectionController!.RequestUse(guid, reservation));
Controller = selectionController = new SelectionInteractionController(
Selection,
Query,
Items,
Transport,
new PlayerInteractionMovementSink(
() => MovementController,
Completions),
toast: null,
Completions);
}
/// <summary>Registers a never-animated target: an approach the
/// selection query reports as genuinely out of range (a real walk)
/// plus the minimal position-only host the canonical object-table
/// resolver would lazily create for it.</summary>
public void AddFarTarget(uint serverGuid, Vector3 position)
{
var entity = new WorldEntity
{
Id = 101u,
ServerGuid = serverGuid,
SourceGfxObjOrSetupId = 0x0200_0001u,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = [],
};
Query.Approaches[serverGuid] = new InteractionApproach(
new WorldInteractionTarget(serverGuid, 101u, entity),
new PlayerInteractionPose(Cell, Vector3.Zero),
UseRadius: 3f,
IsCloseRange: false,
CanCharge: true,
TargetRadius: 0.5f,
TargetHeight: 2f);
TargetHosts[serverGuid] = new EntityPhysicsHost(
serverGuid,
getPosition: () => new Position(
Cell, position, Quaternion.Identity),
getVelocity: static () => Vector3.Zero,
getRadius: static () => 0.5f,
inContact: static () => true,
minterpMaxSpeed: static () => null,
curTime: static () => 0d,
physicsTimerTime: static () => 0d,
getObjectA: static _ => null,
handleUpdateTarget: static _ => { },
interruptCurrentMovement: static () => { });
}
public void Dispose() => RuntimeLifetime.Dispose();
}
/// <summary>
/// Far use → approach arms → natural arrival → dispatch, through the
/// complete production chain. The target sits inside the approach's
/// arrival band (cylinder distance 3.5 0.48 0.5 = 2.52 m ≤ the 3 m
/// wire use radius), so retail's deferred MoveToObject completes
/// naturally the moment the first voyeur snapshot arrives — which it
/// can ONLY do when the never-animated target resolves through the
/// bound object-table resolver. Sabotage-verified by
/// <see cref="UnresolvableTargetLeavesTheApproachInertUntilCancelReleasesTheReservation"/>:
/// the identical drive without the bind dispatches nothing.
/// </summary>
[Fact]
public void FarUseDispatchesOnNaturalArrivalThroughTheRealMovementChain()
{
using var h = new Harness(bindObjectTableResolver: true);
h.AddFarTarget(Vendor, new Vector3(3.5f, 0f, 0f));
ItemUseRequestReservation reservation =
h.Transactions.BeginUseRequestReservation();
Assert.Equal(1, h.Inventory.BusyCount);
h.Controller.RequestUse(Vendor, reservation);
// The deferred object move initialized off the immediate voyeur
// snapshot and completed naturally into the mailbox; nothing has
// dispatched until the production drain runs.
Assert.Empty(h.Transport.Uses);
h.Controller.DrainOutbound();
Assert.Equal(new[] { Vendor }, h.Transport.Uses);
// Dispatch transfers the busy reference to the authoritative
// UseDone (retail's hourglass-until-UseDone), it does not leak.
Assert.Equal(1, h.Inventory.BusyCount);
h.Transactions.CompleteUse(0u);
Assert.Equal(0, h.Inventory.BusyCount);
}
/// <summary>
/// A genuinely cancelled approach (the retail user-input chain's
/// terminal <c>CancelMoveTo</c>) releases the armed use's reservation in
/// the SAME drain that observes the cancellation — the hourglass clears
/// the moment the approach fails, and nothing reaches the wire.
/// </summary>
[Fact]
public void CancelledApproachReleasesTheArmedReservationInTheSameDrain()
{
using var h = new Harness(bindObjectTableResolver: true);
h.AddFarTarget(Vendor, new Vector3(10f, 0f, 0f));
ItemUseRequestReservation reservation =
h.Transactions.BeginUseRequestReservation();
h.Controller.RequestUse(Vendor, reservation);
// Genuinely walking: the far target initialized a real node plan.
Assert.True(h.MoveTo.IsMovingTo());
Assert.True(h.MoveTo.Initialized);
Assert.NotEmpty(h.MoveTo.PendingActions);
Assert.Equal(1, h.Inventory.BusyCount);
// Retail's input-edge cancel chain terminates here
// (InterruptCurrentMovement → MovementManager.CancelMoveTo).
h.MovementController.Movement.CancelMoveTo(
WeenieError.ActionCancelled);
Assert.Equal(1, h.Inventory.BusyCount);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
Assert.Equal(0, h.Inventory.BusyCount);
Assert.True(h.Transactions.TryGetPendingUse(out _) == false);
}
/// <summary>
/// A second far use supersedes the first cleanly: the first armed use's
/// reservation releases at the supersede boundary
/// (<c>CancelPendingApproach</c>), the first approach's cancellation
/// no-matches the second's token in the drain, and only the second
/// dispatches on its own natural arrival.
/// </summary>
[Fact]
public void NewFarUseSupersedesThePriorApproachAndReleasesItsReservation()
{
using var h = new Harness(bindObjectTableResolver: true);
h.AddFarTarget(Vendor, new Vector3(10f, 0f, 0f));
h.AddFarTarget(OtherVendor, new Vector3(3.5f, 0f, 0f));
ItemUseRequestReservation first =
h.Transactions.BeginUseRequestReservation();
h.Controller.RequestUse(Vendor, first);
Assert.Equal(1, h.Inventory.BusyCount);
ItemUseRequestReservation second =
h.Transactions.BeginUseRequestReservation();
Assert.Equal(2, h.Inventory.BusyCount);
h.Controller.RequestUse(OtherVendor, second);
// The first reservation released at the supersede boundary; the
// second is armed (near target: its natural completion is already
// queued behind the first approach's cancellation).
Assert.Equal(1, h.Inventory.BusyCount);
h.Controller.DrainOutbound();
Assert.Equal(new[] { OtherVendor }, h.Transport.Uses);
Assert.Equal(1, h.Inventory.BusyCount);
h.Transactions.CompleteUse(0u);
Assert.Equal(0, h.Inventory.BusyCount);
}
/// <summary>
/// The sabotage control for
/// <see cref="FarUseDispatchesOnNaturalArrivalThroughTheRealMovementChain"/>
/// and the permanent pin of the PRE-FIX pathology: without the
/// object-table bind the host's seam answers null for the
/// never-animated target, so the armed MoveToObject never receives its
/// first target update — no nodes, no natural completion, no dispatch,
/// and the reservation (the hourglass) stays held until something
/// cancels the approach; the cancel then still releases it.
/// </summary>
[Fact]
public void UnresolvableTargetLeavesTheApproachInertUntilCancelReleasesTheReservation()
{
using var h = new Harness(bindObjectTableResolver: false);
h.AddFarTarget(Vendor, new Vector3(3.5f, 0f, 0f));
ItemUseRequestReservation reservation =
h.Transactions.BeginUseRequestReservation();
h.Controller.RequestUse(Vendor, reservation);
h.Controller.DrainOutbound();
// Armed but inert — the live vendor-diag pathology.
Assert.True(h.MoveTo.IsMovingTo());
Assert.False(h.MoveTo.Initialized);
Assert.Empty(h.MoveTo.PendingActions);
Assert.Empty(h.Transport.Uses);
Assert.Equal(1, h.Inventory.BusyCount);
h.MovementController.Movement.CancelMoveTo(
WeenieError.ActionCancelled);
h.Controller.DrainOutbound();
Assert.Empty(h.Transport.Uses);
Assert.Equal(0, h.Inventory.BusyCount);
}
}

View file

@ -696,8 +696,12 @@ public class SelectedObjectControllerTests
/// operand is the item's authored <see cref="VendorShopItem.MaxStackSize"/> /// operand is the item's authored <see cref="VendorShopItem.MaxStackSize"/>
/// (1000 for a Prismatic Taper), not any supply count. This fixture now /// (1000 for a Prismatic Taper), not any supply count. This fixture now
/// matches that exact ACE-realistic shape: unlimited packed supply, no /// matches that exact ACE-realistic shape: unlimited packed supply, no
/// <c>DescStackSize</c> (ACE never sets it — see the class doc above), /// <c>DescStackSize</c>, <c>MaxStackSize=1000</c> from the wire.
/// <c>MaxStackSize=1000</c> from the wire. /// (Second correction, 2026-08-08: the live vendor-diag wire showed ACE
/// actually serializes <c>descStackSize=1</c> for every browse row, so
/// the resolver is now max-FIRST — see
/// <see cref="LiveAceWireShape_DescOneMaxHundred_ShowsSplitSliderWithTheAuthoredCeiling"/>;
/// this desc-absent fixture resolves identically either way.)
/// </para> /// </para>
/// </summary> /// </summary>
[Fact] [Fact]
@ -744,23 +748,20 @@ public class SelectedObjectControllerTests
&& vendorCandidate.ContainerId == vendor.VendorId && vendorCandidate.ContainerId == vendor.VendorId
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)); && VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type));
// G2 root cause, R1-corrected: a REAL ACE vendor listing, // G2 root cause, R1-corrected, second-corrected 2026-08-08: a REAL
// byte-for-byte. ACE's Vendor.LoadInventoryItem (Vendor.cs:144-172) // ACE vendor listing. The R1 reading of Vendor.LoadInventoryItem
// builds the browse-list WorldObject via // concluded DescStackSize "comes back null on the real wire"; the
// WorldObjectFactory.CreateNewWorldObject and sets ONLY // live vendor-diag capture showed the real wire actually carries
// wo.VendorShopCreateListStackSize = stackSize ?? -1 (the "how many // descStackSize=1 for every browse row — either way the field never
// available" packed dword — our VendorShopItem.StackSize) — it // names a usable ceiling. A STANDARD listing (unlimited stock,
// 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. A STANDARD listing (unlimited stock,
// packed StackSize=-1) is the common case the live re-test actually // packed StackSize=-1) is the common case the live re-test actually
// hit — matching the retail screenshot's Prismatic Taper, not a // hit — matching the retail screenshot's Prismatic Taper, not a
// bounded-supply item like the original fixture's "100 arrows". // bounded-supply item like the original fixture's "100 arrows".
// MaxStackSize=1000 IS reliably populated by ACE (an ordinary // MaxStackSize=1000 IS reliably populated by ACE (an ordinary
// weenie property), which is the field the toolbar ceiling now // weenie property), and it is retail's own vendor-side operand
// resolves through — see VendorSplitPolicy.ResolveAuthoredStackSize. // (VendorItemsUI::UpdateItemsList 0x004c1ea0 stamps each row's
// displayed stack from pwd._maxStackSize), which the resolver now
// prefers — see VendorSplitPolicy.ResolveAuthoredStackSize.
vendor.Apply( vendor.Apply(
vendorGuid, vendorGuid,
new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""), new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""),
@ -788,6 +789,95 @@ public class SelectedObjectControllerTests
controller.Dispose(); controller.Dispose();
} }
/// <summary>
/// 2026-08-08 live-evidence re-fix (register AP-169, second
/// correction): the EXACT live ACE wire shape the vendor-diag run
/// captured — every browse row carries <c>descStackSize=1</c> AND
/// <c>stackSizeMax</c> (e.g. a Lead Scarab: desc 1, max 100). The R1
/// desc-first resolver read the 1 and the split bar never appeared
/// (`ApplySelection ... sliderVisible=false
/// failingPredicate=stackSize&lt;=1u stackSize=1` in the live log).
/// Retail's vendor UI reads <c>pwd._maxStackSize</c> directly
/// (<c>VendorItemsUI::UpdateItemsList</c> <c>0x004c1ea0</c>), so the
/// selection must show the bar with ceiling 100, seeded at 1 (scarabs
/// are SpellComponents — split-exempt), and the retail "{count}
/// {plural name}" label. Same REAL-materializer wiring as the G2 test
/// above. Sabotage-verified: restoring the desc-first preference
/// resolves the operand to 1 and every assertion below fails.
/// </summary>
[Fact]
public void LiveAceWireShape_DescOneMaxHundred_ShowsSplitSliderWithTheAuthoredCeiling()
{
const uint vendorGuid = 0x70000012u;
const uint scarabGuid = 0x60009012u;
ImportedLayout layout = FixtureLoader.LoadToolbar();
var objects = new ClientObjectTable();
var vendor = new VendorState();
var selection = new SelectionState();
var splitQuantity = new StackSplitQuantityState();
using var materializer = new VendorShopItemMaterializer(vendor, objects);
SelectedObjectController controller = SelectedObjectController.Bind(
layout,
selection,
subscribeHealthChanged: _ => { },
unsubscribeHealthChanged: _ => { },
subscribeItemManaChanged: _ => { },
unsubscribeItemManaChanged: _ => { },
isHealthTarget: _ => false,
isOwnedByPlayer: _ => false,
name: guid => objects.Get(guid)?.GetAppropriateName(),
healthPercent: _ => 0f,
hasHealth: _ => false,
stackSize: guid => (uint)(objects.Get(guid)?.StackSize ?? 0),
sendQueryHealth: _ => { },
manaPercent: _ => 0f,
sendQueryItemMana: _ => { },
datFont: null,
splitQuantity: splitQuantity,
subscribeObjectUpdated: h => objects.ObjectUpdated += h,
unsubscribeObjectUpdated: h => objects.ObjectUpdated -= h,
isVendorSplitExempt: guid =>
vendor.VendorId != 0u
&& objects.Get(guid) is { } vendorCandidate
&& vendorCandidate.ContainerId == vendor.VendorId
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type));
vendor.Apply(
vendorGuid,
new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""),
new[]
{
new VendorShopItem(
scarabGuid, StackSize: -1, WeenieClassId: 5u, Name: "Lead Scarab",
ItemType: (uint)ItemType.SpellComponents, IconId: 200u, Value: 10,
DescStackSize: 1, MaxStackSize: 100, PluralName: "Lead Scarabs"),
});
Assert.Equal(100, objects.Get(scarabGuid)!.StackSize);
selection.Select(scarabGuid, SelectionChangeSource.Vendor);
var slider = Assert.IsType<UiScrollbar>(
layout.FindElement(SelectedObjectController.StackSizeSliderId));
Assert.True(slider.Visible);
Assert.Equal(100u, splitQuantity.Maximum);
// Split-exempt (SpellComponents intersects the 0xDC41CB0 mask):
// seeds at 1, ceiling stays the authored stack.
Assert.Equal(1u, splitQuantity.GetObjectSplitSize(
scarabGuid, scarabGuid, 100u));
// Retail's "{count} {plural}" toolbar label.
var nameElement = layout.FindElement(SelectedObjectController.NameId);
Assert.NotNull(nameElement);
UiText nameLabel = Assert.Single(nameElement!.Children.OfType<UiText>());
string renderedName = string.Concat(
nameLabel.LinesProvider().Select(static line => line.Text));
Assert.Equal("100 Lead Scarabs", renderedName);
controller.Dispose();
}
[Fact] [Fact]
public void C4_VendorOwnedSplitExemptStackSelection_MatchesRetailsToolbarPresentation() public void C4_VendorOwnedSplitExemptStackSelection_MatchesRetailsToolbarPresentation()
{ {

View file

@ -2442,6 +2442,105 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
return result.ToImmutable(); return result.ToImmutable();
} }
/// <summary>
/// 2026-08-08 vendor-approach root fix (the armed-but-inert far Use):
/// the published local player's host seam <c>getObjectA</c> is retail's
/// <c>CObjectMaint::GetObjectA</c> — it must resolve ANY in-world
/// object so <c>TargetManager.SetTarget</c>'s <c>add_voyeur</c> can
/// deliver the immediate initial target snapshot a deferred
/// <c>MoveToObject</c> needs before it queues a single node. The old
/// binding went straight to
/// <c>RuntimePhysicsState.TryGetPhysicsHost</c> (installed hosts only),
/// so a moveto against a never-animated NPC/static target — no
/// remote-motion binding, no installed host — armed but never
/// initialized: no nodes, no movement, no natural completion, until
/// user input or the 10 s staleness timeout cancelled it (the live
/// vendor-diag evidence: three consecutive approaches at 7.37/6.01/
/// 5.49 m sat inert; the fourth worked only because the vendor had
/// animated by then and gained a host).
///
/// Phase 1 pins the unbound fallback (exact installed hosts only — the
/// no-window shape): the moveto arms and stays UNinitialized with an
/// empty node plan. Phase 2 binds the canonical object-table resolver
/// (the graphical host's <c>ResolvePhysicsHost</c> stand-in, installed
/// by <c>SessionPlayerComposition</c> through
/// <c>RuntimePhysicsState.BindObjectTableHostResolver</c>) and proves
/// the SAME published moveto now receives the AddVoyeur snapshot
/// synchronously and builds its node plan. Sabotage-verified: with the
/// pre-fix <c>getObjectA: _physics.TryGetPhysicsHost</c> binding,
/// Phase 2 fails (the bound resolver is never consulted).
/// </summary>
[Fact]
public void PublishedLocalPlayerMoveToResolvesUninstalledTargetsThroughTheBoundObjectTableResolver()
{
using var fixture = new Fixture(residentWorld: true);
Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
fixture.Owner.Commit(
fixture.Prepare(),
out RuntimeLocalPlayerPhysicsActivationToken token));
Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
fixture.Owner.EvaluateActivation(token, out var evaluation));
Assert.Equal(RuntimeDormantSetPositionCommitStatus.Committed,
fixture.Owner.CommitActivation(evaluation, out _));
Assert.NotNull(fixture.Record.PhysicsHost);
MovementManager movement = fixture.Movement.Controller!.Movement;
MoveToManager moveTo = movement.MoveTo!;
const uint vendorGuid = 0x7C95B01Cu;
Vector3 vendorPos =
fixture.Record.PhysicsBody!.Position + new Vector3(10f, 0f, 0f);
MovementStruct Approach() => new()
{
ObjectId = vendorGuid,
TopLevelId = vendorGuid,
Pos = new AcDream.Core.Physics.Position(
Cell, vendorPos, Quaternion.Identity),
Params = new MovementParameters
{
DistanceToObject = 3f,
CanCharge = true,
},
Type = MovementType.MoveToObject,
Radius = 0.5f,
Height = 2f,
};
// Phase 1 — no resolver bound (the no-window fallback): the target
// has no installed host, so the deferred object move arms but never
// receives its first target update — exactly the pre-fix pathology.
Assert.Equal(WeenieError.None, movement.PerformMovement(Approach()));
Assert.True(moveTo.IsMovingTo());
Assert.False(moveTo.Initialized);
Assert.Empty(moveTo.PendingActions);
moveTo.CancelMoveTo(WeenieError.ActionCancelled);
// Phase 2 — the graphical host's bind: the SAME published moveto
// resolves the never-animated target through the object-table
// resolver, receives AddVoyeur's immediate Ok snapshot
// synchronously, and builds its node plan.
var vendorHost = new EntityPhysicsHost(
vendorGuid,
getPosition: () => new AcDream.Core.Physics.Position(
Cell, vendorPos, Quaternion.Identity),
getVelocity: static () => Vector3.Zero,
getRadius: static () => 0.5f,
inContact: static () => true,
minterpMaxSpeed: static () => null,
curTime: static () => 0d,
physicsTimerTime: static () => 0d,
getObjectA: static _ => null,
handleUpdateTarget: static _ => { },
interruptCurrentMovement: static () => { });
fixture.Lifetime.Physics.BindObjectTableHostResolver(
guid => guid == vendorGuid ? vendorHost : null);
Assert.Equal(WeenieError.None, movement.PerformMovement(Approach()));
Assert.True(moveTo.IsMovingTo());
Assert.True(moveTo.Initialized);
Assert.NotEmpty(moveTo.PendingActions);
moveTo.CancelMoveTo(WeenieError.ActionCancelled);
}
private readonly record struct EvaluationPuritySnapshot( private readonly record struct EvaluationPuritySnapshot(
RuntimePhysicsOwnershipSnapshot PhysicsOwnership, RuntimePhysicsOwnershipSnapshot PhysicsOwnership,
RuntimeSetPositionOwnershipSnapshot SetPositionOwnership, RuntimeSetPositionOwnershipSnapshot SetPositionOwnership,

View file

@ -136,6 +136,11 @@ public sealed class VendorShopItemMaterializerTests
/// <c>DescStackSize</c>, packed <c>StackSize=-1</c> (unlimited), /// <c>DescStackSize</c>, packed <c>StackSize=-1</c> (unlimited),
/// <c>MaxStackSize=1000</c> -&gt; <c>ClientObject.StackSize</c> resolves /// <c>MaxStackSize=1000</c> -&gt; <c>ClientObject.StackSize</c> resolves
/// to 1000, not 1 and not the (nonsensical, unbounded) packed field. /// to 1000, not 1 and not the (nonsensical, unbounded) packed field.
/// (Second correction, 2026-08-08: the live wire showed ACE actually
/// sends <c>descStackSize=1</c>, so <c>MaxStackSize</c> is now the
/// PRIMARY operand rather than a fallback — see
/// <see cref="Apply_LiveAceWireShape_DescOneMaxHundred_ResolvesToTheAuthoredCeiling"/>;
/// this desc-absent case resolves identically either way.)
/// </summary> /// </summary>
[Fact] [Fact]
public void Apply_UnlimitedStockNoDescStackSize_FallsBackToMaxStackSize() public void Apply_UnlimitedStockNoDescStackSize_FallsBackToMaxStackSize()
@ -157,6 +162,42 @@ public sealed class VendorShopItemMaterializerTests
Assert.Equal(1000, item.StackSizeMax); Assert.Equal(1000, item.StackSizeMax);
} }
/// <summary>
/// 2026-08-08 live-evidence re-fix (register AP-169, second
/// correction): the EXACT wire shape the vendor-diag run captured from
/// the live ACE server — `descStackSize=1 stackSizeMax=100` for every
/// browse row (e.g. the Smelting Pot / Lead Scarab rows,
/// `[vendor-diag] ApproachVendor wire-item[0] ... descStackSize=1
/// stackSizeMax=100`). ACE DOES serialize the instance stack size, at
/// the useless value 1, so the R1 desc-first preference resolved every
/// vendor stack to 1 and the toolbar split slider never appeared
/// (`ApplySelection ... failingPredicate=stackSize&lt;=1u`). Retail's
/// own vendor UI reads <c>pwd._maxStackSize</c> directly
/// (<c>VendorItemsUI::UpdateItemsList</c> <c>0x004c1ea0</c>,
/// <c>pc:201085-201133</c>), so the materialized ceiling must be 100
/// here. Sabotage-verified: restoring the desc-first preference makes
/// this resolve 1 and fail.
/// </summary>
[Fact]
public void Apply_LiveAceWireShape_DescOneMaxHundred_ResolvesToTheAuthoredCeiling()
{
var vendor = new VendorState();
var objects = new ClientObjectTable();
using var materializer = new VendorShopItemMaterializer(vendor, objects);
vendor.Apply(VendorGuid, default, new[]
{
new VendorShopItem(
ItemA, StackSize: -1, WeenieClassId: 1u, Name: "Lead Scarab",
ItemType: (uint)ItemType.SpellComponents, IconId: 0x1234u, Value: 10,
DescStackSize: 1, MaxStackSize: 100),
});
ClientObject item = objects.Get(ItemA)!;
Assert.Equal(100, item.StackSize);
Assert.Equal(100, item.StackSizeMax);
}
/// <summary> /// <summary>
/// Sabotage-adjacent control: the SAME unlimited-stock listing but with /// Sabotage-adjacent control: the SAME unlimited-stock listing but with
/// <see cref="VendorShopItem.MaxStackSize"/> ALSO absent (neither wire /// <see cref="VendorShopItem.MaxStackSize"/> ALSO absent (neither wire

View file

@ -52,6 +52,77 @@ public sealed class RuntimePhysicsStateTests
Assert.True(physics.CaptureOwnership().IsDisposed); Assert.True(physics.CaptureOwnership().IsDisposed);
} }
/// <summary>
/// 2026-08-08 vendor-approach root fix: <c>ResolveObjectTableHost</c> is
/// the retail <c>CObjectMaint::GetObjectA</c> seam consumed by the local
/// player's publication-chain host. Unbound (no-window hosts) it answers
/// exact installed hosts only; bound (the graphical host's canonical
/// lazy resolver, installed by <c>SessionPlayerComposition</c>) it
/// delegates every lookup — including entities that have no installed
/// host yet, the case whose null answer left a far-use MoveToObject
/// armed but inert. Dispose clears the binding so a retired session
/// route cannot leak its App closure.
/// </summary>
[Fact]
public void ResolveObjectTableHostDelegatesToTheBoundResolverAndFallsBackToInstalledHosts()
{
var lifetime = new RuntimeEntityObjectLifetime();
RuntimeEntityRecord record =
lifetime.Entities.AddActive(Spawn(0x70000001u, 1));
EntityPhysicsHost installed = MinimalHost(record.ServerGuid);
lifetime.Physics.InstallPhysicsHost(record, installed);
const uint uninstalledGuid = 0x7C95B01Cu;
// Unbound fallback: installed hosts resolve, anything else is null.
Assert.Same(
installed,
lifetime.Physics.ResolveObjectTableHost(record.ServerGuid));
Assert.Null(lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
// Bound: the canonical resolver owns EVERY lookup.
EntityPhysicsHost lazyMinimal = MinimalHost(uninstalledGuid);
var resolvedGuids = new List<uint>();
lifetime.Physics.BindObjectTableHostResolver(guid =>
{
resolvedGuids.Add(guid);
return guid == uninstalledGuid ? lazyMinimal : null;
});
Assert.Same(
lazyMinimal,
lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
Assert.Null(lifetime.Physics.ResolveObjectTableHost(0x70000002u));
Assert.Equal(
new[] { uninstalledGuid, 0x70000002u },
resolvedGuids);
// Rebinding replaces (last bind wins); null clears back to the
// exact-installed-host fallback.
lifetime.Physics.BindObjectTableHostResolver(null);
Assert.Null(lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
Assert.Same(
installed,
lifetime.Physics.ResolveObjectTableHost(record.ServerGuid));
lifetime.Physics.BindObjectTableHostResolver(_ => lazyMinimal);
lifetime.Dispose();
Assert.Throws<ObjectDisposedException>(
() => lifetime.Physics.ResolveObjectTableHost(uninstalledGuid));
}
private static EntityPhysicsHost MinimalHost(uint guid) => new(
guid,
getPosition: static () => new AcDream.Core.Physics.Position(
0u, Vector3.Zero, Quaternion.Identity),
getVelocity: static () => Vector3.Zero,
getRadius: static () => 0f,
inContact: static () => true,
minterpMaxSpeed: static () => null,
curTime: static () => 0d,
physicsTimerTime: static () => 0d,
getObjectA: static _ => null,
handleUpdateTarget: static _ => { },
interruptCurrentMovement: static () => { });
[Fact] [Fact]
public void CanonicalRecordAndPhysicsOwnerOwnRemoteComponentAndWorksets() public void CanonicalRecordAndPhysicsOwnerOwnRemoteComponentAndWorksets()
{ {