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>
@
267 lines
17 KiB
Markdown
267 lines
17 KiB
Markdown
# 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<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-`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<char,FontCharDesc?>` 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).
|