From b7f7e2b4ef6ae872e3e6fdae0dbdce402ea11935 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 16:26:32 +0200 Subject: [PATCH] docs(D.2b): widget-generalization design (Plan 2 widget piece) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for refactoring the hand-named chat widgets + Send/MaxMin click-wiring into generic, Type-registered widgets built by DatWidgetFactory, collapsing ChatWindowController (and, gated-last, VitalsController) to a thin retail gm*UI::PostInit-style find-by-id binder. Key finding that reframes the pass: the importer's base-chain Type resolution is already retail-faithful, and Type 12 is UIElement_Text (a real behavioral class), not a style prototype to skip — verified against acclient_2013_pseudo_c.txt:115655. The generalization is therefore a registration task (register Types 1/3/6/11/12 -> generic widgets, delete the Type-12 skip), not a new mechanism. Approved scope: full registry (bounded to the Types chat+vitals use; rest stays UiDatElement fallback), chat-first, vitals rewire as the final separately-gated step. 7-step one-widget-per-commit migration; new chat_21000006.json golden fixture; vitals fixture stays frozen through steps 1-6. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-06-16-d2b-widget-generalization-design.md | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md diff --git a/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md new file mode 100644 index 00000000..ad4fb859 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md @@ -0,0 +1,351 @@ +# 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`. + +--- + +## 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. + +### 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).