From b2e68c23434a559ef5bd27bf897fd6f55ab5485c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:47:04 +0200 Subject: [PATCH] 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 --- src/AcDream.App/UI/RetailWindowManager.cs | 39 +++++++++++++++++ .../UI/RetailWindowManagerTests.cs | 42 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs index 3a3640476..418e7f9da 100644 --- a/src/AcDream.App/UI/RetailWindowManager.cs +++ b/src/AcDream.App/UI/RetailWindowManager.cs @@ -111,6 +111,45 @@ public sealed class RetailWindowManager : IDisposable return handle; } + /// + /// 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 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 : 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. + /// 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: + /// clamps a negative authoredGeometryRevision + /// 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. + /// + 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!); diff --git a/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs index 9a245023c..8172897d8 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs @@ -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; }