acdream/docs/research/2026-08-21-chat-tagged-name-composition.md
Erik be324c003c tools(LayoutDump): dump authored colour arrays; measure retail's tag green
The chat deep-dive proved the MECHANISM behind retail's green clickable
speaker name — a glyph tag coloured from property 0x1D rather than the line's
own 0x1B — but not the colour itself: BuildChatColorLookupTable @0x004F31C0
builds only the 0x1B array, so the value is authored rather than runtime-built
and the research correctly returned "UNKNOWN" instead of assuming the green in
a screenshot.

--colors prints the 0x1B/0x1D arrays of every element in a layout, which
measures it out of the installed dats:

    chat 0x2100006F, transcript 0x10000011
      P0x1B [0x00] R=204 G=204 B=204
      P0x1D [0x00] R=  0 G=178 B=  0     <- the green

Recorded in the research note, including the trap it exposes: the tag colour is
per-ELEMENT and authored, while the line colour on that same element comes from
the runtime chat table. Filing "tag green" into the LogTextType colour table
would put it in the wrong place entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 06:49:59 +02:00

25 KiB
Raw Blame History

Retail chat: how a tagged, coloured player-name run gets composed

Research-only. No source files modified. All claims cite symbol @ 0xADDRESS in docs/research/named-retail/acclient_2013_pseudo_c.txt (pseudo-C) or docs/research/named-retail/acclient.h (verbatim retail struct defs) unless otherwise noted. Addresses without an explicit file are in the pseudo-C dump.

TL;DR — the mechanism in one paragraph

Retail does not use StringInfo's two-colour-argument mechanism (RecvNotice_DisplayFinalStringInfo's arg3/arg4) to colour the player name differently from the rest of the sentence. StringInfo is only a localization/variable-substitution template (string-table id + named variables, or a literal override) — it has no colour or tag fields at all. Instead, the sender's name is delivered as a literal inline markup tag, <Tell:IIDString:<GUID>:<Name>>Name<\Tell>, baked directly into the plain wide-char sentence before it is ever handed to the UI. When that sentence is appended to the chat log, UIElement_Text's glyph-list builder (UIElement_Text::InqGlyphs @ 0x00468ea0) recognizes the <...> markup, asks TextTagFactory::MakeTag @ 0x00478480 to parse it into a TextTag_IIDString object (GUID + name), and colours every individual Glyph inside the tagged span from m_curTagFontColor (font-color property 0x1D) instead of the line's m_curFontColor (property 0x1B) — but only if the tag's type is the "Tell" enum value 0x10000001. Each Glyph also carries a TextTag* pointer, which is what makes the run clickable and lets a click resolve back to the right player.

1. Where the sender name becomes a tagged run

1a. The literal tag text is baked in at message-composition time

Two adjacent handlers on ClientCommunicationSystem build the chat line for incoming speech, and both embed the markup directly via sprintf, before any StringInfo/UI code runs:

  • Local/overheard speechClientCommunicationSystem::Handle_Communication__HearSpeech @ 0x005712a0:
005714f5      if ((arg4 < 0x50000001 || arg4 > 0x6fffffff))
005714f5          PStringBase<char>::sprintf(&s_NullBuffer_2, "%s says, \"%s\"\n");        // no tag
005714f5      else
00571511          PStringBase<char>::sprintf(&s_NullBuffer_2, "<Tell:IIDString:%d:%s>%s<\Tell> says, \"%s\"\n");

(full literal recovered from the constant pool: data_7d0e60 @ 0x007d0e60 = "<Tell:IIDString:%d:%s>%s<\\Tell> says, \"%s\"\n", since Binary Ninja's inline preview truncates at ~33 chars).

  • Direct tellClientCommunicationSystem::Handle_Communication__HearDirectSpeech @ 0x005715a0, same shape:
00571880      if ((arg4 < 0x50000001 || arg4 > 0x6fffffff))
00571880          PStringBase<char>::sprintf(&arg5, "%s tells you, \"%s\"\n");             // no tag
00571880      else
0057189c          PStringBase<char>::sprintf(&arg5, "<Tell:IIDString:%d:%s>%s<\Tell> tells you, \"%s\"\n");

(full literal: data_7d0ec0 @ 0x007d0ec0 = "<Tell:IIDString:%d:%s>%s<\\Tell> tells you, \"%s\"\n").

%d = arg4, the speaker's actual object GUID from the wire message. %s (first) = the speaker's display name (repeated once inside the tag payload, once again as the visible glyph text after the >).

The 0x50000001..0x6FFFFFFF GUID-range gate is load-bearing: only senders whose object id falls in that range get the clickable/coloured treatment at all. This is AC1's dynamic-object id range (players and other non-static weenies); ids outside it (system/NPC broadcast cases handled elsewhere) fall through to the plain, untagged "%s says/tells... format with no markup and no special colour.

Group/channel broadcasts go through a separate builder, ChatRoomTracker::GetChatFormat @ 0x005cd7c0 (called from gmCCommunicationSystem::uiChatInterfaceProvider::OnSendToRoom @ 0x0058a590, the TurbineChat room-message handler), which always uses IID:0 in the tag (no real object id is available/needed there) and prepends the channel name, e.g. for General chat:

005cd93a      ebx = 0x1b;                                    // LogTextType = General
005cd93f      var_1c_14 = &ChannelSystem::General_GlobalChannelName;
005cd85c      sprintf(&s_NullBuffer_2, "[%ws] <Tell:IIDString:0:%ws>%ws<\\Tell> says, \"%ws\"");

(full literal: data_7e83e8 @ 0x007e83e8 = "[%ws] <Tell:IIDString:0:%ws>%ws<\\Tell> says, \"%ws\""). The function returns a ChatDisplayInfo{ m_ltt (LogTextType), m_display (the whole sprintf'd string), m_doDisplayText } and the caller passes m_display and m_ltt straight into ClientSystem::AddTextToScroll. Similar hard-coded IID:0 tag formats exist for Fellowship broadcast ("[Fellowship] <Tell:IIDString:0:%s>%s<\\Tell> says, \"", data_7d0cdc @ 0x007d0cdc), Co-Vassals (data_7d0bfc @ 0x007d0bfc), Allegiance Broadcast (data_7d0c30 @ 0x007d0c30), patron/vassal/follower tells (data_7d0d10, data_7d0d4c, data_7d0ca0).

BN-truncation note: every one of the sprintf(..., "<Tell:IIDString:...") call sites above showed only a -truncated ~33-char preview in the raw decompiler output. The full strings quoted here were recovered from the named constant-pool entries (data_7d0bfc, data_7d0c30, data_7d0c88, data_7d0ca0, data_7d0cdc, data_7d0d10, data_7d0d4c, data_7d0e60, data_7d0ec0, data_7e83e8), which the pseudo-C dump prints verbatim elsewhere in the file as global char const data_XXXXXXXX[N] = "...", 0 declarations — not guessed.

1b. The plain sprintf'd string reaches the chat window unmodified

ClientSystem::AddTextToScroll @ 0x00487fc0 (char* overload) widens to UTF-16 and tail-calls the wide overload @ 0x00563c50, which wraps the already-composed text (markup and all) in a literal-override StringInfo and forwards it to the notification bus:

00563ee9      StringInfo::SetLiteralValue(&var_890, &m_charbuffer_3 /* full "<Tell:...>...\"" text */, 1);
00563f05      StringInfo::SetLiteralValue(&var_920, &s_NullBuffer_4 /* timestamp string, or empty */, 1);
00563f2b      ECM_UI::SendNotice_DisplayFinalStringInfo(arg3 /* LogTextType/colour index */, &var_890, &var_920, arg5 /* window id */);

This confirms StringInfo here is used purely as a transport wrapper around an already-fully-formed literal string — StringInfo::SetLiteralValue sets m_Override = 1 (literal) so StringInfo::GetString later just returns the wrapped text verbatim; no template/variable substitution happens for chat lines built this way. (See §4 for why StringInfo cannot itself be the tag carrier.)

ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640 (the override registered on ChatInterface) receives this notice:

004f46dc      if (StringInfo::IsValid(arg4, 1) != 0)
004f46e9          UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4, 0, 0xc);   // arg4 = timestamp StringInfo, colour index 0x0C
004f46fc      UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3, 0, arg2);        // arg3 = the message StringInfo, colour index arg2 (LogTextType)

arg4 (the second StringInfo, appended first, at the fixed colour index 0x0C) is the timestamp prefix ("HH:MM:SS ", built a few lines earlier in AddTextToScroll from PlayerModule::DisplayTimeStamps + wcsftime), not a channel-name prefix. arg3 (colour index arg2 = the caller-supplied LogTextType) is the entire rest of the line, channel prefix and all — see §3 for why that resolves the "0x0C is grey" question.

1c. AppendStringInfoWithFont resolves the literal text and hands it to the glyph parser

UIElement_Text::AppendStringInfoWithFont @ 0x00469de0:

00469df4      UIElement_Text::SetFontDIDHelper(this, 0x1a, &this->m_curFontObj, arg3);
00469e09      UIElement_Text::SetFontColorHelper(this, 0x1b, &this->m_curFontColor, arg4);     // line colour, keyed by LogTextType index arg4
00469e1a      UIElement_Text::SetFontColorHelper(this, 0x1d, &this->m_curTagFontColor, arg4);  // tag colour, same index
00469e2a      eax_1 = StringInfo::GetString(arg2, &arg3, 0);       // resolves literal-override text verbatim
00469e44      UIElement_Text::AddText_Internal(this, m_charbuffer, 3);

m_curFontColor and m_curTagFontColor are named fields on UIElement_Text (docs/research/named-retail/acclient.h:53408,53410):

struct __cppobj __declspec(align(8)) UIElement_Text : UIElement_Scrollable, CInputHandler
{
  ...
  RGBAColor m_curFontColor;
  Font *m_curFontObj;
  RGBAColor m_curTagFontColor;
  ...
};

So before any markup parsing happens, the widget primes two colours for the whole append call — the base line colour and a separate tag colour — both looked up via the exact same LogTextType-indexed mechanism (UIElement_Text::SetFontColorHelper @ 0x00466ac0, which does InqProperty(propId) → indexed-array element at [arg4]).

1d. The glyph-list builder recognizes <...> and creates the TextTag

UIElement_Text::InqGlyphs @ 0x00468ea0 (the routine AddText_Internal uses to turn the resolved wide string into Glyph records) scans char-by-char; on finding < it captures through the matching > and calls the tag factory:

00469021      int32_t eax_16 = TextTagFactory::MakeTag();      // parses the whole "<Tell:IIDString:GUID:Name>" span
00469084      if (ebx_1 == 0 || tag->m_type != 0x10000001)
00469084          edx_15 = <UIElement_Text + 0x6a4>;   // this->m_curFontColor  (RGBAColor field order matches struct above)
00469084      else
0046908a          edx_15 = <UIElement_Text + 0x6b8>;   // this->m_curTagFontColor
004690c5      Glyph::Glyph(&esp_1[9]);                  // constructs the glyph with the chosen colour + tag pointer

(Note: this function is heavily register/stack-mangled in Binary Ninja's output — the raw operand forms above are paraphrased from the literal esp/ecx chains in the dump, not verbatim BN text, because the BN pseudo-C here reads as raw stack-slot arithmetic rather than named field accesses. The two struct-offset destinations (+0x6a4, +0x6b8) are 0x14 bytes apart, matching an RGBAColor (16 bytes) + Font* (4 bytes) gap between m_curFontColor and m_curTagFontColor in the struct dump above — consistent with, but not a byte-for-byte confirmed alias of, those two named fields.)

Glyph itself carries per-character colour and tag (docs/research/named-retail/acclient.h:45330):

struct __cppobj Glyph
{
  unsigned __int16 m_data;   // character code
  int m_width;
  int m_height;
  RGBAColor m_color;         // per-glyph colour — this is what makes the name a different colour from the rest of the line
  Font *m_font;
  TextTag *m_tag;            // non-null only for glyphs inside a <Tag:...>...<\Tag> span — this is what makes it clickable
};

This is the answer to "who creates the tagged run": TextTagFactory::MakeTag @ 0x00478480, called from UIElement_Text::InqGlyphs @ 0x00468ea0 while it walks the resolved plain-text string looking for <...> markers. It is a markup parser operating on plain text, not a StringInfo/variable mechanism.

TextTagFactory::MakeTag @ 0x00478480 itself:

  1. Confirms the captured span starts with < and ends with >.
  2. Splits on the first : — the text before it (e.g. "Tell") is looked up via EnumMapper::InqEnum(name, 0x18, &m_type) (a DAT-driven string→enum table, category 0x18) to get the numeric tag type (this->m_type, e.g. 0x10000001 for "Tell").
  3. Splits again on the next : — the text between them (e.g. "IIDString") is looked up the same way to get a small class selector (var_18, 14), which a switch uses to instantiate the right TextTag subclass:
    • 1TextTag_DID
    • 2TextTag_IID
    • 3TextTag_IIDEnum
    • 4TextTag_IIDString (jump table jump_table_478728 @ 0x00478728, case 4 @ 0x00478617)
  4. Calls the new tag's virtual ParseStartTag on the remaining payload text (everything after the second :, i.e. "<GUID>:<Name>").

2. Tag payload — what a click needs to address the right player

struct TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase<unsigned short> m_string; } (docs/research/named-retail/acclient.h:53960), with the base class struct TextTag : ReferenceCountTemplate<1048576,0> { unsigned int m_type; unsigned int m_format; } (docs/research/named-retail/acclient.h:45358).

TextTag_IIDString::ParseStartTag @ 0x00478910 fills it in:

00478946      if (PStringBaseIter_Common<unsigned short>::FindChar(&iter, ':', 0) != 0)   // find the FIRST ':' in "GUID:Name"
00478a02          if (PSUtils::is_uint32(leftPart) != 0)   // left of ':' must parse as a uint32
004788ad          this->m_IID = PStringBase<unsigned short>::to_uint32(&leftPart);         // -> numeric object GUID
00478ae2          PStringBase<unsigned short>::operator=(&this->m_string, &rightPart);      // -> display name text

So the payload is both the numeric object id and the display name string, not just one or the other. TextTag_IIDString::BuildStartTagData @ 0x004788e0 is the inverse (serializes back to "0x%08X:%ls"), confirming the same two-field shape round-trips.

Click resolution (TextTag_IIDString::HandleClick @ 0x00478840):

0047884c      ECM_UI::SendNotice_TextTag_IIDStringClick(this->m_type, this->m_IID, &this->m_string);

which is picked up by gmMainChatUI::RecvNotice_TextTag_IIDStringClick @ 0x004cce10:

004cce1b      if (arg2 == 0x10000001 && ChatInterface::IsTextEntryFocused(this) == 0)   // arg2 = tag->m_type ("Tell")
004cce2d          ChatInterface::StartTell(this, arg4);    // arg4 = tag->m_string  — starts a /tell using the NAME, not the GUID

So in the one client-side consumer we traced, the actual action (StartTell) only uses the name string, even though the tag also carries the numeric GUID. The GUID is transmitted through the notice (arg3) but this handler doesn't consume it — it may be used by other, untraced RecvNotice_TextTag_IIDStringClick overrides (several other UI classes register the same override — see the vtable-slot list in the pseudo-C dump around 0x0079e580 onward — only gmMainChatUI's and the base NoticeHandler::RecvNotice_TextTag_IIDEnumClick fallback were read in this pass). UNKNOWN — needs a scan of every other RecvNotice_TextTag_IIDStringClick override if a consumer that actually resolves by GUID matters for acdream's design (e.g. distinguishing two players who changed names, or a "select in world" action).

3. The [General] channel prefix — colour, and resolving the 0x0C puzzle

Resolved: the apparent "0x0C is grey" contradiction was a mis-identification on my part before tracing the code, not a real contradiction. Index 0x0C is not the channel-prefix colour — it's hard-coded in ChatInterface::RecvNotice_DisplayFinalStringInfo @ 0x004f4640 (§1b) as the colour for the timestamp StringInfo (arg4), which is entirely separate from the channel-prefixed message text (arg3). Confirmed against ChatInterface::BuildChatColorLookupTable @ 0x004f31c0 (see below): index 0x0C's colour is colorGrey — exactly matching the existing project note. It's grey because it's the timestamp, not because it's "[General]".

"[General]" and the rest of the line (says, "...", including the embedded name tag's line-colour-before-override) share one LogTextType value for the whole assembled string — set in ChatRoomTracker::GetChatFormat @ 0x005cd7c0 (§1a):

005cd93a      ebx = 0x1b;   // General
005cd949      ebx = 0x1c;   // Trade
005cd95c      ebx = 0x1d;   // LFG
005cd96d      ebx = 0x1e;   // Roleplay
005cd97e      ebx = 0x12;   // Olthoi
005cd9b8      ebx = 0x20;   // Society (all variants)

ChatInterface::BuildChatColorLookupTable @ 0x004f31c0 builds one LogTextType-indexed colour array (BaseProperty at property id 0x1B, 34 entries, indices 1..0x22) applied to this->m_chatLog. It defaults every index to colorGreen @ 0x81c578 and then overrides ~27 of them. Reading the override sequence address-by-address against the named RGBAColor globals in the constant pool (0x81c4a8..0x81c598, each printed with a name, e.g. class RGBAColor colorGrey = { r=0.824 g=0.824 b=0.784 a=1 }) gives this full index→colour table:

LogTextType index Colour name RGBA
2 colorWhite (1, 1, 1, 1)
0x0C colorGrey (0.824, 0.824, 0.784, 1) — timestamp, not General
3, 0xA, 0x13, 0x1F colorYellow (1, 1, 0.247, 1)
4, 0xB colorTan (0.824, 0.824, 0.392, 1)
5 colorBrightPurple (1, 0.498, 1, 1)
6, 0xF, 0x15 colorDarkRed (1, 0.247, 0.247, 1)
7, 0x11 colorLightBlue (0.247, 0.749, 1, 1)
8, 9 colorPink (1, 0.588, 0.588, 1)
0xD colorCyan (0.247, 0.863, 0.863, 1)
0xE, 0x1B (General), 0x1C (Trade), 0x1D (LFG), 0x1E (Roleplay), 0x20 (Society) colorBlueGrey (0.706, 0.863, 0.941, 1)
0x16 colorLightRed (0.96, 0.459, 0.447, 1)
0x12 (Olthoi) , 0x21 colorOrange (0.933, 0.573, 0.118, 1)
0x1A colorBrightRed (1, 0, 0, 1)
everything else (1, 0x10, 0x14, 0x17, 0x18, 0x19, 0x22) colorGreen (default, unoverridden) (0.5, 1, 0.498, 1)

So General/Trade/LFG/Roleplay/Society chat all render in the same pale blue-grey (colorBlueGrey) as their base line colour — "[General]" and says, "..." are the same colour. This cross-checks cleanly against the project's existing claude-memory/reference_retail_chat_colors.md (same named constants, same addresses, independently dumped live via cdb on 2026-06-16): its colorWhite→LocalSpeech, colorBrightPurple(index 5)→Tell, colorLightRed(index 0x16)→Combat, and colorGrey(index 0x0C)→"Emote/SoulEmote/fallback" mappings all match the indices found here exactly. That memory doc's "Channel"→colorLightBlue guess (its own text flags this mapping as an unverified nearest-match, "the rare kinds map to the nearest named color... wasn't traced") is superseded by the exact trace above: the built-in text channels are colorBlueGrey, not colorLightBlue (colorLightBlue is indices 7 and 0x11, whose LogTextType names weren't identified in this pass — UNKNOWN, would need the DAT-driven LogTextTypeEnumMapper string table to name every index; see §5).

Net effect for the screenshot in the prompt: "[General] <name> says, ..." is two colours, not three — the whole line (brackets, "says,", the quoted message) in colorBlueGrey, and the name span in whatever m_curTagFontColor resolves to (see §5) wherever the tag's type is "Tell" (0x10000001). If the user's read genuinely showed three distinguishable hues, the third one is not explained by anything traced in this pass — flag as UNKNOWN, possibly a rendering/outline-colour effect (m_curOutlineColor, also a field on UIElement_Text, untraced here) or a visual misread of anti-aliasing against the grey timestamp prefix.

4. Is StringInfo the tag carrier?

No. Traced its full field set from the constructor/accessor bodies (StringInfo::StringInfo @ 0x0042da60, ::Reset @ 0x0042daf0, ::IsValid @ 0x0042cbe0, ::AddVariable_Int/UInt/Float/String/StringInfo @ 0x0042dde0-0x0042e7d0): m_Override (0=table-driven / 1=literal / 2=?), m_stringID, m_tableID, m_strToken, m_LiteralValue, m_strEnglish, m_strComment, and m_variables (an IntrusiveHashTable<name, StringInfoData*> for named-variable substitution into a localized template). None of these are colour, tag, or link fields — StringInfo is purely a localization envelope (string-table id + substitution variables, or a raw literal override via SetLiteralValue). Tagging is applied after the envelope is unwrapped: StringInfo::GetString (called inside AppendStringInfoWithFont @ 0x00469de0, §1c) resolves the final plain wide string, and that plain string is what UIElement_Text::AddText_Internal/InqGlyphs scans for <...> markup. So the two systems are cleanly separated: StringInfo answers "what text, and in what language", the glyph-list builder answers "does any of this text contain clickable/differently-coloured spans".

5. Which colour does the tagged name actually use?

Structurally proven, exact value UNKNOWN. §1c/1d prove the mechanism: UIElement_Text::SetFontColorHelper(this, 0x1D, &m_curTagFontColor, arg4) @ 0x00469e1a looks up property 0x1D through the identical indexed-array-by-LogTextType path as property 0x1B (line colour) — see UIElement_Text::SetFontColorHelper @ 0x00466ac0, which does InqProperty(propId) → array bounds check (arg4 < count) → indexed element copy. And UIElement_Text::InqGlyphs @ 0x00468ea0 proves the use: a glyph gets m_curTagFontColor instead of m_curFontColor specifically when its enclosing tag's m_type == 0x10000001 (the "Tell" tag-name enum value, resolved via the DAT-driven EnumMapper category 0x18 — see §1d step 2). Tags of any other type (e.g. IIDEnum-based links used elsewhere in the client) are still clickable (non-null Glyph.m_tag) but render in the ordinary line colour — the green/special-colour behaviour is specific to Tell-type name links, not "any markup tag."

What I could not find: a second BuildXxxColorLookupTable-style function that populates property 0x1D's array the way ChatInterface::BuildChatColorLookupTable @ 0x004f31c0 populates 0x1B (that function only ever calls SetPropertyName(&var_18, 0x1b) once, and the whole function body — read start to end — only ever writes to that one array before the final this->m_chatLog->vtable->SetProperty(&var_18)). Two explanations are consistent with what's traced and neither is confirmed:

  • Property 0x1D is authored directly on the chat-log UIElement_Text widget via its LayoutDesc (a per-widget default, not something ChatInterface code builds at runtime) — plausible since SetFontColorHelper's InqProperty call would find any property the widget inherits, not just ones BuildChatColorLookupTable wrote.
  • A second, unlocated runtime builder populates it elsewhere.

UNKNOWN — needs either: (a) a LayoutDesc/DAT dump of the chat-log window's property 0x1D (or its default RGBAColor), or (b) a live cdb breakpoint on UIElement_Text::SetFontColorHelper with arg2==0x1D while a real Tell-tagged line renders, reading this->m_curTagFontColor after the call returns (same toolchain as claude-memory/reference_retail_chat_colors.md's x acclient!color* / dd recipe). The user's screenshot reads it as green, and AC's clickable-name convention is widely remembered as green, but that is not something this pass proved from decomp — flagging it as inferred-from-screenshot/prior-knowledge, not decomp-verified.

Open items / follow-ups

  • §2: only one RecvNotice_TextTag_IIDStringClick override (gmMainChatUI) was traced for click behaviour; others exist (vtable slots reference NoticeHandler::RecvNotice_TextTag_IIDEnumClick and UIElement::MouseHover as generic fallbacks — worth a second pass if acdream needs to replicate hover/tooltip behaviour, not just click).
  • §3: LogTextType names for indices 7, 0x11 (colorLightBlue), 0xE (shares colorBlueGrey with the built-in channels), and the seven unoverridden default-green indices (1, 0x10, 0x14, 0x17, 0x18, 0x19, 0x22) are unidentified — the DAT-driven LogTextTypeEnumMapper string table (struct __cppobj LogTextTypeEnumMapper, acclient.h:57333) would name them; not pulled in this pass.
  • §5: the exact m_curTagFontColor RGBA value is unproven from static decomp alone — see the two follow-up options listed there.
  • The m_format field on TextTag (set from the tag's second colon-split segment, e.g. "IIDString" → the class-selector 1..4) was read as a class-shape selector, consistent with the TextTagFactory::MakeTag switch, but its retail name/purpose beyond "which TextTag subclass" was not otherwise probed.

RESOLVED: the tag colour is authored, and it is green

The research pass above could only prove the mechanism for the tag colour (property 0x1D, applied per-glyph when a tag is open and its m_type is 0x10000001), not its value — ChatInterface::BuildChatColorLookupTable @0x004F31C0 builds only the ordinary 0x1B array, so it correctly flagged the RGBA as UNKNOWN rather than assuming the green seen in a screenshot.

It is authored in the LayoutDesc, and it measures out of the installed DATs as:

chat window 0x2100006F, transcript element 0x10000011
  P0x1B (line colour) [0x00]  R=204 G=204 B=204 A=255
  P0x1D (tag colour)  [0x00]  R=  0 G=178 B=  0 A=255      <- the green

Reproduce with:

dotnet run --project tools/LayoutDump -c Release -- 0x2100006F --colors

Two things worth carrying into the port:

  • The tag colour is per-ELEMENT, not per-LogTextType. 0x1B here is a one-entry array too, so on this element the ordinary colour comes from the runtime-built chat table while the tag colour comes from the authored property. A port that files "tag green" into the LogTextType colour table would be putting it in the wrong place.
  • The same 0x1D green appears on more than one element in this layout, so it is not unique to the transcript.

tools/LayoutDump --colors was added for this measurement and prints the 0x1B/0x1D arrays of every element in a layout.