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>
6.1 KiB
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
-
The CLIENT composes the markup.
Handle_Communication__HearSpeech @0x005712A0/HearDirectSpeech @0x005715A0sprintf 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 range0x50000001..0x6FFFFFFFare tagged at all. -
UIElement_Text::InqGlyphs @0x00468EA0recognises the brackets while appending and callsTextTagFactory::MakeTag @0x00478480. Any bracketed text that fails to parse closes the open tag —MakeTagrequires a:to succeed, which is exactly what makes the bare closer a closer. -
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_tagpointers are equal. -
Colour: a glyph takes the TAG colour (property
0x1D) only when a tag is open AND itsm_type == 0x10000001; otherwise the ordinary line colour (0x1B). Both are DAT-authored arrays on the element. -
Measured out of the installed dats (
LayoutDump --colors), chat0x2100006F, transcript0x10000011:P0x1B (line) [0x00] R=204 G=204 B=204 P0x1D (tag) [0x00] R= 0 G=178 B= 0 <- the green -
Click:
UIElement_Text::MouseUp @0x004694F0→DeterminePositionFromXY @0x004688F0(xy → glyph index) →GlyphList::InqGlyph @0x00473430→ a virtualHandleClickat 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
IIDStringhas 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:
ChatEntrykeepsSender/SenderGuidall the way throughChatLog, andChatVM.RecentLinesDetailed()builds aFormattedLinethat 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 DATEnumMappercategory0x18. - 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.