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>
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 aDataID(a DAT-file object reference).TextTag_IID— wraps anIID(an in-world Instance ID, i.e. a live object/creature/player's server-assigned id).TextTag_IIDEnum— anIIDplus anenum(a secondary small-integer qualifier).TextTag_IIDString— anIIDplus aPStringBase<unsigned short>(a wide string) — this is the shape used for a clickable player name:m_IIDis the speaker's object id,m_stringmost 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:
- Finds the first
:in the substring (FindChar(':'),acclient_2013_pseudo_c.txt:132904). If none is found, parsing fails andMakeTagreturns0(return 0;@0x478700,acclient_2013_pseudo_c.txt:133064). This is the mechanism an "end tag" uses to close a run — see §3.2. - Resolves the substring before the first
:to an enum value viaEnumMapper::InqEnum(acclient_2013_pseudo_c.txt:132921). - Finds the second
:and resolves that substring to a second enum value, also viaEnumMapper::InqEnum(acclient_2013_pseudo_c.txt:132954), thenswitches 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(...)
- 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)
- Delegates the remainder of the string (after the second
:) to the new object's ownParseStartTagvirtual (via the vtable, atacclient_2013_pseudo_c.txt:133008,*(int32_t*)((char*)vtable + 8)(__return)— vtable slot+0x8=ParseStartTagper the layout in §1.1) to consume the type-specific payload (theIID/DID/enum/stringfields). 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 callsTextTagFactory::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, or0ifMakeTagfailed to parse — replacesebx_1for subsequent characters. - For every ordinary character, the glyph's colour is chosen from
ebx_1(see §5 for the exact rule) and aGlyphis constructed carryingebx_1as itsm_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 0 → ebx_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
TextTagwhose glyphs are entirely scrolled off is destroyed the ordinary way:GlyphList::Deletewalks 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
TextTagwhose 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 ofGlyphList::Delete's implementation, whichBeheadText/TruncateChatLoginvoke 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):
this->vtable->InqProperty(propId, &var_10)— looks up the UIElement's own authored property (0x1bor0x1d) via its normalUIElementproperty mechanism, i.e. this is a LayoutDesc/DAT-authored per-element property, not global engine state.- 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 intoarg2; a bounds checkif (arg4 < arg2); then a virtual call through+0xf4(acclient_2013_pseudo_c.txt:113758) that plausibly fetches the sub-property at indexarg4; then a virtual call through+0x98(acclient_2013_pseudo_c.txt:113761) that plausibly extracts anRGBAColorfrom that sub-property into the caller'sarg3output. - If any step fails (property absent, index out of range),
arg3(the caller'sm_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 getsm_curFontColor(property0x1b's indexed value). - Open tag, but its
m_type != 0x10000001→ stillm_curFontColor. Not every tag type gets the special colour. - Open tag with
m_type == 0x10000001→ glyph getsm_curTagFontColor(property0x1d'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<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
- Data model:
TextTagis a small polymorphic ref-counted object (m_type,m_format, plus subclass payload —IID/DID/enum/stringin various combinations).Glyphcarries an OPTIONALTextTag*.GlyphListis a flat list ofGlyph; nothing above the glyph level stores tag/run information. - 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. - Markup:
<TYPE:FORMAT:DATA>opens a tag (parsed byTextTagFactory::MakeTag, dispatching on theFORMATvalue to one of 4 concrete classes); any bracketed text that fails to parse (in particular the literal<TYPE>-shaped close marker emitted byTextTag::BuildEndTag) closes the currently-open tag. The delimiter text itself is never rendered as glyphs. - 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
GlyphListthat 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. - Colour: two parallel, per-
UIElement_Text, DAT-authored, index-selected colour arrays — property0x1b(base/default) and property0x1d(tag colour) — refreshed from the same caller colour-category index at the top of every append call. A glyph gets the0x1dcolour only if a tag is open AND that tag'sm_typeequals the sentinel0x10000001; otherwise it gets the0x1bcolour regardless of whether some other kind of tag is open. - Click: each concrete
TextTagsubclass'sHandleClickbroadcasts aNoticeHandler-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 == 0x10000001correspond to?TextTagTypehas no recovered named enum (acclient.h:62585is a bare typedef). The literal0x10000001recurs pervasively elsewhere in the pseudo-C for apparently unrelated purposes (dialog IDs,StringInfo::SetStringIDandTableEnumtable-enum arguments, keymap IDs — see the broad grep hits atacclient_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 forTextTag. BothEnumMapper::InqEnum/InqStringroute through a table id of0x18(§1.3), which is very likely a DAT-resident enum/string table (the lookup falls throughMasterDBMap::DivineType-style DBObj resolution seen inEnumMapper::GetEnumByDID,acclient_2013_pseudo_c.txt:29890-29937, for other DID categories), meaning the actual keyword strings (e.g. whatever text maps tom_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 category0x18's EnumMapper table contents (likely inclient_local_English.datorclient_portal.dat) to find the actual keyword-to-m_typemapping, the same wayclaude-memory/project_settings_options_digest.md'sGetNameFromKeywork pulled DAT tables0x2300000A/0x2300000B/0x23000007. - Exact wording of the two
sprintfformat strings atacclient_2013_pseudo_c.txt:133651(u"<%ls:%ls%:%ls>") andacclient_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.rdatastring 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 againstacclient.h'sBaseProperty-family struct definitions in this pass. - The mouse-hit-test →
HandleClickwiring (§5) — which function resolves a click position to a glyph, reads itsm_tag, and invokesHandleClickthrough the vtable — was not located in this pass (no literal-text call site exists to grep for an indirect vtable call). - Which
NoticeHandleroverride actually consumesRecvNotice_TextTag_IIDStringClickand prefills the chat input — not traced. This is the last link needed to fully confirm "clicking a chat name populates a/ttell," though theIID+ name payload shape onTextTag_IIDStringmakes it the overwhelmingly likely candidate tag type for that UI behaviour.