merge: fix #490 part 2 — plugin windows adopt an authored size change (geometry-hash revision)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 17:54:57 +02:00
commit 3d7065ebc0
7 changed files with 301 additions and 29 deletions

View file

@ -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));

View file

@ -9,6 +9,31 @@ namespace AcDream.App.UI;
/// per-resolution settings. It deliberately ignores the temporary pre-login
/// <c>default</c> character key so startup layout cannot overwrite a real
/// character's state.
///
/// <para>
/// <b>Authored-geometry revision (#490 part 2).</b> Every registered window
/// carries an <c>authoredGeometryRevision</c> (see
/// <see cref="RetailWindowHandle.AuthoredGeometryRevision"/>); a restore
/// whose saved revision differs from the handle's current one replaces only
/// the saved WIDTH/HEIGHT with the current authored size
/// (<see cref="MigrateAuthoredGeometry"/>) — position, visibility, and
/// collapsed/maximized state are untouched, and the clamp in
/// <see cref="Apply"/> 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 <c>Register</c> call site (chat windows:
/// <c>authoredGeometryRevision = 1</c>) — a deliberate author decision each
/// time their authored size changes. Plugin windows have no such call site
/// an author remembers to touch, so <c>MountPlugins</c> instead derives the
/// revision automatically from the authored geometry tuple itself via
/// <see cref="RetailWindowManager.ComputeAuthoredGeometryRevision"/>
/// (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
/// <see cref="MigrateAuthoredGeometry"/>'s own doc for why the original
/// "newer revision only" read was wrong for this case.
/// </para>
/// </summary>
public sealed class RetailWindowLayoutPersistence : IDisposable
{
@ -295,11 +320,26 @@ public sealed class RetailWindowLayoutPersistence : IDisposable
handle.AuthoredGeometryRevision);
}
/// <summary>
/// #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 &lt; authored" read fine for them. Plugin windows instead derive
/// their revision from a hash of the authored geometry itself
/// (<see cref="RetailWindowManager.ComputeAuthoredGeometryRevision"/>) 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 -&gt; 984x271 bump got
/// stuck at the old size for every user with a stored layout.
/// </summary>
private static UiWindowLayout MigrateAuthoredGeometry(
UiWindowLayout saved,
UiWindowLayout authored)
{
if (saved.AuthoredGeometryRevision >= authored.AuthoredGeometryRevision)
if (saved.AuthoredGeometryRevision == authored.AuthoredGeometryRevision)
return saved;
return saved with

View file

@ -111,6 +111,45 @@ public sealed class RetailWindowManager : IDisposable
return handle;
}
/// <summary>
/// Derives a stable authored-geometry revision from a window's own
/// authored extent (width, height, min width, min height, resizable), so
/// a plugin window's <see cref="Register"/> call can invalidate an
/// obsolete saved size across an authored-size change WITHOUT the plugin
/// author remembering to bump an explicit revision literal the way
/// built-in retail-imported windows do (#490 part 2 — MossTank shipped
/// 856x236 -> 984x271 and every stored layout stayed at 856x236 forever).
/// Deliberately NOT <see cref="HashCode"/>: that type reseeds its
/// internal state once per process specifically to defeat hash-flooding
/// attacks, so the SAME geometry would hash to a DIFFERENT value on
/// every relaunch — every login would look like a fresh authored-geometry
/// revision and reset every plugin window's saved size, every time. This
/// instead combines the exact IEEE-754 bit patterns with a fixed FNV-1a-
/// style multiplier, which is stable across processes, machines, and
/// .NET versions. <see cref="RetailWindowLayoutPersistence.MigrateAuthoredGeometry"/>
/// compares revisions for INEQUALITY, not ordering — a hash is not a
/// counter, so "authored size changed" means "the value differs",
/// whichever direction it moved. The sign bit is masked off the result:
/// <see cref="Register"/> clamps a negative <c>authoredGeometryRevision</c>
/// up to 0 (its "no explicit revision" sentinel), and a hash landing
/// there would be indistinguishable from an old, pre-hash saved layout
/// that never had a revision at all.
/// </summary>
public static int ComputeAuthoredGeometryRevision(
float width, float height, float minWidth, float minHeight, bool resizable)
{
unchecked
{
int hash = 17;
hash = (hash * 31) + BitConverter.SingleToInt32Bits(width);
hash = (hash * 31) + BitConverter.SingleToInt32Bits(height);
hash = (hash * 31) + BitConverter.SingleToInt32Bits(minWidth);
hash = (hash * 31) + BitConverter.SingleToInt32Bits(minHeight);
hash = (hash * 31) + (resizable ? 1 : 0);
return hash & 0x7FFFFFFF;
}
}
public bool TryGet(string name, out RetailWindowHandle handle)
=> _byName.TryGetValue(name, out handle!);