# 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_Text` port — 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 (the `ChatInputParser` default channel). - Send button + max/min button. - `ChatCommandRouter`: the shared submit pipeline, extracted from `ChatPanel` so 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_chatNewNonVisibleTextIndicator` system. - **Squelch toggle** (menu item 0) — needs a squelch subsystem. - **Clickable name-tags** (`StartTell` on click) — needs `StringInfo`/`TextTag` styled runs in `ChatLog`. - **In-element word-wrap at panel width** — the transcript renders pre-split `ChatLog` lines 1:1; faithful `GlyphList::Recalculate` wrap 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–525` elements 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 track `0x10000012` not surfaced in the top-level dump). > **BN field-name caveat:** the decomp's `m_chatEntry` / `m_chatLog` / > `m_fCurrentOpacity` names are applied inconsistently across functions (a > Binary-Ninja artifact). The roles above are fixed by the decisive evidence — > the `Normal_focussed` sprite is on `0x10000016` (only an editable field gets a > focus state) and the multiline geometry is `0x10000011` — 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? 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-`ChatKind` color via `RetailChatColor`). Pipeline unchanged. Bottom-pin + 10k cap are `UiChatView`/`UiScrollable` behavior. - **Outbound:** `UiChatInput.OnSubmit(text)` → `ChatCommandRouter.Submit(text, vm, commandBus, activeChannel)` → `SendChatCmd` → `LiveCommandBus` → `WorldSession`. `activeChannel` comes from `UiChannelMenu`. - **Channel:** `UiChannelMenu` selection → `ChatWindowController._activeChannel` (→ `ChatInputParser` default channel) + menu label update. - **Scroll:** transcript content height → `UiScrollable` → `UiChatScrollbar` thumb; wheel/buttons/drag → `UiScrollable.ScrollY` → transcript draw offset. ## 6. Faithfulness decisions / divergence-register rows Add on landing (category in parens): 1. **(Adaptation)** Transcript + input are two classes (`UiChatView`/`UiChatInput`) not one mode-flagged `UIElement_Text`. Behavior identical. 2. **(Approximation)** Transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at panel width. Symptom: long lines not re-wrapped on horizontal resize. `file:line` = `UiChatView.cs`. 3. **(Approximation)** One color per display line, not per-glyph styled runs. 4. **(Stopgap)** Numbered chat tabs render but don't switch / filter chat kinds. 5. **(Stopgap)** Squelch toggle + clickable name-tags render/parse-absent. 6. **(Approximation)** Single default translucency; no focused/unfocused opacity transition; default dat font face+size (no `sm_nFontFace` config). 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 from `ChatPanel` into a pure `UI.Abstractions` helper; `ChatPanel` calls it; tests for client-command / unknown-verb / parse / publish parity. *(UI.Abstractions; no GL.)* - **B. `UiChatView` dat-font seam** — add `UiDatFont? DatFont`; prefer it in draw + `HitChar` advance + selection measure + `LineHeight`; change `WheelLines` 3→1; keep `BitmapFont` fallback. Tests: advance/hit-test with a synthetic dat font. - **C. `UiScrollable`** — port `UIElement_Scrollable` math (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 from `UiScrollable`; 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 (port `UIElement_Menu` minimally); 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. - **H. `GameWindow` cutover** — replace the hand-authored `UiNineSlicePanel`+`UiChatView` block with `ChatWindowController`; default bottom-left position + resizable; remove dead code; add divergence rows; `dotnet build` + `dotnet test` green. ## 8. Testing strategy - **Pure/unit (no GL, no dats):** `ChatCommandRouter` parity; `UiScrollable` clamp/thumb/delta golden values from the decomp; `UiChatInput` caret index ↔ pixel + history navigation; `UiChatView` dat-font advance/hit-test via the `Func` seam. - **Layout/import (dat-free fixture):** extend the importer fixture pattern with a `chat_21000006.json` tree (via `ImportInfos`) 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 0x21000006` via `LayoutImporter` — no hand-authored chat rect remains in `GameWindow.cs`. - [ ] Transcript renders inbound chat in the **dat font**, per-`ChatKind` color, 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 test` green; 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).