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

@ -247,6 +247,111 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
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(
UiRoot root,
string name,