# D.2b — Widget generalization (LayoutDesc importer, Plan 2 widget piece) — design **Date:** 2026-06-16 **Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track) **Status:** design — approved scope ("full registry, vitals last & gated"), pending spec review **Predecessor:** the LayoutDesc importer, the vitals re-drive, and the chat-window re-drive (`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`, `docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md`, `docs/research/2026-06-15-layoutdesc-format.md`, `claude-memory/project_d2b_retail_ui.md`). **Opening context:** the "GENERALIZATION PASS — START-COLD CONTEXT" note in `claude-memory/project_d2b_retail_ui.md`. --- ## 1. Goal Refactor the hand-named chat widgets (`UiChatView` / `UiChatInput` / `UiChatScrollbar` / `UiChannelMenu`) and the inline Send/Max-Min `UiDatElement` click-wiring into **generic, Type-registered widgets** built by `DatWidgetFactory`, so that `ChatWindowController` (and, as the final gated step, `VitalsController`) collapses to a thin **find-widget-by-id → bind-data/behavior** controller — the acdream analogue of retail `gm*UI::PostInit`. **The code is modern. The behavior is retail.** This pass changes the *construction path* of widgets, not their on-screen behavior. The chat window must stay visually and behaviorally identical through every step except the final (gated) vitals rewire. ### 1.1 Why this is mostly already done The trace that opened this work (re-confirmed in this design session) established two facts that make the generalization a *registration* task, not a new mechanism: 1. **The importer's base-chain Type resolution is already retail-faithful.** `ElementReader.Merge` resolves a Type-0 placement element up its `BaseElement`/`BaseLayoutId` chain to the base's real registered Type (`ElementReader.cs:137-140`). Every chat/vitals element therefore already resolves to the retail class it would instantiate. 2. **Type 12 is `UIElement_Text` — a real behavioral class, not a "style prototype to skip."** Verified directly in the decomp: `UIElement::RegisterElementClass(0xc, UIElement_Text::Create)` (`docs/research/named-retail/acclient_2013_pseudo_c.txt:115655`). The `Type==12 → return null` rule in `DatWidgetFactory` is a *vitals-only Plan-1 expedient* (AP-37: skip the vitals number elements so they render via `UiMeter.Label`), **not** a structural truth. So the "wrinkle" feared in the start-cold note (Type-0/12 elements hiding their real widget type) **dissolves**: the resolved Type is already correct. The factory just needs to *register* generic widgets for those Types instead of skipping them or dropping to `UiDatElement`. ### 1.2 Why this matters beyond chat (the strategic purpose) Chat is the **proving ground**, not the destination. The payoff is that every future panel — **inventory, spell bar, vendor, character sheet, trade, skills** — becomes *assembled from dat data + a thin controller* instead of being hand-built from scratch. That is exactly how retail did it (`gm*UI::PostInit` everywhere on a shared `UIElement` toolkit), and it is the reason to do this pass carefully now. **What this pass gives all future windows (the foundation):** - The **generic widget toolkit** — `UiButton`, `UiField`, `UiScrollbar`, `UiText`, `UiMenu` — built automatically by `DatWidgetFactory` from the dat layout. - The **thin-controller pattern** — find-widget-by-id → bind-live-data — proven and cemented on chat. Inventory's controller, vendor's controller, etc. all take the same shape. **What those specific windows additionally need (out of scope here; cheap once the pattern exists):** - **A few more widget Types** — inventory/vendor lists want `UiListBox` (Type 5) and `UiPanel` (Type 8); item slots want **drag-drop**, which retail builds into `UIElement_Field` (the decomp shows `Field` has `CatchDroppedItem` / `MouseOverTop` drag-drop hooks — so drag-drop rides on the Field widget this pass already builds). Each gets *registered when that window needs it* — which is exactly why §3 bounds "full registry" to the Types chat+vitals use today rather than speculatively building all 14 retail classes. - **The window manager** — open/close/z-order/persist, drag-bars (Type 2), resize-grips (Type 9). This is the *other* half of Plan 2 — a sibling piece to this one — and lands alongside, because pop-up/stackable windows (inventory, vendor) need it. - **Per-domain data plumbing** — item icons, live container contents, vendor stock lists. Game-state work, separate from the UI toolkit. This pass is therefore the **reusable toolkit + assembly pattern** that makes those later windows mostly-free to build. It is the load-bearing first half of the road to inventory/vendor/spell-bar, not the whole road. --- ## 2. Retail reference (the registry + the PostInit pattern) ### 2.1 The Type → class registry (`UIElement::RegisterElementClass`) Confirmed verbatim from `acclient_2013_pseudo_c.txt` (line numbers cited): | Type | Retail class | Reg. line | | Type | Retail class | Reg. line | |---|---|---|---|---|---|---| | 1 | `UIElement_Button` | :125828 | | 9 | `UIElement_Resizebar` | :118938 | | 2 | `UIElement_Dragbar` | :119926 | | 0xb (11) | `UIElement_Scrollbar` | :124137 | | 3 | `UIElement_Field` (editable) | :126190 | | 0xc (12) | `UIElement_Text` | :115655 | | 5 | `UIElement_ListBox` | :121788 | | 0xd (13) | `UIElement_Viewport` | :119126 | | 6 | `UIElement_Menu` | :120163 | | 0xe (14) | `UIElement_Browser` | :118718 | | 7 | `UIElement_Meter` | :123316 | | 0x10/0x11 | `ColorPicker`/`GroupBox` | :118396/:118177 | | 8 | `UIElement_Panel` | :119820 | | — | **Type 0 and 4: NOT registered** | — | Type 0 has no class of its own — a Type-0 element is a placement/override that inherits its class from its base. That is exactly what `ElementReader.Merge` already does. > **Implementation correction (2026-06-16, settled during execution).** Two of > this design's registration assumptions changed once the empirical resolved > Types were in hand (Task 1): > 1. **The editable input `0x10000016` resolves to Type 12 (Text), not Type 3.** > So the input is **Variant B** — the factory builds it as a `UiText` > placeholder and `ChatWindowController` removes that and controller-places a > `UiField` at its rect. (Confirmed by the chat golden fixture.) > 2. **Type 3 is NOT registered → `UiField` in this pass.** In acdream's vitals > (`0x2100006C`) and chat (`0x21000006`) layouts, Type-3 dat elements are > sprite-bearing **chrome** (the 8-piece bevel corners/edges, e.g. vitals > `0x10000633` → sprite `0x060074C3`) and the transcript/input **container** > panels — NOT editable fields. Retail draws those as inert media-bearing > Fields, which our generic `UiDatElement` reproduces pixel-for-pixel and > without a spurious focus/edit affordance. Registering Type 3 → `UiField` > (which draws no dat sprite) would blank the vitals bevel. So the factory > switch registers **Button (1), Menu (6), Meter (7), Scrollbar (11), Text > (12)**; Type 3 stays on the `UiDatElement` fallback. `UiField` still ships > (the renamed editable widget) — it is just controller-placed, not > factory-wired. Register Type 3 → `UiField` only when a window carries a > factory-built editable Type-3 field (and `UiField` then grows a > background-media draw + an opt-in editable flag). Guarded by > `VitalsTree_ChromeCornerHasExpectedSprite` (asserts the corner stays a > `UiDatElement` drawing its sprite). ### 2.2 The `gm*UI::PostInit` binding pattern (the controller target) `gmVitalsUI::PostInit` (`acclient_2013_pseudo_c.txt:199170-199228`) and `gmMainChatUI::PostInit` (`:212585-212636`) do, per child widget: ``` UIElement* e = UIElement::GetChildRecursive(this, 0x100000e6); // find by id UIElement_Meter* m = e->vtable->DynamicCast(7); // cast to Type this->m_pHealthMeter = m; // store if (!m) { /* skip */ } // null-check ``` acdream analogue (already half-present in `ChatWindowController`): ```csharp var send = layout.FindElement(SendId) as UiButton; // GetChildRecursive + DynamicCast if (send is not null) send.OnClick = () => input.Submit(); // bind behavior ``` The faithful end-state is: **the factory builds every widget from the dat; the controller only finds-by-id and binds data/callbacks** — it never constructs a widget. ### 2.3 Empirically resolved Types of the chat elements (`LayoutDesc 0x21000006`) Traced against the live dat (HIGH confidence; base ids in parentheses): | Element | Resolves to | Retail class | Today | |---|---|---|---| | `0x10000014` channel menu | **6** (own Type 6, no base) | Menu | `UiDatElement` → controller replaces w/ `UiChannelMenu` | | `0x10000012` scrollbar track | **11** (base `0x10000367` in `0x2100003E`) | Scrollbar | `UiDatElement` → controller replaces w/ `UiChatScrollbar` | | `0x10000011` transcript | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatView` | | `0x10000016` input | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatInput` | | `0x10000019` send | **1** (base chain → `0x1000047F` Type 1) | Button | `UiDatElement` + `OnClick` | | `0x1000046F` max/min | **1** (base `0x1000047F` Type 1 in `0x21000040`) | Button | `UiDatElement` + `OnClick` | > **Plan-phase verification #1 (load-bearing):** the editable **input** > `0x10000016` traced to **Type 12 (Text)**, the same base as the read-only > transcript — surprising for an editable field (retail's editable text is > Field=3). Element ids are layout-*local*, so the decomp's `ChatInterface` > Field-id does **not** cross-map; re-dump `0x10000016`'s exact resolved Type and > the `0x10000372` base prototype's Type before relying on it. The design is > robust either way — see §4.3(a). --- ## 3. Approved scope **Decision (this session):** *Full registry, chat-first, vitals rewire as the final, separately-committed, separately-gated step.* **In scope:** - Register generic widgets for the Types the chat + vitals windows actually use: **Button (1), Field (3), Menu (6), Scrollbar (11), Text (12)** — plus Meter (7) already done. - Delete the `Type==12 → return null` skip; Type 12 becomes `UiText`. - Collapse `ChatWindowController.Bind` to a find-by-id binder (no widget construction). - **Final gated step:** rewire `VitalsController` to bind generic `UiText` for the vitals numbers (retail-faithful: vitals numbers *are* `UIElement_Text`), retiring `UiMeter.Label` for vitals. **Explicitly NOT in scope ("full registry" is bounded to what these windows use):** - The long tail retail also registers — `Panel` (8), `Dragbar` (2), `Resizebar` (9), `ListBox` (5), `Viewport` (13), `Browser` (14), `ColorPicker` (16), `GroupBox` (17). Those elements **continue to render correctly as `UiDatElement`** (the universal fallback is non-negotiable). No `UIElement_ColorPicker` port for a window that has no color picker. When a future window needs one of these, it gets registered then. - No new chat *features* (tabs/squelch/name-tags/word-wrap remain as the chat re-drive deferred them — see that spec's §2). - `UiMeter.Label` is **not deleted** — it stays for plugin/markup panels; vitals simply stops using it. --- ## 4. Design ### 4.1 `DatWidgetFactory` — the faithful Type switch `DatWidgetFactory.Create` grows from `{7 → UiMeter, _ → UiDatElement}` to: ```csharp UiElement e = info.Type switch { 1 => BuildButton(info, resolve, datFont), // UIElement_Button 3 => BuildField(info, resolve, datFont), // UIElement_Field (see §4.3a) 6 => BuildMenu(info, resolve, datFont), // UIElement_Menu 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter (unchanged) 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar 12 => BuildText(info, resolve, datFont), // UIElement_Text _ => new UiDatElement(info, resolve), // generic fallback (unchanged) }; ``` The rect/anchor/z-order propagation at the bottom of `Create` is unchanged. The `Type==12 && StateMedia.Count==0` skip is **removed** — but a *pure base prototype* (Type 12 with no own geometry that is only referenced via `BaseLayoutId`, never placed) must still not draw. In practice such prototypes are never top-level placed elements in `0x21000006`/`0x2100006C`; the importer only builds placed elements. **Plan-phase verification #2:** confirm no Type-12 prototype is double-built after the skip is removed (the chat/vitals golden fixtures catch this). Each `BuildX` extracts the widget's dat-derived data (sprite ids per state, label font) the same way `BuildMeter` extracts its 3-slice grandchild sprites. The controller binds providers/callbacks afterward. ### 4.2 The generic widgets Each generic widget extends `UiElement`, is constructed by the factory from `ElementInfo`, and exposes **data providers + callbacks** for the controller to bind. The chat-specific knowledge moves *out* of the widgets and *into* the controller (faithful: retail's `gmMainChatUI`, not `UIElement_Menu`, owns the talk-focus channel list). | Generic widget | Type | Derived from | Generic surface (dat-built + provider-bound) | Controller binds | |---|---|---|---|---| | `UiScrollbar` | 11 | `UiChatScrollbar` (already 100% generic) | track/thumb/cap/arrow sprite ids from dat; `Model : UiScrollable` | `Model = transcript.Scroll` | | `UiButton` | 1 | `UiDatElement`+`OnClick` | state sprites (Normal/Pressed/Disabled), `Label`, `LabelFont`, `LabelColor`, `OnClick`, `NaturalWidth()` autosize | `OnClick`, caption | | `UiMenu` | 6 | `UiChannelMenu` | popup toggle, 2-col layout, 8-piece bevel, row highlight; `Items : IReadOnlyList<(string label, bool enabled, object payload)>`, `OnSelect : Action`, `Selected`, `NaturalButtonWidth()` | populate 14 channel `Items`; map payload↔`ChatChannelKind`; `AvailabilityProvider` | | `UiText` | 12 | `UiChatView` | scrollable + selectable multi-color line list, clipboard, dat-font; `LinesProvider : Func>`; shares `UiScrollable` (`Scroll`) | `LinesProvider` → ChatVM + per-kind colors | | `UiField` | 3 | `UiChatInput` | editable one-line: caret/selection/clipboard/history/auto-repeat/focus-sprite-swap; `Text`, `OnSubmit`, `MaxCharacters` | `OnSubmit` → `ChatCommandRouter` | **Placement.** The generic widgets live in `src/AcDream.App/UI/` alongside `UiMeter` (toolkit widgets). The factory in `src/AcDream.App/UI/Layout/` references them. This matches the current split (`UiMeter` in `UI/`, `UiDatElement` in `UI/Layout/`). **Naming.** `UiX` mirrors retail `UIElement_X`. The old chat-prefixed names are removed (or kept as thin obsolete aliases only if needed mid-migration). ### 4.3 The two wrinkles **(a) The editable input (Type 12 vs Type 3).** Robust to either resolution: - If `0x10000016` resolves to **Type 3** → factory builds `UiField` directly; the controller only binds `OnSubmit`. - If it resolves to **Type 12** → the dat element is a display Text in this layout; the controller *replaces* it with a controller-placed `UiField` at its rect (today's pattern for the track/menu). `UiField` exists as a registered generic widget regardless; only *who places it* differs. Editing behavior (caret/clipboard/history) is never purely dat-derivable, so the input is always provider-bound — the open question only affects whether the factory or the controller *instantiates* it. **(b) Vitals rewire — the final gated step.** Removing the Type-12 skip means the vitals number elements (Type-0 → base Type-12 Text) *could* build as real `UiText`. Today they are **meter children, consumed** (the importer does not recurse a meter's children — `LayoutImporter.cs:113`), rendered via `UiMeter.Label`. The faithful move: `VitalsController` constructs/binds a `UiText` for each number (matching retail `UIElement_Text` vitals numbers) and drops `UiMeter.Label` for vitals. This is **step 7 — the last commit, separately gated**, with its own fixture update and the user's visual sign-off, because vitals shipped pixel-identical and is fixture-locked (`vitals_2100006C.json`). If the rewire risks the pixel-identical result, we **stop and keep the meter-label path** for vitals — a smaller, documented divergence (AP-37 narrowed, not retired). The decision to land step 7 is the user's, made on the running client. ### 4.4 The thin controller (after step 6) `ChatWindowController.Bind` collapses to: for each known element id, `FindElement(id) as UiX`, null-check, bind data/callback. The reflow/maximize/resize-bar-drop logic (`ChatWindowController.cs:155-297`) stays — it is window-layout policy, not widget construction. The `BuildLines` / `WrapText` / `RetailChatColor` helpers stay (chat data shaping). What *leaves* the controller: the construction of `UiChatView`, `UiChatInput`, `UiChatScrollbar`, `UiChannelMenu` (now factory-built) — the controller binds them instead. --- ## 5. Migration sequence (one widget per commit; build + test green each step) Ordered least-risk → most-risk; the chat window is fully generalized before vitals is touched. Each step: `dotnet build` green, `dotnet test` (AcDream.App.Tests) green, its own commit naming the widget; the live chat window stays visually identical through steps 1–6. 1. **`UiScrollbar`** (Type 11) — promote `UiChatScrollbar` (already generic); register; factory builds it; controller binds `Model`. 2. **`UiButton`** (Type 1) — extract from `UiDatElement`+`OnClick`; register; Send + Max/Min build from the dat. 3. **`UiMenu`** (Type 6) — generalize `UiChannelMenu`; register; controller populates channel `Items` + maps payload↔`ChatChannelKind`. 4. **`UiText`** (Type 12) — generalize `UiChatView`; register; **delete the Type-12 skip**; controller binds transcript lines. Guard: verify vitals still renders (its numbers are meter-consumed → no auto-double-draw) via the vitals fixture + a live launch. 5. **`UiField`** (Type 3) — generalize `UiChatInput`; register; wire the input per §4.3(a) (verification #1 resolves factory-built vs controller-placed). 6. **Thin the controller** — collapse `ChatWindowController.Bind` to pure find-by-id binding now that the factory builds everything. 7. **Vitals rewire (gated)** — `VitalsController` binds `UiText` numbers; fixture update + the user's visual sign-off. **Stop-and-confirm gate.** --- ## 6. Testing & conformance - **Generic-widget unit tests** (pure, no GL/dat) — mostly *moved* from the existing chat-widget tests, renamed to the generic widgets: caret↔pixel + history (`UiField`), thumb ratio / page-delta (`UiScrollbar` via `UiScrollable`), menu item-pick + availability (`UiMenu`), line wrap / selection / dat-font hit-test (`UiText`). - **Factory tests** — `DatWidgetFactoryTests` grows one assert per newly registered Type → correct widget class. - **New chat-layout golden fixture** `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` (peer of `vitals_2100006C.json`): the resolved chat tree — each element's id, rect, resolved Type, sprite ids — asserting the factory builds the right widget per element. This locks the generalization. - **Vitals fixture `vitals_2100006C.json` stays green, untouched, through steps 1–6**; updated only at step 7, with visual sign-off. - **Visual acceptance** — the user launches `ACDREAM_RETAIL_UI=1` and confirms the chat window is unchanged through steps 1–6, and the vitals window is unchanged after step 7. --- ## 7. Divergence-register impact - **AP-37** (`DatWidgetFactory.cs`/`LayoutImporter.cs`: Type-0 text skipped + meter- collapse + vitals numbers via `UiMeter.Label`): **amended as steps land** — the "standalone Type-0 text elements are skipped / a dedicated dat-text widget is Plan 2" clause is retired when `UiText` ships (step 4); the vitals-numbers-via- `UiMeter.Label` clause is retired at step 7, or **narrowed** (not retired) if step 7 is deferred. The meter-collapse clause (reuse `UiMeter` 3-slice vs porting `UIElement_Meter::DrawChildren` over nested dat elements) **remains** — this pass does not port `DrawChildren`. - **IA-15** (the importer *is* the retail-UI render path): unchanged; reinforced (more Types now data-driven). - **AP-41** (scrollbar thumb single stretched sprite): **re-check at step 1** — the controller already passes 3-slice cap ids (`ThumbTopSprite`/`ThumbBotSprite`); the row may be retire-able when `UiScrollbar` lands. - **New rows** only if a generic widget introduces a *new* approximation (e.g., a `UiMenu` item model simpler than retail's hierarchical popup chain in `UIElement_Menu::MakePopup`). Add the row in the same commit per register rule 1. --- ## 8. Acceptance criteria - [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets; `_` still falls back to `UiDatElement`. - [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built (golden fixtures green). - [ ] The four chat widgets are generic (no `ChatChannelKind` / chat-color / command-routing knowledge inside a widget); `ChatWindowController` only finds- by-id and binds. - [ ] Chat window is visually + behaviorally identical to the shipped version through steps 1–6 (user-confirmed). - [ ] New `chat_21000006.json` golden fixture + moved generic-widget unit tests; all green. - [ ] Vitals window unchanged after step 7 (user-confirmed), or step 7 deferred with AP-37 narrowed. - [ ] Every generic widget cites its retail `UIElement_X` class + reg. line in a code comment. - [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same commits. - [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands. --- ## 9. Open items for the plan phase 1. **Verification #1 (load-bearing):** re-dump `0x10000016` (input) + the `0x10000372` base prototype to confirm input resolved Type (3 vs 12) → decides factory-built vs controller-placed `UiField` (§4.3a). 2. **Verification #2:** confirm no Type-12 base prototype double-builds once the skip is removed (§4.1). 3. Confirm the `UiMenu` generic item model (`(label, enabled, payload)`) is enough for the 14 talk-focus channels without losing the greyed/available distinction the chat menu currently shows. 4. Decide whether to keep thin obsolete-aliases for the old chat widget names during migration or rename in-place (prefer in-place; the names are internal).