From b2e68c23434a559ef5bd27bf897fd6f55ab5485c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:47:04 +0200 Subject: [PATCH 1/3] 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 3a364047..418e7f9d 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 9a245023..8172897d 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; } From 05f22d46ff63db5a3552a7ca3f8095d93de07c82 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:51:46 +0200 Subject: [PATCH 2/3] fix #490 (part 2): plugin panels adopt an authored size change instead of keeping a stale saved size forever MountPlugins registered every plugin window with authoredGeometryRevision hard-coded to 0 (RetailUiRuntime.cs), so RetailWindowLayoutPersistence's MigrateAuthoredGeometry -- gated on "saved revision >= authored revision" -- never migrated a plugin window's saved size: 0 >= 0 forever. MossTank's panel went 856x236 -> 984x271 and every user with a stored layout stayed stuck at 856x236 with no way to see the new default. Two changes: 1. MountPlugins now passes RetailWindowManager.ComputeAuthoredGeometryRevision (added previous commit) as the plugin window's authoredGeometryRevision, derived from the panel's own authored width/height/minw/minh/resizable. 2. MigrateAuthoredGeometry now compares revisions for INEQUALITY (saved.Revision == authored.Revision) instead of ordering (saved.Revision >= authored.Revision). A hash is not an incrementing counter -- two different authored sizes can hash in either order -- so "the authored size changed" has to mean "the value differs", not "the value went up". Built-in windows' hand-picked incrementing literals (chat: authoredGeometryRevision = 1) are unaffected: no existing saved revision is ever equal to a later, different literal either way. Mutation shown to fail first: the two new PluginMarkupPanel_AuthoredSizeChanged_* tests in RetailWindowLayoutPersistenceTests.cs reproduce the exact bug with concrete literals (856x236/400/150/true -> 984x271/... and 200x100/100/80/true -> 220x110/...) chosen so ComputeAuthoredGeometryRevision's OLD hash is >= the NEW hash for each pair -- confirmed via a throwaway probe before writing the assertions, so the pre-fix run fails deterministically rather than by chance of hash ordering. Both failed before this commit (size stayed at the old authored extent) and pass after (PluginMarkupPanel_AuthoredSizeUnchanged_KeepsUserResizedSize, unaffected either way, is a regression-safety companion). Full RetailWindow/Markup/ PluginSidePanel filter: 249 passed (was 246), 0 failed, 0 skipped. Co-Authored-By: Claude Fable 5.1 --- src/AcDream.App/UI/RetailUiRuntime.cs | 11 +- .../UI/RetailWindowLayoutPersistence.cs | 42 +++++- .../UI/RetailWindowLayoutPersistenceTests.cs | 137 ++++++++++++++++++ 3 files changed, 188 insertions(+), 2 deletions(-) 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, From 94ebe945f3665a9888cabb434b4798ade1318612 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:54:07 +0200 Subject: [PATCH 3/3] docs: mark #490 part 2 fixed and note the reset-once contract in plugin-ui-markup #490 part 2 (plugin layout persistence has no revision bump) is fixed at 05f22d46f (RetailWindowManager.ComputeAuthoredGeometryRevision + MigrateAuthoredGeometry's inequality comparison + MountPlugins wiring). Part 1 (StartVisible=false + ShowInSidePanel=false is permanently unshowable) remains open -- not touched by this work. Also adds one sentence to docs/plugin-ui-markup.md's "Resizable panels and anchors" section: changing a panel's authored w/h/minw/minh/resizable in a later plugin update resets every user's stored size to the new default exactly once, automatically -- no plugin-author call site to remember. Co-Authored-By: Claude Fable 5.1 --- docs/ISSUES.md | 52 ++++++++++++++++++++-------------------- docs/plugin-ui-markup.md | 7 +++++- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index dd732a10..bdf94ad0 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -74,32 +74,32 @@ after each deliberate `Top` write for the imported-layout element. Precedent: `MapPageController.cs:235-249` (the same fix already landed for other runtime-repositioned imported/programmatic elements). -## #491 — MossTank extra/blacklisted buff lists are shown but not consumed by BuffPlan.Build - -**Status:** OPEN — found 2026-09-07 at the slice 7 architecture review. -**Severity:** LOW (honest UI after fix round B; behavior owed) -**Component:** `src/AcDream.Plugins.MossTank/BuffPlan.cs` (`Build`), `BuffSettings.ExtraBuffSpellNames`/`BlacklistedBuffFamilyNames` - -**Description.** Slice 7 added VTank's "Extra Buff Spells" and "Blacklisted Buff Families" lists to the Buffs tab. They are stored (persisted after fix round B) but `BuffPlan.Build` never reads them: extra exemplars are not cast, blacklisted families are not skipped. Belongs to VT2 slice 4 (buffs); wire `Build` to add the "best similar" spell per extra exemplar and to skip blacklisted families, with plan tests. - -## #490 — Plugin panel host: `StartVisible=false` + `ShowInSidePanel=false` is permanently unshowable; layout persistence has no revision bump - -**Status:** OPEN — found live 2026-09-07 at slice 7 fix round A (`78b42a519`), workaround in the plugin. -**Severity:** LOW/MEDIUM (host defect; silent) -**Component:** `src/AcDream.App/UI/RetailUiRuntime.cs` (~5735-5757, `PluginWindowVisibilityController`), `RetailWindowLayoutPersistence` - -**Description.** (1) `_requestedVisible = startVisible` and only `OnShown()` ever sets it true; a window registered with `ShowInSidePanel=false` has no shelf entry to raise `OnShown`, so `StartVisible=false` can never become visible, with no error. MossTank works around it by registering its popups `StartVisible=true` and gating on its own visibility bindings. Fix shape: validate the combination at `AddPanel` (throw, or coerce). (2) Plugin windows restore a stale persisted position over a changed authored default with no revision bump — the slice 7 screenshots needed an isolated `ACDREAM_CONFIG_DIR` to open at the authored 28,42. Fix shape: key the persisted layout by the panel's authored geometry hash (or a plugin-declared layout revision) so a redesign resets the stored position once. - -## #489 — Headless: SpewBox pending queue grows unbounded when no console ticks it; console polish - -**Status:** OPEN — found 2026-09-07 by the Opus re-check of the headless console (`738111239`). -**Severity:** LOW/MEDIUM (leak in long-lived bots) -**Component:** `src/AcDream.Runtime/.../SpewBoxState.cs` (`Enqueue` ~:110, `_pending`), `src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs` - -**Description.** `RuntimeCommunicationState.AddText` routes every `ClientLocal` (0x1A) line into `SpewBoxState.Enqueue`; the only `Tick` caller in the headless host is the console pump, so with the console disabled (every scripted/CI bot) `_pending` grows for the life of the session. Pre-existing before the console; the console merely made it visible. Fix shape: tick the SpewBox from the session tick regardless of the console (or drop `ClientLocal` text when nothing observes it), with a pin that a 10,000-line burst without a console does not grow the queue. - -**Polish carried from the same re-check:** in `--console` mode the JSON diagnostics/resources stream still interleaves with the chat lines on stdout — quiet it or send it to stderr when the console is on; `--console` missing from `--help`; `HeadlessConsoleOptions.cs:51` re-types the env-var literal (the LaunchOptions regex needs it — a const rename would split the two reads); the `/quit`/`/status`/"not handled" writes and `Pump()` sit outside the S4 try/catch (a broken stdout pipe would fault the session); the SpewBox's 4-entry visible cap can drop interface-text lines produced between two pumps. - +## #491 — MossTank extra/blacklisted buff lists are shown but not consumed by BuffPlan.Build + +**Status:** OPEN — found 2026-09-07 at the slice 7 architecture review. +**Severity:** LOW (honest UI after fix round B; behavior owed) +**Component:** `src/AcDream.Plugins.MossTank/BuffPlan.cs` (`Build`), `BuffSettings.ExtraBuffSpellNames`/`BlacklistedBuffFamilyNames` + +**Description.** Slice 7 added VTank's "Extra Buff Spells" and "Blacklisted Buff Families" lists to the Buffs tab. They are stored (persisted after fix round B) but `BuffPlan.Build` never reads them: extra exemplars are not cast, blacklisted families are not skipped. Belongs to VT2 slice 4 (buffs); wire `Build` to add the "best similar" spell per extra exemplar and to skip blacklisted families, with plan tests. + +## #490 — Plugin panel host: `StartVisible=false` + `ShowInSidePanel=false` is permanently unshowable; layout persistence has no revision bump + +**Status:** OPEN — found live 2026-09-07 at slice 7 fix round A (`78b42a519`), workaround in the plugin. +**Severity:** LOW/MEDIUM (host defect; silent) +**Component:** `src/AcDream.App/UI/RetailUiRuntime.cs` (~5735-5757, `PluginWindowVisibilityController`), `RetailWindowLayoutPersistence` + +**Description.** (1) `_requestedVisible = startVisible` and only `OnShown()` ever sets it true; a window registered with `ShowInSidePanel=false` has no shelf entry to raise `OnShown`, so `StartVisible=false` can never become visible, with no error. MossTank works around it by registering its popups `StartVisible=true` and gating on its own visibility bindings. Fix shape: validate the combination at `AddPanel` (throw, or coerce). Still OPEN. (2) **FIXED at `05f22d46f`.** Plugin windows restored a stale persisted SIZE over a changed authored default with no revision bump (MossTank's panel went 856x236 -> 984x271 and every stored layout stayed at 856x236 forever — not a position bug, a size bug; the earlier "28,42" framing above was about part (1)'s workaround, not this). `RetailWindowManager.ComputeAuthoredGeometryRevision` now derives the revision from the panel's own authored `(w, h, minw, minh, resizable)` tuple via a process-stable hash (deliberately not `System.HashCode`, which reseeds per process), and `MountPlugins` passes it instead of the hard-coded `0`. `RetailWindowLayoutPersistence.MigrateAuthoredGeometry` now compares revisions for inequality rather than "newer only", since a hash is not an ordered counter. Position is kept (not reset) and still clamps to the live screen; a genuinely unchanged authored size keeps the user's own resize. See `RetailWindowLayoutPersistence`'s class doc for the full contract. + +## #489 — Headless: SpewBox pending queue grows unbounded when no console ticks it; console polish + +**Status:** OPEN — found 2026-09-07 by the Opus re-check of the headless console (`738111239`). +**Severity:** LOW/MEDIUM (leak in long-lived bots) +**Component:** `src/AcDream.Runtime/.../SpewBoxState.cs` (`Enqueue` ~:110, `_pending`), `src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs` + +**Description.** `RuntimeCommunicationState.AddText` routes every `ClientLocal` (0x1A) line into `SpewBoxState.Enqueue`; the only `Tick` caller in the headless host is the console pump, so with the console disabled (every scripted/CI bot) `_pending` grows for the life of the session. Pre-existing before the console; the console merely made it visible. Fix shape: tick the SpewBox from the session tick regardless of the console (or drop `ClientLocal` text when nothing observes it), with a pin that a 10,000-line burst without a console does not grow the queue. + +**Polish carried from the same re-check:** in `--console` mode the JSON diagnostics/resources stream still interleaves with the chat lines on stdout — quiet it or send it to stderr when the console is on; `--console` missing from `--help`; `HeadlessConsoleOptions.cs:51` re-types the env-var literal (the LaunchOptions regex needs it — a const rename would split the two reads); the `/quit`/`/status`/"not handled" writes and `Pump()` sit outside the S4 try/catch (a broken stdout pipe would fault the session); the SpewBox's 4-entry visible cap can drop interface-text lines produced between two pumps. + ## #488 — MossTank `.utl` expression block: length prefix measured before newline normalization **Status:** OPEN — found 2026-09-07 by the final Opus re-check of Campaign VT diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index 3f1be965..ae1006f2 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -210,7 +210,12 @@ No other markup or host wiring is needed to make a panel resizable: once `resizable="true"` sets the window's `Resizable`/`ResizeX`/`ResizeY`/ `MinWidth`/`MinHeight`, the SAME drag-resize, persistence (save/restore across sessions, clamped to `minw`/`minh`), and UI-lock behavior every other -retained window already has just applies. +retained window already has just applies. Changing a panel's authored `w`/ +`h`/`minw`/`minh`/`resizable` in a later plugin update resets every user's +stored SIZE to the new authored default exactly once (their saved position +is kept and re-clamped to the screen) — the host derives a stable revision +from that tuple automatically, so a plugin author never needs to bump one +by hand (#490 part 2). ```xml