diff --git a/docs/research/2026-08-21-chat-tagged-name-composition.md b/docs/research/2026-08-21-chat-tagged-name-composition.md new file mode 100644 index 00000000..e0428ba4 --- /dev/null +++ b/docs/research/2026-08-21-chat-tagged-name-composition.md @@ -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**, +`:>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::sprintf(&s_NullBuffer_2, "%s says, \"%s\"\n"); // no tag +005714f5 else +00571511 PStringBase::sprintf(&s_NullBuffer_2, "%s<\Tell> says, \"%s\"\n"); +``` + (full literal recovered from the constant pool: `data_7d0e60 @ 0x007d0e60` + = `"%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::sprintf(&arg5, "%s tells you, \"%s\"\n"); // no tag +00571880 else +0057189c PStringBase::sprintf(&arg5, "%s<\Tell> tells you, \"%s\"\n"); +``` + (full literal: `data_7d0ec0 @ 0x007d0ec0` = + `"%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] %ws<\\Tell> says, \"%ws\""); +``` +(full literal: `data_7e83e8 @ 0x007e83e8` = +`"[%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] %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(..., "...\"" 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 "" span +00469084 if (ebx_1 == 0 || tag->m_type != 0x10000001) +00469084 edx_15 = ; // this->m_curFontColor (RGBAColor field order matches struct above) +00469084 else +0046908a edx_15 = ; // 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> 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`, 1–4), 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. `":"`). + +## 2. Tag payload — what a click needs to address the right player + +`struct TextTag_IIDString : TextTag { unsigned int m_IID; PStringBase 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::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::to_uint32(&leftPart); // -> numeric object GUID +00478ae2 PStringBase::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] 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` 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. diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index c54b98c8..3bdd0615 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -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