Six parallel research lanes on retail's chat text and window behaviour, plus a plan. The headline: the green clickable speaker name is not a chat feature and not a colour, it is a missing capability in the TEXT stack. Retail's client sprintfs literal tag markup into the chat line, and the text element parses the brackets while appending, attaching a ref-counted tag PER GLYPH. A tagged run is emergent: adjacent glyphs whose tag pointers are equal. A glyph takes the tag colour (property 0x1D) only when a tag is open and its type is 0x10000001; otherwise the ordinary line colour (0x1B). The colour itself was the one thing the decomp could not settle — it is authored, not runtime-built — so it was MEASURED out of the installed dats rather than assumed from a screenshot: P0x1D = RGB(0,178,0). That also exposed a trap: the tag colour is per-ELEMENT and authored while the line colour on the same element comes from the runtime chat table, so filing "tag green" into the LogTextType table would put it in the wrong place. Our own audit found the gap is narrower than feared. UiText ALREADY draws multi-coloured runs (the character stat panel uses it); the path is just gated to single-line elements. The draw path needs no renderer work, and HitChar already resolves a click to line+column. The real blocker is that sender identity is destroyed before it reaches the renderer: ChatEntry carries Sender/SenderGuid the whole way, and ChatVM.RecentLinesDetailed drops both. Two findings beyond the original question. Retail BOUNDS its transcript (10,000 chars, trimmed to ~7,500 at a newline) and splits auto-scroll from an unread indicator by sampling "was at bottom" before the line lands — a naive port auto-scrolls forever and leaks for the life of a session. And the chat-UI audit turned up an untracked bug: Escape in the chat input does nothing at all, because UiField has no Escape case and a focused field also suppresses the input dispatcher's fallback. Every lane was instructed to write "UNKNOWN — needs X" rather than guess, and they did; the carried unknowns are listed in the plan rather than papered over. Seven slices proposed, nothing implemented yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
33 KiB
Retail chat WINDOW shell — window model, filters, scrollback, chrome
Date: 2026-08-21 Status: RESEARCH ONLY. No source files touched. Scope: retail's chat window SHELL and DISPLAY behavior — window management, filtering, scrollback, chrome/interaction, multi-window, line-composition structure, and other user-visible window mechanics. Explicitly out of scope (covered by sibling research this session): glyph text-tag coloring, clickable/colored names, tag click dispatch, and acdream's own current UI code. This document does not re-derive anything already answered there.
Primary sources
docs/research/named-retail/acclient_2013_pseudo_c.txt(Sept 2013 EoR build, Binary Ninja pseudo-C, PDB-named)docs/research/named-retail/acclient.h(verbatim retail struct/enum defs)docs/research/named-retail/symbols.json
Notes read first so this extends rather than repeats:
docs/research/2026-08-09-chat-retail-window-shell.md(CH6 shell research — window lifecycle/identity, LayoutDesc geometry, resize model, opacity, persistence, multi-window/floaty mechanics). This document is the authority for §1 window-lifecycle mechanics, §4 chrome/resize/opacity, and §5 tabs/multi-window — I only summarize its findings below with pointers, and add what it doesn't cover: scrollback/truncation, the exact window-ID routing predicate as a single decompiled function, structural line-composition order, and the unseen-text/auto-scroll interaction.docs/research/2026-08-09-chat-retail-color-table.md§4 (filter storage,m_llTextTypeFilter,PostInitseeded defaults) — I summarize and do not re-derive; I use its findings to cross-check the routing function decoded fresh below.docs/plans/2026-08-09-chat-parity-campaign.md— Campaign CH plan/ledger.
Binary-Ninja caveats (apply throughout, per
claude-memory/feedback_bn_decomp_field_names.md): BN's struct-field
attribution in ChatInterface::PostInit/gmMainChatUI::PostInit is shifted
by one slot relative to the true member order — the window-shell doc already
documented this for the main window's border elements. I hit the same
artifact in ChatInterface::PostInit's GetChildRecursive binding sequence
(§1) and resolve it the same way: against the verbatim struct order in
acclient.h:54898-54912, which is authoritative and does not shift.
1. Window model
1.1 How many windows, and how they're identified
Confirmed against acclient.h:54898-54912 (verbatim ChatInterface
struct):
/* 6041 */
struct __cppobj ChatInterface : gmNoticeHandler, UIElement_Field
{
unsigned int m_eWindowID;
float m_fDefaultOpacity;
float m_fActiveOpacity;
float m_fCurrentOpacity;
UIElement_Text *m_chatEntry;
UIElement_Text *m_chatLog;
UIElement *m_chatNewNonVisibleTextIndicator;
unsigned __int64 m_llTextTypeFilter;
UIElement_Text *m_pChatTargetButtonText;
PStringBaseArray<unsigned short> m_InputHistory;
unsigned int m_LastInputHistoryPos;
ClientCommunicationSystem *m_pCCS;
};
Per the window-shell doc §1.2/§4.1 (not re-derived here): five live
chat windows exist — the main window (m_eWindowID == 8) and four floating
windows (m_eWindowID == 2..5). m_eWindowID == 0 is the UNAUTHORED
constructor default (ChatInterface::ChatInterface @0x004F4550 sets
this->m_eWindowID = 0; before PostInit reads the real value off the
LayoutDesc attribute 0x1000007E). All five windows are authored,
always-resident children of the gameplay-UI root — there is no
runtime-allocated window registry (window-shell doc §1.1).
The SpewBox (gmSpewBoxUI) is a separate, unrelated class — not a
ChatInterface subclass, not part of this window-id space (per
claude-memory/project_chat_digest.md).
1.2 The wire-to-window routing predicate — one function, load-bearing
ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640 is the single
function every displayed chat line passes through. Its head (the routing
decision, before any text is appended) is:
004f4640 void __thiscall ChatInterface::RecvNotice_DisplayFinalStringInfo(
class ChatInterface* this, uint32_t arg2 /*type*/,
class StringInfo const* arg3 /*body*/,
class StringInfo const* arg4 /*prefix*/, uint32_t arg5 /*windowId*/)
004f4640 {
004f4640 uint32_t eax_7 = arg5;
004f4652 if (eax_7 == this->m_eWindowID)
004f4652 {
004f467c label_4f467c:
… (appends — see §3/§6) …
004f4652 }
004f4652 else if ((eax_7 == 0 && ChatInterface::TypeIsActive(this, arg2) != 0))
004f4666 goto label_4f467c;
004f4640 }
The predicate is exactly: windowId == m_eWindowID OR (windowId == 0
AND TypeIsActive(type)). This is an ADDRESS-vs-BROADCAST model, not a
"windows subscribe to a channel" model:
- A line sent with a specific windowId (matching an already-open target
window, e.g. a command whose output is explicitly directed at the window
that issued it —
m_idCurrentCommandSourceper the color-table doc §4) is shown only in that one window, unconditionally — the destination window's own filter is never consulted for an address-targeted line. - A line sent with windowId == 0 ("broadcast") is shown in every
window whose own
TypeIsActive(type)(i.e. its 64-bitm_llTextTypeFilter, decoded in the color-table doc §4) says yes. This is how the same "Sio says, ..." line can land in the main window and in a floating window simultaneously if both haveSpeechenabled. - Window id 0 is therefore never itself a window — it is the broadcast
sentinel value on the wire/call parameter, exactly as the window-shell
doc's goal-window addendum states. No live
ChatInterfaceinstance ever keepsm_eWindowID == 0afterPostInitruns.
ChatInterface::TypeIsActive @0x004F2F10 (cited, not re-derived, per the
color-table doc §4) is (1ULL << type) & m_llTextTypeFilter.
2. Filters
Fully decoded already in 2026-08-09-chat-retail-color-table.md §4 — not
re-derived here. Summary for completeness of this document's structure:
- Storage: 64-bit
ChatInterface::m_llTextTypeFilter(acclient.h:54907), read fromPlayerModule::InqChatWindowOption(windowId, 0x1000007F, …)(ChatInterface::UpdateFromPlayerModule @0x004F3920) and live-updated viaRecvNotice_GameplayOptionChanged @0x004F30E0. - Test:
ChatInterface::TypeIsActive @0x004F2F10—(1ULL << type) & m_llTextTypeFilter, used both for the broadcast-routing predicate (§1.2) and, per the color-table doc, nowhere else. PostInit's per-window seeded default switches onm_oldState(ChatInterface::PostInit @0x004F3DD0,0x004f3df9): 1 and 8 (the main window) get0xFBFFFFFFlow-dword (everything except client-local0x1A); 2 (floaty 1) gets Speech/Tell/Speech_Direct_Send/Emote; 3 (floaty 2) gets Social/Social_Send/Allegiance; 4 (floaty 3) gets Fellowship; 5 (floaty 4) gets the four Turbine rooms General/Trade/LFG/Roleplay. Every default's HIGH dword is 0 — Society (0x20) and the reserved0x21slot start disabled in every window and must be opted into by the user.- User edit path:
gmChatOptionsUI::InitOptions @0x0049FC60/AddCheckboxBitfield64Option @0x0049EDA0build one checkbox-gridSetUserDatablock per window id (main = id 8, with its own dedicated Society checkbox child at0x0049FEFB). - Squelching is a separate axis from filtering:
LogTextTypeEnumMapper::IsLegalChannel @0x006AFF40whitelists a 14-value subset ofLogTextTypeas squelchable at all; it has no interaction withm_llTextTypeFilter.
Nothing new to add here beyond what the color-table doc already covers —
the routing predicate decoded fresh in §1.2 above is a second, independent
confirmation of the same "windowId==0 → filter-gated broadcast" model that
doc's §4 described from RecvNotice_DisplayFinalStringInfo's citation
alone; this document supplies the full decompiled function body.
3. Scrollback
3.1 The cap, the trigger, and the trim target
Still inside RecvNotice_DisplayFinalStringInfo @0x004F4640, immediately
after the body append (full excerpt with the append order in §6):
004f4701 int32_t m_chatLog_1 = this->m_chatLog;
004f4711 if (*(uint32_t*)(m_chatLog_1 + 0x61c) > 0x2710)
004f4711 {
004f4713 int32_t var_14_4 = 0x1d4c;
004f471a m_chatLog_1 = ChatInterface::TruncateChatLog(this, m_chatLog_1);
004f4711 }
0x2710 = 10,000, 0x1d4c = 7,500. The field read at transcript
offset +0x61C tracks the transcript's total character count (not a
line count) — retail's scrollback limit is a character budget, not a
fixed number of retained lines. Trigger: transcript exceeds 10,000
characters. Target: trim back down toward ~7,500. This runs on every
appended line once the log is over budget — it is not a periodic/timed
sweep, it is inline in the same call that just displayed the line.
3.2 The truncation rule — trims at a newline boundary, not mid-line
ChatInterface::TruncateChatLog @0x004F4290 (arg2 = target length, 7500 at
the only call site found):
004f4290 void __fastcall ChatInterface::TruncateChatLog(class ChatInterface* this, uint32_t arg2)
004f4290 {
text = GetText(m_chatLog); // live PStringBase
currentLen = text.length; // *(len_ptr - 4)
004f42c2 if (currentLen <= arg2)
return; // under budget — no-op
004f42c2 else
{
004f42c8 excess = currentLen - arg2; // chars over target
… PStringBaseIter_Common<unsigned short>::FindChar(iter, "\n", 1) …
// search FORWARD from the excess offset for the next '\n'
004f4350 if (found && (excess - foundPos) < (currentLen / 10))
004f4360 BeheadText(m_chatLog, foundPos + 1, 1); // cut at that newline
else {
… FindChar(iter, "\n", 0) … // search again, other direction arg
004f43f0 if (found2 && (foundPos2 - excess) < (currentLen / 10))
goto (the same BeheadText-at-newline path)
004f4403 else
BeheadText(m_chatLog, excess, 1); // fallback: cut at the raw excess offset
}
004f4290 }
Reading this at the BN pseudo-C level is genuinely uncertain past the
overall shape — flagging per the assignment's constraint rather than
guessing: the 0xCCCCCCCD multiply + HIGHD(...) >> 3 pair is the
standard MSVC constant-division-by-10 idiom (length / 10), and the two
FindChar calls with a PStringBase(&data_79c288) needle (confirmed below,
§3.3, to be a single \n character) plus UIElement_Text::BeheadText are
unambiguous. UNKNOWN — needs a live cdb capture with real transcript
content to nail down exactly: whether the two FindChar calls search in
opposite directions from the excess offset (my reading above) or whether
one is a fallback re-search after the first's 10%-tolerance check fails for
a different reason; the two arg3 values passed to FindChar (1 then
0) are almost certainly a direction or "case-sensitive/whole-word" flag,
but the pseudo-C never names the parameter. What is certain and
sufficient to port: truncation removes text from the FRONT of the
transcript (BeheadText), it PREFERS a boundary within the char that
begins the next \n-terminated line rather than a raw char-offset cut
(there's a ~10%-of-current-length tolerance band around the target for
preferring the newline-aligned cut), and it falls back to an exact
char-offset behead only if no acceptable newline is found nearby.
3.3 The separator character — confirms \n, not \r\n
0079c280 data_79c280: 0d 00 0a 00 00 00 00 00 // L"\r\n" — used elsewhere, NOT here
0079c288 data_79c288: 0a 00 00 00 00 00 00 00 // L"\n" — the separator + the TruncateChatLog needle
data_79c288 is passed both as the inter-line separator string appended in
RecvNotice_DisplayFinalStringInfo (§6) and as the FindChar needle in
TruncateChatLog above — confirming truncation genuinely searches for line
breaks, i.e. it is line-boundary-aware even though the budget itself is
counted in characters.
3.4 Auto-scroll / "stick to bottom" — IsAtVerticalEnd + ScrollToPosition
UIElement_Text::IsAtVerticalEnd @0x00469350:
00469350 uint8_t __fastcall UIElement_Text::IsAtVerticalEnd(class UIElement_Text* this)
00469350 {
lineCount = this->m_glyphList.m_glyphList._num_elements;
00469359 if (lineCount == 0)
return 1; // empty log counts as "at end"
00469360 lastLineIndex = lineCount - 1;
00469369 return UIElement_Text::IsPositionInView(this, &lastLineIndex);
00469350 }
This is not a scroll-offset comparison — it is "is the last line
currently visible inside the viewport right now." IsPositionInView is
the same hit-test the widget uses for click-to-position, applied to the
transcript's own final line.
RecvNotice_DisplayFinalStringInfo captures this before appending the
new line, then decides what to do with it after appending and
truncating:
004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // BEFORE the new line lands
… append prefix, append body, truncate if over budget (§6, §3.1) …
004f4723 if (ebx != 0)
004f4723 {
004f4732 UIElement_Text::ScrollToPosition(m_chatLog, currentLineCount); // re-stick to the new bottom
004f4739 return;
004f4723 }
004f4723 else
004f473c this->m_chatNewNonVisibleTextIndicator->vtable->SetState(1); // flag "unseen text" instead
So: if the user was already looking at the bottom of the log, retail
scrolls the new line into view (sticky-bottom). If the user had scrolled up
into history, retail does NOT move their scroll position at all — it
instead lights the "new unseen text" indicator. There is no separate
manual "scroll lock" toggle; this automatic per-line check IS retail's
scroll-lock mechanism. m_chatNewNonVisibleTextIndicator is a real
UIElement* field (acclient.h:54906), bound in PostInit from element
id 0x1000048C — the 16×16 button the window-shell doc's layout dump
already placed at (21,62) in the main window and (5,169) in the floaties,
labeled there "new-unseen-text indicator (Button)" from the authored rect
alone; this document supplies the code that drives it.
3.5 Clearing the unseen-text flag
ChatInterface::ListenToElementMessage @0x004F51C0, click-message case,
idElement == 0x1000048c:
004f51f1 if (idElement == 0x1000048c)
004f51f1 {
if (m_chatEntry_or_chatLog != 0) // see field-shift caveat below
004f5208 UIElement_Text::ScrollToPosition(transcript, transcript->lineCount);
004f520d indicator->vtable->SetState(0xd);
}
Field-attribution caveat: this function's local variable is BN-named
m_chatEntry at the point it calls ScrollToPosition, but the object it
scrolls is described by _num_elements of its own m_glyphList — the
transcript's own line count, not the chat-entry input field's. Combined
with the ctor/struct order (§1.1) and the same-class shift already
documented in the window-shell doc for PostInit, the operation this
really performs is: clicking the unseen-text indicator scrolls the
transcript to its own bottom and resets the indicator's own visual state
(SetState(0xd), a different state than the "flagged" SetState(1) set
when new text arrives while scrolled up) — i.e. clicking it is the user's
manual "catch up" action, and it un-flags itself. Not independently
re-verified via cdb; treat the exact numeric visual STATE values (1 vs
0xd) as confirmed, but the specific field bound to "which object gets
scrolled to bottom" as inferred from the semantics of IsAtVerticalEnd
elsewhere, not a literal read of this function's own variable names.
4. Window chrome & interaction
Fully covered by the window-shell doc §1 and §2–§4 — not re-derived here. Summary pointers:
- Move/resize: eight authored
UIElement_Resizebar(type 9) grips per window with per-grip bool properties0x2A/0x2B/0x2C/0x2D(bottom/left/right/top); the main window's plain top edge strip is aUIElement_Dragbar(type 2, move-only) rather than a ninth resize grip — window-shell doc §2.1/§2.3,UIElement_Resizebar::StartMouseResizing @0x0046B7E0. - Docking/anchoring: none found — windows are free-floating, clamped
to stay on-screen only at restore time (
gmFloatyMainChatUI::MoveTo @0x004D2D10:004d2d53-004d2dbb). - Opacity: two GLOBAL floats (
Option_DefaultOpacity_Property 0x10000080unfocused,Option_ActiveOpacity_Property 0x10000081focused), applied to the WHOLE composited window surface including text via oneSetOpacitycall — window-shell doc §3,ChatInterface::SetOpacity @0x004F3120. Per-class constructed starting values differ (main window 1.0/1.0 always-opaque, floaties 0.5/1.0) until a saved option overrides them. Retail eases toward the target at 5%-of-delta per tick (ChatInterface::ListenToGlobalMessage @0x004F3840); acdream currently snaps (AP-190, window-shell doc §3.1). - Show/hide: authored elements toggled via
SetVisible, driven by either a keybind (Alt+1..4for the floaties) or a generic registered-action click dispatch — window-shell doc §1.3/§1.4. - Persistence: two independent paths — the per-window
GameplayOptionsblob (position/size/visible/title, gated onm_eWindowID != 0, i.e. the main window's geometry is NEVER saved this way) and a separate local screen-layout text file that IS the only path persisting the main window's geometry — window-shell doc §4.
One piece of chrome not covered by the window-shell doc — the talk-focus menu (main window only):
gmMainChatUI::InitTalkFocusMenu @0x004CDC50 builds a dropdown menu (from
the button/group pair at elements 0x10000014/0x10000015, window-shell
doc §2.1) with a squelch-toggle entry plus 13 target items, each carrying
an Enum attribute 0x1000000B set to a distinct small integer (1
through 0xD) that records which "talk focus" (broadcast target category)
that menu row represents:
004cdcd3 this->m_pSquelchToggleButton = UIElement_Menu::AddTextItem(eax_1, &var_90);
… (13x) …
004cdcfb UIElement::SetAttribute_Enum(eax_3, 0x1000000b, 5);
004cdd07 SmartArray<UIElement_Text *,1>::push_back(&this->m_aTalkFocusButtons, &var_94);
gmMainChatUI::EnableSelection @0x004CE0A0 toggles individual rows'
enabled/greyed state (SetState(0xd) when Olthoi-locked); a companion
RecvNotice_SelectionChanged @0x004CE050 re-syncs the menu's currently
highlighted target whenever the player's WORLD selection changes (via
ACCWeenieObject::selectedID and PublicWeenieDesc::IsTalkable) — this is
a world-object selection feed (F1-click on an NPC), not a transcript
text-tag click, and is out of this document's lane beyond noting that the
main window's talk-focus button exists and is driven from it. Only the
main window has this menu; floaty windows (window-shell doc §2.2) have
neither a talk-focus menu nor a max/min button, only a title bar and close
button.
5. Tabs / multiple windows
Fully covered by the window-shell doc §1.1–§1.4, §2, §4.1 — not re-derived here. Summary:
- There is no "tab" widget. The five windows (§1.1) are five separate, independently positioned/sized/opaque floating panels, not tabs of one container.
- Creation: none — all five exist from gameplay-UI construction; users
cannot create additional windows. Naming: each floaty window has an
editable title (
gmFloatyChatUI::SetWindowTitle @0x004CEAA0, persisted option0x1000008D) but the SET of windows is fixed at five; there is no "new chat tab" affordance analogous to modern MMO UIs. Closing: floaty windows close via their own title-bar close button (gmFloatyChatUI::ListenToElementMessage @0x004CE330, element0x1000052A) or theAlt+Ntoggle; the main window cannot be closed at all (no close button is authored on it — window-shell doc §2.1's element table has none). Switching: there is no focus-cycling shortcut found; each window is an independent, simultaneously-visible panel, and "switching" only means moving keyboard focus into a different window's entry field by clicking it (which is what drives the opacity fade, §4/window-shell doc §3). - Per-window state:
m_eWindowID,m_llTextTypeFilter(§2),DefaultOpacity/ActiveOpacity(global, not per-window — window-shell doc §3 correction), position/size/visible/title (§4), and the transcript itself (m_chatLog, independently truncated per §3 — each window keeps its own scrollback, so a floaty showing only Tells has its own 10k/7.5k character budget separate from the main window's). - The main window's four indicator buttons (
0x10000522-0x10000525) mirror the four floaties' visibility as one-directional state indicators, not a tab strip — window-shell doc §1.4.
6. Timestamps, prefixes, and line composition order
6.1 The two-part composition model — confirmed structurally
ClientSystem::AddTextToScroll @0x00563C50 is where a body string
(arg2), a LogTextType (arg3), a plugin-hook flag (arg4) and a
windowId (arg5) become the two StringInfo arguments
RecvNotice_DisplayFinalStringInfo receives. Its structurally relevant
branch (client-local 0x1A short-circuits both the timestamp AND the local
log file):
00563de6 if (arg3 == 0x1a)
00563de6 {
// build body-only StringInfo, EMPTY prefix StringInfo
00563f2b ECM_UI::SendNotice_DisplayFinalStringInfo(arg3, &bodyOnly, &emptyPrefix, windowId);
00563de6 }
00563de6 else
00563de6 {
00563dfb if (PlayerModule::DisplayTimeStamps(&playerModule) != 0)
00563dfb {
00563e24 wcsftime(&buf, 0x400, u"%#H:%M:%S ", localtime(&now)); // "H:MM:SS " — no date, trailing space
00563e39 PStringBase<unsigned short>::set(&prefixBuffer, &buf);
00563dfb }
… if (s_pLogFile) fprintf(s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer); // §7.4
}
There is exactly ONE structural prefix element: the timestamp, and it is
entirely optional (gated on PlayerModule::DisplayTimeStamps(), a
character option toggle backed by PlayerModule::options2_ bit 6 —
PlayerModule::DisplayTimeStamps @0x005D39B0: return (options2_ >> 6) & 1). There is no separate structural "channel name" prefix element
([Fellowship], [<name>], etc.) anywhere in this function or in
RecvNotice_DisplayFinalStringInfo. Channel-name brackets that DO appear
in retail's transcript (documented already, by content not structure, in
the color-table doc §3.3's channel-bit table) are baked directly into the
arg2 body string by the SENDING handler (e.g.
Handle_Communication__ChannelBroadcast) before it ever reaches
AddTextToScroll — from this function's point of view there are only ever
two composed parts: prefix (timestamp-or-empty) and body.
6.2 The append order — separator, then prefix, then body
Back in RecvNotice_DisplayFinalStringInfo @0x004F4640 (full body, per the
excerpts in §1.2/§3.1/§3.4 stitched together in call order):
004f467c if (this->m_chatLog->m_glyphList.m_glyphList._num_elements > 0)
004f4687 {
004f469a UIElement_Text::AppendTextWithFont(this->m_chatLog, L"\n", 0, arg2 /*type*/);
004f467c } // 1. separator (skipped on the very first line)
004f46d3 ebx = UIElement_Text::IsAtVerticalEnd(this->m_chatLog); // captured BEFORE any of the below
004f46dc if (StringInfo::IsValid(arg4, 1) != 0)
004f46e9 UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg4 /*prefix*/, 0, 0xc);
// 2. timestamp prefix — ALWAYS color idx 0x0C (grey), only if valid/non-empty
004f46fc UIElement_Text::AppendStringInfoWithFont(this->m_chatLog, arg3 /*body*/, 0, arg2 /*type*/);
// 3. body — colored by the wire LogTextType
Fixed structural order: [\n if not first line] → [timestamp, if enabled] → [body]. The leading separator is a property of the LOG
(inserted once per new entry, before the entry, so the transcript never
starts with a blank line), not a property of the entry itself — a port that
appends body + "\n" per-line instead of "\n" + body will still LOOK
identical on screen but will behave differently under TruncateChatLog's
newline-boundary search (§3.2) and under IsAtVerticalEnd line-counting
(§3.4) if the two approaches disagree at the very first/last line. The
color assignment itself is the color-table doc's territory (not re-derived
here) — the load-bearing NEW fact this document adds is the order and
that the timestamp is unconditionally color index 0x0C regardless of the
body's own type, which the color-table doc §3.2 already states from the
same address; this document supplies the surrounding append sequence and
confirms the timestamp's StringInfo is arg4, always appended strictly
BEFORE the body arg3, never interleaved or after.
6.3 Timestamp format, verbatim
u"%#H:%M:%S " fed to wcsftime — hour without a leading zero, minute,
second, no date, one trailing space baked into the format string
(explaining why no separate space-insertion code exists between prefix and
body — the prefix string itself carries its own trailing separator).
7. Other user-visible window behaviors
7.1 Local session log file — a port would miss this
ClientSystem::s_pLogFile — a plain-text file retail writes chat lines to
during the session, independent of the on-screen transcript's 10k/7.5k
character budget (§3.1) or any window's filter (§2). Written from the same
AddTextToScroll branch that builds the on-screen timestamp (§6.1):
00563e5b fprintf(ClientSystem::s_pLogFile, "%ls%ls\n", prefixBuffer, bodyBuffer);
Client-local type 0x1A text (§6.1's short-circuit branch) explicitly
bypasses this — client-local errors/refusals never reach the log file,
only the on-screen transcript. UNKNOWN — needs further grep: the log
file's path/naming convention and whether it rotates per-session or
per-character; not chased further as it's a filesystem-artifact question
more than a window-UI one, but flagged because "retail also writes a
plain-text chat log to disk" is exactly the kind of behavior a UI-only port
would miss entirely.
7.2 Unread/unseen marker — confirmed, see §3.4/§3.5
The 0x1000048C "new unseen text" indicator button IS retail's unread
marker. It is per-window (each ChatInterface owns its own
m_chatNewNonVisibleTextIndicator), lights when a broadcast/addressed line
arrives while the user has scrolled away from the bottom, and clears when
the user clicks it (which also snaps the transcript back to its bottom).
There is no separate "flash the window" or "flash the taskbar/app icon" —
FlashWindow/FlashWindowEx do not appear anywhere in the pseudo-C dump
(checked via a whole-file grep; zero hits).
7.3 Sound cues on incoming chat — UNKNOWN, likely none dedicated
A targeted grep for PlaySound/SoundManager::Play* near the
Handle_Communication__HearDirectSpeech @0x005715A0 (incoming tell) handler
body found no sound-manager call inside it, and no Sound_*-named constant
resembling "tell received" or "chat" turned up in the identifiers swept.
The one chat-adjacent audio-related symbol found is a global preference
— Sound_PlaySoundOnlyWhenActive / ID_Sound_NoFocusNoSound
(UIPreferences::AttachPreference @0x004037E4,
SoundManager::PlaySoundInternal @0x0054FEC0 checks
SoundManager::s_bPlaySoundOnlyWhenActive against Device::m_bIsActiveApp)
— which mutes ALL UI sounds (not specifically chat) when the game window
isn't the active app. UNKNOWN — needs a deeper sweep or a live cdb
capture on an incoming tell: this document did not find a chat-specific
sound cue, but a negative grep result over a 66 MB pseudo-C dump is weak
evidence of absence given how many code paths route through indirect
vtable calls the text search can't follow. Flagging rather than asserting
"retail has no tell sound."
7.4 Copy/paste and text selection — a base UIElement_Text capability
UIElement_Text::GetSelection @0x00466F20 and UIElement_Text::SelectAll @0x004678D0 exist as capabilities of the general text-widget class that
BOTH the chat entry field (m_chatEntry) and the read-only transcript
(m_chatLog) are instances of (acclient.h:54904-54905, both typed
UIElement_Text*). SelectAll's call sites found are mostly OTHER
text-entry fields (a character-name box, a stack-size entry box) triggered
by a "select-all-on-first-click" attribute (UIElement::GetAttribute_Bool(this, 0xd1, ...) inside UIElement_Text::MouseDown @0x00469370), not anything
chat-specific. UNKNOWN — not independently confirmed for the read-only
transcript specifically: whether the transcript panel exposes the SAME
click-drag-select-then-copy affordance as the entry field, or whether it is
flagged read-only in a way that suppresses selection; the class-level
capability clearly exists on the type, but no chat-transcript-specific
selection code path was located distinct from the generic UIElement_Text
mouse-down handler already cited. Worth a live-client check (select text in
the retail transcript, see if a selection highlight appears) rather than
further static digging.
7.5 What's genuinely absent
- No
FlashWindowanywhere in the binary (§7.2). - No docking/snapping between chat windows or to screen edges — the window-shell doc's resize/move research found only free-floating clamped-on-restore positioning (§4).
- No tab strip / tabbed-window container (§5) — five independent panels, not a tab model.
- No manual "scroll lock" toggle — the auto-scroll behavior in §3.4 IS the
scroll-lock mechanism, driven automatically by
IsAtVerticalEnd, with no user-facing on/off switch found.
Behaviours acdream is most likely missing
Ordered by how load-bearing each gap looks against RuntimeCommunicationState
(docs/research/2026-07-26-slice-j4-1-communication-state.md) and
ChatWindowController as of this session:
- Scrollback truncation entirely. No 10,000-char trigger / ~7,500-char
target / newline-boundary-preferring trim (§3.1–§3.3) appears to exist in
acdream today — grep
TruncateChatLog-equivalent behavior inChatLog/ChatWindowControllerbefore assuming an unbounded transcript is fine; it will diverge from retail under long play sessions (memory growth) and, more subtly, under the exact wrap point if a port ever needs pixel/line parity with a retail screenshot at high message volume. - The auto-scroll / stick-to-bottom vs. flag-unseen-instead split
(§3.4–§3.5). This is a genuine behavioral fork, not a cosmetic one: a
naive port that ALWAYS scrolls to bottom on new text will yank the user's
scroll position out from under them mid-read whenever a broadcast line
arrives — exactly the annoyance retail's
IsAtVerticalEndcheck exists to prevent. ConfirmChatWindowControllerchecks "was I at the bottom before this line landed" before auto-scrolling, and confirm the0x1000048Cunseen-indicator element (window-shell doc's layout dump already has its rect for both window layouts) is wired to light up + clear via click exactly as §3.4/§3.5 describe. - The window-ID routing predicate as ONE explicit rule (§1.2). The
color-table doc already flags the routing behavior; this document adds
the exact decompiled shape. Verify
RuntimeCommunicationState's chat windows model (per the CH6c plan in the window-shell doc §6.1) implements preciselywindowId == m_eWindowID || (windowId == 0 && TypeIsActive)— not, e.g., "every window with the type enabled shows every line regardless of address," which would make addressed command-output lines leak into windows they were never meant for. - Structural composition order (§6.2) — separator-before-entry (not
after), timestamp-before-body, timestamp always present-or-absent as a
single unit gated on one option bit. A port that concatenates
timestamp + " " + bodyas one string loses retail's separately-colored, separately-truncatable prefix run and the option-driven all-or-nothing presence. - The local session chat-log file (§7.1). Small, but "retail writes a plain-text transcript to disk every session" is the kind of feature users notice is missing only when they go looking for it after the fact.
- Per-window independent scrollback. Once §1 is implemented, confirm each of the five windows truncates its OWN transcript independently (§5) rather than sharing one global buffer — a floaty window filtered down to just Tells should never truncate early just because the main window's transcript is huge.
- Sound cues and transcript text-selection are open questions, not confirmed gaps (§7.3, §7.4) — do not build negative-result "retail has none of this" code around them; re-check live if/when they become relevant.