diff --git a/docs/plans/2026-08-21-chat-text-tag-campaign.md b/docs/plans/2026-08-21-chat-text-tag-campaign.md new file mode 100644 index 00000000..f80459dd --- /dev/null +++ b/docs/plans/2026-08-21-chat-text-tag-campaign.md @@ -0,0 +1,126 @@ +# Campaign CT — chat text tags and chat-window parity + +**Status:** PROPOSED (2026-08-21). Not started. + +Retail renders a speaker's name inside a chat line in green, and clicking it +opens a tell to that person. acdream renders flat, uniformly coloured, inert +lines. Six parallel research lanes established why, and the answer is not a +chat bug — it is a **missing capability in the text stack**. + +Research notes (all 2026-08-21): `chat-texttag-model.md`, +`chat-tagged-name-composition.md`, `chat-tag-click-dispatch.md`, +`retail-chat-window-ui.md`, `acdream-text-stack-audit.md`, +`acdream-chat-ui-audit.md`. + +## The mechanism, proven + +1. The CLIENT composes the markup. `Handle_Communication__HearSpeech + @0x005712A0` / `HearDirectSpeech @0x005715A0` sprintf a literal tag into the + plain chat line, of the shape + `{name}<\Tell> says, "{text}"` — the closing + marker is a literal backslash. Only senders whose GUID is in AC1's player + range `0x50000001..0x6FFFFFFF` are tagged at all. + +2. `UIElement_Text::InqGlyphs @0x00468EA0` recognises the brackets while + appending and calls `TextTagFactory::MakeTag @0x00478480`. Any bracketed + text that fails to parse closes the open tag — `MakeTag` requires a `:` to + succeed, which is exactly what makes the bare closer a closer. + +3. Tags attach **per glyph**. There is no run or span object anywhere: a + "tagged run" is emergent, re-derived by walking neighbouring glyphs whose + `m_tag` pointers are equal. + +4. Colour: a glyph takes the TAG colour (property `0x1D`) only when a tag is + open AND its `m_type == 0x10000001`; otherwise the ordinary line colour + (`0x1B`). Both are DAT-authored arrays on the element. + +5. **Measured** out of the installed dats (`LayoutDump --colors`), chat + `0x2100006F`, transcript `0x10000011`: + + P0x1B (line) [0x00] R=204 G=204 B=204 + P0x1D (tag) [0x00] R= 0 G=178 B= 0 <- the green + +6. Click: `UIElement_Text::MouseUp @0x004694F0` → `DeterminePositionFromXY + @0x004688F0` (xy → glyph index) → `GlyphList::InqGlyph @0x00473430` → a + virtual `HandleClick` at tag-vtable `+0x14` → `SendNotice_TextTag_*Click` → + `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @0x004CCE10` → + `ChatInterface::StartTell @0x004F41F0`, which writes `"@tell {Name}, "` into + the entry, takes keyboard focus, and shows the entry bar. + +Two facts that shape the port: + +- **The tag colour is per-ELEMENT and authored**, while the line colour on the + same element comes from the runtime chat table. Filing "tag green" into the + LogTextType colour table would put it in the wrong place. +- **Clicking a name always opens a TELL**, everywhere. Fellowship, allegiance, + patron/vassal and named-channel lines all embed the same markup. Dispatch is + generic over four tag shapes, but only `IIDString` has a listener in this + build. + +Retail applies no hover effect to a tag, and the colour is a static per-glyph +bake at append time — not a render-time lookup. + +## What we already have + +The audit found more than expected. `UiText` **already** has a +`TextRun`/`RunsProvider` path that draws several differently-coloured runs on +one line (used today by the character stat panel) — it is simply gated to +`OneLine == true`, and the chat transcript is multi-line. The draw path needs +no renderer work at all: it already accepts an arbitrary pen X and can measure +substrings. `UiText.HitChar` already resolves a click to (line, column). + +So the gap is narrower than "build a text tag system": + +- the multi-line path cannot carry runs, and +- sender identity is **destroyed before it reaches the renderer**: `ChatEntry` + keeps `Sender`/`SenderGuid` all the way through `ChatLog`, and + `ChatVM.RecentLinesDetailed()` builds a `FormattedLine` that drops both. + +## Slices + +**CT1 — runs on the multi-line text path.** Extend the existing `TextRun` +model to multi-line elements; per-line run lists; additive, so `Line` keeps +working and the ~50 files using it are untouched. No behaviour change. + +**CT2 — markup parse.** Parse the tag markup into runs carrying a tag payload, +including retail's rule that an unparseable bracket closes the open tag. Pure +and unit-testable, no UI. + +**CT3 — stop flattening, and emit the markup.** Carry sender name + guid +through `ChatVM` into spans, and compose retail's markup in the speech handlers +behind the player-GUID-range gate. This is the slice that makes the name a +distinct run at all. + +**CT4 — tag colour.** Read the authored `0x1D` array per element and apply it +when a tag is open and its type matches. Uses CT1's runs. + +**CT5 — click to tell.** Sub-line hit-testing (`HitChar` → run → tag) and +`StartTell` behaviour: write `"@tell {Name}, "`, focus the entry, show the +entry bar. Dispatch keyed generically by tag type, with only `IIDString` wired. + +**CT6 — chat window behaviours.** Bound the transcript (10,000 chars, trim to +~7,500 preferring a newline boundary); split auto-scroll from the unread +indicator (`0x1000048C` — retail samples "was at bottom" BEFORE the line +lands); the option-gated timestamp prefix. Unbounded scrollback is also a slow +leak for the life of a session, not only a fidelity gap. + +**CT7 — tail.** The Escape-in-chat-input no-op the audit found (no `Escape` +case in `UiField`, and a focused field also suppresses the input dispatcher's +fallback, so nothing happens at all); delete the dead ImGui-era `ChatPanel`; +reconcile the stale digest/ISSUES rows (#358, #362, #363, #367, #372, #379, +#380, #382 are DONE in code but still listed open). + +## Deliberately NOT in scope + +Item links and the other three tag shapes (`DID`, `IID`, `IIDEnum`). They have +no listener in the retail build we target, so porting them would be inventing +behaviour. CT5's dispatch is generic, so they cost nothing to add later. + +## Known-unknowns carried + +- The symbolic name behind tag type `0x10000001` (only "Tell" is confirmed); + the full roster lives in the DAT `EnumMapper` category `0x18`. +- Whether the retail transcript supports text selection distinctly from the + entry field. +- The chat log file's path and rotation (`ClientSystem::s_pLogFile`) — retail + writes a plain-text session log a port would miss entirely. diff --git a/docs/research/2026-08-21-acdream-chat-ui-audit.md b/docs/research/2026-08-21-acdream-chat-ui-audit.md new file mode 100644 index 00000000..45879e71 --- /dev/null +++ b/docs/research/2026-08-21-acdream-chat-ui-audit.md @@ -0,0 +1,188 @@ +# acdream chat UI audit — window controllers, input bar, menus, filters, window management + +Scope: acdream's own chat UI implementation (window controllers, view models, +input bar, menus, filters, window management). Explicitly OUT of scope per +task boundary: retail's glyph tag system, tag click dispatch, and the text +rendering stack (owned by a parallel audit) — not covered here beyond +incidental mentions needed to explain routing. + +All claims below are cited `file.cs:line` against the current worktree +(`C:\Users\erikn\source\repos\acdream\.claude\worktrees\objective-leavitt-0cbd10`). +This document supersedes nothing in `claude-memory/project_chat_digest.md`; +it verifies and extends it against the current code as of 2026-08-21. + +--- + +## 1. Inventory — what exists and what each surface owns + +| Surface | File | Owns | +|---|---|---| +| Main chat window | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Binds LayoutDesc `0x2100006F` (retail `gmMainChatUI`/`ChatInterface`, `m_eWindowID==8`). Transcript (`UiText`), input (`UiField`), scrollbar, talk-focus channel menu (`UiMenu`), Send button, max/min toggle, the four floating-window indicator LEDs (mirror + click), the 8 resize-grip locked/live cosmetic swap seed. | +| Floating chat windows ×4 | `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` | Binds LayoutDesc `0x2100005B` (retail `gmFloatyChatUI`, `m_eWindowID` 2-5) four times, one `FloatingChatWindowController` instance per `WindowId` 1-4. Transcript, input (channel hardcoded to Say), scrollbar, Send button, hardcoded `"Chat {windowId}"` title, Close button. | +| Chat view-model | `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` | Formats `ChatLog` entries to display lines (`RecentLines`/`RecentLinesDetailed`), owns `/framerate`/`/loc` client-side output, the `ShowSystemMessage`/`ShowInterfaceText` (0x1A→SpewBox) split, and `ChatCommandTargetState` (last-tell-sender/target) via `_commandTargets`. | +| Chat submit pipeline | `src/AcDream.Runtime/Chat/ChatCommandRouter.cs` | The one `Submit` chokepoint both `ChatWindowController.Bind`'s `Input.OnSubmit` (`ChatWindowController.cs:339-340`) and `FloatingChatWindowController.Bind`'s `Input.OnSubmit` (`FloatingChatWindowController.cs:157`) call. | +| Chat parsing / catalog | `src/AcDream.Runtime/Chat/ChatInputParser.cs`, `RetailClientCommandCatalog.cs`, `RetailCommandHelpTable.cs`, `RetailChannelTagTable.cs` | Verb resolution, 152-verb registry, `/help` text. | +| Per-window filter/open state | `src/AcDream.Core/Chat/ChatWindowState.cs` | The ONE canonical `ChatWindowState` (5 windows: id 0 main + 1-4 floaty) both controllers read live — filters, open/closed, `ShouldDisplay`/`TypeIsActive` (retail's `ChatInterface::TypeIsActive`/`RecvNotice_DisplayFinalStringInfo`). | +| Chat colors | `src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs` | 34-value `RetailLogTextType`→RGBA table, hard-coded (matches retail; no user config — confirmed still true, see §2). | +| Input widget | `src/AcDream.App/UI/UiField.cs` | Generic editable-field widget; the chat entry is one instance of this, built by `DatWidgetFactory.BuildText` for the DAT's Type-12 Editable element. | +| Talk-focus / dropdown menu | `src/AcDream.App/UI/UiMenu.cs` | Generic dropdown popup widget; the chat channel selector is one instance. | +| Chat-tab Settings surface | `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (+ `ChatOptionsDatDefaults.cs`) | Options panel → Chat tab: two opacity sliders + 5 per-window 13-row text-type filter blocks, all live-writing `ChatWindowState`/`RetailWindowOpacityController`. | +| Persistence | `src/AcDream.App/UI/RetailUiRuntime.cs` (`SaveChatWindowFilters`, `SaveChatOpacity`, load path ~`RetailUiRuntime.cs:1516-1518`) + generic `RetailWindowLayoutPersistence` | Filters (all 5 windows) + opacity persist to local `settings.json`; window geometry/open-state persist "for free" once registered under `WindowNames.Chat`/`ChatWindow1..4` (`src/AcDream.App/UI/WindowNames.cs:19-23`). | +| Dead/legacy surface | `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | An `IPanel` (ImGui-era D.2a stack) chat panel. **Never instantiated in `src/`** — see §6. | + +**Not in this lane** (owned elsewhere per the task boundary): glyph tag +colouring/click dispatch, `UiText` rendering internals, SpewBox glyph +drawing. Where the input bar or router hands text to the SpewBox +(`ChatVM.ShowInterfaceText`, `ChatVM.cs:177-183`) that routing decision is +in-lane; the SpewBox's own rendering is not. + +--- + +## 2. Window management + +**Multiple windows.** Five windows total: 1 main (always open, `ChatWindowState.MainWindowId=0`, `ChatWindowState.cs:68,169-175`) + 4 floating (independently open/closed, `ChatWindowState.cs:69-70,178-206`). Confirmed working end-to-end: `ChatWindowController.SetIndicatorOpen`/`BindIndicatorClicks` (`ChatWindowController.cs:671-700`) mirror + drive floating-window visibility from the main window's 4 indicator LEDs, and `RetailUiRuntime.ToggleFloatingChatWindow` (`RetailUiRuntime.cs:1142-1143`) is the Alt+1..4 keybind's landing point (`KeyBindings.cs:218-221`). + +**Filters.** Per-window 64-bit `LogTextType` bitmask filter (`ChatWindowState.GetFilter`/`SetFilter`/`ShouldDisplay`, `ChatWindowState.cs:145-167,230-235`), fully live: both controllers read it every transcript rebuild (`ChatWindowController.cs:742,776-777`; `FloatingChatWindowController.cs:261,278-279`), and it's user-editable through the Options→Chat tab (`ChatOptionsPageController.cs:181-193` five `FilterBlockSpec` rows, `:552-598` `BuildFilterBlock`). This is MORE complete than the chat digest implied — the digest's "Main's filter IS user-settable" note (digest line 118-119) is confirmed and the floaty filters are settable through the same UI, not just the main window. + +**Move/resize.** The 8 authored resize grips (`ChatWindowController.cs:38-43` doc) and the drag/move title-strip import generically via `DatWidgetFactory`/`UiResizeGrip` — no per-controller binding code needed (confirmed by the class doc; no resize-specific code exists in either controller beyond `AttachWindow`/`ToggleMaximize`). + +**Maximize/restore (main window only).** `ChatWindowController.ToggleMaximize` (`ChatWindowController.cs:537-580`) is a faithful port of `gmMainChatUI::HandleMaximizeButton @0x004CCE50` (save/restore Y+height, half-parent expansion, up/down growth choice, DAT-constraint clamping) plus `CaptureWindowState`/`RestoreWindowState` (`ChatWindowController.cs:702-715`) for session persistence. **Floating windows have no maximize** — matches retail (no max/min button authored on `0x2100005B`; `FloatingChatWindowController.cs` has no `MaxMinId`/`ToggleMaximize` equivalent, and this is correct, not a gap). + +**Close (floating windows only).** `FloatingChatWindowController.Bind`'s Close-button wiring (`FloatingChatWindowController.cs:211-214`) calls `c.WindowHandle?.Hide()` — a straight port of `gmFloatyChatUI::ListenToElementMessage @0x004CE330`. **Main window has no close button** (matches retail; `ChatWindowState.SetOpen`/`Toggle` are explicit no-ops for window 0, `ChatWindowState.cs:177-188,195-206`). + +**Opacity.** Two linked sliders (Default/Active), ported with retail's `DualHash` drag-the-other-value link (`ChatOptionsPageController.cs:344-372` doc, `:412-451`), scoped to exactly the 5 chat windows per issue #379 (see §5 — DONE). Batched persistence (`RetailUiRuntime.SaveChatOpacity`, `RetailUiRuntime.cs:1201-1210`) flushes once per discrete edit, not per drag tick. + +**Persistence.** Filters for all 5 windows + both opacity values write to local `settings.json` on every live edit (`RetailUiRuntime.cs:1169-1210`) and reload at startup (`RetailUiRuntime.cs:1516-1518` for the main window's filter; the floaty load leg is the analogous call in `MountFloatingChatWindows`, cited by that same comment). Window geometry/open-state ride the generic `RetailWindowLayoutPersistence` path since all 5 windows are registered under distinct `WindowNames` entries (`WindowNames.cs:19-23`). **No gap found here** — window management persistence is comprehensive. + +**Known, already-registered divergences (not new findings, listed for completeness):** +- AP-187/AP-189 (`docs/architecture/retail-divergence-register.md`): floaty filters are local-`settings.json`-only, no `0x1000008C` server-side wire sync between installs; and the chat log's shared `500`-entry ring buffer / `200`-entry display tail (`ChatLog.cs:21-22,447-448`; `InteractionRetainedUiComposition.cs:465`) gives every window a shallower **effective per-window** scrollback than retail's own **per-window** 10,000-line log — a low-traffic window's messages can be evicted from the shared tail by unrelated high-traffic windows' spam before that window's own filter ever sees them. Behaviorally the accumulate-while-closed and independent-per-window-scroll-position mechanics are correctly reproduced; only the depth differs. +- AP-188/#369 (OPEN): floating windows hardcode Send-channel to Say (`FloatingChatWindowController.cs:157`) because the floaty LayoutDesc authors no talk-focus menu; whether retail's floaties actually share the main window's last-picked channel is UNRESEARCHED (see §5). +- AP-190/#379 (#379 DONE, AP-190 partially retired): opacity scope-to-chat-only is fixed; the digest's "we snap, retail eases 5%-of-range per tick" easing-curve residual is unverified as still true today — UNKNOWN, needs re-check against `RetailWindowOpacityController` if picked up. + +--- + +## 3. Input bar — exactly what `UiField` supports + +Source: `src/AcDream.App/UI/UiField.cs`, wired per-window at `ChatWindowController.cs:329-372` (main) and `FloatingChatWindowController.cs:151-175` (floaty). Both controllers configure the SAME widget class with near-identical wiring (the floaty path lacks the talk-focus channel, per §2/#369). + +**Supported:** +- **Typing / editing:** `InsertChar`, `Backspace`, `DeleteForward`, held-key auto-repeat for Backspace/Delete/Left/Right (`UiField.cs:159-189,683-700`, 0.40s delay / 25/s repeat). +- **Caret movement:** Left/Right (`MoveCaret`), Home/End (`MoveCaretTo`), all Shift-extendable when `Selectable` (`UiField.cs:791-811`). No Ctrl+Left/Right word-jump, no Ctrl+Backspace delete-word. +- **Selection:** mouse click+drag (`MouseDown`/`MouseMove`, `UiField.cs:749-763`), Shift+arrow, Ctrl+A select-all — **all three gated behind `Selectable`** (`UiField.cs:781,789`), which is DAT-authored property `0x27` on element `0x10000016`. Confirmed live-DAT-true for the chat input specifically: `ChatLayoutConformanceTests.ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace` (`tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs:220-228`) asserts `input.Selectable == true` after `ChatWindowController.Bind`, and neither controller sets it explicitly (`ChatWindowController.cs`/`FloatingChatWindowController.cs` grep clean for `Input.Selectable`) — so this is the real DAT default, not an accidental pass. **Not a gap.** +- **Clipboard:** Ctrl+C/Ctrl+X gated behind `Selectable` (same as above); Ctrl+V (paste) is **not** gated and always works when `Editable` (`UiField.cs:265-303,784`). Paste strips control characters and normalizes CR/LF for multi-line fields; the chat input is one-line so paste collapses to a single stripped line. +- **History:** 100-entry cap (`UiField.cs:326`, sentinel via `_historyIndex=-1`), Up/Down browse (`HistoryPrev`/`HistoryNext`, `UiField.cs:330-350,809-810`) — a faithful port per the class doc's citation of `ChatInterface::ProcessCommand @0x4f5100`. +- **Submit:** Enter/KeypadEnter (`UiField.cs:792-802`) calls `Submit()` → `OnSubmit` → clears (`ClearOnSubmit`, default true) → pushes history (`RecordHistory`, default true) → releases keyboard focus (`FindRoot()?.SetKeyboardFocus(null)`) — "exit write mode after sending," matching retail's read-mode/write-mode chat behavior. +- **Max length:** DAT-authored via property `0x1E` → `UiField.MaxCharacters` (`DatWidgetFactory.cs:788-789`); default `0xFFFF` if the DAT doesn't author one. Not hardcoded in the controller — correctly deferred to the imported layout. +- **Focus entry (keyboard):** `UiRoot.OnKeyDown` has a special case — when nothing is focused, Tab or Enter/KeypadEnter focuses `DefaultTextInput` (`UiRoot.cs:1059-1071`), which `RetailUiRuntime.cs:1587` sets to the bound chat `Input`. This is the actual, working mechanism for "press Enter/Tab to start typing" — it runs entirely inside `UiRoot`, independent of the `InputDispatcher`/`InputAction` system. + +**Missing / gaps found:** +1. **Escape does nothing while the chat input is focused.** `UiField.OnEvent`'s `KeyDown` switch (`UiField.cs:790-811`) has no `Key.Escape` case, so it falls through to `return false;` (implicit end-of-block after the switch, `UiField.cs:812`). Because `KeyboardFocus.IsEditControl` is true for a focused `UiField` (`UiField.cs:149`), `UiRoot.OnKeyDown`'s modal/root fallback branch (`UiRoot.cs:1083-1089`) is skipped entirely, and the event falls to `WorldKeyFallThrough` (`UiRoot.cs:1091`) — **an event nobody subscribes to in production** (grep for `WorldKeyFallThrough +=` across `src/` finds only the class's own declaration and a `README.md` code sample, `src/AcDream.App/UI/UiHost.cs:19`, `src/AcDream.App/UI/UiRoot.cs:408`). Separately, `InputDispatcher`'s own action-routing is gated off entirely whenever a widget holds keyboard focus (`_mouse.WantCaptureKeyboard` → `SilkMouseSource.cs:193` → `Root.WantsKeyboard` → `KeyboardFocus is not null`, `UiRoot.cs:196`), so `GameplayInputCommandController.HandleEscape` (`GameplayInputCommandController.cs:238-248`, cancel target mode / exit fly mode / close window) never fires either. **Net effect: pressing Escape while the chat box has focus is a complete no-op in acdream today** — no clear, no defocus, no fallback to a game hotkey. This is not tracked in `docs/ISSUES.md` under any existing chat issue. +2. **No autocomplete / tab-completion** of player names, channel tags, or command verbs. Confirmed by exhaustive grep (`autocomplete|tabcomplete|namecomplet` across `src/`) — zero hits. `ChatCommandRouter.Submit` (`ChatCommandRouter.cs:30-179`) is pure parse-and-dispatch with no partial-match suggestion path. Whether retail AC's chat box ever had tab-completion is UNKNOWN — not established either way in this pass; flagging the absence, not asserting it's a regression. +3. **`@title` is a documented no-op** (see §4) — the floating window's title bar (`FloatingChatWindowController.cs:194-207`) is permanently `"Chat {windowId}"`, unaffected by the command that's supposed to set it. +4. **No Ctrl+Left/Right word-jump or Ctrl+Backspace delete-word** — minor editing convenience absent from `UiField`'s `KeyDown` switch entirely (not chat-specific, but the chat input is the surface a user would notice it on most). + +--- + +## 4. Known no-ops and stubs (grepped, cited) + +| Site | What's disabled | +|---|---| +| `src/AcDream.Runtime/Chat/ClientCommandId.cs:50-58` (`SetChatTitle`) + `RetailClientCommandCatalog.cs:258-269` (`SetTitle` definition) | `@title ` — retail sets the popup chat window's title bar (`ClientCommunicationSystem::DoTitle @0x0057A640`); acdream's binding "is a pure no-op (the value is neither stored nor consumed — no title-bar chrome exists to render it yet, AP-182)". Confirmed live: `FloatingChatWindowController.cs:204` hardcodes `$"Chat {windowId}"` with no seam for an external override. | +| `src/AcDream.App/Input/GameplayInputCommandController.cs:208-213` | `InputAction.ToggleChatEntry` (Tab, bound at `KeyBindings.cs:251`) — the switch case's own comment says "IDevToolsGameplayCommands.FocusChatInput() retired... Tab is still consumed here, matching the prior no-op's 'handled' contract." **Harmless**: `UiRoot.OnKeyDown` (§3) independently handles Tab-to-focus-chat before/alongside this path, so functionally nothing is lost — but the `InputAction`/keybind plumbing for it is dead weight that could mislead a future reader into thinking this is the live mechanism. | +| `src/AcDream.App/Input/GameplayInputCommandController.cs` (whole file) | `InputAction.EnterChatMode` (Enter, bound at `KeyBindings.cs:252`) has **no case at all** in `Handle`'s switch (`GameplayInputCommandController.cs:172-235`) — falls to `default: return false;`. Same "harmless because `UiRoot` does it independently" caveat as `ToggleChatEntry` above. | +| `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs:634-696` (`TryMatchAllegiance`) | 9 of 12 `@allegiance` subcommands (boot/ban/officer/title/motd/name/lock/house/chat/broadcast) unported — issue #360, still OPEN (see §5). | +| `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs:319-349` | `@day`/`@log`/`@render` recognized only by `/help `; execution falls through to server passthrough — issue #361, still OPEN (see §5). | +| `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | Entire `IPanel`-based class — see §6, dead code, not reachable from any production construction site. | + +No other `deferred`/`TODO`/`stub`/`placeholder` hits inside `ChatWindowController.cs`, `FloatingChatWindowController.cs`, or the `Panels/Chat/` directory resolve to a genuine behavioral gap beyond what's listed above and in §5 — the remaining grep hits in those files are either doc-comment cross-references to *other* code's no-ops (e.g. `ChatWindowController.cs:230` explaining that the main filter is "not an inert no-op" — i.e. describing something that was FIXED) or historical narration. + +--- + +## 5. Open issues — current code status + +| Issue | One-line verdict | Evidence | +|---|---|---| +| **#358** Ctrl+M mute chord never fires | **STALE — DONE.** `KeyBindings.RetailDefaults()` now binds Ctrl+M (`KeyBindings.cs`, per the fix note); root cause (binding added to the dead `AcdreamCurrentDefaults()` table) is fixed. Live-client verification of the actual mute effect was still owed at closure time — UNKNOWN whether that connected check ever ran. | +| **#359** `0x019E` PlayerKilled prints to participants | **STILL OPEN.** `ChatLog.OnPlayerKilled` (`ChatLog.cs:188-206`) appends the death message unconditionally for every recipient — no `player_id == victim \|\| player_id == killer` guard exists anywhere in the method or its call site. | +| **#360** `@allegiance`/`@house` only port simple subcommands | **STILL OPEN.** `RetailClientCommandCatalog.TryMatchAllegiance` (`RetailClientCommandCatalog.cs:656-696`) only recognizes `hometown`/`ho` and `info`; every other subcommand falls to `AllegianceUnrecognizedSubcommand`'s refusal text (`:689-695`). House subcommands correctly passthrough to ACE per the same file's `TryMatchHouse` comments (`:643`), but neither dispatcher executes the ~22 unported subcommands locally. | +| **#361** `@day`/`@log`/`@render` recognized in help only | **STILL OPEN.** `RetailCommandHelpTable.cs:319-349` still carries the "NOT YET IMPLEMENTED in acdream" meta-tail for all three; `RetailClientCommandCatalog` has no `Day`/`Log`/`Render` client-command definitions with real handlers (only chat verbs actually wired execute; these three fall through to server passthrough, which is a silent no-op against ACE). | +| **#362** Four CH4 outbound requests had no inbound handler | **STALE — DONE**, closed 2026-08-09 (`ClientCommandResponses.cs` parses `ChannelIndex`/`ChannelList`/`AvailableHouses`/`AllegianceInfoResponse`). | +| **#363** Refusal sites typed `0x00` where retail types `0x1A` | **STALE — CLOSED 2026-08-10.** `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (`ChatVM.cs:159-183`) exists and is wired at `InteractionRetainedUiComposition.cs:472-473`; `ChatCommandRouter` routes every named site through it (confirmed at `ChatCommandRouter.cs:85-98,119-121,154,162`). | +| **#366** New-unseen-text indicator (`0x1000048C`) imports but unwired | **STILL OPEN** (narrowed 2026-08-16 — the build/import half is fixed, the behavior half — what triggers it, what a click does — remains un-researched). No controller code references `0x1000048C` in `ChatWindowController.cs`. | +| **#367** Local-presentation fallbacks land in chat scroll, not SpewBox | **STALE — CLOSED 2026-08-10**, closed as a side effect of #363 (same seam). | +| **#369** Unconfirmed whether floaty windows share the main window's talk-focus channel | **STILL OPEN, unresearched.** `FloatingChatWindowController.cs:157` hardcodes Say; whether that's retail-correct is not established either way — filed as a research task, not yet picked up. | +| **#372** Options panel Character/Chat/Config tabs render blank | **STALE — DONE** (blank-tabs half fixed at the `UiTemplateListBox` viewport-anchor level, not chat-specific; the tangential "13 Chat-tab filter labels resolve blank" sub-note was fixed by the `FilterStringTableId = 0x2300000D` correction visible at `ChatOptionsPageController.cs:96-105`). | +| **#379** Chat opacity applied to all windows, not just chat | **STALE — DONE**, `RetailWindowOpacityController` now scoped to the 5 chat windows only. | +| **#380** Chat tab opacity sliders missing row captions | **STALE — DONE**, `ChatOptionsPageController.cs:399-407,473-503` wires `SetOpacityCaption` from `ChatOptionsDatCaptions`. | +| **#382** Floating-window indicator buttons invisible until hovered | **STALE — DONE**, `UiButton.TrySetRetailState` fix (unrelated file, general `UiButton` bug that happened to be discovered via the chat indicators). | + +**Net: of the 12 chat-tagged issues checked, 4 remain genuinely open in code (#359, #360, #361, #366) plus one unresearched design question (#369).** The rest closed since the digest's 2026-08-09/10 snapshot but the digest's own "Open" section (still listing #358/#359/#360/#361/#362/#363) is now stale for #358/#362/#363 — worth a digest refresh independent of this audit. + +--- + +## 6. Test coverage + +**Well covered** (direct, behavior-level tests exist): +- `ChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs` — bind success/failure, talk-focus specials (Squelch/Tell-to-selected), transcript parent/mode, transcript layout caching + out-of-range LogTextType fallback, input submit → `SendChatCmd`, channel-change updates submit channel, input-field resize/reflow (both with and without an imported `LayoutPolicy`), indicator open/closed/cross-window-isolation/out-of-range. +- `FloatingChatWindowController`: `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs` — bind success/failure/invalid-window-id, transcript parent, input parent (floaty row vs main bar), input always-Say, per-window filter subset + filter-change reflection + cache reuse. +- `ChatWindowState`: has its own filter/open/`ShouldDisplay` logic covered by construction (not independently verified in this pass, but the class is simple enough that the controller tests above exercise it transitively). +- `UiField`: `tests/AcDream.App.Tests/UI/UiFieldTests.cs` — insert/caret, backspace, submit/clear/history-push, empty-submit no-op, history up/down, history 100-cap, two multi-line-after-shrink regression tests (the 2026-07-29 crash class), character filter, select-all-on-focus, read-only field, multi-line Enter-inserts-newline. +- Command routing: `ChatCommandRouterTests.cs`, `ChatInputParserTests.cs`, `ChatInputParserAtPrefixTests.cs`, `RetailClientCommandCatalogTests.cs`, `RetailCommandHelpTableTests.cs`, `RetailCommandRegistryConformanceTests.cs` (bidirectional ownership-rule enforcement across the whole 152-verb registry). +- Colors: `RetailChatColorTableTests.cs`. + +**Gaps found — user-visible behaviors with zero automated coverage:** +1. **`ChatWindowController.ToggleMaximize`** — no test anywhere calls it or exercises `CaptureWindowState`/`RestoreWindowState`. Grep for `ToggleMaximize`/`Maximiz` across `tests/` returns nothing. The growUp/clamp/DAT-constraint logic (`ChatWindowController.cs:537-580`, a direct port of `gmMainChatUI::HandleMaximizeButton`) is entirely unverified by automation — a regression here would only be caught by a human clicking the max/min button. +2. **Floating window's Close button** — no test exercises `FloatingChatWindowController.cs:211-214`'s `OnClick` wiring (`WindowHandle?.Hide()`). Grep for `CloseButton` in `FloatingChatWindowControllerTests.cs` returns nothing. +3. **`UiField` Escape handling** (or lack thereof — see §3 finding 1) — no test exists for Escape at all in `UiFieldTests.cs`; the absence of behavior is untested, meaning it could silently "start working" or silently regress further with no signal either way. +4. **`UiField` clipboard (Ctrl+C/X/V) and Shift-selection** — none of `UiFieldTests.cs`'s 13 tests exercise `CopySelection`/`CutSelection`/`Paste`/Shift+arrow extension. `Selectable`-gating (§3) is only indirectly confirmed via the DAT-fixture conformance test (`ChatLayoutConformanceTests.cs:220-228`), which checks the *property resolves true*, not that copy/cut/select-all *actually work* once it's true. +5. **`ChatPanel.cs` and its whole test suite are exercising dead code.** `ChatPanel` (`src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`) implements the old ImGui-era `IPanel` contract from the D.2a stack. `AcDream.UI.ImGui` no longer exists as a project (deleted at Campaign V slice V11 per `CLAUDE.md`), and a repo-wide grep for `new ChatPanel(` finds only the class's own constructor declaration — **no production code anywhere constructs a `ChatPanel`.** Its five test files (`ChatPanelFocusTests.cs`, `ChatPanelInputTests.cs`, `ChatPanelLayoutTests.cs`, plus the shared `ChatVMCombatTests.cs`/`ChatVMLastTellSenderTests.cs`/`ChatVMRetellAndProvidersTests.cs` that exercise `ChatVM` directly and remain legitimately live) still compile and pass, which gives a false impression of "chat input is covered" in a naive test-count read — the REAL live input surface is `UiField` + `ChatWindowController`, covered separately (and less deeply, per findings 1-4 above). This is worth flagging to whoever next touches chat tests: `ChatPanel.cs` and its three panel-specific test files are candidates for deletion (dead code, not a functioning fallback), not maintenance targets. + +--- + +## 7. Prioritized gap list (most user-visible first) + +This is the ordering to plan slices from — judgment calls, not a flat dump. + +1. **Escape does nothing in the chat input (§3 finding 1).** Every retail player's muscle memory includes "Escape backs out of whatever I'm doing," and chat is the single most-used text-entry surface in the client. Right now it's a dead key while typing — worse than doing nothing wrong, because it silently swallows an action a user expects to work (defocus/clear), and if `WorldKeyFallThrough` were ever wired for something else, it would ALSO be swallowed by the exact-focus branch that already fails to handle it. This is a real, previously-untracked bug (no ISSUES.md entry), high frequency of exposure, small fix surface (add an Escape case to `UiField.OnEvent`'s `KeyDown` switch, decide clear-vs-defocus-vs-both against retail). +2. **#360 — `@allegiance`/`@house` management subcommands (22 of them unported).** Highest-traffic gap by command surface area; already tracked, already scoped ("largest single item; deserves its own slice" per the issue's own text), needs byte-level wire verification before implementation (target-name/guid resolution, confirmation dialogs, multi-field payloads) rather than guessing. +3. **#359 — PlayerKilled line double-prints for the victim/killer.** Small, well-scoped, single-method fix (`ChatLog.OnPlayerKilled` needs the local-player-guid participant check) with a clear retail citation already in the issue. Low effort, directly visible to anyone who dies or gets a kill in acdream. +4. **#369 — floaty-window channel-sharing research.** Currently a design assumption (Say-always) shipped without verification. Low implementation cost either way once researched, but the research itself (`gmCCommunicationSystem`'s floaty send path) hasn't started. Worth resolving before more chat work builds on the current assumption. +5. **`ToggleMaximize`/Close-button test coverage gap (§6.1/6.2).** Not a behavior bug — both features work per the code reading — but zero automated coverage on two interactive, DAT-geometry-dependent code paths (max/min clamping, close-then-reopen) is a latent regression risk given how much chat-adjacent layout churn this codebase has had (8+ chat-parity review rounds in the last two weeks alone). +6. **#366 — new-unseen-text indicator inert.** Cosmetic/discoverability only; retail's exact trigger condition is still unresearched, so this can't be fixed correctly without that research first, and its absence doesn't block any other chat behavior. +7. **#361 — `@day`/`@log`/`@render`.** Genuinely low-value: `@day` needs a renderer hook that doesn't exist yet (bigger than a chat fix), `@log` was deliberately deferred (file-handle lifecycle risk across reconnects), `@render` has no acdream render-option surface to bind to. Correctly the lowest priority of the open command-registry gaps. +8. **`@title` no-op + hardcoded floaty titles (§4).** Cosmetic, single command, no other feature depends on it. Fine to bundle with a future title-bar-chrome pass rather than a standalone fix. +9. **`ChatPanel.cs` dead-code cleanup (§6.5).** Not a behavior gap at all — it's hygiene. Flagging here rather than fixing inline per this audit's report-only scope; worth a small follow-up to delete the class and its now-misleading test files so future coverage audits don't need to re-discover this. +10. **Missing autocomplete / word-jump editing conveniences (§3 findings 2, 4).** Lowest priority: unconfirmed whether retail even had these, and even if it did, they're minor efficiency features, not correctness or discoverability gaps. + +--- + +## Appendix: files read for this audit + +- `src/AcDream.App/UI/Layout/ChatWindowController.cs` +- `src/AcDream.App/UI/Layout/FloatingChatWindowController.cs` +- `src/AcDream.Core/Chat/ChatWindowState.cs` +- `src/AcDream.Core/Chat/ChatLog.cs` +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs` +- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` +- `src/AcDream.Runtime/Chat/ChatCommandRouter.cs` +- `src/AcDream.Runtime/Chat/ClientCommandId.cs` +- `src/AcDream.Runtime/Chat/RetailClientCommandCatalog.cs` (partial) +- `src/AcDream.Runtime/Chat/RetailCommandHelpTable.cs` (partial, grep-targeted) +- `src/AcDream.App/UI/UiField.cs` +- `src/AcDream.App/UI/UiMenu.cs` (partial) +- `src/AcDream.App/UI/UiRoot.cs` (partial — key dispatch + focus) +- `src/AcDream.UI.Abstractions/Input/InputDispatcher.cs` (partial) +- `src/AcDream.App/Input/GameplayInputCommandController.cs` (partial) +- `src/AcDream.App/Input/InputCaptureSources.cs` (partial) +- `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` +- `src/AcDream.App/UI/RetailUiRuntime.cs` (partial — persistence + mount) +- `src/AcDream.App/UI/WindowNames.cs` +- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (partial) +- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (partial — Type-12 field build) +- `tests/AcDream.App.Tests/UI/UiFieldTests.cs` +- `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs` +- `tests/AcDream.App.Tests/UI/Layout/FloatingChatWindowControllerTests.cs` +- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` (partial) +- `docs/ISSUES.md` (targeted sections: #358-#382 chat-tagged range) +- `docs/architecture/retail-divergence-register.md` (targeted: AP-185 through AP-191) +- `C:\Users\erikn\.claude\projects\C--Users-erikn-source-repos-acdream\memory\project_chat_digest.md` diff --git a/docs/research/2026-08-21-acdream-text-stack-audit.md b/docs/research/2026-08-21-acdream-text-stack-audit.md new file mode 100644 index 00000000..a55f89b6 --- /dev/null +++ b/docs/research/2026-08-21-acdream-text-stack-audit.md @@ -0,0 +1,376 @@ +# acdream text-stack audit: seam for retail glyph-level tagged text + +**Scope.** Research only — OUR codebase (`src/AcDream.App/UI/**`, +`src/AcDream.UI.Abstractions/Panels/Chat/**`, `src/AcDream.Core/Chat/**`). +Goal: determine what it takes to support retail's glyph-level tagged +text (a differently-colored, clickable name inside an otherwise +uniformly-colored chat line). No code changes made. + +## 1. Current model — what is a rendered text line? + +`UiText` (`src/AcDream.App/UI/UiText.cs`) is the one retained-UI text +widget (`RegisterElementClass(0xc)`, class doc at `UiText.cs:10-22`). +It has **two** display-line shapes, both single-color: + +- **`Line`** — `UiText.cs:50`: + `public readonly record struct Line(string Text, Vector4 Color);` + One string, one `Vector4` color for the WHOLE string. This is what + `LinesProvider` (`UiText.cs:62`, `Func>`) returns + and what the scrollable multi-line transcript path renders + (`DrawClippedText`, `UiText.cs:636-691`). Color is **per-line**, not + per-run: `lines[i].Color` (`UiText.cs:673`/`677`) is one value passed + whole to `ctx.DrawStringDatPass`/`ctx.DrawString`. +- **`TextRun`** — `UiText.cs:55`: + `public readonly record struct TextRun(string Text, Vector4 Color);` + Multiple colored fragments concatenated onto **one** authored line, + fed by `RunsProvider` (`UiText.cs:69`, + `Func>?`) and drawn by `DrawSingleLineRuns` + (`UiText.cs:693-754`). This is real per-run coloring — each run gets + its own `ctx.DrawStringDatPass` call at its own pen X + (`UiText.cs:725-743`) — but it is **only reachable when + `OneLine == true`** (`UiText.cs:506-510`: `if (OneLine && + RunsProvider is { } runsProvider)`), i.e. the static single-line + label path. The chat transcript is NOT `OneLine` (`ChatWindowController.cs:320`: + `c.Transcript.OneLine = false;`), so it can never reach + `DrawSingleLineRuns` — the scrollable multi-line path only ever + reads `LinesProvider`/`Line`. + +**Answer to Q1:** color today is per-`Line` in the transcript +(scrollable, multi-line, bottom-pinned/word-wrapped) path, and +per-`TextRun` only in the unrelated static single-line label path +(currently used by exactly one controller — see §5/§6). Chat uses the +former exclusively. + +## 2. Where the flattening happens (the key finding) + +`ChatEntry` (`src/AcDream.Core/Chat/ChatLog.cs:499-536`) is a +structured record: `Sender` (string), `SenderGuid` (uint), `Text`, +`ChannelId`/`ChannelName`, `Kind`, `LogTextType`. The sender's identity +survives as a distinct field all the way through `ChatLog`. + +It is destroyed in **two** steps inside `ChatVM` +(`src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`), and the second +step is the point of no return: + +**Step A — string composition.** `ChatVM.FormatEntry` +(`ChatVM.cs:262-313`) string-interpolates `entry.Sender` directly into +the message prose, e.g. for `ChatKind.LocalSpeech` +(`ChatVM.cs:269-271`): +``` +ChatKind.LocalSpeech => IsOwnSpeaker(entry.Sender) + ? $"You say, \"{entry.Text}\"" + : $"{entry.Sender} says, \"{entry.Text}\"", +``` +After this call the sender name is prose inside one `string`; there is +no longer a machine-readable boundary marking where "Name" ends and +"says, ..." begins. + +**Step B — metadata drop (the actual point of no return).** +`ChatVM.RecentLinesDetailed()` (`ChatVM.cs:345-371`) builds the +`FormattedLine` record (`ChatVM.cs:384-388`): +``` +public readonly record struct FormattedLine( + string Text, + ChatKind Kind, + CombatLineKind? CombatKind, + uint LogTextType); +``` +`FormattedLine` does **not** carry `Sender` or `SenderGuid` at all — +only the composed `Text`, `Kind`, `CombatKind`, and the retail color +key `LogTextType`. Every downstream consumer +(`ChatWindowController.GetTranscriptLines`, `ChatWindowController.cs:736-781`, +which calls `vm.RecentLinesDetailed()` at `ChatWindowController.cs:753`, +then `ChatTranscriptRenderer.BuildLines`, +`src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs:67-95`) only ever +sees the flat `Text` string plus one `LogTextType` per entry. +`ChatTranscriptRenderer.BuildLines` then assigns exactly **one** +`Vector4 currentColor` per entry (resolved once from `LogTextType` at +`ChatTranscriptRenderer.cs:89`) and stamps every word-wrapped fragment +of that entry with that single color (`ChatTranscriptRenderer.cs:90-93`): +``` +if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved)) + currentColor = resolved; +foreach (string frag in WrapText(d.Text, maxW, measure)) + result.Add(new UiText.Line(frag, currentColor)); +``` + +**So: the sender's identity (name + guid) is available up through +`ChatLog`/`ChatEntry`, is baked into prose by `ChatVM.FormatEntry`, and +is then dropped entirely — not merely flattened, but discarded — by +`ChatVM.RecentLinesDetailed`'s `FormattedLine` shape.** By the time a +`UiText.Line` exists, there is no span boundary, no guid, and (because +word-wrap has already run) not even a guarantee that "the sender name" +is wholly contained within a single rendered `Line` if it happened to +sit at a wrap boundary. Any attempt to recover "where is the name" +downstream of this point would have to regex/string-match the composed +prose back apart — fragile (a message body containing the speaker's +own name, or a name that is a prefix of a common word, breaks it) and +still has no guid to attach for the click action. + +## 3. Draw path + +**One call per line/run, never per-glyph-color-batch beyond that.** +The scrollable path (`DrawClippedText`, `UiText.cs:636-691`) issues one +`ctx.DrawStringDatPass(datFont, text, x, y, color, isOutlinePass)` +(`UiText.cs:687-689`) per visible `Line` (whole wrapped fragment, one +color). The DAT-font backend (`UiRenderContext.DrawStringDatPass`, +`src/AcDream.App/UI/UiRenderContext.cs:320-367`) walks every glyph in +that ONE string with ONE `tint` (`UiRenderContext.cs:321`, `tint` +passed once, applied per-glyph at `UiRenderContext.cs:359-362`) — there +is no per-glyph or per-substring color inside a single +`DrawStringDatPass` call. + +**Drawing N differently-colored runs on one visual line requires N +separate `DrawStringDatPass` calls, each starting at its own pen X** — +this is exactly the mechanism `DrawSingleLineRuns` +(`UiText.cs:693-754`) already uses: it measures each run's width +(`datFont.MeasureWidth(run.Text)`, `UiText.cs:709`/`736`), accumulates +a `penX` (`UiText.cs:731-737`), and issues one +`ctx.DrawStringDatPass(datFont, run.Text, run.X, y, run.Color, +isOutlinePass: false)` per run (`UiText.cs:742-743`). The outline pass +is batched block-wide first (all runs' outlines, then all runs' fills — +`UiText.cs:739-743`, same reasoning as the multi-line block-batching +documented at `UiRenderContext.cs:306-318`) so a per-run outline +doesn't notch an adjacent run's descender. + +**Yes, the DAT font path supports starting a draw at an arbitrary X +offset and measuring a substring's width.** `UiDatFont.MeasureWidth(string +text)` (`src/AcDream.App/UI/UiDatFont.cs:160-172`) sums per-glyph +advances for any string/substring — already used for substring +measurement in the selection-highlight code +(`UiText.cs:656-657`/`661-662`, `datFont.MeasureWidth(text.Substring(0, +c0))`). `DrawStringDatPass`/`DrawStringDat` take an arbitrary `float x` +(`UiRenderContext.cs:277-278`, `320-321`) with no assumption it starts +at the element's left edge. The bitmap-font fallback (`BitmapFont.cs`, +`MeasureWidth` at `BitmapFont.cs:167`) and `UiRenderContext.DrawString` +(`UiRenderContext.cs:188-203`, also takes an arbitrary `float x`) mirror +the same capability. **Conclusion: the low-level draw primitives +already support everything a run-based transcript line needs — no +renderer/font work is required, only a widget-level model change to +call them N times instead of once.** + +## 4. Hit-testing + +**No sub-line hit-testing exists today; click/hover route to whole +elements, never to a text span.** `UiRoot.HitTestTopDown` +(`src/AcDream.App/UI/UiRoot.cs:1410-1430`) walks the retained tree via +`UiElement.HitTest` (`src/AcDream.App/UI/UiElement.cs:705-731`), which +recurses into children and, failing that, calls the virtual +`OnHitTest(localX, localY)` (`UiElement.cs:563-564`, default is a +rectangle containment check) — the granularity is always "some +`UiElement`", never "some substring of a `UiElement`'s text." A +resolved hit becomes a `Click` `UiEvent` at `UiRoot.OnMouseUp` +(around `UiRoot.cs:994-997`) and bubbles via +`UiRoot.BubbleEvent`/`UiElement.OnEvent` (`UiRoot.cs:1516-1525`, +`UiElement.cs:571`). `UiText.OnEvent`'s `Click` case +(`UiText.cs:809-813`) fires the single `OnClick` delegate for the +WHOLE element — there is no notion of "which run was clicked." + +**The pieces needed already exist, just not wired to `Click`.** +`UiText.HitChar(float localX, float localY)` (`UiText.cs:1031-1050`) +already converts a local point into a `Pos(line, col)` caret position +using the cached draw geometry (`_lastLines`/`_lastBaseY`/ +`_lastLineHeight`, `UiText.cs:268-273`) and a per-character advance +lookup (`UiText.cs:1042-1048`, works for both `UiDatFont` and +`BitmapFont`) — but it is currently invoked only from the +selection-drag path (`MouseDown`/`MouseMove` cases, `UiText.cs:826-847`), +gated behind `Selectable` (`UiText.cs:828`, `839`). The chat transcript +IS `Selectable = true` (`ChatWindowController.cs:321`), so `HitChar` +already runs on every mouse-down inside the transcript — it is simply +never asked "which run (if any) covers this `(line, col)`", because +`Line` carries no runs to check against. + +**What mapping a click to a run would need:** +1. A per-line list of run boundaries (start col, end col, and a + payload — e.g. sender guid) reaching `UiText` alongside the text, + which does not exist today (`Line` has no such field, see §1/§2). +2. `HitChar`'s existing `(line, col)` result checked against that + list — this is a small, local addition to `UiText`, not a new + hit-test mechanism. +3. A dispatch from "run payload resolved" to an actual action (e.g. + pre-filling a `/tell ` in the chat input) — analogous to the + existing `OnClick` delegate, but keyed by run rather than by + element. + +No retail-side click semantics (what a name-click does) were +researched here — that is a different agent's lane per the task brief. + +## 5. Seam proposal + +**Given Code Structure Rules (CLAUDE.md "Code Structure Rules" §1-3):** +- `AcDream.Core` must not depend on window/GL/backend projects (rule 2) + — `ChatEntry`/`ChatLog` (Core) can carry the STRUCTURED data a tagged + run needs (sender name + guid + explicit text-span boundaries) but + must not know about `Vector4`/GL/rendering. +- UI panels target `AcDream.UI.Abstractions` only (rule 3) — the + composition of "structured entry -> ordered list of colored, + optionally-actionable spans" is exactly the kind of pure formatting + logic `ChatVM` already owns (`ChatVM.FormatEntry`/ + `RecentLinesDetailed`, `ChatVM.cs:262-371`) and should keep owning; + it must not reach into `AcDream.App` (GL/rendering) types. +- The retained-widget rendering (`AcDream.App/UI/UiText.cs`) is where + GL-adjacent draw calls (`DrawStringDatPass`) and Silk.NET-adjacent + hit-testing (`HitChar`, `UiRoot`) live, and must stay there. + +**Proposed layering (three seams, one per project boundary):** + +1. **`AcDream.UI.Abstractions` (data shape)** — introduce a + run-carrying line shape parallel to (not replacing) `FormattedLine`. + Sketch: `FormattedLine` gains an optional ordered list of spans, or + a new `RichFormattedLine(IReadOnlyList Runs, ...)` is + added, where `FormattedRun` is something like + `(string Text, uint? ActorGuid, bool IsSpeakerName)` — deliberately + NOT carrying a color yet (`AcDream.UI.Abstractions` has no + `System.Numerics`/GL dependency requirement today, but keeping + color resolution in `AcDream.App` mirrors the existing + `RetailChatColorTable`/`ChatTranscriptRenderer` split, where + `ChatVM` supplies `LogTextType`/structured data and + `ChatTranscriptRenderer` in `AcDream.App` resolves it to `Vector4`). + `ChatVM.FormatEntry` (`ChatVM.cs:262-313`) would need a sibling that + returns spans instead of one interpolated string — e.g. split each + `case` into "prefix run" / "sender run" / "suffix run" instead of a + single `$"..."` — and `RecentLinesDetailed` would carry + `entry.Sender`/`entry.SenderGuid` through instead of discarding them + (the §2 fix). +2. **`AcDream.App/UI/Layout` (composition)** — `ChatTranscriptRenderer.BuildLines` + (`ChatTranscriptRenderer.cs:67-95`) is the existing per-controller- + shared seam that already resolves `LogTextType -> Vector4` and + word-wraps. It would gain a variant that word-wraps a RUN LIST + instead of a flat string per entry, producing a new "rendered line + with runs" shape (see below) instead of `UiText.Line`. Both + `ChatWindowController` (`ChatWindowController.cs:778`) and + `FloatingChatWindowController` (same shared function, per the class + doc at `ChatTranscriptRenderer.cs:9-15`) would switch to the new + builder — this is the one place both chat surfaces already share, + so it is the natural single point of change for chat specifically. +3. **`AcDream.App/UI/UiText.cs` (widget)** — this is where the actual + gap is. `Line` needs an additive `Runs` concept for the + MULTI-LINE (`OneLine == false`) path, not just the existing + `OneLine`+`TextRun` path (§1). Minimal shape: extend `Line` (or add + a parallel `RichLine`) to carry `IReadOnlyList` alongside + or instead of a flat `string Text` + single `Vector4 Color`; change + `DrawClippedText`'s multi-line loop (`UiText.cs:636-691`) to, for a + line with runs, do what `DrawSingleLineRuns` already does per-run + (walk runs, accumulate `penX`, call `DrawStringDatPass` per run, + batch all outlines-then-all-fills at the BLOCK level exactly as + `UiText.cs:681-690` already batches across LINES today — extending + that batching one level deeper, across runs within lines, is + mechanical). `HitChar` (`UiText.cs:1031-1050`) needs to additionally + resolve which run (if any) contains the hit `col`, and `OnEvent`'s + `Click` case (`UiText.cs:809-813`) needs a second dispatch path + (run-click, distinct from whole-element `OnClick`) that a controller + (e.g. `ChatWindowController`) can bind to "prefill a tell to this + guid," mirroring how `OnClick` is bound today. + +**Existing abstraction that already almost does this:** +`TextRun`/`RunsProvider`/`DrawSingleLineRuns` (§1, `UiText.cs:55,69,693-754`) +is the closest precedent — it proves the draw-side mechanics (measure +run, accumulate pen, per-run `DrawStringDatPass`, block-batched +outline) already work and are exercised in production by +`CharacterStatController.BuildSelectedTitleRuns` +(`src/AcDream.App/UI/Layout/CharacterStatController.cs:1425-1454`, +wired at `CharacterStatController.cs:1680`) for a skill/attribute title +with a colored numeric delta suffix. It is currently scoped to +`OneLine` only and carries no click/actor payload — extending it to +the multi-line/wrapped path and adding a payload field is smaller than +building a new mechanism from scratch. `DatRichText` +(`src/AcDream.App/UI/Layout/DatRichText.cs`, `Segment(string? Text, +Vector4 Color)` at `DatRichText.cs:40`, `Compose` at +`DatRichText.cs:52-88`) is a second, partially-overlapping precedent: +it already composes multiple colored segments for a multi-line box, +but it word-wraps EACH segment independently and concatenates the +results as separate `Line`s (`DatRichText.cs:83-84`) — so two segments +that would visually share one wrapped row are NOT joined onto that row +today; it solves "multiple colors across a paragraph's several lines," +not "multiple colors sharing one rendered row." A tagged-name-in-chat +feature needs the latter (the name and the rest of the sentence share +row 0 of a possibly-multi-row wrapped message), so neither existing +mechanism is a drop-in — both inform the shape of the fix. + +## 6. Blast radius + +`UiText.Line`/`LinesProvider` is used extremely broadly — 52 files +reference `LinesProvider = ` and 53 reference `UiText.Line(`/`new +UiText()` (full grep list retained below). **If the change is additive** +(new optional `Runs` field/type alongside the existing `Line`, default +behavior unchanged for every caller that keeps returning plain +`Line`s), the blast radius for BEHAVIOR is limited to whichever +controllers opt in (initially: chat only). The blast radius for +BUILD/COMPILE risk (anything that touches `UiText.cs`, `UiElement.cs` +recompiles the whole `AcDream.App` UI layer) and for REVIEW is the full +list below, grouped by category — every one of these should be +smoke-tested after a `UiText`-internal change even if it doesn't touch +their own code: + +- **Chat (the actual feature target):** + `ChatWindowController.cs`, `FloatingChatWindowController.cs`, + `ChatTranscriptRenderer.cs`, `SpewBoxController.cs` + (`src/AcDream.App/UI/SpewBoxController.cs` — retail's other + colored-text-scroll surface, `RetailLogTextType.ClientLocal` per + `ChatVM.cs:159-183`; likely wants the SAME run model eventually since + it renders `LogTextType`-colored lines too). +- **Tooltips:** `RetailTooltipPresenter.cs` — world/UI hover tooltips; + currently plain `Line`s. +- **Appraisal / item & creature reports:** `AppraisalUiController.cs`, + `CreatureAppraisalRows.cs`, `ItemAppraisalReport.cs` — these already + render multi-colored informational text (spell names, damage types) + as SEPARATE `Line`s per colored fragment (one color per whole line, + not per run) — a run model could simplify these, or they could stay + as-is if row-granularity coloring already meets retail fidelity + there (not assessed here — out of this audit's scope). +- **Social panels:** `SocialSquelchPageController.cs`, + `SocialAllegiancePageController.cs`, + `SocialFellowshipPageController.cs`, `SocialFriendsPageController.cs` + — friends/allegiance/fellowship rows; per CLAUDE.md's Campaign FA + notes these already do per-state color swaps on `UiText`, a + different (not run-based) mechanism. +- **Vendor / trade:** `VendorUiController.cs`, + `SecureTradeUiController.cs`. +- **Combat / spellcasting:** `CombatUiController.cs`, + `SpellcastingUiController.cs`, `EffectsUiController.cs`. +- **Dialogs:** `RetailWaitDialogView.cs`, `RetailMessageDialogView.cs`, + `RetailConfirmationDialogView.cs`, + `RetailConfirmationTextInputDialogView.cs`. +- **Character sheet / creation:** `CharacterStatController.cs` (the + existing `TextRun` consumer, §5), `CharacterCreationSkillsPage.cs`, + `CharacterCreationSummaryPage.cs`, `CharacterCreationTownPage.cs`, + `CharacterCreationProfessionPage.cs`, + `CharacterCreationHeritagePage.cs`, + `CharacterManagementUiController.cs`. +- **Options / config:** `ConfigOptionsPageController.cs`, + `ChatOptionsPageController.cs`, `CharacterOptionsPageController.cs`, + `KeyboardConfigController.cs`. +- **Misc panels:** `InventoryController.cs`, `RadarController.cs`, + `MapPageController.cs`, `HousePageController.cs`, + `LinkStatusUiController.cs`, `VitaeUiController.cs`, + `VitalsController.cs`, `RetailFpsController.cs`, + `SelectedObjectController.cs`, `IndicatorDetailText.cs`, + `ComponentBookTemplateFactory.cs`, `EffectRowTemplateFactory.cs`, + `CharacterController.cs`, `DatWidgetFactory.cs` (the factory that + builds every `UiText` from LayoutDesc — touches all of the above by + construction). +- **Tests:** `UiTextTests.cs`, + `SocialFellowshipPageControllerTests.cs`, + `SocialPanelControllerTests.cs`, `RowTemplateResolverTests.cs`, + `DatWidgetFactoryTests.cs`, `CharacterStatControllerTests.cs`, + `VitalsBindingTests.cs`, `AppraisalUiControllerTests.cs` — any of + these that assert on `UiText.Line` shape/count would need review if + `Line`'s shape changes (not if a new type is added additively). + +**Net:** an ADDITIVE seam (new run-carrying line type, existing `Line` +untouched) keeps the functional blast radius to chat (and optionally +SpewBox) while still requiring the whole `AcDream.App/UI` tree to +rebuild/retest since it all depends on `UiText.cs`/`UiElement.cs`. A +seam that changes `Line`'s existing shape would force a review pass +across every file in the list above. + +## What this audit did NOT do + +- Did not research retail's own tagged-glyph-run mechanism (separate + agent's lane per the task brief). +- Did not propose or write any code change — `Line`/`TextRun`/`Segment` + shapes above are illustrative sketches for sizing, not a spec. +- Did not assess whether `AppraisalUiController`/`CreatureAppraisalRows`'s + existing one-color-per-`Line` approach is already retail-faithful for + their own content (out of scope; flagged only as a blast-radius + member). diff --git a/docs/research/2026-08-21-chat-tag-click-dispatch.md b/docs/research/2026-08-21-chat-tag-click-dispatch.md new file mode 100644 index 00000000..41d183fb --- /dev/null +++ b/docs/research/2026-08-21-chat-tag-click-dispatch.md @@ -0,0 +1,543 @@ +# Chat log click-to-tag dispatch (retail, Sept 2013 EoR build) + +Research-only. Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` +(PDB-named pseudo-C) and `docs/research/named-retail/acclient.h` (verbatim +retail struct layouts). Every claim below is cited `symbol @ 0xADDRESS`. +Binary Ninja's rendering caveats (misleading compare idioms, ~33-char +truncated inline strings) are called out inline wherever they bit this +investigation. + +## TL;DR + +The suspected anchor `ChatInterface::SetReplyTextInChatBox @ 0x004F4760` is +**not** the click handler. It is a keyboard text-replacement macro +(`/t `, `/tell `, `reply ` + space → `@tell ,`) wired through +`ChatInterface::HandleTextReplacements @ 0x004F50D0`, itself fired from a +"character typed" UI broadcast, not a mouse event. It happens to share the +`"@tell %s, "` idea with the real click path but is a separate code path +with separate (looser) text. + +The real click path is a generic, polymorphic **tag** system: + +``` +mouse button up over a UIElement_Text + → UIElement_Text::MouseUp @ 0x004694F0 + → UIElement_Text::DeterminePositionFromXY @ 0x004688F0 (screen xy → glyph index) + → GlyphList::InqGlyph @ 0x00473430 (glyph index → Glyph, incl. m_tag) + → TextTag::HandleClick (virtual, vtable+0x14) (dispatch by tag TYPE) + TextTag_IIDString::HandleClick @ 0x00478840 + → ECM_UI::SendNotice_TextTag_IIDStringClick @ 0x006927C0 + → gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10 + (gate: tag m_type == 0x10000001, chat entry not already focused) + → ChatInterface::StartTell @ 0x004F41F0 + (writes "@tell , " into the entry, focuses it) +``` + +Player/speaker names in chat are wrapped by the server/client text +formatters in a `:>displayText<\Tell>` markup +span (note: closing marker is a **backslash**, `<\Tell>`, not a +forward-slash — confirmed from the raw literal at +`data_7d0bfc @ 0x007D0BFC` etc.). This markup is used for direct tells, +channel "says" lines, `[Fellowship]`, `[Co-Vassals]`, patron/vassal lines, +and (per the generic `[%ws] ...` format at +`data_7e83e8 @ 0x007E83E8`) ordinary named-channel chat too — i.e. **every** +chat line that shows a speaker name embeds the same tag, not just tells. +Clicking any of them always opens a **tell**, regardless of which channel +the line came from. + +--- + +## 1. Click → glyph → tag resolution + +### 1a. Entry point: `UIElement_Text::MouseUp` + +`UIElement_Text::MouseUp @ 0x004694F0` is registered directly in +`UIElement_Text`'s vtable slot for `MouseUp` (confirmed at the vtable dump, +e.g. `0079C1A4: MouseUp = UIElement_Text::MouseUp`). The relevant tail, +reached only when the mouse-up's button id was previously recorded as +mouse-down over this same element (`cond:0`, looked up in +`this->m_mouseDownTable` keyed by the button id `arg4`): + +```c +// UIElement_Text::MouseUp @ 0x004694F0, tail (0x0046959C-0x004695DD) +if (eax_4 != 0) // cond:0 — this button's mouse-down WAS on this element +{ + uint32_t eax_6 = UIElement_Text::DeterminePositionFromXY(this, ebp_2, edi_2); + Glyph var_24; + if (GlyphList::InqGlyph(&this->m_glyphList, eax_6, &var_24) != 0 && var_4 != 0) + *(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4); // var_4->HandleClick(arg4) + Glyph::~Glyph(&var_24); +} +``` + +`ebp_2`/`edi_2` are the mouse position converted to element-local, +margin-adjusted coordinates a few lines earlier in the same function: + +```c +int32_t ebp_2 = ((arg2 - this->m_margL) - UIRegion::GetScreenX0(this)); +int32_t edi_2 = ((arg3 - this->m_margU) - UIRegion::GetScreenY0(this)); +``` + +`arg2`/`arg3` are the raw screen-space mouse coordinates passed down from +the UI event system; `m_margL`/`m_margU` are the element's left/top text +margins (`UIElement_Text` struct, `acclient.h:53412-53415`). + +**BN caveat**: `var_4` (the pointer used for the `var_4 != 0` check and the +virtual call) is never shown being assigned in the decompiled output — it +is almost certainly `var_24.m_tag` after `GlyphList::InqGlyph` copies the +found `Glyph` into `var_24` via `Glyph::operator=`, but the copy-into-field +step is not visible in this rendering. This is exactly the kind of +"misleading compare idiom" the project's BN caveat warns about — flagged +rather than silently assumed. The surrounding evidence (struct layout, +vtable offset match below) makes this the only coherent reading, but it is +not a directly-visible assignment. + +### 1b. Screen XY → glyph index: `UIElement_Text::DeterminePositionFromXY` + +`UIElement_Text::DeterminePositionFromXY @ 0x004688F0`: + +```c +int80_t UIElement_Text::DeterminePositionFromXY(this, arg2 /*local x*/, arg3 /*local y*/) +{ + UIElement_Text::RecalculateGlyphList(this); // ensure wrapped-line layout is current + int32_t scrolledY = this->m_iScrollableY + arg3; // undo vertical scroll offset + uint32_t line = 0; + GlyphList::FindLineFromY(&this->m_glyphList, scrolledY, &line); // which wrapped line + uint32_t lineWidthPx = 0; + GlyphList::GetGlyphLineWidth(&this->m_glyphList, line, &lineWidthPx); // that line's pixel width + int32_t lineLocalX = (this->m_iScrollableX + arg2) + - UIElement_Text::CalcJustification(this, lineWidthPx, 1); // undo h-scroll + justification + uint32_t glyphIndex = 0; + GlyphList::FindPosFromLineAndPixels(&this->m_glyphList, line, lineLocalX, 1, &glyphIndex); + // clamp to end-of-text + return min(glyphIndex, this->m_glyphList.m_glyphList._num_elements); +} +``` + +Plain-language: convert the click's local (x, y) into a *scrolled* position +by adding back however far the text view has been scrolled; use the +scrolled Y to find which **wrapped display line** was clicked +(`GlyphList::FindLineFromY @ 0x00472770`); measure that line's pixel width +(`GlyphList::GetGlyphLineWidth @ 0x00472930`) so the justification offset +(left/center/right alignment, `UIElement_Text::CalcJustification @ +0x00467260`) can be subtracted back out of the X; then walk that line's +glyph advances to find which **character index** the X pixel falls on +(`GlyphList::FindPosFromLineAndPixels @ 0x004732D0`). The result is a +single integer: "the click landed on/before character N of the full +(unwrapped) text buffer." + +### 1c. Glyph index → Glyph (and its tag): `GlyphList::InqGlyph` + +`GlyphList::InqGlyph @ 0x00473430`: + +```c +uint8_t GlyphList::InqGlyph(this, arg2 /*index*/, arg3 /*out Glyph*/) +{ + ListNode* node = this->m_glyphList._head; + if (node == 0 || arg2 >= this->m_glyphList._num_elements) return 0; + for (int i = 0; i != arg2; i++) { node = node->next; if (node == 0) return 0; } + Glyph::operator=(arg3, node); // copy the whole Glyph struct, incl. m_tag + return 1; +} +``` + +A straight O(n) linked-list walk (the glyph list is a `List`, not an +array) to the Nth glyph, then a struct copy. The `Glyph` layout +(`acclient.h:45330-45338`): + +```c +struct __cppobj Glyph +{ + unsigned __int16 m_data; // the character code + int m_width; + int m_height; + RGBAColor m_color; // per-glyph color, baked in at append time (see §4) + Font *m_font; + TextTag *m_tag; // non-null only for glyphs inside a <...> tag span +}; +``` + +`m_tag` is set by `Glyph::SetTag @ 0x00474920` while the glyph list is +built from raw text (see §1d), and cleared in bulk by +`GlyphList::RemoveTextTag @ 0x00472BB0` (walks every glyph, clears any +whose `m_tag` matches the tag being removed — used when a tagged span is +truncated/deleted from the log). + +### 1d. Building tags from markup: `TextTagFactory::MakeTag` + +Text is appended to a `UIElement_Text` glyph list via +`UIElement_Text::InqGlyphs @ 0x00468EA0`. Its char-scan loop treats `<` +(0x3C) as the start of a tag span: it accumulates characters up to the +matching `>` (0x3E) into a string, then calls +`TextTagFactory::MakeTag @ 0x00478480` on that whole inner string (e.g. +`"Tell:IIDString:1234:PlayerName"`): + +```c +TextTag* TextTagFactory::MakeTag(PStringBase const* tagBody) +{ + // tagBody looks like "TypeName:ShapeName:payload..." + if (!FindChar(tagBody, ':')) return 0; + typeNameStr = substring-before-first-colon; + if (EnumMapper::InqEnum(0x18 /*category*/, typeNameStr, &tagType) == 0) return 0; // e.g. "Tell" -> 0x10000001 + + if (!FindChar(rest, ':')) return 0; + shapeNameStr = substring-before-second-colon; // e.g. "IIDString" + if (EnumMapper::InqEnum(0x18, shapeNameStr, &shapeId) == 0) return 0; // 1..4 + + switch (shapeId) { + case 1: result = new TextTag_DID(); break; + case 2: result = new TextTag_IID(); break; + case 3: result = new TextTag_IIDEnum(); break; + case 4: result = new TextTag_IIDString(); break; + } + result->m_type = tagType; // from the FIRST lookup ("Tell" -> 0x10000001) + result->m_format = shapeId; + result->ParseStartTag(remaining payload); // shape-specific: fills TextTag_IIDString::m_IID/m_string, etc. + return result; +} +``` + +(Full disassembly at `docs/research/named-retail/acclient_2013_pseudo_c.txt:132871-133065`; +the two-colon split and the two `EnumMapper::InqEnum(..., 0x18, ...)` calls +are visible at `0x004784E6-0x00478545` and `0x00478577-0x004785DA`.) The +resulting `TextTag*` is stashed on every `Glyph` inside the span via +`Glyph::SetTag @ 0x00474920` as `InqGlyphs` walks the display characters +between the tag's `>` and its closing `<\...>`. + +**UNKNOWN — needs a DAT/cdb dump**: `EnumMapper::InqEnum`'s category `0x18` +is a data-driven (DAT-resident, likely `client_portal.dat` StringTable/ +EnumMapper resource) name↔id table — the code only proves the *mechanism*, +not the full roster of type-name strings it accepts. We confirmed "Tell" +(→ `m_type == 0x10000001`) and "IIDString" (→ shape id 4, inferred — see +§ "Other tag types" below) from literal format strings elsewhere in the +binary, but did not independently dump the table itself. + +### 1e. Vtable-offset proof that `var_4` is the tag and `+0x14` is `HandleClick` + +The `TextTag` vtable layout, read straight from the four subclass vtable +dumps (`docs/research/named-retail/acclient_2013_pseudo_c.txt:959321-959368`): + +| offset | slot | `TextTag_DID` | `TextTag_IIDString` | `TextTag_IIDEnum` | `TextTag_IID` | +|---|---|---|---|---|---| +| 0x00 | `__vecDelDtor` | ✓ | ✓ | ✓ | ✓ | +| 0x04 | `ParseEndTag` | `TextTag::ParseEndTag` (shared) | shared | shared | shared | +| 0x08 | `ParseStartTag` | own | own | own | own | +| 0x0C | `BuildEndTag` | `TextTag::BuildEndTag` (shared) | shared | shared | shared | +| 0x10 | `BuildStartTag` | `TextTag::BuildStartTag` (shared) | shared | shared | shared | +| **0x14** | **`HandleClick`** | **own** | **own** | **own** | **own** | +| 0x18 | `BuildStartTagData` | own | own | own | own | + +`+0x14` is exactly `HandleClick`, confirming `MouseUp`'s +`*(uint32_t*)(*(uint32_t*)var_4 + 0x14)(arg4)` is `var_4->HandleClick(arg4)` +— a virtual call, i.e. this is dispatched **per concrete tag subclass**, +not a single hardcoded action. + +--- + +## 2. What fires on click: generic dispatch, not hardcoded to tells + +All four subclasses' `HandleClick` do the same shape of thing — forward to +a global "notice" (AC's internal pub/sub event system) carrying the tag's +type + payload, nothing else: + +```c +// TextTag_DID::HandleClick @ 0x00478740 +ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id); // @ 0x006926D0 + +// TextTag_IID::HandleClick @ 0x00478E80 +ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID); // @ 0x00692720 + +// TextTag_IIDEnum::HandleClick @ 0x00478B40 +ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum); // @ 0x00692770 + +// TextTag_IIDString::HandleClick @ 0x00478840 +ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string); // @ 0x006927C0 +``` + +So the dispatch **is** generic — any listener can register for any of the +four notices and react to any `m_type`. In this build, though, only ONE of +the four notices has an actual (non-stub) listener anywhere in the client: + +- `gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` — real, + wired to chat's click-to-tell (see §3). +- `NoticeHandler::RecvNotice_TextTag_IIDEnumClick @ 0x006A0240` is declared + `__pure` — a pure-virtual stub, no base behavior, and no override for it + was found anywhere in this pass. +- No function definition for a real `RecvNotice_TextTag_DIDClick` or + `RecvNotice_TextTag_IIDClick` override exists anywhere in the pseudo-C + file either — every other hit for those names is vtable-slot noise (the + decompiler filling unresolved thunk slots with neighboring symbol names; + cross-checked, none are real function bodies with those signatures). + +**Conclusion**: the mechanism is generic (4 tag shapes × arbitrary +`m_type` values × arbitrary listeners), but in this Sept 2013 build only +the chat-log "clickable speaker name → start a tell" feature is actually +wired up end-to-end. `TextTag_DID`/`TextTag_IID`/`TextTag_IIDEnum` exist, +parse, and would dispatch correctly if clicked, but nothing in the client +reacts to their click notices — **UNKNOWN whether item links / URLs / +coordinates use these shapes in later builds or via server-composed text +we didn't grep for**; no evidence of them was found in this pass. + +### Other tag types found (full roster) + +| Class | Shape id (inferred) | Ctor | `HandleClick` | Real listener found? | +|---|---|---|---|---| +| `TextTag_DID` | 1 | `0x00478760` | `0x00478740` | No | +| `TextTag_IID` | 2 | `0x00478E60` | `0x00478E80` | No | +| `TextTag_IIDEnum` | 3 | `0x00478B20` | `0x00478B40` | No | +| `TextTag_IIDString` | 4 | `0x00478860` | `0x00478840` | **Yes** — `gmMainChatUI` | + +Shape-id-to-class mapping is inferred from the `switch(shapeId){case +1..4}` construction order in `MakeTag` (`0x00478632`/`0x004785E1`/ +`0x004785FC`/`0x00478617`) plus the fact that the only shape name we can +directly read from format strings ("IIDString") is used everywhere the +"Tell" markup appears, which always constructs `TextTag_IIDString`. This +is strong circumstantial evidence, not a direct read of the DAT enum +table — flagged per the "no guessing" rule. + +--- + +## 3. The prefill itself: `ChatInterface::StartTell` + +`gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004CCE10` is the +registered listener for `SendNotice_TextTag_IIDStringClick`: + +```c +void gmMainChatUI::RecvNotice_TextTag_IIDStringClick(this, uint32_t type, uint32_t iid, PStringBase const* name) +{ + if (type == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0) + ChatInterface::StartTell(this, name); +} +``` + +Two gates: (a) the tag's semantic type must be `0x10000001` — i.e. only +"Tell"-markup spans do anything on click, other `m_type` values on an +`IIDString` tag (if any exist) are silently ignored here; (b) the chat +**entry box must not already have keyboard focus** — if the player is +mid-sentence typing something else, clicking a name in the log does +nothing (`ChatInterface::IsTextEntryFocused @ 0x004F30A0`, which checks +`UIElementManager`'s active/focused element against `this->m_chatEntry`). +Note the tag's own `iid` payload (`arg3`/`m_IID`) is read into the +parameter list but **never used** by this handler — only the embedded name +string matters. + +`ChatInterface::StartTell @ 0x004F41F0`: + +```c +void ChatInterface::StartTell(this, PStringBase const* name) +{ + PStringBase text = Formatted(u"@tell %s, ", name); // note: trailing space after comma + this->m_chatEntry->vtable->Activate(); + this->m_chatEntry->vtable->TakeFocus(); // <-- keyboard focus moves to the chat entry + CM_UI::SendNotice_ToggleChatEntry(1); // <-- ensures the chat entry bar is shown + UIElement_Text::SetText(this->m_chatEntry, &text); + UIElement_Text::MoveCursorToPosition(this->m_chatEntry, /* length of `text` */); + UIElement_Text::ClearSelection(this->m_chatEntry); +} +``` + +So, precisely: + +- **Text placed**: `"@tell , "` — literal `@tell`, a space, + the name, a comma, and a **trailing space** (ready to type the message + body immediately). +- **Focus**: YES, explicitly changed. `Activate()` + `TakeFocus()` move + keyboard focus into the chat entry field, and + `CM_UI::SendNotice_ToggleChatEntry(1) @ 0x0047A200` broadcasts a notice + whose real handler, `ClientUISystem::RecvNotice_ToggleChatEntry @ + 0x00564200`, is what shows/expands the chat entry bar if it was + currently hidden (confirmed as the one non-stub override among many + vtable-slot look-alikes for that notice name). +- **Cursor**: placed at the end of the inserted text (right after the + trailing space), any prior selection cleared. +- Whatever the player had already typed into the box (if it wasn't + focused — see the focus gate above) is **replaced outright**, not + merged or prepended to. + +--- + +## 4. Hover behavior: color is static per-tag-type, not a hover effect + +`UIElement_Text::MouseMove @ 0x004695F0` was checked directly — it does +**no** glyph/tag lookup at all. It only handles active text-selection +dragging (`this->m_bitField & 0x40`) or falls back to the base +`UIElement::MouseMove`. `UIElement_Text::GetShouldBeMouseVisible @ +0x00467460` likewise only inspects `this->m_bitField & 5` (an +editable/selectable flag), not glyph tags. **No hover-triggered highlight, +brightening, or cursor-icon change tied to `Glyph::m_tag` was found +anywhere in this pass.** + +What *is* real, and is presumably what reads as "the name is green," is a +**static per-glyph color chosen at glyph-construction time**, based on +whether the glyph belongs to an active `0x10000001`-typed tag span. Inside +`UIElement_Text::InqGlyphs @ 0x00468EA0`, right after a tag span is +opened/continues: + +```c +// 0x00469084-0x0046908A, ebx_1 = the currently-active TextTag* for this glyph (0 if none) +if (ebx_1 == 0 || ebx_1->m_type != 0x10000001) + color = &this->m_curFontColor; // offset 0x6A4 on UIElement_Text +else + color = &this->m_curTagFontColor; // offset 0x6B8 on UIElement_Text +// ... color is then baked into the new Glyph's m_color field +``` + +`UIElement_Text`'s struct (`acclient.h:53392-53420`) confirms two distinct +color fields exist: `RGBAColor m_curFontColor;` and +`RGBAColor m_curTagFontColor;`, set via +`UIElement_Text::SetFontColorHelper(this, attrId, &field, colorIndex)` +with **different authored attribute ids** — `0x1B` for `m_curFontColor`, +`0x1D` for `m_curTagFontColor` (seen consistently at +`UIElement_Text::AppendTextWithFont @ 0x00469D70` and +`AppendStringInfoWithFont @ 0x00469DE0`). `SetFontColorHelper @ +0x00466AC0` treats its 4th argument as an **index into an authored color +array** (an `InqProperty`-backed attribute, not a raw RGBA value) — so +`FontColor` and `TagFontColor` are two independently-authored per-window +color tables (almost certainly LayoutDesc-driven, consistent with this +project's existing DAT-driven-UI findings), and can hold different colors +at the same index. That is the entire "green name" mechanism: **it's +baked into the glyph once, from data, when the text is appended — not +computed or changed on mouse hover.** + +`m_curTagFontColor` defaults to `RGBAColor_White` (`0x00468641`) unless a +window's layout overrides attribute `0x1D` — chat windows presumably do. + +**UNKNOWN — needs DAT/runtime inspection**: the actual authored RGBA +values (attribute `0x1D`'s color table) live in a LayoutDesc DAT resource, +not in code; not dumped in this pass. Cross-reference +`claude-memory/reference_retail_chat_colors.md` for retail chat-color +ground truth already captured via cdb. + +--- + +## 5. Is `SetReplyTextInChatBox` the click handler? No — keyboard macro only + +`ChatInterface::SetReplyTextInChatBox @ 0x004F4760` has exactly one +caller in the whole binary: `ChatInterface::HandleTextReplacements @ +0x004F50D0`, which tries three "quick reply" expanders in order: + +```c +void ChatInterface::HandleTextReplacements(this) +{ + if (this->m_chatEntry == 0) return; + if (!ChatInterface::SetReplyTextInChatBox(this)) // @ 0x004F4760 — replies to LAST TELLER + if (!ChatInterface::SetMonarchReplyTextInChatBox(this)) // @ 0x004F4B70 — replies to monarch + ChatInterface::SetPatronReplyTextInChatBox(this); // @ 0x004F4EA0 — replies to patron +} +``` + +`HandleTextReplacements` is itself called from exactly one place: +`ChatInterface::ListenToElementMessage @ 0x004F51C0`, `case 0x11` (i.e. +`idMessage == 0x12`), gated on `arg2->dwParam1 == 0x20` (ASCII space) and +the message originating from the chat entry element: + +```c +case 0x11: // idMessage == 0x12 + if (arg2->pElement == this->m_chatEntry /* decompiled as m_fCurrentOpacity, mis-attributed field */ + && arg2->dwParam1 == 0x20) + ChatInterface::HandleTextReplacements(this); + break; +``` + +Message id `0x12` is confirmed elsewhere as the "character typed" +broadcast: `UIElement_Text::CharacterHandler @ 0x00469B90` ends its +non-control-character path with +`UIElement::BroadcastElementMessage(this, 0x12, typedChar, 0) @ +0x00469CAF` — `dwParam1` carries the raw character code. So this whole +path only fires **while the player is typing into the chat entry box and +presses the SPACE bar**, and only after typing one of a small set of +recognized prefixes. + +`SetReplyTextInChatBox` itself: reads the entry's current (trimmed) text, +checks whether it starts with `/` or `@`, and if so, matches the +first-word substring against known shortcut prefixes (`"t"`/`"te"`-style +1–2 char abbreviations at `data_7c4c70`/`data_7c4c68`, and the literal +`u"reply "` at `0x004F4A04`). If matched, it replaces that recognized +prefix (leaving anything typed after it in place) with: + +```c +gmCCommunicationSystem::GetLastTellerName(...) @ 0x00589550 // whoever LAST sent YOU a tell +Formatted(u"@tell %hs,", lastTellerName) // note: NO trailing space, %hs = narrow string +UIElement_Text::SetText / MoveCursorToPosition / ClearSelection // @ 0x004F4ADC-0x004F4AFA +``` + +This is a **different string** from the click path's `"@tell %s, "` (no +trailing space here; `%hs` explicitly narrow-string-formats +`GetLastTellerName`'s `PStringBase*` return, vs. the click path's +already-wide `PStringBase` name) — a small but real +divergence between the two "start a tell" paths worth preserving if both +get ported. `SetMonarchReplyTextInChatBox @ 0x004F4B70` and +`SetPatronReplyTextInChatBox @ 0x004F4EA0` are structurally identical, +sourcing the name from `GetLastAtMonarchUserName`/`GetLastAtPatronUserName` +instead. + +**Verdict**: `SetReplyTextInChatBox` is a **keyboard-shortcut/text-macro +handler only** — triggered by typing a recognized prefix then a space in +the chat entry. It shares the "write `@tell Name,` into the entry" idea +with the click path but is a wholly separate call chain, keyed off "last +person who told me something" global state +(`gmCCommunicationSystem::SetLastTeller @ 0x005891A0`, +`SetLastTellerName @ 0x00589500`) rather than the specific name embedded +in the clicked chat line's tag. It is **not** invoked by, and does not +invoke, any part of the click-to-tag chain in §1–§3. + +A consequence worth flagging for the port: because the click path reads +the name baked into that *specific* chat line's tag, clicking an **old** +"X tells you" line further up the scrollback still starts a tell to X, +even if X is no longer whoever last told you something — whereas the +`/t `+space keyboard shortcut always resolves to the single global +"last teller," which could be a different person by then. + +--- + +## 6. Where the clickable markup comes from (bonus — answers "why does this reproduce on so many message types") + +The `:>displayText<\Tell>` span is built by +`sprintf`-style formatting at multiple sites, not just for direct tells. +Representative literals (some inline strings are BN-truncated at ~33 +chars, marked `…`): + +| Format string (verbatim where fully visible) | Address | Used for | +|---|---|---| +| `"%s<\Tell> tells you, \"%s\"\n"` | `data_7d0ec0 @ 0x007D0EC0` | direct tell received | +| `"%s<\Tell> says, \"%s\"\n"` | `data_7d0e60 @ 0x007D0E60` | local/say-range speech | +| `"[Fellowship] %s<\\Tell> says, \""` | `data_7d0cdc @ 0x007D0CDC` | fellowship chat | +| `"[Co-Vassals] %s<\\Tell> says, \""` | `data_7d0bfc @ 0x007D0BFC` | co-vassal chat | +| `"[Allegiance Broadcast] %s<\\Tell> says, \""` | `data_7d0c30 @ 0x007D0C30` | allegiance broadcast | +| `"Your patron %s<\\Tell> says to you, \""` | `data_7d0d10 @ 0x007D0D10` | patron chat | +| `"Your vassal %s<\\Tell> says to you, \""` | `data_7d0d4c @ 0x007D0D4C` | vassal chat | +| `"Your follower %s<\\Tell> says to you, \""` | `data_7d0ca0 @ 0x007D0CA0` | follower chat | +| `"[%ws] %ws<\Tell> says, \"%ws\""` | `data_7e83e8 @ 0x007E83E8` | generic named channel say (channel name in `[%ws]`) | + +Confirms the closing marker is a literal backslash `<\Tell>` in the raw +string data (not the HTML-style `` one might assume), and that the +non-direct-tell variants hardcode the `iid` field to `0` (the click +handler ignores `iid` anyway, so this has no functional effect — but it +means the tag's `m_IID` is meaningless/decorative for anything except +direct tells). The gating logic for direct tells +(`docs/research/named-retail/acclient_2013_pseudo_c.txt:382537-382553`, +`gmCCommunicationSystem`-adjacent code around `0x00571880`) only emits the +tagged, clickable form when the sender's id falls in the player-character +GUID range (`0x50000001`-`0x6FFFFFFF`); tells attributed to ids outside +that range fall back to the plain, non-clickable +`"%s tells you, \"%s\"\n"` format (`0x00571880` false branch) — so +non-player "tells" (system/GM broadcast-as-tell, etc.) are never +clickable. + +--- + +## Open gaps (explicitly not resolved here) + +- **UNKNOWN**: the full roster of `EnumMapper` category `0x18` type-name + strings (only "Tell" and, by strong inference, "IIDString" are + confirmed). A DAT dump or a cdb breakpoint on `EnumMapper::GetString` + with category `0x18` would enumerate the rest and settle whether item + links/URLs/coordinates exist as other `m_type` values on the same + `IIDString` shape, or as `DID`/`IID`/`IIDEnum` shapes instead. +- **UNKNOWN**: authored RGBA values behind `m_curFontColor`/ + `m_curTagFontColor` attribute ids `0x1B`/`0x1D` for the chat log window + specifically — lives in a LayoutDesc DAT resource, not code. +- **Confirmed absence, not a gap**: no hover-only visual/cursor change was + found for tagged glyphs in this build (`MouseMove`, + `GetShouldBeMouseVisible` both checked directly and neither look at + `Glyph::m_tag`). diff --git a/docs/research/2026-08-21-chat-texttag-model.md b/docs/research/2026-08-21-chat-texttag-model.md new file mode 100644 index 00000000..9ca1f655 --- /dev/null +++ b/docs/research/2026-08-21-chat-texttag-model.md @@ -0,0 +1,849 @@ +# Retail chat TextTag / glyph-tag model — data model, lifetime, colour rule + +Research-only. No source was modified for this document. All addresses are +from the Sept 2013 EoR build (`refs/acclient.pdb` / `acclient.exe` v11.4186, +CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`), as decompiled in +`docs/research/named-retail/acclient_2013_pseudo_c.txt` (pseudo-C) and +`docs/research/named-retail/acclient.h` (verbatim retail struct headers). +Every claim below cites `symbol @ 0xADDRESS`; anything not directly +observed in the decompile is marked **UNKNOWN**. + +This document explains the mechanism behind the retail behaviour: a +speaker's name inside a chat line renders in a different colour and is +clickable (click → prefill a tell to that person). The mechanism is a +**glyph-level tag model**, not per-line colouring. `UIElement_Text` +(the class backing chat log / most retail text widgets) keeps a list of +`Glyph` structs, one per character, and each `Glyph` optionally points at +a shared, reference-counted `TextTag` object. A **contiguous run of +glyphs sharing the same `TextTag*` pointer** is what gets the special +colour and the click behaviour — there is no separate "run" or "span" +object; identity is pointer equality on `Glyph::m_tag`, discovered by +linear walk every time it matters. + +--- + +## 1. The `TextTag` type family + +### 1.1 Struct layout + +``` +acclient.h:45358 +struct __cppobj TextTag : ReferenceCountTemplate<1048576,0> +{ + unsigned int m_type; + unsigned int m_format; +}; +``` + +`ReferenceCountTemplate<1048576,0>` (`acclient.h:7974`) is: + +``` +struct __cppobj ReferenceCountTemplate<1048576,0> +{ + ReferenceCountTemplate<1048576,0>Vtbl *vfptr; // +0x0 + unsigned int m_cRef; // +0x4 +}; +``` + +So on a live `TextTag`, `vfptr` is at `+0x0`, `m_cRef` at `+0x4`, +`m_type` at `+0x8`, `m_format` at `+0xc`. Those exact offsets are used +directly by the pseudo-C at several sites cited below (e.g. +`*(uint32_t*)((char*)result + 8) = var_14` for `m_type`, +`*(uint32_t*)((char*)ebx_1 + 8) != 0x10000001` for a runtime `m_type` +comparison), which cross-checks the struct layout against the header. + +`TextTagType` (the type of `m_type`/`m_format`) is only ever typedef'd: + +``` +acclient.h:62585 +typedef unsigned int TextTagType; +``` + +No named enum for it survived in the PDB (`grep`'d `TextTagType|TAG_TYPE| +eTextTag` across `acclient.h` returns only that one typedef line). See +§7 for what this means for the `0x10000001` sentinel. + +### 1.2 Four concrete subclasses — what identifies/distinguishes a tag + +``` +acclient.h:53947 struct __cppobj TextTag_IID : TextTag { unsigned int m_IID; }; +acclient.h:53953 struct __cppobj TextTag_IIDEnum : TextTag { unsigned int m_IID; unsigned int m_enum; }; +acclient.h:53960 struct __cppobj TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase m_string; }; +acclient.h:53967 struct __cppobj TextTag_DID : TextTag { IDClass<_tagDataID,32,0> m_DID; }; +``` + +So a `TextTag` is a small, polymorphic, ref-counted "click payload" +object. Its identity as far as glyphs/runs are concerned is just its +**pointer value** (see §2). Its semantic identity — what clicking it +actually means — is carried by the concrete subclass's extra field(s): + +- `TextTag_DID` — wraps a `DataID` (a DAT-file object reference). +- `TextTag_IID` — wraps an `IID` (an in-world Instance ID, i.e. a live + object/creature/player's server-assigned id). +- `TextTag_IIDEnum` — an `IID` plus an `enum` (a secondary + small-integer qualifier). +- `TextTag_IIDString` — an `IID` plus a `PStringBase` + (a wide string) — **this is the shape used for a clickable player + name**: `m_IID` is the speaker's object id, `m_string` most plausibly + carries their display name for building the tell command (see §6). + +Each subclass overrides a fixed 7-slot vtable (`__vecDelDtor`, +`ParseEndTag`, `ParseStartTag`, `BuildEndTag`, `BuildStartTag`, +`HandleClick`, `BuildStartTagData` — confirmed layout dumped verbatim at +`acclient_2013_pseudo_c.txt:959321-959375`, e.g. `TextTag_DID::`vftable'' +@ `0x0079e09c`). + +**Decompiler artifact to flag**: in the vtable dump, `TextTag_IID`'s +`ParseStartTag`/`BuildStartTagData` slots point at +`TextTag_DID::ParseStartTag` / `TextTag_DID::BuildStartTagData` +(`acclient_2013_pseudo_c.txt:959363,959367`), not at distinct +`TextTag_IID::*` functions. This is almost certainly MSVC identical-code +folding (COMDAT folding) — `TextTag_IID`'s parse/build logic for a bare +32-bit `m_IID` is byte-identical to `TextTag_DID`'s for a bare 32-bit +`m_DID.id`, so the linker merged them and the PDB can only attribute the +merged function to one of the two symbols. Treat this as "same code, +shared by both classes," not "IID delegates to DID." + +### 1.3 Factory / parsing — `TextTagFactory::MakeTag` + +``` +acclient_2013_pseudo_c.txt:132871 +00478480 class TextTag* TextTagFactory::MakeTag(class PStringBase const* arg1) +``` + +Given the text between `<` and `>` (the delimiters are stripped by the +caller — see §3), `MakeTag`: + +1. Finds the first `:` in the substring (`FindChar(':')`, + `acclient_2013_pseudo_c.txt:132904`). If none is found, parsing fails + and `MakeTag` returns `0` (`return 0;` @ `0x478700`, + `acclient_2013_pseudo_c.txt:133064`). **This is the mechanism an "end + tag" uses to close a run — see §3.2.** +2. Resolves the substring before the first `:` to an enum value via + `EnumMapper::InqEnum` (`acclient_2013_pseudo_c.txt:132921`). +3. Finds the *second* `:` and resolves that substring to a second enum + value, also via `EnumMapper::InqEnum` + (`acclient_2013_pseudo_c.txt:132954`), then `switch`es on it + (`acclient_2013_pseudo_c.txt:132955`) to allocate one of the four + concrete subclasses: + +``` +acclient_2013_pseudo_c.txt:132955-133051 (paraphrased switch table) +case 1: result = TextTag_DID::TextTag_DID(...) +case 2: result = TextTag_IID::TextTag_IID(...) +case 3: result = TextTag_IIDEnum::TextTag_IIDEnum(...) +case 4: result = TextTag_IIDString::TextTag_IIDString(...) +``` + +4. Stores the two resolved enum values into the new object: + +``` +acclient_2013_pseudo_c.txt:132981-132982 +*(int32_t*)((char*)result + 8) = var_14; // m_type = first EnumMapper::InqEnum result +*(int32_t*)((char*)result + 0xc) = var_18; // m_format = second EnumMapper::InqEnum result (== the switch discriminant, 1-4) +``` + +5. Delegates the remainder of the string (after the second `:`) to the + new object's own `ParseStartTag` virtual (via the vtable, at + `acclient_2013_pseudo_c.txt:133008`, + `*(int32_t*)((char*)vtable + 8)(__return)` — vtable slot `+0x8` = + `ParseStartTag` per the layout in §1.1) to consume the + type-specific payload (the `IID`/`DID`/`enum`/`string` fields). + If that fails, the freshly-allocated tag is released + (`ReferenceCountTemplate<1048576,0>::Release(result)` @ + `acclient_2013_pseudo_c.txt:133016`) and no tag is produced for this + span. + +So the overall wire format `MakeTag` parses is +**`TYPE_NAME:FORMAT_NAME:PAYLOAD`**, where `TYPE_NAME` selects `m_type` +(a semantic category — see §7) and `FORMAT_NAME` selects which concrete +subclass parses `PAYLOAD` (`m_format` doubles as "which of the four +built-in payload shapes this is"). + +Round-trip confirmation comes from `TextTag::BuildStartTag` (the +inverse operation, used when a tagged region is serialized back to +text — see §2.3): + +``` +acclient_2013_pseudo_c.txt:133619-133669 (TextTag::BuildStartTag @ 0x478fe0) +eax_1 = EnumMapper::InqString(0x18, this->m_type, &...); // name for m_type +eax_6 = EnumMapper::InqString(0x18, this->m_format, &...); // name for m_format +this->vtable->BuildStartTagData(&...); // subclass-specific payload text +PStringBase::sprintf(arg2, u"<%ls:%ls%:%ls>"); +``` + +(`u"<%ls:%ls%:%ls>"` — the stray `%` immediately after the second `%ls` +is very likely a Binary Ninja string-literal rendering artifact, not a +real extra `%` in the format string; the surrounding logic only ever +supplies three substitutions. **Flagged as uncertain** — resolving it +precisely would need a raw byte dump of the `.rdata` string at its +address rather than BN's decompiled string preview.) + +`EnumMapper::InqString`/`InqEnum` both route through a *table id* +argument of `0x18` (`acclient_2013_pseudo_c.txt:133627,133636,133725, +133364`, and `EnumMapper::InqEnum`'s call site in `MakeTag` at +`acclient_2013_pseudo_c.txt:132918-132921`). This table id is what +selects which named-enum table (`m_type`'s table vs. individual +subclass fields' tables) to search — see §7 for why we can't yet name +what string maps to `m_type == 0x10000001`. + +--- + +## 2. Attachment model — per-glyph, not per-run, not per-line + +### 2.1 `Glyph` struct + +``` +acclient.h:45330 +struct __cppobj Glyph +{ + unsigned __int16 m_data; // the character + int m_width; + int m_height; + RGBAColor m_color; // acclient.h:8100 — 4 floats (r,g,b,a), resolved per-glyph at append time + Font *m_font; + TextTag *m_tag; // nullable, shared, ref-counted +}; +``` + +`GlyphList` (`acclient.h:45305`) is a doubly-linked `List` plus a +cached `SmartArray` (line-break layout cache) and some +bookkeeping (`m_nMaxCharacters`, `m_nFirstInvalidPosition`, etc.). +`UIElement_Text` (`acclient.h:53392`) owns exactly one `GlyphList +m_glyphList` (the live/editable text) and a second `GlyphList +m_glTruncate` (used by the truncation machinery — not investigated +further here). + +**There is no `TextTag*` field, run/span object, or index range +anywhere on `GlyphList`, `GlyphLine`, or `UIElement_Text`.** The *only* +place a tag pointer lives is `Glyph::m_tag`, one per character. A +"tagged run" is purely an emergent property: a maximal sequence of +adjacent glyph list nodes whose `m_tag` fields are pointer-equal. + +### 2.2 How code discovers a run boundary + +Every place in the decompile that needs to know "does this edit split a +tagged run" or "did the tag change here" does the same thing: walk +adjacent glyphs and compare `data.m_tag` by pointer. Three load-bearing +examples: + +**On insert**, if the two glyphs immediately either side of the +insertion point shared a tag, the whole tag is stripped (see §3.1 for +why it's the *whole* tag, not just the boundary): + +``` +acclient_2013_pseudo_c.txt:127079-127090 (GlyphList::Insert @ 0x472e70) +class ListNode* prev = _current->prev; +if (prev != 0) +{ + class TextTag* m_tag = prev->data.m_tag; + if (m_tag == _current->data.m_tag) + GlyphList::RemoveTextTag(this, m_tag); +} +``` + +**On delete**, the boundary glyph of the doomed range is checked the +same way: + +``` +acclient_2013_pseudo_c.txt:127294-127302 (inside GlyphList::Delete @ 0x4730a0) +class TextTag* m_tag = edi->data.m_tag; +if (m_tag != 0) +{ + for (class ListNode* i = this_1->m_glyphList._head; i != 0; i = i->next) + { + if (i->data.m_tag == m_tag) + Glyph::SetTag(i, nullptr); + } +} +``` + +**On bulk append** (`GlyphList::AddText`), the glyph immediately before +the insertion point is compared to the first glyph being spliced in: + +``` +acclient_2013_pseudo_c.txt:127354-127362 (inside GlyphList::AddText @ 0x473190) +int32_t ebx = *(int32_t*)((char*)prev + 0x20); // prev->data.m_tag (offset +0x20 into Glyph) +if ((ebx == var_8->data.m_tag && ebx != 0)) +{ + for (class ListNode* i = this_2->m_glyphList._head; i != 0; i = i->next) + if (i->data.m_tag == ebx) + Glyph::SetTag(i, nullptr); +} +``` + +**On text serialization** (`GlyphList::InqText`, used to reconstruct +markup text — e.g. what `ChatInterface::TruncateChatLog` reads before +truncating, see §3.3), the same pointer-compare drives when to close +the previous tag's markup and open the new one: + +``` +acclient_2013_pseudo_c.txt:127671-127700 (inside GlyphList::InqText @ 0x473560) +class TextTag* m_tag = _head->data.m_tag; +if ((eax_4 != 0 && m_tag != m_tag_1)) // tag changed since previous glyph +{ + if (m_tag_1 != 0) + m_tag_1->vtable->BuildEndTag(&arg5); // close previous run's markup + if (m_tag != 0) + m_tag->vtable->BuildStartTag(&arg5); // open new run's markup +} +... +m_tag_1 = m_tag; // carried into next iteration +``` + +So: **a contiguous run is identified only by walking neighbours and +comparing `Glyph::m_tag` pointers; there is no cached run table.** +Every operation that could break a run's contiguity re-derives the +answer by walking. + +### 2.3 How a tag attaches during append — `UIElement_Text::InqGlyphs` + +``` +acclient_2013_pseudo_c.txt:115983 +00468ea0 uint8_t __stdcall UIElement_Text::InqGlyphs(class UIElement_Text* this @ ecx, class PStringBase const* arg2, class SmartArray* arg3) +``` + +This converts a raw wide string (which may contain embedded +`` markup) into a flat array of `Glyph`s, one +character at a time. It is called from `UIElement_Text::AddText_Internal` +(`acclient_2013_pseudo_c.txt:116791`), which is itself the single choke +point every text-append path funnels through (`AppendText`, +`AppendStringInfo`, `AppendStringInfoWithFont`, `CharacterHandler`, the +paste handler, etc. — confirmed by grepping every +`AddText_Internal(` call site, `acclient_2013_pseudo_c.txt:116886,116916, +116994,117008,117026,117043,117106`). + +The character loop keeps one local, `ebx_1` (`class TextTag*`), which is +the **currently-open tag while walking characters** — this is NOT a +field on `UIElement_Text`; it's a local in this one function's loop: + +- On seeing `<` (`0x3c`), it scans to the matching `>` (`0x3e`), + extracts the substring, and calls + `TextTagFactory::MakeTag(...)` (`acclient_2013_pseudo_c.txt:116117`). + The `<...>` delimiter text itself is **not emitted as glyphs** — the + character cursor (`edi_1`) is advanced past the closing `>` before + glyph emission resumes (`acclient_2013_pseudo_c.txt:116124-116131`). + The result — a new tag pointer, or `0` if `MakeTag` failed to parse — + replaces `ebx_1` for subsequent characters. +- For every ordinary character, the glyph's colour is chosen from `ebx_1` + (see §5 for the exact rule) and a `Glyph` is constructed carrying + `ebx_1` as its `m_tag` (§4 covers the ref-count mechanics of that + construction). + +**Because `ebx_1` is never explicitly reset to `0` on a "closing +bracket," the only way a tag run ends is for a later `<...>` span to +fail to parse into a valid tag** (no `:` found → `MakeTag` returns `0`, +§1.3 step 1). That is exactly what `TextTag::BuildEndTag` emits: + +``` +acclient_2013_pseudo_c.txt:133714-133748 (TextTag::BuildEndTag @ 0x479190) +if (this->m_type != 0) +{ + EnumMapper::InqString(0x18, this->m_type, &var_4); // just the type's name, no ':' + PStringBase::sprintf(arg2, u"<\%ls>"); + return 1; +} +return 0; +``` + +i.e. the end-tag markup is a bracketed **type name with no colon** +(`u"<\%ls>"` — the `\` immediately before `%` is almost certainly a +Binary Ninja rendering artifact for a literal `/`, i.e. the real string +is most plausibly `""`; **flagged as uncertain**, same caveat as +§1.3 — BN's string preview/escaping for embedded control characters is +not reliably faithful and this should be confirmed with a raw +`.rdata` byte dump before being relied on verbatim). Since that +substring has no `:`, `TextTagFactory::MakeTag`'s `FindChar(':')` check +fails and it returns `0` → `ebx_1` becomes `0` → every glyph after that +point is untagged, until the next successfully-parsed `` start tag. **Start/end tags are symmetric in markup shape but +asymmetric in mechanism**: a start tag is a successful `MakeTag` parse; +an end tag is nothing more than *any* bracketed text that fails to +parse as one. + +--- + +## 3. Lifetime + +### 3.1 Creation, retention, destruction — reference counting + +`TextTag` inherits `ReferenceCountTemplate<1048576,0>` (`m_cRef` at +`+0x4`, `vfptr` at `+0x0`). `TextTagFactory::MakeTag` hands back an +object with `m_cRef == 1` (set in the base ctor, +`acclient_2013_pseudo_c.txt:133587-133594`, +`TextTag::TextTag @ 0x478f80`: `this->m_cRef = 1;`). From there, +ownership is **fully distributed across every `Glyph` that points at +it** — there is no separate owning list or registry. + +**Adopting a tag reference increments the count.** The parameterized +`Glyph` constructor used by `InqGlyphs` when building a new glyph does +this explicitly: + +``` +acclient_2013_pseudo_c.txt:129088-129106 (Glyph::Glyph(this, char, color*, font*, tag*) @ 0x474a90) +*(uint32_t*)((char*)this_1 + 0x20) = arg5; // this->m_tag = tag +Glyph::SetFont(this_1, arg4); +int32_t eax_5 = *(uint32_t*)((char*)this_1 + 0x20); +if (eax_5 != 0) + InterlockedIncrement((eax_5 + 4)); // tag->m_cRef++ +``` + +The copy assignment operator (used whenever a glyph is copied — e.g. +splicing the freshly-built `SmartArray` from `InqGlyphs` into +the live `List`, or `List::flush`'s per-node teardown) +does the matching release-then-acquire: + +``` +acclient_2013_pseudo_c.txt:128949-128969 (Glyph::operator= @ 0x474870) +class TextTag* m_tag_1 = this->m_tag; +if (m_tag_1 != 0) +{ + if (InterlockedDecrement(&m_tag_1->m_cRef) == 0 && m_tag_1 != 0) + m_tag_1->vtable->__vecDelDtor(1); // release old tag, free at zero + this->m_tag = nullptr; +} +class Font* m_font = arg2->m_font; +this->m_font = m_font; +this->m_tag = arg2->m_tag; +if (this->m_tag != 0) + InterlockedIncrement(&this->m_tag->m_cRef); // acquire new tag +``` + +**Releasing decrements the count and self-deletes at zero, via the +destructor**: + +``` +acclient_2013_pseudo_c.txt:128905-128925 (Glyph::~Glyph @ 0x474820) +class TextTag* m_tag = this->m_tag; +if (m_tag != 0) +{ + if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0) + m_tag->vtable->__vecDelDtor(1); + this->m_tag = nullptr; +} +``` + +**`Glyph::SetTag` is the exception to note carefully** — it releases the +*old* tag (decrement, free at zero) but does **not** increment the +refcount of the incoming tag: + +``` +acclient_2013_pseudo_c.txt:128977-128993 (Glyph::SetTag @ 0x474920) +void Glyph::SetTag(class Glyph* this, class TextTag* arg2) +{ + class TextTag* m_tag = this->m_tag; + if (m_tag == 0) + { + this->m_tag = arg2; + return; + } + if (InterlockedDecrement(&m_tag->m_cRef) == 0 && m_tag != 0) + m_tag->vtable->__vecDelDtor(1); + this->m_tag = nullptr; + this->m_tag = arg2; +} +``` + +Every call site of `Glyph::SetTag` actually observed in this decompile +passes `nullptr` for `arg2` (§2.2's three excerpts, plus the identical +pattern at `acclient_2013_pseudo_c.txt:126830`, +`GlyphList::RemoveTextTag`). In practice `SetTag` is only ever used as +"sever this glyph's reference to whatever tag it has" — a porting +engineer must not assume the general two-argument form is +refcount-safe for a non-null argument; if a call site with a non-null +tag is ever found, it must AddRef beforehand, mirroring the +constructor/`operator=` pattern above. + +### 3.2 Whole-tag invalidation on any edit that could split a run + +This is the single most important porting gotcha in this document. +None of the three "does this edit touch a tag boundary" checks in §2.2 +try to *split* a run in two. All of them, on detecting that an edit +would break contiguity, call `Glyph::SetTag(i, nullptr)` on **every +glyph in the entire `GlyphList` that shares that tag pointer** — not +just the glyphs adjacent to the edit. See `GlyphList::RemoveTextTag`: + +``` +acclient_2013_pseudo_c.txt:126822-126833 +void __thiscall GlyphList::RemoveTextTag(class GlyphList* this, class TextTag* arg2) +{ + if (arg2 != 0) + { + for (class ListNode* i = this->m_glyphList._head; i != 0; i = i->next) + { + if (i->data.m_tag == arg2) + Glyph::SetTag(i, nullptr); + } + } +} +``` + +`GlyphList::Insert`'s boundary check (§2.2) calls exactly this function +when it detects a would-be-split. `GlyphList::Delete` and +`GlyphList::AddText` inline the identical "walk the whole list, clear +every glyph sharing this tag" loop rather than calling +`RemoveTextTag` directly, but the effect is the same. **The retail +behaviour is: any edit that would leave a discontiguous run under one +tag pointer instead destroys the tag for the ENTIRE list, not just the +disturbed portion.** A tagged player name that gets partially edited or +partially deleted loses its colour/clickability everywhere it appears +in that `GlyphList`, not just at the edit site. + +### 3.3 Scroll-off / truncation — `ChatInterface::TruncateChatLog` + +``` +acclient_2013_pseudo_c.txt:247098 +004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2) +``` + +This reads the chat log's current text length (via +`UIElement_Text::GetText`, which is backed by `GlyphList::InqText`, §2.2) +and, if it exceeds the cap (`arg2`), calls: + +``` +acclient_2013_pseudo_c.txt:247148,247178 +UIElement_Text::BeheadText(this->m_chatLog, N, 1); +``` + +`BeheadText` is a thin wrapper: + +``` +acclient_2013_pseudo_c.txt:116727-116731 (UIElement_Text::BeheadText @ 0x469970) +void __thiscall UIElement_Text::BeheadText(class UIElement_Text* this, uint32_t arg2, uint8_t arg3) +{ + UIElement_Text::DeleteSection(this, 0, arg2, arg3); +} +``` + +which in turn calls `GlyphList::Delete` (`acclient_2013_pseudo_c.txt: +116680`, inside `UIElement_Text::DeleteSection @ 0x469800`) — **the +exact same generic deletion path used for any other text edit** (typed +backspace, cut, selection delete). There is no special-cased "truncate +the chat log" tag handling. Consequences, following directly from §3.1 +and §3.2: + +- A `TextTag` whose glyphs are entirely scrolled off is destroyed the + ordinary way: `GlyphList::Delete` walks the doomed range, finds the + shared tag, `Glyph::SetTag(..., nullptr)`s every glyph that shares it + (§3.2), decrementing to zero and freeing it. +- A `TextTag` whose glyphs are only **partially** scrolled off (the + truncation boundary falls inside a tagged name) has the tag stripped + from **all** its glyphs, including the ones that remain visible — per + §3.2's whole-list behaviour. The surviving remnant of a truncated + tagged name renders and behaves as plain untagged text. This is a + concrete, verified retail behaviour, not a hypothesis — it falls + directly out of `GlyphList::Delete`'s implementation, which + `BeheadText`/`TruncateChatLog` invoke with no special-casing. + +--- + +## 4. Colour rule — property `0x1b` (font colour) vs. `0x1d` (tag font colour) + +### 4.1 `UIElement_Text`'s "current" state fields + +``` +acclient.h:53392-53420 (struct UIElement_Text, relevant fields with confirmed field order) +RGBAColor m_curFontColor; // used for the property "0x1b" value +Font *m_curFontObj; // used for the property "0x1a" value +RGBAColor m_curTagFontColor; // used for the property "0x1d" value +unsigned int m_curOutlineColor; +``` + +The field ORDER in the header (`m_curFontColor`, `m_curFontObj`, +`m_curTagFontColor` back to back) matches the raw offset arithmetic seen +at the glyph-construction site in `InqGlyphs` +(`acclient_2013_pseudo_c.txt:116153-116162`, this-relative offsets +`0x6a4` for `m_curFontColor` and `0x6b8` for `m_curTagFontColor`, a +`0x14`-byte gap = 16 bytes of `RGBAColor` + 4 bytes of the `Font*` +pointer in between) — this cross-check confirms the struct layout +against the pseudo-C's raw pointer math. + +### 4.2 Setting them per append — `UIElement_Text::AppendStringInfoWithFont` + +``` +acclient_2013_pseudo_c.txt:117031-117048 +00469de0 void __thiscall UIElement_Text::AppendStringInfoWithFont(class UIElement_Text* this, class StringInfo const* arg2, int32_t arg3, int32_t arg4) +{ + UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3); + UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4); + UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4); + ... + UIElement_Text::AddText_Internal(this, m_charbuffer, 3); + ... +} +``` + +Both colour properties are refreshed from the **same caller-supplied +index**, `arg4`, immediately before the string is appended +(`AddText_Internal` → `InqGlyphs`, §2.3). The same three-call pattern +(`0x1a`/`0x1b`/`0x1d`, same index argument) recurs at every other append +entry point that carries a colour index: +`UIElement_Text::AppendText`/`AppendStringInfo`'s shared helper at +`acclient_2013_pseudo_c.txt:115213-115222`, the string-download +completion handler at `acclient_2013_pseudo_c.txt:117098-117104` (index +taken from the queued download's own stored index, +`ebx_2[0x25]`/`ebx_2[0x26]`), and the reset-to-index-0 call at +`acclient_2013_pseudo_c.txt:117546-117548`. + +### 4.3 What `SetFontColorHelper` actually does — an indexed property array + +``` +acclient_2013_pseudo_c.txt:113699 (UIElement_Text::SetFontColorHelper @ 0x466ac0) +void __thiscall UIElement_Text::SetFontColorHelper(class UIElement_Text* this, uint32_t arg2 /*propId*/, class RGBAColor* arg3 /*out*/, uint32_t arg4 /*index*/) +``` + +Simplified control flow (BN's exact vtable-offset dispatch on `0xf0`, +`0xf4`, `0x98` is not named by the PDB — see caveat below): + +1. `this->vtable->InqProperty(propId, &var_10)` — looks up the + UIElement's own authored property (`0x1b` or `0x1d`) via its normal + `UIElement` property mechanism, i.e. this is a + **LayoutDesc/DAT-authored per-element property**, not global engine + state. +2. If found, the returned property object is treated as an *indexed + collection*: a virtual call through vtable offset `+0xf0` + (`acclient_2013_pseudo_c.txt:113743`) that plausibly returns the + collection's element count into `arg2`; a bounds check + `if (arg4 < arg2)`; then a virtual call through `+0xf4` + (`acclient_2013_pseudo_c.txt:113758`) that plausibly fetches the + sub-property at index `arg4`; then a virtual call through `+0x98` + (`acclient_2013_pseudo_c.txt:113761`) that plausibly extracts an + `RGBAColor` from that sub-property into the caller's `arg3` output. +3. If any step fails (property absent, index out of range), `arg3` (the + caller's `m_curFontColor`/`m_curTagFontColor`) is left untouched — + i.e. it retains whatever colour it already held from a previous + append. + +**Caveat**: BN does not resolve names for the `+0xf0`/`+0xf4`/`+0x98` +virtual calls (they're dispatched through a generic `BaseProperty`-family +vtable, and the pseudo-C prints them as raw `(*(uint32_t*)(vtable + +offset))(...)` calls). The functional interpretation above +("count / get-at-index / get-color") is inferred from the argument +shapes and control flow (an `arg4 < count` bounds check immediately +followed by an index-parameterized fetch), not from a symbol. Treat the +step-by-step mechanics as **probable, not certain** — the porting-load- +bearing fact that IS certain is the *outcome*: property `0x1b` and +property `0x1d` are each an array of colours on the `UIElement_Text`, +indexed by the same `arg4` the caller supplies, and `SetFontColorHelper` +resolves one colour from each array into `m_curFontColor` / +`m_curTagFontColor` respectively before the string is walked into +glyphs. This lines up with `claude-memory/project_chat_digest.md`'s +`LogTextType colors` note — `arg4` is almost certainly the `LogTextType` +of the message being appended (a per-message-category colour index), +and property `0x1d` is a **parallel, per-category array of "tag" +colours** — i.e. retail authors one link/tag colour per chat category, +not one global link colour. + +### 4.4 Which colour a glyph actually gets — the `m_type == 0x10000001` gate + +Back in `UIElement_Text::InqGlyphs`, per character, the code picks +between the two "current" colours based on whether a tag is open **and** +that tag's `m_type` equals a specific sentinel: + +``` +acclient_2013_pseudo_c.txt:116150-116162 +void* edx_15; +void* esi_6; +if ((ebx_1 == 0 || *(uint32_t*)((char*)ebx_1 + 8) != 0x10000001)) +{ + esi_6 = esp_1[6]; // this (UIElement_Text*) + edx_15 = ((char*)esi_6 + 0x6a4); // &this->m_curFontColor +} +else +{ + esi_6 = esp_1[6]; + edx_15 = ((char*)esi_6 + 0x6b8); // &this->m_curTagFontColor +} +``` + +(`ebx_1 + 8` is `TextTag::m_type`, per the struct layout in §1.1.) +`edx_15` is then passed straight into the parameterized `Glyph` +constructor as the colour source (`acclient_2013_pseudo_c.txt:116173- +116180`). So, precisely: + +- No open tag (`ebx_1 == 0`) → glyph gets `m_curFontColor` (property + `0x1b`'s indexed value). +- Open tag, but its `m_type != 0x10000001` → **still** + `m_curFontColor`. Not every tag type gets the special colour. +- Open tag with `m_type == 0x10000001` → glyph gets `m_curTagFontColor` + (property `0x1d`'s indexed value). + +**This is the exact rule the task asked for**: property `0x1b` is the +default/base colour used for all untagged text and for any tag whose +type isn't the specially-recognized one; property `0x1d` is used only +for glyphs inside a tag of that one recognized type, and both are +selected from the same caller-supplied colour-category index. See §7 +for what is and is not known about what `0x10000001` names. + +### 4.5 "Currently open tag" state + +There is **no persistent "currently open tag" field on `UIElement_Text`** +— `m_curFontColor`/`m_curFontObj`/`m_curTagFontColor` are the *colour +palette currently in effect for this append call* (refreshed once per +`AppendStringInfoWithFont`/`AppendText` call from the indexed DAT +properties), not per-tag state. The actual "is a tag open right now, +and which one" state during the character walk is the **local variable +`ebx_1` inside `UIElement_Text::InqGlyphs`'s loop** (§2.3) — it does not +outlive one call to `InqGlyphs`/`AddText_Internal`. Each append call +starts fresh with no tag open, and the markup embedded in that call's +own string is what opens/closes tags within it. + +--- + +## 5. Click dispatch + +Each concrete subclass's `HandleClick` (vtable slot `+0x14`, per §1.2) +forwards to a `ECM_UI::SendNotice_TextTag_*Click` free function, which — +per the `NoticeHandler` vtable declared in `acclient.h:30237-30240` — is +a broadcast notice any registered `NoticeHandler` (e.g. the chat/social +UI) can receive via a matching `RecvNotice_TextTag_*Click` virtual: + +``` +acclient_2013_pseudo_c.txt:133078-133085 (TextTag_DID::HandleClick @ 0x478740) +ECM_UI::SendNotice_TextTag_DIDClick(this->m_type, this->m_DID.id); + +acclient_2013_pseudo_c.txt:133521-133528 (TextTag_IID::HandleClick @ 0x478e80) +ECM_UI::SendNotice_TextTag_IIDClick(this->m_type, this->m_IID); + +acclient_2013_pseudo_c.txt:133328-133335 (TextTag_IIDEnum::HandleClick @ 0x478b40) +ECM_UI::SendNotice_TextTag_IIDEnumClick(this->m_type, this->m_IID, this->m_enum); + +acclient_2013_pseudo_c.txt:133150-133157 (TextTag_IIDString::HandleClick @ 0x478840) +ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string); +``` + +matching the `NoticeHandler` vtable slots: + +``` +acclient.h:30237-30240 +void (__thiscall *RecvNotice_TextTag_DIDClick)(NoticeHandler *this, unsigned int, IDClass<_tagDataID,32,0>); +void (__thiscall *RecvNotice_TextTag_IIDClick)(NoticeHandler *this, unsigned int, unsigned int); +void (__thiscall *RecvNotice_TextTag_IIDEnumClick)(NoticeHandler *this, unsigned int, unsigned int, unsigned int); +void (__thiscall *RecvNotice_TextTag_IIDStringClick)(NoticeHandler *this, unsigned int, unsigned int, PStringBase *); +``` + +This strongly supports the observed behaviour ("clicking a speaker's +name in chat prefills a tell to them"): a chat name is plausibly tagged +`TextTag_IIDString`, carrying the speaker's `IID` (their in-world +object id — the correct addressee for a `/t` tell) and `m_string` +(plausibly the speaker's display name — needed because the retail tell +command syntax is name-based, not id-based). Clicking dispatches +`ECM_UI::SendNotice_TextTag_IIDStringClick(type, IID, &name)`, and +whichever `NoticeHandler` owns the chat input box (this document did +not trace that far — **UNKNOWN, needs a search of `RecvNotice_TextTag_ +IIDStringClick` overrides across the UI classes to find which panel +consumes it and confirm it prefills `/t "name" `**) reacts by loading +that into the input field. + +**Not traced from HandleClick backward to the mouse-hit-test that finds +"which glyph, hence which tag, is under the cursor."** No literal +`->vtable->HandleClick(...)` call site was found via text grep (it's an +indirect vtable call, invisible to a literal-string search); locating +the exact hit-test function that resolves a click's screen position to +a glyph index and reads that glyph's `m_tag` was out of scope for this +pass. **UNKNOWN — needs a targeted search for the `UIElement_Text` +mouse-down handler** (candidates: something built on +`GlyphList::FindPosFromLineAndPixels` @ +`acclient_2013_pseudo_c.txt:127424`, which is already known to resolve +screen pixels to a glyph index and is a very likely component of that +path, but the actual click-to-`HandleClick` wiring was not confirmed). + +--- + +## 6. Summary — the model to port + +1. **Data model**: `TextTag` is a small polymorphic ref-counted object + (`m_type`, `m_format`, plus subclass payload — `IID`/`DID`/`enum`/ + `string` in various combinations). `Glyph` carries an OPTIONAL + `TextTag*`. `GlyphList` is a flat list of `Glyph`; nothing above the + glyph level stores tag/run information. +2. **Attachment**: identity of a "run" is pointer equality on + consecutive glyphs' `m_tag`. No cached run table exists; every + consumer (insert-boundary check, delete-boundary check, append- + boundary check, text-serialization) re-derives it by walking + neighbours. +3. **Markup**: `` opens a tag (parsed by + `TextTagFactory::MakeTag`, dispatching on the `FORMAT` value to one + of 4 concrete classes); any bracketed text that fails to parse (in + particular the literal ``-shaped close marker emitted by + `TextTag::BuildEndTag`) closes the currently-open tag. The delimiter + text itself is never rendered as glyphs. +4. **Lifetime**: pure intrusive refcounting. Adopting a tag reference + (construction, copy-assignment) increments; releasing (destruction, + explicit clear, reassignment) decrements and self-deletes at zero. + **Any edit that would split a tagged run instead strips the tag from + every glyph in the WHOLE `GlyphList` that shares it** — there is no + run-splitting. Chat-log truncation (`TruncateChatLog` → + `BeheadText` → `DeleteSection` → `GlyphList::Delete`) is not + special-cased; it goes through this exact same path, so a tagged + name straddling the truncation boundary loses its tag entirely, even + on the surviving portion. +5. **Colour**: two parallel, per-`UIElement_Text`, DAT-authored, + index-selected colour arrays — property `0x1b` (base/default) and + property `0x1d` (tag colour) — refreshed from the same caller + colour-category index at the top of every append call. A glyph gets + the `0x1d` colour only if a tag is open AND that tag's `m_type` + equals the sentinel `0x10000001`; otherwise it gets the `0x1b` + colour regardless of whether some *other* kind of tag is open. +6. **Click**: each concrete `TextTag` subclass's `HandleClick` + broadcasts a `NoticeHandler`-family notice + (`ECM_UI::SendNotice_TextTag_*Click`) carrying its payload; some + listener elsewhere (not traced in this pass) reacts to populate chat + input, open a character sheet, etc., depending on subclass/`m_type`. + +--- + +## 7. Open questions / explicitly unresolved + +- **What symbolic name does `TextTag::m_type == 0x10000001` correspond + to?** `TextTagType` has no recovered named enum (`acclient.h:62585` is + a bare typedef). The literal `0x10000001` recurs pervasively + elsewhere in the pseudo-C for apparently unrelated purposes (dialog + IDs, `StringInfo::SetStringIDandTableEnum` table-enum arguments, + keymap IDs — see the broad grep hits at + `acclient_2013_pseudo_c.txt:2334,135182,149121,150950,154810,...`), + which suggests it's a low, sequential "category 1" id reused across + several small per-subsystem enums rather than one global constant + with a single meaning — i.e. seeing the same literal elsewhere is + **not** evidence about what it means for `TextTag`. Both + `EnumMapper::InqEnum`/`InqString` route through a table id of `0x18` + (§1.3), which is very likely a DAT-resident enum/string table (the + lookup falls through `MasterDBMap::DivineType`-style DBObj resolution + seen in `EnumMapper::GetEnumByDID`, + `acclient_2013_pseudo_c.txt:29890-29937`, for other DID categories), + meaning the actual keyword strings (e.g. whatever text maps to + `m_type == 1`) live in a DAT string/enum table, not as a compiled + string literal — grepping for literal tag keywords like `"IID"`, + `"DID"` in the pseudo-C found nothing. **Needs**: pulling DAT category + `0x18`'s EnumMapper table contents (likely in + `client_local_English.dat` or `client_portal.dat`) to find the actual + keyword-to-`m_type` mapping, the same way + `claude-memory/project_settings_options_digest.md`'s `GetNameFromKey` + work pulled DAT tables `0x2300000A`/`0x2300000B`/`0x23000007`. +- **Exact wording of the two `sprintf` format strings** at + `acclient_2013_pseudo_c.txt:133651` (`u"<%ls:%ls%:%ls>"`) and + `acclient_2013_pseudo_c.txt:133728` (`u"<\%ls>"`). Binary Ninja's + string-literal rendering is known to mis-escape embedded + slashes/percents in this codebase; both are flagged inline in §1.3/§2.3 + as probable artifacts (a stray `%` in the first, a `\` that's likely a + literal `/` in the second). **Needs**: a raw byte dump of the two + `.rdata` string constants at their addresses (not BN's decompiled + preview) to confirm exact bytes before porting the exact markup + syntax. +- **The exact semantics of `SetFontColorHelper`'s three virtual calls** + (vtable offsets `+0xf0`, `+0xf4`, `+0x98`, §4.3) are inferred from + control flow, not named by the PDB. The functional summary (indexed + colour array) is believed correct, but the precise interface + (`BaseProperty`'s exact virtual table) was not independently + confirmed against `acclient.h`'s `BaseProperty`-family struct + definitions in this pass. +- **The mouse-hit-test → `HandleClick` wiring** (§5) — which function + resolves a click position to a glyph, reads its `m_tag`, and invokes + `HandleClick` through the vtable — was not located in this pass (no + literal-text call site exists to grep for an indirect vtable call). +- **Which `NoticeHandler` override actually consumes + `RecvNotice_TextTag_IIDStringClick` and prefills the chat input** — + not traced. This is the last link needed to fully confirm "clicking a + chat name populates a `/t` tell," though the `IID` + name payload + shape on `TextTag_IIDString` makes it the overwhelmingly likely + candidate tag type for that UI behaviour. diff --git a/docs/research/2026-08-21-retail-chat-window-ui.md b/docs/research/2026-08-21-retail-chat-window-ui.md new file mode 100644 index 00000000..152d44d7 --- /dev/null +++ b/docs/research/2026-08-21-retail-chat-window-ui.md @@ -0,0 +1,650 @@ +# Retail chat WINDOW shell — window model, filters, scrollback, chrome + +**Date:** 2026-08-21 +**Status:** RESEARCH ONLY. No source files touched. +**Scope:** retail's chat window SHELL and DISPLAY behavior — window +management, filtering, scrollback, chrome/interaction, multi-window, +line-composition structure, and other user-visible window mechanics. +**Explicitly out of scope** (covered by sibling research this session): +glyph text-tag coloring, clickable/colored names, tag click dispatch, and +acdream's own current UI code. This document does not re-derive anything +already answered there. + +**Primary sources** +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR + build, Binary Ninja pseudo-C, PDB-named) +- `docs/research/named-retail/acclient.h` (verbatim retail struct/enum defs) +- `docs/research/named-retail/symbols.json` + +**Notes read first so this extends rather than repeats:** +- `docs/research/2026-08-09-chat-retail-window-shell.md` (CH6 shell research + — window lifecycle/identity, LayoutDesc geometry, resize model, opacity, + persistence, multi-window/floaty mechanics). **This document is the + authority for §1 window-lifecycle mechanics, §4 chrome/resize/opacity, and + §5 tabs/multi-window — I only summarize its findings below with pointers, + and add what it doesn't cover:** scrollback/truncation, the exact + window-ID routing predicate as a single decompiled function, structural + line-composition order, and the unseen-text/auto-scroll interaction. +- `docs/research/2026-08-09-chat-retail-color-table.md` §4 (filter storage, + `m_llTextTypeFilter`, `PostInit` seeded defaults) — I summarize and do not + re-derive; I use its findings to cross-check the routing function decoded + fresh below. +- `docs/plans/2026-08-09-chat-parity-campaign.md` — Campaign CH plan/ledger. + +**Binary-Ninja caveats (apply throughout, per +`claude-memory/feedback_bn_decomp_field_names.md`):** BN's struct-field +attribution in `ChatInterface::PostInit`/`gmMainChatUI::PostInit` is shifted +by one slot relative to the true member order — the window-shell doc already +documented this for the main window's border elements. I hit the same +artifact in `ChatInterface::PostInit`'s `GetChildRecursive` binding sequence +(§1) and resolve it the same way: against the verbatim struct order in +`acclient.h:54898-54912`, which is authoritative and does not shift. + +--- + +## 1. Window model + +### 1.1 How many windows, and how they're identified + +Confirmed against `acclient.h:54898-54912` (verbatim `ChatInterface` +struct): + +```cpp +/* 6041 */ +struct __cppobj ChatInterface : gmNoticeHandler, UIElement_Field +{ + unsigned int m_eWindowID; + float m_fDefaultOpacity; + float m_fActiveOpacity; + float m_fCurrentOpacity; + UIElement_Text *m_chatEntry; + UIElement_Text *m_chatLog; + UIElement *m_chatNewNonVisibleTextIndicator; + unsigned __int64 m_llTextTypeFilter; + UIElement_Text *m_pChatTargetButtonText; + PStringBaseArray m_InputHistory; + unsigned int m_LastInputHistoryPos; + ClientCommunicationSystem *m_pCCS; +}; +``` + +Per the window-shell doc §1.2/§4.1 (not re-derived here): **five** live +chat windows exist — the main window (`m_eWindowID == 8`) and four floating +windows (`m_eWindowID == 2..5`). `m_eWindowID == 0` is the **UNAUTHORED +constructor default** (`ChatInterface::ChatInterface @0x004F4550` sets +`this->m_eWindowID = 0;` before `PostInit` reads the real value off the +LayoutDesc attribute `0x1000007E`). All five windows are authored, +always-resident children of the gameplay-UI root — there is no +runtime-allocated window registry (window-shell doc §1.1). + +The SpewBox (`gmSpewBoxUI`) is a **separate, unrelated class** — not a +`ChatInterface` subclass, not part of this window-id space (per +`claude-memory/project_chat_digest.md`). + +### 1.2 The wire-to-window routing predicate — one function, load-bearing + +`ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640` is the single +function every displayed chat line passes through. Its **head** (the routing +decision, before any text is appended) is: + +``` +004f4640 void __thiscall ChatInterface::RecvNotice_DisplayFinalStringInfo( + class ChatInterface* this, uint32_t arg2 /*type*/, + class StringInfo const* arg3 /*body*/, + class StringInfo const* arg4 /*prefix*/, uint32_t arg5 /*windowId*/) +004f4640 { +004f4640 uint32_t eax_7 = arg5; +004f4652 if (eax_7 == this->m_eWindowID) +004f4652 { +004f467c label_4f467c: + … (appends — see §3/§6) … +004f4652 } +004f4652 else if ((eax_7 == 0 && ChatInterface::TypeIsActive(this, arg2) != 0)) +004f4666 goto label_4f467c; +004f4640 } +``` + +**The predicate is exactly: `windowId == m_eWindowID` OR (`windowId == 0` +AND `TypeIsActive(type)`).** This is an ADDRESS-vs-BROADCAST model, not a +"windows subscribe to a channel" model: + +- A line sent with a **specific windowId** (matching an already-open target + window, e.g. a command whose output is explicitly directed at the window + that issued it — `m_idCurrentCommandSource` per the color-table doc §4) + is shown **only** in that one window, unconditionally — the destination + window's own filter is never consulted for an address-targeted line. +- A line sent with **windowId == 0** ("broadcast") is shown in **every** + window whose own `TypeIsActive(type)` (i.e. its 64-bit + `m_llTextTypeFilter`, decoded in the color-table doc §4) says yes. This is + how the same "Sio says, ..." line can land in the main window and in a + floating window simultaneously if both have `Speech` enabled. +- **Window id 0 is therefore never itself a *window* — it is the broadcast + sentinel value on the wire/call parameter, exactly as the window-shell + doc's goal-window addendum states.** No live `ChatInterface` instance ever + keeps `m_eWindowID == 0` after `PostInit` runs. + +`ChatInterface::TypeIsActive @0x004F2F10` (cited, not re-derived, per the +color-table doc §4) is `(1ULL << type) & m_llTextTypeFilter`. + +--- + +## 2. Filters + +**Fully decoded already in `2026-08-09-chat-retail-color-table.md` §4 — not +re-derived here.** Summary for completeness of this document's structure: + +- Storage: 64-bit `ChatInterface::m_llTextTypeFilter` (`acclient.h:54907`), + read from `PlayerModule::InqChatWindowOption(windowId, 0x1000007F, …)` + (`ChatInterface::UpdateFromPlayerModule @0x004F3920`) and live-updated via + `RecvNotice_GameplayOptionChanged @0x004F30E0`. +- Test: `ChatInterface::TypeIsActive @0x004F2F10` — `(1ULL << type) & + m_llTextTypeFilter`, used both for the broadcast-routing predicate (§1.2) + and, per the color-table doc, nowhere else. +- **`PostInit`'s per-window seeded default** switches on `m_oldState` + (`ChatInterface::PostInit @0x004F3DD0`, `0x004f3df9`): 1 and 8 (the main + window) get `0xFBFFFFFF` low-dword (everything except client-local `0x1A`); + 2 (floaty 1) gets Speech/Tell/Speech_Direct_Send/Emote; 3 (floaty 2) gets + Social/Social_Send/Allegiance; 4 (floaty 3) gets Fellowship; 5 (floaty 4) + gets the four Turbine rooms General/Trade/LFG/Roleplay. Every default's + HIGH dword is 0 — Society (`0x20`) and the reserved `0x21` slot start + disabled in **every** window and must be opted into by the user. +- User edit path: `gmChatOptionsUI::InitOptions @0x0049FC60` / + `AddCheckboxBitfield64Option @0x0049EDA0` build one checkbox-grid `SetUserData` + block per window id (main = id 8, with its own dedicated Society checkbox + child at `0x0049FEFB`). +- Squelching is a **separate axis** from filtering: + `LogTextTypeEnumMapper::IsLegalChannel @0x006AFF40` whitelists a 14-value + subset of `LogTextType` as squelchable at all; it has no interaction with + `m_llTextTypeFilter`. + +Nothing new to add here beyond what the color-table doc already covers — +the routing predicate decoded fresh in §1.2 above is a second, independent +confirmation of the same "`windowId==0` → filter-gated broadcast" model that +doc's §4 described from `RecvNotice_DisplayFinalStringInfo`'s citation +alone; this document supplies the full decompiled function body. + +--- + +## 3. Scrollback + +### 3.1 The cap, the trigger, and the trim target + +Still inside `RecvNotice_DisplayFinalStringInfo @0x004F4640`, immediately +after the body append (full excerpt with the append order in §6): + +``` +004f4701 int32_t m_chatLog_1 = this->m_chatLog; +004f4711 if (*(uint32_t*)(m_chatLog_1 + 0x61c) > 0x2710) +004f4711 { +004f4713 int32_t var_14_4 = 0x1d4c; +004f471a m_chatLog_1 = ChatInterface::TruncateChatLog(this, m_chatLog_1); +004f4711 } +``` + +`0x2710` = **10,000**, `0x1d4c` = **7,500**. The field read at transcript +offset `+0x61C` tracks the transcript's total **character** count (not a +line count) — retail's scrollback limit is a character budget, not a +fixed number of retained lines. **Trigger: transcript exceeds 10,000 +characters. Target: trim back down toward ~7,500.** This runs on every +appended line once the log is over budget — it is not a periodic/timed +sweep, it is inline in the same call that just displayed the line. + +### 3.2 The truncation rule — trims at a newline boundary, not mid-line + +`ChatInterface::TruncateChatLog @0x004F4290` (arg2 = target length, 7500 at +the only call site found): + +``` +004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2) +004f4290 { + text = GetText(m_chatLog); // live PStringBase + currentLen = text.length; // *(len_ptr - 4) +004f42c2 if (currentLen <= arg2) + return; // under budget — no-op +004f42c2 else + { +004f42c8 excess = currentLen - arg2; // chars over target + … PStringBaseIter_Common::FindChar(iter, "\n", 1) … + // search FORWARD from the excess offset for the next '\n' +004f4350 if (found && (excess - foundPos) < (currentLen / 10)) +004f4360 BeheadText(m_chatLog, foundPos + 1, 1); // cut at that newline + else { + … FindChar(iter, "\n", 0) … // search again, other direction arg +004f43f0 if (found2 && (foundPos2 - excess) < (currentLen / 10)) + goto (the same BeheadText-at-newline path) +004f4403 else + BeheadText(m_chatLog, excess, 1); // fallback: cut at the raw excess offset + } +004f4290 } +``` + +Reading this at the BN pseudo-C level is genuinely uncertain past the +overall shape — **flagging per the assignment's constraint rather than +guessing**: the `0xCCCCCCCD` multiply + `HIGHD(...) >> 3` pair is the +standard MSVC constant-division-by-10 idiom (`length / 10`), and the two +`FindChar` calls with a `PStringBase(&data_79c288)` needle (confirmed below, +§3.3, to be a single `\n` character) plus `UIElement_Text::BeheadText` are +unambiguous. **UNKNOWN — needs a live cdb capture with real transcript +content to nail down exactly:** whether the two `FindChar` calls search in +opposite directions from the excess offset (my reading above) or whether +one is a fallback re-search after the first's 10%-tolerance check fails for +a different reason; the two `arg3` values passed to `FindChar` (`1` then +`0`) are almost certainly a direction or "case-sensitive/whole-word" flag, +but the pseudo-C never names the parameter. **What is certain and +sufficient to port:** truncation removes text from the FRONT of the +transcript (`BeheadText`), it PREFERS a boundary within the char that +begins the next `\n`-terminated line rather than a raw char-offset cut +(there's a ~10%-of-current-length tolerance band around the target for +preferring the newline-aligned cut), and it falls back to an exact +char-offset behead only if no acceptable newline is found nearby. + +### 3.3 The separator character — confirms `\n`, not `\r\n` + +``` +0079c280 data_79c280: 0d 00 0a 00 00 00 00 00 // L"\r\n" — used elsewhere, NOT here +0079c288 data_79c288: 0a 00 00 00 00 00 00 00 // L"\n" — the separator + the TruncateChatLog needle +``` + +`data_79c288` is passed both as the inter-line separator string appended in +`RecvNotice_DisplayFinalStringInfo` (§6) and as the `FindChar` needle in +`TruncateChatLog` above — confirming truncation genuinely searches for line +breaks, i.e. it is line-boundary-aware even though the budget itself is +counted in characters. + +### 3.4 Auto-scroll / "stick to bottom" — `IsAtVerticalEnd` + `ScrollToPosition` + +`UIElement_Text::IsAtVerticalEnd @0x00469350`: + +``` +00469350 uint8_t __fastcall UIElement_Text::IsAtVerticalEnd(class UIElement_Text* this) +00469350 { + lineCount = this->m_glyphList.m_glyphList._num_elements; +00469359 if (lineCount == 0) + return 1; // empty log counts as "at end" +00469360 lastLineIndex = lineCount - 1; +00469369 return UIElement_Text::IsPositionInView(this, &lastLineIndex); +00469350 } +``` + +**This is not a scroll-offset comparison — it is "is the last line +currently visible inside the viewport right now."** `IsPositionInView` is +the same hit-test the widget uses for click-to-position, applied to the +transcript's own final line. + +`RecvNotice_DisplayFinalStringInfo` captures this **before** appending the +new line, then decides what to do with it **after** appending and +truncating: + +``` +004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // BEFORE the new line lands + … append prefix, append body, truncate if over budget (§6, §3.1) … +004f4723 if (ebx != 0) +004f4723 { +004f4732 UIElement_Text::ScrollToPosition(m_chatLog, currentLineCount); // re-stick to the new bottom +004f4739 return; +004f4723 } +004f4723 else +004f473c this->m_chatNewNonVisibleTextIndicator->vtable->SetState(1); // flag "unseen text" instead +``` + +**So: if the user was already looking at the bottom of the log, retail +scrolls the new line into view (sticky-bottom). If the user had scrolled up +into history, retail does NOT move their scroll position at all — it +instead lights the "new unseen text" indicator.** There is no separate +manual "scroll lock" toggle; this automatic per-line check IS retail's +scroll-lock mechanism. `m_chatNewNonVisibleTextIndicator` is a real +`UIElement*` field (`acclient.h:54906`), bound in `PostInit` from element +id `0x1000048C` — the 16×16 button the window-shell doc's layout dump +already placed at (21,62) in the main window and (5,169) in the floaties, +labeled there "new-unseen-text indicator (Button)" from the authored rect +alone; this document supplies the code that drives it. + +### 3.5 Clearing the unseen-text flag + +`ChatInterface::ListenToElementMessage @0x004F51C0`, click-message case, +`idElement == 0x1000048c`: + +``` +004f51f1 if (idElement == 0x1000048c) +004f51f1 { + if (m_chatEntry_or_chatLog != 0) // see field-shift caveat below +004f5208 UIElement_Text::ScrollToPosition(transcript, transcript->lineCount); +004f520d indicator->vtable->SetState(0xd); + } +``` + +**Field-attribution caveat:** this function's local variable is BN-named +`m_chatEntry` at the point it calls `ScrollToPosition`, but the object it +scrolls is described by `_num_elements` of its own `m_glyphList` — the +transcript's own line count, not the chat-entry input field's. Combined +with the ctor/struct order (§1.1) and the same-class shift already +documented in the window-shell doc for `PostInit`, the operation this +really performs is: **clicking the unseen-text indicator scrolls the +transcript to its own bottom and resets the indicator's own visual state** +(`SetState(0xd)`, a different state than the "flagged" `SetState(1)` set +when new text arrives while scrolled up) — i.e. clicking it is the user's +manual "catch up" action, and it un-flags itself. **Not independently +re-verified via cdb; treat the exact numeric visual STATE values (1 vs +0xd) as confirmed, but the specific field bound to "which object gets +scrolled to bottom" as inferred from the semantics of `IsAtVerticalEnd` +elsewhere, not a literal read of this function's own variable names.** + +--- + +## 4. Window chrome & interaction + +**Fully covered by the window-shell doc §1 and §2–§4 — not re-derived +here.** Summary pointers: + +- **Move/resize:** eight authored `UIElement_Resizebar` (type 9) grips per + window with per-grip bool properties `0x2A`/`0x2B`/`0x2C`/`0x2D` + (bottom/left/right/top); the main window's plain top edge strip is a + `UIElement_Dragbar` (type 2, move-only) rather than a ninth resize grip — + window-shell doc §2.1/§2.3, `UIElement_Resizebar::StartMouseResizing + @0x0046B7E0`. +- **Docking/anchoring:** none found — windows are free-floating, clamped + to stay on-screen only at restore time (`gmFloatyMainChatUI::MoveTo + @0x004D2D10:004d2d53-004d2dbb`). +- **Opacity:** two GLOBAL floats (`Option_DefaultOpacity_Property + 0x10000080` unfocused, `Option_ActiveOpacity_Property 0x10000081` + focused), applied to the WHOLE composited window surface including text + via one `SetOpacity` call — window-shell doc §3, `ChatInterface::SetOpacity + @0x004F3120`. Per-class constructed starting values differ (main window + 1.0/1.0 always-opaque, floaties 0.5/1.0) until a saved option overrides + them. Retail eases toward the target at 5%-of-delta per tick + (`ChatInterface::ListenToGlobalMessage @0x004F3840`); acdream currently + snaps (AP-190, window-shell doc §3.1). +- **Show/hide:** authored elements toggled via `SetVisible`, driven by + either a keybind (`Alt+1..4` for the floaties) or a generic + registered-action click dispatch — window-shell doc §1.3/§1.4. +- **Persistence:** two independent paths — the per-window `GameplayOptions` + blob (position/size/visible/title, gated on `m_eWindowID != 0`, i.e. the + main window's geometry is NEVER saved this way) and a separate local + screen-layout text file that IS the only path persisting the main + window's geometry — window-shell doc §4. + +**One piece of chrome not covered by the window-shell doc — the talk-focus +menu (main window only):** + +`gmMainChatUI::InitTalkFocusMenu @0x004CDC50` builds a dropdown menu (from +the button/group pair at elements `0x10000014`/`0x10000015`, window-shell +doc §2.1) with a squelch-toggle entry plus 13 target items, each carrying +an `Enum` attribute `0x1000000B` set to a distinct small integer (`1` +through `0xD`) that records which "talk focus" (broadcast target category) +that menu row represents: + +``` +004cdcd3 this->m_pSquelchToggleButton = UIElement_Menu::AddTextItem(eax_1, &var_90); +… (13x) … +004cdcfb UIElement::SetAttribute_Enum(eax_3, 0x1000000b, 5); +004cdd07 SmartArray::push_back(&this->m_aTalkFocusButtons, &var_94); +``` + +`gmMainChatUI::EnableSelection @0x004CE0A0` toggles individual rows' +enabled/greyed state (`SetState(0xd)` when Olthoi-locked); a companion +`RecvNotice_SelectionChanged @0x004CE050` re-syncs the menu's currently +highlighted target whenever the player's WORLD selection changes (via +`ACCWeenieObject::selectedID` and `PublicWeenieDesc::IsTalkable`) — this is +a **world-object selection** feed (F1-click on an NPC), not a transcript +text-tag click, and is out of this document's lane beyond noting that the +main window's talk-focus button exists and is driven from it. Only the +main window has this menu; floaty windows (window-shell doc §2.2) have +neither a talk-focus menu nor a max/min button, only a title bar and close +button. + +--- + +## 5. Tabs / multiple windows + +**Fully covered by the window-shell doc §1.1–§1.4, §2, §4.1 — not +re-derived here.** Summary: + +- There is no "tab" widget. The five windows (§1.1) are five separate, + independently positioned/sized/opaque floating panels, not tabs of one + container. +- **Creation:** none — all five exist from gameplay-UI construction; users + cannot create additional windows. **Naming:** each floaty window has an + editable title (`gmFloatyChatUI::SetWindowTitle @0x004CEAA0`, persisted + option `0x1000008D`) but the SET of windows is fixed at five; there is no + "new chat tab" affordance analogous to modern MMO UIs. **Closing:** + floaty windows close via their own title-bar close button + (`gmFloatyChatUI::ListenToElementMessage @0x004CE330`, element + `0x1000052A`) or the `Alt+N` toggle; the main window cannot be closed at + all (no close button is authored on it — window-shell doc §2.1's element + table has none). **Switching:** there is no focus-cycling shortcut found; + each window is an independent, simultaneously-visible panel, and + "switching" only means moving keyboard focus into a different window's + entry field by clicking it (which is what drives the opacity fade, + §4/window-shell doc §3). +- **Per-window state:** `m_eWindowID`, `m_llTextTypeFilter` (§2), + `DefaultOpacity`/`ActiveOpacity` (global, not per-window — window-shell + doc §3 correction), position/size/visible/title (§4), and the transcript + itself (`m_chatLog`, independently truncated per §3 — each window keeps + its own scrollback, so a floaty showing only Tells has its own 10k/7.5k + character budget separate from the main window's). +- The main window's four indicator buttons (`0x10000522`-`0x10000525`) + mirror the four floaties' visibility as one-directional state indicators, + not a tab strip — window-shell doc §1.4. + +--- + +## 6. Timestamps, prefixes, and line composition order + +### 6.1 The two-part composition model — confirmed structurally + +`ClientSystem::AddTextToScroll @0x00563C50` is where a body string +(`arg2`), a `LogTextType` (`arg3`), a plugin-hook flag (`arg4`) and a +windowId (`arg5`) become the two `StringInfo` arguments +`RecvNotice_DisplayFinalStringInfo` receives. Its structurally relevant +branch (client-local `0x1A` short-circuits both the timestamp AND the local +log file): + +``` +00563de6 if (arg3 == 0x1a) +00563de6 { + // build body-only StringInfo, EMPTY prefix StringInfo +00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3, &bodyOnly, &emptyPrefix, windowId); +00563de6 } +00563de6 else +00563de6 { +00563dfb if (PlayerModule::DisplayTimeStamps(&playerModule) != 0) +00563dfb { +00563e24 wcsftime(&buf, 0x400, u"%#H:%M:%S ", localtime(&now)); // "H:MM:SS " — no date, trailing space +00563e39 PStringBase::set(&prefixBuffer, &buf); +00563dfb } + … if (s_pLogFile) fprintf(s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer); // §7.4 + } +``` + +**There is exactly ONE structural prefix element: the timestamp, and it is +entirely optional (gated on `PlayerModule::DisplayTimeStamps()`, a +character option toggle backed by `PlayerModule::options2_` bit 6 — +`PlayerModule::DisplayTimeStamps @0x005D39B0`: `return (options2_ >> 6) & +1`).** There is **no separate structural "channel name" prefix element** +(`[Fellowship]`, `[]`, etc.) anywhere in this function or in +`RecvNotice_DisplayFinalStringInfo`. Channel-name brackets that DO appear +in retail's transcript (documented already, by content not structure, in +the color-table doc §3.3's channel-bit table) are baked directly into the +`arg2` body string by the SENDING handler (e.g. +`Handle_Communication__ChannelBroadcast`) before it ever reaches +`AddTextToScroll` — from this function's point of view there are only ever +two composed parts: prefix (timestamp-or-empty) and body. + +### 6.2 The append order — separator, then prefix, then body + +Back in `RecvNotice_DisplayFinalStringInfo @0x004F4640` (full body, per the +excerpts in §1.2/§3.1/§3.4 stitched together in call order): + +``` +004f467c if (this->m_chatLog->m_glyphList.m_glyphList._num_elements > 0) +004f4687 { +004f469a UIElement_Text::AppendTextWithFont(this->m_chatLog, L"\n", 0, arg2 /*type*/); +004f467c } // 1. separator (skipped on the very first line) +004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // captured BEFORE any of the below +004f46dc if (StringInfo::IsValid(arg4, 1) != 0) +004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4 /*prefix*/, 0, 0xc); + // 2. timestamp prefix — ALWAYS color idx 0x0C (grey), only if valid/non-empty +004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3 /*body*/, 0, arg2 /*type*/); + // 3. body — colored by the wire LogTextType +``` + +**Fixed structural order: `[\n if not first line] → [timestamp, if +enabled] → [body]`.** The leading separator is a property of the LOG +(inserted once per new entry, before the entry, so the transcript never +starts with a blank line), not a property of the entry itself — a port that +appends `body + "\n"` per-line instead of `"\n" + body` will still LOOK +identical on screen but will behave differently under `TruncateChatLog`'s +newline-boundary search (§3.2) and under `IsAtVerticalEnd` line-counting +(§3.4) if the two approaches disagree at the very first/last line. The +color assignment itself is the color-table doc's territory (not re-derived +here) — the load-bearing NEW fact this document adds is the *order* and +that the timestamp is unconditionally color index `0x0C` regardless of the +body's own type, which the color-table doc §3.2 already states from the +same address; this document supplies the surrounding append sequence and +confirms the timestamp's StringInfo is `arg4`, always appended strictly +BEFORE the body `arg3`, never interleaved or after. + +### 6.3 Timestamp format, verbatim + +`u"%#H:%M:%S "` fed to `wcsftime` — hour without a leading zero, minute, +second, **no date**, one trailing space baked into the format string +(explaining why no separate space-insertion code exists between prefix and +body — the prefix string itself carries its own trailing separator). + +--- + +## 7. Other user-visible window behaviors + +### 7.1 Local session log file — a port would miss this + +`ClientSystem::s_pLogFile` — a plain-text file retail writes chat lines to +during the session, independent of the on-screen transcript's 10k/7.5k +character budget (§3.1) or any window's filter (§2). Written from the same +`AddTextToScroll` branch that builds the on-screen timestamp (§6.1): + +``` +00563e5b fprintf(ClientSystem::s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer); +``` + +Client-local type `0x1A` text (§6.1's short-circuit branch) explicitly +bypasses this — client-local errors/refusals never reach the log file, +only the on-screen transcript. **UNKNOWN — needs further grep:** the log +file's path/naming convention and whether it rotates per-session or +per-character; not chased further as it's a filesystem-artifact question +more than a window-UI one, but flagged because "retail also writes a +plain-text chat log to disk" is exactly the kind of behavior a UI-only port +would miss entirely. + +### 7.2 Unread/unseen marker — confirmed, see §3.4/§3.5 + +The `0x1000048C` "new unseen text" indicator button IS retail's unread +marker. It is per-window (each `ChatInterface` owns its own +`m_chatNewNonVisibleTextIndicator`), lights when a broadcast/addressed line +arrives while the user has scrolled away from the bottom, and clears when +the user clicks it (which also snaps the transcript back to its bottom). +There is no separate "flash the window" or "flash the taskbar/app icon" — +`FlashWindow`/`FlashWindowEx` do not appear anywhere in the pseudo-C dump +(checked via a whole-file grep; zero hits). + +### 7.3 Sound cues on incoming chat — UNKNOWN, likely none dedicated + +A targeted grep for `PlaySound`/`SoundManager::Play*` near the +`Handle_Communication__HearDirectSpeech @0x005715A0` (incoming tell) handler +body found no sound-manager call inside it, and no `Sound_*`-named constant +resembling "tell received" or "chat" turned up in the identifiers swept. +The one chat-adjacent audio-related symbol found is a **global** preference +— `Sound_PlaySoundOnlyWhenActive` / `ID_Sound_NoFocusNoSound` +(`UIPreferences::AttachPreference @0x004037E4`, +`SoundManager::PlaySoundInternal @0x0054FEC0` checks +`SoundManager::s_bPlaySoundOnlyWhenActive` against `Device::m_bIsActiveApp`) +— which mutes ALL UI sounds (not specifically chat) when the game window +isn't the active app. **UNKNOWN — needs a deeper sweep or a live cdb +capture on an incoming tell**: this document did not find a chat-specific +sound cue, but a negative grep result over a 66 MB pseudo-C dump is weak +evidence of absence given how many code paths route through indirect +vtable calls the text search can't follow. Flagging rather than asserting +"retail has no tell sound." + +### 7.4 Copy/paste and text selection — a base `UIElement_Text` capability + +`UIElement_Text::GetSelection @0x00466F20` and `UIElement_Text::SelectAll +@0x004678D0` exist as capabilities of the general text-widget class that +BOTH the chat entry field (`m_chatEntry`) and the read-only transcript +(`m_chatLog`) are instances of (`acclient.h:54904-54905`, both typed +`UIElement_Text*`). `SelectAll`'s call sites found are mostly OTHER +text-entry fields (a character-name box, a stack-size entry box) triggered +by a "select-all-on-first-click" attribute (`UIElement::GetAttribute_Bool(this, +0xd1, ...)` inside `UIElement_Text::MouseDown @0x00469370`), not anything +chat-specific. **UNKNOWN — not independently confirmed for the read-only +transcript specifically:** whether the transcript panel exposes the SAME +click-drag-select-then-copy affordance as the entry field, or whether it is +flagged read-only in a way that suppresses selection; the class-level +capability clearly exists on the type, but no chat-transcript-specific +selection code path was located distinct from the generic `UIElement_Text` +mouse-down handler already cited. Worth a live-client check (select text in +the retail transcript, see if a selection highlight appears) rather than +further static digging. + +### 7.5 What's genuinely absent + +- No `FlashWindow` anywhere in the binary (§7.2). +- No docking/snapping between chat windows or to screen edges — the + window-shell doc's resize/move research found only free-floating + clamped-on-restore positioning (§4). +- No tab strip / tabbed-window container (§5) — five independent panels, + not a tab model. +- No manual "scroll lock" toggle — the auto-scroll behavior in §3.4 IS the + scroll-lock mechanism, driven automatically by `IsAtVerticalEnd`, with no + user-facing on/off switch found. + +--- + +## Behaviours acdream is most likely missing + +Ordered by how load-bearing each gap looks against `RuntimeCommunicationState` +(`docs/research/2026-07-26-slice-j4-1-communication-state.md`) and +`ChatWindowController` as of this session: + +1. **Scrollback truncation entirely.** No 10,000-char trigger / ~7,500-char + target / newline-boundary-preferring trim (§3.1–§3.3) appears to exist in + acdream today — grep `TruncateChatLog`-equivalent behavior in + `ChatLog`/`ChatWindowController` before assuming an unbounded transcript + is fine; it will diverge from retail under long play sessions (memory + growth) and, more subtly, under the exact wrap point if a port ever needs + pixel/line parity with a retail screenshot at high message volume. +2. **The auto-scroll / stick-to-bottom vs. flag-unseen-instead split + (§3.4–§3.5).** This is a genuine behavioral fork, not a cosmetic one: a + naive port that ALWAYS scrolls to bottom on new text will yank the user's + scroll position out from under them mid-read whenever a broadcast line + arrives — exactly the annoyance retail's `IsAtVerticalEnd` check exists to + prevent. Confirm `ChatWindowController` checks "was I at the bottom + before this line landed" before auto-scrolling, and confirm the + `0x1000048C` unseen-indicator element (window-shell doc's layout dump + already has its rect for both window layouts) is wired to light up + + clear via click exactly as §3.4/§3.5 describe. +3. **The window-ID routing predicate as ONE explicit rule (§1.2).** The + color-table doc already flags the routing behavior; this document adds + the exact decompiled shape. Verify `RuntimeCommunicationState`'s chat + windows model (per the CH6c plan in the window-shell doc §6.1) implements + precisely `windowId == m_eWindowID || (windowId == 0 && TypeIsActive)` — + not, e.g., "every window with the type enabled shows every line + regardless of address," which would make addressed command-output lines + leak into windows they were never meant for. +4. **Structural composition order (§6.2)** — separator-before-entry (not + after), timestamp-before-body, timestamp always present-or-absent as a + single unit gated on one option bit. A port that concatenates + `timestamp + " " + body` as one string loses retail's separately-colored, + separately-truncatable prefix run and the option-driven all-or-nothing + presence. +5. **The local session chat-log file (§7.1).** Small, but "retail writes a + plain-text transcript to disk every session" is the kind of feature users + notice is missing only when they go looking for it after the fact. +6. **Per-window independent scrollback.** Once §1 is implemented, confirm + each of the five windows truncates its OWN transcript independently + (§5) rather than sharing one global buffer — a floaty window filtered + down to just Tells should never truncate early just because the main + window's transcript is huge. +7. **Sound cues and transcript text-selection are open questions, not + confirmed gaps** (§7.3, §7.4) — do not build negative-result "retail has + none of this" code around them; re-check live if/when they become + relevant.