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.