diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 4132c333..3be79c62 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -54,7 +54,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
-| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, and controller teardown; `RetailWindowFrame` is now the single production/Studio mount contract for both imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; production mounts in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Unported persistence semantics can still make windows drift across restart/resolution changes; lifecycle edge cases remain constrained by conformance tests rather than a Keystone byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` |
+| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, controller teardown, and schema-v2 per-character/per-resolution layout persistence; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; production mounts in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence is behaviorally reconstructed from `saveui/loadui` semantics rather than Keystone internals; lifecycle edge cases remain constrained by conformance tests instead of a byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` |
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
@@ -203,7 +203,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
| AP-95 | `UiButton` dispatches Click only; it lacks retail hover, pressed, released-outside, disabled, selected/toggle, and missing-state fallback transitions | `src/AcDream.App/UI/UiButton.cs` | Controllers manually set a few semantic states; generic pointer visuals remain incomplete | Buttons look inert or overwrite custom states, and disabled/toggle behavior can fire incorrectly | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` |
| AP-96 | Layout/property import loses raw edge-mode semantics (especially 3/4) and treats default scalar values as absent, so explicit `false`/`0` cannot override inheritance | `src/AcDream.App/UI/Layout/ElementReader.cs`; `LayoutImporter.cs` | Existing shipped layouts are hand-gated; Wave 1 adds presence-aware properties and the exact solver | Non-reference resizing recenters/stretches incorrectly; derived DAT properties silently inherit the wrong base value | `UIElement::UpdateForParentSizeChange @ 0x00462640`; production LayoutDesc inheritance |
| ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` |
-| AP-98 | **PARTIAL 2026-07-10 (Wave 2.2)** — typed handles/manager own lifecycle and every production/Studio window uses `RetailWindowFrame`. Chat maximize now ports the half-parent growth/restore algorithm, resizes the outer frame, uses DAT 100–360 height limits, and drives imported button states. Complete layout persistence is still absent. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs` | Lifecycle, mounts, and chat outer geometry now have one tested owner; Wave 2.3 adds persistence | Layouts still reset/drift across restart and resolution/character changes | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail saveui/loadui behavior |
+| ~~AP-98~~ | **RETIRED 2026-07-10 (Wave 2.3)** — typed handles/manager own lifecycle; every production/Studio window uses `RetailWindowFrame`; schema-v2 `UiWindowLayout` persists outer bounds, visibility, toolbar collapse, and chat maximize by character/resolution with legacy-radar migration, nearest-resolution fallback, clamping, and a pre-login `default` write guard. Chat maximize uses DAT 100–360 limits and preserves its lower edge when growing upward. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowLayoutPersistence.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs`; `SettingsStore.cs` | — | — | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail `saveui/loadui` behavior |
| AP-99 | Shared item activation implements only a subset of `ItemHolder::UseObject`/placement legality and confirmation policy | `src/AcDream.App/UI/ItemInteractionController.cs` | Current PvE use/equip/container flows cover the M2 loop; Wave 3 ports the complete pure decision matrix | Vendor/trade/busy/confirmation/target edge cases send the wrong action or permit an illegal use | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` |
| AP-100 | Cursor semantics cover only a reachable subset of retail states, and widget cursor media can outrank authoritative global target mode | `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/Rendering/RetailCursorManager.cs` | Target-use art works for current flows; Wave 3 ports the full table and precedence | Hovering UI can replace the use/examine/busy/combat cursor; unsupported branches fall back to OS art | `ClientUISystem::UpdateCursorState @ 0x00564630` |
| AP-101 | Toolbar quick-slot actions and most panel/Use/Examine/combat controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, stance indicators, health/name, and some toggles are present | Bound retail keys/buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` |
diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
index c15a29a3..6563f2d3 100644
--- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
+++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
@@ -259,15 +259,21 @@ scrollbar buttons, track/thumb dragging, wheel scrolling, and bottom-pin behavio
### 2.3 Persist the complete UI layout
-- [ ] Replace radar-only persistence with `UiWindowLayout` containing position,
+- [x] Replace radar-only persistence with `UiWindowLayout` containing position,
size, visibility, collapsed/maximized state, and any retail-persisted panel state.
-- [ ] Key layouts by character and resolution, matching retail `saveui/loadui`
+- [x] Key layouts by character and resolution, matching retail `saveui/loadui`
semantics; add migration from existing radar entries.
-- [ ] Restore only after the character context and screen size are known; clamp
+- [x] Restore only after the character context and screen size are known; clamp
safely on resolution changes.
-- [ ] Save move/resize on interaction completion, visibility on transition, and
+- [x] Save move/resize on interaction completion, visibility on transition, and
never let the temporary `default` login key overwrite character-specific state.
-- [ ] Apply global LockUI through the window manager to every window.
+- [x] Apply global LockUI through the window manager to every window.
+
+**Acceptance (2026-07-10):** schema-v2 per-character/resolution restore passed a
+live Debug restart with every retained window persisted. The gate also caught and
+closed hidden-inventory baseline capture (expanded main-pack rows) and chat's
+clamped maximize jump (lower edge now remains fixed). Full suite green: 4,545
+passed, 5 skipped.
### 2.4 Enforce controller teardown
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 5665a995..f2940b29 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -764,7 +764,6 @@ public sealed class GameWindow : IDisposable
// Core + the retained controller; GameWindow supplies live state only.
private AcDream.App.UI.Layout.RadarController? _radarController;
private AcDream.App.UI.Layout.RadarSnapshotProvider? _radarSnapshotProvider;
- private AcDream.App.UI.UiRadar? _radarRoot;
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
@@ -2036,7 +2035,6 @@ public sealed class GameWindow : IDisposable
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
- _uiHost.Root.WindowMoved += OnRetailWindowMoved;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
@@ -2130,7 +2128,6 @@ public sealed class GameWindow : IDisposable
ResolveChrome, vitalsDatFont, ResolveDatFont);
if (radarLayout is not null && radarLayout.Root is AcDream.App.UI.UiRadar radarRoot)
{
- _radarRoot = radarRoot;
_radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
_entitiesByServerGuid,
@@ -2162,7 +2159,6 @@ public sealed class GameWindow : IDisposable
ConstrainDragToParent = true,
ContentClickThrough = false,
});
- ApplySavedRadarPosition();
Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074.");
}
else
@@ -2223,6 +2219,7 @@ public sealed class GameWindow : IDisposable
ResizeX = true,
ResizeY = true,
Opacity = 0.75f,
+ StateController = chatController,
});
chatController.AttachWindow(chatHandle);
// The LayoutDesc root is 800px wide because it shares the display
@@ -2529,27 +2526,8 @@ public sealed class GameWindow : IDisposable
});
var inventoryFrame = (AcDream.App.UI.UiNineSlicePanel)inventoryHandle.OuterFrame;
- // Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid
- // + scrollbar + the full-window backdrop grow vertically (Left|Top|Bottom); the
- // paperdoll + side-bag column stay pinned at the top (Left|Top). The grid's ViewHeight
- // grows → more rows + the scrollbar thumb ratio (view/content) reflects it.
- void StretchV(uint id)
- {
- if (invLayout!.FindElement(id) is { } el)
- el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
- | AcDream.App.UI.AnchorEdges.Bottom;
- }
- void PinTopLeft(uint id)
- {
- if (invLayout!.FindElement(id) is { } el)
- el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top;
- }
- StretchV(0x100001D0u); // full-window backdrop
- StretchV(0x100001CFu); // gm3DItemsUI sub-window (contents-grid container)
- StretchV(0x100001C6u); // contents grid (UiItemList)
- StretchV(0x100001C7u); // contents scrollbar
- PinTopLeft(0x100001CDu); // paperdoll (fixed at top)
- PinTopLeft(0x100001CEu); // side-bag column (fixed at top)
+ // InventoryController owns descendant resize/reflow policy and captures
+ // its hidden-window baselines when it binds below.
// Phase D.2b-B — populate the inventory from ClientObjectTable: the
// "Contents of Backpack" grid + the pack-selector strip + the burden meter +
@@ -2616,6 +2594,15 @@ public sealed class GameWindow : IDisposable
toggleCharacter: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Character));
SyncToolbarWindowButtons();
+ if (_settingsStore is not null)
+ {
+ _windowLayoutPersistence = new AcDream.App.UI.RetailWindowLayoutPersistence(
+ _uiHost.WindowManager,
+ _settingsStore,
+ characterKey: () => _activeToonKey,
+ screenSize: () => (_window!.Size.X, _window.Size.Y));
+ }
+
if (_options.UiProbeEnabled)
{
void ProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
@@ -2910,7 +2897,8 @@ public sealed class GameWindow : IDisposable
_liveSession.EnterWorld(user, characterIndex: 0);
_activeToonKey = chosen.Name;
- ApplySavedRadarPosition();
+ _windowLayoutPersistence?.RestoreAll();
+ SyncToolbarWindowButtons();
if (_settingsStore is not null && _settingsVm is not null)
{
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
@@ -11799,6 +11787,7 @@ public sealed class GameWindow : IDisposable
// "default" and gets swapped to the actual character name on the
// first EnterWorld.
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
+ private AcDream.App.UI.RetailWindowLayoutPersistence? _windowLayoutPersistence;
private string _activeToonKey = "default";
private bool ToggleRetailWindow(string name)
@@ -11842,41 +11831,6 @@ public sealed class GameWindow : IDisposable
}
}
- private void ApplySavedRadarPosition()
- {
- if (_radarRoot is null || _settingsStore is null || _window is null)
- return;
-
- var saved = _settingsStore.LoadWindowPosition(
- _activeToonKey, AcDream.App.UI.WindowNames.Radar);
- if (saved is not { } position)
- return;
-
- _radarRoot.Left = System.Math.Clamp(
- position.X, 0f, System.Math.Max(0f, _window.Size.X - _radarRoot.Width));
- _radarRoot.Top = System.Math.Clamp(
- position.Y, 0f, System.Math.Max(0f, _window.Size.Y - _radarRoot.Height));
- }
-
- private void OnRetailWindowMoved(string name, AcDream.App.UI.UiElement window)
- {
- if (name != AcDream.App.UI.WindowNames.Radar || _settingsStore is null)
- return;
-
- try
- {
- _settingsStore.SaveWindowPosition(
- _activeToonKey,
- name,
- new AcDream.UI.Abstractions.Panels.Settings.UiWindowPosition(
- window.Left, window.Top));
- }
- catch (Exception ex)
- {
- Console.WriteLine($"settings: radar position save failed: {ex.Message}");
- }
- }
-
private void UpdateRetailCursorFeedback()
{
if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null)
@@ -14097,6 +14051,8 @@ public sealed class GameWindow : IDisposable
_paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures
_particleRenderer?.Dispose();
_debugLines?.Dispose();
+ _windowLayoutPersistence?.Dispose();
+ _windowLayoutPersistence = null;
_uiHost?.Dispose();
_textRenderer?.Dispose();
_debugFont?.Dispose();
diff --git a/src/AcDream.App/Studio/MockupDesktop.cs b/src/AcDream.App/Studio/MockupDesktop.cs
index ff6c2b06..233e6385 100644
--- a/src/AcDream.App/Studio/MockupDesktop.cs
+++ b/src/AcDream.App/Studio/MockupDesktop.cs
@@ -254,6 +254,7 @@ internal static class MockupDesktop
ResizeX = true,
ResizeY = true,
Opacity = 0.75f,
+ StateController = controller,
});
controller.AttachWindow(handle);
diff --git a/src/AcDream.App/UI/IRetainedWindowStateController.cs b/src/AcDream.App/UI/IRetainedWindowStateController.cs
new file mode 100644
index 00000000..cdb129bc
--- /dev/null
+++ b/src/AcDream.App/UI/IRetainedWindowStateController.cs
@@ -0,0 +1,18 @@
+namespace AcDream.App.UI;
+
+/// Panel state that is not completely described by outer-frame bounds.
+public readonly record struct RetainedWindowState(
+ bool Collapsed = false,
+ bool Maximized = false,
+ float? PersistedTop = null,
+ float? PersistedHeight = null);
+
+///
+/// Optional state seam used by retained-window persistence. Bounds are restored
+/// first; the controller then reapplies collapsed/maximized presentation.
+///
+public interface IRetainedWindowStateController
+{
+ RetainedWindowState CaptureWindowState();
+ void RestoreWindowState(RetainedWindowState state);
+}
diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
index dabe602d..a944988c 100644
--- a/src/AcDream.App/UI/Layout/ChatWindowController.cs
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -24,7 +24,7 @@ namespace AcDream.App.UI.Layout;
/// and bound in place.
///
///
-public sealed class ChatWindowController
+public sealed class ChatWindowController : IRetainedWindowStateController
{
public const uint LayoutId = 0x21000006u;
@@ -364,13 +364,16 @@ public sealed class ChatWindowController
_normalHeight = frame.Height;
float expansion = parentHeight / 2f;
- float targetTop = frame.Top;
- float targetHeight = frame.Height + expansion;
- if (frame.Top + targetHeight > parentHeight || frame.Top >= parentHeight / 2f)
- targetTop = MathF.Max(0f, frame.Top - expansion);
-
+ float targetHeight = Math.Clamp(
+ MathF.Min(frame.Height + expansion, parentHeight),
+ frame.MinHeight,
+ frame.MaxHeight);
+ bool growUp = frame.Top + targetHeight > parentHeight
+ || frame.Top >= parentHeight / 2f;
+ float targetTop = growUp
+ ? MathF.Max(0f, frame.Top - (targetHeight - frame.Height))
+ : frame.Top;
targetHeight = MathF.Min(targetHeight, parentHeight - targetTop);
- targetHeight = Math.Clamp(targetHeight, frame.MinHeight, frame.MaxHeight);
_maximized = true;
_maxMinButton?.TrySetRetailState(RetailUiStateIds.Maximized);
@@ -378,6 +381,21 @@ public sealed class ChatWindowController
handle.MoveTo(frame.Left, targetTop);
}
+ public RetainedWindowState CaptureWindowState()
+ => new(
+ Maximized: _maximized,
+ PersistedTop: _maximized ? _normalTop : null,
+ PersistedHeight: _maximized ? _normalHeight : null);
+
+ public void RestoreWindowState(RetainedWindowState state)
+ {
+ if (state.Maximized != _maximized)
+ ToggleMaximize();
+ else
+ _maxMinButton?.TrySetRetailState(
+ _maximized ? RetailUiStateIds.Maximized : RetailUiStateIds.Minimized);
+ }
+
private static ElementInfo? FindInfo(ElementInfo node, uint id)
{
if (node.Id == id) return node;
diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs
index a61a56f4..b7ba9379 100644
--- a/src/AcDream.App/UI/Layout/InventoryController.cs
+++ b/src/AcDream.App/UI/Layout/InventoryController.cs
@@ -24,6 +24,10 @@ public sealed class InventoryController : IItemListDragHandler
public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack"
public const uint TitleTextId = 0x100001D3u; // "Inventory of "
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
+ private const uint BackdropId = 0x100001D0u;
+ private const uint ContentsWindowId = 0x100001CFu;
+ private const uint PaperdollWindowId = 0x100001CDu;
+ private const uint BackpackWindowId = 0x100001CEu;
// 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
private const int ContentsColumns = 6;
@@ -92,6 +96,8 @@ public sealed class InventoryController : IItemListDragHandler
_containerList = layout.FindElement(ContainerListId) as UiItemList;
_topContainer = layout.FindElement(TopContainerId) as UiItemList;
+ ConfigureResizeLayout(layout);
+
if (_contentsGrid is not null)
{
_contentsGrid.Columns = ContentsColumns;
@@ -153,6 +159,24 @@ public sealed class InventoryController : IItemListDragHandler
Populate();
}
+ internal static void ConfigureResizeLayout(ImportedLayout layout)
+ {
+ static void Set(ImportedLayout imported, uint id, AnchorEdges anchors)
+ {
+ if (imported.FindElement(id) is not { } element) return;
+ element.Anchors = anchors;
+ element.CaptureCurrentAnchorBaseline();
+ }
+
+ AnchorEdges stretch = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom;
+ Set(layout, BackdropId, stretch);
+ Set(layout, ContentsWindowId, stretch);
+ Set(layout, ContentsGridId, stretch);
+ Set(layout, ContentsScrollbarId, stretch);
+ Set(layout, PaperdollWindowId, AnchorEdges.Left | AnchorEdges.Top);
+ Set(layout, BackpackWindowId, AnchorEdges.Left | AnchorEdges.Top);
+ }
+
public static InventoryController Bind(
ImportedLayout layout,
ClientObjectTable objects,
diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs
index 1240b4ae..b9d38594 100644
--- a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs
+++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs
@@ -66,6 +66,7 @@ public static class RetailWindowFrame
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
public bool? ContentClickThrough { get; init; }
public IRetainedPanelController? Controller { get; init; }
+ public IRetainedWindowStateController? StateController { get; init; }
}
public static RetailWindowHandle Mount(
@@ -159,7 +160,8 @@ public static class RetailWindowFrame
options.WindowName,
outerFrame,
content,
- options.Controller);
+ options.Controller,
+ options.StateController);
}
private static float ResolveConstraint(
diff --git a/src/AcDream.App/UI/README.md b/src/AcDream.App/UI/README.md
index 839577cf..45a79081 100644
--- a/src/AcDream.App/UI/README.md
+++ b/src/AcDream.App/UI/README.md
@@ -38,6 +38,9 @@ synthesis + six deep-dive docs under
geometry events, lock propagation, and controller teardown.
- `IRetainedPanelController.cs` — disposable lifecycle contract for
panel-specific show/hide and descendant-focus behavior.
+- `RetailWindowLayoutPersistence.cs` / `IRetainedWindowStateController.cs` —
+ per-character/per-resolution bounds, visibility, collapse/maximize, legacy
+ migration, and safe resolution-clamped restore.
- `Layout/RetailWindowFrame.cs` — the single production/Studio mount
contract for imported-chrome and shared-wrapper windows.
- `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,
diff --git a/src/AcDream.App/UI/RetailWindowHandle.cs b/src/AcDream.App/UI/RetailWindowHandle.cs
index da96ffcd..c200f30c 100644
--- a/src/AcDream.App/UI/RetailWindowHandle.cs
+++ b/src/AcDream.App/UI/RetailWindowHandle.cs
@@ -19,13 +19,15 @@ public sealed class RetailWindowHandle
string name,
UiElement outerFrame,
UiElement contentRoot,
- IRetainedPanelController? controller)
+ IRetainedPanelController? controller,
+ IRetainedWindowStateController? stateController)
{
_owner = owner;
Name = name;
OuterFrame = outerFrame;
ContentRoot = contentRoot;
Controller = controller;
+ StateController = stateController;
_notifiedVisible = outerFrame.Visible;
}
@@ -33,6 +35,7 @@ public sealed class RetailWindowHandle
public UiElement OuterFrame { get; }
public UiElement ContentRoot { get; }
public IRetainedPanelController? Controller { get; }
+ public IRetainedWindowStateController? StateController { get; }
public bool IsRegistered => !_disposed;
public bool IsVisible => OuterFrame.Visible;
public bool IsLocked => _owner.IsLocked;
diff --git a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs
new file mode 100644
index 00000000..e4b8eb28
--- /dev/null
+++ b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections.Generic;
+using AcDream.UI.Abstractions.Panels.Settings;
+
+namespace AcDream.App.UI;
+
+///
+/// Bridges typed retained-window lifecycle events to per-character,
+/// per-resolution settings. It deliberately ignores the temporary pre-login
+/// default character key so startup layout cannot overwrite a real
+/// character's state.
+///
+public sealed class RetailWindowLayoutPersistence : IDisposable
+{
+ private readonly RetailWindowManager _manager;
+ private readonly SettingsStore _store;
+ private readonly Func _characterKey;
+ private readonly Func<(int Width, int Height)> _screenSize;
+ private readonly List _attached = new();
+ private bool _restoring;
+ private bool _disposed;
+
+ public RetailWindowLayoutPersistence(
+ RetailWindowManager manager,
+ SettingsStore store,
+ Func characterKey,
+ Func<(int Width, int Height)> screenSize)
+ {
+ _manager = manager ?? throw new ArgumentNullException(nameof(manager));
+ _store = store ?? throw new ArgumentNullException(nameof(store));
+ _characterKey = characterKey ?? throw new ArgumentNullException(nameof(characterKey));
+ _screenSize = screenSize ?? throw new ArgumentNullException(nameof(screenSize));
+
+ foreach (RetailWindowHandle handle in manager.Windows)
+ Attach(handle);
+ }
+
+ /// Restore all registered windows after character and screen are known.
+ public void RestoreAll()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ string character = _characterKey();
+ if (!CanPersist(character)) return;
+ var screen = ValidScreenSize();
+ string resolution = ResolutionKey(screen);
+
+ _restoring = true;
+ try
+ {
+ foreach (RetailWindowHandle handle in _attached)
+ {
+ UiWindowLayout fallback = Capture(handle);
+ UiWindowLayout? saved = _store.LoadWindowLayout(
+ character, resolution, handle.Name, fallback);
+ if (saved is not { } layout) continue;
+
+ Apply(handle, layout, screen);
+ // Lazily migrate legacy position-only entries into the complete schema.
+ _store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
+ }
+ }
+ finally
+ {
+ _restoring = false;
+ }
+ }
+
+ private void Attach(RetailWindowHandle handle)
+ {
+ _attached.Add(handle);
+ handle.Moved += OnChanged;
+ handle.Resized += OnChanged;
+ handle.Shown += OnChanged;
+ handle.Hidden += OnChanged;
+ }
+
+ private void OnChanged(RetailWindowHandle handle)
+ {
+ if (_restoring || _disposed) return;
+ string character = _characterKey();
+ if (!CanPersist(character)) return;
+
+ try
+ {
+ var screen = ValidScreenSize();
+ _store.SaveWindowLayout(
+ character,
+ ResolutionKey(screen),
+ handle.Name,
+ Capture(handle));
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"settings: window layout save failed [{handle.Name}]: {ex.Message}");
+ }
+ }
+
+ private static UiWindowLayout Capture(RetailWindowHandle handle)
+ {
+ RetainedWindowState state = handle.StateController?.CaptureWindowState() ?? default;
+ return new UiWindowLayout(
+ handle.Left,
+ state.PersistedTop ?? handle.Top,
+ handle.Width,
+ state.PersistedHeight ?? handle.Height,
+ handle.IsVisible,
+ state.Collapsed,
+ state.Maximized);
+ }
+
+ private static void Apply(
+ RetailWindowHandle handle,
+ UiWindowLayout layout,
+ (int Width, int Height) screen)
+ {
+ UiElement frame = handle.OuterFrame;
+ float width = ClampDimension(layout.Width, frame.Width, frame.MinWidth, frame.MaxWidth, screen.Width);
+ float height = ClampDimension(layout.Height, frame.Height, frame.MinHeight, frame.MaxHeight, screen.Height);
+ handle.ResizeTo(width, height);
+
+ float maxX = MathF.Max(0f, screen.Width - handle.Width);
+ float maxY = MathF.Max(0f, screen.Height - handle.Height);
+ float x = Math.Clamp(FiniteOr(layout.X, handle.Left), 0f, maxX);
+ float y = Math.Clamp(FiniteOr(layout.Y, handle.Top), 0f, maxY);
+ handle.MoveTo(x, y);
+
+ handle.StateController?.RestoreWindowState(new RetainedWindowState(
+ Collapsed: layout.Collapsed,
+ Maximized: layout.Maximized));
+
+ if (layout.Visible) handle.Show();
+ else handle.Hide();
+ }
+
+ private (int Width, int Height) ValidScreenSize()
+ {
+ var screen = _screenSize();
+ return (Math.Max(1, screen.Width), Math.Max(1, screen.Height));
+ }
+
+ private static float ClampDimension(
+ float saved,
+ float current,
+ float minimum,
+ float maximum,
+ int screenExtent)
+ {
+ float value = FiniteOr(saved, current);
+ float upper = MathF.Max(minimum, MathF.Min(maximum, screenExtent));
+ return Math.Clamp(value, minimum, upper);
+ }
+
+ private static float FiniteOr(float value, float fallback)
+ => float.IsFinite(value) ? value : fallback;
+
+ private static string ResolutionKey((int Width, int Height) screen)
+ => $"{screen.Width}x{screen.Height}";
+
+ private static bool CanPersist(string key)
+ => !string.IsNullOrWhiteSpace(key)
+ && !string.Equals(key, "default", StringComparison.OrdinalIgnoreCase);
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ foreach (RetailWindowHandle handle in _attached)
+ {
+ handle.Moved -= OnChanged;
+ handle.Resized -= OnChanged;
+ handle.Shown -= OnChanged;
+ handle.Hidden -= OnChanged;
+ }
+ _attached.Clear();
+ }
+}
diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs
index 3be2aba0..26dd05d5 100644
--- a/src/AcDream.App/UI/RetailWindowManager.cs
+++ b/src/AcDream.App/UI/RetailWindowManager.cs
@@ -36,7 +36,8 @@ public sealed class RetailWindowManager : IDisposable
string name,
UiElement outerFrame,
UiElement? contentRoot = null,
- IRetainedPanelController? controller = null)
+ IRetainedPanelController? controller = null,
+ IRetainedWindowStateController? stateController = null)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
@@ -46,11 +47,15 @@ public sealed class RetailWindowManager : IDisposable
throw new InvalidOperationException(
$"Retained window '{name}' must be mounted as a direct UiRoot child before registration.");
+ IRetainedWindowStateController? resolvedStateController =
+ stateController ?? outerFrame as IRetainedWindowStateController;
+
if (_byName.TryGetValue(name, out var sameName))
{
if (ReferenceEquals(sameName.OuterFrame, outerFrame)
&& ReferenceEquals(sameName.ContentRoot, contentRoot ?? outerFrame)
- && ReferenceEquals(sameName.Controller, controller))
+ && ReferenceEquals(sameName.Controller, controller)
+ && ReferenceEquals(sameName.StateController, resolvedStateController))
return sameName;
Unregister(name);
@@ -64,7 +69,8 @@ public sealed class RetailWindowManager : IDisposable
name,
outerFrame,
contentRoot ?? outerFrame,
- controller);
+ controller,
+ resolvedStateController);
_byName.Add(name, handle);
_byFrame.Add(outerFrame, handle);
handle.NotifyInitialState();
diff --git a/src/AcDream.App/UI/UiCollapsibleFrame.cs b/src/AcDream.App/UI/UiCollapsibleFrame.cs
index 7f1fc340..97899243 100644
--- a/src/AcDream.App/UI/UiCollapsibleFrame.cs
+++ b/src/AcDream.App/UI/UiCollapsibleFrame.cs
@@ -10,7 +10,7 @@ namespace AcDream.App.UI;
/// the dragged height to the nearer stop so the frame always rests collapsed or expanded — never a
/// half-row. Toolkit UX (keystone.dll has no decomp; the dat stacks both rows always) — see IA-17.
///
-public sealed class UiCollapsibleFrame : UiNineSlicePanel
+public sealed class UiCollapsibleFrame : UiNineSlicePanel, IRetainedWindowStateController
{
public UiCollapsibleFrame(Func resolve) : base(resolve) { }
@@ -33,4 +33,15 @@ public sealed class UiCollapsibleFrame : UiNineSlicePanel
/// Test hook — OnTick is protected. Drives one snap+visibility reconcile.
internal void TickForTest(double dt) => OnTick(dt);
+
+ public RetainedWindowState CaptureWindowState()
+ => new(Collapsed: !IsExpanded);
+
+ public void RestoreWindowState(RetainedWindowState state)
+ {
+ if (ExpandedHeight <= CollapsedHeight) return;
+ Height = state.Collapsed ? CollapsedHeight : ExpandedHeight;
+ for (int i = 0; i < SecondRow.Count; i++)
+ SecondRow[i].Visible = !state.Collapsed;
+ }
}
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index c0c9dc43..4c0ec2e3 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -526,6 +526,18 @@ public abstract class UiElement
(int)Parent.Height));
}
+ ///
+ /// Capture the current mounted rect as the baseline for a compatibility
+ /// anchor policy immediately. Hidden trees do not receive a layout traversal,
+ /// so lazy capture after a persistence resize would use the wrong parent size.
+ ///
+ internal void CaptureCurrentAnchorBaseline()
+ {
+ if (Parent is null || Anchors == AnchorEdges.None) return;
+ _anchorCaptured = false;
+ ApplyAnchor(Parent.Width, Parent.Height);
+ }
+
///
/// Rebase each immediate child after a host deliberately changes this element's
/// design extent. This is used when a production DAT root contains auxiliary
diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs
index 9f6bb3a9..409c4613 100644
--- a/src/AcDream.App/UI/UiHost.cs
+++ b/src/AcDream.App/UI/UiHost.cs
@@ -111,8 +111,9 @@ public sealed class UiHost : System.IDisposable
string name,
UiElement window,
UiElement? contentRoot = null,
- IRetainedPanelController? controller = null)
- => Root.RegisterWindow(name, window, contentRoot, controller);
+ IRetainedPanelController? controller = null,
+ IRetainedWindowStateController? stateController = null)
+ => Root.RegisterWindow(name, window, contentRoot, controller, stateController);
public bool UnregisterWindow(string name) => Root.UnregisterWindow(name);
diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs
index 3562e5d7..aa670921 100644
--- a/src/AcDream.App/UI/UiRoot.cs
+++ b/src/AcDream.App/UI/UiRoot.cs
@@ -704,8 +704,9 @@ public sealed class UiRoot : UiElement
string name,
UiElement window,
UiElement? contentRoot = null,
- IRetainedPanelController? controller = null)
- => WindowManager.Register(name, window, contentRoot, controller);
+ IRetainedPanelController? controller = null,
+ IRetainedWindowStateController? stateController = null)
+ => WindowManager.Register(name, window, contentRoot, controller, stateController);
public bool UnregisterWindow(string name) => WindowManager.Unregister(name);
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
index 99cf3132..ee38cbf6 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
@@ -18,11 +18,12 @@ public readonly record struct UiWindowPosition(float X, float Y);
/// path.
///
///
-/// Schema (current version 1):
+/// Schema (current version 2):
///
/// {
-/// "version": 1,
+/// "version": 2,
/// "display": { "resolution": "1920x1080", "fullscreen": false, ... }
+/// "windowLayouts": { "Character": { "1920x1080": { "chat": { ... } } } }
/// }
///
/// Unknown top-level keys are preserved on save so future tab additions
@@ -32,7 +33,7 @@ public readonly record struct UiWindowPosition(float X, float Y);
///
public sealed class SettingsStore
{
- private const int CurrentSchemaVersion = 1;
+ private const int CurrentSchemaVersion = 2;
private readonly string _path;
public SettingsStore(string path)
@@ -250,26 +251,7 @@ public sealed class SettingsStore
if (toonKey is null) throw new ArgumentNullException(nameof(toonKey));
if (settings is null) throw new ArgumentNullException(nameof(settings));
- var dir = Path.GetDirectoryName(_path);
- if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
-
- // Read existing file as a mutable JsonObject (or start fresh).
- JsonObject root;
- if (File.Exists(_path))
- {
- try
- {
- root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject();
- }
- catch
- {
- root = new JsonObject();
- }
- }
- else
- {
- root = new JsonObject();
- }
+ JsonObject root = LoadMutableRoot();
// Build the toon's payload.
var toonObj = new JsonObject
@@ -282,15 +264,10 @@ public sealed class SettingsStore
// Slot it under character[toonKey], creating the character map if
// necessary. Other toons in the map are preserved.
- if (root["character"] is not JsonObject characterMap)
- {
- characterMap = new JsonObject();
- root["character"] = characterMap;
- }
+ JsonObject characterMap = GetOrCreateObject(root, "character");
characterMap[toonKey] = toonObj;
root["version"] = CurrentSchemaVersion;
-
- File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ WriteMutableRoot(root);
}
/// Load a named retained-window position for one character.
@@ -324,37 +301,165 @@ public sealed class SettingsStore
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
- var dir = Path.GetDirectoryName(_path);
- if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
-
- JsonObject root;
- if (File.Exists(_path))
- {
- try { root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); }
- catch { root = new JsonObject(); }
- }
- else
- {
- root = new JsonObject();
- }
-
- if (root["windowPositions"] is not JsonObject positions)
- {
- positions = new JsonObject();
- root["windowPositions"] = positions;
- }
- if (positions[toonKey] is not JsonObject toon)
- {
- toon = new JsonObject();
- positions[toonKey] = toon;
- }
+ JsonObject root = LoadMutableRoot();
+ JsonObject positions = GetOrCreateObject(root, "windowPositions");
+ JsonObject toon = GetOrCreateObject(positions, toonKey);
toon[windowName] = new JsonObject
{
["x"] = position.X,
["y"] = position.Y,
};
root["version"] = CurrentSchemaVersion;
- File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ WriteMutableRoot(root);
+ }
+
+ ///
+ /// Load complete retained-window state for one character and screen resolution.
+ /// Missing fields in an older partial layout inherit from
+ /// . When no modern entry exists, the legacy
+ /// windowPositions[toonKey][windowName] radar entry is returned as a
+ /// position-only migration layered over the same fallback.
+ ///
+ public UiWindowLayout? LoadWindowLayout(
+ string toonKey,
+ string resolutionKey,
+ string windowName,
+ UiWindowLayout fallback)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
+ ArgumentException.ThrowIfNullOrWhiteSpace(resolutionKey);
+ ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
+ if (!File.Exists(_path)) return null;
+
+ try
+ {
+ var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject;
+ JsonObject? node = root?["windowLayouts"]?[toonKey]?[resolutionKey]?[windowName] as JsonObject;
+ if (node is null
+ && root?["windowLayouts"]?[toonKey] is JsonObject resolutions
+ && TryParseResolution(resolutionKey, out int requestedWidth, out int requestedHeight))
+ {
+ long bestDistance = long.MaxValue;
+ foreach ((string key, JsonNode? value) in resolutions)
+ {
+ if (value?[windowName] is not JsonObject candidate
+ || !TryParseResolution(key, out int width, out int height))
+ continue;
+ long dx = width - requestedWidth;
+ long dy = height - requestedHeight;
+ long distance = dx * dx + dy * dy;
+ if (distance >= bestDistance) continue;
+ bestDistance = distance;
+ node = candidate;
+ }
+ }
+
+ if (node is not null)
+ {
+ return new UiWindowLayout(
+ ReadNodeFloat(node, "x", fallback.X),
+ ReadNodeFloat(node, "y", fallback.Y),
+ ReadNodeFloat(node, "width", fallback.Width),
+ ReadNodeFloat(node, "height", fallback.Height),
+ ReadNodeBool(node, "visible", fallback.Visible),
+ ReadNodeBool(node, "collapsed", fallback.Collapsed),
+ ReadNodeBool(node, "maximized", fallback.Maximized));
+ }
+
+ // Version-1 migration: radar stored only X/Y and had no resolution key.
+ if (root?["windowPositions"]?[toonKey]?[windowName] is JsonObject legacy)
+ {
+ return fallback with
+ {
+ X = ReadNodeFloat(legacy, "x", fallback.X),
+ Y = ReadNodeFloat(legacy, "y", fallback.Y),
+ };
+ }
+
+ return null;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"settings: failed to load window layout from {_path}: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Save complete retained-window state under
+ /// windowLayouts[toonKey][resolutionKey][windowName], preserving every
+ /// other character, resolution, window, and settings section.
+ ///
+ public void SaveWindowLayout(
+ string toonKey,
+ string resolutionKey,
+ string windowName,
+ UiWindowLayout layout)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
+ ArgumentException.ThrowIfNullOrWhiteSpace(resolutionKey);
+ ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
+
+ JsonObject root = LoadMutableRoot();
+ JsonObject layouts = GetOrCreateObject(root, "windowLayouts");
+ JsonObject toon = GetOrCreateObject(layouts, toonKey);
+ JsonObject resolution = GetOrCreateObject(toon, resolutionKey);
+ resolution[windowName] = new JsonObject
+ {
+ ["x"] = layout.X,
+ ["y"] = layout.Y,
+ ["width"] = layout.Width,
+ ["height"] = layout.Height,
+ ["visible"] = layout.Visible,
+ ["collapsed"] = layout.Collapsed,
+ ["maximized"] = layout.Maximized,
+ };
+ root["version"] = CurrentSchemaVersion;
+ WriteMutableRoot(root);
+ }
+
+ private JsonObject LoadMutableRoot()
+ {
+ var dir = Path.GetDirectoryName(_path);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+ if (!File.Exists(_path)) return new JsonObject();
+ try { return JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); }
+ catch { return new JsonObject(); }
+ }
+
+ private void WriteMutableRoot(JsonObject root)
+ => File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+
+ private static JsonObject GetOrCreateObject(JsonObject parent, string key)
+ {
+ if (parent[key] is JsonObject existing) return existing;
+ var created = new JsonObject();
+ parent[key] = created;
+ return created;
+ }
+
+ private static float ReadNodeFloat(JsonObject node, string key, float fallback)
+ {
+ try { return node[key]?.GetValue() ?? fallback; }
+ catch { return fallback; }
+ }
+
+ private static bool ReadNodeBool(JsonObject node, string key, bool fallback)
+ {
+ try { return node[key]?.GetValue() ?? fallback; }
+ catch { return fallback; }
+ }
+
+ private static bool TryParseResolution(string key, out int width, out int height)
+ {
+ width = 0;
+ height = 0;
+ int separator = key.IndexOf('x', StringComparison.OrdinalIgnoreCase);
+ return separator > 0
+ && int.TryParse(key.AsSpan(0, separator), out width)
+ && int.TryParse(key.AsSpan(separator + 1), out height)
+ && width > 0
+ && height > 0;
}
private static SortedDictionary BuildChatObject(ChatSettings c)
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs b/src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs
new file mode 100644
index 00000000..2e33566c
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs
@@ -0,0 +1,15 @@
+namespace AcDream.UI.Abstractions.Panels.Settings;
+
+///
+/// Complete persisted state for one retained window at one screen resolution.
+/// Position and size are outer-frame pixels; panel flags retain the small amount
+/// of state that geometry alone cannot reproduce reliably.
+///
+public readonly record struct UiWindowLayout(
+ float X,
+ float Y,
+ float Width,
+ float Height,
+ bool Visible,
+ bool Collapsed,
+ bool Maximized);
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
index aa8029d8..628247f0 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
@@ -180,8 +180,9 @@ public class ChatLayoutConformanceTests
maxMin.OnClick!();
Assert.True(controller.IsMaximized);
- Assert.Equal(90f, handle.Top);
+ Assert.Equal(130f, handle.Top);
Assert.Equal(370f, handle.Height); // DAT max 360 + 2*5px shared chrome
+ Assert.Equal(500f, handle.Top + handle.Height); // lower edge stays fixed while growing upward
Assert.Equal(RetailUiStateIds.Maximized, maxMin.ActiveRetailStateId);
controller.Root.ApplyAnchor(handle.Width, handle.Height);
Assert.Equal(360f, controller.Root.Height);
diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
index b3a422f1..05289a85 100644
--- a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
@@ -106,6 +106,59 @@ public sealed class RetailWindowFrameTests
Assert.False(content.Resizable);
}
+ [Fact]
+ public void HiddenInventory_RestoredExpandedBeforeFirstDraw_GrowsContentsRows()
+ {
+ ImportedLayout layout = FixtureLoader.LoadInventory();
+ UiItemList grid = Assert.IsType(
+ layout.FindElement(InventoryController.ContentsGridId));
+ grid.Columns = 6;
+ grid.CellWidth = 32f;
+ grid.CellHeight = 32f;
+ grid.Flush();
+ for (int i = 0; i < 102; i++)
+ grid.AddItem(new UiItemSlot());
+
+ var root = new UiRoot { Width = 1280, Height = 720 };
+ RetailWindowHandle handle = RetailWindowFrame.Mount(
+ root,
+ layout.Root,
+ NoTex,
+ new RetailWindowFrame.Options
+ {
+ WindowName = WindowNames.Inventory,
+ Chrome = RetailWindowChrome.NineSlice,
+ ContentWidth = layout.Root.Width,
+ ContentHeight = layout.Root.Height,
+ Visible = false,
+ ResizeX = false,
+ ResizeY = true,
+ ResizableEdges = ResizeEdges.Bottom,
+ MaxHeight = 560f,
+ });
+
+ // Production installs the controller-owned policies after mounting, while
+ // inventory is still hidden, and captures their design baseline immediately.
+ InventoryController.ConfigureResizeLayout(layout);
+
+ // Persistence restores the hidden window before its first visible draw.
+ handle.ResizeTo(handle.Width, 527f);
+ ApplyAnchors(handle.OuterFrame);
+ grid.LayoutCells();
+
+ Assert.Equal(251f, grid.Height);
+ Assert.Equal(42, grid.Children.Count(child => child.Visible));
+ }
+
+ private static void ApplyAnchors(UiElement parent)
+ {
+ foreach (UiElement child in parent.Children)
+ {
+ child.ApplyAnchor(parent.Width, parent.Height);
+ ApplyAnchors(child);
+ }
+ }
+
private static ElementInfo Constraints(int minHeight, int maxHeight)
{
var info = new ElementInfo();
diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs
new file mode 100644
index 00000000..d97b96c8
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs
@@ -0,0 +1,102 @@
+using AcDream.App.UI;
+using AcDream.UI.Abstractions.Panels.Settings;
+
+namespace AcDream.App.Tests.UI;
+
+public sealed class RetailWindowLayoutPersistenceTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(
+ Path.GetTempPath(), "acdream-window-layout-tests-" + Guid.NewGuid().ToString("N"));
+ private string PathName => Path.Combine(_directory, "settings.json");
+
+ [Fact]
+ public void CompletedInteractions_SaveCompleteCharacterResolutionLayout()
+ {
+ var store = new SettingsStore(PathName);
+ var root = new UiRoot { Width = 800, Height = 600 };
+ RetailWindowHandle handle = Mount(root, "chat");
+ using var persistence = new RetailWindowLayoutPersistence(
+ root.WindowManager, store, () => "Alice", () => (800, 600));
+
+ handle.MoveTo(44f, 55f);
+ handle.ResizeTo(320f, 180f);
+ handle.Hide();
+
+ UiWindowLayout saved = Assert.IsType(
+ store.LoadWindowLayout("Alice", "800x600", "chat", default));
+ Assert.Equal((44f, 55f, 320f, 180f), (saved.X, saved.Y, saved.Width, saved.Height));
+ Assert.False(saved.Visible);
+ }
+
+ [Fact]
+ public void Restore_ClampsBoundsAndReappliesPanelStateAndVisibility()
+ {
+ var store = new SettingsStore(PathName);
+ store.SaveWindowLayout(
+ "Alice",
+ "640x480",
+ "chat",
+ new UiWindowLayout(900f, 700f, 500f, 300f, false, false, true));
+
+ var root = new UiRoot { Width = 640, Height = 480 };
+ var state = new FakeWindowState();
+ RetailWindowHandle handle = Mount(root, "chat", state);
+ using var persistence = new RetailWindowLayoutPersistence(
+ root.WindowManager, store, () => "Alice", () => (640, 480));
+
+ persistence.RestoreAll();
+
+ Assert.Equal((500f, 300f), (handle.Width, handle.Height));
+ Assert.Equal((140f, 180f), (handle.Left, handle.Top));
+ Assert.False(handle.IsVisible);
+ Assert.True(state.Restored.Maximized);
+ }
+
+ [Fact]
+ public void DefaultPreLoginKey_NeverOverwritesCharacterLayouts()
+ {
+ var store = new SettingsStore(PathName);
+ var root = new UiRoot { Width = 800, Height = 600 };
+ RetailWindowHandle handle = Mount(root, "radar");
+ using var persistence = new RetailWindowLayoutPersistence(
+ root.WindowManager, store, () => "default", () => (800, 600));
+
+ handle.MoveTo(123f, 234f);
+
+ Assert.Null(store.LoadWindowLayout("default", "800x600", "radar", default));
+ }
+
+ private static RetailWindowHandle Mount(
+ UiRoot root,
+ string name,
+ IRetainedWindowStateController? state = null)
+ {
+ var frame = new UiPanel
+ {
+ Left = 10f,
+ Top = 20f,
+ Width = 200f,
+ Height = 100f,
+ MinWidth = 100f,
+ MinHeight = 80f,
+ MaxWidth = 600f,
+ MaxHeight = 400f,
+ Draggable = true,
+ Resizable = true,
+ };
+ root.AddChild(frame);
+ return root.RegisterWindow(name, frame, stateController: state);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
+ }
+
+ private sealed class FakeWindowState : IRetainedWindowStateController
+ {
+ public RetainedWindowState Restored { get; private set; }
+ public RetainedWindowState CaptureWindowState() => Restored;
+ public void RestoreWindowState(RetainedWindowState state) => Restored = state;
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
index 519c5f41..59e06fa1 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
@@ -384,4 +384,54 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.Null(store.LoadWindowPosition("Alice", "inventory"));
Assert.True(store.LoadGameplay().LockUI);
}
+
+ [Fact]
+ public void WindowLayouts_RoundTripPerCharacterResolutionAndPreserveSettings()
+ {
+ var store = new SettingsStore(_tempPath);
+ store.SaveGameplay(GameplaySettings.Default with { LockUI = true });
+ var alice1080 = new UiWindowLayout(10f, 20f, 500f, 300f, true, false, true);
+ var alice1440 = new UiWindowLayout(30f, 40f, 650f, 420f, false, true, false);
+ var bob1080 = new UiWindowLayout(50f, 60f, 310f, 132f, true, false, false);
+
+ store.SaveWindowLayout("Alice", "1920x1080", "chat", alice1080);
+ store.SaveWindowLayout("Alice", "2560x1440", "chat", alice1440);
+ store.SaveWindowLayout("Bob", "1920x1080", "toolbar", bob1080);
+
+ Assert.Equal(alice1080, store.LoadWindowLayout("Alice", "1920x1080", "chat", default));
+ Assert.Equal(alice1440, store.LoadWindowLayout("Alice", "2560x1440", "chat", default));
+ Assert.Equal(bob1080, store.LoadWindowLayout("Bob", "1920x1080", "toolbar", default));
+ Assert.Null(store.LoadWindowLayout("Alice", "1920x1080", "inventory", default));
+ Assert.True(store.LoadGameplay().LockUI);
+ }
+
+ [Fact]
+ public void WindowLayouts_MigrateLegacyRadarPositionOverCurrentDefaults()
+ {
+ var store = new SettingsStore(_tempPath);
+ store.SaveWindowPosition("Alice", "radar", new UiWindowPosition(321.5f, 18f));
+ var current = new UiWindowLayout(10f, 10f, 160f, 180f, true, false, false);
+
+ UiWindowLayout migrated = Assert.IsType(
+ store.LoadWindowLayout("Alice", "1920x1080", "radar", current));
+
+ Assert.Equal(321.5f, migrated.X);
+ Assert.Equal(18f, migrated.Y);
+ Assert.Equal(current.Width, migrated.Width);
+ Assert.Equal(current.Height, migrated.Height);
+ Assert.Equal(current.Visible, migrated.Visible);
+ }
+
+ [Fact]
+ public void WindowLayouts_FallBackToNearestSavedResolution()
+ {
+ var store = new SettingsStore(_tempPath);
+ var hd = new UiWindowLayout(100f, 110f, 400f, 250f, true, false, false);
+ var uhd = new UiWindowLayout(300f, 310f, 600f, 450f, true, false, false);
+ store.SaveWindowLayout("Alice", "1280x720", "chat", hd);
+ store.SaveWindowLayout("Alice", "3840x2160", "chat", uhd);
+
+ Assert.Equal(hd, store.LoadWindowLayout("Alice", "1600x900", "chat", default));
+ Assert.Equal(uhd, store.LoadWindowLayout("Alice", "3200x1800", "chat", default));
+ }
}