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>
1009 lines
53 KiB
Markdown
1009 lines
53 KiB
Markdown
# Campaign CH slice CH6 — retail chat-window SHELL research
|
||
|
||
**Date:** 2026-08-09
|
||
**Scope:** RESEARCH ONLY. How retail Asheron's Call constructs, shows, sizes,
|
||
persists and fades the chat windows — the *shell*, not the text pipeline.
|
||
The per-window text FILTER model is already decoded in
|
||
[`2026-08-09-chat-retail-color-table.md`](2026-08-09-chat-retail-color-table.md)
|
||
§4 and is not re-derived here.
|
||
|
||
**Primary sources**
|
||
- `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build,
|
||
Binary Ninja pseudo-C, PDB-named)
|
||
- `docs/research/named-retail/acclient.h` (verbatim retail struct/enum defs)
|
||
- `docs/research/named-retail/symbols.json`
|
||
- `docs/research/named-retail/retail-default.keymap.txt`
|
||
- `docs/research/2026-06-25-retail-ui-layout-dump.json` — **an existing acdream
|
||
dump of the retail gameplay-UI top-level windows.** It carries the LayoutDesc
|
||
id, element ids, widget kinds and authored rects for every window below, and
|
||
is the geometric oracle used throughout §2.
|
||
- `references/ACE/Source/...` (server cross-check)
|
||
|
||
**Binary-Ninja field-name caveat (applies throughout).** BN's struct-field
|
||
attribution in `gm*ChatUI::PostInit` is shifted by one slot relative to the true
|
||
member order (the classic artifact class in
|
||
`claude-memory/feedback_bn_decomp_field_names.md`). Every id↔role binding in §2
|
||
was therefore re-derived from the **authored rectangles in the layout dump**,
|
||
which agree with the decomp's *call order* exactly once the one-slot shift is
|
||
undone. Where a claim rests on BN naming alone it is marked UNVERIFIED.
|
||
|
||
---
|
||
|
||
## 1. Window lifecycle — how windows 1–4 get created and shown
|
||
|
||
### 1.1 They are not created on demand. They are authored, always-resident, and toggled.
|
||
|
||
`gmGamePlayUI::SetupChildren @0x004E9EC0` builds the whole gameplay UI once:
|
||
|
||
```
|
||
004e9ed5 m_pGameplayUI = CreateAndAddRootElement(0x10000006, 0x10000495)
|
||
004e9f40 var_b0 = 0x10000505 // floating chat window 1
|
||
004e9f48 var_ac = 0x1000050E // floating chat window 2
|
||
004e9f50 var_a8 = 0x1000050F // floating chat window 3
|
||
004e9f58 var_a4 = 0x10000510 // floating chat window 4
|
||
004e9f6a hash("ID_Chat_Chat1_DefaultTitle") … "ID_Chat_Chat4_DefaultTitle"
|
||
004e9ff5 for i in 0..3:
|
||
004e9faf StringInfo::SetStringIDandTableEnum(&title, ids[i], 0x10000001)
|
||
004e9fbf el = UIElement::GetChildRecursive(m_pGameplayUI, windowIds[i])
|
||
004e9fd1 chat = el->DynamicCast(0x10000040) // gmFloatyChatUI
|
||
004e9fe9 chat->vtable[0x2AC](&title) // SetWindowTitle
|
||
```
|
||
|
||
So all four extra chat windows exist as **authored children of the gameplay-UI
|
||
root from the moment the game UI is built**. There is no window-manager `Create`
|
||
call, no per-window allocation at toggle time, and no floating-window registry.
|
||
Opening one is `SetVisible(true)`; closing one is `SetVisible(false)`.
|
||
|
||
Confirming class identity: `gmFloatyChatUI::GetUIElementType @0x004CE2B0` returns
|
||
`0x10000040`, and `gmFloatyChatUI::Register @0x004CE3D0` registers that element
|
||
class with the LayoutDesc element factory. `gmFloatyChatUI` derives from
|
||
`ChatInterface` and adds **no members at all** (`acclient.h:55144`), which is why
|
||
`m_eWindowID` (the first `ChatInterface` field, `acclient.h:54900`) is the only
|
||
per-window identity it carries.
|
||
|
||
### 1.2 Window identity comes from a LayoutDesc attribute, not from code
|
||
|
||
`ChatInterface::PostInit @0x004F3DD0`:
|
||
|
||
```
|
||
004f3de8 UIElement::GetAttribute_Enum(this, 0x1000007E, &this->m_eWindowID)
|
||
004f3df9 switch (m_eWindowID - 1) { … seed m_llTextTypeFilter defaults … }
|
||
```
|
||
|
||
**Attribute `0x1000007E` on the authored element IS the window id.** The main
|
||
chat window is id `0`; the floaties are `1..4` (the `PostInit` switch also has
|
||
arms for 5 and 8 — see the colour-table doc §4 for the seeded filter values).
|
||
|
||
### 1.3 The toggle is a KEYBIND, not a button
|
||
|
||
`docs/research/named-retail/retail-default.keymap.txt:150-153`, inside the
|
||
`ToggleWindows` action group:
|
||
|
||
```
|
||
ToggleFloatingChatWindow1 [ "" [ 0 DIK_1 ] 0x00000004 ]
|
||
ToggleFloatingChatWindow2 [ "" [ 0 DIK_2 ] 0x00000004 ]
|
||
ToggleFloatingChatWindow3 [ "" [ 0 DIK_3 ] 0x00000004 ]
|
||
ToggleFloatingChatWindow4 [ "" [ 0 DIK_4 ] 0x00000004 ]
|
||
```
|
||
|
||
`0x00000004` is the modifier mask (the same mask the `UseQuickSlot_14..18` rows
|
||
use for `Alt`+digit, versus `0x00000002` for the CTRL-digit quickslot rows —
|
||
corrected below; the original filing mislabeled `0x00000002` as "shift").
|
||
So retail's default is **Alt+1 … Alt+4**, not bare 1–4.
|
||
RESOLVED at Campaign CH slice CH6b: `retail-default.keymap.txt`'s own
|
||
`MetaKeys` legend (not a guess — the keymap file's literal index table) reads
|
||
```
|
||
MetaKeys
|
||
[
|
||
1 [ 0 DIK_LSHIFT ]
|
||
2 [ 0 DIK_LCONTROL ]
|
||
2 [ 0 DIK_RCONTROL ]
|
||
3 [ 0 DIK_LMENU ]
|
||
3 [ 0 DIK_RALT ]
|
||
4 [ 0 DIK_LWIN ]
|
||
4 [ 0 DIK_RWIN ]
|
||
]
|
||
```
|
||
so the modifier-mask bit for MetaKeys index *N* is `1 << (N-1)`: **index 1 =
|
||
Shift → `0x1`, index 2 = Ctrl → `0x2`, index 3 = Alt → `0x4`, index 4 = Win →
|
||
`0x8`**. `0x00000004` on the `ToggleFloatingChatWindow1..4` rows is therefore
|
||
unambiguously **Alt**, cross-checked against the same file's own Alt+A/D
|
||
strafe and Alt+Enter/Tab/F4 rows (all `0x00000004`). `KeyBindings` already
|
||
carried `ModifierMask.Alt` for these four actions since Phase K.1c — no code
|
||
change was needed, only removing this hedge.
|
||
|
||
The dispatch chain is fully generic — there is no chat-specific code in it:
|
||
|
||
```
|
||
UIElementManager::KeyPressEvent @0x0045C300
|
||
0045c347 DoVisibilityToggleAction(this, actionId)
|
||
|
||
UIElementManager::DoVisibilityToggleAction @0x0045B660
|
||
0045b680 look up actionId in m_elementInputActionListenerTable
|
||
0045b6c1 for each registered element:
|
||
BroadcastElementMessage(element, 0x31, actionId, 0)
|
||
```
|
||
|
||
and the registration side is a plain authored property:
|
||
|
||
```
|
||
UIElement::OnSetAttribute (property switch) @0x00462D80
|
||
004631d7 case 0x24: // "input action" property
|
||
004631fb UIElementManager::RegisterElementForInputAction(
|
||
UIElementManager::s_pInstance, enumValue, this)
|
||
```
|
||
|
||
**So: LayoutDesc property `0x24` on the window element declares the input action
|
||
that toggles it; the manager broadcasts element message `0x31` ("toggle
|
||
visibility") to every element registered for that action.** The same mechanism
|
||
serves `ToggleInventoryPanel`, `ToggleSpellbookPanel`, etc.
|
||
|
||
UNVERIFIED: the numeric action-enum values behind
|
||
`ToggleFloatingChatWindow1..4` (the keymap file stores names, and the property
|
||
`0x24` value lives in the LayoutDesc, not in code). Cheapest resolution: dump
|
||
property `0x24` for elements `0x10000505/0x1000050E/0x1000050F/0x10000510` from
|
||
LayoutDesc `0x21000070`/gameplay root — one `ui-studio --dump` pass. Not needed
|
||
to implement CH6: acdream binds its own actions.
|
||
|
||
### 1.4 The 1/2/3/4 buttons in the main window are STATE MIRRORS, not the toggle
|
||
|
||
They exist and they are authored (§2.1, elements `0x10000522`–`0x10000525`), but
|
||
the code path is one-directional — window visibility drives the buttons:
|
||
|
||
```
|
||
gmGamePlayUI::ListenToElementMessage @0x004E9CE0
|
||
004e9cee if (idMessage == 0x18) // visibility changed
|
||
004e9d29 if (idElement == 0x10000505 ||
|
||
(idElement > 0x1000050D && idElement <= 0x10000510))
|
||
004e9d4c CM_UI::SendNotice_SetPanelVisibility(idElement, elementVisibleBit)
|
||
|
||
gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80
|
||
004ccd9c 0x10000505 -> child 0x10000522
|
||
004ccda3 0x1000050E -> child 0x10000523
|
||
004ccdaa 0x1000050F -> child 0x10000524
|
||
004ccdb1 0x10000510 -> child 0x10000525
|
||
004ccdbf child = GetChildRecursive(this, mapped)
|
||
004ccdcd visible ? child->SetState(6) : child->SetState(1)
|
||
```
|
||
|
||
State 6 = "on/depressed", state 1 = "normal". No handler anywhere in the binary
|
||
switches on `0x10000522..0x10000525` as a *source* of a click — `grep` over the
|
||
whole pseudo-C returns only `gmMainChatUI::RecvNotice_SetPanelVisibility`.
|
||
|
||
**RESOLVED 2026-08-10 at Campaign CH slice CH6b; wording corrected at the
|
||
CH6a/b REJECT-review (NIT 4) — the original "ONLY function in the whole 2013
|
||
binary that branches on `idMessage == 1`" superlative was false**
|
||
(`gmFloatyChatUI::ListenToElementMessage @0x004CE330`, the floaty windows'
|
||
close-button handler, also branches on `idMessage == 1`; §1.5). **The
|
||
substantive claim stands: within `gmMainChatUI::ListenToElementMessage`
|
||
specifically, there is no case for `0x10000522`-`0x10000525`.** The prior
|
||
UNVERIFIED paragraph's hedge ("safe to wire both") is superseded by a direct
|
||
read of `gmMainChatUI::ListenToElementMessage @0x004CDA80`. It handles
|
||
exactly two element ids: `0x1000046f` (max/min, dispatching
|
||
`HandleMaximizeButton`) and the talk-focus menu's selection message
|
||
(`idMessage == 7`, checked against `this->m_pCCS` / a `0x1000000b` attribute
|
||
read). There is no case, anywhere in that function or its base-class fallback
|
||
(`ChatInterface::ListenToElementMessage`, called unconditionally at the
|
||
function's tail), for `0x10000522`–`0x10000525`. **This citation is still a
|
||
true statement about `gmMainChatUI::ListenToElementMessage` — see the round-4
|
||
correction below for why it is the wrong function to have grepped.**
|
||
|
||
---
|
||
|
||
**ROUND-4 CORRECTION (2026-08-10) — the user's own retail memory ("clicking
|
||
opens/closes the window") overruled the CONCLUSION above, and re-attacking the
|
||
question with that as the starting axiom (per CLAUDE.md: the user's retail
|
||
memory is the axiom, not a hypothesis to be argued down) found the exact
|
||
mechanism the CH6b pass missed.**
|
||
|
||
The CH6b pass's mistake was scope, not accuracy: it proved
|
||
`gmMainChatUI::ListenToElementMessage` has no click case for these ids, then
|
||
reasoned "a button's click message… routes to its LISTENING PARENT" and
|
||
stopped there. **That's wrong — `UIElement_Button` (Type 1, the class every
|
||
one of these four indicators actually is) overrides its OWN click handling
|
||
and never asks its parent window first:**
|
||
|
||
```
|
||
UIElement_Button::HandleButtonClick @0x00471E50
|
||
00471e65 if (UIElement::GetAttribute_Enum(this, 0x12, &actionId)) // reads its OWN property
|
||
00471e72 if (actionId != 1)
|
||
00471e95 build an InputEvent(actionId)
|
||
00471eb2 ICIDM::GetActionMap()… dispatch through the action map
|
||
```
|
||
|
||
Property `0x12` here is the SAME kind of "input action" enum as `0x24` — not
|
||
the parent-window dispatch §1.4's original text assumed didn't apply to
|
||
clicks. This IS the generic mechanism a click uses to reach
|
||
`UIElementManager::DoVisibilityToggleAction @0x0045B660` (§1.3's own citation,
|
||
previously assumed keybind-only) → `BroadcastElementMessage(target, 0x31,
|
||
actionId, 0)` for every element registered under that action id via
|
||
`RegisterElementForInputAction` (§1.3's property-`0x24` registration —
|
||
confirmed as the ONLY call site of `RegisterElementForInputAction` in the
|
||
whole binary) → the RECEIVING element's generic base-class handler:
|
||
|
||
```
|
||
UIElement::ListenToElementMessage @0x00462340
|
||
00462447 case 8: // idMessage - 0x29 == 8, i.e. raw idMessage 0x31
|
||
0046244f GetAttribute_Enum(this, 0x58, &mode) // reads the RECEIVER's OWN property
|
||
00462459 if (mode == 1) SetVisible(!currentlyVisible) // toggle
|
||
0046245c else if (mode == 2) SetVisible(1) // force-show
|
||
0046245f else if (mode == 3) SetVisible(0) // force-hide
|
||
```
|
||
|
||
Neither `gmMainChatUI`, `gmFloatyChatUI`, nor `ChatInterface` overrides
|
||
`ListenToElementMessage` for raw idMessage `0x31` (confirmed by reading all
|
||
three switches directly — none has a case landing on it), so EVERY window
|
||
falls through to this base-class handler unconditionally. This is a complete,
|
||
generic, working "click toggles a registered listener's visibility" system —
|
||
exactly the "authored button behavior" this reconciliation task hypothesized
|
||
— and it is NOT gated on keyboard input the way the original §1.3 text
|
||
assumed; `UIElementManager::KeyPressEvent`'s call to `DoVisibilityToggleAction`
|
||
is simply ONE caller among the several that can reach it (the button's own
|
||
`HandleButtonClick` is another).
|
||
|
||
**What the authored DATA shows, checked directly against the committed
|
||
fixtures:**
|
||
|
||
- `chat_2100006f.json` — all four indicator elements (`0x10000522`-`0x10000525`)
|
||
DO author an Enum-kind property `0x12` (confirmed by `Kind: 0` = Enum in the
|
||
fixture's own property dump, matching `LayoutImporter.ConvertProperty`'s
|
||
`EnumBaseProperty → UiPropertyKind.Enum` mapping exactly), with values
|
||
`0x10000114`-`0x10000117` in element-id order (re-verified directly against
|
||
the committed fixture's own `UnsignedValue` fields — an earlier pass here
|
||
misread these as `0x10000514`-`0x10000517`). This is a real, present,
|
||
correctly-typed action id — the button-click half of the generic mechanism
|
||
is genuinely armed.
|
||
- `chat_floaty_2100005b.json` — the floating chat window's own fixture
|
||
authors **NO Enum-kind property `0x24` anywhere** (its only hit on property
|
||
number 36 decimal is `Kind: 4` = Integer, an unrelated attribute — not the
|
||
registration property) and **no property `0x58` at all**. Since
|
||
`RegisterElementForInputAction` has exactly one call site in the entire
|
||
binary (the property-`0x24` handler in `UIElement::Initialize`; no class
|
||
anywhere calls it directly from code), nothing in the shipped floating chat
|
||
window LayoutDesc ever registers it as a listener for ANY action id.
|
||
`DoVisibilityToggleAction` would look up the button's action id, find zero
|
||
registered listeners, and silently return — the click would fire a real
|
||
message with no receiver.
|
||
- The four action-id VALUES (`0x10000114`-`0x10000117`) are also not
|
||
chat-specific: the first two, `0x10000114` and `0x10000115`, are
|
||
`m_prevButton`/`m_nextButton` child-element ids for an UNRELATED
|
||
pagination widget elsewhere in the decomp (`UIElement::GetChildRecursive(this,
|
||
0x10000115)` / `(this, 0x10000114)`,
|
||
`acclient_2013_pseudo_c.txt:194343-194344`, confirmed by direct read) — a
|
||
coincidence of Turbine's global per-dat-file asset-id allocator (ids are
|
||
assigned client-wide, not scoped per panel), not a cross-reference.
|
||
(**Correction:** an earlier pass here misread the fixture's Enum values as
|
||
`0x10000514`-`0x10000517` and built a since-retracted claim that they
|
||
matched `gmFriendsUI::PostInit`'s own Add/Remove/Tell child ids — that
|
||
match does not exist at the correct `0x1000011x` values and `gmFriendsUI`
|
||
is not part of this finding.)
|
||
|
||
**Conclusion: the generic UI action system is real, it exists, and the
|
||
buttons genuinely arm their half of it — but the authored DATA available to
|
||
us (both committed fixtures, generated from the installed DAT) does not wire
|
||
a target for it.** This is consistent with, not a refutation of, the original
|
||
CH6b grep of `gmMainChatUI::ListenToElementMessage` — that citation was
|
||
looking in the wrong function, but its NEGATIVE RESULT (retail's window-level
|
||
message handlers never claim these clicks) still holds; the generic
|
||
mechanism, if it does connect the dots in real retail, does so entirely
|
||
below the level either grep could see. **Per CLAUDE.md, the user's retail
|
||
memory is the axiom regardless: acdream now wires each indicator's click to
|
||
toggle its floating window through the SAME `ToggleFloatingChatWindow`
|
||
chokepoint the `Alt+1..4` keybinds use
|
||
(`ChatWindowController.BindIndicatorClicks`, called by `RetailUiRuntime`
|
||
right after mounting the main chat window) — explicitly as USER-DIRECTED
|
||
retail behavior, not a claim that the generic-action-system data path has
|
||
been proven end-to-end.** `SetIndicatorOpen` stays the ONLY writer of the
|
||
indicator's `Selected` mirror (`UiButton.SuppressSelfToggle` stays `true`);
|
||
the click drives the real toggle, and the mirror reports the outcome back —
|
||
so the visual stays consistent through the round trip even though the write
|
||
is now two-way at the FEATURE level.
|
||
|
||
---
|
||
|
||
acdream ports this exactly: the four indicator buttons
|
||
(`ChatWindowController._indicatorButtons`) now carry a real `OnClick`
|
||
(`ChatWindowController.BindIndicatorClicks`, round 4); the mirror half is
|
||
unchanged — `ChatWindowController.SetIndicatorOpen` is still the ONLY writer
|
||
of `Selected`, still called from `RetailUiRuntime.OnWindowVisibilityChanged`
|
||
in response to the floating window's own visibility changing, regardless of
|
||
what triggered it (click, keybind, or a restored layout).
|
||
|
||
### 1.5 Closing a floaty window from its own title bar
|
||
|
||
```
|
||
gmFloatyChatUI::ListenToElementMessage @0x004CE330
|
||
004ce344 if (idMessage == 1 && idElement == 0x1000052A)
|
||
004ce346 this->vtable[…]( 0 ) // SetVisible(false)
|
||
```
|
||
|
||
Element `0x1000052A` is the 14×14 close button at (230, 86) in the floaty
|
||
layout (§2.2). `idMessage == 1` is "clicked".
|
||
|
||
### 1.6 The lock-UI cosmetic swap
|
||
|
||
`gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0` (driven by global message
|
||
`0x0D` via `gmFloatyMainChatUI::ListenToGlobalMessage @0x004D2940`) reads
|
||
`PlayerModule::LockUI` and swaps the 8 live border/corner elements for their 8
|
||
`_Locked` cosmetic twins. When the UI is locked the *interactive* Resizebars are
|
||
hidden and the inert art is shown — that is how retail disables resizing without
|
||
touching the resize code.
|
||
|
||
---
|
||
|
||
## 2. LayoutDesc geometry
|
||
|
||
### 2.1 Main chat window — LayoutDesc `0x2100006F`
|
||
|
||
Window element `0x10000601` (this is the id `gmGamePlayUI` looks up and the id
|
||
`SaveScreenLayout @0x004EAD50` writes as `<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 1–4 — LayoutDesc `0x2100005B`
|
||
|
||
All four windows instantiate **the same** LayoutDesc; only the window element id
|
||
and the `0x1000007E` window-id attribute differ. Layout root `0x100004F7`,
|
||
**authored extent 250 × 108**.
|
||
|
||
| element | rect (x,y,w,h) | role |
|
||
|---|---|---|
|
||
| `0x100004F7` | 0,80,250,108 | window root (Group) |
|
||
| `0x100004FC` | 0,80,5,5 | TL corner |
|
||
| `0x1000000F` | 5,80,240,5 | top edge |
|
||
| `0x100004FE` | 245,80,5,5 | TR corner |
|
||
| `0x100004D2` | 0,85,5,98 | left edge |
|
||
| `0x10000501` | 0,183,5,5 | BL corner |
|
||
| `0x100004D4` | 5,183,240,5 | bottom edge |
|
||
| `0x10000503` | 245,183,5,5 | BR corner |
|
||
| `0x100004D3` | 245,85,5,98 | right edge |
|
||
| `0x100004D9` | 5,85,240,16 | **title bar** |
|
||
| `0x10000528` | 5,100,240,5 | title/content divider |
|
||
| `0x10000529` | 5,85,240,20 | title-bar group (drag handle) |
|
||
| `0x1000052A` | 230,86,14,14 | **close button** |
|
||
| `0x10000010` | 5,105,224,60 | transcript panel |
|
||
| `0x10000011` | 5,105,224,60 | transcript text |
|
||
| `0x1000048C` | 5,169,16,16 | new-unseen-text indicator |
|
||
| `0x10000012` | 229,105,16,60 | scrollbar column (+ children) |
|
||
| `0x10000509` | 5,165,240,18 | input row |
|
||
| `0x10000016` | 5,165,202,18 | chat entry field |
|
||
| `0x1000052B`, `0x100002B5`, `0x10000209`, `0x1000020A`, `0x1000020B` | — | entry-field frame rails |
|
||
| `0x10000019` | 207,165,38,18 | Send button |
|
||
|
||
Independent code cross-check: `gmFloatyChatUI::SetWindowTitle @0x004CEAA0` does
|
||
`GetChildRecursive(this, 0x100004D9)` and `SetStringInfo` on it — exactly the
|
||
240×16 title strip above.
|
||
|
||
A floaty chat window has **no** talk-focus menu, **no** max/min button and **no**
|
||
1/2/3/4 indicators. It has a title bar (which the main window does not) and a
|
||
close button.
|
||
|
||
### 2.3 How retail encodes resizability — there are no "sizable edge" flags
|
||
|
||
Retail does **not** put a bitmask on the window. Each draggable edge and corner
|
||
is its own child element of type 9 (`UIElement_Resizebar`), and four authored
|
||
BOOL properties on that child say which border it drives:
|
||
|
||
`UIElement_Resizebar::StartMouseResizing @0x0046B7E0`
|
||
|
||
```
|
||
0046b7f8 GetAttribute_Bool(this, 0x2C, &bRight)
|
||
0046b806 GetAttribute_Bool(this, 0x2B, &bLeft)
|
||
0046b814 GetAttribute_Bool(this, 0x2D, &bTop)
|
||
0046b822 GetAttribute_Bool(this, 0x2A, &bBottom)
|
||
|
||
if (bRight) border = bTop ? BORDER_UR : (bBottom ? BORDER_LR : BORDER_RIGHT)
|
||
else if (bLeft) border = bTop ? BORDER_UL : (bBottom ? BORDER_LL : BORDER_LEFT)
|
||
else if (bTop) border = BORDER_TOP
|
||
else if (bBottom)border = BORDER_BOTTOM
|
||
|
||
0046b88e this->m_mousePressed = 1
|
||
0046b89f UIElement::StartResizing(parent, border, pt.x, pt.y)
|
||
0046b8a4 this->SetState(3) // "pressed" art
|
||
```
|
||
|
||
`BorderLocation` (`acclient.h:4327`):
|
||
`BORDER_NONE=0, BORDER_UL=1, BORDER_TOP=2, BORDER_UR=3, BORDER_RIGHT=4,
|
||
BORDER_LR=5, BORDER_BOTTOM=6, BORDER_LL=7, BORDER_LEFT=8`.
|
||
|
||
So the property map is **`0x2A` = bottom, `0x2B` = left, `0x2C` = right,
|
||
`0x2D` = top**, and a corner grip simply sets two of them.
|
||
|
||
Drag lifecycle (`UIElement_Resizebar::ListenToElementMessage @0x0046B930`):
|
||
message `0x1C` (mouse down, `dwParam1 == 7` = left button) → `StartMouseResizing`
|
||
+ parent's vtable+0x60; `0x1E` (mouse move) → parent's vtable+0x4C
|
||
(`MouseResizeElement @0x00461130`); `0x1D` (mouse up) → parent
|
||
vtable+0x64 + `StopMouseResizing` → `UIElement::StopResizing @0x0045FD60`.
|
||
|
||
**Both chat layouts author all eight grips.** Retail is resizable from every
|
||
edge and every corner, including the top and the top corners, on both the main
|
||
and the floating chat windows.
|
||
|
||
**CORRECTED 2026-08-10 at Campaign CH slice CH6a implementation.** This claim
|
||
is wrong for the main window's plain TOP EDGE specifically, established by a
|
||
direct `ElementDesc.Type` dump of the installed DAT (ground truth, not a
|
||
decomp reading) of the 8 ids `0x1000069B`-`0x100006A2`: 7 of them are
|
||
Type 9 (`UIElement_Resizebar`) as claimed, but `0x1000069C` — the straight
|
||
top-edge strip between the two top corners — is Type **2**
|
||
(`UIElement_Dragbar`), not Type 9. So retail's main chat window is
|
||
resizable from every edge and corner EXCEPT the plain top strip, which is a
|
||
MOVE handle instead (there is no title bar, so the top strip does double
|
||
duty as the drag affordance). The two top CORNERS (`0x1000069B` UL,
|
||
`0x1000069D` UR) are still genuine Resizebar grips carrying the top bool, so
|
||
dragging from a corner still resizes the Y/top axis — only the straight
|
||
edge in between does not. This is fully consistent with, and explains, the
|
||
`UIElement_Resizebar::StartMouseResizing` cursor media dump: `0x1000069C`
|
||
carries cursor `0x06006119` (the four-arrow MOVE cursor, matching
|
||
`WindowMove`), not one of the two diagonal or the vertical resize cursor
|
||
ids the seven true grips carry. Not independently re-verified for the
|
||
floating-window layout `0x2100005B` (CH6b's scope); assume it needs the
|
||
same direct-dump check rather than trusting this row for that layout too.
|
||
|
||
### 2.4 Min/max extents
|
||
|
||
Retail reads them as ordinary integer attributes on the window element:
|
||
`gmMainChatUI::HandleMaximizeButton @0x004CCE50` uses
|
||
`GetAttribute_Int(this, 0x3C, &maxH)` and `GetAttribute_Int(this, 0x3E, &minH)`.
|
||
acdream already consumes the same family — `RetailWindowFrame.ResolveConstraint`
|
||
(`src/AcDream.App/UI/Layout/RetailWindowFrame.cs:160-169`) maps
|
||
`0x3C`=maxH, `0x3D`=maxW, `0x3E`=minH, `0x3F`=minW. No change needed.
|
||
|
||
---
|
||
|
||
## 3. Opacity — the two values
|
||
|
||
Named constants in the binary's read-only data at `0x007A8D50`:
|
||
|
||
```
|
||
007a8d50 uint32_t const Option_TextType_Property = 0x1000007F
|
||
007a8d54 uint32_t const Option_DefaultOpacity_Property = 0x10000080
|
||
007a8d58 uint32_t const Option_ActiveOpacity_Property = 0x10000081
|
||
```
|
||
|
||
| id | name | type | scope | meaning |
|
||
|---|---|---|---|---|
|
||
| `0x10000080` | `Option_DefaultOpacity_Property` | float | **global** (`InqOption`, not per-window) | opacity when the window's text entry does NOT have focus |
|
||
| `0x10000081` | `Option_ActiveOpacity_Property` | float | **global** | opacity while the window's text entry HAS focus |
|
||
|
||
Read path (`gmFloatyChatUI::UpdateFromPlayerModule @0x004CE3F0`,
|
||
identical in `gmFloatyMainChatUI::UpdateFromPlayerModule @0x004D2970`):
|
||
|
||
```
|
||
004ce42b if (PlayerModule::InqOption(pm, 0x10000080, &prop) && prop->InqFloat(&v))
|
||
004ce445 ChatInterface::SetDefaultOpacity(this, v)
|
||
004ce465 if (PlayerModule::InqOption(pm, 0x10000081, &prop) && prop->InqFloat(&v))
|
||
004ce47f ChatInterface::SetActiveOpacity(this, v)
|
||
```
|
||
|
||
Live-update path (`gmFloatyMainChatUI::RecvNotice_GameplayOptionChanged
|
||
@0x004D25A0`) switches on the same two ids and forwards anything else to
|
||
`ChatInterface::RecvNotice_GameplayOptionChanged @0x004F30E0` (which handles the
|
||
`0x1000007F` filter).
|
||
|
||
They are also settable per-element from the LayoutDesc:
|
||
`ChatInterface::OnSetAttribute @0x004F3F60` accepts `0x10000080` / `0x10000081`
|
||
as element attributes and **falls back to `0x3F800000` (= 1.0f)** when the
|
||
property value cannot be read (`004f3fb2`, `004f3f84`). That 1.0f is the only
|
||
default the code itself carries.
|
||
|
||
The options UI wires them to two linked sliders — `gmChatOptionsUI::InitOptions
|
||
@0x0049FC60`:
|
||
|
||
```
|
||
0049fcc0 UIOption_Slider::SetGameplayOptionProperty(slider1, 0x10000080)
|
||
0049fd1a UIOption_Slider::SetGameplayOptionProperty(slider2, 0x10000081)
|
||
0049fd5c DualHash<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 1–4 (`<FCH1>`–`<FCH4>`) |
|
||
| `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 |
|