# 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): ```cpp /* 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 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::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::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::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]`, `[]`, 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.