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
The vendor window is retail's own: LayoutDesc 0x21000012, root 0x100000B7, found by enumerating all 101 layouts for the one containing both known tab controls and clinched by the root's Type 0x10000017 — the literal UIElement::RegisterElementClass id for gmVendorUI (pc:202075). Discovery evidence and the D0 read live in the research doc's new §B.4. D0 corrected two assumptions: retail's category "tabs" are a UiMenu DROPDOWN fed by a hardcoded 18-row ordered category table (ported bit-for-bit against our ItemType enum; list always scoped to exactly one category, first-present wins, selection preserved across refresh per retail's clamp), and the layout authors THREE tabs — Items (browse, this slice), Buying and Selling (staged-transaction review, Slice 6) — decision 4's "browse/Buy tab" names the Items tab retail's mode-2 OpenTab opens. The non-default tabs render and switch pages but stay inert, fenced in comments. VendorUiController mounts Items: category dropdown, icon-cell item row with the retained scrollbar, per-unit retail pricing via VendorPricing.SellPrice (the vendor-stock path VendorProfile:: VendorSellPrice feeds), name/cost on selection. The panel is a pure projection of VendorState — opens on populate, closes on clear; the close button's VendorState.Close() is its only permitted mutation. Nothing on the wire. AP-110 narrowed (vendor leaves the absent-panels list); AP-161 files the precise Slice-6 remainder (Buying/Selling unwired, Buy/Add buttons, InqAcceptability). Twelve controller tests on a real-dat fixture. Clean-room complete solution: 11,323 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
920 lines
54 KiB
Markdown
920 lines
54 KiB
Markdown
# Slice 5 research — vendor browse lifecycle
|
|
|
|
**Date:** 2026-08-08
|
|
**Scope:** Slice 5 of `docs/plans/2026-07-23-world-interaction-completion.md` —
|
|
"Vendor use opens the authored vendor surface, publishes its inventory, and
|
|
supports retail selection/browsing." Buy/sell transactions, quantities, and
|
|
authoritative reconciliation are Slice 6 and explicitly fenced out below
|
|
(Section D).
|
|
|
|
**Verified starting point:** repo HEAD at research time was `fa0c053e`
|
|
("docs(physics): #347 closed WITHOUT a code change..."), which is at/after
|
|
the required `fa0c053e` gate. This document makes no code changes; it is a
|
|
research foundation only.
|
|
|
|
**Reference hierarchy used** (per `CLAUDE.md`): named-retail decomp
|
|
(`docs/research/named-retail/acclient_2013_pseudo_c.txt` +
|
|
`symbols.json`) is the top oracle for client behavior. ACE
|
|
(`references/ACE/`) is authoritative for what the server sends/validates.
|
|
holtburger (`references/holtburger/`) is authoritative for what a real
|
|
client actually sends and how a full client models the state. Chorizite.ACProtocol
|
|
(`references/Chorizite.ACProtocol/`) is a clean-room field-order cross-check.
|
|
Where two sources could plausibly disagree, this document says which one the
|
|
project's hierarchy prefers — in every case investigated here they agreed
|
|
byte-for-byte.
|
|
|
|
---
|
|
|
|
## A. The wire (browse only)
|
|
|
|
### A.1 — What the client sends to open a vendor
|
|
|
|
There is **no vendor-specific open message**. Opening a vendor rides the
|
|
ordinary **`GameAction 0x36 — UseItem`** (`Use`) action — the exact same
|
|
opcode already used for every other "double-click to use" interaction
|
|
(containers, doors, levers, Slice 4's equipped-child picking, etc.).
|
|
|
|
- ACE: `GameActionType.Use = 0x0036` —
|
|
`references/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:28`.
|
|
Handler: `GameActionUseItem.Handle` →
|
|
`session.Player.HandleActionUseItem(itemGuid)` —
|
|
`references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionUseItem.cs:5-16`.
|
|
`HandleActionUseItem` resolves the guid, optionally walks the player to it,
|
|
then calls `TryUseItem(item)` →
|
|
`references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-241`.
|
|
`TryUseItem` calls `item.OnActivate(this)`, which (for a `Vendor`, whose
|
|
`ActivationResponse` includes `Use`) dispatches virtually to
|
|
`Vendor.ActOnUse` —
|
|
`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:229-266`.
|
|
- holtburger: `AppAction::OpenShop { vendor }` →
|
|
`result.commands.push(ClientCommand::Use(vendor))` —
|
|
`references/holtburger/apps/holtburger-cli/src/pages/game/domains/trade_vendor.rs:40-49`.
|
|
Confirms a real client also just sends the ordinary Use command; there is
|
|
no `ClientCommand::OpenVendor`.
|
|
- acdream already sends this opcode today:
|
|
`InteractRequests.UseOpcode = 0x0036` —
|
|
`src/AcDream.Core.Net/Messages/InteractRequests.cs:29`. **No new outbound
|
|
message is needed for Slice 5.**
|
|
- The retail client's own use-classification policy already special-cases
|
|
vendors: `PublicWeenieFlags.Vendor = 0x00000200` (retail
|
|
`PublicWeenieDesc::BitfieldIndex`, `acclient.h:6431`, verified at binary
|
|
`0x005884B6`/`0x00588752`) —
|
|
`src/AcDream.Core/Items/ItemInteractionPolicy.cs:21`. A vendor NPC's
|
|
`Useability` marks it `IsUseable`, so `ItemInteractionPolicy.DecideUse`
|
|
falls into the plain `SendUse` + `IncrementBusy` branch
|
|
(`src/AcDream.Core/Items/ItemInteractionPolicy.cs:267-288`) — the exact
|
|
same path already exercised by every other useable NPC/object.
|
|
|
|
### A.2 — What the server sends back: `ApproachVendor` (GameEvent `0x0062`)
|
|
|
|
`GameEventType.ApproachVendor = 0x0062` —
|
|
`references/ACE/Source/ACE.Server/Network/GameEvent/GameEventType.cs:16`;
|
|
already declared in acdream at
|
|
`src/AcDream.Core.Net/Messages/GameEventType.cs:26` (currently unhandled —
|
|
`GameEventDispatcher` will count it in `UnhandledCounts` until Slice 5 wires
|
|
a handler).
|
|
|
|
Full field-by-field wire layout, cross-verified across **three independent
|
|
sources** (ACE's writer, retail's `VendorProfile::UnPack`/`ItemProfile::UnPack`
|
|
decompiled reader, and Chorizite's generated reader/writer) with **zero
|
|
disagreement**:
|
|
|
|
| # | Field | Type | ACE source | Retail source | Chorizite name |
|
|
|---|---|---|---|---|---|
|
|
| 1 | Vendor's own guid | `u32` | `GameEventApproachVendor.cs:14` | `Handle_VendorInfo` `edi = *(u32*)eax` (`pc:370614`) | `Vendor_VendorInfo.ObjectId` |
|
|
| 2 | `MerchandiseItemTypes` (categories the vendor buys) | `u32` (`ItemType`) | `:17` | `VendorProfile::UnPack` `item_types` (`pc:484940`) | `VendorProfile.Categories` |
|
|
| 3 | `MerchandiseMinValue` | `u32` | `:18` | `min_value` (`pc:484943`) | `VendorProfile.MinValue` |
|
|
| 4 | `MerchandiseMaxValue` | `u32` | `:19` | `max_value` (`pc:484946`) | `VendorProfile.MaxValue` |
|
|
| 5 | `DealMagicalItems` | `u32` (0/1) | `:21` `Convert.ToUInt32` | `magic` (`pc:484949`) | `VendorProfile.DealsMagic` (bool) |
|
|
| 6 | `BuyPrice` (rate applied when the vendor **buys from** the player — i.e. what the player receives when selling) | `float` | `:23` | `buy_price` (`pc:484952`) | `VendorProfile.BuyPrice` |
|
|
| 7 | `SellPrice` (rate applied when the vendor **sells to** the player — i.e. what the player pays when buying) | `float` | `:24` | `sell_price` (`pc:484955`) | `VendorProfile.SellPrice` |
|
|
| 8 | Alternate-currency wcid (`AlternateCurrency ?? 0`) | `u32` (dat id) | `:27` | `trade_id.id` (`pc:484960`) | `VendorProfile.CurrencyId` |
|
|
| 9 | Player's current holding of that currency + amount just spent (0 if pyreal vendor) | `u32` | `:37,44` | `trade_num` (`pc:484961`) | `VendorProfile.CurrencyAmount` |
|
|
| 10 | Alt-currency plural name (empty string if pyreal vendor) | `String16L` | `:40,45` | `trade_name` (`PStringBase<char>::UnPack`, `pc:484963`) | `VendorProfile.CurrencyName` |
|
|
| 11 | Item count | `u32` (`vendor.DefaultItemsForSale.Count + UniqueItemsForSale.Count`) | `:50` | `PackableList<ItemProfile>::UnPack` count prefix (`pc:370625`) | `Vendor_VendorInfo.Items` (list length prefix) |
|
|
| 12..N | Per-item entries (see below) | — | `:52-61` | — | `List<ItemProfile>` |
|
|
|
|
**Field-name naming trap (worth flagging loudly for the contract):**
|
|
retail's client-side `VendorProfile::VendorSellPrice(profile, pwd, stack)`
|
|
computes what **you pay to buy** an item — it uses the `sell_price` field
|
|
(field 7 above), i.e. "the price at which the vendor is selling." ACE's
|
|
server-side `Vendor.GetSellCost` (`Vendor.cs:577-585`) is the same
|
|
computation and uses `SellPrice` (same field). Conversely
|
|
`VendorBuyPrice`/`GetBuyCost` (what the vendor pays when **you sell to it**)
|
|
uses `buy_price`/`BuyPrice`. The names read backwards from an English-first
|
|
intuition ("BuyPrice" sounds like "price I pay to buy," but it's actually
|
|
"price the vendor pays when buying from you"). Any pricing helper in
|
|
acdream should carry an explicit doc comment quoting this inversion.
|
|
|
|
**Per-item encoding** — confirmed to be the **full CreateObject-style
|
|
weenie description**, not a compact profile. Retail's `ItemProfile::UnPack`
|
|
(`pc:484668-484742`, symbol `0x005D1910`):
|
|
|
|
1. Packed `u32`: low 24 bits = stack size (sign-extended; `0xFFFFFF` ⇒ `-1`
|
|
⇒ unlimited supply), high 8 bits = `pwdType` (`-1` = new
|
|
`PublicWeenieDesc`, `1` = legacy `OldPublicWeenieDesc`, always `-1` in
|
|
practice). Matches ACE's writer exactly:
|
|
`Writer.Write(stackSize & 0xFFFFFF | -1 << 24)` —
|
|
`GameEventApproachVendor.cs:58`.
|
|
2. `u32` item guid (`iid`) — read **before** the desc body, i.e. it is a
|
|
distinct field from whatever the desc-body parser reads.
|
|
3. The desc body itself: `pwd->vtable->UnPack(arg2, arg3)` — this is the
|
|
**same `PublicWeenieDesc` unpack used by ordinary `CreateObject`**, minus
|
|
model/physics data. ACE confirms: `obj.SerializeGameDataOnly(writer)` →
|
|
`SerializeCreateObject(writer, gamedataonly: true, ...)` —
|
|
`references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs:39-58`.
|
|
`SerializeCreateObject` writes `Guid` first (matching step 2 above, since
|
|
`gamedataonly` still writes the guid, just skips `SerializeModelData` +
|
|
`SerializePhysicsData`), then the `weenieFlags`/name/wcid/icon/itemType/
|
|
`objDescriptionFlags` fixed prefix and the same conditional tail fields
|
|
(`PluralName`, `ItemsCapacity`, `Value`, `Useability`, `StackSize`,
|
|
`WielderId`, `ValidSlots`, etc.) that every `CreateObject` carries.
|
|
Chorizite's `PublicWeenieDesc.generated.cs` (`Read`/`Write`, lines
|
|
226-480) is the exact field-by-field cross-check and matches
|
|
`WorldObject_Networking.cs:56-130` bit-for-bit.
|
|
4. Retail's `gmVendorUI::OpenVendor` (`pc:203650`, `0x004C4BA0`) then
|
|
materializes **each list item as a full `CWeenieObject`** via
|
|
`CFactory::MakeCWeenieObject` and registers it in
|
|
`ClientObjMaintSystem`/`CObjectMaint` — the same object table every other
|
|
spawned entity lives in (`pc:203720-203748`). Vendor items are not a
|
|
separate lightweight vendor-item record in retail; they are ordinary
|
|
client objects with no spatial presence. **Recommendation:** acdream's
|
|
`ClientObjectTable` should host vendor items the same way (see C.2/C.5).
|
|
|
|
`decode_vendor_item_supply` in holtburger confirms the sign-extension
|
|
handling for the packed stack-size field independently:
|
|
`references/holtburger/crates/holtburger-world/src/hydration.rs:33-40`
|
|
(`(packed << 8) as i32 >> 8`, negative ⇒ unlimited), with tests at
|
|
`hydration.rs:320-330`.
|
|
|
|
### A.3 — How browsing stays current; what closes it server-side
|
|
|
|
**The list is a full snapshot, not incrementally updated.** Each
|
|
`ApproachVendor` is a complete replace (whole `VendorProfile` + whole item
|
|
list); there is no delta/patch opcode for vendor contents in ACE or the
|
|
retail decomp. A fresh `ApproachVendor` arrives on: initial open
|
|
(`Vendor.ActOnUse` → `ApproachVendor(player, VendorType.Open)`,
|
|
`Vendor.cs:246-266`), after a successful buy
|
|
(`FinalizeBuyTransaction` → `vendor.ApproachVendor(this, VendorType.Buy, ...)`,
|
|
`Player_Commerce.cs:112`), and after a successful sell
|
|
(`ProcessItemsForPurchase` → `ApproachVendor(player, VendorType.Sell)`,
|
|
`Vendor.cs:661`). All three are Slice 6 triggers (buy/sell); Slice 5 only
|
|
needs to handle the initial-open case, but the parser/state owner should be
|
|
built expecting **replace semantics** (mirroring how
|
|
`ExternalContainerState`/`ViewContents` already model "authoritative full
|
|
replace," see C.2).
|
|
|
|
**Server-side close is polling, not a push.** `Vendor.CheckClose` runs
|
|
every `closeInterval = 1.5f` seconds
|
|
(`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367`) and, if
|
|
the last-interacting player has moved beyond `UseRadius`, calls
|
|
`EmoteManager.DoVendorEmote(VendorType.Close, ...)` — this is a **cosmetic
|
|
emote broadcast** (goodbye animation/chat text via
|
|
`EmoteManager.DoVendorEmote` →
|
|
`references/ACE/Source/ACE.Server/WorldObjects/Managers/EmoteManager.cs:1763-1770`),
|
|
**not a distinct wire opcode that tells the client to close its panel.**
|
|
There is no server→client "vendor closed" GameEvent.
|
|
|
|
Retail's own client independently tracks distance and closes locally: on
|
|
open, `gmVendorUI::OpenVendor` registers a range watcher —
|
|
`CPlayerSystem::RegisterObjectRangeHandler(..., eax->id, eax->pwd._useRadius, ...)`
|
|
(`pc:203677`, `0x004C4C34`) — and `gmVendorUI::OnObjectRangeExit`
|
|
(`pc:199486`, `0x004C02F0`) calls `gmVendorUI::CloseVendor` when that
|
|
handler fires. `gmVendorUI::CloseVendor` (`pc:202080`, `0x004C3020`) clears
|
|
the buy/sell/items sub-UI, calls
|
|
`CPlayerSystem::UnregisterObjectRangeHandler`, and resets shop state. This
|
|
confirms **the client, not the server, owns the close decision** — retail's
|
|
client independently watches distance client-side (belt-and-suspenders with
|
|
ACE's server-side emote, which is purely cosmetic).
|
|
|
|
Also: reopening the *same* vendor id while already open does **not** tear
|
|
down the sub-UIs — `gmVendorUI::OpenVendor` only calls `CloseVendor(true)`
|
|
(same-vendor flag) when `shopVendorID` was already set and matches, which
|
|
skips the sub-UI clear (`pc:203664-203668`). Reopening a *different* vendor
|
|
while one is open closes the old one first. This matters for the reset
|
|
lifecycle contract (C.2): "reset on any new `ApproachVendor` for a different
|
|
guid; refresh-in-place on a same-guid `ApproachVendor`."
|
|
|
|
### A.4 — What the client sends when the panel closes
|
|
|
|
**Nothing.** Closing the vendor panel is entirely client-local UI state with
|
|
no outbound wire message. Confirmed in two independent places:
|
|
|
|
- holtburger: `AppAction::ClearVendor => { state.view.vendor = None; }` —
|
|
`references/holtburger/apps/holtburger-cli/src/pages/game/domains/trade_vendor.rs:95-98`.
|
|
No command is pushed to the outbound queue.
|
|
- ACE has no `HandleAction*CloseVendor*` handler anywhere in
|
|
`references/ACE/Source/ACE.Server/WorldObjects/` (only
|
|
`HandleActionBuyItem`/`HandleActionSellItem` exist —
|
|
`Player_Commerce.cs:22`, `126`).
|
|
- Retail's `gmVendorUI::CloseVendor` (`pc:202080`) is a pure UI-teardown
|
|
function; it does not build or send a message.
|
|
|
|
There is one indirect trigger: holtburger clears the vendor view when a
|
|
*trade* (player-to-player) starts (`ClientViewEvent::TradeStateUpdated`,
|
|
`trade_vendor.rs:134-137`), which is a local UX choice (only one modal
|
|
commerce surface at a time), not a protocol requirement.
|
|
|
|
---
|
|
|
|
## B. Retail client mechanism (named-retail decomp)
|
|
|
|
685 lines in `acclient_2013_pseudo_c.txt` reference "Vendor." The full
|
|
symbol family (`docs/research/named-retail/symbols.json`), with addresses:
|
|
|
|
| Symbol | Address | Role |
|
|
|---|---|---|
|
|
| `ClientUISystem::Handle_VendorInfo` | `0x00565D10` | Entry point for the inbound `ApproachVendor` message; unpacks `VendorProfile` + item list, then fans out via `CM_Vendor::SendNotice_OpenVendor` |
|
|
| `VendorProfile::UnPack` / `::VendorProfile` | `0x005D1D20` / `0x005D1BE0` | Wire unpack for the profile header (field table above) |
|
|
| `ItemProfile::UnPack` / `::Pack` | `0x005D1910` / `0x005D1890` | Wire unpack for each shop item |
|
|
| `VendorProfile::VendorSellPrice` / `::VendorBuyPrice` | `0x005D1B00` / `0x005D1B70` | Client-local price computation (see B.1) |
|
|
| `VendorProfile::InqAcceptability` / `::IsAcceptable` | `0x005D1A90` / `0x005D1B50` | Sell-eligibility filter (item type/value/magic) — Slice 6 territory but touches the browse UI (see D) |
|
|
| `ShopSystem::BuyPrice` / `::SellPrice` | `0x006B6120` / `0x006B6180` | The actual rounding formula (see B.1) |
|
|
| `gmVendorUI::Create` | `0x004C26A0` | Panel factory (`LayoutDesc`-driven `UIElement` construction) |
|
|
| `gmVendorUI::OpenVendor` | `0x004C4BA0` | Panel-level open handler — receives the already-unpacked profile+list |
|
|
| `gmVendorUI::CloseVendor` | `0x004C3020` | Panel-level close/reset |
|
|
| `gmVendorUI::ResetShopState` | `0x004C26D0` | Full shop-state reset (buy list, sell list, filters) |
|
|
| `gmVendorUI::OpenTab` | `0x004C0390` | Switches the Buy/Sell tab |
|
|
| `gmVendorUI::OnObjectRangeExit` | `0x004C02F0` | Client-side distance-close trigger (see A.3) |
|
|
| `VendorItemsUI::OpenVendor` / `::UpdateItemsList` / `::UpdateItemsUI` / `::AddTypeFilter` / `::ListContainsType` | `0x004C16D0` / `0x004C1EA0` / `0x004C38E0` / `0x004C05C0` / `0x004C0D90` | The item-list sub-panel: population, category-tab filtering |
|
|
| `VendorBuyUI::OpenVendor` / `::UpdateBuyUI` / `::UpdateTotalValue` / `::UpdateTransactionValue` | `0x004C49D0` / `0x004C0E10` / `0x004C33D0` / `0x004C3150` | Buy-side sub-panel (Slice 6, but shares the item list — see D) |
|
|
| `VendorSellUI::OpenVendor` / `::AddItemToSell` / `::DragItemAcceptable` | `0x004C2FB0` / ... | Sell-side sub-panel (Slice 6) |
|
|
| `CM_Vendor::SendNotice_OpenVendor` / `::SendNotice_CloseVendor` / `::SendNotice_AddItemToSell` | `0x0055F...`-family | Internal (non-network) client notice-bus fanout from the wire handler to the UI listeners |
|
|
| `CM_Vendor::Event_Buy` / `::Event_Sell` | `0x006AA0F0` / `0x006AA000` | Outbound buy/sell message builders (Slice 6) |
|
|
|
|
### B.1 — Panel open/close, list population, tabs, price display
|
|
|
|
`ClientUISystem::Handle_VendorInfo` (`pc:370610-370649`) is the routing
|
|
entry point: reads the vendor guid, unpacks `VendorProfile`, unpacks the
|
|
`PackableList<ItemProfile>`, then calls
|
|
`CM_Vendor::SendNotice_OpenVendor(vendorGuid, &profile, &itemList, mode)`
|
|
where `mode` is `2` (Open/Buy tab) or `3` (Sell tab), chosen by whether this
|
|
reply correlates to the client's own tracked `attemptOpenVendorID` (a
|
|
client-side request-correlation token, analogous to acdream's
|
|
`RuntimeInteractionTransactionState` request tracking — see C.1). If a
|
|
pending sell-item id (`attemptSaleObjectID`) is also armed, a second
|
|
internal notice (`SendNotice_AddItemToSell`) fires. **For Slice 5's browse
|
|
scope, the practical rule is: opening a vendor via ordinary Use always
|
|
yields mode 2 (Buy/browse tab), since `attemptOpenVendorID` is only armed by
|
|
a sell-drag action (Slice 6).**
|
|
|
|
`gmVendorUI::OpenVendor(vendorId, profile, itemList, mode)`
|
|
(`pc:203650-...`) is the panel controller reached via that notice:
|
|
|
|
1. If a *different* vendor is currently open, it force-closes the old one
|
|
first (`CloseVendor(false)`); reopening the *same* vendor id refreshes
|
|
in place (`CloseVendor(true)`, skipping sub-UI teardown).
|
|
2. Registers a distance watcher: `CPlayerSystem::RegisterObjectRangeHandler`
|
|
keyed to the vendor's own `UseRadius` (`pwd._useRadius`) — this is what
|
|
drives the client-local auto-close (A.3).
|
|
3. Copies the profile and item list into panel-owned storage
|
|
(`VendorProfile::operator=`, `PackableList<ItemProfile>::operator=`).
|
|
4. Materializes every list item as a full `CWeenieObject` in
|
|
`ClientObjMaintSystem` (already covered in A.2 point 4) — items you have
|
|
never seen a `CreateObject` for still get a real client object, since the
|
|
`PublicWeenieDesc` in the `ItemProfile` is sufficient to construct one.
|
|
5. Opens a tab by authored control id: **`UIElement_Panel::OpenTab(panel, 0x100000B9)`**
|
|
for mode 2 (Buy/browse) or **`0x100000BB`** for mode 3 (Sell) —
|
|
`pc:203791`/`203801`. These are concrete DAT UI-element ids baked into
|
|
the executable; they are strong candidates for the browse panel's own
|
|
tab-control ids once the panel's own `LayoutDesc` is dat-extracted (see
|
|
B.3 — the numeric neighborhood matches other already-confirmed vendor-
|
|
adjacent ids like the examination window's `0x100000B5/B6/71/72`).
|
|
|
|
**Category/type filtering** happens in `VendorItemsUI` — `AddTypeFilter`
|
|
(`0x004C05C0`) and `ListContainsType` (`0x004C0D90`) drive the tab-per-
|
|
item-type UI (weapons/armor/misc tabs seen in retail vendor windows), fed
|
|
by `UpdateItemsList`/`UpdateItemsUI` (`0x004C1EA0`/`0x004C38E0`). This is
|
|
squarely "supports retail selection/browsing" (in Slice 5's stated scope)
|
|
but has not been read in exhaustive detail here — flagged as an open
|
|
question (see bottom).
|
|
|
|
**Price display is computed client-side, not sent pre-computed.** Neither
|
|
`VendorProfile` nor `ItemProfile`/`PublicWeenieDesc` carries a "displayed
|
|
price" field — only the item's raw `Value` and the vendor's `buy_price`/
|
|
`sell_price` rates. The client computes the number shown in the list
|
|
itself:
|
|
|
|
- `VendorProfile::VendorSellPrice(profile, pwd, stackCount)` (`pc:484801-484813`,
|
|
`0x005D1B00`) — "price to buy this from the vendor" — calls
|
|
`ShopSystem::SellPrice(perUnitValue, itemType, sell_price, stackCount)`.
|
|
- `ShopSystem::SellPrice` (`pc:702107-702128`, `0x006B6180`):
|
|
`max(1, ceil(rate * value * stackCount - 0.1))`, where
|
|
`rate = 1.15` if `itemType == PromissoryNote` else `sell_price`.
|
|
- `ShopSystem::BuyPrice` (`pc:702082-702103`, `0x006B6120`):
|
|
`max(1, floor(rate * value * stackCount + 0.1))`, where
|
|
`rate = 1.0` if `PromissoryNote` else `buy_price`.
|
|
|
|
These are **byte-identical** to ACE's server-side `Vendor.GetSellCost`/
|
|
`GetBuyCost` (`Vendor.cs:573-599`:
|
|
`Math.Max(1, (uint)Math.Ceiling((sellRate * value) - 0.1))` and
|
|
`Math.Max(1, (int)Math.Floor((buyRate * value) + 0.1))`). Three-way
|
|
cross-check (retail decomp, ACE server formula, and the fact that ACE's
|
|
formula is what actually determines the transaction) with zero
|
|
disagreement. **Recommendation:** port `ShopSystem::BuyPrice`/`SellPrice`
|
|
as a small pure `Core` function (e.g. `VendorPricing.SellPrice`/`BuyPrice`)
|
|
so Slice 5's item-list rows can show the retail-correct number, with the
|
|
naming-inversion warning from A.2 called out in the doc comment.
|
|
|
|
### B.2 — How retail reacts to the approach-vendor event; teardown
|
|
|
|
Already covered in A.3/B.1: `ClientUISystem::Handle_VendorInfo` is the sole
|
|
routing entry (there is no separate "OpenVendor" opcode — everything comes
|
|
through the one `ApproachVendor`/`0x0062` GameEvent), it fans out via the
|
|
internal `CM_Vendor::SendNotice_OpenVendor` notice bus to
|
|
`gmVendorUI::RecvNotice_OpenVendor` (a listener registration, symbol present
|
|
in `symbols.json` but not read in detail here), and `gmVendorUI` is the
|
|
state object that owns `shopVendorID`, `shopVendorProfile`,
|
|
`shopItemProfileList`, and the sub-UI panels. Teardown is
|
|
`gmVendorUI::CloseVendor` (client-local, distance-triggered or
|
|
different-vendor-triggered — never server-pushed).
|
|
|
|
### B.3 — LayoutDesc/DAT identity
|
|
|
|
`docs/research/retail-ui/UI-DATAIDS.md:229-236` explicitly states the
|
|
vendor panel (along with Trade/Allegiance/Fellowship/Combat/Tooltip) is
|
|
**"confirmed to exist... but their per-panel sprite IDs live entirely
|
|
inside their LayoutDesc records... [and] will populate this section after
|
|
the first dat-extraction pass."** The vendor panel's own top-level
|
|
`LayoutDesc` id (the `0x210000xx`-range analog of the examination window's
|
|
`0x2100006B`) has **not yet been dat-extracted or recorded anywhere in the
|
|
repo.** Slice 5 will need to find it via the same import-and-search
|
|
mechanism Slice 3 used (see C.3), not a pre-existing citation.
|
|
|
|
What **is** already a concrete, retail-sourced constant: the two tab
|
|
control ids opened by `gmVendorUI::OpenVendor` — `0x100000B9` (Buy/browse
|
|
tab) and `0x100000BB` (Sell tab) — both baked directly into the executable
|
|
at `pc:203791`/`203801`. These sit in the same numeric neighborhood as
|
|
other already-confirmed vendor-adjacent/list-UI ids:
|
|
`AppraisalUiController`'s scrollbar/list ids
|
|
(`0x100000B5` horizontal scrollbar, `0x100000B6` item list, `0x10000071`/
|
|
`0x10000072` decrement/increment buttons —
|
|
`docs/plans/2026-07-23-world-interaction-completion.md:286-289`). This is
|
|
circumstantial but suggestive: Turbine's UI designers assigned these ids in
|
|
a contiguous block, so the vendor panel's root/child ids likely live nearby
|
|
and should turn up quickly once `LayoutImporter` is pointed at a plausible
|
|
`client_local_English.dat` `LayoutDesc` range (the same brute-force/known-
|
|
neighbor search Slice 3 used to locate `0x2100006B`/`0x100005F2` — see the
|
|
git history / research notes for that discovery if a more exact recipe is
|
|
needed; it was not separately re-derived here since the concrete outcome
|
|
[`AppraisalUiController.LayoutId`/`RootId`] is what matters as the
|
|
precedent).
|
|
|
|
**Precedent citation for "how Slice 3 found its layout id":**
|
|
`AppraisalUiController` (`src/AcDream.App/UI/Layout/AppraisalUiController.cs:17-44`)
|
|
declares `LayoutId = 0x2100006Bu`, `RootId = 0x100005F2u`, and every other
|
|
authored control as `public const uint` fields resolved by
|
|
`layout.FindElement(id)` after `LayoutImporter.Import(dats, LayoutId,
|
|
RootId, ...)` succeeds. The wiring site
|
|
(`src/AcDream.App/UI/RetailUiRuntime.cs:970-1068`, `MountAppraisal()`) is
|
|
the exact template to replicate for a `MountVendor()` method (see C.3).
|
|
|
|
---
|
|
|
|
## C. Existing acdream seams (read-only — nothing here was modified)
|
|
|
|
### C.1 — The use/interaction path a vendor-open rides on
|
|
|
|
Confirmed: vendor-open is not a new interaction path — it rides the
|
|
**exact same** `ItemInteractionController` → `ItemInteractionPolicy.DecideUse`
|
|
→ `SendUse` → `_requestUse` reservation → J5.2's
|
|
`RuntimeInteractionTransactionState` strict-use-gate path already used by
|
|
Slice 4 (equipped-child picking) and every other useable object:
|
|
|
|
- `src/AcDream.App/UI/ItemInteractionController.cs:877-924`
|
|
(`ExecuteUseActions`, case `ItemPolicyActionKind.SendUse`) routes through
|
|
`_requestUse(action.ObjectId, reservation)` when a `requestUse` delegate
|
|
is supplied (it always is in production wiring), which is the J5.2
|
|
strict-use-gate reservation described in
|
|
`docs/research/2026-07-26-slice-j5-2-interaction-transactions.md`.
|
|
- **The vendor-specific hook already exists but is unwired**:
|
|
`ItemInteractionController` has a `Func<uint> _activeVendorId` parameter
|
|
(`ItemInteractionController.cs:48`, `85`, `120`) that defaults to
|
|
`() => 0u` and is **never given a real value anywhere in the codebase**
|
|
(`grep` for `activeVendorId`/`ActiveVendorId` across `src/` turns up only
|
|
the declaration and the one policy consumer). `ItemInteractionPolicy`
|
|
already uses it: `if (input.ActiveVendorId != 0 && source.ContainerId ==
|
|
input.ActiveVendorId) return Consumed();` (`ItemInteractionPolicy.cs:227-228`)
|
|
— i.e. "using" an item that's inside the currently-open vendor's shop
|
|
(browsing/clicking a shop-list row) is swallowed as a no-op rather than
|
|
sent as an ordinary Use, matching retail's `UseObject` short-circuit for
|
|
items already inside an open shop window. **This is the concrete branch
|
|
point question C.1 asked about**: Slice 5 needs to (a) build the vendor
|
|
state owner, (b) wire its "currently open vendor guid" into
|
|
`activeVendorId` at the `ItemInteractionController` construction site
|
|
(currently defaulted everywhere), closing a gap that has existed since
|
|
before this research.
|
|
- `PublicWeenieFlags.Vendor = 0x00000200` (`ItemInteractionPolicy.cs:21`)
|
|
is already ported and already used for the drag-to-sell branch
|
|
(`SellToVendor` action, `DecidePlacement`,
|
|
`ItemInteractionPolicy.cs:357-363`) — that's Slice 6 territory (dragging
|
|
a player item onto the vendor NPC), but confirms the flag itself is
|
|
already correctly decoded from `PublicWeenieDesc` for any vendor NPC's
|
|
`CreateObject`.
|
|
|
|
### C.2 — Where vendor session state should live (Runtime ownership)
|
|
|
|
**Recommendation: a new `VendorState` Core class, owned as a new child of
|
|
`RuntimeInventoryState`, following the `ExternalContainerState` shape.**
|
|
|
|
Reasoning, grounded in the two closeout docs:
|
|
|
|
- `docs/research/2026-07-26-slice-j4-2-inventory-state.md`: `RuntimeInventoryState`
|
|
(`src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs`) is the
|
|
"single presentation-independent owner for the live external-container
|
|
state, item-mana state, shortcut assignments, desired spell-component
|
|
counts, and the retail one-inventory-request-at-a-time transaction gate."
|
|
It borrows J3's `ClientObjectTable`
|
|
(`RuntimeInventoryState.cs:54`, `Objects => _entityObjects.Objects`) and
|
|
exposes each child as a property (`ExternalContainers`, `ItemMana`,
|
|
`Shortcuts`, `Transactions`) plus a `RuntimeInventoryOwnershipSnapshot`
|
|
used for reset/teardown convergence checks
|
|
(`RuntimeInventoryState.cs:6-31`, `62-74`) and a `Dispose()` that resets
|
|
every child in a fixed order (`137-161`). A `VendorState` slots in exactly
|
|
the same way: a new property, a new line in
|
|
`RuntimeInventoryOwnershipSnapshot`, a new `Try(...)` call in `Dispose()`,
|
|
and (per A.3's "reset on portal/reconnect" requirement) a new
|
|
`ResetVendor()` method called from wherever `ResetExternalContainer()`/
|
|
`ResetTransactions()` are called today (session reset, portal-out,
|
|
disconnect — the generation/lifecycle contract every other J4/J5 child
|
|
follows).
|
|
- **Shape to copy**: `ExternalContainerState`
|
|
(`src/AcDream.Core/Items/ExternalContainerState.cs`) is the closest
|
|
existing analog — "an authoritative server-driven full-replace view keyed
|
|
by a requested/current id, with a `Changed` event for presentation
|
|
observers." A vendor session is conceptually identical: request-open
|
|
(fires on the Use action), apply-open (fires on `ApproachVendor`
|
|
matching the requested id), apply-close (client-local distance/switch
|
|
trigger, A.3), reset (portal/disconnect). The main structural
|
|
difference from `ExternalContainerState` is that a vendor session also
|
|
carries a **profile** (rates/currency/categories) and an **item list**,
|
|
not just a container id — so `VendorState` is a slightly richer sibling,
|
|
not a literal subclass.
|
|
- **Naming precedent**: holtburger's own full-client implementation
|
|
independently arrived at the exact name `VendorState`
|
|
(`references/holtburger/crates/holtburger-world/src/vendor.rs:98-108`,
|
|
fields: `vendor_guid`, `items: Vec<CoreVendorItem>`, `buy_multiplier`,
|
|
`sell_multiplier`, `merchandise_item_types`, `alternate_currency_wcid`,
|
|
`alternate_currency_amount`, `alternate_currency_name`) — a strong
|
|
independent signal that this is the natural shape/name, not an
|
|
acdream-specific invention.
|
|
- J5.2's `RuntimeInteractionTransactionState`
|
|
(`docs/research/2026-07-26-slice-j5-2-interaction-transactions.md`)
|
|
is the natural home for **request correlation** if Slice 5 wants to
|
|
faithfully port retail's `attemptOpenVendorID` token (B.1) — it already
|
|
owns "last ordinary or targeted-use source/target identity" and the
|
|
typed Activate/Use/Pickup FIFO. For Slice 5's browse-only scope this is
|
|
optional polish (the mode-2-vs-3 tab distinction only matters once
|
|
sell-drag exists in Slice 6); flagged as an open question below.
|
|
|
|
### C.3 — Authored-panel infrastructure (Slice 3's precedent, to replicate)
|
|
|
|
Complete, concrete template, read end-to-end:
|
|
|
|
1. **Controller** (`src/AcDream.App/UI/Layout/AppraisalUiController.cs`):
|
|
implements `IRetainedPanelController`
|
|
(`src/AcDream.App/UI/IRetainedPanelController.cs` — `OnShown`/`OnHidden`/
|
|
`OnDescendantFocusChanged`, all default no-ops; "the window manager owns
|
|
visibility, focus, capture, geometry, and teardown ordering"). Declares
|
|
every authored control id as a `public const uint` resolved later via
|
|
`layout.FindElement(id)`. Exposes a static `Bind(...)` factory that takes
|
|
the `ImportedLayout` plus every Core dependency the controller needs
|
|
(object table, interaction controller, selection state, etc.) and a
|
|
`show`/`close` action pair, returning `null` if a required authored
|
|
control is missing from the imported layout (defensive — the panel
|
|
simply doesn't mount rather than crash).
|
|
2. **Wiring site** (`src/AcDream.App/UI/RetailUiRuntime.cs:970-1068`,
|
|
`MountAppraisal()`):
|
|
- `LayoutImporter.Import(dats, LayoutId, RootId, resolveSprite,
|
|
defaultFont, resolveFont)` → `ImportedLayout?` (null if the
|
|
`LayoutDesc`/root id pair isn't found in the dats — logged and
|
|
bailed).
|
|
- Any per-panel asset factories (row templates, name resolvers) loaded
|
|
from the same dat lock.
|
|
- `XxxController.Bind(layout, ...dependencies..., show: () =>
|
|
Host.ShowWindow(WindowNames.X), close: () => CloseWindow(WindowNames.X),
|
|
...)`.
|
|
- `RetailWindowFrame.Mount(Host.Root, layout.Root, resolveSprite, new
|
|
RetailWindowFrame.Options { WindowName, Chrome =
|
|
RetailWindowChrome.Imported, Left/Top/ContentWidth/ContentHeight =
|
|
root's authored geometry, AuthoredGeometryRevision = 1, Visible =
|
|
false, ResizeX/Y, MinWidth/MinHeight, ConstrainDragToParent/Resize,
|
|
ContentClickThrough, Controller = controller })` → `RetailWindowHandle`.
|
|
This is what gives Slice 3's window its "independent movable/resizable
|
|
retail floaty" behavior and its foreground-stacking — all owned by
|
|
`RetailWindowFrame`/`Host`, not by the controller.
|
|
- Window name registered in `src/AcDream.App/UI/WindowNames.cs` (e.g.
|
|
`public const string Examination = "examination";` at line 24) —
|
|
Slice 5 needs a new `WindowNames.Vendor` (or similar) constant.
|
|
3. **Foreground stacking / show-close semantics**: `Host.ShowWindow(name)` /
|
|
`CloseWindow(name)` are the only calls the controller needs; the window
|
|
manager (`Host`) handles z-order, focus, and drag/resize bounds
|
|
uniformly across every retained window. Slice 5 does not need to
|
|
reinvent any of this — it needs a `MountVendor()` sibling to
|
|
`MountAppraisal()`.
|
|
|
|
### C.4 — Inbound-message routing (where a new `0x0062` handler registers)
|
|
|
|
ACE `GameEvent`s enter through `WorldSession`'s decode path (`GameEvents`
|
|
property, `GameEventDispatcher`, `src/AcDream.Core.Net/WorldSession.cs:567`)
|
|
and get routed by `GameEventDispatcher.Dispatch(envelope)`
|
|
(`src/AcDream.Core.Net/Messages/GameEventDispatcher.cs:95-117`) to whatever
|
|
handler was registered for that `GameEventType`. The single central
|
|
registration point for every handler is
|
|
`src/AcDream.Core.Net/GameEventWiring.cs` — `GameEventWiring.WireAll(...)`
|
|
(a big static method, one `registrar.Register(GameEventType.X, e => {
|
|
parse; apply to state; })` call per opcode, grouped by domain with comment
|
|
banners `// ── Chat ──`, `// ── Combat ──`, `// ── Spells ──`, `// ──
|
|
Inventory ──`, `// ── Player ──`).
|
|
|
|
**Most recently added handler (the concrete pattern to copy):**
|
|
`GameEventType.HouseUpdateRestrictions` (`0x0248`) —
|
|
`GameEventWiring.cs:365-376`, added for AP-129 (Campaign P Slice P4,
|
|
2026-07-30):
|
|
|
|
```csharp
|
|
registrar.Register(GameEventType.HouseUpdateRestrictions, e =>
|
|
{
|
|
var p = GameEvents.ParseHouseUpdateRestrictions(e.Payload.Span);
|
|
if (p is null) return;
|
|
items.UpdateHouseRestrictions(p.Value.SenderId, p.Value.Restrictions);
|
|
});
|
|
```
|
|
|
|
A new `GameEventType.ApproachVendor` handler follows the same shape: parse
|
|
the payload with a new `VendorApproach.TryParse` (or similar,
|
|
`src/AcDream.Core.Net/Messages/`), and apply it to the new `VendorState`
|
|
owner (C.2). `GameEventWiring.WireAll` would need a new optional parameter
|
|
(`VendorState? vendor = null`) matching the existing pattern used for
|
|
`itemMana`, `friends`, `squelch`, `externalContainers`, etc.
|
|
(`GameEventWiring.cs:77-86`).
|
|
|
|
### C.5 — Icon/tooltip/item-row rendering reuse
|
|
|
|
`SpellbookWindowController` (Slice 1's spell list,
|
|
`src/AcDream.App/UI/Layout/SpellbookWindowController.cs`) is the concrete
|
|
precedent for a scrollable authored list with icons:
|
|
|
|
- `UiScrollbar` (`src/AcDream.App/UI/UiScrollbar.cs`) is a shared,
|
|
panel-agnostic primitive bound to an authored control id via
|
|
`layout.FindElement(scrollbarId) is UiScrollbar scrollbar`
|
|
(`SpellbookWindowController.cs:237`, `246`).
|
|
- Row icons come from a `Func<uint, uint> resolveXxxIcon` delegate injected
|
|
at construction (`resolveSpellIcon`, `resolveComponentIcon` —
|
|
`SpellbookWindowController.cs:53-54`, `89-90`), used as
|
|
`CatalogIconTexture = _resolveSpellIcon(spellId)`
|
|
(`SpellbookWindowController.cs:291`) against a `_rowStyle`
|
|
(`SpellbookRowStyle`) describing icon geometry (`IconLeft`/`Top`/
|
|
`Width`/`Height`, `SpellbookWindowController.cs:299-302`).
|
|
- Since retail materializes each vendor item as a full weenie object with a
|
|
real `PublicWeenieDesc` (icon dat id included — A.2 point 4), a vendor
|
|
item row can resolve its icon exactly the way the **inventory panel**
|
|
resolves any ordinary item's icon (by the item's own `IconId` field,
|
|
already parsed by the shared `CreateObject`/`PublicWeenieDesc` body
|
|
parser — see the note in the next paragraph), not by a spell-specific
|
|
resolver. This is a materially easier reuse case than the spell list,
|
|
since vendor items are ordinary items once parsed.
|
|
|
|
**One structural gap worth flagging as a contract decision, not just
|
|
reuse**: `src/AcDream.Core.Net/Messages/CreateObject.cs` (`TryParse`,
|
|
line 512 onward) parses the entire `WeenieHeader` body inline as one large
|
|
method (opcode → guid → model data → physics data → weenie-header fixed
|
|
prefix at `~line 781` → weenie-header optional tail at `~line 834` onward,
|
|
using the same bit flags as Chorizite's `PublicWeenieDesc.generated.cs`).
|
|
It is **not currently factored into a reusable standalone function** callable
|
|
from a new vendor-item parser. Since A.2 established that each `ItemProfile`
|
|
entry uses the *exact same* `PublicWeenieDesc` body (just without the
|
|
preceding model/physics data), the clean move is to extract the
|
|
weenie-header-body parsing logic (`~CreateObject.cs:781` to the end of
|
|
`TryParse`) into a shared static helper both `CreateObject.TryParse` and
|
|
the new vendor-item parser call, rather than re-deriving/duplicating ~300
|
|
lines of conditional-field parsing. This is flagged explicitly as an open
|
|
question for the contract (below) since it's a nontrivial refactor
|
|
decision, not a pure research finding.
|
|
|
|
---
|
|
|
|
## D. Out-of-scope fence — what Slice 6 owns
|
|
|
|
Per `docs/plans/2026-07-23-world-interaction-completion.md:56-57`:
|
|
|
|
> | 6 | Vendor transactions | server-authoritative buy/sell command and
|
|
> reconciliation owner |
|
|
>
|
|
> "Vendor buy/sell transactions, quantities, pending-state ownership, and
|
|
> authoritative inventory reconciliation complete the loop."
|
|
|
|
Concretely, Slice 6 (not Slice 5) owns:
|
|
|
|
- **Outbound buy/sell wire messages.** ACE: `HandleActionBuyItem`
|
|
(`Player_Commerce.cs:22-52`) / `HandleActionSellItem`
|
|
(`Player_Commerce.cs:126-226`), each taking a `List<ItemProfile>`
|
|
(quantities + guids). Retail: `CM_Vendor::Event_Buy` (`0x006AA0F0`) /
|
|
`::Event_Sell` (`0x006AA000`).
|
|
- **Quantity/stack-split selection UI** (`VendorBuyUI`/`VendorSellUI`
|
|
sub-panels, stack-slider interaction —
|
|
`gmVendorUI::RecvNotice_StackSliderChanged` in the symbol table).
|
|
- **Drag-to-sell** — dragging a player inventory item onto the vendor NPC
|
|
(`ItemInteractionPolicy.ItemPolicyActionKind.SellToVendor`,
|
|
`ItemInteractionPolicy.cs:357-363`, already ported but its dispatch/UI
|
|
consumption is Slice 6's to wire).
|
|
- **`VendorProfile::InqAcceptability`/`IsAcceptable`** (sell-eligibility
|
|
filtering by item type/value/magic — `pc:484768-484797`) — this is a
|
|
judgment call flagged below, since "does my inventory show which items
|
|
this vendor will buy" arguably touches Slice 5's "supports retail
|
|
selection/browsing" language too.
|
|
- **Currency/pyreal math validation**, `Vendor.GetSellCost`/`GetBuyCost`
|
|
**as an authoritative transaction input** (Slice 5 only needs the
|
|
*display* computation — see B.1 — not the transaction-time application).
|
|
- **Pending-sale correlation** (`attemptOpenVendorID`/`attemptSaleObjectID`,
|
|
the mode-2-vs-3 tab-switch nuance from B.1) — Slice 5's browse-only scope
|
|
can default to always opening the Buy/browse tab and defer full request
|
|
correlation to Slice 6, since the correlation token only matters once a
|
|
sell-drag exists.
|
|
- **`ApproachVendor` refreshes triggered by `FinalizeBuyTransaction`
|
|
(`Player_Commerce.cs:112`) and `ProcessItemsForPurchase` (`Vendor.cs:661`)**
|
|
— Slice 5's parser/state owner should be built to handle repeat
|
|
`ApproachVendor` events correctly (A.3, full-replace semantics), but the
|
|
actual *triggers* for those repeats are Slice 6 actions.
|
|
- **Authoritative inventory reconciliation** after a purchase/sale lands
|
|
(new items appearing in the player's pack, coins deducted/added) — this
|
|
is standard `InventoryPutObjInContainer`/`ViewContents`/property-update
|
|
plumbing already owned by existing Slice 4/J4.2 machinery, but *triggered*
|
|
by Slice 6's buy/sell actions.
|
|
|
|
---
|
|
|
|
## Open questions for the contract
|
|
|
|
1. **Does `VendorState` live in `AcDream.Core.Items` (next to
|
|
`ExternalContainerState`) or a new `AcDream.Core.Commerce`/`Vendor`
|
|
namespace?** Recommendation: `AcDream.Core.Items` — it's small, it
|
|
directly parallels `ExternalContainerState`, and it avoids a
|
|
near-empty new namespace for one class family. Weak preference; either
|
|
is defensible.
|
|
2. **Should the shared `PublicWeenieDesc`-body parser be extracted from
|
|
`CreateObject.TryParse` into a standalone reusable method before Slice 5
|
|
writes the vendor-item parser, or should Slice 5 duplicate/adapt a
|
|
subset first and defer the refactor?** Recommendation: extract first —
|
|
duplicating ~300 lines of conditional-flag parsing for the second time
|
|
this project has needed it is exactly the kind of drift the project's
|
|
"grep named → decompile → verify → port" discipline exists to prevent,
|
|
and a parser bug fixed in one copy but not the other is a classic
|
|
two-owners bug.
|
|
3. **Does Slice 5 port `ShopSystem::BuyPrice`/`SellPrice` as a pure Core
|
|
function now (needed to show a display price in the browse list), or is
|
|
showing raw item value acceptable for a browse-only slice with actual
|
|
price math deferred to Slice 6?** Recommendation: port it now — it's a
|
|
~10-line pure function with a byte-verified formula (B.1), and a vendor
|
|
list that shows the wrong price (or no price) is a visibly broken browse
|
|
experience even before Buy is wired up.
|
|
4. **Does Slice 5 wire the `attemptOpenVendorID`-style request-correlation
|
|
token (mode 2 vs 3 tab selection), or hardcode "always open on the
|
|
Buy/browse tab" and let Slice 6 add correlation when sell-drag lands?**
|
|
Recommendation: hardcode Buy/browse tab for Slice 5; the correlation
|
|
only has externally-visible effect once a sell-initiated open exists.
|
|
5. **Does "supports retail selection/browsing" (the Slice 5 charter
|
|
language) include showing which of the *player's own inventory* items
|
|
this vendor would accept (via `VendorProfile::InqAcceptability`), or is
|
|
that squarely Slice 6 since it's meaningless without the sell action
|
|
attached?** Recommendation: defer to Slice 6 — `InqAcceptability` has no
|
|
purpose without a sell UI to gate, and pulling it into Slice 5 would mean
|
|
touching the sell sub-panel's territory for a browse-only slice.
|
|
6. **Category/type filter tabs** (`VendorItemsUI::AddTypeFilter`/
|
|
`ListContainsType`) were located but not read in full decompiled detail
|
|
in this pass (time-boxed). If the Slice 5 contract wants exact retail
|
|
parity for tab filtering (vs. a single flat browsable list for a first
|
|
cut), a follow-up grep-and-read pass on `pc:` lines around
|
|
`0x004C05C0`/`0x004C0D90` is needed before implementation.
|
|
7. **The vendor panel's own top-level `LayoutDesc` id is not yet
|
|
dat-extracted anywhere in the repo.** The contract should budget time
|
|
for a `LayoutImporter.Import` discovery pass (the same process that
|
|
found `0x2100006B` for the examination window) rather than assuming the
|
|
id is already known. The two concrete tab-control ids (`0x100000B9`
|
|
Buy, `0x100000BB` Sell) are known and can serve as a cross-check once a
|
|
candidate root/LayoutDesc id is found (their `FindElement` should
|
|
succeed under the correct root).
|
|
8. **`docs/architecture/retail-divergence-register.md` row `AP-110`**
|
|
currently lists "vendor/trade/salvage/tinkering" together as absent
|
|
panels. Per the project's binding divergence-register rule, Slice 5's
|
|
landing commit must narrow AP-110 to drop "vendor" from the absent list
|
|
(or add a precise successor row describing what remains absent — e.g.
|
|
category filter tabs if #6 above is deferred) in the **same commit**
|
|
that lands vendor browsing.
|
|
|
|
---
|
|
|
|
## §B.4 — D0 tab-filter read, 5.4 (2026-08-09)
|
|
|
|
Mandatory pre-UI read for Slice 5.4 (contract decision 6), grepping
|
|
`VendorItemsUI::AddTypeFilter` (`0x004C05C0`) / `::ListContainsType`
|
|
(`0x004C0D90`) plus their caller `VendorItemsUI::OpenVendor` (`0x004C16D0`)
|
|
and the whole-panel `gmVendorUI::OpenVendor`/`PostInit`/constructor
|
|
(`0x004C4BA0`/`0x004C09A0`/`0x004C2470`). This also resolves contract
|
|
decision 7 (layout-id discovery) — the two findings are entangled since the
|
|
D0 read is what proves the layout structure below.
|
|
|
|
### Layout identity: LayoutDesc `0x21000012`, root `0x100000B7`
|
|
|
|
Found via brute-force enumeration of every `LayoutDesc` in
|
|
`client_local_English.dat` (`dats.GetAllIdsOfType<LayoutDesc>()`, 101
|
|
entries) for the one whose descendant tree contains BOTH known tab-control
|
|
ids `0x100000B9` and `0x100000BB` as siblings. Exactly one match. Cross-check
|
|
per contract decision 7: `UIElement::RegisterElementClass(0x10000017,
|
|
gmVendorUI::Create)` (`pc:202075`) — root `0x100000B7`'s own resolved `Type`
|
|
is **`0x10000017`**, the literal retail class id for `gmVendorUI` itself.
|
|
This is not circumstantial; it is the direct proof the root element found IS
|
|
gmVendorUI's own instantiated root.
|
|
|
|
Root geometry: `800x110` at design position `(0,500)` inside an `800x600`
|
|
canvas — a compact bottom-docked strip, the same shape family as
|
|
`ExternalContainerController`'s `0x21000008`/`0x10000063` (not a large
|
|
grid+icon browse window; AC's vendor UI is a horizontal single-row icon
|
|
strip, matching general retail recollection).
|
|
|
|
Method: a throwaway scanner tool (`tools/VendorLayoutScan/`, not part of the
|
|
shipped solution) using `DatCollection.GetAllIdsOfType<LayoutDesc>()` +
|
|
recursive `ElementDesc.Children` search, then
|
|
`LayoutImporter.ImportInfos(dats, layoutId, rootId)` (the SAME resolved
|
|
inheritance pipeline production uses) to dump full resolved `Type` +
|
|
`StateDesc.Properties` per element, including `DatStringResolver`-resolved
|
|
`StringInfo` labels (property `0x17`). This is what nailed the exact
|
|
tab→page mapping below with dat-authored-label proof, not inference.
|
|
|
|
### Tree shape (verified via the resolved-property dump, not just raw ElementDesc)
|
|
|
|
```
|
|
0x100000B7 gmVendorUI root (Type 0x10000017), 800x110 @ design (0,500)
|
|
├─ 0x100000D6 close/pushpin button (Type 1, icon-only, no label — plain X)
|
|
├─ 0x100000B8 m_vendorPanel (Type 0x8 = UIElement_Panel; PostInit binds
|
|
│ this via GetChildRecursive(this, 0x100000b8))
|
|
│ ├─ 0x100000B9 tab, label "Items" (order 1, x=0)
|
|
│ ├─ 0x100000BA tab, label "Buying" (order 2, x=92)
|
|
│ ├─ 0x100000BB tab, label "Selling" (order 3, x=184)
|
|
│ ├─ 0x100000BC page for tab B9 ("Items") == VendorItemsUI's content
|
|
│ │ ├─ 0x100000BD m_shopList (Type 0x10000031 UiItemList, 710x32 —
|
|
│ │ │ cell 32x32 from base 0x2100003D/0x10000339 ⇒ ~22-slot
|
|
│ │ │ single-row horizontal strip, matches
|
|
│ │ │ ExternalContainerController's list shape)
|
|
│ │ ├─ 0x100000BE scrollbar (Type 0xB, horizontal, 710x16)
|
|
│ │ ├─ 0x100000BF m_itemTypeMenu (Type 0x6 = UIElement_Menu, resolves
|
|
│ │ │ to acdream's UiMenu via DatWidgetFactory's `6 =>
|
|
│ │ │ new UiMenu()` — a FULLY IMPLEMENTED dropdown widget
|
|
│ │ │ already, Items/Selected/OnSelect/ButtonLabelProvider)
|
|
│ │ ├─ 0x100000C0 m_itemNameText
|
|
│ │ ├─ 0x100000C1 m_itemCostText
|
|
│ │ ├─ 0x100000C2 m_buyButton, label "Buy"
|
|
│ │ └─ 0x100000C3 m_addButton, label "Add to List"
|
|
│ ├─ 0x100000C4 page for tab BA ("Buying") == VendorBuyUI's content
|
|
│ │ ├─ 0x100000C5 m_buyShopList (staged-to-buy list, NOT the shop's
|
|
│ │ │ full stock — see below)
|
|
│ │ ├─ 0x100000C6 scrollbar
|
|
│ │ ├─ 0x100000C7/C8 m_buyListText / m_buyPurseText
|
|
│ │ ├─ 0x100000C9 m_buyItemButton, label "Buy Item"
|
|
│ │ ├─ 0x100000CA m_buyAllButton, label "Buy All"
|
|
│ │ └─ 0x100000CB/CC m_buyClearItemButton "Clear Item" /
|
|
│ │ m_buyClearListButton "Clear List"
|
|
│ └─ 0x100000CD page for tab BB ("Selling") == VendorSellUI's content
|
|
│ ├─ 0x100000CE m_sellShopList (staged-to-sell list)
|
|
│ ├─ 0x100000CF scrollbar
|
|
│ ├─ 0x100000D0/D1 m_sellListText / m_sellPurseText
|
|
│ ├─ 0x100000D2 m_sellItemButton, label "Sell Item"
|
|
│ ├─ 0x100000D3 m_sellAllButton, label "Sell All"
|
|
│ └─ 0x100000D4/D5 m_sellClearItemButton / m_sellClearListButton,
|
|
│ labels "Clear Item" / "Clear List"
|
|
└─ 0x1000008D title/backdrop (Type 0x8, ZLevel 100 — drawn behind)
|
|
```
|
|
|
|
Every id above is confirmed two ways: (1) `VendorItemsUI::VendorItemsUI`
|
|
(`pc:199612`)/`VendorBuyUI::VendorBuyUI` (`pc:199717`)/
|
|
`VendorSellUI::VendorSellUI` (`pc:199753`) bind these EXACT child ids via
|
|
`UIElement::GetChildRecursive`; (2) the resolved-property dump shows the
|
|
matching `0x17` StringInfo label on each button, so the id↔label pairing is
|
|
read directly off the dat, not inferred from ctor ordering alone.
|
|
|
|
### Tab↔page semantics — corrects the contract's 2-tab assumption
|
|
|
|
The contract (decision 4, written before this read) assumed two tabs:
|
|
"browse/Buy" (`0x100000B9`) and "Sell" (`0x100000BB`). The dat actually
|
|
authors **three** tabs, and the dat-resolved labels settle their meaning
|
|
precisely — this is NOT the Buy/Sell MODE switch the contract assumed, it is
|
|
Items/Buying/Selling, three independent staging views:
|
|
|
|
- **"Items" (`0x100000B9`)** — `VendorItemsUI`: the vendor's full stock,
|
|
filterable by category, each row clickable, with `Buy`/`Add to List`
|
|
buttons. **This is Slice 5.4's browse view** — the only page with useful
|
|
content before Slice 6 exists.
|
|
- **"Buying" (`0x100000BA`)** — `VendorBuyUI`: review/confirm panel for
|
|
`gmVendorUI.m_buyList`, a CLIENT-side staging list of items you've
|
|
chosen to buy from the Items tab (`m_buyItemButton`/`m_buyAllButton` on
|
|
the Items page add to it; this tab's own buttons commit/clear it). Empty
|
|
on a fresh open (`m_buyList` starts empty — confirmed by
|
|
`gmVendorUI::OpenVendor`'s unconditional `CloseVendor` on any previous
|
|
session before rebuilding). Slice 6 territory.
|
|
- **"Selling" (`0x100000BB`)** — `VendorSellUI`: the symmetric staging view
|
|
for items dragged from your own inventory onto the vendor
|
|
(`ItemInteractionPolicy.ItemPolicyActionKind.SellToVendor`, already
|
|
ported, Slice 6 wires its UI consumption). Matches contract decision 4's
|
|
"Sell tab... stays inert" almost exactly — its authored label is
|
|
"Selling," not "Sell," but the semantic match is exact.
|
|
|
|
`gmVendorUI::OpenVendor` (`pc:203650`) confirms **all three sub-panels
|
|
(`m_itemsUI`, `m_buyUI`, `m_sellUI`) refresh unconditionally on every
|
|
`ApproachVendor`** (`this->m_itemsUI->vtable->OpenVendor(...)`;
|
|
`m_sellUI->OpenVendor(...)`; `m_buyUI->OpenVendor(...)`, `pc:203852-203854`),
|
|
regardless of which tab ends up visually open — only the VISIBLE tab is
|
|
mode-dependent (`OpenTab(m_vendorPanel, 0x100000b9)` for mode 2,
|
|
`0x100000bb` for mode 3, `pc:203791`/`203801` — **mode 2 opens tab
|
|
`0x100000B9` ("Items"), confirming decision 4's "browse/Buy tab" language
|
|
was describing this exact tab**, just informally — its authored name is
|
|
"Items," not "Buy"). Slice 5.4 therefore: mounts all three tabs
|
|
(matching the authored layout and `RetailTabBinding`'s existing tri-state
|
|
pattern precedent, `SpellbookWindowController`'s Spell/Component tabs),
|
|
defaults to "Items" selected/visible, and leaves "Buying"/"Selling" as
|
|
present-but-unpopulated pages (no `VendorBuyUI`/`VendorSellUI` port this
|
|
slice — both are squarely Slice 6, matching the contract's "no buy/sell
|
|
actions" fence). This symmetric treatment — not just the contract's
|
|
originally-anticipated single "Sell" tab — gets the "comment the fence"
|
|
treatment for both non-default tabs.
|
|
|
|
### Category/type filter mechanism (the actual D0 answer)
|
|
|
|
`VendorItemsUI::AddTypeFilter(this, label, typeMask)` (`pc:199667`) does
|
|
**not** create a visible tab button — it calls
|
|
`UIElement_Menu::InsertTextItem(m_itemTypeMenu, label, m_numTypeFilters)`,
|
|
i.e. it inserts a row into the dropdown menu at `0x100000BF`, tagging the
|
|
new menu item with the type mask via a per-item property
|
|
(`BaseProperty::SetPropertyName(&prop, 0x10000039)` then storing `typeMask`
|
|
into it). "Category tabs" in the contract's phrasing means this dropdown,
|
|
not additional tab buttons.
|
|
|
|
`VendorItemsUI::OpenVendor` (`pc:200779-201025`) rebuilds the dropdown from
|
|
scratch on every open/refresh: flushes `m_shopList` and `m_itemTypeMenu`,
|
|
then walks an **ordered, hardcoded 18-entry table**, calling
|
|
`ListContainsType(shopItemProfileList, mask)` for each and only adding the
|
|
filter (`AddTypeFilter`) if the vendor's stock contains a matching item.
|
|
`ListContainsType` (`pc:200132-200159`) is a linear scan: `true` iff any
|
|
shop item's `InqType() & mask != 0`. Table, in authored order, with the
|
|
acdream `ItemType` composite that reproduces each literal retail mask bit
|
|
for bit (verified against `src/AcDream.Core/Items/ClientObject.cs:26-75` —
|
|
every bit accounted for, no gaps):
|
|
|
|
| # | Label | Retail mask | acdream `ItemType` |
|
|
|---|---|---|---|
|
|
| 1 | Armor | `0x2` | `Armor` |
|
|
| 2 | Books, Paper | `0x2000` | `Writable` |
|
|
| 3 | Clothing | `0x4` | `Clothing` |
|
|
| 4 | Containers | `0x200` | `Container` |
|
|
| 5 | Food | `0x20` | `Food` |
|
|
| 6 | Gems | `0x800` | `Gem` |
|
|
| 7 | Jewelry | `0x8` | `Jewelry` |
|
|
| 8 | Keys, Tools | `0x20004000` | `TinkeringTool \| Key` |
|
|
| 9 | Miscellaneous | `0x490` | `Useless \| Misc \| Creature` |
|
|
| 10 | Services | `0x100000` | `Service` |
|
|
| 11 | Spell Components | `0x1000` | `SpellComponents` |
|
|
| 12 | Trade Notes | `0x40000` | `PromissoryNote` |
|
|
| 13 | Weapons | `0x101` | `Weapon` (existing composite) |
|
|
| 14 | Mana Stones | `0x80000` | `ManaStone` |
|
|
| 15 | Magic Items | `0x8000` | `Caster` |
|
|
| 16 | Alchemical Items | `0x4800000` | `CraftAlchemyIntermediate \| CraftAlchemyBase` |
|
|
| 17 | Cooking Items | `0x400000` | `CraftCookingBase` |
|
|
| 18 | Fletching Items | `0x9000000` | `CraftFletchingIntermediate \| CraftFletchingBase` |
|
|
|
|
After rebuilding, retail selects an index (`pc:201008-201022`): the
|
|
PREVIOUS selected index if it's still `< newCount - 1` inclusive-clamp,
|
|
otherwise clamps to `newCount - 1`, then floors at `0`. Net effect: first
|
|
open (`GetSelectedIndex` returns `-1`, nothing selected yet) always lands on
|
|
index `0` — the FIRST present category in table order, not "show
|
|
everything." Pseudocode:
|
|
|
|
```
|
|
selected = previousSelectedIndex // -1 on first open
|
|
if (selected >= presentCount - 1) selected = presentCount - 1
|
|
if (selected < 0) selected = 0
|
|
```
|
|
|
|
`VendorItemsUI::UpdateItemsList(this, mask, notify)` (`pc:201029` on)
|
|
confirms the filter is load-bearing, not cosmetic: it re-walks the FULL
|
|
`shopItemProfileList` and inserts into `m_shopList` only items where
|
|
`(activeMask & itemType) != 0`, where `activeMask` is either the explicit
|
|
`mask` argument (`arg2 != 0`) or, when `arg2 == 0`, the CURRENTLY selected
|
|
menu item's stored `0x10000039` property. **There is no "all types" state**
|
|
— an empty/no selection filters everything out (`0 & anything == 0`), which
|
|
is exactly why retail always force-selects index 0 after rebuilding. Slice
|
|
5.4 ports this literally: the item list is always scoped to exactly one
|
|
category; clicking a different dropdown entry re-filters via the stored
|
|
mask on that entry, matching `UpdateItemsList`'s `arg2 != 0` explicit-mask
|
|
branch.
|
|
|
|
### Verdict: bounded, in scope, no fallback needed
|
|
|
|
The full mechanism is an 18-row static table + a linear membership test
|
|
reused per row + one dropdown-population pass + a selection-index clamp —
|
|
small and precedented (`UiMenu` is a complete pre-existing widget;
|
|
`SpellbookWindowController`'s `FilterButtons` array is the same "static
|
|
mask table drives filterable UI" shape, just buttons instead of a
|
|
dropdown). This does **not** trigger the "mechanism too large — STOP"
|
|
clause; the flat-list fallback is not needed. The genuinely new information
|
|
this read surfaced beyond the contract's decisions — three tabs instead of
|
|
two, and the dropdown-vs-tab distinction for "category tabs" — is recorded
|
|
above for the record, not treated as a scope escalation.
|