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>
54 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 = 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.HandleActionUseItemresolves the guid, optionally walks the player to it, then callsTryUseItem(item)→references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-241.TryUseItemcallsitem.OnActivate(this), which (for aVendor, whoseActivationResponseincludesUse) dispatches virtually toVendor.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 noClientCommand::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(retailPublicWeenieDesc::BitfieldIndex,acclient.h:6431, verified at binary0x005884B6/0x00588752) —src/AcDream.Core/Items/ItemInteractionPolicy.cs:21. A vendor NPC'sUseabilitymarks itIsUseable, soItemInteractionPolicy.DecideUsefalls into the plainSendUse+IncrementBusybranch (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):
- Packed
u32: low 24 bits = stack size (sign-extended;0xFFFFFF⇒-1⇒ unlimited supply), high 8 bits =pwdType(-1= newPublicWeenieDesc,1= legacyOldPublicWeenieDesc, always-1in practice). Matches ACE's writer exactly:Writer.Write(stackSize & 0xFFFFFF | -1 << 24)—GameEventApproachVendor.cs:58. u32item guid (iid) — read before the desc body, i.e. it is a distinct field from whatever the desc-body parser reads.- The desc body itself:
pwd->vtable->UnPack(arg2, arg3)— this is the samePublicWeenieDescunpack used by ordinaryCreateObject, minus model/physics data. ACE confirms:obj.SerializeGameDataOnly(writer)→SerializeCreateObject(writer, gamedataonly: true, ...)—references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs:39-58.SerializeCreateObjectwritesGuidfirst (matching step 2 above, sincegamedataonlystill writes the guid, just skipsSerializeModelData+SerializePhysicsData), then theweenieFlags/name/wcid/icon/itemType/objDescriptionFlagsfixed prefix and the same conditional tail fields (PluralName,ItemsCapacity,Value,Useability,StackSize,WielderId,ValidSlots, etc.) that everyCreateObjectcarries. Chorizite'sPublicWeenieDesc.generated.cs(Read/Write, lines 226-480) is the exact field-by-field cross-check and matchesWorldObject_Networking.cs:56-130bit-for-bit. - Retail's
gmVendorUI::OpenVendor(pc:203650,0x004C4BA0) then materializes each list item as a fullCWeenieObjectviaCFactory::MakeCWeenieObjectand registers it inClientObjMaintSystem/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'sClientObjectTableshould 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 inreferences/ACE/Source/ACE.Server/WorldObjects/(onlyHandleActionBuyItem/HandleActionSellItemexist —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:
- 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). - Registers a distance watcher:
CPlayerSystem::RegisterObjectRangeHandlerkeyed to the vendor's ownUseRadius(pwd._useRadius) — this is what drives the client-local auto-close (A.3). - Copies the profile and item list into panel-owned storage
(
VendorProfile::operator=,PackableList<ItemProfile>::operator=). - Materializes every list item as a full
CWeenieObjectinClientObjMaintSystem(already covered in A.2 point 4) — items you have never seen aCreateObjectfor still get a real client object, since thePublicWeenieDescin theItemProfileis sufficient to construct one. - Opens a tab by authored control id:
UIElement_Panel::OpenTab(panel, 0x100000B9)for mode 2 (Buy/browse) or0x100000BBfor 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 ownLayoutDescis dat-extracted (see B.3 — the numeric neighborhood matches other already-confirmed vendor- adjacent ids like the examination window's0x100000B5/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" — callsShopSystem::SellPrice(perUnitValue, itemType, sell_price, stackCount).ShopSystem::SellPrice(pc:702107-702128,0x006B6180):max(1, ceil(rate * value * stackCount - 0.1)), whererate = 1.15ifitemType == PromissoryNoteelsesell_price.ShopSystem::BuyPrice(pc:702082-702103,0x006B6120):max(1, floor(rate * value * stackCount + 0.1)), whererate = 1.0ifPromissoryNoteelsebuy_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, caseItemPolicyActionKind.SendUse) routes through_requestUse(action.ObjectId, reservation)when arequestUsedelegate is supplied (it always is in production wiring), which is the J5.2 strict-use-gate reservation described indocs/research/2026-07-26-slice-j5-2-interaction-transactions.md.- The vendor-specific hook already exists but is unwired:
ItemInteractionControllerhas aFunc<uint> _activeVendorIdparameter (ItemInteractionController.cs:48,85,120) that defaults to() => 0uand is never given a real value anywhere in the codebase (grepforactiveVendorId/ActiveVendorIdacrosssrc/turns up only the declaration and the one policy consumer).ItemInteractionPolicyalready 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'sUseObjectshort-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" intoactiveVendorIdat theItemInteractionControllerconstruction 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 (SellToVendoraction,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 fromPublicWeenieDescfor any vendor NPC'sCreateObject.
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'sClientObjectTable(RuntimeInventoryState.cs:54,Objects => _entityObjects.Objects) and exposes each child as a property (ExternalContainers,ItemMana,Shortcuts,Transactions) plus aRuntimeInventoryOwnershipSnapshotused for reset/teardown convergence checks (RuntimeInventoryState.cs:6-31,62-74) and aDispose()that resets every child in a fixed order (137-161). AVendorStateslots in exactly the same way: a new property, a new line inRuntimeInventoryOwnershipSnapshot, a newTry(...)call inDispose(), and (per A.3's "reset on portal/reconnect" requirement) a newResetVendor()method called from whereverResetExternalContainer()/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 aChangedevent for presentation observers." A vendor session is conceptually identical: request-open (fires on the Use action), apply-open (fires onApproachVendormatching the requested id), apply-close (client-local distance/switch trigger, A.3), reset (portal/disconnect). The main structural difference fromExternalContainerStateis that a vendor session also carries a profile (rates/currency/categories) and an item list, not just a container id — soVendorStateis 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'sattemptOpenVendorIDtoken (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:
- Controller (
src/AcDream.App/UI/Layout/AppraisalUiController.cs): implementsIRetainedPanelController(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 apublic const uintresolved later vialayout.FindElement(id). Exposes a staticBind(...)factory that takes theImportedLayoutplus every Core dependency the controller needs (object table, interaction controller, selection state, etc.) and ashow/closeaction pair, returningnullif a required authored control is missing from the imported layout (defensive — the panel simply doesn't mount rather than crash). - Wiring site (
src/AcDream.App/UI/RetailUiRuntime.cs:970-1068,MountAppraisal()):LayoutImporter.Import(dats, LayoutId, RootId, resolveSprite, defaultFont, resolveFont)→ImportedLayout?(null if theLayoutDesc/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 byRetailWindowFrame/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 newWindowNames.Vendor(or similar) constant.
- 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 aMountVendor()sibling toMountAppraisal().
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.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):
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 vialayout.FindElement(scrollbarId) is UiScrollbar scrollbar(SpellbookWindowController.cs:237,246).- Row icons come from a
Func<uint, uint> resolveXxxIcondelegate injected at construction (resolveSpellIcon,resolveComponentIcon—SpellbookWindowController.cs:53-54,89-90), used asCatalogIconTexture = _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 ownIconIdfield, already parsed by the sharedCreateObject/PublicWeenieDescbody 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 aList<ItemProfile>(quantities + guids). Retail:CM_Vendor::Event_Buy(0x006AA0F0) /::Event_Sell(0x006AA000). - Quantity/stack-split selection UI (
VendorBuyUI/VendorSellUIsub-panels, stack-slider interaction —gmVendorUI::RecvNotice_StackSliderChangedin 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/GetBuyCostas 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. ApproachVendorrefreshes triggered byFinalizeBuyTransaction(Player_Commerce.cs:112) andProcessItemsForPurchase(Vendor.cs:661) — Slice 5's parser/state owner should be built to handle repeatApproachVendorevents 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
- Does
VendorStatelive inAcDream.Core.Items(next toExternalContainerState) or a newAcDream.Core.Commerce/Vendornamespace? Recommendation:AcDream.Core.Items— it's small, it directly parallelsExternalContainerState, and it avoids a near-empty new namespace for one class family. Weak preference; either is defensible. - Should the shared
PublicWeenieDesc-body parser be extracted fromCreateObject.TryParseinto 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. - Does Slice 5 port
ShopSystem::BuyPrice/SellPriceas 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. - 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. - 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 —InqAcceptabilityhas 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. - 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 onpc:lines around0x004C05C0/0x004C0D90is needed before implementation. - The vendor panel's own top-level
LayoutDescid is not yet dat-extracted anywhere in the repo. The contract should budget time for aLayoutImporter.Importdiscovery pass (the same process that found0x2100006Bfor the examination window) rather than assuming the id is already known. The two concrete tab-control ids (0x100000B9Buy,0x100000BBSell) are known and can serve as a cross-check once a candidate root/LayoutDesc id is found (theirFindElementshould succeed under the correct root). docs/architecture/retail-divergence-register.mdrowAP-110currently 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, withBuy/Add to Listbuttons. This is Slice 5.4's browse view — the only page with useful content before Slice 6 exists. - "Buying" (
0x100000BA) —VendorBuyUI: review/confirm panel forgmVendorUI.m_buyList, a CLIENT-side staging list of items you've chosen to buy from the Items tab (m_buyItemButton/m_buyAllButtonon the Items page add to it; this tab's own buttons commit/clear it). Empty on a fresh open (m_buyListstarts empty — confirmed bygmVendorUI::OpenVendor's unconditionalCloseVendoron 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.