acdream/docs/research/2026-08-21-chat-texttag-model.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

38 KiB

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 switches 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(...)
  1. 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)
  1. 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 Glyphs, 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 0ebx_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_InternalInqGlyphs, §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 != 0x10000001still 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_Textm_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 (TruncateChatLogBeheadTextDeleteSectionGlyphList::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.