feat(ui): Slice 5.4 — the authored vendor browse panel (LayoutDesc 0x21000012)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The vendor window is retail's own: LayoutDesc 0x21000012, root 0x100000B7, found by enumerating all 101 layouts for the one containing both known tab controls and clinched by the root's Type 0x10000017 — the literal UIElement::RegisterElementClass id for gmVendorUI (pc:202075). Discovery evidence and the D0 read live in the research doc's new §B.4. D0 corrected two assumptions: retail's category "tabs" are a UiMenu DROPDOWN fed by a hardcoded 18-row ordered category table (ported bit-for-bit against our ItemType enum; list always scoped to exactly one category, first-present wins, selection preserved across refresh per retail's clamp), and the layout authors THREE tabs — Items (browse, this slice), Buying and Selling (staged-transaction review, Slice 6) — decision 4's "browse/Buy tab" names the Items tab retail's mode-2 OpenTab opens. The non-default tabs render and switch pages but stay inert, fenced in comments. VendorUiController mounts Items: category dropdown, icon-cell item row with the retained scrollbar, per-unit retail pricing via VendorPricing.SellPrice (the vendor-stock path VendorProfile:: VendorSellPrice feeds), name/cost on selection. The panel is a pure projection of VendorState — opens on populate, closes on clear; the close button's VendorState.Close() is its only permitted mutation. Nothing on the wire. AP-110 narrowed (vendor leaves the absent-panels list); AP-161 files the precise Slice-6 remainder (Buying/Selling unwired, Buy/Add buttons, InqAcceptability). Twelve controller tests on a real-dat fixture. Clean-room complete solution: 11,323 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
609a2dfda0
commit
c721830e71
10 changed files with 17024 additions and 2 deletions
File diff suppressed because one or more lines are too long
|
|
@ -701,3 +701,220 @@ Concretely, Slice 6 (not Slice 5) owns:
|
|||
(or add a precise successor row describing what remains absent — e.g.
|
||||
category filter tabs if #6 above is deferred) in the **same commit**
|
||||
that lands vendor browsing.
|
||||
|
||||
---
|
||||
|
||||
## §B.4 — D0 tab-filter read, 5.4 (2026-08-09)
|
||||
|
||||
Mandatory pre-UI read for Slice 5.4 (contract decision 6), grepping
|
||||
`VendorItemsUI::AddTypeFilter` (`0x004C05C0`) / `::ListContainsType`
|
||||
(`0x004C0D90`) plus their caller `VendorItemsUI::OpenVendor` (`0x004C16D0`)
|
||||
and the whole-panel `gmVendorUI::OpenVendor`/`PostInit`/constructor
|
||||
(`0x004C4BA0`/`0x004C09A0`/`0x004C2470`). This also resolves contract
|
||||
decision 7 (layout-id discovery) — the two findings are entangled since the
|
||||
D0 read is what proves the layout structure below.
|
||||
|
||||
### Layout identity: LayoutDesc `0x21000012`, root `0x100000B7`
|
||||
|
||||
Found via brute-force enumeration of every `LayoutDesc` in
|
||||
`client_local_English.dat` (`dats.GetAllIdsOfType<LayoutDesc>()`, 101
|
||||
entries) for the one whose descendant tree contains BOTH known tab-control
|
||||
ids `0x100000B9` and `0x100000BB` as siblings. Exactly one match. Cross-check
|
||||
per contract decision 7: `UIElement::RegisterElementClass(0x10000017,
|
||||
gmVendorUI::Create)` (`pc:202075`) — root `0x100000B7`'s own resolved `Type`
|
||||
is **`0x10000017`**, the literal retail class id for `gmVendorUI` itself.
|
||||
This is not circumstantial; it is the direct proof the root element found IS
|
||||
gmVendorUI's own instantiated root.
|
||||
|
||||
Root geometry: `800x110` at design position `(0,500)` inside an `800x600`
|
||||
canvas — a compact bottom-docked strip, the same shape family as
|
||||
`ExternalContainerController`'s `0x21000008`/`0x10000063` (not a large
|
||||
grid+icon browse window; AC's vendor UI is a horizontal single-row icon
|
||||
strip, matching general retail recollection).
|
||||
|
||||
Method: a throwaway scanner tool (`tools/VendorLayoutScan/`, not part of the
|
||||
shipped solution) using `DatCollection.GetAllIdsOfType<LayoutDesc>()` +
|
||||
recursive `ElementDesc.Children` search, then
|
||||
`LayoutImporter.ImportInfos(dats, layoutId, rootId)` (the SAME resolved
|
||||
inheritance pipeline production uses) to dump full resolved `Type` +
|
||||
`StateDesc.Properties` per element, including `DatStringResolver`-resolved
|
||||
`StringInfo` labels (property `0x17`). This is what nailed the exact
|
||||
tab→page mapping below with dat-authored-label proof, not inference.
|
||||
|
||||
### Tree shape (verified via the resolved-property dump, not just raw ElementDesc)
|
||||
|
||||
```
|
||||
0x100000B7 gmVendorUI root (Type 0x10000017), 800x110 @ design (0,500)
|
||||
├─ 0x100000D6 close/pushpin button (Type 1, icon-only, no label — plain X)
|
||||
├─ 0x100000B8 m_vendorPanel (Type 0x8 = UIElement_Panel; PostInit binds
|
||||
│ this via GetChildRecursive(this, 0x100000b8))
|
||||
│ ├─ 0x100000B9 tab, label "Items" (order 1, x=0)
|
||||
│ ├─ 0x100000BA tab, label "Buying" (order 2, x=92)
|
||||
│ ├─ 0x100000BB tab, label "Selling" (order 3, x=184)
|
||||
│ ├─ 0x100000BC page for tab B9 ("Items") == VendorItemsUI's content
|
||||
│ │ ├─ 0x100000BD m_shopList (Type 0x10000031 UiItemList, 710x32 —
|
||||
│ │ │ cell 32x32 from base 0x2100003D/0x10000339 ⇒ ~22-slot
|
||||
│ │ │ single-row horizontal strip, matches
|
||||
│ │ │ ExternalContainerController's list shape)
|
||||
│ │ ├─ 0x100000BE scrollbar (Type 0xB, horizontal, 710x16)
|
||||
│ │ ├─ 0x100000BF m_itemTypeMenu (Type 0x6 = UIElement_Menu, resolves
|
||||
│ │ │ to acdream's UiMenu via DatWidgetFactory's `6 =>
|
||||
│ │ │ new UiMenu()` — a FULLY IMPLEMENTED dropdown widget
|
||||
│ │ │ already, Items/Selected/OnSelect/ButtonLabelProvider)
|
||||
│ │ ├─ 0x100000C0 m_itemNameText
|
||||
│ │ ├─ 0x100000C1 m_itemCostText
|
||||
│ │ ├─ 0x100000C2 m_buyButton, label "Buy"
|
||||
│ │ └─ 0x100000C3 m_addButton, label "Add to List"
|
||||
│ ├─ 0x100000C4 page for tab BA ("Buying") == VendorBuyUI's content
|
||||
│ │ ├─ 0x100000C5 m_buyShopList (staged-to-buy list, NOT the shop's
|
||||
│ │ │ full stock — see below)
|
||||
│ │ ├─ 0x100000C6 scrollbar
|
||||
│ │ ├─ 0x100000C7/C8 m_buyListText / m_buyPurseText
|
||||
│ │ ├─ 0x100000C9 m_buyItemButton, label "Buy Item"
|
||||
│ │ ├─ 0x100000CA m_buyAllButton, label "Buy All"
|
||||
│ │ └─ 0x100000CB/CC m_buyClearItemButton "Clear Item" /
|
||||
│ │ m_buyClearListButton "Clear List"
|
||||
│ └─ 0x100000CD page for tab BB ("Selling") == VendorSellUI's content
|
||||
│ ├─ 0x100000CE m_sellShopList (staged-to-sell list)
|
||||
│ ├─ 0x100000CF scrollbar
|
||||
│ ├─ 0x100000D0/D1 m_sellListText / m_sellPurseText
|
||||
│ ├─ 0x100000D2 m_sellItemButton, label "Sell Item"
|
||||
│ ├─ 0x100000D3 m_sellAllButton, label "Sell All"
|
||||
│ └─ 0x100000D4/D5 m_sellClearItemButton / m_sellClearListButton,
|
||||
│ labels "Clear Item" / "Clear List"
|
||||
└─ 0x1000008D title/backdrop (Type 0x8, ZLevel 100 — drawn behind)
|
||||
```
|
||||
|
||||
Every id above is confirmed two ways: (1) `VendorItemsUI::VendorItemsUI`
|
||||
(`pc:199612`)/`VendorBuyUI::VendorBuyUI` (`pc:199717`)/
|
||||
`VendorSellUI::VendorSellUI` (`pc:199753`) bind these EXACT child ids via
|
||||
`UIElement::GetChildRecursive`; (2) the resolved-property dump shows the
|
||||
matching `0x17` StringInfo label on each button, so the id↔label pairing is
|
||||
read directly off the dat, not inferred from ctor ordering alone.
|
||||
|
||||
### Tab↔page semantics — corrects the contract's 2-tab assumption
|
||||
|
||||
The contract (decision 4, written before this read) assumed two tabs:
|
||||
"browse/Buy" (`0x100000B9`) and "Sell" (`0x100000BB`). The dat actually
|
||||
authors **three** tabs, and the dat-resolved labels settle their meaning
|
||||
precisely — this is NOT the Buy/Sell MODE switch the contract assumed, it is
|
||||
Items/Buying/Selling, three independent staging views:
|
||||
|
||||
- **"Items" (`0x100000B9`)** — `VendorItemsUI`: the vendor's full stock,
|
||||
filterable by category, each row clickable, with `Buy`/`Add to List`
|
||||
buttons. **This is Slice 5.4's browse view** — the only page with useful
|
||||
content before Slice 6 exists.
|
||||
- **"Buying" (`0x100000BA`)** — `VendorBuyUI`: review/confirm panel for
|
||||
`gmVendorUI.m_buyList`, a CLIENT-side staging list of items you've
|
||||
chosen to buy from the Items tab (`m_buyItemButton`/`m_buyAllButton` on
|
||||
the Items page add to it; this tab's own buttons commit/clear it). Empty
|
||||
on a fresh open (`m_buyList` starts empty — confirmed by
|
||||
`gmVendorUI::OpenVendor`'s unconditional `CloseVendor` on any previous
|
||||
session before rebuilding). Slice 6 territory.
|
||||
- **"Selling" (`0x100000BB`)** — `VendorSellUI`: the symmetric staging view
|
||||
for items dragged from your own inventory onto the vendor
|
||||
(`ItemInteractionPolicy.ItemPolicyActionKind.SellToVendor`, already
|
||||
ported, Slice 6 wires its UI consumption). Matches contract decision 4's
|
||||
"Sell tab... stays inert" almost exactly — its authored label is
|
||||
"Selling," not "Sell," but the semantic match is exact.
|
||||
|
||||
`gmVendorUI::OpenVendor` (`pc:203650`) confirms **all three sub-panels
|
||||
(`m_itemsUI`, `m_buyUI`, `m_sellUI`) refresh unconditionally on every
|
||||
`ApproachVendor`** (`this->m_itemsUI->vtable->OpenVendor(...)`;
|
||||
`m_sellUI->OpenVendor(...)`; `m_buyUI->OpenVendor(...)`, `pc:203852-203854`),
|
||||
regardless of which tab ends up visually open — only the VISIBLE tab is
|
||||
mode-dependent (`OpenTab(m_vendorPanel, 0x100000b9)` for mode 2,
|
||||
`0x100000bb` for mode 3, `pc:203791`/`203801` — **mode 2 opens tab
|
||||
`0x100000B9` ("Items"), confirming decision 4's "browse/Buy tab" language
|
||||
was describing this exact tab**, just informally — its authored name is
|
||||
"Items," not "Buy"). Slice 5.4 therefore: mounts all three tabs
|
||||
(matching the authored layout and `RetailTabBinding`'s existing tri-state
|
||||
pattern precedent, `SpellbookWindowController`'s Spell/Component tabs),
|
||||
defaults to "Items" selected/visible, and leaves "Buying"/"Selling" as
|
||||
present-but-unpopulated pages (no `VendorBuyUI`/`VendorSellUI` port this
|
||||
slice — both are squarely Slice 6, matching the contract's "no buy/sell
|
||||
actions" fence). This symmetric treatment — not just the contract's
|
||||
originally-anticipated single "Sell" tab — gets the "comment the fence"
|
||||
treatment for both non-default tabs.
|
||||
|
||||
### Category/type filter mechanism (the actual D0 answer)
|
||||
|
||||
`VendorItemsUI::AddTypeFilter(this, label, typeMask)` (`pc:199667`) does
|
||||
**not** create a visible tab button — it calls
|
||||
`UIElement_Menu::InsertTextItem(m_itemTypeMenu, label, m_numTypeFilters)`,
|
||||
i.e. it inserts a row into the dropdown menu at `0x100000BF`, tagging the
|
||||
new menu item with the type mask via a per-item property
|
||||
(`BaseProperty::SetPropertyName(&prop, 0x10000039)` then storing `typeMask`
|
||||
into it). "Category tabs" in the contract's phrasing means this dropdown,
|
||||
not additional tab buttons.
|
||||
|
||||
`VendorItemsUI::OpenVendor` (`pc:200779-201025`) rebuilds the dropdown from
|
||||
scratch on every open/refresh: flushes `m_shopList` and `m_itemTypeMenu`,
|
||||
then walks an **ordered, hardcoded 18-entry table**, calling
|
||||
`ListContainsType(shopItemProfileList, mask)` for each and only adding the
|
||||
filter (`AddTypeFilter`) if the vendor's stock contains a matching item.
|
||||
`ListContainsType` (`pc:200132-200159`) is a linear scan: `true` iff any
|
||||
shop item's `InqType() & mask != 0`. Table, in authored order, with the
|
||||
acdream `ItemType` composite that reproduces each literal retail mask bit
|
||||
for bit (verified against `src/AcDream.Core/Items/ClientObject.cs:26-75` —
|
||||
every bit accounted for, no gaps):
|
||||
|
||||
| # | Label | Retail mask | acdream `ItemType` |
|
||||
|---|---|---|---|
|
||||
| 1 | Armor | `0x2` | `Armor` |
|
||||
| 2 | Books, Paper | `0x2000` | `Writable` |
|
||||
| 3 | Clothing | `0x4` | `Clothing` |
|
||||
| 4 | Containers | `0x200` | `Container` |
|
||||
| 5 | Food | `0x20` | `Food` |
|
||||
| 6 | Gems | `0x800` | `Gem` |
|
||||
| 7 | Jewelry | `0x8` | `Jewelry` |
|
||||
| 8 | Keys, Tools | `0x20004000` | `TinkeringTool \| Key` |
|
||||
| 9 | Miscellaneous | `0x490` | `Useless \| Misc \| Creature` |
|
||||
| 10 | Services | `0x100000` | `Service` |
|
||||
| 11 | Spell Components | `0x1000` | `SpellComponents` |
|
||||
| 12 | Trade Notes | `0x40000` | `PromissoryNote` |
|
||||
| 13 | Weapons | `0x101` | `Weapon` (existing composite) |
|
||||
| 14 | Mana Stones | `0x80000` | `ManaStone` |
|
||||
| 15 | Magic Items | `0x8000` | `Caster` |
|
||||
| 16 | Alchemical Items | `0x4800000` | `CraftAlchemyIntermediate \| CraftAlchemyBase` |
|
||||
| 17 | Cooking Items | `0x400000` | `CraftCookingBase` |
|
||||
| 18 | Fletching Items | `0x9000000` | `CraftFletchingIntermediate \| CraftFletchingBase` |
|
||||
|
||||
After rebuilding, retail selects an index (`pc:201008-201022`): the
|
||||
PREVIOUS selected index if it's still `< newCount - 1` inclusive-clamp,
|
||||
otherwise clamps to `newCount - 1`, then floors at `0`. Net effect: first
|
||||
open (`GetSelectedIndex` returns `-1`, nothing selected yet) always lands on
|
||||
index `0` — the FIRST present category in table order, not "show
|
||||
everything." Pseudocode:
|
||||
|
||||
```
|
||||
selected = previousSelectedIndex // -1 on first open
|
||||
if (selected >= presentCount - 1) selected = presentCount - 1
|
||||
if (selected < 0) selected = 0
|
||||
```
|
||||
|
||||
`VendorItemsUI::UpdateItemsList(this, mask, notify)` (`pc:201029` on)
|
||||
confirms the filter is load-bearing, not cosmetic: it re-walks the FULL
|
||||
`shopItemProfileList` and inserts into `m_shopList` only items where
|
||||
`(activeMask & itemType) != 0`, where `activeMask` is either the explicit
|
||||
`mask` argument (`arg2 != 0`) or, when `arg2 == 0`, the CURRENTLY selected
|
||||
menu item's stored `0x10000039` property. **There is no "all types" state**
|
||||
— an empty/no selection filters everything out (`0 & anything == 0`), which
|
||||
is exactly why retail always force-selects index 0 after rebuilding. Slice
|
||||
5.4 ports this literally: the item list is always scoped to exactly one
|
||||
category; clicking a different dropdown entry re-filters via the stored
|
||||
mask on that entry, matching `UpdateItemsList`'s `arg2 != 0` explicit-mask
|
||||
branch.
|
||||
|
||||
### Verdict: bounded, in scope, no fallback needed
|
||||
|
||||
The full mechanism is an 18-row static table + a linear membership test
|
||||
reused per row + one dropdown-population pass + a selection-index clamp —
|
||||
small and precedented (`UiMenu` is a complete pre-existing widget;
|
||||
`SpellbookWindowController`'s `FilterButtons` array is the same "static
|
||||
mask table drives filterable UI" shape, just buttons instead of a
|
||||
dropdown). This does **not** trigger the "mechanism too large — STOP"
|
||||
clause; the flat-list fallback is not needed. The genuinely new information
|
||||
this read surfaced beyond the contract's decisions — three tabs instead of
|
||||
two, and the dropdown-vs-tab distinction for "category tabs" — is recorded
|
||||
above for the record, not treated as a scope escalation.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue