acdream/docs/research/2026-08-09-chat-retail-color-table.md
Erik 172c6f9aa3 feat(chat): Campaign CH slice CH1 — retail LogTextType color table
Retail colors chat lines by the 34-value wire LogTextType (ACE's
ChatMessageType), NOT by acdream's synthetic 9-value ChatKind. The old
ChatWindowController.RetailChatColor(ChatKind) collapsed distinct retail
colors onto one bucket per ChatKind — e.g. every Channel line rendered
colorLightBlue (Magic's slot) when retail's actual palette spans five
different colors across the Turbine rooms and legacy allegiance family.

Ports ChatInterface::BuildChatColorLookupTable @0x004F31C0 verbatim
(RetailChatColorTable, all 34 RGBA floats read from the PDB-paired
binary's .data section) and threads a new ChatEntry.LogTextType field
through every ingestion site to the correct retail wire value:
HearSpeech/Tell pass the wire chatType through verbatim; Emote/SoulEmote
hard-code 0x0C; the Tell self-echo hard-codes 0x04; legacy ChatChannel
broadcasts derive their type from the channel bit via the new
LegacyChannelChatType helper (ported from the decompiled
Handle_Communication__ChannelBroadcast dispatch, hear vs. own-send);
TurbineChat rooms map through TurbineChatDisplayNames.LogTextType;
CombatChatTranslator's hit/miss/evade lines map to ACE's CombatSelf/
CombatEnemy per Player_Combat.cs; kill/death lines use retail's
decompiled 0x00 Default (not a combat color). ChatWindowController's
transcript now folds LogTextType through RetailChatColorTable with
retail's exact "out-of-range keeps the previous line's color" carry
rule; ChatPanel's combat highlighting sources the same table.

Corrects HearSpeech.cs's doc-comment ChatType legend (4 of 6 entries
were wrong). Adds register row AP-175 for the pre-existing (unchanged)
Popup-renders-in-chat divergence and updates AP-39's stale per-ChatKind
description. Narrows ISSUES #139 — its chat-colors half is done.

Retail renders no chat timestamp prefix path exists in acdream today,
so the "timestamp is always colorGrey 0x0C" rule has nothing to attach
to; noted here per the research doc rather than left silent.

Research: docs/research/2026-08-09-chat-retail-color-table.md
Full Release suite: 11,833 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:24:09 +02:00

28 KiB
Raw Blame History

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 0x000x21.
  • 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 0x000x21. 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 0x000x1F emitting the canonical strings; anything > 0x1F (and 0x1A0x1E, 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: LogTextType.

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.

  • 0x1B0x1E, 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

<inbound game message>
   └─ 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 fprintfs 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 @0x0056E550gmCCommunicationSystem::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 @0x005CD7C0m_ltt 0x12 / 0x1B0x1E / 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):

Channel bit Prefix retail prints Type
0x0800 Fellowship [Fellowship] 0x13
0x1000 Patron / 0x2000 Vassal Your patron … / Your vassal … 0x0A
0x4000 Follower/Monarch Your follower … 0x0A (hear) / 0x0B (own send)
0x1000000 Co-Vassals [Co-Vassals] 0x0A
0x2000000 Allegiance Broadcast [Allegiance Broadcast] 0x0A
0x4000000 0x13
admin/audit/sentinel channels [<name>] 0x0E / 0x0F

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 — it keeps the PostInit default.
  • 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 0x000x1F 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 0x1B0x1E/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 CHANGEcolorLightBlue 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 CHANGEcolorBrightPurple 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 PopUpStringHandle_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 0x0CHearSoulEmote 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 0x1B0x1E/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

# 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.