acdream/docs/research/2026-08-09-chat-retail-window-shell.md
Erik 1fd515436c feat(chat): Campaign CH slice CH6a — retail chat-window layout + 8-grip resize
Swap ChatWindowController's imported main-chat LayoutDesc from the wrong
0x21000006 (an unrelated layout whose root and 800px resize bar appear
nowhere in the EoR gameplay UI) to retail's ACTUAL main chat window,
0x2100006F (window root 0x10000600, authored 410x100 — confirmed by a
direct DAT dump, found in dats.Local not dats.Portal). Every downstream
compensation that existed only to paper over the wrong import is deleted:
the hand-cropped 490px content width, the dropped 800px resize bar, the
9px transcript patch, the orphan-sibling pruning, the max/min-vs-scrollbar
overlap shift, and the scrollbar top-reclaim. The window now mounts with
RetailWindowChrome.Imported (0x2100006F's own 8 border/corner elements are
its complete chrome) instead of the universal nine-slice wrapper.

LayoutImporter/DatWidgetFactory gain a Type-9 (UIElement_Resizebar) case:
UiResizeGrip decodes retail's exact four-bool BorderLocation algorithm
(0x2A=bottom/0x2B=left/0x2C=right/0x2D=top,
UIElement_Resizebar::StartMouseResizing @0x0046B7E0) into a ResizeEdges
bitmask. A direct DAT dump established the true shape: only 7 of the 8
grip-position ids are Type 9 — the straight top-EDGE strip (0x1000069C) is
a Type-2 Dragbar (move handle), not a Resizebar, because the main window
has no title bar. UiRoot now gives a directly-hit grip's own edges
priority over its generic proximity heuristic, and a directly-hit move
handle the same priority over ambient proximity — so the plain top strip
moves the window while its two corner grips resize it including the Y
axis, and all 4 edges + 4 corners work everywhere else. This also fixes
the reported "no diagonal cursor at corners" (CursorFeedbackController's
existing RetailCursorCatalog cursor ids already matched the DAT exactly;
they just never received a genuine diagonal edge combination) and "cannot
grow in Y from the bottom-right corner" (the old NineSlice+crop mount's
indirection is gone; the Imported mount uses the DAT's real
minH=100/maxH=2000/minW=300/maxW=2000 directly).

The 8 cosmetic "_Locked" border-art twins default hidden (register row
AP-185 — retail's UiLocked-driven art swap between the two skins is not
ported; UiRoot.UiLocked continues to gate the underlying interaction
correctly either way). The 4 chat-window-1..4 indicator buttons import
generically (visible, inert) for CH6b to wire. The two hand-drawn
translucent-black tints on the transcript/input are removed now that
their parent panels draw their own authored background sprites.

Filed #366 (chat window's new-unseen-text indicator 0x1000048C is
swallowed by UiText.ConsumesDatChildren, pre-existing and out of scope).
Corrected the research doc's "all eight grips" claim against the direct
DAT dump. Full Release suite: 12,317 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:38:27 +02:00

38 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 shift-digit quickslot rows). So retail's default is Alt+1 … Alt+4, not bare 14. UNVERIFIED: the exact modifier bit → key mapping (4 = Alt vs 4 = Ctrl). Cheapest resolution: read keymap-default.txt's modifier legend, or one cdb breakpoint on UIElementManager::KeyPressEvent. The mechanism below is independent of this.

The dispatch chain is fully generic — there is no chat-specific code in it:

UIElementManager::KeyPressEvent @0x0045C300
  0045c347   DoVisibilityToggleAction(this, actionId)

UIElementManager::DoVisibilityToggleAction @0x0045B660
  0045b680   look up actionId in m_elementInputActionListenerTable
  0045b6c1   for each registered element:
                 BroadcastElementMessage(element, 0x31, actionId, 0)

and the registration side is a plain authored property:

UIElement::OnSetAttribute  (property switch) @0x00462D80
  004631d7   case 0x24:                       // "input action" property
  004631fb       UIElementManager::RegisterElementForInputAction(
                     UIElementManager::s_pInstance, enumValue, this)

So: LayoutDesc property 0x24 on the window element declares the input action that toggles it; the manager broadcasts element message 0x31 ("toggle visibility") to every element registered for that action. The same mechanism serves ToggleInventoryPanel, ToggleSpellbookPanel, etc.

UNVERIFIED: the numeric action-enum values behind ToggleFloatingChatWindow1..4 (the keymap file stores names, and the property 0x24 value lives in the LayoutDesc, not in code). Cheapest resolution: dump property 0x24 for elements 0x10000505/0x1000050E/0x1000050F/0x10000510 from LayoutDesc 0x21000070/gameplay root — one ui-studio --dump pass. Not needed to implement CH6: acdream binds its own actions.

1.4 The 1/2/3/4 buttons in the main window are STATE MIRRORS, not the toggle

They exist and they are authored (§2.1, elements 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.

UNVERIFIED (and the one place I would not guess): whether clicking those four buttons does anything in retail at all. Two readings are consistent with the decomp — (a) they are pure indicators, and (b) they carry the same LayoutDesc property 0x24 input-action value as the windows themselves, so a click routes through the generic UIElement action path rather than through any chat code. (b) is the more likely reading given §1.3's generic mechanism. Cheapest resolution: the same LayoutDesc property dump as above, reading property 0x24 on 0x100005220x10000525. For CH6 it is safe to wire both: keybind AND button click both call the same toggle, because retail's observable behaviour (button lights up iff window is visible) is satisfied either way.

1.5 Closing a floaty window from its own title bar

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

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

1.6 The lock-UI cosmetic swap

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


2. LayoutDesc geometry

2.1 Main chat window — LayoutDesc 0x2100006F

Window element 0x10000601 (this is the id gmGamePlayUI looks up and the id SaveScreenLayout @0x004EAD50 writes as <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.

UNVERIFIED: retail's shipped default values for the two sliders. They are not constants in code (the only in-code fallback is 1.0f); they arrive from the server's GameplayOptions blob or from the options page's authored slider defaults. Cheapest resolution: one cdb breakpoint on ChatInterface::SetDefaultOpacity at login and read st(0) — or dump the slider template defaults from the options LayoutDesc (0x2100002B).


4. Persistence — how filters, geometry, visibility and title survive

4.1 The per-window option structure

Every per-window chat setting lives inside ONE global gameplay option, an array indexed by windowId - 1. PlayerModule::GetChatOptionStructure @0x005D5300:

005d5326  find option 0x1000008C in m_colGameplayOptions       // the ARRAY
005d5360  … if absent: SetPropertyName(&p, 0x1000008C); hash.add(p)
005d5473  if (arrayProp->type == 0x11 && (windowId-1) < arrayProp->capacity)
005d54a8      SetPropertyName(&elem, 0x1000008B)               // the ELEMENT
005d54b3      for i in currentCount..=(windowId-1): append elem
005d553b  return arrayProp->vtable[0x10C](windowId - 1)        // entry
  • 0x1000008C — the per-window option ARRAY (one entry per chat window).
  • 0x1000008B — the property name of each ARRAY ELEMENT (a nested bag).

PlayerModule::InqChatWindowOption @0x005D5540 / SetChatWindowOption @0x005D5570 are thin (windowId, propertyId) accessors over that structure.

4.2 The complete InqChatWindowOption property family

id type written by read by
0x1000007F bitfield64 chat options UI (gmChatOptionsUI::AddCheckboxBitfield64Option @0x0049EDA0) ChatInterface::UpdateFromPlayerModule @0x004F3920; live via RecvNotice_GameplayOptionChanged @0x004F30E0
0x10000086 int — window X gmFloatyChatUI::MoveTo @0x004CE840:004ce892; gmFloatyMainChatUI::MoveTo @0x004D2D10:004d2e06 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. 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.
  • New row if CH6b keeps text out of the window alpha (retail's ChatInterface::SetOpacity @0x004F3120 fades the whole surface).
  • New row for local-only chat-window persistence until CH6f, since retail stores this server-side in GameplayOptions.

Appendix — quick id reference

id meaning
0x1000007E LayoutDesc attribute: chat window id (m_eWindowID)
0x1000007F per-window text-type filter (bitfield64)
0x10000080 global default (unfocused) opacity (float)
0x10000081 global active (focused) opacity (float)
0x10000086 / 0x10000087 per-window X / Y
0x10000088 / 0x10000089 per-window width / height
0x1000008A per-window visible flag
0x1000008B per-window option-bag element name
0x1000008C the per-window option ARRAY (global gameplay option)
0x1000008D per-window title (StringInfo)
0x2A / 0x2B / 0x2C / 0x2D Resizebar bottom / left / right / top bools
0x24 LayoutDesc property: input action that toggles this element
0x3C / 0x3D / 0x3E / 0x3F max height / max width / min height / min width
0x10000040 element class gmFloatyChatUI
0x10000041 element class gmMainChatUI
0x10000050 element class gmFloatyMainChatUI
9 element class UIElement_Resizebar
0x2100006F LayoutDesc — main chat window
0x2100005B LayoutDesc — floating chat window (all four)
0x10000601 main chat window element (<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