diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 02600df3..061a839d 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -4767,11 +4767,20 @@ public sealed class RetailUiRuntime : IDisposable
// later registration/sidepanel failure then rolls the mounted
// subtree back through FailMount instead of leaking it.
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
+ // #490 part 2: derive the authored-geometry revision from
+ // the panel's own authored extent instead of a hard-coded 0
+ // — see RetailWindowLayoutPersistence's class doc and
+ // RetailWindowManager.ComputeAuthoredGeometryRevision's own
+ // doc for why a plugin window can't use the built-in
+ // windows' manual-literal scheme.
+ int authoredGeometryRevision = RetailWindowManager.ComputeAuthoredGeometryRevision(
+ element.Width, element.Height, element.MinWidth, element.MinHeight, element.Resizable);
RetailWindowHandle handle = Host.WindowManager.Register(
panel.WindowName,
element,
element,
- visibility);
+ visibility,
+ authoredGeometryRevision: authoredGeometryRevision);
_bindings.Plugins.CompleteWindowMount(
panel,
() => Host.WindowManager.Unregister(panel.WindowName));
diff --git a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs
index ca1bf655..ee2baf95 100644
--- a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs
+++ b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs
@@ -9,6 +9,31 @@ namespace AcDream.App.UI;
/// per-resolution settings. It deliberately ignores the temporary pre-login
/// default character key so startup layout cannot overwrite a real
/// character's state.
+///
+///
+/// Authored-geometry revision (#490 part 2). Every registered window
+/// carries an authoredGeometryRevision (see
+/// ); a restore
+/// whose saved revision differs from the handle's current one replaces only
+/// the saved WIDTH/HEIGHT with the current authored size
+/// () — position, visibility, and
+/// collapsed/maximized state are untouched, and the clamp in
+/// still re-fits the kept position to the live screen.
+/// Built-in retail-imported windows hand-pick that revision as a small
+/// incrementing literal at their Register call site (chat windows:
+/// authoredGeometryRevision = 1) — a deliberate author decision each
+/// time their authored size changes. Plugin windows have no such call site
+/// an author remembers to touch, so MountPlugins instead derives the
+/// revision automatically from the authored geometry tuple itself via
+///
+/// (width, height, min width, min height, resizable): unchanged authored
+/// geometry hashes to the same revision (a user's own resize survives
+/// restore), and ANY authored geometry change hashes to a different one
+/// (the stored size resets to the new default exactly once). Because a hash
+/// is not an ordered counter, the comparison is for INEQUALITY — see
+/// 's own doc for why the original
+/// "newer revision only" read was wrong for this case.
+///
///
public sealed class RetailWindowLayoutPersistence : IDisposable
{
@@ -295,11 +320,26 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
handle.AuthoredGeometryRevision);
}
+ ///
+ /// #490 part 2: compares revisions for INEQUALITY, not ordering. Built-in
+ /// retail-imported windows hand-pick a small incrementing literal
+ /// (0, 1, 2…) that only ever grows, so the original "migrate only if
+ /// saved < authored" read fine for them. Plugin windows instead derive
+ /// their revision from a hash of the authored geometry itself
+ /// () so
+ /// their author never has to remember to bump a literal — but a hash is
+ /// not a counter, and two different authored sizes can hash in either
+ /// order. "The authored size changed" therefore means "the value
+ /// differs", not "the value went up"; treating it as ordered silently
+ /// dropped every size-decreasing (by hash value, not by pixels) plugin
+ /// update, which is exactly how MossTank's 856x236 -> 984x271 bump got
+ /// stuck at the old size for every user with a stored layout.
+ ///
private static UiWindowLayout MigrateAuthoredGeometry(
UiWindowLayout saved,
UiWindowLayout authored)
{
- if (saved.AuthoredGeometryRevision >= authored.AuthoredGeometryRevision)
+ if (saved.AuthoredGeometryRevision == authored.AuthoredGeometryRevision)
return saved;
return saved with
diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs
index 67c0abe1..d82cf0ab 100644
--- a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs
+++ b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs
@@ -499,6 +499,143 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
Assert.Equal((200f, 150f), (panel.Width, panel.Height));
}
+ // ── #490 part 2: a plugin's authored panel size changes across a plugin
+ // update (MossTank went 856x236 -> 984x271) and every user's stored
+ // layout must adopt the new authored size rather than keep the old one
+ // forever. MountPlugins derives authoredGeometryRevision from the
+ // authored geometry tuple via RetailWindowManager.ComputeAuthoredGeometryRevision
+ // (#490 part 2) instead of a manual literal, so these tests register the
+ // way MountPlugins does: pass the SAME computed hash to RegisterWindow. ──
+
+ [Fact]
+ public void PluginMarkupPanel_AuthoredSizeChanged_ReplacesStoredSizeButKeepsPosition()
+ {
+ const string oldXml =
+ "";
+ const string newXml =
+ "";
+ var store = new SettingsStore(PathName);
+
+ // "Old session": the plugin's previous authored size registers and
+ // the user drags the window.
+ var oldPanel = MarkupDocument.Build(oldXml, new object(), _ => (1u, 32, 32));
+ var oldRoot = new UiRoot { Width = 1280, Height = 720 };
+ oldRoot.AddChild(oldPanel);
+ RetailWindowHandle oldHandle = oldRoot.RegisterWindow(
+ "moss-tank",
+ oldPanel,
+ authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
+ oldPanel.Width, oldPanel.Height, oldPanel.MinWidth, oldPanel.MinHeight, oldPanel.Resizable));
+ using (var oldPersistence = new RetailWindowLayoutPersistence(
+ oldRoot.WindowManager, store, () => "Alice", () => (1280, 720)))
+ {
+ oldHandle.MoveTo(120f, 90f);
+ }
+
+ // "New session": the plugin ships its new authored 984x271 size.
+ var newPanel = MarkupDocument.Build(newXml, new object(), _ => (1u, 32, 32));
+ var newRoot = new UiRoot { Width = 1280, Height = 720 };
+ newRoot.AddChild(newPanel);
+ newRoot.RegisterWindow(
+ "moss-tank",
+ newPanel,
+ authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
+ newPanel.Width, newPanel.Height, newPanel.MinWidth, newPanel.MinHeight, newPanel.Resizable));
+ using var persistence = new RetailWindowLayoutPersistence(
+ newRoot.WindowManager, store, () => "Alice", () => (1280, 720));
+
+ persistence.RestoreAll();
+
+ Assert.Equal((984f, 271f), (newPanel.Width, newPanel.Height));
+ Assert.Equal((120f, 90f), (newPanel.Left, newPanel.Top));
+ }
+
+ [Fact]
+ public void PluginMarkupPanel_AuthoredSizeUnchanged_KeepsUserResizedSize()
+ {
+ const string xml =
+ "";
+ var store = new SettingsStore(PathName);
+
+ var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
+ var root = new UiRoot { Width = 1280, Height = 720 };
+ root.AddChild(panel);
+ RetailWindowHandle handle = root.RegisterWindow(
+ "moss-tank",
+ panel,
+ authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
+ panel.Width, panel.Height, panel.MinWidth, panel.MinHeight, panel.Resizable));
+ using (var persistence = new RetailWindowLayoutPersistence(
+ root.WindowManager, store, () => "Alice", () => (1280, 720)))
+ {
+ handle.MoveTo(50f, 50f);
+ handle.ResizeTo(900f, 300f);
+ }
+
+ // Fresh session: the SAME authored geometry (same markup) registers
+ // again — the derived revision is unchanged, so the user's own
+ // resize must survive.
+ var freshPanel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
+ var freshRoot = new UiRoot { Width = 1280, Height = 720 };
+ freshRoot.AddChild(freshPanel);
+ freshRoot.RegisterWindow(
+ "moss-tank",
+ freshPanel,
+ authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
+ freshPanel.Width, freshPanel.Height, freshPanel.MinWidth, freshPanel.MinHeight, freshPanel.Resizable));
+ using var freshPersistence = new RetailWindowLayoutPersistence(
+ freshRoot.WindowManager, store, () => "Alice", () => (1280, 720));
+
+ freshPersistence.RestoreAll();
+
+ Assert.Equal((900f, 300f), (freshPanel.Width, freshPanel.Height));
+ }
+
+ [Fact]
+ public void PluginMarkupPanel_AuthoredSizeChanged_ClampsPositionToNewScreenBounds()
+ {
+ // The authored-size migration keeps the saved POSITION, but that
+ // position still runs through the ordinary restore clamp (Apply's
+ // MoveTo clamp) — a larger authored size can push a near-edge saved
+ // X/Y off the live screen.
+ const string oldXml =
+ "";
+ const string newXml =
+ "";
+ var store = new SettingsStore(PathName);
+
+ var oldPanel = MarkupDocument.Build(oldXml, new object(), _ => (1u, 32, 32));
+ var oldRoot = new UiRoot { Width = 800, Height = 600 };
+ oldRoot.AddChild(oldPanel);
+ RetailWindowHandle oldHandle = oldRoot.RegisterWindow(
+ "moss-tank",
+ oldPanel,
+ authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
+ oldPanel.Width, oldPanel.Height, oldPanel.MinWidth, oldPanel.MinHeight, oldPanel.Resizable));
+ using (var oldPersistence = new RetailWindowLayoutPersistence(
+ oldRoot.WindowManager, store, () => "Alice", () => (800, 600)))
+ {
+ oldHandle.MoveTo(590f, 490f); // fits the OLD 200x100 size exactly
+ }
+
+ var newPanel = MarkupDocument.Build(newXml, new object(), _ => (1u, 32, 32));
+ var newRoot = new UiRoot { Width = 800, Height = 600 };
+ newRoot.AddChild(newPanel);
+ newRoot.RegisterWindow(
+ "moss-tank",
+ newPanel,
+ authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision(
+ newPanel.Width, newPanel.Height, newPanel.MinWidth, newPanel.MinHeight, newPanel.Resizable));
+ using var persistence = new RetailWindowLayoutPersistence(
+ newRoot.WindowManager, store, () => "Alice", () => (800, 600));
+
+ persistence.RestoreAll();
+
+ Assert.Equal((220f, 110f), (newPanel.Width, newPanel.Height));
+ // maxX = 800 - 220 = 580 (590 clamps down); maxY = 600 - 110 = 490 (already in bounds).
+ Assert.Equal((580f, 490f), (newPanel.Left, newPanel.Top));
+ }
+
private static RetailWindowHandle Mount(
UiRoot root,
string name,