diff --git a/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md b/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md new file mode 100644 index 00000000..342ed53d --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md @@ -0,0 +1,267 @@ +# 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). diff --git a/src/AcDream.Cli/LayoutIndexDump.cs b/src/AcDream.Cli/LayoutIndexDump.cs new file mode 100644 index 00000000..5276486c --- /dev/null +++ b/src/AcDream.Cli/LayoutIndexDump.cs @@ -0,0 +1,101 @@ +using System.Reflection; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.Cli; + +/// +/// Read-only research diagnostic: index EVERY UI in the +/// dat by its root element's Type + size + an element-Type histogram, so a +/// panel re-drive can locate its layout from the decomp-registered class id +/// (e.g. gmMainChatUI registers type 0x10000041 → the chat window +/// is the layout whose root element has Type 0x10000041). Optionally filter to a +/// single root Type. No writes; purely a console dump used during brainstorming. +/// +public static class LayoutIndexDump +{ + public static int Run(string datDir, string? rootTypeText) + { + if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + uint? filter = null; + if (!string.IsNullOrWhiteSpace(rootTypeText)) + { + var t = rootTypeText.Trim(); + if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..]; + if (uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out var f)) filter = f; + } + + Console.WriteLine(filter is { } ff + ? $"=== LayoutDescs with a root element of Type 0x{ff:X8} ===" + : "=== All LayoutDescs (id : root element Type : size : #elements : type histogram) ==="); + + int total = 0, shown = 0; + foreach (var id in dats.GetAllIdsOfType().OrderBy(x => x)) + { + var l = dats.Get(id); + if (l is null) continue; + total++; + + // The root is the single top-level element (or, if several, the largest). + ElementDesc? root = null; + foreach (var kv in l.Elements) + if (root is null || Area(kv.Value) > Area(root)) root = kv.Value; + if (root is null) continue; + + if (filter is { } want && root.Type != want) continue; + shown++; + + var hist = new SortedDictionary(); + int count = 0; + CountTypes(root, hist, ref count); + string h = string.Join(" ", hist.Select(kv => $"{TypeName(kv.Key)}×{kv.Value}")); + Console.WriteLine( + $" 0x{id:X8} root=0x{root.ElementId:X8} type=0x{root.Type:X8}({TypeName(root.Type)}) " + + $"{root.Width}x{root.Height} n={count} [{h}]"); + } + + Console.WriteLine(); + Console.WriteLine($"shown {shown} / {total} LayoutDescs."); + return 0; + } + + private static long Area(ElementDesc e) => (long)e.Width * e.Height; + + private static void CountTypes(ElementDesc e, SortedDictionary hist, ref int count) + { + count++; + hist[e.Type] = hist.TryGetValue(e.Type, out var c) ? c + 1 : 1; + foreach (var kv in e.Children) + CountTypes(kv.Value, hist, ref count); + } + + private static string TypeName(uint t) => t switch + { + 0 => "Text0", + 1 => "Button", + 2 => "Dragbar", + 3 => "Field", + 5 => "ListBox", + 6 => "Menu", + 7 => "Meter", + 8 => "Panel", + 9 => "Resizebar", + 0xB => "Scrollbar", + 0xC => "Text", + 0xD => "Viewport", + 0xE => "Browser", + 0x10 => "ColorPicker", + 0x11 => "GroupBox", + 0x12 => "Proto", + 0x10000041 => "gmMainChatUI", + 0x10000040 => "gmFloatyChatUI", + 0x10000050 => "gmFloatyMainChatUI", + 0x10000042 => "gmChatOptionsUI", + 0x10000009 => "gmVitalsUI", + _ => $"0x{t:X}", + }; +} diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index 6be503c4..44094b55 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -31,6 +31,18 @@ if (args.Length >= 1 && args[0] == "dump-vitals-layout") return VitalsLayoutDump.Run(dvlDatDir, dvlLayout); } +if (args.Length >= 1 && args[0] == "list-ui-layouts") +{ + string? luiDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string? luiRootType = args.ElementAtOrDefault(2); + if (string.IsNullOrWhiteSpace(luiDatDir)) + { + Console.Error.WriteLine("usage: AcDream.Cli list-ui-layouts [0xRootType]"); + return 2; + } + return LayoutIndexDump.Run(luiDatDir, luiRootType); +} + if (args.Length >= 1 && args[0] == "render-vitals-mockup") { string? rvmDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");