acdream/docs/research/2026-08-09-chat-retail-window-shell.md
Erik 22020ef2c4 feat(chat): Campaign CH slice CH6b — floating chat windows 1-4
Mounts retail's four floating chat windows as always-resident, born-hidden
children per gmGamePlayUI::SetupChildren @0x004E9EC0, all sharing LayoutDesc
0x2100005B (window ids 0x10000505/0x1000050E/0x1000050F/0x10000510). New
FloatingChatWindowController (AcDream.App/UI/Layout) binds each window's own
widget tree — built fresh per instance from one shared imported ElementInfo
— reusing ChatWindowController's word-wrap + retail color-carry algorithm via
the extracted ChatTranscriptRenderer instead of duplicating it. A floaty
window has no talk-focus menu (research doc §2.2), so its entry field always
sends on Say; the mismatch against retail's possible shared-channel behavior
is UNVERIFIED and filed as #369/AP-188.

Runtime owns the per-window filter/open state: ChatWindowState (new,
AcDream.Core.Chat) seeds retail's exact PostInit defaults per window
(window 1 0x0000101C Speech/Tell/DirectSend/Emote, window 2 0x00040C00
Social/SocialSend/Allegiance, window 3 0x00080000 Fellowship, window 4
0x78000000 Turbine General/Trade/LFG/Roleplay) and implements the full
ShouldDisplay(windowId, targetWindowId, logTextType) display predicate from
ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640. It lives on
RuntimeCommunicationState.ChatWindows so every host borrows the same
instance. The main window's filter (0xFBFFFFFF, "no user filter") never
actually gates anything because its own explicit-address branch already
covers every broadcast line — that's why UpdateFromPlayerModule early-returns
for window 0 in retail, ported here by construction rather than a special
case.

Keybind wiring: InputAction.ToggleFloatingChatWindow1..4 and their
KeyBindings.RetailDefaults() chords already existed since Phase K.1c
(unwired until now). The MetaKeys table confirms retail's default is Alt+1
through Alt+4 (index 3 = bit 0x00000004, cross-checked against the same
file's Alt+A/D strafe and Alt+Enter/Tab/F4 rows). Routes through
GameplayInputCommandController -> RetainedGameplayWindowCommands ->
RetailUiRuntime.ToggleFloatingChatWindow -> the generic UiHost.ToggleWindow,
whose visibility-change event is the single chokepoint that syncs
ChatWindowState.SetOpen and mirrors the main window's 1-4 indicator button
regardless of what changed a window's visibility (keybind, close button, or
a restored layout).

A direct decomp read of gmMainChatUI::ListenToElementMessage @0x004CDA80 —
the only function in the whole binary that branches on a click message —
settles what the research doc had left as a hedge: it handles exactly
0x1000046f (max/min) and the talk-focus menu's selection message, with NO
case for 0x10000522-0x10000525. The four indicator buttons are PURE
one-directional mirrors in retail; clicking them does nothing.
ChatWindowController.SetIndicatorOpen ports this with no OnClick at all.
Corrected research doc §1.4 accordingly.

Persistence is local-only (register row AP-187; the retail 0x1000008C
GameplayOptions wire remains deferred to CH6f): window geometry and
open/visible state ride the existing generic RetailWindowLayoutPersistence
path for free once each window registers under its own WindowNames entry;
the four filter masks get a dedicated ChatSettings round-trip
(ChatWindow1Filter..ChatWindow4Filter, defaulting to the retail PostInit
constants) loaded at mount and saved alongside SaveLayout().

Tests: ChatWindowStateTests (defaults, TypeIsActive, the full display-rule
matrix, toggle/reset, revision counter), FloatingChatWindowControllerTests
(bind smoke tests against a synthetic 0x2100005B tree, per-window filter
routing, filter-change cache invalidation, fixed-Say submit), new
ChatWindowController.SetIndicatorOpen tests (Highlight/Normal state,
cross-window isolation, range validation), GameplayInputCommandController
routing for the four toggle actions, and a SettingsStore filter round-trip.
Full Release suite: 12,392 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:10:20 +02:00

757 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Campaign CH slice CH6 — retail chat-window SHELL research
**Date:** 2026-08-09
**Scope:** RESEARCH ONLY. How retail Asheron's Call constructs, shows, sizes,
persists and fades the chat windows — the *shell*, not the text pipeline.
The per-window text FILTER model is already decoded in
[`2026-08-09-chat-retail-color-table.md`](2026-08-09-chat-retail-color-table.md)
§4 and is not re-derived here.
**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`
- `docs/research/named-retail/retail-default.keymap.txt`
- `docs/research/2026-06-25-retail-ui-layout-dump.json` — **an existing acdream
dump of the retail gameplay-UI top-level windows.** It carries the LayoutDesc
id, element ids, widget kinds and authored rects for every window below, and
is the geometric oracle used throughout §2.
- `references/ACE/Source/...` (server cross-check)
**Binary-Ninja field-name caveat (applies throughout).** BN's struct-field
attribution in `gm*ChatUI::PostInit` is shifted by one slot relative to the true
member order (the classic artifact class in
`claude-memory/feedback_bn_decomp_field_names.md`). Every id↔role binding in §2
was therefore re-derived from the **authored rectangles in the layout dump**,
which agree with the decomp's *call order* exactly once the one-slot shift is
undone. Where a claim rests on BN naming alone it is marked UNVERIFIED.
---
## 1. Window lifecycle — how windows 14 get created and shown
### 1.1 They are not created on demand. They are authored, always-resident, and toggled.
`gmGamePlayUI::SetupChildren @0x004E9EC0` builds the whole gameplay UI once:
```
004e9ed5 m_pGameplayUI = CreateAndAddRootElement(0x10000006, 0x10000495)
004e9f40 var_b0 = 0x10000505 // floating chat window 1
004e9f48 var_ac = 0x1000050E // floating chat window 2
004e9f50 var_a8 = 0x1000050F // floating chat window 3
004e9f58 var_a4 = 0x10000510 // floating chat window 4
004e9f6a hash("ID_Chat_Chat1_DefaultTitle") … "ID_Chat_Chat4_DefaultTitle"
004e9ff5 for i in 0..3:
004e9faf StringInfo::SetStringIDandTableEnum(&title, ids[i], 0x10000001)
004e9fbf el = UIElement::GetChildRecursive(m_pGameplayUI, windowIds[i])
004e9fd1 chat = el->DynamicCast(0x10000040) // gmFloatyChatUI
004e9fe9 chat->vtable[0x2AC](&title) // SetWindowTitle
```
So all four extra chat windows exist as **authored children of the gameplay-UI
root from the moment the game UI is built**. There is no window-manager `Create`
call, no per-window allocation at toggle time, and no floating-window registry.
Opening one is `SetVisible(true)`; closing one is `SetVisible(false)`.
Confirming class identity: `gmFloatyChatUI::GetUIElementType @0x004CE2B0` returns
`0x10000040`, and `gmFloatyChatUI::Register @0x004CE3D0` registers that element
class with the LayoutDesc element factory. `gmFloatyChatUI` derives from
`ChatInterface` and adds **no members at all** (`acclient.h:55144`), which is why
`m_eWindowID` (the first `ChatInterface` field, `acclient.h:54900`) is the only
per-window identity it carries.
### 1.2 Window identity comes from a LayoutDesc attribute, not from code
`ChatInterface::PostInit @0x004F3DD0`:
```
004f3de8 UIElement::GetAttribute_Enum(this, 0x1000007E, &this->m_eWindowID)
004f3df9 switch (m_eWindowID - 1) { … seed m_llTextTypeFilter defaults … }
```
**Attribute `0x1000007E` on the authored element IS the window id.** The main
chat window is id `0`; the floaties are `1..4` (the `PostInit` switch also has
arms for 5 and 8 — see the colour-table doc §4 for the seeded filter values).
### 1.3 The toggle is a KEYBIND, not a button
`docs/research/named-retail/retail-default.keymap.txt:150-153`, inside the
`ToggleWindows` action group:
```
ToggleFloatingChatWindow1 [ "" [ 0 DIK_1 ] 0x00000004 ]
ToggleFloatingChatWindow2 [ "" [ 0 DIK_2 ] 0x00000004 ]
ToggleFloatingChatWindow3 [ "" [ 0 DIK_3 ] 0x00000004 ]
ToggleFloatingChatWindow4 [ "" [ 0 DIK_4 ] 0x00000004 ]
```
`0x00000004` is the modifier mask (the same mask the `UseQuickSlot_14..18` rows
use for `Alt`+digit, versus `0x00000002` for the shift-digit quickslot rows).
So retail's default is **Alt+1 … Alt+4**, not bare 14.
UNVERIFIED: the exact modifier bit → key mapping (4 = Alt vs 4 = Ctrl). Cheapest
resolution: read `keymap-default.txt`'s modifier legend, or one cdb breakpoint on
`UIElementManager::KeyPressEvent`. The *mechanism* below is independent of this.
The dispatch chain is fully generic — there is no chat-specific code in it:
```
UIElementManager::KeyPressEvent @0x0045C300
0045c347 DoVisibilityToggleAction(this, actionId)
UIElementManager::DoVisibilityToggleAction @0x0045B660
0045b680 look up actionId in m_elementInputActionListenerTable
0045b6c1 for each registered element:
BroadcastElementMessage(element, 0x31, actionId, 0)
```
and the registration side is a plain authored property:
```
UIElement::OnSetAttribute (property switch) @0x00462D80
004631d7 case 0x24: // "input action" property
004631fb UIElementManager::RegisterElementForInputAction(
UIElementManager::s_pInstance, enumValue, this)
```
**So: LayoutDesc property `0x24` on the window element declares the input action
that toggles it; the manager broadcasts element message `0x31` ("toggle
visibility") to every element registered for that action.** The same mechanism
serves `ToggleInventoryPanel`, `ToggleSpellbookPanel`, etc.
UNVERIFIED: the numeric action-enum values behind
`ToggleFloatingChatWindow1..4` (the keymap file stores names, and the property
`0x24` value lives in the LayoutDesc, not in code). Cheapest resolution: dump
property `0x24` for elements `0x10000505/0x1000050E/0x1000050F/0x10000510` from
LayoutDesc `0x21000070`/gameplay root — one `ui-studio --dump` pass. Not needed
to implement CH6: acdream binds its own actions.
### 1.4 The 1/2/3/4 buttons in the main window are STATE MIRRORS, not the toggle
They exist and they are authored (§2.1, elements `0x10000522``0x10000525`), but
the code path is one-directional — window visibility drives the buttons:
```
gmGamePlayUI::ListenToElementMessage @0x004E9CE0
004e9cee if (idMessage == 0x18) // visibility changed
004e9d29 if (idElement == 0x10000505 ||
(idElement > 0x1000050D && idElement <= 0x10000510))
004e9d4c CM_UI::SendNotice_SetPanelVisibility(idElement, elementVisibleBit)
gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80
004ccd9c 0x10000505 -> child 0x10000522
004ccda3 0x1000050E -> child 0x10000523
004ccdaa 0x1000050F -> child 0x10000524
004ccdb1 0x10000510 -> child 0x10000525
004ccdbf child = GetChildRecursive(this, mapped)
004ccdcd visible ? child->SetState(6) : child->SetState(1)
```
State 6 = "on/depressed", state 1 = "normal". No handler anywhere in the binary
switches on `0x10000522..0x10000525` as a *source* of a click — `grep` over the
whole pseudo-C returns only `gmMainChatUI::RecvNotice_SetPanelVisibility`.
**RESOLVED 2026-08-10 at Campaign CH slice CH6b.** The prior UNVERIFIED
paragraph's hedge ("safe to wire both") is superseded by a direct read of
`gmMainChatUI::ListenToElementMessage @0x004CDA80` — the ONLY function in the
whole 2013 binary that branches on `idMessage == 1` ("clicked"). It handles
exactly two element ids: `0x1000046f` (max/min, dispatching
`HandleMaximizeButton`) and the talk-focus menu's selection message
(`idMessage == 7`, checked against `this->m_pCCS` / a `0x1000000b` attribute
read). There is no case, anywhere in that function or its base-class fallback
(`ChatInterface::ListenToElementMessage`, called unconditionally at the
function's tail), for `0x10000522``0x10000525`. **Clicking a chat-window
indicator button does NOTHING in retail — reading (a), pure indicator, is
correct; reading (b) is refuted.** The `0x24` input-action-property theory
in reading (b) does not even apply to a mouse click on the button itself: that
property only wires *keyboard* dispatch (`UIElementManager::
DoVisibilityToggleAction` in §1.3), not a button's own `UIElement` click
message, which routes to its LISTENING PARENT — and that parent's handler has
no case for these four ids. acdream ports this exactly: the four indicator
buttons (`ChatWindowController._indicatorButtons`) carry no `OnClick` at all;
`ChatWindowController.SetIndicatorOpen` is their only writer, called only from
`RetailUiRuntime.OnWindowVisibilityChanged` in response to the floating
window's own visibility changing (keybind or otherwise) — a pure one-directional
mirror, matching retail exactly.
### 1.5 Closing a floaty window from its own title bar
```
gmFloatyChatUI::ListenToElementMessage @0x004CE330
004ce344 if (idMessage == 1 && idElement == 0x1000052A)
004ce346 this->vtable[…]( 0 ) // SetVisible(false)
```
Element `0x1000052A` is the 14×14 close button at (230, 86) in the floaty
layout (§2.2). `idMessage == 1` is "clicked".
### 1.6 The lock-UI cosmetic swap
`gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0` (driven by global message
`0x0D` via `gmFloatyMainChatUI::ListenToGlobalMessage @0x004D2940`) reads
`PlayerModule::LockUI` and swaps the 8 live border/corner elements for their 8
`_Locked` cosmetic twins. When the UI is locked the *interactive* Resizebars are
hidden and the inert art is shown — that is how retail disables resizing without
touching the resize code.
---
## 2. LayoutDesc geometry
### 2.1 Main chat window — LayoutDesc `0x2100006F`
Window element `0x10000601` (this is the id `gmGamePlayUI` looks up and the id
`SaveScreenLayout @0x004EAD50` writes as `<CHAT>`), layout root element
`0x10000600`, **authored extent 410 × 100**.
Full authored tree (from `2026-06-25-retail-ui-layout-dump.json`; rects are
absolute in the dump's 640×480 space, i.e. root at 0,0):
| element | rect (x,y,w,h) | role |
|---|---|---|
| `0x10000600` | 0,0,410,100 | window root (Group) |
| `0x10000693` | 0,0,5,5 | TL corner — **Locked twin** |
| `0x10000694` | 5,0,400,5 | top edge — Locked twin |
| `0x10000695` | 405,0,5,5 | TR corner — Locked twin |
| `0x10000696` | 0,5,5,90 | left edge — Locked twin |
| `0x10000697` | 0,95,5,5 | BL corner — Locked twin |
| `0x10000698` | 5,95,400,5 | bottom edge — Locked twin |
| `0x10000699` | 405,95,5,5 | BR corner — Locked twin |
| `0x1000069A` | 405,5,5,90 | right edge — Locked twin |
| `0x1000069B` | 0,0,5,5 | **TL corner — live Resizebar** |
| `0x1000069C` | 5,0,400,5 | **top edge — live Resizebar** |
| `0x1000069D` | 405,0,5,5 | **TR corner — live Resizebar** |
| `0x1000069E` | 0,5,5,90 | **left edge — live Resizebar** |
| `0x1000069F` | 0,95,5,5 | **BL corner — live Resizebar** |
| `0x100006A0` | 5,95,400,5 | **bottom edge — live Resizebar** |
| `0x100006A1` | 405,95,5,5 | **BR corner — live Resizebar** |
| `0x100006A2` | 405,5,5,90 | **right edge — live Resizebar** |
| `0x10000010` | 5,5,400,73 | transcript panel (Sprite) |
| `0x10000011` | 21,5,368,73 | transcript text (Group) |
| `0x1000048C` | 21,62,16,16 | new-unseen-text indicator (Button) |
| `0x10000012` | 389,5,16,73 | scrollbar column |
| `0x10000364/65/66`, `0x10000071`, `0x10000072` | — | scrollbar thumb pieces + up/down buttons |
| `0x1000046F` | 368,5,16,16 | max/min toggle (Button) |
| `0x10000522` | 5,5,16,16 | **chat-window-1 indicator (Button)** |
| `0x10000523` | 5,22,16,16 | **chat-window-2 indicator** |
| `0x10000524` | 5,39,16,16 | **chat-window-3 indicator** |
| `0x10000525` | 5,56,16,16 | **chat-window-4 indicator** |
| `0x10000013` | 5,78,400,17 | input row (Sprite) |
| `0x10000014` | 5,78,46,17 | talk-focus menu button |
| `0x10000015` | 5,78,46,17 | talk-focus menu group |
| `0x10000016` | 51,78,306,17 | **chat entry field** |
| `0x10000017` / `0x10000018` | 1px | entry field left/right rails |
| `0x10000019` | 359,78,46,17 | Send button |
Cross-checks that pin this layout to the code, independent of the dump:
- `gmMainChatUI::HandleMaximizeButton @0x004CCE50` looks up `0x1000046F`.
- `gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80` writes
`0x10000522``0x10000525`.
- `gmFloatyMainChatUI::PostInit @0x004D2670` binds exactly the 16 ids
`0x10000693``0x100006A2`, and casts eight of them via `DynamicCast(9)`
element type **9 is `UIElement_Resizebar`**
(`UIElement_Resizebar::Register @0x0046B920``RegisterElementClass(9, …)`).
**The 1/2/3/4 buttons are on the LEFT edge, stacked vertically** — not a tab row.
### 2.2 Floating chat windows 14 — LayoutDesc `0x2100005B`
All four windows instantiate **the same** LayoutDesc; only the window element id
and the `0x1000007E` window-id attribute differ. Layout root `0x100004F7`,
**authored extent 250 × 108**.
| element | rect (x,y,w,h) | role |
|---|---|---|
| `0x100004F7` | 0,80,250,108 | window root (Group) |
| `0x100004FC` | 0,80,5,5 | TL corner |
| `0x1000000F` | 5,80,240,5 | top edge |
| `0x100004FE` | 245,80,5,5 | TR corner |
| `0x100004D2` | 0,85,5,98 | left edge |
| `0x10000501` | 0,183,5,5 | BL corner |
| `0x100004D4` | 5,183,240,5 | bottom edge |
| `0x10000503` | 245,183,5,5 | BR corner |
| `0x100004D3` | 245,85,5,98 | right edge |
| `0x100004D9` | 5,85,240,16 | **title bar** |
| `0x10000528` | 5,100,240,5 | title/content divider |
| `0x10000529` | 5,85,240,20 | title-bar group (drag handle) |
| `0x1000052A` | 230,86,14,14 | **close button** |
| `0x10000010` | 5,105,224,60 | transcript panel |
| `0x10000011` | 5,105,224,60 | transcript text |
| `0x1000048C` | 5,169,16,16 | new-unseen-text indicator |
| `0x10000012` | 229,105,16,60 | scrollbar column (+ children) |
| `0x10000509` | 5,165,240,18 | input row |
| `0x10000016` | 5,165,202,18 | chat entry field |
| `0x1000052B`, `0x100002B5`, `0x10000209`, `0x1000020A`, `0x1000020B` | — | entry-field frame rails |
| `0x10000019` | 207,165,38,18 | Send button |
Independent code cross-check: `gmFloatyChatUI::SetWindowTitle @0x004CEAA0` does
`GetChildRecursive(this, 0x100004D9)` and `SetStringInfo` on it — exactly the
240×16 title strip above.
A floaty chat window has **no** talk-focus menu, **no** max/min button and **no**
1/2/3/4 indicators. It has a title bar (which the main window does not) and a
close button.
### 2.3 How retail encodes resizability — there are no "sizable edge" flags
Retail does **not** put a bitmask on the window. Each draggable edge and corner
is its own child element of type 9 (`UIElement_Resizebar`), and four authored
BOOL properties on that child say which border it drives:
`UIElement_Resizebar::StartMouseResizing @0x0046B7E0`
```
0046b7f8 GetAttribute_Bool(this, 0x2C, &bRight)
0046b806 GetAttribute_Bool(this, 0x2B, &bLeft)
0046b814 GetAttribute_Bool(this, 0x2D, &bTop)
0046b822 GetAttribute_Bool(this, 0x2A, &bBottom)
if (bRight) border = bTop ? BORDER_UR : (bBottom ? BORDER_LR : BORDER_RIGHT)
else if (bLeft) border = bTop ? BORDER_UL : (bBottom ? BORDER_LL : BORDER_LEFT)
else if (bTop) border = BORDER_TOP
else if (bBottom)border = BORDER_BOTTOM
0046b88e this->m_mousePressed = 1
0046b89f UIElement::StartResizing(parent, border, pt.x, pt.y)
0046b8a4 this->SetState(3) // "pressed" art
```
`BorderLocation` (`acclient.h:4327`):
`BORDER_NONE=0, BORDER_UL=1, BORDER_TOP=2, BORDER_UR=3, BORDER_RIGHT=4,
BORDER_LR=5, BORDER_BOTTOM=6, BORDER_LL=7, BORDER_LEFT=8`.
So the property map is **`0x2A` = bottom, `0x2B` = left, `0x2C` = right,
`0x2D` = top**, and a corner grip simply sets two of them.
Drag lifecycle (`UIElement_Resizebar::ListenToElementMessage @0x0046B930`):
message `0x1C` (mouse down, `dwParam1 == 7` = left button) → `StartMouseResizing`
+ parent's vtable+0x60; `0x1E` (mouse move) → parent's vtable+0x4C
(`MouseResizeElement @0x00461130`); `0x1D` (mouse up) → parent
vtable+0x64 + `StopMouseResizing``UIElement::StopResizing @0x0045FD60`.
**Both chat layouts author all eight grips.** Retail is resizable from every
edge and every corner, including the top and the top corners, on both the main
and the floating chat windows.
**CORRECTED 2026-08-10 at Campaign CH slice CH6a implementation.** This claim
is wrong for the main window's plain TOP EDGE specifically, established by a
direct `ElementDesc.Type` dump of the installed DAT (ground truth, not a
decomp reading) of the 8 ids `0x1000069B`-`0x100006A2`: 7 of them are
Type 9 (`UIElement_Resizebar`) as claimed, but `0x1000069C` — the straight
top-edge strip between the two top corners — is Type **2**
(`UIElement_Dragbar`), not Type 9. So retail's main chat window is
resizable from every edge and corner EXCEPT the plain top strip, which is a
MOVE handle instead (there is no title bar, so the top strip does double
duty as the drag affordance). The two top CORNERS (`0x1000069B` UL,
`0x1000069D` UR) are still genuine Resizebar grips carrying the top bool, so
dragging from a corner still resizes the Y/top axis — only the straight
edge in between does not. This is fully consistent with, and explains, the
`UIElement_Resizebar::StartMouseResizing` cursor media dump: `0x1000069C`
carries cursor `0x06006119` (the four-arrow MOVE cursor, matching
`WindowMove`), not one of the two diagonal or the vertical resize cursor
ids the seven true grips carry. Not independently re-verified for the
floating-window layout `0x2100005B` (CH6b's scope); assume it needs the
same direct-dump check rather than trusting this row for that layout too.
### 2.4 Min/max extents
Retail reads them as ordinary integer attributes on the window element:
`gmMainChatUI::HandleMaximizeButton @0x004CCE50` uses
`GetAttribute_Int(this, 0x3C, &maxH)` and `GetAttribute_Int(this, 0x3E, &minH)`.
acdream already consumes the same family — `RetailWindowFrame.ResolveConstraint`
(`src/AcDream.App/UI/Layout/RetailWindowFrame.cs:160-169`) maps
`0x3C`=maxH, `0x3D`=maxW, `0x3E`=minH, `0x3F`=minW. No change needed.
---
## 3. Opacity — the two values
Named constants in the binary's read-only data at `0x007A8D50`:
```
007a8d50 uint32_t const Option_TextType_Property = 0x1000007F
007a8d54 uint32_t const Option_DefaultOpacity_Property = 0x10000080
007a8d58 uint32_t const Option_ActiveOpacity_Property = 0x10000081
```
| id | name | type | scope | meaning |
|---|---|---|---|---|
| `0x10000080` | `Option_DefaultOpacity_Property` | float | **global** (`InqOption`, not per-window) | opacity when the window's text entry does NOT have focus |
| `0x10000081` | `Option_ActiveOpacity_Property` | float | **global** | opacity while the window's text entry HAS focus |
Read path (`gmFloatyChatUI::UpdateFromPlayerModule @0x004CE3F0`,
identical in `gmFloatyMainChatUI::UpdateFromPlayerModule @0x004D2970`):
```
004ce42b if (PlayerModule::InqOption(pm, 0x10000080, &prop) && prop->InqFloat(&v))
004ce445 ChatInterface::SetDefaultOpacity(this, v)
004ce465 if (PlayerModule::InqOption(pm, 0x10000081, &prop) && prop->InqFloat(&v))
004ce47f ChatInterface::SetActiveOpacity(this, v)
```
Live-update path (`gmFloatyMainChatUI::RecvNotice_GameplayOptionChanged
@0x004D25A0`) switches on the same two ids and forwards anything else to
`ChatInterface::RecvNotice_GameplayOptionChanged @0x004F30E0` (which handles the
`0x1000007F` filter).
They are also settable per-element from the LayoutDesc:
`ChatInterface::OnSetAttribute @0x004F3F60` accepts `0x10000080` / `0x10000081`
as element attributes and **falls back to `0x3F800000` (= 1.0f)** when the
property value cannot be read (`004f3fb2`, `004f3f84`). That 1.0f is the only
default the code itself carries.
The options UI wires them to two linked sliders — `gmChatOptionsUI::InitOptions
@0x0049FC60`:
```
0049fcc0 UIOption_Slider::SetGameplayOptionProperty(slider1, 0x10000080)
0049fd1a UIOption_Slider::SetGameplayOptionProperty(slider2, 0x10000081)
0049fd5c DualHash<UIOption_Slider*,UIOption_Slider*>::add(&m_hashSliderLinks, &slider1, &slider2)
```
The `DualHash` link is why the two sliders track each other in the retail
options panel, and it is mirrored in code:
`ChatInterface::SetDefaultOpacity @0x004F3BC0` calls `SetActiveOpacity` when
active < default, and `SetActiveOpacity @0x004F3C40` calls `SetDefaultOpacity`
when default > active. **Invariant: `activeOpacity >= defaultOpacity` always.**
Which one is applied right now (`SetDefaultOpacity @0x004F3BC0`,
`SetActiveOpacity @0x004F3C40`):
```
if (this window's root is the active element
&& GetFocusDescendant(activeElement) == m_chatEntry)
m_fCurrentOpacity = m_fActiveOpacity
else m_fCurrentOpacity = m_fDefaultOpacity
```
and application is one call on the window's own render surface —
`ChatInterface::SetOpacity @0x004F3120`:
```
004f3124 this->m_fCurrentOpacity = v
004f312a obj = m_object ?: UIRegion::GetObjectA(m_parent)
004f314b surface = obj->vtable[0x1C]() // get render surface
004f3156 surface->vtable[0x48](v) // set surface alpha
```
**Retail fades the whole composited window surface — chrome, backgrounds AND
text — with one alpha, not a per-widget background tint.** This is the shape the
future acdream user setting should take.
UNVERIFIED: retail's *shipped default values* for the two sliders. They are not
constants in code (the only in-code fallback is 1.0f); they arrive from the
server's `GameplayOptions` blob or from the options page's authored slider
defaults. Cheapest resolution: one cdb breakpoint on
`ChatInterface::SetDefaultOpacity` at login and read `st(0)` — or dump the
slider template defaults from the options LayoutDesc (`0x2100002B`).
---
## 4. Persistence — how filters, geometry, visibility and title survive
### 4.1 The per-window option structure
Every per-window chat setting lives inside ONE global gameplay option, an
array indexed by `windowId - 1`. `PlayerModule::GetChatOptionStructure
@0x005D5300`:
```
005d5326 find option 0x1000008C in m_colGameplayOptions // the ARRAY
005d5360 … if absent: SetPropertyName(&p, 0x1000008C); hash.add(p)
005d5473 if (arrayProp->type == 0x11 && (windowId-1) < arrayProp->capacity)
005d54a8 SetPropertyName(&elem, 0x1000008B) // the ELEMENT
005d54b3 for i in currentCount..=(windowId-1): append elem
005d553b return arrayProp->vtable[0x10C](windowId - 1) // entry
```
- **`0x1000008C`** — the per-window option ARRAY (one entry per chat window).
- **`0x1000008B`** — the property name of each ARRAY ELEMENT (a nested bag).
`PlayerModule::InqChatWindowOption @0x005D5540` / `SetChatWindowOption
@0x005D5570` are thin `(windowId, propertyId)` accessors over that structure.
### 4.2 The complete `InqChatWindowOption` property family
| id | type | written by | read by |
|---|---|---|---|
| `0x1000007F` | bitfield64 | chat options UI (`gmChatOptionsUI::AddCheckboxBitfield64Option @0x0049EDA0`) | `ChatInterface::UpdateFromPlayerModule @0x004F3920`; live via `RecvNotice_GameplayOptionChanged @0x004F30E0` |
| `0x10000086` | int — window **X** | `gmFloatyChatUI::MoveTo @0x004CE840:004ce892`; `gmFloatyMainChatUI::MoveTo @0x004D2D10:004d2e06` | `UpdateFromPlayerModule``MoveTo(x,y)` |
| `0x10000087` | int — window **Y** | same `MoveTo` sites, `:004ce8da` / `:004d2e4e` | same |
| `0x10000088` | int — **width** | `gmFloatyChatUI::ResizeTo @0x004CE6D0:004ce722`; `gmFloatyMainChatUI::ResizeTo @0x004D2C00:004d2c5e` | `UpdateFromPlayerModule``ResizeTo(w,h)` |
| `0x10000089` | int — **height** | same `ResizeTo` sites, `:004ce770` / `:004d2cac` | same |
| `0x1000008A` | bool — **visible / open** | `gmFloatyChatUI::SetVisible @0x004CE9B0:004ce9ff` | `UpdateFromPlayerModule``SetVisible` |
| `0x1000008D` | StringInfo — **window title** | `gmFloatyChatUI::SetWindowTitle @0x004CEAA0:004ceb14` | `UpdateFromPlayerModule @0x004CE3F0:004ce63a` |
Two guards worth porting verbatim:
1. Every write is gated on `m_eWindowID != 0` — **the main chat window (id 0)
never persists geometry, visibility, title or filter.** Only windows 1..4 do.
2. Position/size restore is skipped entirely when
`CPlayerSystem::GetPlayerSystem()->m_layoutFromFile != 0`
(`004ce4a8`, `004d2a25`) — i.e. a `LoadScreenLayout` file wins over the
server-side option blob. Visibility and title still restore in that case.
`gmFloatyMainChatUI::MoveTo @0x004D2D10` additionally clamps the window inside
its parent before delegating (`004d2d53``004d2dbb`), so a saved position from a
larger resolution can never strand the window off-screen.
### 4.3 The local screen-layout file (a second, independent persistence path)
`gmGamePlayUI::SaveScreenLayout @0x004EAD50` / `LoadScreenLayout @0x004EA8F0`
write a plain-text file (path built by `CreateScreenLayoutPath @0x004EA690`)
with one line per window:
```
<SBOX> X:%d Y: %d W: %d H: %d // 0x1000049A
<CHAT> X:%d Y: %d W: %d H: %d // 0x10000601 ← main chat
<FCH1> X:%d Y: %d W: %d H: %d // 0x10000505
<FCH2> … // 0x1000050E
<FCH3> … // 0x1000050F
<FCH4> … // 0x10000510
```
`LoadScreenLayout` matches the 4-character tags back to element ids
(`004eaa91`, `004eaab8`, `004eaadf`, `004eab06`). This is retail's
"save/load UI layout" feature and is the `m_layoutFromFile` flag's source.
Notably it is the ONLY path that persists the **main** chat window's geometry.
### 4.4 The wire
`m_colGameplayOptions` is packed whole into the character-options blob.
Server side, ACE treats it as **opaque bytes**:
- outbound: `references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventPlayerDescription.cs:349-350, 393-394`
sets `CharacterOptionDataFlag.GameplayOptions` (`0x00000200`) and writes
`Character.GameplayOptions` verbatim.
- inbound: `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionSetCharacterOptions.cs:183-190`
*"This is the last message... So it should be all that is left"* — reads the
remaining bytes and calls `SetCharacterGameplayOptions(bytes)`.
- storage: `references/ACE/Source/ACE.Database/Models/Shard/Character.cs:49`
`public byte[] GameplayOptions`.
**ACE does not parse chat-window options at all.** It stores and echoes the
blob. Consequences for acdream:
- Anything acdream writes into that blob will round-trip through ACE unchanged.
- Nothing on the server validates it, so acdream owns the format entirely —
which means acdream must match retail's `PropertyCollection` packing exactly
if a retail client and acdream are ever to share a character.
- Until acdream can pack it, the cheapest correct behaviour is **local**
persistence (the existing `SettingsStore` window-layout path), with the wire
round-trip deferred.
---
## 5. acdream inventory — what exists, what is missing
### 5.1 What we mount today
`RetailUiRuntime.MountChat()``src/AcDream.App/UI/RetailUiRuntime.cs:670-736`:
```
676 LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId) // 0x21000006
691 ChatWindowController.Bind(info, layout, …)
708 RetailWindowFrame.Mount(Host.Root, root, …)
714 WindowName = WindowNames.Chat
715 Chrome = RetailWindowChrome.NineSlice
718 ContentWidth = 490f
728 ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Bottom
729 Opacity = 0.75f
```
`ChatWindowController``src/AcDream.App/UI/Layout/ChatWindowController.cs`:
- `:29` `LayoutId = 0x21000006`, `:33` `RootId = 0x1000000E`
- `:34` `ResizeBarId = 0x1000000F`**dropped** at `:204-205`
- `:35-42` transcript panel / transcript / track / input bar / menu / input /
send / max-min ids
- `:228`, `:237` `BackgroundColor = (0,0,0,0.35)` on transcript and input
- `:383-426` `ToggleMaximize` — a faithful port of
`gmMainChatUI::HandleMaximizeButton @0x004CCE50`
### 5.2 Gap list
**G1 and G2 are CLOSED as of Campaign CH slice CH6a (2026-08-10).**
`ChatWindowController.LayoutId`/`RootId` now import `0x2100006F`/`0x10000600`;
the crop/rebase/orphan-pruning compensations are deleted; `LayoutImporter`
gained a Type-9 case (`UiResizeGrip`); `UiRoot` gives a directly-hit grip's
own edges priority over its generic proximity heuristic; the main window
mounts with `RetailWindowChrome.Imported` (0x2100006F's own border art is
its chrome). The two subsections below are kept verbatim as the historical
diagnosis — do not re-run this investigation.
**G1 — Wrong LayoutDesc for the main window.**
`ChatWindowController.cs:29` imports `0x21000006` with root `0x1000000E`.
Retail's EoR main chat window is **LayoutDesc `0x2100006F`**, window element
`0x10000601`, root `0x10000600`, 410 × 100 (§2.1), evidenced three independent
ways: the runtime layout dump, `gmGamePlayUI::SetupChildren`/`SaveScreenLayout`
element ids, and `gmMainChatUI`'s own `0x1000046F` / `0x10000522``0x10000525`
child lookups. `0x21000006` is a different (older/standalone) chat layout — its
root `0x1000000E` and its 800px-wide resize bar `0x1000000F` appear nowhere in
the EoR gameplay UI, and `ChatWindowController.cs:185-193` already documents
that it drags along stray unparented siblings (`0x1000001C/1D/1E`,
`0x10000526`) that had to be orphaned by hand. Every symptom in the user's
report (3) — stray geometry, hand-cropped 490px content width, a dropped
800px resize bar, a 9px hole patched by growing the transcript panel at
`:210-211` — is downstream of this single choice.
UNVERIFIED: the exact provenance of `0x21000006`. Cheapest resolution:
`AcDream.App ui-studio --layout 0x21000006 --dump` next to
`--layout 0x2100006F --dump` and diff the element sets — one command, no
connected session.
**G2 — Only three edges resize, and the resize model is not retail's.**
`RetailUiRuntime.cs:728` sets `ResizableEdges = Left | Right | Bottom`,
deliberately excluding `Top` because the authored resize bar was dropped (G1).
`UiRoot.HitEdges` (`src/AcDream.App/UI/UiRoot.cs:999-1010`) does support
corners — a corner is just two bits — so with `Top` masked out the top-left
and top-right corners are dead and only the two bottom corners work. That is
exactly the user's report (2). Retail authors **eight** grips (§2.3) with
per-grip bool properties `0x2A`/`0x2B`/`0x2C`/`0x2D`. Additionally,
`LayoutImporter` has no concept of element type 9 (`UIElement_Resizebar`) — grep
for `Resizebar` in `src/AcDream.App/UI` returns nothing — so authored grips
would be imported as inert sprites today.
**G3 — Window opacity is completely inert.**
`RetailWindowFrame.cs:157` sets `outerFrame.Opacity`, and
`UiElement.DrawSelfAndChildren` (`src/AcDream.App/UI/UiElement.cs:465`) and
`DrawOverlays` (`:513`) push it onto `UiRenderContext`'s alpha stack. But
**`UiRenderContext.AlphaMod` (`src/AcDream.App/UI/UiRenderContext.cs:55`) has
zero consumers** — a repo-wide grep over `src/` and `tests/` returns only its
own declaration plus one `PushAlphaAbsolute` call in `UiMenu.cs:293`. No sprite,
rect or text draw ever multiplies by it. So `Opacity = 0.75f` at
`RetailUiRuntime.cs:729` changes nothing, and the only translucency the chat
window has is the two hard-coded `(0,0,0,0.35)` background tints at
`ChatWindowController.cs:228` and `:237` plus whatever alpha is baked into the
DAT chrome textures. That is the user's report (3). Retail applies ONE alpha to
the whole composited window surface including text (§3).
**G4 — No multi-window support of any kind.**
`WindowNames` has a single `Chat` entry; `RetailUiRuntime.MountChat()` mounts
exactly one window; there is no window-id concept, no `0x1000007E` attribute
read, no filter state, no `0x10000522``0x10000525` binding, and no
`ToggleFloatingChatWindow1..4` input action. Grep for `ToggleFloatingChat` or
`WindowId` under `src/AcDream.App/UI` returns nothing.
**G5 — No per-window filter state.** The 64-bit `m_llTextTypeFilter` model and
the `PostInit` seeded defaults are decoded (colour-table doc §4) but unbuilt.
`ChatWindowController` renders `vm.RecentLinesDetailed()` unfiltered
(`ChatWindowController.cs:477`).
**G6 — No gameplay-options wire.** `PlayerDescriptionParser.cs:433-443` slices
the inbound `GameplayOptions` blob **heuristically** (`TryHeuristicInventoryStart`)
and never parses it; `SocialActions.cs:43-49` records that the outbound
`SetCharacterOptions (0x01A1)` full-blob builder was **deleted** in CH3 as
malformed and callerless. So there is currently no way to read or write
`0x1000008B`/`0x1000008C`.
**G7 — Local persistence has no opacity or window-id dimension.**
`RetailWindowLayoutPersistence.Capture` (`src/AcDream.App/UI/RetailWindowLayoutPersistence.cs:166-178`)
stores X/Y/W/H/Visible/Collapsed/Maximized only. It would persist four chat
windows correctly the moment they are registered under distinct `WindowNames`,
but it carries no opacity field and no filter field.
---
## 6. Recommended CH6 port shape
The single most valuable move is **G1**: import the layouts retail actually
uses. `0x2100006F` brings the eight resize grips, the 1/2/3/4 indicator buttons
and a coherent 410 × 100 root; `0x2100005B` is the floaty template. That
retires the hand-cropping, the dropped resize bar and the 9px patch in one
change, and it turns G2 from "add a resize model" into "import the one retail
authored".
### 6.1 Layering (matches the J4.1 communication-state pattern)
**Runtime — `RuntimeCommunicationState` extension** (it already owns the
transcript, reply/retell targets, rooms, friends and squelch, per
`docs/research/2026-07-26-slice-j4-1-communication-state.md`). Add a
presentation-free `ChatWindows` child owning, for ids 0..4:
- `ulong TextTypeFilter` per window, seeded by retail's `PostInit` switch
- `bool Open` per window (id 0 always open)
- `float DefaultOpacity` / `float ActiveOpacity`**global**, not per-window,
with retail's `active >= default` coupling enforced in the setter pair
(`SetDefaultOpacity`/`SetActiveOpacity` semantics, §3)
- the routing predicate: display iff `windowId == m_eWindowID` **or**
(`windowId == 0` && `TypeIsActive(type)`) — the rule already decoded in the
colour-table doc §4, currently living nowhere.
Runtime owns this because no-window headless hosts already consume chat and
must not depend on presentation, and because the filter decides *routing*, not
appearance.
**Presentation — one `ChatWindowController` INSTANCE PER WINDOW.** The class is
already instance-based with no statics; give it a `WindowId` and a
`layoutId`/root-id pair so the same type binds both `0x2100006F` (main: adds
talk-focus menu, max/min, four indicator buttons) and `0x2100005B` (floaty:
adds title bar + close button). Register the four floaties as
`WindowNames.ChatWindow1..4` so `RetailWindowLayoutPersistence` picks them up
for free (G7).
**Input.** Four new actions `ToggleFloatingChatWindow1..4` in
`AcDream.UI.Abstractions/Input/`, defaulted to retail's modifier+digit chords
(§1.3), dispatched through the existing `InputDispatcher`. The four indicator
buttons call the same command; their pressed/normal state mirrors window
visibility exactly as `gmMainChatUI::RecvNotice_SetPanelVisibility` does.
**Settings.** Opacity becomes two `SettingsStore` floats plus two linked
sliders, matching `gmChatOptionsUI::InitOptions`'s `DualHash` linkage. Defer the
`0x1000008B`/`0x1000008C` wire (G6) — local persistence first, and file a
follow-up issue for the blob, because a malformed `0x01A1` is what CH3 already
had to delete once.
### 6.2 Slice sizing
| work | size | notes |
|---|---|---|
| **CH6a** — re-import main chat from `0x2100006F`; import + honour the eight Resizebar grips (element type 9, props `0x2A``0x2D`) in `LayoutImporter`; retire the 490px crop, the dropped resize bar and the 9px patch | **one slice**, but the biggest one | Fixes user reports (2) and (3)-artifacts. Visual gate required. |
| **CH6b** — make `Opacity` real: consume `UiRenderContext.AlphaMod` in every sprite/rect/text draw; delete the two hard-coded `(0,0,0,0.35)` tints | **one slice** | Fixes report (3)-transparency. Touches every widget draw path, so it wants its own slice and its own screenshot gate. Note the divergence: retail fades text too (§3); acdream's current comment at `UiRenderContext.cs:48-50` asserts the opposite as a deliberate choice — that row needs a decision and a register entry either way. |
| **CH6c** — Runtime `ChatWindows` state (filters + open flags + routing predicate) with no UI | **one slice** | Pure Runtime + tests, no visual gate. |
| **CH6d** — four floaty windows from `0x2100005B`, per-window controllers, toggle actions, indicator-button mirroring, close button, per-window local persistence | **one slice** | Depends on CH6a + CH6c. Fixes report (1). |
| **CH6e** — opacity settings UI + the two linked sliders | **small, deferrable** | |
| **CH6f**`0x1000008B`/`0x1000008C` gameplay-options packing over `0x01A1` | **separate, later** | Do not bundle. CH3's deleted builder is the cautionary precedent. |
CH6a and CH6b both touch the render/import path and must not be parallelised
(`feedback_dont_parallelize_coupled_plan_slices`). CH6c is independent and can
run alongside either.
### 6.3 Divergence-register rows this work implies
- **Retires** any row asserting "chat window is not resizable from the top" once
CH6a lands.
- **New row** if CH6b keeps text out of the window alpha (retail's
`ChatInterface::SetOpacity @0x004F3120` fades the whole surface).
- **New row** for local-only chat-window persistence until CH6f, since retail
stores this server-side in `GameplayOptions`.
---
## Appendix — quick id reference
| id | meaning |
|---|---|
| `0x1000007E` | LayoutDesc attribute: chat window id (`m_eWindowID`) |
| `0x1000007F` | per-window text-type filter (bitfield64) |
| `0x10000080` | global default (unfocused) opacity (float) |
| `0x10000081` | global active (focused) opacity (float) |
| `0x10000086` / `0x10000087` | per-window X / Y |
| `0x10000088` / `0x10000089` | per-window width / height |
| `0x1000008A` | per-window visible flag |
| `0x1000008B` | per-window option-bag element name |
| `0x1000008C` | the per-window option ARRAY (global gameplay option) |
| `0x1000008D` | per-window title (StringInfo) |
| `0x2A` / `0x2B` / `0x2C` / `0x2D` | Resizebar bottom / left / right / top bools |
| `0x24` | LayoutDesc property: input action that toggles this element |
| `0x3C` / `0x3D` / `0x3E` / `0x3F` | max height / max width / min height / min width |
| `0x10000040` | element class `gmFloatyChatUI` |
| `0x10000041` | element class `gmMainChatUI` |
| `0x10000050` | element class `gmFloatyMainChatUI` |
| `9` | element class `UIElement_Resizebar` |
| `0x2100006F` | LayoutDesc — main chat window |
| `0x2100005B` | LayoutDesc — floating chat window (all four) |
| `0x10000601` | main chat window element (`<CHAT>`) |
| `0x10000505` / `0x1000050E` / `0x1000050F` / `0x10000510` | floating chat windows 14 (`<FCH1>``<FCH4>`) |
| `0x10000522``0x10000525` | main-window indicator buttons for windows 14 |
| `0x1000052A` | floaty-window close button |
| `0x100004D9` | floaty-window title bar |
| `0x1000046F` | main-window max/min button |