feat(ui): add stable authored-geometry-revision hash for plugin windows

Part of #490 part 2: plugin panels register with authoredGeometryRevision
hard-coded to 0, so a plugin author who ships a new authored panel size
(MossTank: 856x236 -> 984x271) has no way to signal the change short of
adding a manual revision-bump call site, and every user's stored layout
keeps the old size forever. Built-in retail-imported windows solve this
with an explicit incrementing int literal at each Register call; a plugin
author does not maintain that call site by hand.

RetailWindowManager.ComputeAuthoredGeometryRevision derives the revision
from the authored geometry tuple itself (width, height, minw, minh,
resizable) via a fixed FNV-1a-style combine over the values' raw IEEE-754
bit patterns -- deliberately not System.HashCode, whose per-process reseed
would make the "same" authored geometry hash differently on every launch.
The sign bit is masked off so the result is never negative (Register's own
Math.Max(0, revision) would otherwise silently fold distinct negative
hashes onto the same "unversioned" 0 bucket used by legacy saves).

Mutation shown to fail first: without this method,
RetailWindowManagerTests.ComputeAuthoredGeometryRevision_* (7 new tests)
fails to compile (CS0117, method does not exist). No wiring yet -- this
commit only adds the pure, inert helper; MountPlugins still passes no
revision. That lands next along with the comparison-semantics fix it
depends on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 17:47:04 +02:00
parent 5de8ab6063
commit b2e68c2343
2 changed files with 81 additions and 0 deletions

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

View file

@ -291,6 +291,48 @@ public sealed class RetailWindowManagerTests
Assert.Equal(0, resized);
}
// ── #490 part 2: plugin windows derive their authored-geometry revision
// from the authored geometry itself, so a plugin author who ships a new
// panel size does not also have to remember to bump an explicit revision
// literal the way built-in retail-imported windows do. ────────────────
[Fact]
public void ComputeAuthoredGeometryRevision_SameGeometry_IsStable()
{
int a = RetailWindowManager.ComputeAuthoredGeometryRevision(856f, 236f, 400f, 150f, true);
int b = RetailWindowManager.ComputeAuthoredGeometryRevision(856f, 236f, 400f, 150f, true);
Assert.Equal(a, b);
}
[Theory]
[InlineData(984f, 236f, 400f, 150f, true)] // width changed (the #490 MossTank case)
[InlineData(856f, 271f, 400f, 150f, true)] // height changed
[InlineData(856f, 236f, 420f, 150f, true)] // minw changed
[InlineData(856f, 236f, 400f, 160f, true)] // minh changed
[InlineData(856f, 236f, 400f, 150f, false)] // resizable changed
public void ComputeAuthoredGeometryRevision_AnyFieldDiffers_ChangesTheValue(
float width, float height, float minWidth, float minHeight, bool resizable)
{
int baseline = RetailWindowManager.ComputeAuthoredGeometryRevision(856f, 236f, 400f, 150f, true);
int changed = RetailWindowManager.ComputeAuthoredGeometryRevision(
width, height, minWidth, minHeight, resizable);
Assert.NotEqual(baseline, changed);
}
[Fact]
public void ComputeAuthoredGeometryRevision_IsNeverNegative()
{
// Register clamps a negative authoredGeometryRevision up to 0 (its
// "no explicit revision" sentinel) — a hash landing there would be
// indistinguishable from an old, pre-hash saved layout that never
// had a revision at all, so the function must never produce one.
Assert.True(RetailWindowManager.ComputeAuthoredGeometryRevision(856f, 236f, 400f, 150f, true) >= 0);
Assert.True(RetailWindowManager.ComputeAuthoredGeometryRevision(0f, 0f, 0f, 0f, false) >= 0);
Assert.True(RetailWindowManager.ComputeAuthoredGeometryRevision(-1f, -1f, -1f, -1f, true) >= 0);
}
private sealed class RecordingController : IRetainedPanelController
{
public int ShownCount { get; private set; }