diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 14290d36..df963fc8 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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) -**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 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 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index e63725c1..634a1850 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 69 active rows (AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 70 active rows (AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -185,6 +185,7 @@ readiness/requeue adaptation. See | AD-87 | **Filed 2026-08-12 at Campaign FA slice FA6.** The allegiance-swear half of the two-bot headless connected gate (`FellowshipAllegianceLeaderBotPolicy`/`FellowshipAllegianceRecruitBotPolicy`) is written and wired end-to-end (proximity, `0x001D` swear, the confirmation-relay seam, `0x0020` tree-reseed assertions, break, reconnect-idempotence) but has never actually been verified to complete over the wire — `AllegianceGateEnabled = false` in both classes keeps it unreachable by default. Six live runs against local ACE all reproduced the same result: the fellowship half passes decisively (the Recruit bot's own `RuntimeFellowshipState` flips, proven three separate times), but ACE returns nothing at all to the `0x001D` swear (no `0x0274` confirmation, no `0x0020`, no error) even at 0.005 m separation — see docs/ISSUES.md #384 for the full evidence trail. So while the FELLOWSHIP two-session machinery is proven live, the ALLEGIANCE two-session machinery (Runtime commands, wire builders, `RuntimeAllegianceState` reseed) remains unverified end-to-end over a real connection — only its unit/fixture-level tests and its (successful) LOCAL echo on the swearer's own client are exercised. | `src/AcDream.Headless/Policies/HeadlessBotPolicy.cs` (`FellowshipAllegianceLeaderBotPolicy.AllegianceGateEnabled`, `FellowshipAllegianceRecruitBotPolicy.AllegianceGateEnabled`, both `false`) | Shipping the fellowship gate ALONE (rather than blocking the whole slice on the allegiance blocker) matches the campaign's own D8/item-6 split — fellowship and allegiance are independent retail systems with independent wire families, and the fellowship half's proof stands on its own regardless of the allegiance outcome. Disabling rather than deleting the allegiance code keeps a reviewed-quality, ready-to-run harness in place for whoever closes #384. | Anyone reading "the FA6 bot-vs-ACE gate passed" without the qualifier could assume the allegiance swear/break/reconnect path is proven over the wire when it is not — only its LOCAL send-and-echo behavior is proven; ACE's actual acceptance of the swear is the open question #384 tracks. | docs/ISSUES.md #384; `docs/research/2026-08-11-fa-allegiance-wire.md` §1.3 (the expected `0x0274`/`0x0275`/`0x0020` handshake); run6 evidence (0.005 m distance, zero inbound after swear) | | AD-88 | **Filed 2026-08-13 at the #385 dropdown fix (classification: UNCLEAR).** The vendor category dropdown ships G5's fixed 6-row scrollable popup window, but its authored popup ListBox (`0x21000043/0x10000350`) is edge-docked on all four sides (L=T=R=B=1, measured by menuprobe3 `OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle`) — the exact authored condition that arms retail `UIElement_Menu::RecalculatePopupSize @0x0046caf0`, which resizes the popup to the ListBox's summed content height, uncapped (`0x0046e5f4..0046e66c`). The Config option-menus' identical docked shape now drives `UiMenu.PopupSizeToContent=true` (#385); vendor deliberately keeps `false`. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (its UiMenu wiring leaves `PopupSizeToContent` at the class-default false) | The G5 vendor-gate retail screenshot was read as a ~6-row-with-scrollbar look and the vendor connected gate USER-PASSED on that shape — reworking a user-gated surface on decomp inference alone would invert the retail-oracle rule. The two pieces of evidence conflict; the row records the conflict rather than silently picking a side. | If retail actually opens the category popup full-height, our vendor dropdown shows a 6-row scroll window where retail shows every category at once — visible at any vendor with >6 categories. If retail truly shows 6 rows, the mechanism question (why the docked ListBox does not trigger RecalculatePopupSize there) is unanswered and could mislead the next dropdown port. | docs/ISSUES.md #386 (the retail side-by-side to run + the two candidate resolutions); #385 (the Config fix that exposed the conflict) | | AD-90 | **Filed 2026-08-13 at the #389 mechanism-review fix round (finding M1).** Retail's smartbox divisor aspect is not raw width/height: `RenderDevice::ComputeAspectForViewport @0x0054f150` yields `(w/h) × m_DisplayAspectRatio × 0.75`, with `m_DisplayAspectRatio` fed by the registered `Render.AspectRatio` preference. At that preference's DEFAULT (4:3) the factor is exactly 1.0f and the expression collapses to raw w/h — which is what acdream uses. acdream carries no AspectRatio preference at all. Also folded in: retail's `SetFOVRad` gate arithmetic ACCEPTS NaN (x87 unordered-compare quirk) where acdream's port rejects it — unreachable in practice, deliberately not reproduced (mechanism review M3). | `src/AcDream.App/Rendering/RetailFieldOfView.cs` (class doc names this row) | Bit-exact at retail's registered default; the preference existed for 2003-era stretched-CRT correction with no modern counterpart. Reproducing it would add a user knob retail itself defaulted away. | A retail user who had changed `Render.AspectRatio` saw framing acdream cannot reproduce; anyone porting FOV behavior from a capture made with a non-default AspectRatio preference will measure a mismatch against our law. | `RenderDevice::ComputeAspectForViewport @0x0054f150`; `Render::SetFOVRad @0x0054b2d0`; consumer `D3DXMatrixPerspectiveFovLH @0x0059ab71`; docs/research/2026-08-13-389-fov-mechanism-review.md | +| AD-91 | **Filed 2026-08-13 at the #390 port.** acdream's display-change clamp covers ALL registered floating windows; retail's does not — every retail floaty overrides `MoveTo` with the clamp `x = max(0, min(x, parentW − selfW))` EXCEPT `gmFloatyChatUI` (floating chats 2–4), which has no clamp and can genuinely strand off-screen on a resolution change (decomp finding, `docs/research/2026-08-13-retail-ui-display-change.md`). The display block's product requirement ("UI windows must stay reachable on resolution change", the 2026-08-13 /goal) overrides the exception. | `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs` (`ClampAllToScreen` — clamps every attached handle, floating chats included) | User-directed reachability beats reproducing a retail defect-shaped gap; the clamp math itself is retail's own, applied uniformly. | A retail-parity comparison that deliberately strands a floating chat window will find acdream rescuing it where retail leaves it lost. | `UIElementManager::RefreshEvent @0x0045C530`; `UIElement::UpdateForParentSizeChange @0x00462640`; the per-floaty `MoveTo` clamp overrides; docs/research/2026-08-13-retail-ui-display-change.md | --- diff --git a/docs/research/2026-08-13-display-block-test-script.md b/docs/research/2026-08-13-display-block-test-script.md index 1ff43083..16c5d660 100644 --- a/docs/research/2026-08-13-display-block-test-script.md +++ b/docs/research/2026-08-13-display-block-test-script.md @@ -44,11 +44,28 @@ default) on first load. desktop's own mode — the deliberate replacement for retail's 800x600 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 -shape will be: park panels near the right/bottom edges at 2560x1440, pick -1280x720, and confirm every window is still reachable per retail's rule.)* +Retail's rule, ported exactly: on a display change every floating window +re-clamps to `x = max(0, min(x, screenW − windowW))` — top-left priority — +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 diff --git a/docs/research/2026-08-13-retail-ui-display-change.md b/docs/research/2026-08-13-retail-ui-display-change.md new file mode 100644 index 00000000..8a7dfe0a --- /dev/null +++ b/docs/research/2026-08-13-retail-ui-display-change.md @@ -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----.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----.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 `) | + +`SaveScreenLayout` (0x004EAD50) writes one text line per movable window: +` 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): `` `` ``..`` +`` `` `` `` `` `` `` `` `` +``. 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 []` / `@loadautoui` (handlers `ClientCommunicationSystem::DoLoadUI` + 0x00570150; `saveautoui`/`loadautoui` registered at 0x00584FBD/0x00585029). + +Save triggers: **only** `@saveui []` (`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---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 | diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 2898ef83..07fa2f2e 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -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) { diff --git a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs index 9022c780..efbeb673 100644 --- a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs +++ b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs @@ -40,8 +40,14 @@ public sealed class RetailWindowLayoutPersistence : IDisposable Attach(handle); } - /// Restore all registered windows after character and screen are known. - public void RestoreAll() + /// Restore all registered windows after character and screen are + /// known. (#390): the login-time restore keeps + /// its lazy schema-migration save; the LIVE display-change reload passes + /// false — retail persists layouts only via @saveui/@saveautoui, + /// 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. + 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; + } + } + + /// + /// #390: retail's display-change anti-stranding rule. On every display + /// change, retail's UI cascade (UIElementManager::RefreshEvent + /// @0x0045C530UIElement::UpdateForParentSizeChange + /// @0x00462640) unconditionally re-applies each floating window's + /// own MoveTo override, whose clamp is + /// x = max(0, min(x, parentW − selfW)) — top-left priority, so an + /// oversized window pins to 0 and its top-left chrome stays reachable. + /// Same math as '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 (gmFloatyChatUI) do NOT clamp and can strand; the block's + /// product requirement ("UI windows must stay reachable") clamps ALL + /// registered windows including them. + /// + 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 diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs index 0370d04e..ca9554f7 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs @@ -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,