acdream/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md
Erik 83076cdbb6 docs(D.2b): spec correction — input is Variant B, Type 3 not registered
Record the two execution-time corrections to the design's registration
assumptions: the editable input resolves to Type 12 (Variant B, controller-placed
UiField), and Type 3 is NOT factory-registered (acdream's Type-3 elements are
chrome/containers, kept on the UiDatElement fallback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:54:52 +02:00

22 KiB
Raw Blame History

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 toolkitUiButton, 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):

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:

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 OnSubmitChatCommandRouter

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 testsDatWidgetFactoryTests 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).