acdream/docs/research/2026-08-08-slice5-vendor-browse-research.md
2026-08-07 14:31:33 +02:00

42 KiB

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 = 0x0036references/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:28. Handler: GameActionUseItem.Handlesession.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.ActOnUsereferences/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 = 0x0036src/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 = 0x0062references/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.ActOnUseApproachVendor(player, VendorType.Open), Vendor.cs:246-266), after a successful buy (FinalizeBuyTransactionvendor.ApproachVendor(this, VendorType.Buy, ...), Player_Commerce.cs:112), and after a successful sell (ProcessItemsForPurchaseApproachVendor(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.DoVendorEmotereferences/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 VendorItemsUIAddTypeFilter (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::OpenVendor0x100000B9 (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 ItemInteractionControllerItemInteractionPolicy.DecideUseSendUse_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.csOnShown/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 GameEvents 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.csGameEventWiring.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):

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, resolveComponentIconSpellbookWindowController.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.