acdream/docs/research/2026-08-21-chat-tag-click-dispatch.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

26 KiB
Raw Blame History

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):

// 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:

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:

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:

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):

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"):

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:

// 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 YesgmMainChatUI

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:

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:

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:

// 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 ids0x1B 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:

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:

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) @ 0x00469CAFdwParam1 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:

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).