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>
650 lines
33 KiB
Markdown
650 lines
33 KiB
Markdown
# 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<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.
|