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>
22 KiB
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:
-
The importer's base-chain Type resolution is already retail-faithful.
ElementReader.Mergeresolves a Type-0 placement element up itsBaseElement/BaseLayoutIdchain 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. -
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). TheType==12 → return nullrule inDatWidgetFactoryis a vitals-only Plan-1 expedient (AP-37: skip the vitals number elements so they render viaUiMeter.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 byDatWidgetFactoryfrom 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) andUiPanel(Type 8); item slots want drag-drop, which retail builds intoUIElement_Field(the decomp showsFieldhasCatchDroppedItem/MouseOverTopdrag-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):
- The editable input
0x10000016resolves to Type 12 (Text), not Type 3. So the input is Variant B — the factory builds it as aUiTextplaceholder andChatWindowControllerremoves that and controller-places aUiFieldat its rect. (Confirmed by the chat golden fixture.)- Type 3 is NOT registered →
UiFieldin 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. vitals0x10000633→ sprite0x060074C3) and the transcript/input container panels — NOT editable fields. Retail draws those as inert media-bearing Fields, which our genericUiDatElementreproduces 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 theUiDatElementfallback.UiFieldstill ships (the renamed editable widget) — it is just controller-placed, not factory-wired. Register Type 3 →UiFieldonly when a window carries a factory-built editable Type-3 field (andUiFieldthen grows a background-media draw + an opt-in editable flag). Guarded byVitalsTree_ChromeCornerHasExpectedSprite(asserts the corner stays aUiDatElementdrawing 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
0x10000016traced 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'sChatInterfaceField-id does not cross-map; re-dump0x10000016's exact resolved Type and the0x10000372base 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 nullskip; Type 12 becomesUiText. - Collapse
ChatWindowController.Bindto a find-by-id binder (no widget construction). - Final gated step: rewire
VitalsControllerto bind genericUiTextfor the vitals numbers (retail-faithful: vitals numbers areUIElement_Text), retiringUiMeter.Labelfor 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 asUiDatElement(the universal fallback is non-negotiable). NoUIElement_ColorPickerport 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.Labelis 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 |
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
0x10000016resolves to Type 3 → factory buildsUiFielddirectly; the controller only bindsOnSubmit. - If it resolves to Type 12 → the dat element is a display Text in this
layout; the controller replaces it with a controller-placed
UiFieldat its rect (today's pattern for the track/menu).UiFieldexists 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.
UiScrollbar(Type 11) — promoteUiChatScrollbar(already generic); register; factory builds it; controller bindsModel.UiButton(Type 1) — extract fromUiDatElement+OnClick; register; Send + Max/Min build from the dat.UiMenu(Type 6) — generalizeUiChannelMenu; register; controller populates channelItems+ maps payload↔ChatChannelKind.UiText(Type 12) — generalizeUiChatView; 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.UiField(Type 3) — generalizeUiChatInput; register; wire the input per §4.3(a) (verification #1 resolves factory-built vs controller-placed).- Thin the controller — collapse
ChatWindowController.Bindto pure find-by-id binding now that the factory builds everything. - Vitals rewire (gated) —
VitalsControllerbindsUiTextnumbers; 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 (UiScrollbarviaUiScrollable), menu item-pick + availability (UiMenu), line wrap / selection / dat-font hit-test (UiText). - Factory tests —
DatWidgetFactoryTestsgrows 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 ofvitals_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.jsonstays green, untouched, through steps 1–6; updated only at step 7, with visual sign-off. - Visual acceptance — the user launches
ACDREAM_RETAIL_UI=1and 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 viaUiMeter.Label): amended as steps land — the "standalone Type-0 text elements are skipped / a dedicated dat-text widget is Plan 2" clause is retired whenUiTextships (step 4); the vitals-numbers-via-UiMeter.Labelclause is retired at step 7, or narrowed (not retired) if step 7 is deferred. The meter-collapse clause (reuseUiMeter3-slice vs portingUIElement_Meter::DrawChildrenover nested dat elements) remains — this pass does not portDrawChildren. - 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 whenUiScrollbarlands. - New rows only if a generic widget introduces a new approximation (e.g., a
UiMenuitem model simpler than retail's hierarchical popup chain inUIElement_Menu::MakePopup). Add the row in the same commit per register rule 1.
8. Acceptance criteria
DatWidgetFactoryregisters Types 1, 3, 6, 11, 12 (+ 7) → generic widgets;_still falls back toUiDatElement.- The
Type==12 → nullskip 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);ChatWindowControlleronly finds- by-id and binds. - Chat window is visually + behaviorally identical to the shipped version through steps 1–6 (user-confirmed).
- New
chat_21000006.jsongolden 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_Xclass + 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.mdupdated when the pass lands.
9. Open items for the plan phase
- Verification #1 (load-bearing): re-dump
0x10000016(input) + the0x10000372base prototype to confirm input resolved Type (3 vs 12) → decides factory-built vs controller-placedUiField(§4.3a). - Verification #2: confirm no Type-12 base prototype double-builds once the skip is removed (§4.1).
- Confirm the
UiMenugeneric item model ((label, enabled, payload)) is enough for the 14 talk-focus channels without losing the greyed/available distinction the chat menu currently shows. - 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).