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; }