Decomp-first per the block's rule: the research doc
(docs/research/2026-08-13-retail-ui-display-change.md, committed here)
pulled retail's actual mechanism before any code. A display change runs
UIElementManager::RefreshEvent @0x0045C530 ->
UIElement::UpdateForParentSizeChange @0x00462640, which unconditionally
re-applies every floating window's own clamping MoveTo override
(x = max(0, min(x, parentW - selfW)) - top-left priority, oversized
windows pin to 0), then broadcasts global message 0xE whose sole
listener reloads the per-resolution auto layout. No proportional moves,
no resets; retail saves layouts only via @saveui.
Port: RetailWindowLayoutPersistence.ClampAllToScreen() is the cascade
clamp (no store I/O; _restoring suppresses the per-move save so a live
drag-resize cannot write settings.json per frame), and
RetailUiRuntime.Draw carries a two-step screen-size edge detector:
change frame -> clamp; first stable frame -> one
RestoreAll(saveBack:false) per-resolution reload (the 0xE analog; no
lazy save-back, matching retail's save-only-on-command). The login
restore path already used retail's exact clamp math (Apply) - the live
trigger was the missing half, which is precisely the stranding the user
reported.
Deliberate deviation, register AD-91: retail's gmFloatyChatUI windows
have NO clamp and can strand; the block's requirement ("UI windows must
stay reachable") clamps every registered window uniformly.
Tests: 5 new persistence facts (clamp/top-left-pin/no-move/no-save-on-
clamp/no-save-on-live-reload). App suite 4,967/3 skips. Gate script
section D3 filled in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
22 KiB
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:
- A per-element re-anchoring cascade that runs on every parent resize.
UIElement::ResizeToiterates its children and callsUIElement::UpdateForParentSizeChangeon 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 virtualMoveTo+ResizeTo. - A clamp-to-parent rule baked into every floating window's
MoveTooverride:x = max(0, min(x, parentW - selfW)),y = max(0, min(y, parentH - selfH)). Because the cascade above ends in an unconditional virtualMoveTo, 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. - Per-resolution layout persistence: a plain-text auto-layout file keyed by
character + world + resolution (
UI-<char>-<world>-<W>-<H>.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 clampingMoveTo.
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
Device::LoadDisplayPreferences— read the desired presentation (theDisplay.Resolutionpreference packs W/H into one dword:W = res >> 16,H = res & 0xFFFF, see 0x00439070).- Strip/refresh window styles (
SetWindowLongA,SetWindowPos). Render::RestartRenderingSystem(presentation, config)(0x0054D6B0):Render::RestartDevice+GraphicsResource::RestoreLostResources()+ fire every registered RGR callback ("restore graphics resources",Render::LinkRGRCallback0x0054F000). The UI's callback is described in §3.- 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.SetWindowPosapplies it. - 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:
BroadcastGlobalMessage(5, 0)— no registrant of global message 5 was found in this build (see UNKNOWNs).m_pRootElement->vtable->ResizeTo(GetDisplayWidth(), GetDisplayHeight())— the manager's root ("hollow element", a plain full-screenUIElementcreated at display size byCreateHollowElement0x0045D0E0) is resized to the new display size.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
__inner23bit 21 set — set bySetIsRootElement): 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)).
- Root-level element (no parent, or
- The new reference frame:
- Root-level: (0,0)..(displayW-1, displayH-1) from the live
RenderDevice. - Otherwise: the parent's current box (
GetCurrentPosition(parent)).
- Root-level: (0,0)..(displayW-1, displayH-1) from the live
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::ResizeToclamps 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-<charName>-<worldName>-<W>-<H>.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 <name>) |
SaveScreenLayout (0x004EAD50) writes one text line per movable window:
<TAG> 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): <SBOX> <CHAT> <FCH1>..<FCH4>
<EXAM> <VITS> <SVIT> <ENVP> <PANS> <TBAR> <INDI> <PBAR> <COMB>
<RADA>. 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 inCPlayerSystem::m_layoutFromFile.- Global message 0xE — every resolution change (this is the "layout follows the
resolution" behavior the
@saveautouihelp text describes). @loadui [<name>]/@loadautoui(handlersClientCommunicationSystem::DoLoadUI0x00570150;saveautoui/loadautouiregistered at 0x00584FBD/0x00585029).
Save triggers: only @saveui [<name>] (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).
ChangePresentationrestarts the device;RefreshEventresizes the manager root to 1024x768;UIElement::ResizeTocascadesUpdateForParentSizeChangedown the tree.- The gameplay page (a root element) re-derives its box from its authored anchors
against (authored LayoutDesc resolution → 1024x768) and shrinks; its own
ResizeTocascades to its children — the floating windows. - 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. - Then the 0xE broadcast fires and, if
UI-<char>-<world>-1024-768.txtexists, the user's saved 1024x768 layout overrides the clamped positions (ResizeTo before MoveTo, both clamped again). - 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)
- 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. - Port the clamp into the floating-window move seam (the equivalent of the
MoveTooverride):x = max(0, min(x, parentW - selfW)),ylikewise, 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. - 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.
- Do not reset to authored defaults on display change and do not scale positions proportionally — retail does neither for free-floating windows.
- Apply-order detail worth copying: retail applies
ResizeTo(W,H)beforeMoveTo(X,Y)when restoring a window, so the clamp evaluates against the restored size, not the stale one. - 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.
- 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
UpdateForParentSizeChangeequivalent — worth its own slice; the floating-window stranding fix only needs items 1–5.
8. UNKNOWNs / ambiguities
- Global message 5 (broadcast by
RefreshEvent): noRegisterForGlobalMessage(_, 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
SetupChildrenmember 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 (_ftol2with prior float state). The scale factornewFrameW/oldFrameWis visible; whether Y uses an independent Y-scale was not recoverable from the export (almost certainly yes by symmetry). __inner23bit 21 ("use display frame instead of parent frame" inUpdateForParentSizeChange) is inferred to be theSetIsRootElementflag; 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 →__inner23bits 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.@saveautouihandler passing"#auto"is inferred from the#autostring, theDoSaveAutoUIneighborhood (0x005702C5..0x005702FA) and the help text; the exact call site line was not read end-to-end.- The order of
%sargs (char vs world) inUI-%s-%s-%d-%d.txtwas 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 |