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>
This commit is contained in:
Erik 2026-08-21 06:49:59 +02:00
parent 3eb28c57f2
commit be324c003c
2 changed files with 493 additions and 0 deletions

View file

@ -0,0 +1,469 @@
# 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 speech**`ClientCommunicationSystem::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 tell**`ClientCommunicationSystem::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:
- `1``TextTag_DID`
- `2``TextTag_IID`
- `3``TextTag_IIDEnum`
- `4``TextTag_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.

View file

@ -22,6 +22,7 @@ if (args.Length == 0)
}
bool showStates = args.Contains("--states");
bool showColors = args.Contains("--colors");
uint[] ids = args.Where(a => !a.StartsWith("--"))
.Select(a => Convert.ToUInt32(a, a.StartsWith("0x") ? 16 : 10))
.ToArray();
@ -117,6 +118,29 @@ void Print(ElementInfo e, int depth)
+ $"parent={(e.HasOriginalParentSize ? $"{e.OriginalParentWidth:0.#}x{e.OriginalParentHeight:0.#}" : "-")} "
+ $"z={e.ZLevel} order={e.ReadOrder}");
// Colour-array properties. 0x1B is the ordinary font-colour array and 0x1D
// the TAG font-colour array (UIElement_Text::SetFontColorHelper); both are
// indexed by the caller, so the tagged-name colour is a row in 0x1D rather
// than anything the runtime builds.
if (showColors)
{
foreach (UiStateInfo state in e.States.Values)
{
foreach (uint prop in new[] { 0x1Bu, 0x1Du })
{
if (!state.Properties.TryGetValue(prop, out UiPropertyValue? v) || v is null)
continue;
Console.WriteLine($"{pad} P0x{prop:X2} ({v.ArrayValue.Count} entries):");
for (int i = 0; i < v.ArrayValue.Count; i++)
{
UiColorValue c = v.ArrayValue[i].ColorValue;
Console.WriteLine(
$"{pad} [0x{i:X2}] R={c.Red,3} G={c.Green,3} B={c.Blue,3} A={c.Alpha,3}");
}
}
}
}
if (showStates && e.States.Count != 0)
{
string names = string.Join(", ", e.States