acdream/docs/research/2026-08-09-chat-retail-window-shell.md
Erik aa6635aebf fix(ui): round-5 review polish — S1 block outline pass, S2 non-UiText outline paths, S3 citation fix
Collects the post-gate polish left uncommitted by the killed round-5 agent
(S1/S3 + review fixes N1/N3/N4) and completes the missing S2 half:

- S1: UiText multi-line transcript + colored-run label now submit EVERY
  line/run's outline pass before ANY fill pass, matching retail's
  UIElement_Text::DrawSelf @0x00467aa0 whole-block walk. DrawStringDatPass
  is exposed for block-level batching; single lines keep DrawStringDat.
- S2 (completed this commit): authored outline 0x21/0x22 now reaches every
  text-bearing widget — UiButton, UiDatElement, UiField, UiMeter, UiMenu,
  UiCatalogSlot — seeded from the element's effective-default state exactly
  like UiText (BuildButton lifts the label-bearing Text child's authored
  value first, same chain as the label color). Per-STATE outline switching
  (dialog/character/combat buttons author 0x21 in state 0x3 only) is NOT
  ported — filed as register row AP-192 in this commit.
- S3: ChatWindowController reconciliation comment corrects the misread
  indicator action ids 0x10000514-17 -> 0x10000114-17 and re-attributes the
  id-coincidence to the pagination widget's m_prevButton/m_nextButton, not
  gmFriendsUI; register + window-shell research doc corrected to match.
- N1: LayoutImporter's duplicate per-state any-state-first-wins 0x21 read is
  deleted — ElementReader.ApplyCanonicalLegacyProjection's DirectState-then-
  effective-default resolution is the single source (the duplicate would have
  lit state-0x3-only outlines permanently once S2 widened consumption).
- N3: the outline pass tints with the outline color's OWN alpha, not the
  fill's (retail tints m_curOutlineColor and m_curTextColor independently).
- N4: the outline-inflated glyph SOURCE rect is clamped to the atlas bounds
  with matching dest shrink, porting CreateCharRectPair @0x00441480's edge
  behavior — edge glyphs crop instead of sampling a neighbour's texels.

Full Release suite: 12,610 passed / 4 skipped / 0 failed.

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

1009 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Campaign CH slice CH6 — retail chat-window SHELL research
**Date:** 2026-08-09
**Scope:** RESEARCH ONLY. How retail Asheron's Call constructs, shows, sizes,
persists and fades the chat windows — the *shell*, not the text pipeline.
The per-window text FILTER model is already decoded in
[`2026-08-09-chat-retail-color-table.md`](2026-08-09-chat-retail-color-table.md)
§4 and is not re-derived here.
**Primary sources**
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build,
Binary Ninja pseudo-C, PDB-named)
- `docs/research/named-retail/acclient.h` (verbatim retail struct/enum defs)
- `docs/research/named-retail/symbols.json`
- `docs/research/named-retail/retail-default.keymap.txt`
- `docs/research/2026-06-25-retail-ui-layout-dump.json` — **an existing acdream
dump of the retail gameplay-UI top-level windows.** It carries the LayoutDesc
id, element ids, widget kinds and authored rects for every window below, and
is the geometric oracle used throughout §2.
- `references/ACE/Source/...` (server cross-check)
**Binary-Ninja field-name caveat (applies throughout).** BN's struct-field
attribution in `gm*ChatUI::PostInit` is shifted by one slot relative to the true
member order (the classic artifact class in
`claude-memory/feedback_bn_decomp_field_names.md`). Every id↔role binding in §2
was therefore re-derived from the **authored rectangles in the layout dump**,
which agree with the decomp's *call order* exactly once the one-slot shift is
undone. Where a claim rests on BN naming alone it is marked UNVERIFIED.
---
## 1. Window lifecycle — how windows 14 get created and shown
### 1.1 They are not created on demand. They are authored, always-resident, and toggled.
`gmGamePlayUI::SetupChildren @0x004E9EC0` builds the whole gameplay UI once:
```
004e9ed5 m_pGameplayUI = CreateAndAddRootElement(0x10000006, 0x10000495)
004e9f40 var_b0 = 0x10000505 // floating chat window 1
004e9f48 var_ac = 0x1000050E // floating chat window 2
004e9f50 var_a8 = 0x1000050F // floating chat window 3
004e9f58 var_a4 = 0x10000510 // floating chat window 4
004e9f6a hash("ID_Chat_Chat1_DefaultTitle") … "ID_Chat_Chat4_DefaultTitle"
004e9ff5 for i in 0..3:
004e9faf StringInfo::SetStringIDandTableEnum(&title, ids[i], 0x10000001)
004e9fbf el = UIElement::GetChildRecursive(m_pGameplayUI, windowIds[i])
004e9fd1 chat = el->DynamicCast(0x10000040) // gmFloatyChatUI
004e9fe9 chat->vtable[0x2AC](&title) // SetWindowTitle
```
So all four extra chat windows exist as **authored children of the gameplay-UI
root from the moment the game UI is built**. There is no window-manager `Create`
call, no per-window allocation at toggle time, and no floating-window registry.
Opening one is `SetVisible(true)`; closing one is `SetVisible(false)`.
Confirming class identity: `gmFloatyChatUI::GetUIElementType @0x004CE2B0` returns
`0x10000040`, and `gmFloatyChatUI::Register @0x004CE3D0` registers that element
class with the LayoutDesc element factory. `gmFloatyChatUI` derives from
`ChatInterface` and adds **no members at all** (`acclient.h:55144`), which is why
`m_eWindowID` (the first `ChatInterface` field, `acclient.h:54900`) is the only
per-window identity it carries.
### 1.2 Window identity comes from a LayoutDesc attribute, not from code
`ChatInterface::PostInit @0x004F3DD0`:
```
004f3de8 UIElement::GetAttribute_Enum(this, 0x1000007E, &this->m_eWindowID)
004f3df9 switch (m_eWindowID - 1) { … seed m_llTextTypeFilter defaults … }
```
**Attribute `0x1000007E` on the authored element IS the window id.** The main
chat window is id `0`; the floaties are `1..4` (the `PostInit` switch also has
arms for 5 and 8 — see the colour-table doc §4 for the seeded filter values).
### 1.3 The toggle is a KEYBIND, not a button
`docs/research/named-retail/retail-default.keymap.txt:150-153`, inside the
`ToggleWindows` action group:
```
ToggleFloatingChatWindow1 [ "" [ 0 DIK_1 ] 0x00000004 ]
ToggleFloatingChatWindow2 [ "" [ 0 DIK_2 ] 0x00000004 ]
ToggleFloatingChatWindow3 [ "" [ 0 DIK_3 ] 0x00000004 ]
ToggleFloatingChatWindow4 [ "" [ 0 DIK_4 ] 0x00000004 ]
```
`0x00000004` is the modifier mask (the same mask the `UseQuickSlot_14..18` rows
use for `Alt`+digit, versus `0x00000002` for the CTRL-digit quickslot rows —
corrected below; the original filing mislabeled `0x00000002` as "shift").
So retail's default is **Alt+1 … Alt+4**, not bare 14.
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 `<CHAT>`), layout root element
`0x10000600`, **authored extent 410 × 100**.
Full authored tree (from `2026-06-25-retail-ui-layout-dump.json`; rects are
absolute in the dump's 640×480 space, i.e. root at 0,0):
| element | rect (x,y,w,h) | role |
|---|---|---|
| `0x10000600` | 0,0,410,100 | window root (Group) |
| `0x10000693` | 0,0,5,5 | TL corner — **Locked twin** |
| `0x10000694` | 5,0,400,5 | top edge — Locked twin |
| `0x10000695` | 405,0,5,5 | TR corner — Locked twin |
| `0x10000696` | 0,5,5,90 | left edge — Locked twin |
| `0x10000697` | 0,95,5,5 | BL corner — Locked twin |
| `0x10000698` | 5,95,400,5 | bottom edge — Locked twin |
| `0x10000699` | 405,95,5,5 | BR corner — Locked twin |
| `0x1000069A` | 405,5,5,90 | right edge — Locked twin |
| `0x1000069B` | 0,0,5,5 | **TL corner — live Resizebar** |
| `0x1000069C` | 5,0,400,5 | **top edge — live Resizebar** |
| `0x1000069D` | 405,0,5,5 | **TR corner — live Resizebar** |
| `0x1000069E` | 0,5,5,90 | **left edge — live Resizebar** |
| `0x1000069F` | 0,95,5,5 | **BL corner — live Resizebar** |
| `0x100006A0` | 5,95,400,5 | **bottom edge — live Resizebar** |
| `0x100006A1` | 405,95,5,5 | **BR corner — live Resizebar** |
| `0x100006A2` | 405,5,5,90 | **right edge — live Resizebar** |
| `0x10000010` | 5,5,400,73 | transcript panel (Sprite) |
| `0x10000011` | 21,5,368,73 | transcript text (Group) |
| `0x1000048C` | 21,62,16,16 | new-unseen-text indicator (Button) |
| `0x10000012` | 389,5,16,73 | scrollbar column |
| `0x10000364/65/66`, `0x10000071`, `0x10000072` | — | scrollbar thumb pieces + up/down buttons |
| `0x1000046F` | 368,5,16,16 | max/min toggle (Button) |
| `0x10000522` | 5,5,16,16 | **chat-window-1 indicator (Button)** |
| `0x10000523` | 5,22,16,16 | **chat-window-2 indicator** |
| `0x10000524` | 5,39,16,16 | **chat-window-3 indicator** |
| `0x10000525` | 5,56,16,16 | **chat-window-4 indicator** |
| `0x10000013` | 5,78,400,17 | input row (Sprite) |
| `0x10000014` | 5,78,46,17 | talk-focus menu button |
| `0x10000015` | 5,78,46,17 | talk-focus menu group |
| `0x10000016` | 51,78,306,17 | **chat entry field** |
| `0x10000017` / `0x10000018` | 1px | entry field left/right rails |
| `0x10000019` | 359,78,46,17 | Send button |
Cross-checks that pin this layout to the code, independent of the dump:
- `gmMainChatUI::HandleMaximizeButton @0x004CCE50` looks up `0x1000046F`.
- `gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80` writes
`0x10000522``0x10000525`.
- `gmFloatyMainChatUI::PostInit @0x004D2670` binds exactly the 16 ids
`0x10000693``0x100006A2`, and casts eight of them via `DynamicCast(9)`
element type **9 is `UIElement_Resizebar`**
(`UIElement_Resizebar::Register @0x0046B920``RegisterElementClass(9, …)`).
**The 1/2/3/4 buttons are on the LEFT edge, stacked vertically** — not a tab row.
### 2.2 Floating chat windows 14 — LayoutDesc `0x2100005B`
All four windows instantiate **the same** LayoutDesc; only the window element id
and the `0x1000007E` window-id attribute differ. Layout root `0x100004F7`,
**authored extent 250 × 108**.
| element | rect (x,y,w,h) | role |
|---|---|---|
| `0x100004F7` | 0,80,250,108 | window root (Group) |
| `0x100004FC` | 0,80,5,5 | TL corner |
| `0x1000000F` | 5,80,240,5 | top edge |
| `0x100004FE` | 245,80,5,5 | TR corner |
| `0x100004D2` | 0,85,5,98 | left edge |
| `0x10000501` | 0,183,5,5 | BL corner |
| `0x100004D4` | 5,183,240,5 | bottom edge |
| `0x10000503` | 245,183,5,5 | BR corner |
| `0x100004D3` | 245,85,5,98 | right edge |
| `0x100004D9` | 5,85,240,16 | **title bar** |
| `0x10000528` | 5,100,240,5 | title/content divider |
| `0x10000529` | 5,85,240,20 | title-bar group (drag handle) |
| `0x1000052A` | 230,86,14,14 | **close button** |
| `0x10000010` | 5,105,224,60 | transcript panel |
| `0x10000011` | 5,105,224,60 | transcript text |
| `0x1000048C` | 5,169,16,16 | new-unseen-text indicator |
| `0x10000012` | 229,105,16,60 | scrollbar column (+ children) |
| `0x10000509` | 5,165,240,18 | input row |
| `0x10000016` | 5,165,202,18 | chat entry field |
| `0x1000052B`, `0x100002B5`, `0x10000209`, `0x1000020A`, `0x1000020B` | — | entry-field frame rails |
| `0x10000019` | 207,165,38,18 | Send button |
Independent code cross-check: `gmFloatyChatUI::SetWindowTitle @0x004CEAA0` does
`GetChildRecursive(this, 0x100004D9)` and `SetStringInfo` on it — exactly the
240×16 title strip above.
A floaty chat window has **no** talk-focus menu, **no** max/min button and **no**
1/2/3/4 indicators. It has a title bar (which the main window does not) and a
close button.
### 2.3 How retail encodes resizability — there are no "sizable edge" flags
Retail does **not** put a bitmask on the window. Each draggable edge and corner
is its own child element of type 9 (`UIElement_Resizebar`), and four authored
BOOL properties on that child say which border it drives:
`UIElement_Resizebar::StartMouseResizing @0x0046B7E0`
```
0046b7f8 GetAttribute_Bool(this, 0x2C, &bRight)
0046b806 GetAttribute_Bool(this, 0x2B, &bLeft)
0046b814 GetAttribute_Bool(this, 0x2D, &bTop)
0046b822 GetAttribute_Bool(this, 0x2A, &bBottom)
if (bRight) border = bTop ? BORDER_UR : (bBottom ? BORDER_LR : BORDER_RIGHT)
else if (bLeft) border = bTop ? BORDER_UL : (bBottom ? BORDER_LL : BORDER_LEFT)
else if (bTop) border = BORDER_TOP
else if (bBottom)border = BORDER_BOTTOM
0046b88e this->m_mousePressed = 1
0046b89f UIElement::StartResizing(parent, border, pt.x, pt.y)
0046b8a4 this->SetState(3) // "pressed" art
```
`BorderLocation` (`acclient.h:4327`):
`BORDER_NONE=0, BORDER_UL=1, BORDER_TOP=2, BORDER_UR=3, BORDER_RIGHT=4,
BORDER_LR=5, BORDER_BOTTOM=6, BORDER_LL=7, BORDER_LEFT=8`.
So the property map is **`0x2A` = bottom, `0x2B` = left, `0x2C` = right,
`0x2D` = top**, and a corner grip simply sets two of them.
Drag lifecycle (`UIElement_Resizebar::ListenToElementMessage @0x0046B930`):
message `0x1C` (mouse down, `dwParam1 == 7` = left button) → `StartMouseResizing`
+ parent's vtable+0x60; `0x1E` (mouse move) → parent's vtable+0x4C
(`MouseResizeElement @0x00461130`); `0x1D` (mouse up) → parent
vtable+0x64 + `StopMouseResizing``UIElement::StopResizing @0x0045FD60`.
**Both chat layouts author all eight grips.** Retail is resizable from every
edge and every corner, including the top and the top corners, on both the main
and the floating chat windows.
**CORRECTED 2026-08-10 at Campaign CH slice CH6a implementation.** This claim
is wrong for the main window's plain TOP EDGE specifically, established by a
direct `ElementDesc.Type` dump of the installed DAT (ground truth, not a
decomp reading) of the 8 ids `0x1000069B`-`0x100006A2`: 7 of them are
Type 9 (`UIElement_Resizebar`) as claimed, but `0x1000069C` — the straight
top-edge strip between the two top corners — is Type **2**
(`UIElement_Dragbar`), not Type 9. So retail's main chat window is
resizable from every edge and corner EXCEPT the plain top strip, which is a
MOVE handle instead (there is no title bar, so the top strip does double
duty as the drag affordance). The two top CORNERS (`0x1000069B` UL,
`0x1000069D` UR) are still genuine Resizebar grips carrying the top bool, so
dragging from a corner still resizes the Y/top axis — only the straight
edge in between does not. This is fully consistent with, and explains, the
`UIElement_Resizebar::StartMouseResizing` cursor media dump: `0x1000069C`
carries cursor `0x06006119` (the four-arrow MOVE cursor, matching
`WindowMove`), not one of the two diagonal or the vertical resize cursor
ids the seven true grips carry. Not independently re-verified for the
floating-window layout `0x2100005B` (CH6b's scope); assume it needs the
same direct-dump check rather than trusting this row for that layout too.
### 2.4 Min/max extents
Retail reads them as ordinary integer attributes on the window element:
`gmMainChatUI::HandleMaximizeButton @0x004CCE50` uses
`GetAttribute_Int(this, 0x3C, &maxH)` and `GetAttribute_Int(this, 0x3E, &minH)`.
acdream already consumes the same family — `RetailWindowFrame.ResolveConstraint`
(`src/AcDream.App/UI/Layout/RetailWindowFrame.cs:160-169`) maps
`0x3C`=maxH, `0x3D`=maxW, `0x3E`=minH, `0x3F`=minW. No change needed.
---
## 3. Opacity — the two values
Named constants in the binary's read-only data at `0x007A8D50`:
```
007a8d50 uint32_t const Option_TextType_Property = 0x1000007F
007a8d54 uint32_t const Option_DefaultOpacity_Property = 0x10000080
007a8d58 uint32_t const Option_ActiveOpacity_Property = 0x10000081
```
| id | name | type | scope | meaning |
|---|---|---|---|---|
| `0x10000080` | `Option_DefaultOpacity_Property` | float | **global** (`InqOption`, not per-window) | opacity when the window's text entry does NOT have focus |
| `0x10000081` | `Option_ActiveOpacity_Property` | float | **global** | opacity while the window's text entry HAS focus |
Read path (`gmFloatyChatUI::UpdateFromPlayerModule @0x004CE3F0`,
identical in `gmFloatyMainChatUI::UpdateFromPlayerModule @0x004D2970`):
```
004ce42b if (PlayerModule::InqOption(pm, 0x10000080, &prop) && prop->InqFloat(&v))
004ce445 ChatInterface::SetDefaultOpacity(this, v)
004ce465 if (PlayerModule::InqOption(pm, 0x10000081, &prop) && prop->InqFloat(&v))
004ce47f ChatInterface::SetActiveOpacity(this, v)
```
Live-update path (`gmFloatyMainChatUI::RecvNotice_GameplayOptionChanged
@0x004D25A0`) switches on the same two ids and forwards anything else to
`ChatInterface::RecvNotice_GameplayOptionChanged @0x004F30E0` (which handles the
`0x1000007F` filter).
They are also settable per-element from the LayoutDesc:
`ChatInterface::OnSetAttribute @0x004F3F60` accepts `0x10000080` / `0x10000081`
as element attributes and **falls back to `0x3F800000` (= 1.0f)** when the
property value cannot be read (`004f3fb2`, `004f3f84`). That 1.0f is the only
default the code itself carries.
The options UI wires them to two linked sliders — `gmChatOptionsUI::InitOptions
@0x0049FC60`:
```
0049fcc0 UIOption_Slider::SetGameplayOptionProperty(slider1, 0x10000080)
0049fd1a UIOption_Slider::SetGameplayOptionProperty(slider2, 0x10000081)
0049fd5c DualHash<UIOption_Slider*,UIOption_Slider*>::add(&m_hashSliderLinks, &slider1, &slider2)
```
The `DualHash` link is why the two sliders track each other in the retail
options panel, and it is mirrored in code:
`ChatInterface::SetDefaultOpacity @0x004F3BC0` calls `SetActiveOpacity` when
active < default, and `SetActiveOpacity @0x004F3C40` calls `SetDefaultOpacity`
when default > active. **Invariant: `activeOpacity >= defaultOpacity` always.**
Which one is applied right now (`SetDefaultOpacity @0x004F3BC0`,
`SetActiveOpacity @0x004F3C40`):
```
if (this window's root is the active element
&& GetFocusDescendant(activeElement) == m_chatEntry)
m_fCurrentOpacity = m_fActiveOpacity
else m_fCurrentOpacity = m_fDefaultOpacity
```
and application is one call on the window's own render surface —
`ChatInterface::SetOpacity @0x004F3120`:
```
004f3124 this->m_fCurrentOpacity = v
004f312a obj = m_object ?: UIRegion::GetObjectA(m_parent)
004f314b surface = obj->vtable[0x1C]() // get render surface
004f3156 surface->vtable[0x48](v) // set surface alpha
```
**Retail fades the whole composited window surface — chrome, backgrounds AND
text — with one alpha, not a per-widget background tint.** This is the shape the
future acdream user setting should take.
**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:
```
<SBOX> X:%d Y: %d W: %d H: %d // 0x1000049A
<CHAT> X:%d Y: %d W: %d H: %d // 0x10000601 ← main chat
<FCH1> X:%d Y: %d W: %d H: %d // 0x10000505
<FCH2> … // 0x1000050E
<FCH3> … // 0x1000050F
<FCH4> … // 0x10000510
```
`LoadScreenLayout` matches the 4-character tags back to element ids
(`004eaa91`, `004eaab8`, `004eaadf`, `004eab06`). This is retail's
"save/load UI layout" feature and is the `m_layoutFromFile` flag's source.
Notably it is the ONLY path that persists the **main** chat window's geometry.
### 4.4 The wire
`m_colGameplayOptions` is packed whole into the character-options blob.
Server side, ACE treats it as **opaque bytes**:
- outbound: `references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventPlayerDescription.cs:349-350, 393-394`
sets `CharacterOptionDataFlag.GameplayOptions` (`0x00000200`) and writes
`Character.GameplayOptions` verbatim.
- inbound: `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionSetCharacterOptions.cs:183-190`
*"This is the last message... So it should be all that is left"* — reads the
remaining bytes and calls `SetCharacterGameplayOptions(bytes)`.
- storage: `references/ACE/Source/ACE.Database/Models/Shard/Character.cs:49`
`public byte[] GameplayOptions`.
**ACE does not parse chat-window options at all.** It stores and echoes the
blob. Consequences for acdream:
- Anything acdream writes into that blob will round-trip through ACE unchanged.
- Nothing on the server validates it, so acdream owns the format entirely —
which means acdream must match retail's `PropertyCollection` packing exactly
if a retail client and acdream are ever to share a character.
- Until acdream can pack it, the cheapest correct behaviour is **local**
persistence (the existing `SettingsStore` window-layout path), with the wire
round-trip deferred.
---
## 5. acdream inventory — what exists, what is missing
### 5.1 What we mount today
`RetailUiRuntime.MountChat()``src/AcDream.App/UI/RetailUiRuntime.cs:670-736`:
```
676 LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId) // 0x21000006
691 ChatWindowController.Bind(info, layout, …)
708 RetailWindowFrame.Mount(Host.Root, root, …)
714 WindowName = WindowNames.Chat
715 Chrome = RetailWindowChrome.NineSlice
718 ContentWidth = 490f
728 ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Bottom
729 Opacity = 0.75f
```
`ChatWindowController``src/AcDream.App/UI/Layout/ChatWindowController.cs`:
- `:29` `LayoutId = 0x21000006`, `:33` `RootId = 0x1000000E`
- `:34` `ResizeBarId = 0x1000000F`**dropped** at `:204-205`
- `:35-42` transcript panel / transcript / track / input bar / menu / input /
send / max-min ids
- `:228`, `:237` `BackgroundColor = (0,0,0,0.35)` on transcript and input
- `:383-426` `ToggleMaximize` — a faithful port of
`gmMainChatUI::HandleMaximizeButton @0x004CCE50`
### 5.2 Gap list
**G1 and G2 are CLOSED as of Campaign CH slice CH6a (2026-08-10).**
`ChatWindowController.LayoutId`/`RootId` now import `0x2100006F`/`0x10000600`;
the crop/rebase/orphan-pruning compensations are deleted; `LayoutImporter`
gained a Type-9 case (`UiResizeGrip`); `UiRoot` gives a directly-hit grip's
own edges priority over its generic proximity heuristic; the main window
mounts with `RetailWindowChrome.Imported` (0x2100006F's own border art is
its chrome). The two subsections below are kept verbatim as the historical
diagnosis — do not re-run this investigation.
**G1 — Wrong LayoutDesc for the main window.**
`ChatWindowController.cs:29` imports `0x21000006` with root `0x1000000E`.
Retail's EoR main chat window is **LayoutDesc `0x2100006F`**, window element
`0x10000601`, root `0x10000600`, 410 × 100 (§2.1), evidenced three independent
ways: the runtime layout dump, `gmGamePlayUI::SetupChildren`/`SaveScreenLayout`
element ids, and `gmMainChatUI`'s own `0x1000046F` / `0x10000522``0x10000525`
child lookups. `0x21000006` is a different (older/standalone) chat layout — its
root `0x1000000E` and its 800px-wide resize bar `0x1000000F` appear nowhere in
the EoR gameplay UI, and `ChatWindowController.cs:185-193` already documents
that it drags along stray unparented siblings (`0x1000001C/1D/1E`,
`0x10000526`) that had to be orphaned by hand. Every symptom in the user's
report (3) — stray geometry, hand-cropped 490px content width, a dropped
800px resize bar, a 9px hole patched by growing the transcript panel at
`:210-211` — is downstream of this single choice.
UNVERIFIED: the exact provenance of `0x21000006`. Cheapest resolution:
`AcDream.App ui-studio --layout 0x21000006 --dump` next to
`--layout 0x2100006F --dump` and diff the element sets — one command, no
connected session.
**G2 — Only three edges resize, and the resize model is not retail's.**
`RetailUiRuntime.cs:728` sets `ResizableEdges = Left | Right | Bottom`,
deliberately excluding `Top` because the authored resize bar was dropped (G1).
`UiRoot.HitEdges` (`src/AcDream.App/UI/UiRoot.cs:999-1010`) does support
corners — a corner is just two bits — so with `Top` masked out the top-left
and top-right corners are dead and only the two bottom corners work. That is
exactly the user's report (2). Retail authors **eight** grips (§2.3) with
per-grip bool properties `0x2A`/`0x2B`/`0x2C`/`0x2D`. Additionally,
`LayoutImporter` has no concept of element type 9 (`UIElement_Resizebar`) — grep
for `Resizebar` in `src/AcDream.App/UI` returns nothing — so authored grips
would be imported as inert sprites today.
**G3 — Window opacity is completely inert. 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 (`<CHAT>`) |
| `0x10000505` / `0x1000050E` / `0x1000050F` / `0x10000510` | floating chat windows 14 (`<FCH1>``<FCH4>`) |
| `0x10000522``0x10000525` | main-window indicator buttons for windows 14 |
| `0x1000052A` | floaty-window close button |
| `0x100004D9` | floaty-window title bar |
| `0x1000046F` | main-window max/min button |