# 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 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` (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 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 `switch`es 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(...) ``` 4. 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) ``` 5. 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::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` plus a cached `SmartArray` (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* 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* 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* 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 const* arg2, class SmartArray* arg3) ``` This converts a raw wide string (which may contain embedded `` markup) into a flat array of `Glyph`s, 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::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 `""`; **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 `0` → `ebx_1` becomes `0` → every glyph after that point is untagged, until the next successfully-parsed `` 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` from `InqGlyphs` into the live `List`, or `List::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* 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_Internal` → `InqGlyphs`, §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 != 0x10000001` → **still** `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_Text`** — `m_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 *); ``` 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**: `` 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 ``-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 (`TruncateChatLog` → `BeheadText` → `DeleteSection` → `GlyphList::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.