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>
28 KiB
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
LogTextTypevalue — the same integer that arrives on the wire as ACE'sChatMessageType. 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_18is the list property (SetPropertyName(&var_18, 0x1b)),var_10is the single color property (SetPropertyName(&var_10, 0x19)).- vtable slot
+0x9Con the color property = "set value to thisRGBAColor*"; the argument is a raw.dataaddress. - vtable slot
+0xFCon 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 → indices0x00–0x21. The highest index the function ever writes is0x21. Independent confirmation of the 34-value space is the squelch enumerator at0x00589DEB: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
0x1Bonly — the main font-color list. It does not build property0x1D, the tag font-color list thatUIElement_Text::SetFontColorHelperalso 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: 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
-
0x1Ais not dead.LogTextTypeToStringhas 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 threecant_jump_*strings, most command-parser errors. It also has a special path inAddTextToScroll(§3.2): type0x1Askips the timestamp prefix and skips the chat log file. Color: bright red. -
0x1B–0x1E,0x20are the Turbine (global community) chat rooms. Their identity is not guesswork —ChatRoomTracker::GetChatFormat @ 0x005CD7C0writesChatDisplayInfo::m_lttdirectly:room field m_lttm_allegianceRoomID0x12(Allegiance)mGeneralChatRoomID0x1BmTradeChatRoomID0x1CmLFGChatRoomID0x1DmRoleplayChatRoomID0x1EmOlthoiChatRoomID0x12(reuses Allegiance)mSocietyChatRoomID,mSocietyCelHanChatRoomID,mSocietyEldWebChatRoomID,mSocietyRadBloChatRoomID0x20This is why ACE's
ChatMessageType.cslabels0x1B/0x1E"light cyan, unknown purpose" — they are the Turbine rooms, andcolorBlueGreyis exactly that sky-blue. -
0x21has a color slot and a filter bit but no producer anywhere in the 2013 client:LogTextTypeToStringreturns"Unknown",IsLegalChannelreturns 0, noAddTextToScroll/m_lttsite emits it. Treat it as reserved. Its orange matches0x12Allegiance, 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 @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):
| 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.
- The 14
RGBAColoraddresses are referenced from exactly one function in the entire binary (§1.2). BuildChatColorLookupTableruns unconditionally atPostInitand ends withm_chatLog->SetProperty(list), which replaces property0x1Bwholesale — so even a LayoutDesc that authored a color list would be overwritten.ChatInterface::RecvNotice_GameplayOptionChanged @0x004F30E0andChatInterface::OnSetAttribute @0x004F3F60handle 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:
UpdateFromPlayerModuleearly-returns whenm_eWindowID == 0, so the main window has no user filter — it keeps thePostInitdefault.RecvNotice_DisplayFinalStringInfodisplays whenwindowId == m_eWindowID(explicitly addressed — e.g. command output usesm_idCurrentCommandSource) orwindowId == 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 theOnLocalSpeechcall site (src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:260).ServerMessage.Parsed.ChatType(src/AcDream.Core.Net/Messages/ServerMessage.cs:33) — already threaded through associal.Chat.OnSystemMessage(message.Message, message.ChatType)(LiveSessionEventRouter.cs:268), whereChatLog.OnSystemMessageparks it inChatEntry.ChannelId. It is available; it just is not used for color.TurbineChat.ChatType— available atLiveSessionEventRouter.RouteTurbineChat(line 484), already used for the display name viaTurbineChatDisplayNames.Resolve.GameEventstell/emote payloads (src/AcDream.Core.Net/Messages/GameEvents.cs:53) carryChatTypetoo.
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
src/AcDream.Core.Net/Messages/HearSpeech.cs:39-48— theChatTypelegend in the XML doc comment is wrong on 4 of 6 entries. It claims0x02 = Combat,0x0B = Speech,0x0F = Emote,0x10 = Tell. Retail:0x02 = Speech,0x0B = Social_Send,0x0F = Help,0x10 = Appraisal,0x03 = Tell,0x0C = Emote. Only0x01(Broadcast/AllChannels — retail calls itAll) and0x11(Spellcasting, the comment's "Syllables") are close.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.