docs: Campaign CT — chat text tags, researched and planned

Six parallel research lanes on retail's chat text and window behaviour, plus a
plan. The headline: the green clickable speaker name is not a chat feature and
not a colour, it is a missing capability in the TEXT stack.

Retail's client sprintfs literal tag markup into the chat line, and the text
element parses the brackets while appending, attaching a ref-counted tag PER
GLYPH. A tagged run is emergent: adjacent glyphs whose tag pointers are equal.
A glyph takes the tag colour (property 0x1D) only when a tag is open and its
type is 0x10000001; otherwise the ordinary line colour (0x1B).

The colour itself was the one thing the decomp could not settle — it is
authored, not runtime-built — so it was MEASURED out of the installed dats
rather than assumed from a screenshot: P0x1D = RGB(0,178,0). That also exposed
a trap: the tag colour is per-ELEMENT and authored while the line colour on the
same element comes from the runtime chat table, so filing "tag green" into the
LogTextType table would put it in the wrong place.

Our own audit found the gap is narrower than feared. UiText ALREADY draws
multi-coloured runs (the character stat panel uses it); the path is just gated
to single-line elements. The draw path needs no renderer work, and HitChar
already resolves a click to line+column. The real blocker is that sender
identity is destroyed before it reaches the renderer: ChatEntry carries
Sender/SenderGuid the whole way, and ChatVM.RecentLinesDetailed drops both.

Two findings beyond the original question. Retail BOUNDS its transcript
(10,000 chars, trimmed to ~7,500 at a newline) and splits auto-scroll from an
unread indicator by sampling "was at bottom" before the line lands — a naive
port auto-scrolls forever and leaks for the life of a session. And the chat-UI
audit turned up an untracked bug: Escape in the chat input does nothing at all,
because UiField has no Escape case and a focused field also suppresses the
input dispatcher's fallback.

Every lane was instructed to write "UNKNOWN — needs X" rather than guess, and
they did; the carried unknowns are listed in the plan rather than papered over.

Seven slices proposed, nothing implemented yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 06:52:05 +02:00
parent be324c003c
commit 663129c340
6 changed files with 2732 additions and 0 deletions

View file

@ -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
`<Tell:IIDString:{iid}:{name}>{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.

View file

@ -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 <text>` — 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 <verb>`; 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`

View file

@ -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<IReadOnlyList<Line>>`) 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<IReadOnlyList<TextRun>>?`) 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 <name>` 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<FormattedRun> 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<TextRun>` 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).

View file

@ -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 <LastTeller>,`) 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 <Name>, " into the entry, focuses it)
```
Player/speaker names in chat are wrapped by the server/client text
formatters in a `<Tell:IIDString:<iid>:<name>>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] <Tell:IIDString:0:%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<Glyph>* 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<Glyph>`, 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<unsigned short> 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<unsigned short> 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<unsigned short> const* name)
{
PStringBase<unsigned short> 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 <PlayerName>, "` — 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
12 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<char>*` return, vs. the click path's
already-wide `PStringBase<unsigned short>` 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 `<Tell:IIDString:<iid>:<name>>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 |
|---|---|---|
| `"<Tell:IIDString:%d:%s>%s<\Tell> tells you, \"%s\"\n"` | `data_7d0ec0 @ 0x007D0EC0` | direct tell received |
| `"<Tell:IIDString:%d:%s>%s<\Tell> says, \"%s\"\n"` | `data_7d0e60 @ 0x007D0E60` | local/say-range speech |
| `"[Fellowship] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0cdc @ 0x007D0CDC` | fellowship chat |
| `"[Co-Vassals] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0bfc @ 0x007D0BFC` | co-vassal chat |
| `"[Allegiance Broadcast] <Tell:IIDString:0:%s>%s<\\Tell> says, \""` | `data_7d0c30 @ 0x007D0C30` | allegiance broadcast |
| `"Your patron <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0d10 @ 0x007D0D10` | patron chat |
| `"Your vassal <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0d4c @ 0x007D0D4C` | vassal chat |
| `"Your follower <Tell:IIDString:0:%s>%s<\\Tell> says to you, \""` | `data_7d0ca0 @ 0x007D0CA0` | follower chat |
| `"[%ws] <Tell:IIDString:0:%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 `</Tell>` 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`).

View file

@ -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<unsigned short> 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<unsigned short>`
(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<unsigned short> 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<unsigned short>::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<Glyph>` plus a
cached `SmartArray<GlyphLine,1>` (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<Glyph>* 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<Glyph>* 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<Glyph>* 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<unsigned short> const* arg2, class SmartArray<Glyph,1>* arg3)
```
This converts a raw wide string (which may contain embedded
`<TYPE:FORMAT:DATA>` 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<unsigned short>::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 `"</%ls>"`; **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 `<TYPE:FORMAT:
DATA>` 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<Glyph,1>` from `InqGlyphs` into
the live `List<Glyph>`, or `List<Glyph>::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<Glyph>* 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<unsigned short> *);
```
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**: `<TYPE:FORMAT:DATA>` 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 `<TYPE>`-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.

View file

@ -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<unsigned short> 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<unsigned short>::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<UIElement_Text *,1>::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<unsigned short>::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]`, `[<name>]`, 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.