docs(D.2b): chat-window re-drive design spec + list-ui-layouts research tool
Plan-2 chat piece of the LayoutDesc importer. Identifies the chat window as
LayoutDesc 0x21000006 (gmMainChatUI, element class 0x10000041) and grounds a
faithful, data-driven re-drive in the named retail decomp (ChatInterface +
gmMainChatUI + UIElement_Text/_Scrollable/_Scrollbar/_Menu) plus a user-provided
retail screenshot.
Design (full-faithful scope, user-approved):
- transcript = UIElement_Text 0x10000011 (dat font, bottom-pinned, 10k behead cap,
pixel scroll, 1 line/wheel-notch)
- scrollbar = right-side track 0x10000012 + thumb 0x1000048c + up/down
- input = editable UIElement_Text 0x10000016 (caret, 100-entry history, Enter/Send)
- channel menu = UIElement_Menu 0x10000014 ("Chat" selector -> active channel)
- shared ChatCommandRouter extracted from ChatPanel
- screenshot correction: the four 0x10000522-525 left-edge elements are the
numbered CHAT TABS (1-4), not scroll buttons (a research-agent inference the
retail screenshot refutes)
- deferred (need non-UI plumbing, each gets a divergence row): tab switching/
filtering, squelch, clickable name-tags, in-element word-wrap, styled runs,
font config, opacity transition
Tooling: AcDream.Cli `list-ui-layouts <datdir> [0xRootType]` — read-only index of
every UI LayoutDesc by root element class + size + element-Type histogram; how the
chat layout was located (root type 0x10000041). Reusable for future panel re-drives.
Spec: docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
17 KiB
D.2b — Chat-window re-drive (LayoutDesc importer, Plan 2 chat piece) — design
Date: 2026-06-15
Branch: claude/hopeful-maxwell-214a12 (D.2b retail-UI track; lighting/M1.5 is a separate branch off main)
Status: design — approved scope, pending spec review
Predecessor: the LayoutDesc importer + the vitals re-drive
(docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md,
docs/research/2026-06-15-layoutdesc-format.md,
claude-memory/project_d2b_retail_ui.md).
Handoff that opened this work: docs/research/2026-06-15-chat-window-redrive-handoff.md.
1. Goal
Replace the hand-authored retail chat window (a UiNineSlicePanel hosting a
UiChatView at a guessed rect, built inline in GameWindow.cs under
if (_options.RetailUi)) with the data-driven retail chat window read from
the dat LayoutDesc 0x21000006 (gmMainChatUI) via the existing LayoutImporter,
with faithful behavioral widgets ported from the named retail decomp and the
dat font — the same way the vitals window became data-driven.
The code is modern. The behavior is retail. Every widget algorithm is ported
from docs/research/named-retail/acclient_2013_pseudo_c.txt with a cited
class::method @address.
2. Approved scope
In scope (faithful core):
- Data-driven window frame from
0x21000006(bg sprites, resize bar, grip chrome, translucency). - Transcript: dat-font
UIElement_Textport — scrollable, bottom-pinned, per-line chat-kind color, 10k-glyph behead cap. - Scrollbar: right-side track + thumb + up/down buttons, pixel-based scroll,
thumbRatio = view/content, wheel = 1 line per notch. - Input: editable one-line field — caret, insert/delete, 100-entry command history (up/down arrow), focus sprite, Enter→submit.
- Channel menu (
Chat ▸): dropdown of channels; selection sets the active outbound channel (theChatInputParserdefault channel). - Send button + max/min button.
ChatCommandRouter: the shared submit pipeline, extracted fromChatPanelso the ImGui devtools chat and the retail chat share one routing path.
Deferred (each gets a retail-divergence-register.md row — these need non-UI
plumbing acdream lacks, they are NOT UI scope cuts):
- Numbered chat tabs (1–4) — switching + per-tab chat-type filtering. The tab
sprites render (they come free from the importer), but clicking a tab to filter
which chat kinds show needs the per-tab
m_llTextTypeFilter/m_chatNewNonVisibleTextIndicatorsystem. - Squelch toggle (menu item 0) — needs a squelch subsystem.
- Clickable name-tags (
StartTellon click) — needsStringInfo/TextTagstyled runs inChatLog. - In-element word-wrap at panel width — the transcript renders pre-split
ChatLoglines 1:1; faithfulGlyphList::Recalculatewrap reworks the selection/hit-test model (visual-row ≠ record). Highest-risk UI piece; deferred. - Per-glyph mixed-color runs / configurable font face+size (
gmClient::sm_nFontFace). - Active/inactive opacity switch — a single default translucency is in scope; the focused-brighter / unfocused-dimmer transition is deferred.
3. Retail reference (the port target)
gmMainChatUI (registered element class 0x10000041, built from LayoutDesc 0x21000006) extends a base ChatInterface. ChatInterface owns the
transcript, input, inbound routing, submit, history, truncate and opacity;
gmMainChatUI adds the channel menu, squelch, max/min, tab visibility and
clickable name-tags.
3.1 Element → role map (0x21000006)
| Element | Type | Role | Decomp anchor |
|---|---|---|---|
0x1000000E |
0x10000041 |
window root (gmMainChatUI), bg 0x0600114D, 800×100 authored |
gmMainChatUI::Register @0x4cd350 |
0x1000000F |
9 Resizebar | top resize bar, img 0x06001125, cursor 0x06005E66 |
— |
0x1000046F |
0 | max/min button (Maximized 0x06005E64 / Minimized 0x06005E65) |
gmMainChatUI::HandleMaximizeButton @0x4cce50 |
0x10000010 |
3 Field | transcript panel bg 0x06001115 |
— |
0x10000011 |
0 (UIElement_Text) | transcript — read-only, multiline, scrollable | ChatInterface::PostInit @0x4f3e47 |
0x1000048c |
0 | scroll thumb (child of transcript) | ChatInterface::PostInit @0x4f3e79 |
0x10000012 |
0 | scrollbar track (right edge, 16×68) | UIElement_Scrollable::GetScrollbarPointer_ @0x473ec0 |
0x10000013 |
3 Field | input bar bg 0x0600113A |
— |
0x10000014 |
6 Menu | channel menu (Chat ▸) — 14 items (squelch + 13 channels) |
gmMainChatUI::InitTalkFocusMenu @0x4cdc50, HandleSelection @0x4cd540 |
0x10000016 |
0 (UIElement_Text) | input — editable, one-line, focus sprite 0x060011AB |
ChatInterface::PostInit @0x4f3e86 |
0x10000017/18 |
3 | input focus edges (sprite 0x06004D67) |
— |
0x10000019 |
0 | Send button (0x06001915/pressed …16/ghost …34) |
ChatInterface::ListenToElementMessage @0x4f51ea |
0x10000522–525 |
0 | numbered chat tabs 1–4 (left strip; Normal 0x06006218/Hi 0x06006219) |
gmMainChatUI::RecvNotice_SetPanelVisibility @0x4ccd80 |
Screenshot correction (user-provided retail ground truth, 2026-06-15): the four
0x10000522–525elements are the left-edge numbered chat tabs, NOT the "line/page scroll buttons" a research agent inferred from their 16×16 vertical geometry. The scrollbar (track + thumb + up/down) is on the right. The exact dat ids of the right-side scroll up/down buttons are located during Task D (likely children of track0x10000012not surfaced in the top-level dump).
BN field-name caveat: the decomp's
m_chatEntry/m_chatLog/m_fCurrentOpacitynames are applied inconsistently across functions (a Binary-Ninja artifact). The roles above are fixed by the decisive evidence — theNormal_focussedsprite is on0x10000016(only an editable field gets a focus state) and the multiline geometry is0x10000011— corroborated by both surviving research agents. Port by role, not by the C++ member name.
3.2 Key retail algorithms (cited)
Inbound — ChatInterface::RecvNotice_DisplayFinalStringInfo @0x4f4640:
append arg4 (prefix/timestamp) then arg3 (body) to the transcript via
UIElement_Text::AppendStringInfoWithFont with per-chat-type color arg2 (color
table built by BuildChatColorLookupTable). If glyph count > 0x2710 (10000),
TruncateChatLog @0x4f4290 beheads from the top at a newline boundary. Bottom-pin:
capture IsAtVerticalEnd before appending; if it was true, ScrollToPosition
to the new end; else light the unread-text indicator.
Submit — ChatInterface::HandleEnterKey @0x4f52d0 (fired by the Accept
input-action, not raw \r — one-line mode drops the \r char) → ProcessCommand @0x4f5100: read input text, dispatch, push to m_InputHistory (cap 100, drop
index 0 when full), reset history cursor to 0xFFFFFFFF, clear input. The Send
button (0x10000019) is an alternate trigger via ListenToElementMessage.
Scroll — UIElement_Scrollable: pixel offset m_iScrollableY, clamped to
[0, contentHeight − viewHeight] (SetScrollableXY @0x4740c0). thumbRatio = view/content clamped to 1, bar hidden when content ≤ view
(UpdateScrollbarSize_ @0x4741a0). posRatio = scrollY/(content−view)
(UpdateScrollbarPosition_ @0x473f20). Scroll quantum = one line-height
(UIElement_Text::InqScrollDelta @0x4689b0); page = view height; wheel = 1 line
per notch (HandleMouseWheel @0x471450).
Input — caret m_nCursorPos (glyph index); GlyphList::FindPixelsFromPos @0x472b40 = Σ glyph advances to the caret; midpoint-snap hit-test
FindPosFromLineAndPixels @0x4732d0; ~1 Hz blink. Glyph advance =
HorizontalOffsetBefore + Width + HorizontalOffsetAfter (signed bytes,
Font::GetCharWidthA @0x4433f0) — already implemented by
UiDatFont.GlyphAdvance. History: SelectCommandFromHistory (up=back, down=fwd),
sentinel 0xFFFFFFFF = "not browsing".
Channel menu — InitTalkFocusMenu @0x4cdc50 fills UIElement_Menu 0x10000014
with 14 items: item 0 = squelch toggle, items 1–13 = channels carrying attr
0x1000000B = channel enum (1=Say, 2=Tell/Target, 3=Emote, 4=Fellowship,
5=Patron, 6=Trade, 7=Allegiance, 8–0xD=area/custom). HandleSelection @0x4cd540
reads the enum, calls SetTalkFocus(enum), updates the label, marks the item
selected.
4. Architecture (acdream)
Faithful structure: an importer builds the generic frame; a controller
(ChatInterface+gmMainChatUI::PostInit analogue) binds behavior by element id
and swaps the transcript/input placeholders for behavioral widgets. New classes
live in src/AcDream.App/UI/ (widgets, GL-side) and src/AcDream.UI.Abstractions/
(the shared submit router).
| Component | Kind | Retail analogue | Responsibility |
|---|---|---|---|
ChatWindowController |
new (App/UI/Layout/) |
ChatInterface + gmMainChatUI PostInit |
import 0x21000006; FindElement(id); swap transcript/input widgets; wire scrollbar/menu/send/max-min; route in/outbound |
UiChatView |
extend (App/UI/) |
UIElement_Text (transcript) |
+ UiDatFont? DatFont; dat-font measure/advance/draw; 1-line wheel quantum; keep bottom-pin + drag-select + Ctrl+C |
UiChatInput |
new (App/UI/) |
UIElement_Text (editable) |
caret, insert/delete, 100-entry history, focus sprite swap, dat font, Action<string>? OnSubmit |
UiScrollable |
new (App/UI/) |
UIElement_Scrollable |
pixel scroll math: ScrollY, ContentHeight, ViewHeight, ClampScroll, ThumbRatio, ThumbOffsetRatio, line/page delta |
UiChatScrollbar |
new (App/UI/) |
composed scrollbar | own the imported track/thumb/up-down sprites; size+place thumb from UiScrollable; clicks/drag → UiScrollable |
UiChannelMenu |
new (App/UI/) |
UIElement_Menu |
dropdown popup of channels; selection → active ChatChannelKind; label reflects selection |
ChatCommandRouter |
new (UI.Abstractions/Panels/Chat/) |
ProcessCommand |
shared submit: client-command intercept → unknown-verb guard → ChatInputParser.Parse(text, channel, lastTell, lastOutgoing) → Publish(SendChatCmd) |
UiDatFont |
no change | Font |
already implements retail glyph advance |
Why two widget classes (UiChatView + UiChatInput) when retail uses one
UIElement_Text with mode bits: acdream's retained-mode widget layer predates
D.2b; the behavioral contract (read-only multiline scroll vs editable one-line) is
identical, only the class split differs. Accepted ADAPTATION divergence; both
classes share the UiDatFont.GlyphAdvance measure seam so geometry is consistent.
Placeholder swap: the transcript (0x10000011) and input (0x10000016)
render no background sprite of their own (bg comes from parent panels
0x10000010 / 0x10000013), so ChatWindowController reads each placeholder's
rect + anchors, instantiates UiChatView / UiChatInput there, adds it to the
placeholder's parent, and removes the placeholder. Mirrors GetChildRecursive(id)
binding in ChatInterface::PostInit.
5. Data flow
- Inbound:
ChatLog → ChatVM.RecentLinesDetailed()(200-deep tail) →UiChatView.LinesProvider(per-ChatKindcolor viaRetailChatColor). Pipeline unchanged. Bottom-pin + 10k cap areUiChatView/UiScrollablebehavior. - Outbound:
UiChatInput.OnSubmit(text)→ChatCommandRouter.Submit(text, vm, commandBus, activeChannel)→SendChatCmd→LiveCommandBus→WorldSession.activeChannelcomes fromUiChannelMenu. - Channel:
UiChannelMenuselection →ChatWindowController._activeChannel(→ChatInputParserdefault channel) + menu label update. - Scroll: transcript content height →
UiScrollable→UiChatScrollbarthumb; wheel/buttons/drag →UiScrollable.ScrollY→ transcript draw offset.
6. Faithfulness decisions / divergence-register rows
Add on landing (category in parens):
- (Adaptation) Transcript + input are two classes (
UiChatView/UiChatInput) not one mode-flaggedUIElement_Text. Behavior identical. - (Approximation) Transcript renders pre-split
ChatLoglines 1:1; no in-element word-wrap at panel width. Symptom: long lines not re-wrapped on horizontal resize.file:line=UiChatView.cs. - (Approximation) One color per display line, not per-glyph styled runs.
- (Stopgap) Numbered chat tabs render but don't switch / filter chat kinds.
- (Stopgap) Squelch toggle + clickable name-tags render/parse-absent.
- (Approximation) Single default translucency; no focused/unfocused opacity
transition; default dat font face+size (no
sm_nFontFaceconfig).
Retire nothing (no existing register row is fixed by this work).
7. Build sequence (tasks for the plan)
Pipelineable where independent; ChatWindowController (G) and the GameWindow
cutover (H) are the integration barrier.
- A.
ChatCommandRouter— extract the submit flow fromChatPanelinto a pureUI.Abstractionshelper;ChatPanelcalls it; tests for client-command / unknown-verb / parse / publish parity. (UI.Abstractions; no GL.) - B.
UiChatViewdat-font seam — addUiDatFont? DatFont; prefer it in draw +HitCharadvance + selection measure +LineHeight; changeWheelLines3→1; keepBitmapFontfallback. Tests: advance/hit-test with a synthetic dat font. - C.
UiScrollable— portUIElement_Scrollablemath (pixel clamp, thumb ratio/offset, line/page delta). Pure, fully unit-tested (no GL). - D.
UiChatScrollbar— own imported track/thumb/up-down sprites; size+place thumb fromUiScrollable; wheel/button/drag → scroll. Locate the right-side up/down button ids in the dat here. - E.
UiChatInput— editable one-line widget: caret (MeasurePrefix=UiDatFont.MeasureWidth(text[..caret])), insert/delete, Home/End/arrows, 100-entry history with−1=live sentinel, focus sprite swap,OnSubmit. Tests for caret math + history. - F.
UiChannelMenu— channel dropdown (portUIElement_Menuminimally); 13 channels →ChatChannelKind; selection event + label. - G.
ChatWindowController—LayoutImporter.Import(0x21000006); bind by id; swap transcript/input; wire scrollbar/menu/send/max-min; route inbound (ChatVM)- outbound (
ChatCommandRouter); translucency.
- outbound (
- H.
GameWindowcutover — replace the hand-authoredUiNineSlicePanel+UiChatViewblock withChatWindowController; default bottom-left position + resizable; remove dead code; add divergence rows;dotnet build+dotnet testgreen.
8. Testing strategy
- Pure/unit (no GL, no dats):
ChatCommandRouterparity;UiScrollableclamp/thumb/delta golden values from the decomp;UiChatInputcaret index ↔ pixel + history navigation;UiChatViewdat-font advance/hit-test via theFunc<char,FontCharDesc?>seam. - Layout/import (dat-free fixture): extend the importer fixture pattern with a
chat_21000006.jsontree (viaImportInfos) asserting the element→role map and rects. - Real-dat smoke:
LayoutImporter.Import(0x21000006)against the live dat resolves the root + all bound ids before wiring (guarded, like the vitals smoke). - Visual acceptance (user): launch live
ACDREAM_RETAIL_UI=1; compare to the retail screenshot — transcript scrolls, input types + sends, channel menu switches, Send works, scrollbar drags, window moves/resizes, translucency.
9. Acceptance criteria
- Chat window is built from
LayoutDesc 0x21000006viaLayoutImporter— no hand-authored chat rect remains inGameWindow.cs. - Transcript renders inbound chat in the dat font, per-
ChatKindcolor, bottom-pinned, 10k-cap, mouse-wheel = 1 line/notch, drag-select + Ctrl+C kept. - Right-side scrollbar: thumb sizes to content, drag + up/down scroll the transcript.
- Input: type, caret moves, backspace/delete, up/down history, Enter and the
Send button both submit through
ChatCommandRouter→ wire. Chat ▸menu opens, lists channels, selection changes the outbound channel + updates the label.- Max/min toggles window height; window moves + resizes; translucent frame.
- Every ported widget cites a
class::method @address; every deferral has a divergence-register row. dotnet build+dotnet testgreen; user visual sign-off.
10. Deferred / follow-ups (filed, not built)
In-element word-wrap (+ selection rework); numbered-tab switching + per-tab chat
filtering; squelch; clickable name-tags; per-glyph styled runs; configurable font
face/size; active/inactive opacity transition; the unidentified top-level Type-5
ListBox 0x1000001D (not bound by ChatInterface; likely a floaty/options element).