docs(D.2b): widget-generalization design (Plan 2 widget piece)

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-16 16:26:32 +02:00
parent fed838847b
commit b7f7e2b4ef

View file

@ -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<object>`, `Selected`, `NaturalButtonWidth()` | populate 14 channel `Items`; map payload↔`ChatChannelKind`; `AvailabilityProvider` |
| `UiText` | 12 | `UiChatView` | scrollable + selectable multi-color line list, clipboard, dat-font; `LinesProvider : Func<IReadOnlyList<(string,Vector4)>>`; 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 16.
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
16**; 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 16, 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 16 (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).