From 81f53bfd46c85f11889e4b69e2d3491666ff6b46 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 10 Aug 2026 07:48:38 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20CH6=20research=20landed=20=E2=80=94=20c?= =?UTF-8?q?hat-window=20shell=20plan=20(CH6a/b/c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrong-LayoutDesc root cause (0x21000006 vs retail 0x2100006F), resident floating windows toggled by keybind, 8 authored resize grips, global opacity options 0x10000080/81. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-08-09-chat-parity-campaign.md | 33 +- .../2026-08-09-chat-retail-window-shell.md | 716 ++++++++++++++++++ 2 files changed, 746 insertions(+), 3 deletions(-) create mode 100644 docs/research/2026-08-09-chat-retail-window-shell.md diff --git a/docs/plans/2026-08-09-chat-parity-campaign.md b/docs/plans/2026-08-09-chat-parity-campaign.md index facd609d..8344310c 100644 --- a/docs/plans/2026-08-09-chat-parity-campaign.md +++ b/docs/plans/2026-08-09-chat-parity-campaign.md @@ -107,9 +107,36 @@ implementer per slice against a pinned contract (per - **CH5 — closeout.** Register sweep, ledger flip, ISSUES updates, in-client test script for the user gate. - **CH6 — chat-window shell parity (filed 2026-08-09 at user gate round - 1).** Retail multi-window chat, per-window type filters + PostInit - defaults (see color research §4), all-corner resize, opacity; research - first. + 1; research complete: + `docs/research/2026-08-09-chat-retail-window-shell.md`).** Three + sub-slices: + - **CH6a — correct main-window import + resize.** Swap the wrong + LayoutDesc `0x21000006` for retail's `0x2100006F` and delete the + downstream compensations (hand-cropped 490px ContentWidth, dropped + 800px resize bar, the 9px transcript patch, orphan-sibling pruning); + teach `LayoutImporter` element type 9 (`UIElement_Resizebar`, 8 + authored grips: 4 edges + 4 corners via bools 0x2A–0x2D); fix the + resize mask that excludes Top. Expected to also clear the round-1 + artifact report. + - **CH6b — floating windows 1–4.** Mount `0x2100005B` ×4 as + always-resident children per `gmGamePlayUI::SetupChildren + @0x004E9EC0` (ids 0x10000505/0x1000050E/0x1000050F/0x10000510); + keybind actions `ToggleFloatingChatWindow1..4` (retail defaults at + `retail-default.keymap.txt:150-153`) through the dispatcher; the + main window's 1/2/3/4 buttons are one-directional state mirrors + (`gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80`), not + the toggle; per-window PostInit filter defaults (color research §4) + with the `windowId == m_eWindowID OR (windowId==0 && TypeIsActive)` + display rule. + - **CH6c — opacity.** Implement `UiRenderContext.AlphaMod` + consumption (whole-composited-window alpha per + `ChatInterface::SetOpacity @0x004F3120`); the two GLOBAL retail + options `0x10000080` (unfocused) / `0x10000081` (focused, + active >= default); retail defaults now, the user-facing Settings + slider in the settings track. Persistence: local settings first — + the retail per-window option array `0x1000008C` is stored by ACE as + an opaque byte[] it never parses, so the wire format is its own + deferred slice. ## Gates diff --git a/docs/research/2026-08-09-chat-retail-window-shell.md b/docs/research/2026-08-09-chat-retail-window-shell.md new file mode 100644 index 00000000..feb98df8 --- /dev/null +++ b/docs/research/2026-08-09-chat-retail-window-shell.md @@ -0,0 +1,716 @@ +# 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 shift-digit quickslot rows). +So retail's default is **Alt+1 … Alt+4**, not bare 1–4. +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`. + +UNVERIFIED (and the one place I would not guess): whether clicking those four +buttons does anything in retail at all. Two readings are consistent with the +decomp — (a) they are pure indicators, and (b) they carry the same LayoutDesc +property `0x24` input-action value as the windows themselves, so a click routes +through the generic `UIElement` action path rather than through any chat code. +(b) is the more likely reading given §1.3's generic mechanism. Cheapest +resolution: the same LayoutDesc property dump as above, reading property `0x24` +on `0x10000522`–`0x10000525`. **For CH6 it is safe to wire both: keybind AND +button click both call the same toggle**, because retail's observable behaviour +(button lights up iff window is visible) is satisfied either way. + +### 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. + +### 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. + +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: + +``` + 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 — 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 (``) | +| `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 |