acdream/docs/research/2026-08-09-chat-retail-window-shell.md
Erik cc58289967 fix(chat): CH6c review fixes — opaque default, opacity-transition register clauses
BLOCKER: ChatSettings.DefaultOpacity shipped retail's base ChatInterface
value (0.5) as ONE shared global default applied to every
RetailWindowManager-registered window, not just the four floating chat
windows retail itself fades. That faded the whole out-of-box registered
UI (radar, vitals, toolbar, main chat, ...) to 50% opacity, including
several windows that can never take keyboard focus and so were stuck at
0.5 permanently. Fixed to gmMainChatUI's 1.0/1.0 override
(0x004CD0F0) instead — retail-identical opaque presentation for the 11
non-chat windows and the main chat window; only the four floating chat
windows now diverge from retail's 0.5-while-idle default, and the
Settings -> Chat transparency slider remains fully user-settable.

AP-190 reworded and gains two new decomp-verified clauses: (3) retail
eases opacity toward its target by 5% of the delta per tick
(ChatInterface::ListenToGlobalMessage @0x004F3840, armed from the focus
element-messages at @0x004F5275) where acdream snaps -- deferred, needs
a UI frame-tick hook the opacity controller doesn't have; (4) retail's
focus predicate is the chat ENTRY FIELD specifically
(ChatInterface::IsTextEntryFocused @0x004F30A0) where acdream uses
any-focusable-descendant. Both findings + the pre-existing UiMenu.cs
PushAlphaAbsolute(1f) popup bypass are folded into the window-shell
research doc's opacity section.

NITs: fixed the stale "text bypasses the alpha" comment in
UiElement.DrawSelfAndChildren (CH6c already routed DrawStringDat/
DrawString through the same ApplyAlpha chokepoint as sprites/rects);
added RetailWindowManager.WindowUnregistered + wired
RetailWindowOpacityController to detach and forget a window unregistered
while it held focus (previously only Dispose detached, leaking any
window unregistered mid-focus for the rest of the session); added
post-Dispose no-op guards to the three Set* opacity mutators; added a
DrawString (BitmapFont path) alpha regression test and a DrawStringDat
outline/background-pass alpha test (the existing tests only ever
exercised the foreground/fill pass).

Also fixes RuntimeSettingsControllerTests.SettingsViewModelSavePreserves
SectionAndTargetOrder's now-stale "target-chat-opacity:0.5:1" expectation
(caught by the full-suite run this fix requires) to match the new 1.0
default.

Campaign ledger CH6c row updated to APPROVE-WITH-FIXES.

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

47 KiB
Raw Blame History

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 §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.jsonan 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 0x100005220x10000525), 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 0x100005220x10000525. Clicking a chat-window indicator button does NOTHING in retail — reading (a), pure indicator, is correct; reading (b) is refuted. The 0x24 input-action-property theory in reading (b) does not even apply to a mouse click on the button itself: that property only wires keyboard dispatch (UIElementManager:: DoVisibilityToggleAction in §1.3), not a button's own UIElement click message, which routes to its LISTENING PARENT — and that parent's handler has no case for these four ids. acdream ports this exactly: the four indicator buttons (ChatWindowController._indicatorButtons) carry no OnClick at all; ChatWindowController.SetIndicatorOpen is their only writer, called only from RetailUiRuntime.OnWindowVisibilityChanged in response to the floating window's own visibility changing (keybind or otherwise) — a pure one-directional mirror, matching retail exactly.

1.5 Closing a floaty window from its own title bar

gmFloatyChatUI::ListenToElementMessage @0x004CE330
  004ce344   if (idMessage == 1 && idElement == 0x1000052A)
  004ce346       this->vtable[…]( 0 )        // SetVisible(false)

Element 0x1000052A is the 14×14 close button at (230, 86) in the floaty layout (§2.2). idMessage == 1 is "clicked".

1.6 The lock-UI cosmetic swap

gmFloatyMainChatUI::UpdateLockedStatus @0x004D23D0 (driven by global message 0x0D via gmFloatyMainChatUI::ListenToGlobalMessage @0x004D2940) reads PlayerModule::LockUI and swaps the 8 live border/corner elements for their 8 _Locked cosmetic twins. When the UI is locked the interactive Resizebars are hidden and the inert art is shown — that is how retail disables resizing without touching the resize code.


2. LayoutDesc geometry

2.1 Main chat window — LayoutDesc 0x2100006F

Window element 0x10000601 (this is the id gmGamePlayUI looks up and the id SaveScreenLayout @0x004EAD50 writes as <CHAT>), layout root element 0x10000600, authored extent 410 × 100.

Full authored tree (from 2026-06-25-retail-ui-layout-dump.json; rects are absolute in the dump's 640×480 space, i.e. root at 0,0):

element rect (x,y,w,h) role
0x10000600 0,0,410,100 window root (Group)
0x10000693 0,0,5,5 TL corner — Locked twin
0x10000694 5,0,400,5 top edge — Locked twin
0x10000695 405,0,5,5 TR corner — Locked twin
0x10000696 0,5,5,90 left edge — Locked twin
0x10000697 0,95,5,5 BL corner — Locked twin
0x10000698 5,95,400,5 bottom edge — Locked twin
0x10000699 405,95,5,5 BR corner — Locked twin
0x1000069A 405,5,5,90 right edge — Locked twin
0x1000069B 0,0,5,5 TL corner — live Resizebar
0x1000069C 5,0,400,5 top edge — live Resizebar
0x1000069D 405,0,5,5 TR corner — live Resizebar
0x1000069E 0,5,5,90 left edge — live Resizebar
0x1000069F 0,95,5,5 BL corner — live Resizebar
0x100006A0 5,95,400,5 bottom edge — live Resizebar
0x100006A1 405,95,5,5 BR corner — live Resizebar
0x100006A2 405,5,5,90 right edge — live Resizebar
0x10000010 5,5,400,73 transcript panel (Sprite)
0x10000011 21,5,368,73 transcript text (Group)
0x1000048C 21,62,16,16 new-unseen-text indicator (Button)
0x10000012 389,5,16,73 scrollbar column
0x10000364/65/66, 0x10000071, 0x10000072 scrollbar thumb pieces + up/down buttons
0x1000046F 368,5,16,16 max/min toggle (Button)
0x10000522 5,5,16,16 chat-window-1 indicator (Button)
0x10000523 5,22,16,16 chat-window-2 indicator
0x10000524 5,39,16,16 chat-window-3 indicator
0x10000525 5,56,16,16 chat-window-4 indicator
0x10000013 5,78,400,17 input row (Sprite)
0x10000014 5,78,46,17 talk-focus menu button
0x10000015 5,78,46,17 talk-focus menu group
0x10000016 51,78,306,17 chat entry field
0x10000017 / 0x10000018 1px entry field left/right rails
0x10000019 359,78,46,17 Send button

Cross-checks that pin this layout to the code, independent of the dump:

  • gmMainChatUI::HandleMaximizeButton @0x004CCE50 looks up 0x1000046F.
  • gmMainChatUI::RecvNotice_SetPanelVisibility @0x004CCD80 writes 0x100005220x10000525.
  • gmFloatyMainChatUI::PostInit @0x004D2670 binds exactly the 16 ids 0x100006930x100006A2, and casts eight of them via DynamicCast(9) — element type 9 is UIElement_Resizebar (UIElement_Resizebar::Register @0x0046B920RegisterElementClass(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 + StopMouseResizingUIElement::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 UpdateFromPlayerModuleMoveTo(x,y)
0x10000087 int — window Y same MoveTo sites, :004ce8da / :004d2e4e same
0x10000088 int — width gmFloatyChatUI::ResizeTo @0x004CE6D0:004ce722; gmFloatyMainChatUI::ResizeTo @0x004D2C00:004d2c5e UpdateFromPlayerModuleResizeTo(w,h)
0x10000089 int — height same ResizeTo sites, :004ce770 / :004d2cac same
0x1000008A bool — visible / open gmFloatyChatUI::SetVisible @0x004CE9B0:004ce9ff UpdateFromPlayerModuleSetVisible
0x1000008D StringInfo — window title gmFloatyChatUI::SetWindowTitle @0x004CEAA0:004ceb14 UpdateFromPlayerModule @0x004CE3F0:004ce63a

Two guards worth porting verbatim:

  1. Every write is gated on m_eWindowID != 0the 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 (004d2d53004d2dbb), 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:49public 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

ChatWindowControllersrc/AcDream.App/UI/Layout/ChatWindowController.cs:

  • :29 LayoutId = 0x21000006, :33 RootId = 0x1000000E
  • :34 ResizeBarId = 0x1000000Fdropped 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 / 0x100005220x10000525 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 0x100005220x10000525 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.


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 ActiveOpacityglobal, 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 0x2A0x2D) 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
CH6f0x1000008B/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>)
0x100005220x10000525 main-window indicator buttons for windows 14
0x1000052A floaty-window close button
0x100004D9 floaty-window title bar
0x1000046F main-window max/min button