# Retail chat color table — the exact type→color lookup **Date:** 2026-08-09 **Status:** RESEARCH ONLY — no production code changed. **Primary source:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Binary Ninja pseudo-C of the Sept 2013 EoR `acclient.exe`, PDB-named) + direct byte-read of the PDB-paired binary `C:\Users\erikn\Downloads\acclient.exe` (v11.4186, CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). **Ghidra MCP:** not reachable this session (ports 8080 and 8081 both refused); everything below comes from the committed static decomp plus the binary itself. --- ## 0. TL;DR - The lookup table has **exactly 34 entries**, indices `0x00`–`0x21`. - The index is the **`LogTextType`** value — the same integer that arrives on the wire as ACE's `ChatMessageType`. **There is no remapping anywhere**: the wire byte is passed hand-to-hand from the message handler down to the font-color setter and used as a raw array index. - The table's **default fill is `colorGreen`** (0.5, 1, 0.498). Seven indices are never overwritten and therefore stay green. - The colors are **hard-coded C++ constants**. There is no chat-color option, no registry key, no DAT override. What retail *did* let the player configure is which text types go to which chat *window* (a 64-bit bitfield), not what color they are. - All 13 cdb-dumped RGBA values are confirmed byte-for-byte against the binary, and the one address the 2026-06-16 cdb session missed (`0x81c4d8`) is resolved here. --- ## 1. The builder — `ChatInterface::BuildChatColorLookupTable @ 0x004F31C0` Called unconditionally from `ChatInterface::PostInit @ 0x004F3DD0` (call site `0x004F3F13`), once per chat-window construction. ### 1.1 What it actually does ``` if (m_chatLog == null) return; var list = new BaseProperty(name = 0x1B); // the font-color LIST property var color = new BaseProperty(name = 0x19); // one RGBAColor-valued element color.SetColor(&colorGreen); // 0x81C578 for (i = 0x22; i != 1; i--) // 34 iterations list.Append(color); // → indices 0..0x21 all green color.SetColor(&colorWhite); list.SetValue(0x02, color); color.SetColor(&colorGrey); list.SetValue(0x0C, color); color.SetColor(&yellow); list.SetValue(0x03, 0x1F, 0x0A, 0x13); color.SetColor(&darkYellow); list.SetValue(0x04, 0x0B); color.SetColor(&colorPink); list.SetValue(0x08, 0x09); color.SetColor(&orange); list.SetValue(0x12, 0x21); color.SetColor(&colorBlueGrey); list.SetValue(0x1B, 0x1C, 0x1D, 0x1E, 0x20, 0x0E); color.SetColor(&colorDarkRed); list.SetValue(0x0F, 0x06, 0x15); color.SetColor(&colorLightRed); list.SetValue(0x16); color.SetColor(&colorLightBlue); list.SetValue(0x07, 0x11); color.SetColor(&colorCyan); list.SetValue(0x0D); color.SetColor(&colorBrightPurple); list.SetValue(0x05); color.SetColor(&colorBrightRed); list.SetValue(0x1A); m_chatLog->SetProperty(list); // 0x004F3799 ``` Decoding notes for anyone re-reading the raw pseudo-C: - `var_18` is the list property (`SetPropertyName(&var_18, 0x1b)`), `var_10` is the single color property (`SetPropertyName(&var_10, 0x19)`). - vtable slot `+0x9C` on the color property = "set value to this `RGBAColor*`"; the argument is a raw `.data` address. - vtable slot `+0xFC` on the list = **Append** (one arg); slot `+0xF8` = **SetValue(index, value)** (two args). This is how you tell the default-fill loop from the per-index overrides. - Loop trip count: `i_1 = 0x22; do { append; i = i_1; i_1--; } while (i != 1);` runs 34 times → indices `0x00`–`0x21`. The highest index the function ever writes is `0x21`. Independent confirmation of the 34-value space is the squelch enumerator at `0x00589DEB`: `for (uint i = 0; i < 0x22; i++)`. - 27 indices are written explicitly; 7 (`0x00, 0x01, 0x10, 0x14, 0x17, 0x18, 0x19`) keep the green default. - The function builds property `0x1B` only — the **main font-color list**. It does *not* build property `0x1D`, the *tag* font-color list that `UIElement_Text::SetFontColorHelper` also consults; that one comes from the LayoutDesc. ### 1.2 The color constants (verified against the binary) Read from `.data` (image base `0x400000`, `.data` VA `0x80A000` → raw `0x40A000`). Each is a 16-byte `RGBAColor` = four little-endian floats R, G, B, A. **Every alpha is 1.0.** | Address | Symbol | R | G | B | A | ≈8-bit | |---|---|---|---|---|---|---| | `0x0081C4A8` | `colorBrightRed` | 1.0 | 0.0 | 0.0 | 1.0 | `#FF0000` | | `0x0081C4B8` | `colorWhite` | 1.0 | 1.0 | 1.0 | 1.0 | `#FFFFFF` | | `0x0081C4C8` | *(unnamed)* **yellow** | 1.0 | 1.0 | 0.247 | 1.0 | `#FFFF3F` | | `0x0081C4D8` | *(unnamed)* **dark yellow** | 0.824 | 0.824 | 0.392 | 1.0 | `#D2D264` | | `0x0081C4E8` | `colorBrightPurple` | 1.0 | 0.498 | 1.0 | 1.0 | `#FF7FFF` | | `0x0081C4F8` | `colorDarkRed` | 1.0 | 0.247 | 0.247 | 1.0 | `#FF3F3F` | | `0x0081C508` | `colorLightRed` | 0.96 | 0.459 | 0.447 | 1.0 | `#F57572` | | `0x0081C518` | `colorLightBlue` | 0.247 | 0.749 | 1.0 | 1.0 | `#3FBFFF` | | `0x0081C528` | `colorPink` | 1.0 | 0.588 | 0.588 | 1.0 | `#FF9696` | | `0x0081C538` | `colorCyan` | 0.247 | 0.863 | 0.863 | 1.0 | `#3FDCDC` | | `0x0081C548` | `colorBlueGrey` | 0.706 | 0.863 | 0.941 | 1.0 | `#B4DCF0` | | `0x0081C558` | `colorGrey` | 0.824 | 0.824 | 0.784 | 1.0 | `#D2D2C8` | | `0x0081C568` | *(unnamed)* **orange** | 0.933 | 0.573 | 0.118 | 1.0 | `#EE921E` | | `0x0081C578` | `colorGreen` | 0.5 | 1.0 | 0.498 | 1.0 | `#80FF7F` | **`0x0081C4D8` is new** — the 2026-06-16 cdb session skipped it, and it is used by two table slots (`0x04` Speech_Direct_Send and `0x0B` Social_Send). Its exact bytes are `aa f1 52 3f aa f1 52 3f 39 b4 c8 3e 00 00 80 3f`; `0.8235294` and `0.39215687` are exactly 210/255 and 100/255, so retail authored it as RGB(210, 210, 100). Adjacent entries not used by the chat table: `0x0081C588` = white again, `0x0081C598` = black. Ship the **float** values, not the 8-bit approximations — several (`colorLightRed`, `orange`) are not integral /255 and were authored as decimal floats. **Provenance rule:** these 14 addresses appear in the whole 66 MB pseudo-C dump exactly 14 times, and every one of those occurrences is inside `BuildChatColorLookupTable`. Nothing else in the client reads or writes them. --- ## 2. The index enum — `LogTextType` `docs/research/named-retail/acclient.h` has an `enum eChatTypes` (line 4935) that stops at `eTextTypeTotalNumChannels = 0x19`. **That header enum is stale** — it predates the 2013 build and does not cover the table. The authoritative 2013 names come from `LogTextTypeEnumMapper::LogTextTypeToString @ 0x006AFF90`, which is a literal `switch` over `0x00`–`0x1F` emitting the canonical strings; anything `> 0x1F` (and `0x1A`–`0x1E`, which fall through) returns `"Unknown"`. The field name `ChatDisplayInfo::m_ltt` (acclient.h line 40739, written at `0x005CD89A`) is what pins the enum's name: **L**og**T**ext**T**ype. ### 2.1 The complete table | Idx | `LogTextType` name | Color symbol | RGBA (float) | Hex | Source | |---:|---|---|---|---|---| | `0x00` | `Default` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | | `0x01` | `All` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | | `0x02` | `Speech` | `colorWhite` | 1, 1, 1, 1 | `#FFFFFF` | explicit | | `0x03` | `Tell` | yellow `0x81C4C8` | 1, 1, 0.247, 1 | `#FFFF3F` | explicit | | `0x04` | `Speech_Direct_Send` | dark yellow `0x81C4D8` | 0.824, 0.824, 0.392, 1 | `#D2D264` | explicit | | `0x05` | `System` | `colorBrightPurple` | 1, 0.498, 1, 1 | `#FF7FFF` | explicit | | `0x06` | `Combat` | `colorDarkRed` | 1, 0.247, 0.247, 1 | `#FF3F3F` | explicit | | `0x07` | `Magic` | `colorLightBlue` | 0.247, 0.749, 1, 1 | `#3FBFFF` | explicit | | `0x08` | `Channel` | `colorPink` | 1, 0.588, 0.588, 1 | `#FF9696` | explicit | | `0x09` | `Channel_Send` | `colorPink` | 1, 0.588, 0.588, 1 | `#FF9696` | explicit | | `0x0A` | `Social` | yellow `0x81C4C8` | 1, 1, 0.247, 1 | `#FFFF3F` | explicit | | `0x0B` | `Social_Send` | dark yellow `0x81C4D8` | 0.824, 0.824, 0.392, 1 | `#D2D264` | explicit | | `0x0C` | `Emote` | `colorGrey` | 0.824, 0.824, 0.784, 1 | `#D2D2C8` | explicit | | `0x0D` | `Advancement` | `colorCyan` | 0.247, 0.863, 0.863, 1 | `#3FDCDC` | explicit | | `0x0E` | `Abuse` | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | | `0x0F` | `Help` | `colorDarkRed` | 1, 0.247, 0.247, 1 | `#FF3F3F` | explicit | | `0x10` | `Appraisal` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | | `0x11` | `Spellcasting` | `colorLightBlue` | 0.247, 0.749, 1, 1 | `#3FBFFF` | explicit | | `0x12` | `Allegiance` | orange `0x81C568` | 0.933, 0.573, 0.118, 1 | `#EE921E` | explicit | | `0x13` | `Fellowship` | yellow `0x81C4C8` | 1, 1, 0.247, 1 | `#FFFF3F` | explicit | | `0x14` | `World_Broadcast` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | | `0x15` | `Combat_Enemy` | `colorDarkRed` | 1, 0.247, 0.247, 1 | `#FF3F3F` | explicit | | `0x16` | `Combat_Self` | `colorLightRed` | 0.96, 0.459, 0.447, 1 | `#F57572` | explicit | | `0x17` | `Recall` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | | `0x18` | `Craft` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | | `0x19` | `Salvaging` | `colorGreen` | 0.5, 1, 0.498, 1 | `#80FF7F` | default fill | | `0x1A` | *(no string; "Unknown")* — **client-local text** | `colorBrightRed` | 1, 0, 0, 1 | `#FF0000` | explicit | | `0x1B` | *(no string)* — **Turbine General** | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | | `0x1C` | *(no string)* — **Turbine Trade** | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | | `0x1D` | *(no string)* — **Turbine LFG** | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | | `0x1E` | *(no string)* — **Turbine Roleplay** | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | | `0x1F` | `Admin_Tell` | yellow `0x81C4C8` | 1, 1, 0.247, 1 | `#FFFF3F` | explicit | | `0x20` | *(no string)* — **Turbine Society** (all 4 rooms) | `colorBlueGrey` | 0.706, 0.863, 0.941, 1 | `#B4DCF0` | explicit | | `0x21` | *(no string; no producer found)* | orange `0x81C568` | 0.933, 0.573, 0.118, 1 | `#EE921E` | explicit | ### 2.2 Notes on the unnamed slots - **`0x1A`** is not dead. `LogTextTypeToString` has no name for it, but it is the single most-used literal in the client: **client-local text that never reaches the wire** — `"You need an open vendor."`, the three `cant_jump_*` strings, most command-parser errors. It also has a special path in `AddTextToScroll` (§3.2): type `0x1A` skips the timestamp prefix **and** skips the chat log file. Color: bright red. - **`0x1B`–`0x1E`, `0x20`** are the Turbine (global community) chat rooms. Their identity is not guesswork — `ChatRoomTracker::GetChatFormat @ 0x005CD7C0` writes `ChatDisplayInfo::m_ltt` directly: | room field | `m_ltt` | |---|---| | `m_allegianceRoomID` | `0x12` (Allegiance) | | `mGeneralChatRoomID` | `0x1B` | | `mTradeChatRoomID` | `0x1C` | | `mLFGChatRoomID` | `0x1D` | | `mRoleplayChatRoomID` | `0x1E` | | `mOlthoiChatRoomID` | `0x12` (reuses Allegiance) | | `mSocietyChatRoomID`, `mSocietyCelHanChatRoomID`, `mSocietyEldWebChatRoomID`, `mSocietyRadBloChatRoomID` | `0x20` | This is why ACE's `ChatMessageType.cs` labels `0x1B`/`0x1E` "light cyan, unknown purpose" — they are the Turbine rooms, and `colorBlueGrey` is exactly that sky-blue. - **`0x21`** has a color slot and a filter bit but **no producer anywhere in the 2013 client**: `LogTextTypeToString` returns `"Unknown"`, `IsLegalChannel` returns 0, no `AddTextToScroll` / `m_ltt` site emits it. Treat it as reserved. Its orange matches `0x12` Allegiance, which hints at an allegiance-adjacent type that was never shipped (or was server-only). --- ## 3. Consumption trace — wire byte → pixel color ### 3.1 The chain ``` └─ ClientCommunicationSystem::Handle_Communication__* (type comes off the wire) └─ ClientSystem::AddTextToScroll(text, type, fireToPlugin, windowId) @0x00563C50 └─ ECM_UI::SendNotice_DisplayFinalStringInfo(type, body, prefix, windowId) @0x00692550 └─ ChatInterface::RecvNotice_DisplayFinalStringInfo(type, body, prefix, windowId) @0x004F4640 ├─ AppendStringInfoWithFont(m_chatLog, prefix, font=0, colorIdx=0x0C) ← timestamp, ALWAYS grey └─ AppendStringInfoWithFont(m_chatLog, body, font=0, colorIdx=type) └─ UIElement_Text::SetFontColorHelper(this, prop 0x1B, &m_curFontColor, type) @0x00466AC0 └─ list.GetValue(type) → m_curFontColor ``` `type` is never transformed. It is the wire value, used as the array index. ### 3.2 Two details worth porting **Timestamp prefix is hard-coded to index `0x0C`.** In `RecvNotice_DisplayFinalStringInfo @0x004F46E9` the second `StringInfo` (the `"%#H:%M:%S "` prefix built in `AddTextToScroll` when `PlayerModule::DisplayTimeStamps()` is on) is appended with color index `0x0C` — i.e. `colorGrey`, regardless of the message's own type. Retail's timestamps are always grey. **Out-of-range index leaves the color alone.** `SetFontColorHelper` reads the list's element count into `arg2`, then `if (arg4 < arg2) { GetValue(arg4) ... }`. If the index is `>= 34`, it falls through without touching `m_curFontColor`, so the run inherits the *previous* line's color. It does **not** fall back to a default. (The green default only applies to the seven in-range slots the builder never overwrites.) **Type `0x1A` bypasses the timestamp and the log file.** `AddTextToScroll` branches on `arg3 == 0x1a` at `0x00563DE6`; the non-`0x1A` path is the one that formats the timestamp and `fprintf`s to `ClientSystem::s_pLogFile`. ### 3.3 Wire → type for the message kinds you asked about Anchors are the `ClientCommunicationSystem::Handle_Communication__*` handlers. | Inbound kind | Opcode (ACE) | Handler | Type passed | |---|---|---|---| | Local speech | `0x02BB` | `Handle_Communication__HearSpeech @0x005712A0` | **`arg5` verbatim from the wire** (`AddTextToScroll(..., arg5, ...)` at `0x0057154D` for "X says", `0x00571325` for the "You say" self-echo) | | Ranged speech | `0x02BC` | `Handle_Communication__HearRangedSpeech @0x0056E550` → `gmCCommunicationSystem::HandleRangedTalkEvent @0x00589F60` | wire type (`arg6`) | | Tell | `0x02BD` | `Handle_Communication__HearDirectSpeech @0x005715A0` | **`arg6` verbatim** (`AddTextToScroll(..., arg6, ...)` at `0x005718D8` / `0x00571834` / `0x0057160C`) | | Emote / soul emote | `0x01E0` / `0x01E2` | `Handle_Communication__HearEmote @0x0057CBE0` (soul emote tail-calls it) | **hard-coded `0x0C`** at `0x0057CF94` | | Legacy channel speech | `0x0147` `ChannelBroadcast` | `Handle_Communication__ChannelBroadcast @0x00570B90` | derived from the channel bit — see below | | Turbine room speech | `0xF7DE` | `ChatRoomTracker::GetChatFormat @0x005CD7C0` → `m_ltt` | `0x12` / `0x1B`–`0x1E` / `0x20` — table in §2.2 | | Server message / system | `0xF7E0` | dispatched through `RecvNotice_DisplayStringInfo @0x0056E890` | **wire type verbatim** | | Client-local errors, command output | *(none)* | many sites | `0x1A` (or `0x00` for informational command output) | | Combat / magic / advancement / recall / craft / salvage / appraisal | `0xF7E0` and friends | server-chosen | wire type verbatim — `0x06`/`0x15`/`0x16`, `0x07`/`0x11`, `0x0D`, `0x17`, `0x18`, `0x19`, `0x10` | | Death messages | `0x019E` etc. | server-chosen | wire type verbatim (retail carries no special client-side death color) | `Handle_Communication__ChannelBroadcast` channel-bit → type (the `m_buffer_5` variable feeding `AddTextToScroll` at `0x00571169`): **Corrected 2026-08-09 (review):** the row 8 rows below marked "corrected" were wrong in the original CH1 drop. Binary Ninja renders retail's `neg esi; sbb esi, esi` idiom — a branchless select between `Channel` (0x08) and `Channel_Send` (0x09) — as the trivial pseudo-C expression `esi - esi` (always 0), which hid the real values. The correction comes from decoding the raw bytes at the PDB-paired binary: the HEAR branch's `sbb` site is at VA `0x00570F0A` (mask `-6` → `0x08` Channel) and the SEND branch's is at VA `0x00570D4F` (mask `-5` → `0x09` Channel_Send). | Channel bit | Prefix retail prints | Type | |---|---|---| | `0x0001` Abuse | `[]` | `0x0E` — retail's ONLY 0x0E producer (corrected 2026-08-09) | | `0x0400` Help | `[]` | `0x0F` | | `0x0800` Fellowship | `[Fellowship]` | `0x13` | | `0x1000` Patron / `0x2000` Vassal | `Your patron …` / `Your vassal …` | `0x0A` (hear) / `0x0B` (own send) — own-send precision added 2026-08-09 (corrected) | | `0x4000` Follower/Monarch | `Your follower …` | `0x0A` (hear) / `0x0B` (own send) | | `0x1000000` Co-Vassals | `[Co-Vassals]` | `0x0A` | | `0x2000000` Allegiance Broadcast | `[Allegiance Broadcast]` | `0x0A` | | `0x4000000` FellowBroadcast | — | `0x08` (hear) / `0x13` (own send) — corrected 2026-08-09, was wrongly `0x13` for both | | admin/audit/advocate/QA/sentinel/town catch-all | `[]` | `0x08` (hear) / `0x09` (own send) — corrected 2026-08-09, was wrongly `0x0E` / `0x0F` for all of them | Note the split that surprises people: **legacy allegiance-family chat arrives as `Social` (`0x0A`, yellow) / `Social_Send` (`0x0B`, dark yellow)**, while `Allegiance` (`0x12`, orange) is reserved for the *Turbine* allegiance room. ### 3.4 The plugin hook sees the same integer `AddTextToScroll` calls `IACPlugin::OnChatWindowText(bstr, type, &suppress)` at `0x00563CA4` **before** any formatting, and a plugin returning `suppress != 0` drops the line entirely. Relevant if acdream's plugin chat API wants retail parity: the plugin contract is `(text, LogTextType, out bool eaten)`. --- ## 4. Configurability — colors are NOT user-settable; filters are **Colors: hard-coded, no override path.** 1. The 14 `RGBAColor` addresses are referenced from exactly one function in the entire binary (§1.2). 2. `BuildChatColorLookupTable` runs unconditionally at `PostInit` and ends with `m_chatLog->SetProperty(list)`, which **replaces** property `0x1B` wholesale — so even a LayoutDesc that authored a color list would be overwritten. 3. `ChatInterface::RecvNotice_GameplayOptionChanged @0x004F30E0` and `ChatInterface::OnSetAttribute @0x004F3F60` handle only the window's text-type filter and the two opacity values. Neither touches color. Conclusion: **the table above is the shipped behavior, not a default.** There is nothing for acdream to make configurable for retail parity. **Filters: genuinely user-settable, per window.** `ChatInterface::UpdateFromPlayerModule @0x004F3920` reads `PlayerModule::InqChatWindowOption(windowId, 0x1000007F, …)` into a 64-bit `m_llTextTypeFilter`, and `RecvNotice_GameplayOptionChanged` live-updates it when the option changes. `ChatInterface::TypeIsActive @0x004F2F10` tests `(1ULL << type) & m_llTextTypeFilter`. Two consequences: - ~~`UpdateFromPlayerModule` early-returns when `m_eWindowID == 0`, so the main window has no user filter~~ **CORRECTED 2026-08-10 (CH6a/b review, `docs/research/2026-08-10-ch6ab-review-findings.md`):** the main chat window is `m_eWindowID == 8` and the floaties are 2–5; `m_eWindowID == 0` is the UNAUTHORED constructor default, which is what that early-return guards. The main window's filter IS user-settable — `gmChatOptionsUI::InitOptions @0x0049FC60` builds its filter block at `SetUserData` id 8 (default `0x00000000_FBFFFFFF` @0x0049FDC9, with a dedicated high-dword Society child @0x0049FEFB, so 0x20 Society is opt-in on main). - `RecvNotice_DisplayFinalStringInfo` displays when `windowId == m_eWindowID` (explicitly addressed — e.g. command output uses `m_idCurrentCommandSource`) **or** `windowId == 0 && TypeIsActive(type)` (broadcast). `PostInit` default filters (`0x004F3DF9` switch on `m_oldState`; Binary Ninja mis-attributes the low dword to `m_chatNewNonVisibleTextIndicator` — it is the low half of the 64-bit filter, and the following `m_llTextTypeFilter = 0` is the high half): | `m_oldState` | Low dword | Types | |---|---|---| | 1, 8 | `0xFBFFFFFF` | everything `0x00`–`0x1F` except `0x1A` | | 2 | `0x0000101C` | `0x02` Speech, `0x03` Tell, `0x04` Speech_Direct_Send, `0x0C` Emote | | 3 | `0x00040C00` | `0x0A` Social, `0x0B` Social_Send, `0x12` Allegiance | | 4 | `0x00080000` | `0x13` Fellowship | | 5 | `0x78000000` | `0x1B` General, `0x1C` Trade, `0x1D` LFG, `0x1E` Roleplay | Every default sets the **high** dword to 0, so `0x20` (Society) and `0x21` are in no window's default filter — Society chat only appears once the player enables it, which matches retail's opt-in Society channel. Squelching is a separate axis: `LogTextTypeEnumMapper::IsLegalChannel @ 0x006AFF40` whitelists exactly `0x02, 0x03, 0x06, 0x07, 0x0C, 0x10, 0x11, 0x12, 0x13, 0x15, 0x16, 0x17, 0x18, 0x19` as squelchable. --- ## 5. acdream correction list **Current code:** `src/AcDream.App/UI/Layout/ChatWindowController.cs:542` (`RetailChatColor(ChatKind)`), driven from line 462. The `ChatKind` enum is `src/AcDream.Core/Chat/ChatLog.cs:337`. (`ChatChannelKind` in `src/AcDream.UI.Abstractions/ChatChannelKind.cs` is the **outbound** channel selector and never reaches the color path — it needs no color change.) ### 5.1 Per-arm verdict | `ChatKind` | acdream RGBA now | Retail type it represents | Retail RGBA | Verdict | |---|---|---|---|---| | `LocalSpeech` | 1, 1, 1, 1 `colorWhite` | `0x02` Speech | 1, 1, 1, 1 | ✅ **CONFIRMED** | | `RangedSpeech` | 1, 1, 1, 1 `colorWhite` | wire type on `0x02BC`; ACE's own header documents `0x0C` for this opcode | 0.824, 0.824, 0.784, 1 `colorGrey` | ❌ **CHANGE** — must follow the wire type, not a constant | | `Channel` | 0.247, 0.749, 1, 1 `colorLightBlue` | Turbine rooms `0x1B`–`0x1E`/`0x20`; legacy `0x0A`/`0x0B`/`0x13`; Turbine allegiance `0x12` | Turbine rooms 0.706, 0.863, 0.941 `colorBlueGrey`; Social 1, 1, 0.247; Social_Send 0.824, 0.824, 0.392; Fellowship 1, 1, 0.247; Allegiance 0.933, 0.573, 0.118 | ❌ **CHANGE** — `colorLightBlue` is `0x07` Magic / `0x11` Spellcasting, never a channel | | `Tell` | 1, 0.498, 1, 1 `colorBrightPurple` | `0x03` Tell (incoming), `0x04` Speech_Direct_Send (own "You tell …") | incoming 1, 1, 0.247 `yellow`; own send 0.824, 0.824, 0.392 `dark yellow` | ❌ **CHANGE** — `colorBrightPurple` is `0x05` System | | `System` | 0.5, 1, 0.498, 1 `colorGreen` | wire type; ACE `0xF7E0` uses `0x00`, `0x03`, `0x04`, `0x05`, `0x06`, `0x07`, `0x0D`, `0x10`, `0x11`, `0x17`, `0x18` | `0x00` → 0.5, 1, 0.498 (green, matches today); `0x05` → 1, 0.498, 1 (purple) | ❌ **CHANGE** — green is right only for wire type `0x00`; the collapse to one color is the bug | | `Popup` | 0.5, 1, 0.498, 1 `colorGreen` | `0x0004 PopUpString` → `Handle_Communication__PopUpString @0x0057FE80` — a modal dialog, **not** a chat-log line in retail | n/a | ⚪ **OUT OF SCOPE** — no retail color; acdream's choice to render it in chat is an acdream divergence | | `Emote` | 0.824, 0.824, 0.784, 1 `colorGrey` | `0x0C` Emote (hard-coded by `HearEmote`) | 0.824, 0.824, 0.784, 1 | ✅ **CONFIRMED** | | `SoulEmote` | 0.824, 0.824, 0.784, 1 `colorGrey` | `0x0C` — `HearSoulEmote` tail-calls `HearEmote` at `0x0057D096` | 0.824, 0.824, 0.784, 1 | ✅ **CONFIRMED** | | `Combat` | 0.96, 0.459, 0.447, 1 `colorLightRed` | `0x06` Combat, `0x15` Combat_Enemy, `0x16` Combat_Self | `0x06`/`0x15` → 1, 0.247, 0.247 `colorDarkRed`; `0x16` → 0.96, 0.459, 0.447 `colorLightRed` | ❌ **CHANGE** — today's value is correct only for `Combat_Self` | | `_` fallback | 0.824, 0.824, 0.784, 1 `colorGrey` | unassigned in-range slots | 0.5, 1, 0.498, 1 `colorGreen` | ❌ **CHANGE** — retail's unset default is green; out-of-range indices keep the *previous* line's color | **Score: 3 confirmed, 6 changed, 1 out of scope.** ### 5.2 The structural correction Every "CHANGE" above has the same root cause: acdream colors by a **synthetic 9-value `ChatKind`**, while retail colors by the **34-value wire `LogTextType`**. Keeping `ChatKind` for routing/formatting is fine, but the color must key off the wire integer. The wire value is already parsed and already in hand — no new parsing needed: - `HearSpeech.Parsed.ChatType` (`src/AcDream.Core.Net/Messages/HearSpeech.cs:64`) — parsed today and **discarded** at the `OnLocalSpeech` call site (`src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:260`). - `ServerMessage.Parsed.ChatType` (`src/AcDream.Core.Net/Messages/ServerMessage.cs:33`) — already threaded through as `social.Chat.OnSystemMessage(message.Message, message.ChatType)` (`LiveSessionEventRouter.cs:268`), where `ChatLog.OnSystemMessage` parks it in `ChatEntry.ChannelId`. It is available; it just is not used for color. - `TurbineChat.ChatType` — available at `LiveSessionEventRouter.RouteTurbineChat` (line 484), already used for the display name via `TurbineChatDisplayNames.Resolve`. - `GameEvents` tell/emote payloads (`src/AcDream.Core.Net/Messages/GameEvents.cs:53`) carry `ChatType` too. The retail-faithful shape is a 34-entry `Vector4[]` built once (exactly as `BuildChatColorLookupTable` does), indexed by the wire type, with "index ≥ 34 → keep the previous run's color". Several call sites that currently pass a made-up type would need the real one: `ChatLog.OnSystemMessage(text, 0x1Au)` appears at five App composition sites and `chatType: 0u` at three `GameEventWiring` sites — those are placeholders, and `0x1A` happens to be the *correct* retail type for client-local text (bright red), so those five are already right by accident. ### 5.3 Coverage gap acdream has no representation at all for 22 of the 34 retail types: `All`(0x01), `Speech_Direct_Send`(0x04), `Channel`(0x08), `Channel_Send`(0x09), `Social`(0x0A), `Social_Send`(0x0B), `Advancement`(0x0D), `Abuse`(0x0E), `Help`(0x0F), `Appraisal`(0x10), `Spellcasting`(0x11), `Allegiance`(0x12), `Fellowship`(0x13), `World_Broadcast`(0x14), `Combat_Enemy`(0x15), `Combat_Self`(0x16), `Recall`(0x17), `Craft`(0x18), `Salvaging`(0x19), `Admin_Tell`(0x1F), Turbine `0x1B`–`0x1E`/`0x20`, and reserved `0x21`. A table keyed by the wire integer closes all of them at once. ### 5.4 Two documentation defects found in passing 1. **`src/AcDream.Core.Net/Messages/HearSpeech.cs:39-48`** — the `ChatType` legend in the XML doc comment is wrong on 4 of 6 entries. It claims `0x02 = Combat`, `0x0B = Speech`, `0x0F = Emote`, `0x10 = Tell`. Retail: `0x02 = Speech`, `0x0B = Social_Send`, `0x0F = Help`, `0x10 = Appraisal`, `0x03 = Tell`, `0x0C = Emote`. Only `0x01` (Broadcast/AllChannels — retail calls it `All`) and `0x11` (Spellcasting, the comment's "Syllables") are close. 2. **`ChatWindowController.cs:536-541`** — the doc comment asserts "the four common kinds (speech/tell/channel/system) are confirmed by the named symbols". Speech is confirmed; tell, channel and system are all wrong. That sentence should be deleted along with the fix. --- ## 6. Cross-check against ACE `references/ACE/Source/ACE.Entity/Enum/ChatMessageType.cs` is the same index space (its `LogTextTypeEnumMapper:` doc lines are literally the strings from `LogTextTypeToString`), and its informal color notes independently corroborate the decompiled table on every entry it comments: | ACE note | Retail table | Agrees? | |---|---|---| | `0x08` Channel "Light Pink Text" | `colorPink` | ✅ | | `0x0A` Social "Bright Yellow Text" | yellow `#FFFF3F` | ✅ | | `0x0B` Social_Send "Light Yellow Text" | dark yellow `#D2D264` | ✅ | | `0x0E` Abuse "Light Cyan (skyblue?)" | `colorBlueGrey` | ✅ | | `0x0F` Help "Red Text" | `colorDarkRed` | ✅ | | `0x13` Fellowship "Bright Yellow Text" | yellow | ✅ | | `0x14` WorldBroadcast "Green Text" | `colorGreen` (default fill) | ✅ | | `0x15` CombatEnemy "Red Text" | `colorDarkRed` | ✅ | | `0x16` CombatSelf "Pink Text" | `colorLightRed` (salmon) | ✅ | | `0x19` Salvaging "Green Text" | `colorGreen` (default fill) | ✅ | | `0x1B`, `0x1E` "Light cyan (sky blue)" | `colorBlueGrey` | ✅ | | `0x1F` AdminTell "Bright Yellow Text" | yellow | ✅ | ACE stops at `0x1F` and comments `0x1A` out as "client doesn't display it" — both are ACE gaps, not retail behavior: retail has 34 slots and `0x1A` is its busiest client-local type. --- ## 7. Reproduction commands ```bash # The builder grep -n "BuildChatColorLookupTable" docs/research/named-retail/acclient_2013_pseudo_c.txt sed -n '246117,246440p' docs/research/named-retail/acclient_2013_pseudo_c.txt # The 2013 type names sed -n '695462,695790p' docs/research/named-retail/acclient_2013_pseudo_c.txt # LogTextTypeToString sed -n '695397,695425p' docs/research/named-retail/acclient_2013_pseudo_c.txt # IsLegalChannel # Turbine room -> m_ltt sed -n '479725,479870p' docs/research/named-retail/acclient_2013_pseudo_c.txt # Consumption sed -n '247282,247330p' docs/research/named-retail/acclient_2013_pseudo_c.txt # RecvNotice_DisplayFinalStringInfo sed -n '113699,113760p' docs/research/named-retail/acclient_2013_pseudo_c.txt # SetFontColorHelper sed -n '368347,368520p' docs/research/named-retail/acclient_2013_pseudo_c.txt # AddTextToScroll ``` The RGBA quads were read straight out of the PDB-paired binary by walking the PE section table (`.data` VA `0x80A000` → raw `0x40A000`) and unpacking `<4f` at each 16-byte stride from `0x0081C4A8`. Re-run `py tools/pdb-extract/check_exe_pdb.py "C:/Users/erikn/Downloads/acclient.exe"` first — it must report `MATCH` before any address in this document is valid.