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

@ -616,7 +616,41 @@ public sealed class RetailUiRuntime : IDisposable
_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)
{

View file

@ -40,8 +40,14 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
Attach(handle);
}
/// <summary>Restore all registered windows after character and screen are known.</summary>
public void RestoreAll()
/// <summary>Restore all registered windows after character and screen are
/// 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);
string character = _characterKey();
@ -68,7 +74,52 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
screen,
restoreVisibility: !_stateManagedVisibilityWindows.Contains(handle.Name));
// 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