acdream/docs/research/2026-08-13-retail-ui-display-change.md
Erik 2153bee247 fix #390: retail display-change UI cascade — clamp + per-res reload
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>
2026-08-13 17:42:50 +02:00

22 KiB
Raw Permalink Blame History

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-<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 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 (FCH1FCH4 in the layout file): persists X/Y to PlayerModule but does not clamp. Retail's floating chat windows 14 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-<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 14), 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 [<name>] / @loadautoui (handlers ClientCommunicationSystem::DoLoadUI 0x00570150; saveautoui/loadautoui registered at 0x00584FBD/0x00585029).

Save triggers: only @saveui [<name>] (DoSaveUI 0x0056FFF0 → CM_UI::SendNotice_SaveUIgmGamePlayUI::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-<char>-<world>-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 FCH1FCH4 (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 FCH1FCH4 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 15.

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 (@lockuiUpdateLockedStatus), 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, FCH14 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