# 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 1–4 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 CTRL-digit quickslot rows — corrected below; the original filing mislabeled `0x00000002` as "shift"). So retail's default is **Alt+1 … Alt+4**, not bare 1–4. RESOLVED at Campaign CH slice CH6b: `retail-default.keymap.txt`'s own `MetaKeys` legend (not a guess — the keymap file's literal index table) reads ``` MetaKeys [ 1 [ 0 DIK_LSHIFT ] 2 [ 0 DIK_LCONTROL ] 2 [ 0 DIK_RCONTROL ] 3 [ 0 DIK_LMENU ] 3 [ 0 DIK_RALT ] 4 [ 0 DIK_LWIN ] 4 [ 0 DIK_RWIN ] ] ``` so the modifier-mask bit for MetaKeys index *N* is `1 << (N-1)`: **index 1 = Shift → `0x1`, index 2 = Ctrl → `0x2`, index 3 = Alt → `0x4`, index 4 = Win → `0x8`**. `0x00000004` on the `ToggleFloatingChatWindow1..4` rows is therefore unambiguously **Alt**, cross-checked against the same file's own Alt+A/D strafe and Alt+Enter/Tab/F4 rows (all `0x00000004`). `KeyBindings` already carried `ModifierMask.Alt` for these four actions since Phase K.1c — no code change was needed, only removing this hedge. 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; wording corrected at the CH6a/b REJECT-review (NIT 4) — the original "ONLY function in the whole 2013 binary that branches on `idMessage == 1`" superlative was false** (`gmFloatyChatUI::ListenToElementMessage @0x004CE330`, the floaty windows' close-button handler, also branches on `idMessage == 1`; §1.5). **The substantive claim stands: within `gmMainChatUI::ListenToElementMessage` specifically, there is no case for `0x10000522`-`0x10000525`.** The prior UNVERIFIED paragraph's hedge ("safe to wire both") is superseded by a direct read of `gmMainChatUI::ListenToElementMessage @0x004CDA80`. 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`. **This citation is still a true statement about `gmMainChatUI::ListenToElementMessage` — see the round-4 correction below for why it is the wrong function to have grepped.** --- **ROUND-4 CORRECTION (2026-08-10) — the user's own retail memory ("clicking opens/closes the window") overruled the CONCLUSION above, and re-attacking the question with that as the starting axiom (per CLAUDE.md: the user's retail memory is the axiom, not a hypothesis to be argued down) found the exact mechanism the CH6b pass missed.** The CH6b pass's mistake was scope, not accuracy: it proved `gmMainChatUI::ListenToElementMessage` has no click case for these ids, then reasoned "a button's click message… routes to its LISTENING PARENT" and stopped there. **That's wrong — `UIElement_Button` (Type 1, the class every one of these four indicators actually is) overrides its OWN click handling and never asks its parent window first:** ``` UIElement_Button::HandleButtonClick @0x00471E50 00471e65 if (UIElement::GetAttribute_Enum(this, 0x12, &actionId)) // reads its OWN property 00471e72 if (actionId != 1) 00471e95 build an InputEvent(actionId) 00471eb2 ICIDM::GetActionMap()… dispatch through the action map ``` Property `0x12` here is the SAME kind of "input action" enum as `0x24` — not the parent-window dispatch §1.4's original text assumed didn't apply to clicks. This IS the generic mechanism a click uses to reach `UIElementManager::DoVisibilityToggleAction @0x0045B660` (§1.3's own citation, previously assumed keybind-only) → `BroadcastElementMessage(target, 0x31, actionId, 0)` for every element registered under that action id via `RegisterElementForInputAction` (§1.3's property-`0x24` registration — confirmed as the ONLY call site of `RegisterElementForInputAction` in the whole binary) → the RECEIVING element's generic base-class handler: ``` UIElement::ListenToElementMessage @0x00462340 00462447 case 8: // idMessage - 0x29 == 8, i.e. raw idMessage 0x31 0046244f GetAttribute_Enum(this, 0x58, &mode) // reads the RECEIVER's OWN property 00462459 if (mode == 1) SetVisible(!currentlyVisible) // toggle 0046245c else if (mode == 2) SetVisible(1) // force-show 0046245f else if (mode == 3) SetVisible(0) // force-hide ``` Neither `gmMainChatUI`, `gmFloatyChatUI`, nor `ChatInterface` overrides `ListenToElementMessage` for raw idMessage `0x31` (confirmed by reading all three switches directly — none has a case landing on it), so EVERY window falls through to this base-class handler unconditionally. This is a complete, generic, working "click toggles a registered listener's visibility" system — exactly the "authored button behavior" this reconciliation task hypothesized — and it is NOT gated on keyboard input the way the original §1.3 text assumed; `UIElementManager::KeyPressEvent`'s call to `DoVisibilityToggleAction` is simply ONE caller among the several that can reach it (the button's own `HandleButtonClick` is another). **What the authored DATA shows, checked directly against the committed fixtures:** - `chat_2100006f.json` — all four indicator elements (`0x10000522`-`0x10000525`) DO author an Enum-kind property `0x12` (confirmed by `Kind: 0` = Enum in the fixture's own property dump, matching `LayoutImporter.ConvertProperty`'s `EnumBaseProperty → UiPropertyKind.Enum` mapping exactly), with values `0x10000114`-`0x10000117` in element-id order (re-verified directly against the committed fixture's own `UnsignedValue` fields — an earlier pass here misread these as `0x10000514`-`0x10000517`). This is a real, present, correctly-typed action id — the button-click half of the generic mechanism is genuinely armed. - `chat_floaty_2100005b.json` — the floating chat window's own fixture authors **NO Enum-kind property `0x24` anywhere** (its only hit on property number 36 decimal is `Kind: 4` = Integer, an unrelated attribute — not the registration property) and **no property `0x58` at all**. Since `RegisterElementForInputAction` has exactly one call site in the entire binary (the property-`0x24` handler in `UIElement::Initialize`; no class anywhere calls it directly from code), nothing in the shipped floating chat window LayoutDesc ever registers it as a listener for ANY action id. `DoVisibilityToggleAction` would look up the button's action id, find zero registered listeners, and silently return — the click would fire a real message with no receiver. - The four action-id VALUES (`0x10000114`-`0x10000117`) are also not chat-specific: the first two, `0x10000114` and `0x10000115`, are `m_prevButton`/`m_nextButton` child-element ids for an UNRELATED pagination widget elsewhere in the decomp (`UIElement::GetChildRecursive(this, 0x10000115)` / `(this, 0x10000114)`, `acclient_2013_pseudo_c.txt:194343-194344`, confirmed by direct read) — a coincidence of Turbine's global per-dat-file asset-id allocator (ids are assigned client-wide, not scoped per panel), not a cross-reference. (**Correction:** an earlier pass here misread the fixture's Enum values as `0x10000514`-`0x10000517` and built a since-retracted claim that they matched `gmFriendsUI::PostInit`'s own Add/Remove/Tell child ids — that match does not exist at the correct `0x1000011x` values and `gmFriendsUI` is not part of this finding.) **Conclusion: the generic UI action system is real, it exists, and the buttons genuinely arm their half of it — but the authored DATA available to us (both committed fixtures, generated from the installed DAT) does not wire a target for it.** This is consistent with, not a refutation of, the original CH6b grep of `gmMainChatUI::ListenToElementMessage` — that citation was looking in the wrong function, but its NEGATIVE RESULT (retail's window-level message handlers never claim these clicks) still holds; the generic mechanism, if it does connect the dots in real retail, does so entirely below the level either grep could see. **Per CLAUDE.md, the user's retail memory is the axiom regardless: acdream now wires each indicator's click to toggle its floating window through the SAME `ToggleFloatingChatWindow` chokepoint the `Alt+1..4` keybinds use (`ChatWindowController.BindIndicatorClicks`, called by `RetailUiRuntime` right after mounting the main chat window) — explicitly as USER-DIRECTED retail behavior, not a claim that the generic-action-system data path has been proven end-to-end.** `SetIndicatorOpen` stays the ONLY writer of the indicator's `Selected` mirror (`UiButton.SuppressSelfToggle` stays `true`); the click drives the real toggle, and the mirror reports the outcome back — so the visual stays consistent through the round trip even though the write is now two-way at the FEATURE level. --- acdream ports this exactly: the four indicator buttons (`ChatWindowController._indicatorButtons`) now carry a real `OnClick` (`ChatWindowController.BindIndicatorClicks`, round 4); the mirror half is unchanged — `ChatWindowController.SetIndicatorOpen` is still the ONLY writer of `Selected`, still called from `RetailUiRuntime.OnWindowVisibilityChanged` in response to the floating window's own visibility changing, regardless of what triggered it (click, keybind, or a restored layout). ### 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 ``), 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 1–4 — 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::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. **RESOLVED at Campaign CH slice CH6c (2026-08-10) by static decomp, not cdb — the values are constructor-literal, so no live attach was needed.** Retail's shipped defaults are PER WINDOW CLASS, not one constant: ``` 004f4550 ChatInterface::ChatInterface(this, arg2, arg3) // BASE ctor 004f459f this->m_fDefaultOpacity = 0.5f; 004f45a5 this->m_fCurrentOpacity = 0.5f; 004f45ab this->m_fActiveOpacity = 1f; 004cd0f0 gmMainChatUI::gmMainChatUI(this, arg2, arg3) // derived, calls base first 004cd0ff ChatInterface::ChatInterface(this, arg2, arg3); 004cd148 this->m_fDefaultOpacity = 1f; // OVERRIDES base 004cd14e this->m_fCurrentOpacity = 1f; // m_fActiveOpacity left at base's 1f 004ce2c0 gmFloatyChatUI::Create(arg1, arg2) // the 4 floating windows 004ce2e0 ChatInterface::ChatInterface(eax, arg1, arg2); // NO override — keeps base 0.5/1.0 ``` `gmFloatyMainChatUI` (element class `0x10000050`, the concrete class actually instantiated for the retail main chat window — its `DynamicCast` accepts both `0x10000050` and `0x10000041`) calls `gmMainChatUI::gmMainChatUI` as its own base constructor (`0x004D22B0`) and adds no opacity override of its own, so it inherits `gmMainChatUI`'s 1.0/1.0. **So: the main chat window is ALWAYS FULLY OPAQUE in both states (Default=1.0, Active=1.0) unless a saved `GameplayOptions` value overrides it via `UpdateFromPlayerModule`; the four floating windows default to Default=0.5/Active=1.0 (translucent when idle, opaque once the chat entry has focus).** These are IN-MEMORY CONSTRUCTED starting values for each window INSTANCE's fields — they get overwritten the moment `UpdateFromPlayerModule` successfully reads a persisted `0x10000080`/`0x10000081` value from `PlayerModule::InqOption` (the SAME global option for every window instance), which is why the two options are still correctly described as GLOBAL rather than per-window: only a NEVER-SAVED option (a fresh character, nothing in the `GameplayOptions` blob yet) lets the per-class constructed defaults show through, and even then only until the user's first slider drag pushes one shared value into every live window via `RecvNotice_GameplayOptionChanged`. **CH6c review-fix round (2026-08-10): the shared default above was WRONG.** Shipping the base `ChatInterface` value (0.5/1.0) as ONE shared global default, combined with the scope extension to every registered window, faded the WHOLE registered UI (radar, vitals, toolbar, main chat, ...) to 50% opacity out of the box — including several windows that can never take keyboard focus at all, so they were stuck at 0.5 permanently. acdream now ships `gmMainChatUI`'s per-class 1.0/1.0 override (`0x004CD0F0`) as the shared global default instead (register row AP-190 in `docs/architecture/retail-divergence-register.md`), which is retail-identical for the 11 non-chat windows and the main chat window and leaves only the four floating chat windows diverging from retail's 0.5-while-idle fade — a default-VALUE divergence the transparency slider still fully covers. The linking invariant (active >= default, restored by dragging the OTHER value — verified from `SetDefaultOpacity`/`SetActiveOpacity`'s own bodies, matching the summary already recorded above) is ported exactly regardless of which default seeds it. ### 3.1 Two more residuals found at the CH6c review (not yet ported) Both are decomp-verified and both are recorded as new AP-190 clauses; neither is implemented this round. **(a) Retail eases opacity between endpoints; acdream snaps.** `ChatInterface::ListenToGlobalMessage @0x004F3840` is the handler for global message id `3`, armed (`UIListener::RegisterForGlobalMessage(this, 3)`) from the element-focus messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E` inside the window's `ListenToElementMessage` switch at `0x004F5275`. Once armed, every tick nudges the live opacity toward whichever endpoint `IsTextEntryFocused` currently selects by 5% of the endpoint delta (`fabsl(target - current) * 0.05f`), and unregisters from the global message once the value lands within FP-epsilon of the target. acdream's `RetailWindowOpacityController.Apply` sets the target opacity directly on the focus-change event — the START and END states are retail-exact, but the transition is an instant snap instead of a roughly 20-tick fade. Porting the lerp needs a UI frame-tick hook `RetailWindowOpacityController` does not have today (it only reacts to `DescendantFocusChanged`); deferred. **(b) Retail's focus predicate is the chat entry field specifically; acdream's is any focusable descendant.** `ChatInterface::IsTextEntryFocused @0x004F30A0` tests `GetFocusDescendant(rootElement) == this->m_chatEntry` — literally the window's text-entry element, not "some descendant of this window has focus." acdream's `RetailWindowHandle.DescendantFocusChanged` (the event `RetailWindowOpacityController` subscribes to) fires whenever ANY focusable descendant of the window gains focus. For a window with exactly one focusable child the two predicates coincide; for a window with several (a settings panel's multiple controls, for example) acdream's broader predicate holds ActiveOpacity while retail would already have faded back to DefaultOpacity once focus left the specific text-entry widget. **Pre-existing, unrelated: `UiMenu.cs:293`'s opacity bypass.** Popup menus call `ctx.PushAlphaAbsolute(1f)` before drawing so a menu always reads solid even when it is opened from a translucent (faded) window — this is a deliberate acdream-only presentation choice (menus must stay legible regardless of the host window's current fade state), not a divergence from either of the two opacity mechanisms documented in this section, and it predates the CH6c slice. --- ## 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: ``` X:%d Y: %d W: %d H: %d // 0x1000049A X:%d Y: %d W: %d H: %d // 0x10000601 ← main chat X:%d Y: %d W: %d H: %d // 0x10000505 … // 0x1000050E … // 0x1000050F … // 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. CLOSED at Campaign CH slice CH6c (2026-08-10).** `UiRenderContext.ApplyAlpha` already gated `DrawRect`/ `DrawFill`/`DrawSprite` before this slice (added back at `1da697ec`, well before CH6 — the "zero consumers" framing below described the PUBLIC `AlphaMod` property specifically, not the private `_alpha`/`ApplyAlpha` pair those three draws already used); the actual gap was narrower than originally scoped: (a) `DrawStringDat`/`DrawString` still passed `applyAlpha: false`, so TEXT stayed sharp over a translucent window against retail's whole-surface `SetOpacity` semantics — CH6c fixed both; (b) nothing ever SET a window's `Opacity` below its 1f default, since `RetailUiRuntime.MountChat` deliberately left it at 1f pending this slice. CH6c added `RetailWindowOpacityController` (`src/AcDream.App/UI/RetailWindowOpacityController.cs`), which drives every `RetailWindowManager`-registered window's live `Opacity` from keyboard-focus state and the two retail-linked Default/Active floats, now exposed as a Settings → Chat tab transparency slider pair (`SettingsPanel.RenderChatTab`). See the verified defaults + linking behavior above (§3) and register row AP-190. The original paragraph below is kept verbatim as the historical record of what CH6a/b actually shipped — do not re-run this investigation. `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. - **CLOSED at CH6c**: text now respects window alpha — `DrawStringDat`/ `DrawString` route through `ApplyAlpha` exactly like `DrawSprite`/`DrawRect`/ `DrawFill`, matching `ChatInterface::SetOpacity`'s whole-surface fade. No divergence row needed for this part. - **New row AP-190** (CH6c): acdream applies the two opacity options to EVERY `RetailWindowManager` window (chat + floaties + vitals + toolbar + everything else), where retail's mechanism only ever runs from `ChatInterface`-derived windows; and ships ONE shared default (the base `ChatInterface` ctor's 0.5/1.0) rather than `gmMainChatUI`'s per-class 1.0/1.0 override for the main window specifically. - **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 (``) | | `0x10000505` / `0x1000050E` / `0x1000050F` / `0x10000510` | floating chat windows 1–4 (``–``) | | `0x10000522`–`0x10000525` | main-window indicator buttons for windows 1–4 | | `0x1000052A` | floaty-window close button | | `0x100004D9` | floaty-window title bar | | `0x1000046F` | main-window max/min button |