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:
parent
8463d64311
commit
2153bee247
7 changed files with 620 additions and 10 deletions
|
|
@ -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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
384
docs/research/2026-08-13-retail-ui-display-change.md
Normal file
384
docs/research/2026-08-13-retail-ui-display-change.md
Normal 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 (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-<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 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 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 |
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue