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>
This commit is contained in:
Erik 2026-08-13 17:42:50 +02:00
parent 8463d64311
commit 2153bee247
7 changed files with 620 additions and 10 deletions

View file

@ -56,7 +56,25 @@ user-directed) in the implementing commit. Part of the display block
## #390 — UI windows stranded off-screen when the resolution shrinks (no retail reposition/clamp on display change) ## #390 — UI windows stranded off-screen when the resolution shrinks (no retail reposition/clamp on display change)
**Status:** OPEN — filed 2026-08-13 (user gate report: "If I go from a high **Status:** DONE 2026-08-13 (this commit) — display block slice 3, pending
the user's gate. Retail's mechanism was pulled from the decomp FIRST
(`docs/research/2026-08-13-retail-ui-display-change.md`): a display change
runs the UI cascade (`UIElementManager::RefreshEvent @0x0045C530`
`UIElement::UpdateForParentSizeChange @0x00462640`), which unconditionally
re-applies every floating window's own clamping `MoveTo`
(`x = max(0, min(x, parentW selfW))`, top-left priority), then reloads
the per-resolution auto layout (global message 0xE) — no proportional
moves, no resets, saves only via `@saveui`. Port:
`RetailWindowLayoutPersistence.ClampAllToScreen()` (the cascade clamp, no
I/O, every attached window — including floating chats, which retail
leaves unclamped: register row AD-91) + `RetailUiRuntime.Draw`'s two-step
screen-size edge detector (change frame → clamp; first stable frame →
one `RestoreAll(saveBack:false)` per-resolution reload — no store writes
from live changes). The login restore path already carried retail's exact
clamp math (`Apply`); the live trigger was the missing half. Original
filing below.
**Original filing:** OPEN — filed 2026-08-13 (user gate report: "If I go from a high
resolution to a low, the GUI will be outside of the screen and I have to resolution to a low, the GUI will be outside of the screen and I have to
resize the window to get it"). Floating retail-UI windows keep absolute resize the window to get it"). Floating retail-UI windows keep absolute
pixel positions across resolution changes; a panel parked at x=2000 on a pixel positions across resolution changes; a panel parked at x=2000 on a

File diff suppressed because one or more lines are too long

View file

@ -44,11 +44,28 @@ default) on first load.
desktop's own mode — the deliberate replacement for retail's 800x600 desktop's own mode — the deliberate replacement for retail's 800x600
default; register row IA-22), and the dropdown shows it selected. default; register row IA-22), and the dropdown shows it selected.
## §D3 — UI windows stay reachable on downscale (#390) — PENDING SLICE ## §D3 — UI windows stay reachable on downscale (#390)
*(To be filled in when the retail reposition mechanism lands. The test Retail's rule, ported exactly: on a display change every floating window
shape will be: park panels near the right/bottom edges at 2560x1440, pick re-clamps to `x = max(0, min(x, screenW windowW))` — top-left priority —
1280x720, and confirm every window is still reachable per retail's rule.)* and the per-resolution saved layout for the NEW size reloads (without
saving anything until you save).
1. **At 2560x1440, drag several windows to the edges** — chat to the
bottom-right corner, the Options panel far right, a floating chat too.
2. **Pick 1280x720 in the Resolution dropdown.** Every window must end up
fully inside the new screen — anything that was past the new bounds
slides in flush with the edge; nothing needs a window-resize to fetch
back. (This includes floating chat windows — retail actually strands
those; we clamp them by the block's own requirement, register AD-91.)
3. **Drag-resize the window edge smaller** (not via the dropdown). Windows
must stay reachable continuously while you drag — the clamp runs live.
4. **Per-resolution memory:** at 1280x720 move a window somewhere
distinctive, pick 2560x1440, then pick 1280x720 again — the window
returns to where 1280x720 last had it (each resolution keeps its own
layout, like retail's per-resolution auto layouts — YOUR drags save
immediately under the current resolution; the automatic clamp/reload on
a resolution change never writes anything).
## §D4§D6 — Fullscreen family (#377/#376/#388) — PENDING SLICES ## §D4§D6 — Fullscreen family (#377/#376/#388) — PENDING SLICES

View file

@ -0,0 +1,384 @@
# 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_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-<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 (`@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, 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 |

View file

@ -616,7 +616,41 @@ public sealed class RetailUiRuntime : IDisposable
_automation?.Tick(deltaSeconds); _automation?.Tick(deltaSeconds);
} }
public void Draw(System.Numerics.Vector2 screenSize) => Host.Draw(screenSize); private System.Numerics.Vector2 _lastScreenSize;
private bool _screenSizeSettling;
public void Draw(System.Numerics.Vector2 screenSize)
{
// #390: retail's display-change UI cascade, ported as a two-step edge
// detector on the per-frame screen size:
// - the frame a change is seen: re-clamp every floating window into
// the new bounds (retail's unconditional clamping MoveTo cascade,
// UIElementManager::RefreshEvent @0x0045C530) — cheap, no I/O,
// keeps windows reachable through a live drag-resize;
// - the first frame the size REPEATS after a change: one reload of
// the per-resolution saved layout (retail's post-change global
// message 0xE → per-resolution auto-layout reload), without the
// login path's lazy save-back (retail saves only via @saveui).
// The first-ever frame seeds the size silently — the login restore
// owns initial placement.
if (_lastScreenSize == default)
{
_lastScreenSize = screenSize;
}
else if (screenSize != _lastScreenSize)
{
_persistence?.ClampAllToScreen();
_lastScreenSize = screenSize;
_screenSizeSettling = true;
}
else if (_screenSizeSettling)
{
_screenSizeSettling = false;
_persistence?.RestoreAll(saveBack: false);
}
Host.Draw(screenSize);
}
public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action) public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action)
{ {

View file

@ -40,8 +40,14 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
Attach(handle); Attach(handle);
} }
/// <summary>Restore all registered windows after character and screen are known.</summary> /// <summary>Restore all registered windows after character and screen are
public void RestoreAll() /// known. <paramref name="saveBack"/> (#390): the login-time restore keeps
/// its lazy schema-migration save; the LIVE display-change reload passes
/// false — retail persists layouts only via <c>@saveui</c>/<c>@saveautoui</c>,
/// never as a side effect of a display change, and writing here from a
/// mid-drag reload would litter settings.json with intermediate-resolution
/// keys.</summary>
public void RestoreAll(bool saveBack = true)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
string character = _characterKey(); string character = _characterKey();
@ -68,7 +74,52 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
screen, screen,
restoreVisibility: !_stateManagedVisibilityWindows.Contains(handle.Name)); restoreVisibility: !_stateManagedVisibilityWindows.Contains(handle.Name));
// Lazily migrate legacy position-only entries into the complete schema. // Lazily migrate legacy position-only entries into the complete schema.
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle)); if (saveBack)
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
}
}
finally
{
_restoring = false;
}
}
/// <summary>
/// #390: retail's display-change anti-stranding rule. On every display
/// change, retail's UI cascade (<c>UIElementManager::RefreshEvent
/// @0x0045C530</c> → <c>UIElement::UpdateForParentSizeChange
/// @0x00462640</c>) unconditionally re-applies each floating window's
/// own <c>MoveTo</c> override, whose clamp is
/// <c>x = max(0, min(x, parentW selfW))</c> — top-left priority, so an
/// oversized window pins to 0 and its top-left chrome stays reachable.
/// Same math as <see cref="Apply"/>'s restore clamp, run against the live
/// screen with no store I/O — safe to call on every resize frame.
/// Deliberate deviation, register-rowed with #390: retail's floating
/// chats (<c>gmFloatyChatUI</c>) do NOT clamp and can strand; the block's
/// product requirement ("UI windows must stay reachable") clamps ALL
/// registered windows including them.
/// </summary>
public void ClampAllToScreen()
{
ObjectDisposedException.ThrowIf(_disposed, this);
var screen = ValidScreenSize();
// _restoring also suppresses OnChanged's per-move save: this runs on
// every resize FRAME during a live window drag, and each clamping
// MoveTo would otherwise write settings.json per frame under whatever
// intermediate resolution the drag is passing through. A USER drag of
// the window itself still saves (OnChanged, unguarded path) — retail
// deviation already carried by this class's save-on-move behavior.
_restoring = true;
try
{
foreach (RetailWindowHandle handle in _attached)
{
float maxX = MathF.Max(0f, screen.Width - handle.Width);
float maxY = MathF.Max(0f, screen.Height - handle.Height);
float x = Math.Clamp(handle.Left, 0f, maxX);
float y = Math.Clamp(handle.Top, 0f, maxY);
if (x != handle.Left || y != handle.Top)
handle.MoveTo(x, y);
} }
} }
finally finally

View file

@ -247,6 +247,111 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
Assert.False(radar.IsVisible); Assert.False(radar.IsVisible);
} }
// ── #390: retail's display-change anti-stranding cascade ────────────
// The clamp rule is each floating window's own MoveTo override,
// x = max(0, min(x, parentW selfW)) (top-left priority), re-applied
// unconditionally on every display change — see
// docs/research/2026-08-13-retail-ui-display-change.md.
[Fact]
public void ClampAllToScreen_ReclampsStrandedWindows_TopLeftPriority()
{
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 2560, Height = 1440 };
RetailWindowHandle handle = Mount(
root, WindowNames.Examination, width: 310f, height: 400f);
handle.MoveTo(2200f, 1000f); // parked near the bottom-right corner
(int Width, int Height) screen = (2560, 1440);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => screen);
screen = (1280, 720); // the display shrank
persistence.ClampAllToScreen();
Assert.Equal(1280f - 310f, handle.Left);
Assert.Equal(720f - 400f, handle.Top);
}
[Fact]
public void ClampAllToScreen_OversizedWindow_PinsTopLeftToZero()
{
// max(0, min(...)) — the max applies LAST, so a window larger than
// the screen pins to 0 and its top-left chrome stays reachable.
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 800, Height = 600 };
RetailWindowHandle handle = Mount(
root, WindowNames.Examination, width: 310f, height: 400f);
handle.MoveTo(400f, 300f);
(int Width, int Height) screen = (800, 600);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => screen);
screen = (200, 200); // smaller than the window itself
persistence.ClampAllToScreen();
Assert.Equal(0f, handle.Left);
Assert.Equal(0f, handle.Top);
}
[Fact]
public void ClampAllToScreen_InBoundsWindow_DoesNotMove()
{
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 2560, Height = 1440 };
RetailWindowHandle handle = Mount(
root, WindowNames.Examination, width: 310f, height: 400f);
handle.MoveTo(100f, 120f);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (1280, 720));
persistence.ClampAllToScreen();
Assert.Equal((100f, 120f), (handle.Left, handle.Top));
}
[Fact]
public void ClampAllToScreen_DoesNotSaveTheClampedPositions()
{
// The clamp runs per resize FRAME during a live window drag; its
// MoveTo must not trip the per-move save (settings.json writes per
// frame, keyed by whatever intermediate resolution the drag passes
// through). Only USER moves save.
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 2560, Height = 1440 };
RetailWindowHandle handle = Mount(
root, WindowNames.Examination, width: 310f, height: 400f);
handle.MoveTo(2200f, 1000f); // pre-attach: no save subscription yet
(int Width, int Height) screen = (2560, 1440);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => screen);
screen = (1280, 720);
persistence.ClampAllToScreen();
Assert.Equal(1280f - 310f, handle.Left); // it DID clamp…
Assert.Null(store.LoadWindowLayout( // …and did NOT write.
"Alice", "1280x720", WindowNames.Examination, default));
}
[Fact]
public void RestoreAll_WithoutSaveBack_WritesNothingToTheStore()
{
// The live display-change reload (#390) must not persist — retail
// saves layouts only via @saveui/@saveautoui, and a mid-drag reload
// writing would litter settings.json with intermediate-resolution
// keys.
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 800, Height = 600 };
_ = Mount(root, WindowNames.Examination, width: 310f, height: 400f);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600));
persistence.RestoreAll(saveBack: false);
Assert.Null(store.LoadWindowLayout(
"Alice", "800x600", WindowNames.Examination, default));
}
private static RetailWindowHandle Mount( private static RetailWindowHandle Mount(
UiRoot root, UiRoot root,
string name, string name,