acdream/docs/research/2026-08-21-retail-chat-window-ui.md
Erik 663129c340 docs: Campaign CT — chat text tags, researched and planned
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>
2026-08-21 06:52:05 +02:00

33 KiB
Raw Blame History

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, PostInit seeded 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_idCurrentCommandSource per 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-bit m_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 have Speech enabled.
  • 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 ChatInterface instance ever keeps m_eWindowID == 0 after PostInit runs.

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 from PlayerModule::InqChatWindowOption(windowId, 0x1000007F, …) (ChatInterface::UpdateFromPlayerModule @0x004F3920) and live-updated via RecvNotice_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 on m_oldState (ChatInterface::PostInit @0x004F3DD0, 0x004f3df9): 1 and 8 (the main window) get 0xFBFFFFFF low-dword (everything except client-local 0x1A); 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 reserved 0x21 slot start disabled in every window and must be opted into by the user.
  • User edit path: gmChatOptionsUI::InitOptions @0x0049FC60 / AddCheckboxBitfield64Option @0x0049EDA0 build one checkbox-grid SetUserData block per window id (main = id 8, with its own dedicated Society checkbox child at 0x0049FEFB).
  • Squelching is a separate axis from filtering: LogTextTypeEnumMapper::IsLegalChannel @0x006AFF40 whitelists a 14-value subset of LogTextType as squelchable at all; it has no interaction with m_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 properties 0x2A/0x2B/0x2C/0x2D (bottom/left/right/top); the main window's plain top edge strip is a UIElement_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 0x10000080 unfocused, Option_ActiveOpacity_Property 0x10000081 focused), applied to the WHOLE composited window surface including text via one SetOpacity call — 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..4 for the floaties) or a generic registered-action click dispatch — window-shell doc §1.3/§1.4.
  • Persistence: two independent paths — the per-window GameplayOptions blob (position/size/visible/title, gated on m_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 option 0x1000008D) 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, element 0x1000052A) or the Alt+N toggle; 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 FlashWindow anywhere 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:

  1. 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 in ChatLog/ChatWindowController before 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.
  2. 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 IsAtVerticalEnd check exists to prevent. Confirm ChatWindowController checks "was I at the bottom before this line landed" before auto-scrolling, and confirm the 0x1000048C unseen-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.
  3. 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 precisely windowId == 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.
  4. 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 + " " + body as one string loses retail's separately-colored, separately-truncatable prefix run and the option-driven all-or-nothing presence.
  5. 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.
  6. 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.
  7. 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.