acdream/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md
Erik 26cb34f126 @
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>
@
2026-06-15 19:38:27 +02:00

267 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (14) — 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` |
| `0x10000522525` | 0 | **numbered chat tabs 14** (left strip; Normal `0x06006218`/Hi `0x06006219`) | `gmMainChatUI::RecvNotice_SetPanelVisibility @0x4ccd80` |
> **Screenshot correction (user-provided retail ground truth, 2026-06-15):** the
> four `0x10000522525` 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/(contentview)`
(`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 113 = channels carrying attr
`0x1000000B` = channel enum (1=Say, 2=Tell/Target, 3=Emote, 4=Fellowship,
5=Patron, 6=Trade, 7=Allegiance, 80xD=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).