# Retail UI behavior on display-resolution change (research for #390) **Date:** 2026-08-13 **Oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named), cross-checked against the raw PDB-paired binary (`C:\Users\erikn\Downloads\acclient.exe`, v11.4186) where the BN export lacked bodies. All addresses are the 2013 build's VAs. **Question:** what does the retail client do to its UI windows when the display resolution changes, so floating windows never end up stranded off-screen? (acdream's floating retail-UI windows keep absolute pixel positions across resolution changes; shrinking strands them — user gate report 2026-08-13.) --- ## Executive summary Retail has **three cooperating mechanisms**, and all three matter: 1. **A per-element re-anchoring cascade** that runs on every parent resize. `UIElement::ResizeTo` iterates its children and calls `UIElement::UpdateForParentSizeChange` on each; that function recomputes the child's box from its **authored ElementDesc position**, its four **edge-anchor modes** (`m_leftEdge`/`m_rightEdge`/`m_topEdge`/`m_bottomEdge`), and the delta between the **authored reference resolution stored in the LayoutDesc** (`LayoutDesc::m_displayWidth/m_displayHeight`) and the actual display size — then applies the result through the element's **virtual** `MoveTo` + `ResizeTo`. 2. **A clamp-to-parent rule baked into every floating window's `MoveTo` override**: `x = max(0, min(x, parentW - selfW))`, `y = max(0, min(y, parentH - selfH))`. Because the cascade above ends in an *unconditional* virtual `MoveTo`, the clamp runs for every floating window on every resolution change — even when nothing else would have moved it. This is the mechanism that un-strands windows on shrink. 3. **Per-resolution layout persistence**: a plain-text auto-layout file keyed by **character + world + resolution** (`UI----.txt`), reloaded automatically after every resolution change via a dedicated global UI message (0xE), plus a server-side fallback (PlayerModule "chat window options") applied through the same clamping `MoveTo`. There is **no proportional rescaling of window positions** and **no reset-to-default on display change** (defaults only apply when an element's authored anchors say so). Positions persist per-resolution; the clamp is the safety net. --- ## 1. Trigger paths into a resolution change | Path | Function | Address | Notes | |---|---|---|---| | In-game Options change | `Render::UpdateFromPreferences` | 0x0054D850 | Diffs `Device::m_DisplayPrefs.Resolution/FullScreen/RefreshRate/SyncToRefresh/Antialiasing` against `Current_Display_*` statics; any diff → `Device::ChangePresentation()` at 0x0054DA29. | | Console command | `Device::ConsoleCommand_ForceDisplayResolution` | 0x0043A830 | `ForceDisplayResolution [w h]` → `Device::ForceDisplayResolution` (0x0043A750) → `ChangePresentation` when the size actually differs. | | Login/char-select ↔ gameplay | `gmGamePlayUI::gmGamePlayUI` / `~gmGamePlayUI` | 0x004EA010 / 0x004EA2A0 | Pre-gameplay screens run force-800x600 (`ForceDisplayResolution(1, 0x320, 0x258)`); the gameplay ctor un-forces (0x004EA093) → switch to the user's preferred resolution; the dtor re-forces (0x004EA353). | | Device lost (alt-tab etc.) | `Render::CheckForLostDevice` | 0x0054E890 | Same-resolution `Render::RestartRenderingSystem()`; runs the UI refresh cascade below but does **not** broadcast 0xE (no layout reload). | ## 2. `Device::ChangePresentation` (0x0043A2D0) — the device-side sequence 1. `Device::LoadDisplayPreferences` — read the desired presentation (the `Display.Resolution` preference packs W/H into one dword: `W = res >> 16`, `H = res & 0xFFFF`, see 0x00439070). 2. Strip/refresh window styles (`SetWindowLongA`, `SetWindowPos`). 3. **`Render::RestartRenderingSystem(presentation, config)`** (0x0054D6B0): `Render::RestartDevice` + `GraphicsResource::RestoreLostResources()` + fire every registered **RGR callback** ("restore graphics resources", `Render::LinkRGRCallback` 0x0054F000). The UI's callback is described in §3. 4. Compute the new OS window rect (windowed mode): client size + dialog-frame/caption metrics (`GetSystemMetrics`), centered on the previous window rect (or the screen), then **clamped into the desktop work area** (`SystemParametersInfoA(SPI_GETWORKAREA)`) — left/top-priority clamp, 0x0043A459..0x0043A4A9. `SetWindowPos` applies it. 5. On success: **`UIElementManager::BroadcastGlobalMessage(0xE, 0)`** (0x0043A4E6). On failure: fatal error dialog + `exit(1)`. Note the ordering: the UI refresh cascade (§3) runs **inside** step 3; the 0xE broadcast (§5's layout reload) happens **after** it. ## 3. The UI refresh cascade — `UIElementManager::RefreshEvent` (0x0045C530) `UIElementManager::Init` (0x0045EE10) registers `RefreshEvent_g` as an RGR callback at 0x0045EEB9. When the device restarts: 1. `BroadcastGlobalMessage(5, 0)` — no registrant of global message 5 was found in this build (see UNKNOWNs). 2. **`m_pRootElement->vtable->ResizeTo(GetDisplayWidth(), GetDisplayHeight())`** — the manager's root ("hollow element", a plain full-screen `UIElement` created at display size by `CreateHollowElement` 0x0045D0E0) is resized to the new display size. 3. `UIRegion::ForceUpdate(root, 7)`, `DrawDirtyRegions`, cursor re-set. ### 3a. `UIElement::ResizeTo` (0x00463C30) cascades to children After clamping the requested size against the element's min/max attributes (attr ids 0x3C..0x3F) and updating its own box, **if the size actually changed** it walks `m_children` and calls **`UIElement::UpdateForParentSizeChange(child)`** (loop at 0x00463E25..0x00463E52). All UI pages are under the manager root (mouse hit-testing recurses from `m_pRootElement`), so a display change reflows the whole tree. `UIRegion::MoveTo` (0x0069F830) and `UIRegion::ResizeTo` (0x0069F8C0) themselves are pure box math — x0/y0/x1/y1 updates plus dirty-region invalidation, **no children, no clamping** (verified by disassembling the PDB-paired binary; the BN export lacks these two bodies). ### 3b. `UIElement::UpdateForParentSizeChange` (0x00462640) — the actual math Inputs per element: - `GetOriginalPosition(this)` — the **authored** ElementDesc box (origX0, origY0, origX1, origY1) + authored z-level. - The **old reference frame**: - Root-level element (no parent, or `__inner23` bit 21 set — set by `SetIsRootElement`): the LayoutDesc's authored reference resolution — old frame = (0,0)..(`m_layout->m_displayWidth - 1`, `m_layout->m_displayHeight - 1`). **Retail .uil LayoutDescs embed the resolution they were authored at** (fields at LayoutDesc+0x30/+0x34, serialized at 0x0069A4C3/0x0069A4EB). - Otherwise: the parent's **original** box (`GetOriginalPosition(parent)`). - The **new reference frame**: - Root-level: (0,0)..(displayW-1, displayH-1) from the live `RenderDevice`. - Otherwise: the parent's **current** box (`GetCurrentPosition(parent)`). Derived: ``` deltaW = (newFrameWidth - oldFrameWidth) // ebx_6 at 0x00462766 deltaH = (newFrameHeight - oldFrameHeight) // ecx_12 at 0x00462762 scale = newFrameWidth / oldFrameWidth // float, 0x00462794 (mode-4 only) ``` Per-edge application (values are the raw `m_desc.m_leftEdge` etc. — verbatim retail field names from the PDB): | edge value | left/top coordinate | right/bottom coordinate | |---|---|---| | 0 | authored; **but if the element already has a size: keep the CURRENT runtime coordinate** (0x00462959..) — this is the "free-floating window" mode | same rule (keep current x1/y1) | | 1 | authored (left/top-anchored) | `orig + delta` (right/bottom-anchored — stretches with the frame) | | 2 | `orig + delta` (right/bottom-anchored) | authored | | 3 | centered: `(newSize>>1) - (origSize>>1)` | centered: `(newSize>>1) + (origSize>>1) - 1` | | 4 | proportional: `ftol(scale * ...)` (exact float expr elided by BN) | proportional | Finally — and this is the load-bearing part: ``` this->vtable->MoveTo(newX0, newY0); // 0x00462998 — VIRTUAL this->vtable->ResizeTo(newX1-newX0+1, newY1-newY0+1); // 0x004629A1 — VIRTUAL ``` `MoveTo` is called **unconditionally** — even for a free-floating (all-edges-0) window whose coordinates come out unchanged. The virtual dispatch lands in the floating-window overrides below, so **the clamp runs for every floating window on every display change**. ## 4. The clamp rule — floating-window `MoveTo` overrides Every "floaty" window class overrides `MoveTo` with the same exact math: ``` if (GetParent() != null) { if (x > parentWidth - selfWidth) x = parentWidth - selfWidth; if (x < 0) x = 0; if (y > parentHeight - selfHeight) y = parentHeight - selfHeight; if (y < 0) y = 0; } UIElement::MoveTo(x, y); if (m_eWindowID != 0 && PlayerSystem exists) write clamped X/Y into PlayerModule chat-window options // see §5b ``` Order of the two clamps means **top-left wins** when the window is larger than the parent (x first clamped possibly negative, then floored to 0). Clamping classes (all confirmed in the pseudo-C): | class | MoveTo addr | |---|---| | `gmFloatyToolbarUI` | 0x004CFE80 | | `gmFloatyPowerBarUI` | 0x004D15D0 | | `gmFloatyPanelUI` (the tabbed side panel host) | 0x004D20B0 | | `gmFloatyMainChatUI` (main chat when floating) | 0x004D2D10 | | `gmFloatyIndicatorsUI` | 0x004D38C0 | | `gmFloatyExaminationUI` | 0x004D45E0 | | `gmFloatyCombatPanelUI` | 0x004D5190 | | `gmSmartBoxUI` (the 3D viewport window) | 0x004D67E0 | | `gmRadarUI` | 0x004D81A0 | **NOT clamping** (verified): - `gmMainChatUI::MoveTo` (0x004CCCC0) — docked main chat; persists dock offsets into element attributes 0x54/0x55, no clamp. - `gmFloatyChatUI::MoveTo` (0x004CE840) — the extra floating chat windows (FCH1–FCH4 in the layout file): persists X/Y to PlayerModule but does **not** clamp. Retail's floating chat windows 1–4 genuinely can sit off-screen; only the auto-layout file or a re-anchor can recover them. (Retail quirk — see "what acdream should do".) - Base `UIElement::MoveTo` (0x004633E0): no positional clamp at all. - `UIElement::ResizeTo` clamps only against min/max size attributes (0x3C..0x3F), never against the screen. The same overrides are the **drag** path (title-bar drag ends in the virtual `MoveTo`), so interactive dragging obeys the same bounds — drag clamping and display-change clamping are literally the same code. ## 5. Per-resolution layout persistence ### 5a. The screen-layout file (gmGamePlayUI) `gmGamePlayUI` is the **only** registrant of global message 0xE (`RegisterForGlobalMessage(this, 0xE)` at 0x004EA0B1). Its handler (`ListenToGlobalMessage` 0x004EB5A0): ``` case 0xE: if (!m_endingSession) LoadScreenLayout("#auto"); ``` `CreateScreenLayoutPath` (0x004EA690) builds the file path in the **directory of the user-preferences file** (`PSUtils::get_directory(UserPreferences::sm_strDefaultFile)`): | name argument | format (string addr) | result | |---|---|---| | `"#auto"` (0x007C2A3C) | `"%sUI-%s-%s-%d-%d.txt"` (0x007C2A24) | `UI----.txt` where W/H = `UIRegion::GetWidth/GetHeight(m_pGameplayUI)` — the gameplay page's CURRENT size == the display resolution. **Layouts are keyed per character + world + resolution.** | | empty | `"%sUI-Default.txt"` (0x007C2A10) | shared default file | | anything else | `"%s%s.txt"` (0x007C2A04) | named file (`@saveui `) | `SaveScreenLayout` (0x004EAD50) writes one text line per movable window: ` X:%d Y: %d W: %d H: %d` (screen-space X0/Y0 + width/height), for exactly 16 elements. `LoadScreenLayout` (0x004EA8F0) reads lines with `sscanf("%s X:%d Y: %d W: %d H: %d")` (0x007C2AC4), maps the 6-char tag to an element id, then applies **`ResizeTo(W,H)` first, `MoveTo(X,Y)` second** (0x004EAC8E) — both virtual, so the §4 clamp applies to everything loaded. If the file does not exist the function returns 0 and touches nothing. Tags (strings at 0x007C2A44..0x007C2ABC): `` `` ``..`` `` `` `` `` `` `` `` `` `` ``. Element ids in save order: 0x1000049A (smartbox), 0x10000601 (main chat), 0x10000505/0x1000050E/0x1000050F/0x10000510 (floaty chat 1–4), 0x100005F7, 0x100005FA (stacked vitals), 0x100006D5 (side-by-side vitals), 0x100005FD, 0x100005FF, 0x10000603, 0x10000611, 0x10000613, 0x100006B5, 0x100006D2 (radar). Load triggers: - **`RecvNotice_PlayerDescReceived`** (0x004EB660) — login: `LoadScreenLayout("#auto")`; the boolean result is stored in `CPlayerSystem::m_layoutFromFile`. - **Global message 0xE** — every resolution change (this is the "layout follows the resolution" behavior the `@saveautoui` help text describes). - `@loadui []` / `@loadautoui` (handlers `ClientCommunicationSystem::DoLoadUI` 0x00570150; `saveautoui`/`loadautoui` registered at 0x00584FBD/0x00585029). Save triggers: **only** `@saveui []` (`DoSaveUI` 0x0056FFF0 → `CM_UI::SendNotice_SaveUI` → `gmGamePlayUI::RecvNotice_SaveUI` 0x004EB600) and `@saveautoui` (passes `"#auto"`). **Retail never auto-saves the layout file** — not on exit, not on drag. Ambient persistence is PlayerModule's job (§5b). Help text (verbatim, data 0x007DCB90): *"@saveautoui - Stores the current layout to a character and resolution specific file. This layout will automatically be used when the resolution changes for this character to the current size."* ### 5b. Server-side fallback — PlayerModule chat-window options Every floaty window with a nonzero `m_eWindowID` writes its (clamped) geometry into the PlayerModule on every move/resize via `PlayerModule::SetChatWindowOption`: | property | id | |---|---| | X | 0x10000086 | | Y | 0x10000087 | | W | 0x10000088 | | H | 0x10000089 | This rides the PlayerModule blob (the same `0x01A1` save-path Campaign OP ported). On login / player-option notices, each floaty's `UpdateFromPlayerModule` (e.g. `gmFloatyVitalsUI` 0x004CF140, called from the tail of each floaty's setup and from notice 0x4DD1F0 handlers) applies the stored geometry — **but only when `CPlayerSystem::m_layoutFromFile == 0`** (the auto-layout file wins when it exists) — again via the virtual, clamping `MoveTo`/`ResizeTo`. So the restore priority is: **per-resolution auto file > PlayerModule blob > authored LayoutDesc defaults**, and every path funnels through the clamp. ## 6. So what exactly happens to a stranded window? Scenario: 1600x1200 → 1024x768, window at (1400, 900). 1. `ChangePresentation` restarts the device; `RefreshEvent` resizes the manager root to 1024x768; `UIElement::ResizeTo` cascades `UpdateForParentSizeChange` down the tree. 2. The gameplay page (a root element) re-derives its box from its authored anchors against (authored LayoutDesc resolution → 1024x768) and shrinks; its own `ResizeTo` cascades to its children — the floating windows. 3. Each floating window's anchors are typically "free" (edge mode 0 → keep current coordinates), so the re-derivation yields its old (1400, 900) — but the result is applied through the window's clamping `MoveTo`, which pins it to `(1024 - w, 768 - h)`. **Clamp on display-change broadcast, not on load, and not a reset to default.** The clamped position is immediately written back to PlayerModule. 4. *Then* the 0xE broadcast fires and, **if** `UI---1024-768.txt` exists, the user's saved 1024x768 layout overrides the clamped positions (ResizeTo before MoveTo, both clamped again). 5. Exception: the floating chat windows FCH1–FCH4 (`gmFloatyChatUI`) skip the clamp and genuinely can stay stranded in retail. No file, no PlayerModule entry → the window keeps its authored-anchor-derived position, clamped. There is no proportional reposition of free-floating windows (mode 4 exists in the anchor system but retail's floating windows don't use it — they'd move on every change otherwise). ## 7. What acdream's port should do (mechanism only — no code here) 1. **Keep per-resolution keying** in `RetailWindowLayoutPersistence` — retail keys the auto layout by character + world + resolution. If we key only by resolution, that is a (small) divergence worth a register row; retail's key includes character and world. 2. **Port the clamp into the floating-window move seam** (the equivalent of the `MoveTo` override): `x = max(0, min(x, parentW - selfW))`, `y` likewise, top-left priority, applied on *every* programmatic or interactive move — restore-from-disk, restore-from-server, drag, and display-change reflow must all funnel through it. 3. **On display change, run the reflow then the reload, in that order**: (a) resize the UI root to the new display size and propagate a parent-size-change pass that ends in the clamping move for every floating window; (b) then load the per-resolution saved layout if one exists and apply it size-first, position-second, through the same clamping move. 4. **Do not reset to authored defaults on display change** and **do not scale positions proportionally** — retail does neither for free-floating windows. 5. Apply-order detail worth copying: retail applies `ResizeTo(W,H)` **before** `MoveTo(X,Y)` when restoring a window, so the clamp evaluates against the restored size, not the stale one. 6. Decision point (flag for the user): retail's floating chat windows FCH1–FCH4 do NOT clamp and can be stranded. Matching that exactly is retail-faithful; clamping them too is a deliberate quality divergence that needs a register row. 7. The anchor system (edge modes + LayoutDesc authored reference resolution) is the general mechanism that keeps docked/authored HUD elements correct across resolutions. If acdream's importer currently bakes absolute pixel positions at import time, the full fix for authored (non-floating) elements is the `UpdateForParentSizeChange` equivalent — worth its own slice; the floating-window stranding fix only needs items 1–5. ## 8. UNKNOWNs / ambiguities - **Global message 5** (broadcast by `RefreshEvent`): no `RegisterForGlobalMessage(_, 5)` site found; presumed a no-op in the 2013 build. (Message ids seen in use: 1 keypress, 3 per-frame UseTime, 0xB, 0xC, 0xD = UI-lock toggled (`@lockui` → `UpdateLockedStatus`), 0xE display-changed.) - **Exact tag↔element-id mapping** for the ten middle entries of the layout file: the per-tag format-string operands are BN-mislabeled vtable-relative constants. SBOX, CHAT, FCH1–4 are confirmed by adjacent data; RADA=0x100006D2 (radar), VITS=0x100005FA (stacked vitals) and SVIT=0x100006D5 (side-by-side vitals) are high-confidence from `SetupChildren` member names; TBAR/PBAR/PANS/INDI/EXAM/COMB/ENVP assignments to 0x100005F7/0x100005FD/0x100005FF/0x10000603/0x10000611/0x10000613/0x100006B5 are inferred from class names, not proven. - **Mode-4 (proportional) exact float expression** in `UpdateForParentSizeChange`: BN elided the x87 sequence (`_ftol2` with prior float state). The scale factor `newFrameW/oldFrameW` is visible; whether Y uses an independent Y-scale was not recoverable from the export (almost certainly yes by symmetry). - **`__inner23` bit 21** ("use display frame instead of parent frame" in `UpdateForParentSizeChange`) is inferred to be the `SetIsRootElement` flag; the bit arithmetic is consistent but the setter's exact bit position was not independently confirmed. - **`UIElement::SetSaveLocation`/`SetSaveSize`** (LayoutDesc attrs 0x12/0x13, setters 0x0045F930/0x0045F950 → `__inner23` bits 4/5): no named consumer found; presumed vestigial or consumed by unlabeled code. They are NOT the persistence mechanism — that is the hardcoded 16-window list + PlayerModule. - **`@saveautoui` handler** passing `"#auto"` is inferred from the `#auto` string, the `DoSaveAutoUI` neighborhood (0x005702C5..0x005702FA) and the help text; the exact call site line was not read end-to-end. - The order of `%s` args (char vs world) in `UI-%s-%s-%d-%d.txt` was not provable from the BN export's local-variable listing; both names and both dimensions are in the filename, which is what matters for the port. ## 9. Key address appendix | Function | Address | |---|---| | `Device::ForceDisplayResolution` | 0x0043A750 | | `Device::ChangePresentation` | 0x0043A2D0 (0xE broadcast at 0x0043A4E6; work-area clamp 0x0043A459) | | `Render::UpdateFromPreferences` | 0x0054D850 (→ ChangePresentation 0x0054DA29) | | `Render::RestartRenderingSystem` | 0x0054D6B0 / wrapper 0x0054D710 | | `Render::CheckForLostDevice` | 0x0054E890 | | `Render::LinkRGRCallback` | 0x0054F000 | | `UIElementManager::Init` | 0x0045EE10 (RGR hook 0x0045EEB9) | | `UIElementManager::RefreshEvent` | 0x0045C530 | | `UIElementManager::CreateHollowElement` | 0x0045D0E0 | | `UIElement::UpdateForParentSizeChange` | 0x00462640 | | `UIElement::MoveTo` / `ResizeTo` | 0x004633E0 / 0x00463C30 (child cascade 0x00463E25) | | `UIElement::SetParent` (also triggers re-anchor) | 0x00462A50 | | `UIRegion::MoveTo` / `ResizeTo` (box math only; from binary disasm) | 0x0069F830 / 0x0069F8C0 | | `gmGamePlayUI` ctor / dtor | 0x004EA010 / 0x004EA2A0 | | `gmGamePlayUI::ListenToGlobalMessage` | 0x004EB5A0 | | `gmGamePlayUI::CreateScreenLayoutPath` | 0x004EA690 | | `gmGamePlayUI::LoadScreenLayout` | 0x004EA8F0 (apply site 0x004EAC8E) | | `gmGamePlayUI::SaveScreenLayout` | 0x004EAD50 | | `gmGamePlayUI::RecvNotice_PlayerDescReceived` (`m_layoutFromFile`) | 0x004EB660 | | `ClientCommunicationSystem::DoSaveUI` / `DoLoadUI` | 0x0056FFF0 / 0x00570150 | | Clamping `MoveTo` overrides | see table in §4 | | `gmFloatyVitalsUI::UpdateFromPlayerModule` (restore pattern) | 0x004CF140 | | Path/tag/format strings | 0x007C2A04..0x007C2CC0 | | `@saveautoui` help text | 0x007DCB90 |