From 763b127ad97a892af89cb7b93a530827e9c643ee Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 7 Aug 2026 14:31:33 +0200 Subject: [PATCH] =?UTF-8?q?docs(plan):=20Slice=205=20vendor-browse=20contr?= =?UTF-8?q?act=20=E2=80=94=20research=20doc=20+=20the=20eight=20decisions?= =?UTF-8?q?=20+=20ordered=205.0-5.5=20work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- ...2026-07-23-world-interaction-completion.md | 77 ++ ...026-08-08-slice5-vendor-browse-research.md | 703 ++++++++++++++++++ 2 files changed, 780 insertions(+) create mode 100644 docs/research/2026-08-08-slice5-vendor-browse-research.md diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 38cba297..d5fe8928 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -554,3 +554,80 @@ already published per frame to `EntityEffectPoseRegistry` (`PublishChildPose`, - Existing architectural divergence, unchanged by this slice: retail re-arms the pick every frame for hover/tooltips (`sr_MouseOver`); acdream picks on demand per click against the last published frame, with an identity recheck. + +## Slice 5 — vendor browse lifecycle (contract authored 2026-08-08) + +**Research foundation:** +`docs/research/2026-08-08-slice5-vendor-browse-research.md` (all wire, +retail-symbol, and seam citations live there — this contract only records +DECISIONS and ordered work). Browse only; every buy/sell/accept concern is +Slice 6 (see the research doc's §D fence). + +### Decisions on the research doc's eight open questions + +1. `VendorState` lives in `AcDream.Core.Items`, a sibling of + `ExternalContainerState`. +2. The shared `PublicWeenieDesc`-body parser IS extracted from + `CreateObject.TryParse` FIRST, as its own behavior-preserving commit + (5.0). Existing CreateObject wire tests must pass unchanged; the + extraction adds no parsing behavior. +3. `ShopSystem::BuyPrice`/`SellPrice` (0x006B6120/0x006B6180, + byte-identical to ACE's `GetBuyCost`/`GetSellCost`) are ported NOW as + pure Core functions with golden-value conformance tests — the browse + list shows retail-correct prices from day one. +4. No request-correlation token in Slice 5: the panel always opens on the + browse/Buy tab. Slice 6 adds the sell-initiated correlation. +5. `VendorProfile::InqAcceptability` (which player items the vendor would + accept) is deferred to Slice 6 with the sell UI it gates. +6. Category/type filter tabs are IN SCOPE for retail parity. The + implementer's D0 reads `VendorItemsUI::AddTypeFilter` / + `ListContainsType` (around 0x004C05C0/0x004C0D90) into a pseudocode + note before any UI work; if that read reveals a mechanism too large + for this slice, STOP and report (fallback — flat list + register row — + requires explicit approval, not implementer discretion). +7. The vendor panel's top-level LayoutDesc id is NOT yet known: the UI + piece budgets a LayoutImporter discovery pass (the exact process that + found the examination window's 0x2100006B), cross-checked by the two + known tab-control ids (0x100000B9 Buy / 0x100000BB Sell) resolving + under the candidate root. +8. AP-110 is narrowed in the SAME COMMIT that lands the panel: "vendor" + leaves the absent-panels list; whatever sub-scope remains absent after + this slice gets its own precise row. + +### Ordered work (each lands separately, bisectable) + +- **5.0** — extract the shared `PublicWeenieDesc`-body parser + (behavior-preserving; wire tests unchanged; no vendor code). +- **5.1** — `ApproachVendor` (GameEvent 0x0062) inbound parser: + `VendorProfile` + the full-desc item list, against the research doc's + byte-verified field table; Core.Net tests with golden byte fixtures. +- **5.2** — `VendorState` in Core.Items + the BuyPrice/SellPrice pure + port + conformance tests. +- **5.3** — Runtime ownership: `RuntimeInventoryState` owns the vendor + session per the J4.2 pattern (generation-gated, torn down on + reset/portal/logout); the 0x0062 route opens it; close is CLIENT-LOCAL + (nothing sent on the wire) via the retail distance-watcher semantics; + `ItemInteractionController._activeVendorId` / + `ItemInteractionPolicy.ActiveVendorId` finally receive the real id. +- **5.4** — the authored vendor panel: layout-id discovery, LayoutDesc + import via the Slice-3 examination-window pattern (foreground stacking, + authored extent), browse list reusing Slice-1's retained list/scrollbar + + DAT icon resolution, category tabs per the D0 read, prices via 5.2. +- **5.5** — register narrowing (decision 8) rides the 5.4 landing commit. + +### Trap list (binding) + +Do not touch: the J5.2 strict use gate's semantics (the vendor open rides +the EXISTING use transaction — no second gate, per the J4.5 invariant); +`CreateObject.TryParse` behavior (5.0 is extraction only); anything in the +Slice 6 fence (no buy/sell wire, no currency mutation, no +InqAcceptability). New event handling follows the newest existing +GameEvent handler's registration pattern, not a bespoke route. + +### Gates + +Per landing: build + full suite green (clean-room before each landing +commit). Slice gate (user, connected, ~3 min): approach a Holtburg +vendor, use them, the authored panel opens on the browse tab with +retail-correct items/icons/prices; category tabs filter; walking out of +range closes the panel by itself; nothing is purchasable anywhere. diff --git a/docs/research/2026-08-08-slice5-vendor-browse-research.md b/docs/research/2026-08-08-slice5-vendor-browse-research.md new file mode 100644 index 00000000..37fbbda9 --- /dev/null +++ b/docs/research/2026-08-08-slice5-vendor-browse-research.md @@ -0,0 +1,703 @@ +# Slice 5 research — vendor browse lifecycle + +**Date:** 2026-08-08 +**Scope:** Slice 5 of `docs/plans/2026-07-23-world-interaction-completion.md` — +"Vendor use opens the authored vendor surface, publishes its inventory, and +supports retail selection/browsing." Buy/sell transactions, quantities, and +authoritative reconciliation are Slice 6 and explicitly fenced out below +(Section D). + +**Verified starting point:** repo HEAD at research time was `fa0c053e` +("docs(physics): #347 closed WITHOUT a code change..."), which is at/after +the required `fa0c053e` gate. This document makes no code changes; it is a +research foundation only. + +**Reference hierarchy used** (per `CLAUDE.md`): named-retail decomp +(`docs/research/named-retail/acclient_2013_pseudo_c.txt` + +`symbols.json`) is the top oracle for client behavior. ACE +(`references/ACE/`) is authoritative for what the server sends/validates. +holtburger (`references/holtburger/`) is authoritative for what a real +client actually sends and how a full client models the state. Chorizite.ACProtocol +(`references/Chorizite.ACProtocol/`) is a clean-room field-order cross-check. +Where two sources could plausibly disagree, this document says which one the +project's hierarchy prefers — in every case investigated here they agreed +byte-for-byte. + +--- + +## A. The wire (browse only) + +### A.1 — What the client sends to open a vendor + +There is **no vendor-specific open message**. Opening a vendor rides the +ordinary **`GameAction 0x36 — UseItem`** (`Use`) action — the exact same +opcode already used for every other "double-click to use" interaction +(containers, doors, levers, Slice 4's equipped-child picking, etc.). + +- ACE: `GameActionType.Use = 0x0036` — + `references/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:28`. + Handler: `GameActionUseItem.Handle` → + `session.Player.HandleActionUseItem(itemGuid)` — + `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionUseItem.cs:5-16`. + `HandleActionUseItem` resolves the guid, optionally walks the player to it, + then calls `TryUseItem(item)` → + `references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-241`. + `TryUseItem` calls `item.OnActivate(this)`, which (for a `Vendor`, whose + `ActivationResponse` includes `Use`) dispatches virtually to + `Vendor.ActOnUse` — + `references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:229-266`. +- holtburger: `AppAction::OpenShop { vendor }` → + `result.commands.push(ClientCommand::Use(vendor))` — + `references/holtburger/apps/holtburger-cli/src/pages/game/domains/trade_vendor.rs:40-49`. + Confirms a real client also just sends the ordinary Use command; there is + no `ClientCommand::OpenVendor`. +- acdream already sends this opcode today: + `InteractRequests.UseOpcode = 0x0036` — + `src/AcDream.Core.Net/Messages/InteractRequests.cs:29`. **No new outbound + message is needed for Slice 5.** +- The retail client's own use-classification policy already special-cases + vendors: `PublicWeenieFlags.Vendor = 0x00000200` (retail + `PublicWeenieDesc::BitfieldIndex`, `acclient.h:6431`, verified at binary + `0x005884B6`/`0x00588752`) — + `src/AcDream.Core/Items/ItemInteractionPolicy.cs:21`. A vendor NPC's + `Useability` marks it `IsUseable`, so `ItemInteractionPolicy.DecideUse` + falls into the plain `SendUse` + `IncrementBusy` branch + (`src/AcDream.Core/Items/ItemInteractionPolicy.cs:267-288`) — the exact + same path already exercised by every other useable NPC/object. + +### A.2 — What the server sends back: `ApproachVendor` (GameEvent `0x0062`) + +`GameEventType.ApproachVendor = 0x0062` — +`references/ACE/Source/ACE.Server/Network/GameEvent/GameEventType.cs:16`; +already declared in acdream at +`src/AcDream.Core.Net/Messages/GameEventType.cs:26` (currently unhandled — +`GameEventDispatcher` will count it in `UnhandledCounts` until Slice 5 wires +a handler). + +Full field-by-field wire layout, cross-verified across **three independent +sources** (ACE's writer, retail's `VendorProfile::UnPack`/`ItemProfile::UnPack` +decompiled reader, and Chorizite's generated reader/writer) with **zero +disagreement**: + +| # | Field | Type | ACE source | Retail source | Chorizite name | +|---|---|---|---|---|---| +| 1 | Vendor's own guid | `u32` | `GameEventApproachVendor.cs:14` | `Handle_VendorInfo` `edi = *(u32*)eax` (`pc:370614`) | `Vendor_VendorInfo.ObjectId` | +| 2 | `MerchandiseItemTypes` (categories the vendor buys) | `u32` (`ItemType`) | `:17` | `VendorProfile::UnPack` `item_types` (`pc:484940`) | `VendorProfile.Categories` | +| 3 | `MerchandiseMinValue` | `u32` | `:18` | `min_value` (`pc:484943`) | `VendorProfile.MinValue` | +| 4 | `MerchandiseMaxValue` | `u32` | `:19` | `max_value` (`pc:484946`) | `VendorProfile.MaxValue` | +| 5 | `DealMagicalItems` | `u32` (0/1) | `:21` `Convert.ToUInt32` | `magic` (`pc:484949`) | `VendorProfile.DealsMagic` (bool) | +| 6 | `BuyPrice` (rate applied when the vendor **buys from** the player — i.e. what the player receives when selling) | `float` | `:23` | `buy_price` (`pc:484952`) | `VendorProfile.BuyPrice` | +| 7 | `SellPrice` (rate applied when the vendor **sells to** the player — i.e. what the player pays when buying) | `float` | `:24` | `sell_price` (`pc:484955`) | `VendorProfile.SellPrice` | +| 8 | Alternate-currency wcid (`AlternateCurrency ?? 0`) | `u32` (dat id) | `:27` | `trade_id.id` (`pc:484960`) | `VendorProfile.CurrencyId` | +| 9 | Player's current holding of that currency + amount just spent (0 if pyreal vendor) | `u32` | `:37,44` | `trade_num` (`pc:484961`) | `VendorProfile.CurrencyAmount` | +| 10 | Alt-currency plural name (empty string if pyreal vendor) | `String16L` | `:40,45` | `trade_name` (`PStringBase::UnPack`, `pc:484963`) | `VendorProfile.CurrencyName` | +| 11 | Item count | `u32` (`vendor.DefaultItemsForSale.Count + UniqueItemsForSale.Count`) | `:50` | `PackableList::UnPack` count prefix (`pc:370625`) | `Vendor_VendorInfo.Items` (list length prefix) | +| 12..N | Per-item entries (see below) | — | `:52-61` | — | `List` | + +**Field-name naming trap (worth flagging loudly for the contract):** +retail's client-side `VendorProfile::VendorSellPrice(profile, pwd, stack)` +computes what **you pay to buy** an item — it uses the `sell_price` field +(field 7 above), i.e. "the price at which the vendor is selling." ACE's +server-side `Vendor.GetSellCost` (`Vendor.cs:577-585`) is the same +computation and uses `SellPrice` (same field). Conversely +`VendorBuyPrice`/`GetBuyCost` (what the vendor pays when **you sell to it**) +uses `buy_price`/`BuyPrice`. The names read backwards from an English-first +intuition ("BuyPrice" sounds like "price I pay to buy," but it's actually +"price the vendor pays when buying from you"). Any pricing helper in +acdream should carry an explicit doc comment quoting this inversion. + +**Per-item encoding** — confirmed to be the **full CreateObject-style +weenie description**, not a compact profile. Retail's `ItemProfile::UnPack` +(`pc:484668-484742`, symbol `0x005D1910`): + +1. Packed `u32`: low 24 bits = stack size (sign-extended; `0xFFFFFF` ⇒ `-1` + ⇒ unlimited supply), high 8 bits = `pwdType` (`-1` = new + `PublicWeenieDesc`, `1` = legacy `OldPublicWeenieDesc`, always `-1` in + practice). Matches ACE's writer exactly: + `Writer.Write(stackSize & 0xFFFFFF | -1 << 24)` — + `GameEventApproachVendor.cs:58`. +2. `u32` item guid (`iid`) — read **before** the desc body, i.e. it is a + distinct field from whatever the desc-body parser reads. +3. The desc body itself: `pwd->vtable->UnPack(arg2, arg3)` — this is the + **same `PublicWeenieDesc` unpack used by ordinary `CreateObject`**, minus + model/physics data. ACE confirms: `obj.SerializeGameDataOnly(writer)` → + `SerializeCreateObject(writer, gamedataonly: true, ...)` — + `references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs:39-58`. + `SerializeCreateObject` writes `Guid` first (matching step 2 above, since + `gamedataonly` still writes the guid, just skips `SerializeModelData` + + `SerializePhysicsData`), then the `weenieFlags`/name/wcid/icon/itemType/ + `objDescriptionFlags` fixed prefix and the same conditional tail fields + (`PluralName`, `ItemsCapacity`, `Value`, `Useability`, `StackSize`, + `WielderId`, `ValidSlots`, etc.) that every `CreateObject` carries. + Chorizite's `PublicWeenieDesc.generated.cs` (`Read`/`Write`, lines + 226-480) is the exact field-by-field cross-check and matches + `WorldObject_Networking.cs:56-130` bit-for-bit. +4. Retail's `gmVendorUI::OpenVendor` (`pc:203650`, `0x004C4BA0`) then + materializes **each list item as a full `CWeenieObject`** via + `CFactory::MakeCWeenieObject` and registers it in + `ClientObjMaintSystem`/`CObjectMaint` — the same object table every other + spawned entity lives in (`pc:203720-203748`). Vendor items are not a + separate lightweight vendor-item record in retail; they are ordinary + client objects with no spatial presence. **Recommendation:** acdream's + `ClientObjectTable` should host vendor items the same way (see C.2/C.5). + +`decode_vendor_item_supply` in holtburger confirms the sign-extension +handling for the packed stack-size field independently: +`references/holtburger/crates/holtburger-world/src/hydration.rs:33-40` +(`(packed << 8) as i32 >> 8`, negative ⇒ unlimited), with tests at +`hydration.rs:320-330`. + +### A.3 — How browsing stays current; what closes it server-side + +**The list is a full snapshot, not incrementally updated.** Each +`ApproachVendor` is a complete replace (whole `VendorProfile` + whole item +list); there is no delta/patch opcode for vendor contents in ACE or the +retail decomp. A fresh `ApproachVendor` arrives on: initial open +(`Vendor.ActOnUse` → `ApproachVendor(player, VendorType.Open)`, +`Vendor.cs:246-266`), after a successful buy +(`FinalizeBuyTransaction` → `vendor.ApproachVendor(this, VendorType.Buy, ...)`, +`Player_Commerce.cs:112`), and after a successful sell +(`ProcessItemsForPurchase` → `ApproachVendor(player, VendorType.Sell)`, +`Vendor.cs:661`). All three are Slice 6 triggers (buy/sell); Slice 5 only +needs to handle the initial-open case, but the parser/state owner should be +built expecting **replace semantics** (mirroring how +`ExternalContainerState`/`ViewContents` already model "authoritative full +replace," see C.2). + +**Server-side close is polling, not a push.** `Vendor.CheckClose` runs +every `closeInterval = 1.5f` seconds +(`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:322-367`) and, if +the last-interacting player has moved beyond `UseRadius`, calls +`EmoteManager.DoVendorEmote(VendorType.Close, ...)` — this is a **cosmetic +emote broadcast** (goodbye animation/chat text via +`EmoteManager.DoVendorEmote` → +`references/ACE/Source/ACE.Server/WorldObjects/Managers/EmoteManager.cs:1763-1770`), +**not a distinct wire opcode that tells the client to close its panel.** +There is no server→client "vendor closed" GameEvent. + +Retail's own client independently tracks distance and closes locally: on +open, `gmVendorUI::OpenVendor` registers a range watcher — +`CPlayerSystem::RegisterObjectRangeHandler(..., eax->id, eax->pwd._useRadius, ...)` +(`pc:203677`, `0x004C4C34`) — and `gmVendorUI::OnObjectRangeExit` +(`pc:199486`, `0x004C02F0`) calls `gmVendorUI::CloseVendor` when that +handler fires. `gmVendorUI::CloseVendor` (`pc:202080`, `0x004C3020`) clears +the buy/sell/items sub-UI, calls +`CPlayerSystem::UnregisterObjectRangeHandler`, and resets shop state. This +confirms **the client, not the server, owns the close decision** — retail's +client independently watches distance client-side (belt-and-suspenders with +ACE's server-side emote, which is purely cosmetic). + +Also: reopening the *same* vendor id while already open does **not** tear +down the sub-UIs — `gmVendorUI::OpenVendor` only calls `CloseVendor(true)` +(same-vendor flag) when `shopVendorID` was already set and matches, which +skips the sub-UI clear (`pc:203664-203668`). Reopening a *different* vendor +while one is open closes the old one first. This matters for the reset +lifecycle contract (C.2): "reset on any new `ApproachVendor` for a different +guid; refresh-in-place on a same-guid `ApproachVendor`." + +### A.4 — What the client sends when the panel closes + +**Nothing.** Closing the vendor panel is entirely client-local UI state with +no outbound wire message. Confirmed in two independent places: + +- holtburger: `AppAction::ClearVendor => { state.view.vendor = None; }` — + `references/holtburger/apps/holtburger-cli/src/pages/game/domains/trade_vendor.rs:95-98`. + No command is pushed to the outbound queue. +- ACE has no `HandleAction*CloseVendor*` handler anywhere in + `references/ACE/Source/ACE.Server/WorldObjects/` (only + `HandleActionBuyItem`/`HandleActionSellItem` exist — + `Player_Commerce.cs:22`, `126`). +- Retail's `gmVendorUI::CloseVendor` (`pc:202080`) is a pure UI-teardown + function; it does not build or send a message. + +There is one indirect trigger: holtburger clears the vendor view when a +*trade* (player-to-player) starts (`ClientViewEvent::TradeStateUpdated`, +`trade_vendor.rs:134-137`), which is a local UX choice (only one modal +commerce surface at a time), not a protocol requirement. + +--- + +## B. Retail client mechanism (named-retail decomp) + +685 lines in `acclient_2013_pseudo_c.txt` reference "Vendor." The full +symbol family (`docs/research/named-retail/symbols.json`), with addresses: + +| Symbol | Address | Role | +|---|---|---| +| `ClientUISystem::Handle_VendorInfo` | `0x00565D10` | Entry point for the inbound `ApproachVendor` message; unpacks `VendorProfile` + item list, then fans out via `CM_Vendor::SendNotice_OpenVendor` | +| `VendorProfile::UnPack` / `::VendorProfile` | `0x005D1D20` / `0x005D1BE0` | Wire unpack for the profile header (field table above) | +| `ItemProfile::UnPack` / `::Pack` | `0x005D1910` / `0x005D1890` | Wire unpack for each shop item | +| `VendorProfile::VendorSellPrice` / `::VendorBuyPrice` | `0x005D1B00` / `0x005D1B70` | Client-local price computation (see B.1) | +| `VendorProfile::InqAcceptability` / `::IsAcceptable` | `0x005D1A90` / `0x005D1B50` | Sell-eligibility filter (item type/value/magic) — Slice 6 territory but touches the browse UI (see D) | +| `ShopSystem::BuyPrice` / `::SellPrice` | `0x006B6120` / `0x006B6180` | The actual rounding formula (see B.1) | +| `gmVendorUI::Create` | `0x004C26A0` | Panel factory (`LayoutDesc`-driven `UIElement` construction) | +| `gmVendorUI::OpenVendor` | `0x004C4BA0` | Panel-level open handler — receives the already-unpacked profile+list | +| `gmVendorUI::CloseVendor` | `0x004C3020` | Panel-level close/reset | +| `gmVendorUI::ResetShopState` | `0x004C26D0` | Full shop-state reset (buy list, sell list, filters) | +| `gmVendorUI::OpenTab` | `0x004C0390` | Switches the Buy/Sell tab | +| `gmVendorUI::OnObjectRangeExit` | `0x004C02F0` | Client-side distance-close trigger (see A.3) | +| `VendorItemsUI::OpenVendor` / `::UpdateItemsList` / `::UpdateItemsUI` / `::AddTypeFilter` / `::ListContainsType` | `0x004C16D0` / `0x004C1EA0` / `0x004C38E0` / `0x004C05C0` / `0x004C0D90` | The item-list sub-panel: population, category-tab filtering | +| `VendorBuyUI::OpenVendor` / `::UpdateBuyUI` / `::UpdateTotalValue` / `::UpdateTransactionValue` | `0x004C49D0` / `0x004C0E10` / `0x004C33D0` / `0x004C3150` | Buy-side sub-panel (Slice 6, but shares the item list — see D) | +| `VendorSellUI::OpenVendor` / `::AddItemToSell` / `::DragItemAcceptable` | `0x004C2FB0` / ... | Sell-side sub-panel (Slice 6) | +| `CM_Vendor::SendNotice_OpenVendor` / `::SendNotice_CloseVendor` / `::SendNotice_AddItemToSell` | `0x0055F...`-family | Internal (non-network) client notice-bus fanout from the wire handler to the UI listeners | +| `CM_Vendor::Event_Buy` / `::Event_Sell` | `0x006AA0F0` / `0x006AA000` | Outbound buy/sell message builders (Slice 6) | + +### B.1 — Panel open/close, list population, tabs, price display + +`ClientUISystem::Handle_VendorInfo` (`pc:370610-370649`) is the routing +entry point: reads the vendor guid, unpacks `VendorProfile`, unpacks the +`PackableList`, 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::operator=`). +4. Materializes every list item as a full `CWeenieObject` in + `ClientObjMaintSystem` (already covered in A.2 point 4) — items you have + never seen a `CreateObject` for still get a real client object, since the + `PublicWeenieDesc` in the `ItemProfile` is sufficient to construct one. +5. Opens a tab by authored control id: **`UIElement_Panel::OpenTab(panel, 0x100000B9)`** + for mode 2 (Buy/browse) or **`0x100000BB`** for mode 3 (Sell) — + `pc:203791`/`203801`. These are concrete DAT UI-element ids baked into + the executable; they are strong candidates for the browse panel's own + tab-control ids once the panel's own `LayoutDesc` is dat-extracted (see + B.3 — the numeric neighborhood matches other already-confirmed vendor- + adjacent ids like the examination window's `0x100000B5/B6/71/72`). + +**Category/type filtering** happens in `VendorItemsUI` — `AddTypeFilter` +(`0x004C05C0`) and `ListContainsType` (`0x004C0D90`) drive the tab-per- +item-type UI (weapons/armor/misc tabs seen in retail vendor windows), fed +by `UpdateItemsList`/`UpdateItemsUI` (`0x004C1EA0`/`0x004C38E0`). This is +squarely "supports retail selection/browsing" (in Slice 5's stated scope) +but has not been read in exhaustive detail here — flagged as an open +question (see bottom). + +**Price display is computed client-side, not sent pre-computed.** Neither +`VendorProfile` nor `ItemProfile`/`PublicWeenieDesc` carries a "displayed +price" field — only the item's raw `Value` and the vendor's `buy_price`/ +`sell_price` rates. The client computes the number shown in the list +itself: + +- `VendorProfile::VendorSellPrice(profile, pwd, stackCount)` (`pc:484801-484813`, + `0x005D1B00`) — "price to buy this from the vendor" — calls + `ShopSystem::SellPrice(perUnitValue, itemType, sell_price, stackCount)`. +- `ShopSystem::SellPrice` (`pc:702107-702128`, `0x006B6180`): + `max(1, ceil(rate * value * stackCount - 0.1))`, where + `rate = 1.15` if `itemType == PromissoryNote` else `sell_price`. +- `ShopSystem::BuyPrice` (`pc:702082-702103`, `0x006B6120`): + `max(1, floor(rate * value * stackCount + 0.1))`, where + `rate = 1.0` if `PromissoryNote` else `buy_price`. + +These are **byte-identical** to ACE's server-side `Vendor.GetSellCost`/ +`GetBuyCost` (`Vendor.cs:573-599`: +`Math.Max(1, (uint)Math.Ceiling((sellRate * value) - 0.1))` and +`Math.Max(1, (int)Math.Floor((buyRate * value) + 0.1))`). Three-way +cross-check (retail decomp, ACE server formula, and the fact that ACE's +formula is what actually determines the transaction) with zero +disagreement. **Recommendation:** port `ShopSystem::BuyPrice`/`SellPrice` +as a small pure `Core` function (e.g. `VendorPricing.SellPrice`/`BuyPrice`) +so Slice 5's item-list rows can show the retail-correct number, with the +naming-inversion warning from A.2 called out in the doc comment. + +### B.2 — How retail reacts to the approach-vendor event; teardown + +Already covered in A.3/B.1: `ClientUISystem::Handle_VendorInfo` is the sole +routing entry (there is no separate "OpenVendor" opcode — everything comes +through the one `ApproachVendor`/`0x0062` GameEvent), it fans out via the +internal `CM_Vendor::SendNotice_OpenVendor` notice bus to +`gmVendorUI::RecvNotice_OpenVendor` (a listener registration, symbol present +in `symbols.json` but not read in detail here), and `gmVendorUI` is the +state object that owns `shopVendorID`, `shopVendorProfile`, +`shopItemProfileList`, and the sub-UI panels. Teardown is +`gmVendorUI::CloseVendor` (client-local, distance-triggered or +different-vendor-triggered — never server-pushed). + +### B.3 — LayoutDesc/DAT identity + +`docs/research/retail-ui/UI-DATAIDS.md:229-236` explicitly states the +vendor panel (along with Trade/Allegiance/Fellowship/Combat/Tooltip) is +**"confirmed to exist... but their per-panel sprite IDs live entirely +inside their LayoutDesc records... [and] will populate this section after +the first dat-extraction pass."** The vendor panel's own top-level +`LayoutDesc` id (the `0x210000xx`-range analog of the examination window's +`0x2100006B`) has **not yet been dat-extracted or recorded anywhere in the +repo.** Slice 5 will need to find it via the same import-and-search +mechanism Slice 3 used (see C.3), not a pre-existing citation. + +What **is** already a concrete, retail-sourced constant: the two tab +control ids opened by `gmVendorUI::OpenVendor` — `0x100000B9` (Buy/browse +tab) and `0x100000BB` (Sell tab) — both baked directly into the executable +at `pc:203791`/`203801`. These sit in the same numeric neighborhood as +other already-confirmed vendor-adjacent/list-UI ids: +`AppraisalUiController`'s scrollbar/list ids +(`0x100000B5` horizontal scrollbar, `0x100000B6` item list, `0x10000071`/ +`0x10000072` decrement/increment buttons — +`docs/plans/2026-07-23-world-interaction-completion.md:286-289`). This is +circumstantial but suggestive: Turbine's UI designers assigned these ids in +a contiguous block, so the vendor panel's root/child ids likely live nearby +and should turn up quickly once `LayoutImporter` is pointed at a plausible +`client_local_English.dat` `LayoutDesc` range (the same brute-force/known- +neighbor search Slice 3 used to locate `0x2100006B`/`0x100005F2` — see the +git history / research notes for that discovery if a more exact recipe is +needed; it was not separately re-derived here since the concrete outcome +[`AppraisalUiController.LayoutId`/`RootId`] is what matters as the +precedent). + +**Precedent citation for "how Slice 3 found its layout id":** +`AppraisalUiController` (`src/AcDream.App/UI/Layout/AppraisalUiController.cs:17-44`) +declares `LayoutId = 0x2100006Bu`, `RootId = 0x100005F2u`, and every other +authored control as `public const uint` fields resolved by +`layout.FindElement(id)` after `LayoutImporter.Import(dats, LayoutId, +RootId, ...)` succeeds. The wiring site +(`src/AcDream.App/UI/RetailUiRuntime.cs:970-1068`, `MountAppraisal()`) is +the exact template to replicate for a `MountVendor()` method (see C.3). + +--- + +## C. Existing acdream seams (read-only — nothing here was modified) + +### C.1 — The use/interaction path a vendor-open rides on + +Confirmed: vendor-open is not a new interaction path — it rides the +**exact same** `ItemInteractionController` → `ItemInteractionPolicy.DecideUse` +→ `SendUse` → `_requestUse` reservation → J5.2's +`RuntimeInteractionTransactionState` strict-use-gate path already used by +Slice 4 (equipped-child picking) and every other useable object: + +- `src/AcDream.App/UI/ItemInteractionController.cs:877-924` + (`ExecuteUseActions`, case `ItemPolicyActionKind.SendUse`) routes through + `_requestUse(action.ObjectId, reservation)` when a `requestUse` delegate + is supplied (it always is in production wiring), which is the J5.2 + strict-use-gate reservation described in + `docs/research/2026-07-26-slice-j5-2-interaction-transactions.md`. +- **The vendor-specific hook already exists but is unwired**: + `ItemInteractionController` has a `Func _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`, `buy_multiplier`, + `sell_multiplier`, `merchandise_item_types`, `alternate_currency_wcid`, + `alternate_currency_amount`, `alternate_currency_name`) — a strong + independent signal that this is the natural shape/name, not an + acdream-specific invention. +- J5.2's `RuntimeInteractionTransactionState` + (`docs/research/2026-07-26-slice-j5-2-interaction-transactions.md`) + is the natural home for **request correlation** if Slice 5 wants to + faithfully port retail's `attemptOpenVendorID` token (B.1) — it already + owns "last ordinary or targeted-use source/target identity" and the + typed Activate/Use/Pickup FIFO. For Slice 5's browse-only scope this is + optional polish (the mode-2-vs-3 tab distinction only matters once + sell-drag exists in Slice 6); flagged as an open question below. + +### C.3 — Authored-panel infrastructure (Slice 3's precedent, to replicate) + +Complete, concrete template, read end-to-end: + +1. **Controller** (`src/AcDream.App/UI/Layout/AppraisalUiController.cs`): + implements `IRetainedPanelController` + (`src/AcDream.App/UI/IRetainedPanelController.cs` — `OnShown`/`OnHidden`/ + `OnDescendantFocusChanged`, all default no-ops; "the window manager owns + visibility, focus, capture, geometry, and teardown ordering"). Declares + every authored control id as a `public const uint` resolved later via + `layout.FindElement(id)`. Exposes a static `Bind(...)` factory that takes + the `ImportedLayout` plus every Core dependency the controller needs + (object table, interaction controller, selection state, etc.) and a + `show`/`close` action pair, returning `null` if a required authored + control is missing from the imported layout (defensive — the panel + simply doesn't mount rather than crash). +2. **Wiring site** (`src/AcDream.App/UI/RetailUiRuntime.cs:970-1068`, + `MountAppraisal()`): + - `LayoutImporter.Import(dats, LayoutId, RootId, resolveSprite, + defaultFont, resolveFont)` → `ImportedLayout?` (null if the + `LayoutDesc`/root id pair isn't found in the dats — logged and + bailed). + - Any per-panel asset factories (row templates, name resolvers) loaded + from the same dat lock. + - `XxxController.Bind(layout, ...dependencies..., show: () => + Host.ShowWindow(WindowNames.X), close: () => CloseWindow(WindowNames.X), + ...)`. + - `RetailWindowFrame.Mount(Host.Root, layout.Root, resolveSprite, new + RetailWindowFrame.Options { WindowName, Chrome = + RetailWindowChrome.Imported, Left/Top/ContentWidth/ContentHeight = + root's authored geometry, AuthoredGeometryRevision = 1, Visible = + false, ResizeX/Y, MinWidth/MinHeight, ConstrainDragToParent/Resize, + ContentClickThrough, Controller = controller })` → `RetailWindowHandle`. + This is what gives Slice 3's window its "independent movable/resizable + retail floaty" behavior and its foreground-stacking — all owned by + `RetailWindowFrame`/`Host`, not by the controller. + - Window name registered in `src/AcDream.App/UI/WindowNames.cs` (e.g. + `public const string Examination = "examination";` at line 24) — + Slice 5 needs a new `WindowNames.Vendor` (or similar) constant. +3. **Foreground stacking / show-close semantics**: `Host.ShowWindow(name)` / + `CloseWindow(name)` are the only calls the controller needs; the window + manager (`Host`) handles z-order, focus, and drag/resize bounds + uniformly across every retained window. Slice 5 does not need to + reinvent any of this — it needs a `MountVendor()` sibling to + `MountAppraisal()`. + +### C.4 — Inbound-message routing (where a new `0x0062` handler registers) + +ACE `GameEvent`s enter through `WorldSession`'s decode path (`GameEvents` +property, `GameEventDispatcher`, `src/AcDream.Core.Net/WorldSession.cs:567`) +and get routed by `GameEventDispatcher.Dispatch(envelope)` +(`src/AcDream.Core.Net/Messages/GameEventDispatcher.cs:95-117`) to whatever +handler was registered for that `GameEventType`. The single central +registration point for every handler is +`src/AcDream.Core.Net/GameEventWiring.cs` — `GameEventWiring.WireAll(...)` +(a big static method, one `registrar.Register(GameEventType.X, e => { +parse; apply to state; })` call per opcode, grouped by domain with comment +banners `// ── Chat ──`, `// ── Combat ──`, `// ── Spells ──`, `// ── +Inventory ──`, `// ── Player ──`). + +**Most recently added handler (the concrete pattern to copy):** +`GameEventType.HouseUpdateRestrictions` (`0x0248`) — +`GameEventWiring.cs:365-376`, added for AP-129 (Campaign P Slice P4, +2026-07-30): + +```csharp +registrar.Register(GameEventType.HouseUpdateRestrictions, e => +{ + var p = GameEvents.ParseHouseUpdateRestrictions(e.Payload.Span); + if (p is null) return; + items.UpdateHouseRestrictions(p.Value.SenderId, p.Value.Restrictions); +}); +``` + +A new `GameEventType.ApproachVendor` handler follows the same shape: parse +the payload with a new `VendorApproach.TryParse` (or similar, +`src/AcDream.Core.Net/Messages/`), and apply it to the new `VendorState` +owner (C.2). `GameEventWiring.WireAll` would need a new optional parameter +(`VendorState? vendor = null`) matching the existing pattern used for +`itemMana`, `friends`, `squelch`, `externalContainers`, etc. +(`GameEventWiring.cs:77-86`). + +### C.5 — Icon/tooltip/item-row rendering reuse + +`SpellbookWindowController` (Slice 1's spell list, +`src/AcDream.App/UI/Layout/SpellbookWindowController.cs`) is the concrete +precedent for a scrollable authored list with icons: + +- `UiScrollbar` (`src/AcDream.App/UI/UiScrollbar.cs`) is a shared, + panel-agnostic primitive bound to an authored control id via + `layout.FindElement(scrollbarId) is UiScrollbar scrollbar` + (`SpellbookWindowController.cs:237`, `246`). +- Row icons come from a `Func resolveXxxIcon` delegate injected + at construction (`resolveSpellIcon`, `resolveComponentIcon` — + `SpellbookWindowController.cs:53-54`, `89-90`), used as + `CatalogIconTexture = _resolveSpellIcon(spellId)` + (`SpellbookWindowController.cs:291`) against a `_rowStyle` + (`SpellbookRowStyle`) describing icon geometry (`IconLeft`/`Top`/ + `Width`/`Height`, `SpellbookWindowController.cs:299-302`). +- Since retail materializes each vendor item as a full weenie object with a + real `PublicWeenieDesc` (icon dat id included — A.2 point 4), a vendor + item row can resolve its icon exactly the way the **inventory panel** + resolves any ordinary item's icon (by the item's own `IconId` field, + already parsed by the shared `CreateObject`/`PublicWeenieDesc` body + parser — see the note in the next paragraph), not by a spell-specific + resolver. This is a materially easier reuse case than the spell list, + since vendor items are ordinary items once parsed. + +**One structural gap worth flagging as a contract decision, not just +reuse**: `src/AcDream.Core.Net/Messages/CreateObject.cs` (`TryParse`, +line 512 onward) parses the entire `WeenieHeader` body inline as one large +method (opcode → guid → model data → physics data → weenie-header fixed +prefix at `~line 781` → weenie-header optional tail at `~line 834` onward, +using the same bit flags as Chorizite's `PublicWeenieDesc.generated.cs`). +It is **not currently factored into a reusable standalone function** callable +from a new vendor-item parser. Since A.2 established that each `ItemProfile` +entry uses the *exact same* `PublicWeenieDesc` body (just without the +preceding model/physics data), the clean move is to extract the +weenie-header-body parsing logic (`~CreateObject.cs:781` to the end of +`TryParse`) into a shared static helper both `CreateObject.TryParse` and +the new vendor-item parser call, rather than re-deriving/duplicating ~300 +lines of conditional-field parsing. This is flagged explicitly as an open +question for the contract (below) since it's a nontrivial refactor +decision, not a pure research finding. + +--- + +## D. Out-of-scope fence — what Slice 6 owns + +Per `docs/plans/2026-07-23-world-interaction-completion.md:56-57`: + +> | 6 | Vendor transactions | server-authoritative buy/sell command and +> reconciliation owner | +> +> "Vendor buy/sell transactions, quantities, pending-state ownership, and +> authoritative inventory reconciliation complete the loop." + +Concretely, Slice 6 (not Slice 5) owns: + +- **Outbound buy/sell wire messages.** ACE: `HandleActionBuyItem` + (`Player_Commerce.cs:22-52`) / `HandleActionSellItem` + (`Player_Commerce.cs:126-226`), each taking a `List` + (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.