acdream/docs/plans/2026-08-21-chat-text-tag-campaign.md
Erik 663129c340 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>
2026-08-21 06:52:05 +02:00

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

  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 @0x004694F0DeterminePositionFromXY @0x004688F0 (xy → glyph index) → GlyphList::InqGlyph @0x00473430 → a virtual HandleClick at tag-vtable +0x14SendNotice_TextTag_*ClickgmMainChatUI::RecvNotice_TextTag_IIDStringClick @0x004CCE10ChatInterface::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.