From fc6e6b98e981d544a9a44755dfc60fef9883120d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:28:16 +0200 Subject: [PATCH 01/12] =?UTF-8?q?docs(vt):=20slice=207=20ledger=20?= =?UTF-8?q?=E2=80=94=20round=20D=20landed;=20final=20re-checks=20dispatche?= =?UTF-8?q?d;=20gate=20build=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/plans/2026-09-07-campaign-vt-slice7-tabs.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md index 12292e7f..1cbfeb3c 100644 --- a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md +++ b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md @@ -103,3 +103,4 @@ re-review, merge to the campaign branch, then the owner's visual gate. - 2026-09-07 15:20 OWNER LIVE LOOK at `8d3c6ad7c` (gate build): "Looks mostly fine, a lot of polish can be done though. But we do that later." Findings: (1) Advanced Options' category filter shows the raw bitmask values (0x1, 0x2, …) with lamps instead of the category NAMES (Misc, Recharge, MeleeCombat, SpellCombat, Ranges, Navigation, Buffing, Crafting, Looting); (2) Advanced Options must follow VTank's model (owner's screenshots 2/3): name + value columns, clicking a bool/enum VALUE flips it in place, only NUMBERS open the edit field at the bottom (Enter applies) with the description box under it, no Apply/Back buttons; (3) scrollable dropdowns and the popup windows use the SAME scrollbar/window assets as the chat and inventory windows (not the flat scrollbar); (4) the whole window: larger default size AND resizable (window-manager resize like chat, lists stretching); (5) Route's Add Recall is missing most of VTank's recalls and recalls do not work in routes yet. Dispatched now (App side, own worktrees): retail scrollbar assets for plain popups/lists; resizable plugin panels + `anchor` markup. Queued on the panel worktree after fix round C: Advanced Options model + category names; the full VTank recall table with spell ids (metaf `NRecall`, py:10981-11008 — 26 spells: Primary Portal Recall 48, Secondary 2647, Lifestone 1635, Lifestone Sending 1636, Portal Recall 2645, Aphus 2931, Sanctuary 2023, Singularity Caul 2943, Glenden Wood 3865, Aerlinthe 2041, Mount Lethe 2813, Ulgrim's 2941, Bur 4084, PtOIA 4198, Mhoire Forge 4128, Colosseum 4213, Facility Hub 5175, Gear Knight Camp 5330, Neftet 5541, Return to the Keep 4214, Rynthid 6150, Viridian Rise 6321, Viridian Rise Great Tree 6322, Celestial Hand 6325, Radiant Blood 6327, Eldrytch Web 6326) wired to the existing recall execution; adopt anchors + the larger default. - 2026-09-07 16:10 App-side pieces merged into the campaign branch: `b71a8ea37` retail scrollbar chrome on plain `` popups and overflowing ``s (chat/inventory skin ids `0x06004C5F`/`6C`/`69`/`60`/`63`/`66`; markup menus are one-column scrollable), `2e63391cc` `` + `anchor="left top right bottom"` on every element (window-manager resize + persistence already generic). App markup/menu/list/window/anchor filter 294/1 skipped. Panel round D (after fix round C on the panel worktree): merge the campaign branch in, Advanced Options model + category names, VTank's 26-recall table wired, `resizable="true"` + anchors on the main panel with a larger default. - 2026-09-07 16:30 fix round C landed on the panel worktree (`466fac426`…`6b42fd68e`): Advanced Options bindings materialized once, `/mt refresh` refreshes the Monsters grid (all six other `Rules` mutators already did), the bottom-band pin counts default heights, hands column materialized, popup pins globbed, #491 cited, the control-count detector retired; MossTank suite 678/678. Round D dispatched on the same worktree (merges the campaign branch first): category names + VTank's Advanced Options interaction model, the 26-recall table wired to route execution, resizable main panel at a ~15% larger default with anchored lists. +- 2026-09-07 18:05 round D landed on the panel worktree (`4ba0a557f` merge of scrollbar chrome + resizable/anchor markup, `ebe670adf` category names from data, `435ced86f` VTank's Advanced Options click model (bool flips, enum cycles from `SettingsEnumInfo`, numbers/strings via the edit field with `/vt opt set` validation, Apply/Back removed), `5318adbb3` the 26-recall table with spell ids + Marketplace as slash (Lifestone's old slash entry dropped — the spell entry replaces it), `2d626971d` main panel resizable at 984×271 (min 856×236) with anchored tab groups and lists; MossTank suite 678 → 715). Deviations: Monsters' extra width goes to its last icon column; seven tabs keep unused margin at the enlarged default; old JSON routes' recall ordinals flagged as a migration risk (re-check judges it). Final narrow re-checks (architecture over C+D; resemblance over the final markup) dispatched; the gate build at `2b79ca325` launched for the owner's look. From 5de8ab606320857535434f030493c700dfbdd098 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:39:42 +0200 Subject: [PATCH 02/12] =?UTF-8?q?docs(vt):=20slice=207=20ledger=20?= =?UTF-8?q?=E2=80=94=20architecture=20re-check=20of=20C+D;=20round=20E=20d?= =?UTF-8?q?ispatched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/plans/2026-09-07-campaign-vt-slice7-tabs.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md index 1cbfeb3c..41054d9d 100644 --- a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md +++ b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md @@ -104,3 +104,4 @@ re-review, merge to the campaign branch, then the owner's visual gate. - 2026-09-07 16:10 App-side pieces merged into the campaign branch: `b71a8ea37` retail scrollbar chrome on plain `` popups and overflowing ``s (chat/inventory skin ids `0x06004C5F`/`6C`/`69`/`60`/`63`/`66`; markup menus are one-column scrollable), `2e63391cc` `` + `anchor="left top right bottom"` on every element (window-manager resize + persistence already generic). App markup/menu/list/window/anchor filter 294/1 skipped. Panel round D (after fix round C on the panel worktree): merge the campaign branch in, Advanced Options model + category names, VTank's 26-recall table wired, `resizable="true"` + anchors on the main panel with a larger default. - 2026-09-07 16:30 fix round C landed on the panel worktree (`466fac426`…`6b42fd68e`): Advanced Options bindings materialized once, `/mt refresh` refreshes the Monsters grid (all six other `Rules` mutators already did), the bottom-band pin counts default heights, hands column materialized, popup pins globbed, #491 cited, the control-count detector retired; MossTank suite 678/678. Round D dispatched on the same worktree (merges the campaign branch first): category names + VTank's Advanced Options interaction model, the 26-recall table wired to route execution, resizable main panel at a ~15% larger default with anchored lists. - 2026-09-07 18:05 round D landed on the panel worktree (`4ba0a557f` merge of scrollbar chrome + resizable/anchor markup, `ebe670adf` category names from data, `435ced86f` VTank's Advanced Options click model (bool flips, enum cycles from `SettingsEnumInfo`, numbers/strings via the edit field with `/vt opt set` validation, Apply/Back removed), `5318adbb3` the 26-recall table with spell ids + Marketplace as slash (Lifestone's old slash entry dropped — the spell entry replaces it), `2d626971d` main panel resizable at 984×271 (min 856×236) with anchored tab groups and lists; MossTank suite 678 → 715). Deviations: Monsters' extra width goes to its last icon column; seven tabs keep unused margin at the enlarged default; old JSON routes' recall ordinals flagged as a migration risk (re-check judges it). Final narrow re-checks (architecture over C+D; resemblance over the final markup) dispatched; the gate build at `2b79ca325` launched for the owner's look. +- 2026-09-07 18:20 architecture re-check of rounds C+D at `2b79ca325`: round C fully closed; round D sound but NOT merge-ready on two items — D-1 pre-cutover JSON routes silently remap recalls (old ordinals 0..3 now name different kinds; the `Enum.IsDefined` guard never fires), D-2 the recall combo shows C# identifiers instead of the metaf-exact names; follow-ups D-3 (Advanced Options popup claims resizable but is not), D-4 (unknown `.af` recall name falls back to Primary Portal Recall), D-5 (values stale on popup open), D-6 (no resolved-geometry pin at min/enlarged sizes). Category names, the enum table and the click model verified data-driven and single-path; zero App-side production change in round D. Round E dispatched with all six. From 69638584e85a6ed4fb7f60170d33f47a0b46363e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:41:05 +0200 Subject: [PATCH 03/12] =?UTF-8?q?docs(vt):=20slice=207=20ledger=20?= =?UTF-8?q?=E2=80=94=20resemblance=20re-check=20of=20D;=20geometry-revisio?= =?UTF-8?q?n=20fix=20dispatched;=20round=20F=20queued?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/plans/2026-09-07-campaign-vt-slice7-tabs.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md index 41054d9d..b12e2b37 100644 --- a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md +++ b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md @@ -105,3 +105,4 @@ re-review, merge to the campaign branch, then the owner's visual gate. - 2026-09-07 16:30 fix round C landed on the panel worktree (`466fac426`…`6b42fd68e`): Advanced Options bindings materialized once, `/mt refresh` refreshes the Monsters grid (all six other `Rules` mutators already did), the bottom-band pin counts default heights, hands column materialized, popup pins globbed, #491 cited, the control-count detector retired; MossTank suite 678/678. Round D dispatched on the same worktree (merges the campaign branch first): category names + VTank's Advanced Options interaction model, the 26-recall table wired to route execution, resizable main panel at a ~15% larger default with anchored lists. - 2026-09-07 18:05 round D landed on the panel worktree (`4ba0a557f` merge of scrollbar chrome + resizable/anchor markup, `ebe670adf` category names from data, `435ced86f` VTank's Advanced Options click model (bool flips, enum cycles from `SettingsEnumInfo`, numbers/strings via the edit field with `/vt opt set` validation, Apply/Back removed), `5318adbb3` the 26-recall table with spell ids + Marketplace as slash (Lifestone's old slash entry dropped — the spell entry replaces it), `2d626971d` main panel resizable at 984×271 (min 856×236) with anchored tab groups and lists; MossTank suite 678 → 715). Deviations: Monsters' extra width goes to its last icon column; seven tabs keep unused margin at the enlarged default; old JSON routes' recall ordinals flagged as a migration risk (re-check judges it). Final narrow re-checks (architecture over C+D; resemblance over the final markup) dispatched; the gate build at `2b79ca325` launched for the owner's look. - 2026-09-07 18:20 architecture re-check of rounds C+D at `2b79ca325`: round C fully closed; round D sound but NOT merge-ready on two items — D-1 pre-cutover JSON routes silently remap recalls (old ordinals 0..3 now name different kinds; the `Enum.IsDefined` guard never fires), D-2 the recall combo shows C# identifiers instead of the metaf-exact names; follow-ups D-3 (Advanced Options popup claims resizable but is not), D-4 (unknown `.af` recall name falls back to Primary Portal Recall), D-5 (values stale on popup open), D-6 (no resolved-geometry pin at min/enlarged sizes). Category names, the enum table and the click model verified data-driven and single-path; zero App-side production change in round D. Round E dispatched with all six. +- 2026-09-07 18:40 resemblance re-check at `2b79ca325`: all 13 earlier items CLOSED; owner asks 1–3 CLOSED, 4–5 PARTIAL. NOT ready for the gate on: (1) the enlarged default never reaches a machine with a stored layout — plugin windows register with `authoredGeometryRevision = 0`, so the saved 856×236 always wins (App-side fix dispatched on its own worktree: derive the revision from the authored geometry; #490 part 2); (2) the recall combo's full spell names overflow the 120 px box on most of the 27 entries — VTank's `cmbRecallType` uses terse captions ("Primary", "LS Sending", "PtOIA", "Fac. Hub"…): show those, keep the full name in the waypoint and `.af`; (3) Monsters' move-down arrow drifted ~67 px from move-up because the last (auto) column absorbed the new width — add a trailing filler column; (4) Advanced Options popup not resizable (round E D-3). Nits for the same round: single-column `items=` lists still draw the selection band while grids do not; Buffs' 136 px middle gap and the orphaned Coverage label; the Loot Editor's leftover Back button; Route's count column squeezing to 17 px when the list scrolls. Six of nine tabs keep dead margin at 984×271 (Route 366 px right, Profiles 244) — accepted for now under "polish later", noted for the owner. Round F queued after round E. From b2e68c23434a559ef5bd27bf897fd6f55ab5485c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:47:04 +0200 Subject: [PATCH 04/12] 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 3cec9df4ab3e9a2909d8e1bf26bdb91f948d9eac Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:49:27 +0200 Subject: [PATCH 05/12] fix(vt): plugin draws no selection band by default in either mode Slice-7 resemblance re-check found UiMarkupList's column-less items= mode and its mode disagreed on whether a selected row gets a persistent SelectedColor fill, so a plugin's Buffs lists highlighted a row while the Monsters/Meta grids looked different for the same widget. Real VVS lists (VTank's HudList) draw no such fill at all. UiMarkupList.SelectionBandEnabled (default false) now gates the fill in both OnDraw's legacy branch and DrawColumns; MarkupDocument parses (same literal-bool convention as openupward/clearonsubmit) to opt a single list back in. selected/onchange semantics and scroll-into-view of the selected row are unchanged. Co-Authored-By: Claude Fable 5.1 --- docs/plugin-ui-markup.md | 7 +- src/AcDream.App/UI/MarkupDocument.cs | 6 + src/AcDream.App/UI/UiMarkupList.cs | 23 +++- .../UI/MarkupListColumnsTests.cs | 125 ++++++++++++++++++ 4 files changed, 158 insertions(+), 3 deletions(-) diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index 3f1be965..a51879c6 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -110,7 +110,7 @@ vanishing from the built tree. | `slider` | Horizontal scalar | `x y w h value onchange anchor` | | `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background anchor` | | `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward style anchor` | -| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight anchor` + either the single-column `items colors icons iconkind`, or one-to-many `` children (see "Columns" below) — never both | +| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight selectionband anchor` + either the single-column `items colors icons iconkind`, or one-to-many `` children (see "Columns" below) — never both | `menu style` is `plain` (the default) or `retail`: retail's gold pushbutton art read as an out-of-place "big yellow button" next to a plugin's own dark @@ -410,6 +410,11 @@ attributes: ``` +VVS lists draw no persistent row-selection fill, so `` matches that by +default in both the single-column and `` forms — a plugin that wants +a visible band anyway sets `selectionband="true"` (row selection itself, +including scroll-into-view, is unaffected either way). + This mirrors VTank's own Monsters tab (several boolean flag columns, a name column, and icon-button columns) — see `docs/research/vtank-kb/08-ui-views.md` §3's "Multi-column lists with typed diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 00c70e15..303020b6 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -594,6 +594,12 @@ public static class MarkupDocument binding, "list selected"), SelectionChanged = listChanged, + // Campaign VT slice 7 resemblance re-check: VVS lists draw + // no persistent row-selection fill by default (matches + // both single-column and mode now — see + // UiMarkupList.SelectionBandEnabled). A plugin that wants + // one back opts in with . + SelectionBandEnabled = B(el, "selectionband", false), }; if (listUsesColumns) diff --git a/src/AcDream.App/UI/UiMarkupList.cs b/src/AcDream.App/UI/UiMarkupList.cs index 967a4165..ddebc0a6 100644 --- a/src/AcDream.App/UI/UiMarkupList.cs +++ b/src/AcDream.App/UI/UiMarkupList.cs @@ -73,6 +73,25 @@ public sealed class UiMarkupList : UiElement public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f); public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f); + /// + /// Campaign VT slice 7 resemblance re-check (2026-09-07): real VVS lists + /// (VTank's own HudList) draw no persistent row-selection fill at + /// all — before this fix the column-less items= mode drew + /// under the selected row while the + /// <column> mode did the same, so a plugin's Buffs lists + /// highlighted a row while the Monsters/Meta grids happened not to (or + /// vice versa, depending on which mode a given list used) — same-looking + /// widgets, inconsistent behavior. Default false now suppresses the fill + /// in BOTH 's legacy branch and + /// , matching VVS. <list + /// selectionband="true"> (parsed in 's + /// case "list") opts a single list back into a visible band for + /// plugins that want one. This gates ONLY the fill — , + /// , and the selected-row scroll-into-view + /// logic in / are unchanged. + /// + public bool SelectionBandEnabled { get; set; } + /// /// Owner live-client report 2026-09-07 ("For scrollable dropdown or the /// meta window we use the same assets as we do in for example chat or @@ -163,7 +182,7 @@ public sealed class UiMarkupList : UiElement for (int index = _topRow; index < end; index++) { float y = (index - _topRow) * RowHeight; - if (index == selected) + if (index == selected && SelectionBandEnabled) context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor); if (iconIds is not null && index < iconIds.Count && IconResolve is { } resolve) @@ -380,7 +399,7 @@ public sealed class UiMarkupList : UiElement for (int index = _topRow; index < end; index++) { float y = (index - _topRow) * RowHeight; - if (index == selected) + if (index == selected && SelectionBandEnabled) context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor); for (int c = 0; c < columns.Count; c++) diff --git a/tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs b/tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs index 81002296..0ce30736 100644 --- a/tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs @@ -1126,6 +1126,18 @@ public sealed class MarkupListColumnsTests /// entirely — i.e. against the widget's own unmodified /// OnDraw/legacy branch, not a snapshot captured from a different /// commit. Every vertex float must match exactly. + /// + /// + /// Deliberately updated for the slice-7 resemblance re-check's + /// fix: SelectedIndex + /// = 1 (a real selected row) previously meant this byte-for-byte + /// comparison implicitly included the + /// fill quad on BOTH sides. Neither the markup XML nor the hand-built + /// widget below sets selectionband/, + /// so that fill is now absent from both — the explicit assertion at the + /// end locks in that the new no-band default applies here too, rather + /// than leaving it to accidentally fall out of the byte-for-byte diff. + /// /// [Fact] public void ColumnLessList_ProducesTheIdenticalDrawRecordToTheHandBuiltWidget() @@ -1181,6 +1193,119 @@ public sealed class MarkupListColumnsTests } Assert.Null(viaMarkup.Columns); + + // Neither side opted into selectionband="true" — row 1 IS selected + // (LegacyBinding.SelectedIndex = 1) but the new default draws no + // SelectedColor fill for it, on either the markup or the hand-built + // path. + Assert.False(viaMarkup.SelectionBandEnabled); + Assert.False(handBuilt.SelectionBandEnabled); + Assert.DoesNotContain(markupVerts, s => s.Texture == 0u + && Chunk(s.Verts).Any(v => ColorMatches(v, viaMarkup.SelectedColor))); + } + + // ── Slice 7 resemblance re-check: selectionband default + opt-in ──────── + + private sealed class SelectionBandBinding + { + public IReadOnlyList Choices => new[] { "First", "Second" }; + public int Selected { get; set; } = 1; + public Action SelectIndex => _ => { }; + } + + /// Any untextured (fill) quad in whose vertex + /// color matches — used to detect the selection-band + /// fill regardless of its exact geometry. + private static bool HasFillOfColor( + IEnumerable<(uint Texture, IReadOnlyList Verts)> segs, Vector4 color) + => segs.Where(s => s.Texture == 0u) + .SelectMany(s => Chunk(s.Verts)) + .Any(v => ColorMatches(v, color)); + + /// + /// Slice-7 resemblance re-check finding: VVS lists (VTank's real + /// HudList) draw no persistent row-selection fill, so a + /// column-less <list items="..."> must match that by + /// default — defaults + /// false. selectionband="true" opts a single list back into the + /// visible band. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SingleColumnList_SelectionBand_DefaultsOffAndAttributeOptsIn(bool enabled) + { + var binding = new SelectionBandBinding(); + string attr = enabled ? " selectionband=\"true\"" : ""; + string xml = + "" + + "" + + ""; + + var panel = MarkupDocument.Build(xml, binding, Sprite); + var list = Assert.IsType(panel.Children[0]); + Assert.Equal(enabled, list.SelectionBandEnabled); + + var (renderer, ctx) = MakeContext(200f, 200f); + list.DrawSelfAndChildren(ctx); + + Assert.Equal(enabled, HasFillOfColor(renderer.DebugSpriteSegmentVerts, list.SelectedColor)); + } + + /// Same fix, exercised through <column> mode + /// () — the gate applies to both + /// draw branches of the shared widget, not just the legacy one. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ColumnModeList_SelectionBand_DefaultsOffAndAttributeOptsIn(bool enabled) + { + var binding = new SelectionBandBinding(); + string attr = enabled ? " selectionband=\"true\"" : ""; + string xml = + "" + + $"" + + " " + + ""; + + var panel = MarkupDocument.Build(xml, binding, Sprite); + var list = Assert.IsType(panel.Children[0]); + Assert.Equal(enabled, list.SelectionBandEnabled); + + var (renderer, ctx) = MakeContext(200f, 200f); + list.DrawSelfAndChildren(ctx); + + Assert.Equal(enabled, HasFillOfColor(renderer.DebugSpriteSegmentVerts, list.SelectedColor)); + } + + /// Parse-only pin: selectionband follows the same silent + /// literal-bool convention as openupward/clearonsubmit + /// ('s private B helper) — omitted + /// defaults false, and "true" sets the property, with no draw + /// involved at all. + [Fact] + public void ListSelectionBandAttribute_ParsesToProperty() + { + var binding = new SelectionBandBinding(); + const string xmlDefault = + "" + + "" + + ""; + const string xmlEnabled = + "" + + "" + + ""; + + var defaultList = Assert.IsType( + MarkupDocument.Build(xmlDefault, binding, Sprite).Children[0]); + var enabledList = Assert.IsType( + MarkupDocument.Build(xmlEnabled, binding, Sprite).Children[0]); + + Assert.False(defaultList.SelectionBandEnabled); + Assert.True(enabledList.SelectionBandEnabled); } // ── Fix round: end-to-end MarkupDocument builds (fix item 10) ─────────── From 05f22d46ff63db5a3552a7ca3f8095d93de07c82 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:51:46 +0200 Subject: [PATCH 06/12] 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 07/12] 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 From 94caf0ba60528c51b52db79e90d9ec2021b9d813 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:55:29 +0200 Subject: [PATCH 08/12] =?UTF-8?q?docs(vt):=20slice=207=20ledger=20?= =?UTF-8?q?=E2=80=94=20selection-band=20and=20geometry-revision=20merges;?= =?UTF-8?q?=20round=20F=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/plans/2026-09-07-campaign-vt-slice7-tabs.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md index b12e2b37..0e5b0449 100644 --- a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md +++ b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md @@ -106,3 +106,4 @@ re-review, merge to the campaign branch, then the owner's visual gate. - 2026-09-07 18:05 round D landed on the panel worktree (`4ba0a557f` merge of scrollbar chrome + resizable/anchor markup, `ebe670adf` category names from data, `435ced86f` VTank's Advanced Options click model (bool flips, enum cycles from `SettingsEnumInfo`, numbers/strings via the edit field with `/vt opt set` validation, Apply/Back removed), `5318adbb3` the 26-recall table with spell ids + Marketplace as slash (Lifestone's old slash entry dropped — the spell entry replaces it), `2d626971d` main panel resizable at 984×271 (min 856×236) with anchored tab groups and lists; MossTank suite 678 → 715). Deviations: Monsters' extra width goes to its last icon column; seven tabs keep unused margin at the enlarged default; old JSON routes' recall ordinals flagged as a migration risk (re-check judges it). Final narrow re-checks (architecture over C+D; resemblance over the final markup) dispatched; the gate build at `2b79ca325` launched for the owner's look. - 2026-09-07 18:20 architecture re-check of rounds C+D at `2b79ca325`: round C fully closed; round D sound but NOT merge-ready on two items — D-1 pre-cutover JSON routes silently remap recalls (old ordinals 0..3 now name different kinds; the `Enum.IsDefined` guard never fires), D-2 the recall combo shows C# identifiers instead of the metaf-exact names; follow-ups D-3 (Advanced Options popup claims resizable but is not), D-4 (unknown `.af` recall name falls back to Primary Portal Recall), D-5 (values stale on popup open), D-6 (no resolved-geometry pin at min/enlarged sizes). Category names, the enum table and the click model verified data-driven and single-path; zero App-side production change in round D. Round E dispatched with all six. - 2026-09-07 18:40 resemblance re-check at `2b79ca325`: all 13 earlier items CLOSED; owner asks 1–3 CLOSED, 4–5 PARTIAL. NOT ready for the gate on: (1) the enlarged default never reaches a machine with a stored layout — plugin windows register with `authoredGeometryRevision = 0`, so the saved 856×236 always wins (App-side fix dispatched on its own worktree: derive the revision from the authored geometry; #490 part 2); (2) the recall combo's full spell names overflow the 120 px box on most of the 27 entries — VTank's `cmbRecallType` uses terse captions ("Primary", "LS Sending", "PtOIA", "Fac. Hub"…): show those, keep the full name in the waypoint and `.af`; (3) Monsters' move-down arrow drifted ~67 px from move-up because the last (auto) column absorbed the new width — add a trailing filler column; (4) Advanced Options popup not resizable (round E D-3). Nits for the same round: single-column `items=` lists still draw the selection band while grids do not; Buffs' 136 px middle gap and the orphaned Coverage label; the Loot Editor's leftover Back button; Route's count column squeezing to 17 px when the list scrolls. Six of nine tabs keep dead margin at 984×271 (Route 366 px right, Profiles 244) — accepted for now under "polish later", noted for the owner. Round F queued after round E. +- 2026-09-07 19:10 App-side merges: `7b7e5e579` plugin `` draws no selection band by default (`selectionband="true"` opts in); `3d7065ebc` #490 part 2 fixed — plugin windows carry a geometry-hash `authoredGeometryRevision`, so a changed authored size replaces a stored size once (position kept, clamped); App window/markup/list filter 254/254. Round F (after round E on the panel worktree): merge the campaign branch in; VTank's terse `cmbRecallType` captions in the recall combo (full spell name stays in the waypoint and `.af`); Monsters trailing filler column so the arrows stay adjacent; Buffs middle gap + Coverage label; Loot Editor's leftover Back; Route count column min width. Then one combined final re-check, merge, gate build. From 3bb3ec930dffb7cf3be4e58250ee41f767193a7c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 17:58:53 +0200 Subject: [PATCH 09/12] =?UTF-8?q?docs:=20file=20#492=20=E2=80=94=20Vulkan?= =?UTF-8?q?=20device=20loss=20crash=20+=20shutdown=20re-throw=20on=20the?= =?UTF-8?q?=20lost=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/ISSUES.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index bdf94ad0..b3e20971 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -74,7 +74,17 @@ 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 +## #492 — Vulkan device loss at frame submit crashes the client and the shutdown path throws again on the lost device + +**Status:** OPEN — observed 2026-09-07 17:56 on the owner's machine during the slice-7 live look (gate build at `2b79ca325`). +**Severity:** MEDIUM (crash; the trigger is external) +**Component:** `src/AcDream.App/Rendering/Gpu/Vk/` (frame submit), `GameWindowLifetime.TryComplete` / `ResourceShutdownTransaction` / `WbMeshAdapter.Dispose` + +**Description.** `vkQueueSubmit2 (frame)` returned `ErrorDeviceLost` (an unhandled `VulkanCallException`); the client then ran its graceful logout (ACE confirmed it) and the ordered shutdown, where the mesh-adapter stage failed on `vkWaitSemaphores … ErrorDeviceLost` and `ResourceShutdownTransaction.CompleteOrThrow` re-threw. Crash report: `C:SERSRIKNAPPDATAocalcdreamchediagnosticsash-20260907t1556317152878z-6224-d80b3162cf1e406fadad21098b322b5a.json`. the owner reported "disconnected and crash" — the disconnect is the client's own logout after the device loss, not a network event. + +**fix shape.** (1) treat `errordevicelost` at submit as a terminal render fault: stop rendering, keep the session alive long enough for the graceful logout, and exit with a clear self-report instead of an unhandled exception; (2) the shutdown transaction must tolerate a lost device (skip gpu waits/frees that cannot succeed on a lost device, report once) so teardown converges. not slice-7 scope; belongs with the renderer's fault handling. + +## #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) From 850f84e54fd62e372eee5cf4d91a86db60aebb0f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 18:10:25 +0200 Subject: [PATCH 10/12] =?UTF-8?q?docs:=20file=20#493=20(in-game=20meta/loo?= =?UTF-8?q?t=20rule=20editors,=20deferred);=20slice=207=20ledger=20?= =?UTF-8?q?=E2=80=94=20owner's=20second=20live=20look?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/ISSUES.md | 8 ++++++++ docs/plans/2026-09-07-campaign-vt-slice7-tabs.md | 1 + 2 files changed, 9 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b3e20971..1a9e6af9 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -74,6 +74,14 @@ 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). +## #493 — In-game rule editors for metas and loot rules (VTank's "Edit Meta Rule" and the loot editor) — deferred by the owner + +**Status:** OPEN — filed 2026-09-07 at the slice-7 live look (owner: "we should not implement that right now since most metas are implemented outside of the game. Same with loot rules"). +**Severity:** LOW (feature; post-Campaign VT) +**Component:** `src/AcDream.Plugins.MossTank/mosstank-metaeditor.xml`, `mosstank-loot-editor.xml`, `MossTankPanel.cs` + +**Description.** VTank's Meta tab "Create" opens its "Edit Meta Rule" view (state, condition type + fields, action type + fields, nested All/Any/Not); its loot rules are edited in VTClassic's external editor. MossTank has a minimal popup editor for both. Metas are authored as metaf `.af` outside the game and loot profiles with the external `.utl` editor, so a full in-game editor is not on the campaign path. When picked up: model the editor on VTank's `Edit Meta Rule` view (KB 07 §1.3/§1.4 for every condition/action field) with a tree for nested rules, and a loot-rule editor over the `.utl` requirement vocabulary (KB 05 §1.3); the `.af`/`.utl` writers already exist. + ## #492 — Vulkan device loss at frame submit crashes the client and the shutdown path throws again on the lost device **Status:** OPEN — observed 2026-09-07 17:56 on the owner's machine during the slice-7 live look (gate build at `2b79ca325`). diff --git a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md index 0e5b0449..65259e2d 100644 --- a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md +++ b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md @@ -107,3 +107,4 @@ re-review, merge to the campaign branch, then the owner's visual gate. - 2026-09-07 18:20 architecture re-check of rounds C+D at `2b79ca325`: round C fully closed; round D sound but NOT merge-ready on two items — D-1 pre-cutover JSON routes silently remap recalls (old ordinals 0..3 now name different kinds; the `Enum.IsDefined` guard never fires), D-2 the recall combo shows C# identifiers instead of the metaf-exact names; follow-ups D-3 (Advanced Options popup claims resizable but is not), D-4 (unknown `.af` recall name falls back to Primary Portal Recall), D-5 (values stale on popup open), D-6 (no resolved-geometry pin at min/enlarged sizes). Category names, the enum table and the click model verified data-driven and single-path; zero App-side production change in round D. Round E dispatched with all six. - 2026-09-07 18:40 resemblance re-check at `2b79ca325`: all 13 earlier items CLOSED; owner asks 1–3 CLOSED, 4–5 PARTIAL. NOT ready for the gate on: (1) the enlarged default never reaches a machine with a stored layout — plugin windows register with `authoredGeometryRevision = 0`, so the saved 856×236 always wins (App-side fix dispatched on its own worktree: derive the revision from the authored geometry; #490 part 2); (2) the recall combo's full spell names overflow the 120 px box on most of the 27 entries — VTank's `cmbRecallType` uses terse captions ("Primary", "LS Sending", "PtOIA", "Fac. Hub"…): show those, keep the full name in the waypoint and `.af`; (3) Monsters' move-down arrow drifted ~67 px from move-up because the last (auto) column absorbed the new width — add a trailing filler column; (4) Advanced Options popup not resizable (round E D-3). Nits for the same round: single-column `items=` lists still draw the selection band while grids do not; Buffs' 136 px middle gap and the orphaned Coverage label; the Loot Editor's leftover Back button; Route's count column squeezing to 17 px when the list scrolls. Six of nine tabs keep dead margin at 984×271 (Route 366 px right, Profiles 244) — accepted for now under "polish later", noted for the owner. Round F queued after round E. - 2026-09-07 19:10 App-side merges: `7b7e5e579` plugin `` draws no selection band by default (`selectionband="true"` opts in); `3d7065ebc` #490 part 2 fixed — plugin windows carry a geometry-hash `authoredGeometryRevision`, so a changed authored size replaces a stored size once (position kept, clamped); App window/markup/list filter 254/254. Round F (after round E on the panel worktree): merge the campaign branch in; VTank's terse `cmbRecallType` captions in the recall combo (full spell name stays in the waypoint and `.af`); Monsters trailing filler column so the arrows stay adjacent; Buffs middle gap + Coverage label; Loot Editor's leftover Back; Route count column min width. Then one combined final re-check, merge, gate build. +- 2026-09-07 18:15 OWNER LIVE LOOK at `2b79ca325`: (1) Advanced Options — the whole bottom block ("Editing X." notice + the MossTank Extras section with the Route extras) must go; (2) Advanced Options — the list scrollbar shows but clicks/drags work only sometimes (App-side hit-testing bug; dispatched); (3) `BuffProfile-Prots`/`-Banes` appear "duplicated" beside `BuffProfile_Prots`/`_Banes` and only the enum ones respond — the hyphen ones are `tString`, which VTank's Advanced Options never lists (KB 01, `db.cs:132-166`): hide `tString` settings from the list; (4) Items tab — remove the MossTank block right of the list (Weapon/Offhand readout, Refill Worn Mana slider, status, hint); (5) Meta "Create" should open a VTank-style rule editor — deferred by the owner, filed as #493 (with the loot-rule editor). Items 1, 3, 4 join round F on the panel worktree. From 002769030692fdc33337e0c28fabbc42cdb6c9ef Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 18:20:53 +0200 Subject: [PATCH 11/12] fix: stop the advanced-options scrollbar from snapping back every frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UiMarkupList.OnDraw/DrawColumns re-ran their "keep the selected row visible" clamp on EVERY draw call, not only when SelectedIndexSource actually changed. A real bound list (MossTank's Advanced Options, selected="{SelectedAdvancedOptionIndex}") holds a stable selected index while the user operates the scrollbar, so the very next frame's OnDraw saw that (unchanged) row now outside the just-scrolled view and yanked _topRow straight back to it — undoing the arrow click or thumb drag before it was ever visible. This is exactly the "sometimes works" symptom: a scroll that happens to keep the selected row in view survives; one that moves it out of view gets reverted on the next frame. Gate the clamp on an observed change in the selected index (int.MinValue sentinel so the first frame still reveals it), so a stable selection no longer fights user-driven scrolling while a genuinely new selection is still auto-revealed once. New tests build a real nested Panel > Group > List tree at non-zero offsets and drive it exclusively through UiRoot's public mouse entry points, ruling out a coordinate-frame bug in the hit-test/dispatch chain as well as proving the fix for both single-column and -mode lists (arrow click and thumb drag). Co-Authored-By: Claude Fable 5.1 --- src/AcDream.App/UI/UiMarkupList.cs | 50 +++- ...rkupListScrollSelectionPersistenceTests.cs | 245 ++++++++++++++++++ 2 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiMarkupListScrollSelectionPersistenceTests.cs diff --git a/src/AcDream.App/UI/UiMarkupList.cs b/src/AcDream.App/UI/UiMarkupList.cs index ddebc0a6..d886893b 100644 --- a/src/AcDream.App/UI/UiMarkupList.cs +++ b/src/AcDream.App/UI/UiMarkupList.cs @@ -116,6 +116,28 @@ public sealed class UiMarkupList : UiElement private int _topRow; private IReadOnlyList? _columns; + /// + /// Owner live-client report 2026-09-07 ("Scrolling in advanced options + /// does not work... or it works sometimes"): the last + /// value the "keep selection visible" + /// clamp below (/) actually + /// reacted to. A real bound list (e.g. MossTank's Advanced Options, + /// selected="{SelectedAdvancedOptionIndex}") keeps a STABLE + /// selected index while the user scrolls elsewhere with the scrollbar — + /// re-running the reveal clamp on EVERY frame regardless of whether + /// selection actually changed snapped straight back + /// to the (unchanged) selected row on the very next draw, undoing the + /// scrollbar arrow/track/thumb interaction the same frame it happened. + /// Gating the clamp on an observed CHANGE in the selected index — the + /// only time retail HudList-style lists scroll to reveal a row — lets a + /// stable selection coexist with the user scrolling away from it via the + /// bar, while still auto-revealing a genuinely NEW selection exactly + /// once. Sentinel so the very first draw with + /// ANY selected index (including the valid -1 "nothing selected") still + /// runs the clamp once. + /// + private int _lastRevealedSelected = int.MinValue; + /// /// Pixel-based scroll projection used ONLY to feed /// 's geometry math (thumb @@ -164,12 +186,16 @@ public sealed class UiMarkupList : UiElement : 0f; int visibleRows = VisibleRows; int selected = SelectedIndexSource(); - if (selected >= 0 && selected < items.Count) + if (selected != _lastRevealedSelected) { - if (selected < _topRow) - _topRow = selected; - else if (selected >= _topRow + visibleRows) - _topRow = selected - visibleRows + 1; + _lastRevealedSelected = selected; + if (selected >= 0 && selected < items.Count) + { + if (selected < _topRow) + _topRow = selected; + else if (selected >= _topRow + visibleRows) + _topRow = selected - visibleRows + 1; + } } ClampTop(items.Count, visibleRows); @@ -383,12 +409,16 @@ public sealed class UiMarkupList : UiElement ComputeColumnLayout(columns, contentWidth); int selected = SelectedIndexSource(); - if (selected >= 0 && selected < rowCount) + if (selected != _lastRevealedSelected) { - if (selected < _topRow) - _topRow = selected; - else if (selected >= _topRow + visibleRows) - _topRow = selected - visibleRows + 1; + _lastRevealedSelected = selected; + if (selected >= 0 && selected < rowCount) + { + if (selected < _topRow) + _topRow = selected; + else if (selected >= _topRow + visibleRows) + _topRow = selected - visibleRows + 1; + } } ClampTop(rowCount, visibleRows); diff --git a/tests/AcDream.App.Tests/UI/UiMarkupListScrollSelectionPersistenceTests.cs b/tests/AcDream.App.Tests/UI/UiMarkupListScrollSelectionPersistenceTests.cs new file mode 100644 index 00000000..e23f2be0 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMarkupListScrollSelectionPersistenceTests.cs @@ -0,0 +1,245 @@ +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +/// +/// Owner live-client report 2026-09-07 ("Scrolling in advanced options does +/// not work. Scrollbar shows, but can't click or drag slidebar, or it works +/// sometimes"): root cause is in itself, not the +/// coordinate frame between the popup/group/panel nesting. +/// +/// +/// / +/// run a "keep the selected row visible" clamp UNCONDITIONALLY on every +/// single draw call (every frame), not only when the selection actually +/// changes. A real plugin list (e.g. MossTank's Advanced Options, +/// selected="{SelectedAdvancedOptionIndex}") keeps a STABLE selected +/// index while the user scrolls elsewhere with the scrollbar — but because +/// that index does not change, the very next frame's OnDraw sees the +/// (unchanged) selected row now outside the just-scrolled view and snaps +/// _topRow straight back to reveal it, undoing the arrow click or +/// thumb drag before the user ever sees the new scroll position. This +/// reproduces exactly the "sometimes works" symptom: a scroll that keeps the +/// stable selected row in view survives (looks like it "worked"); one that +/// moves the selected row out of view gets silently reverted on the next +/// frame. +/// +/// +/// +/// Every test here builds a REAL nested Panel > Group > List tree (like +/// MossTank's Advanced Options group living inside its panel) at non-zero +/// offsets at every level, and drives it exclusively through +/// 's public mouse entry points — the same path +/// GameWindow uses — to also rule out a coordinate-frame bug in the +/// hit-test/dispatch chain (candidate 1 in the investigation). A stray +/// coordinate-frame bug would make the FIRST click (the arrow / thumb press) +/// silently miss the scrollbar; these tests assert that press succeeds AND +/// that the resulting scroll position survives a subsequent redraw (the +/// real regression). +/// +/// +public sealed class UiMarkupListScrollSelectionPersistenceTests +{ + private sealed class TestElement : UiElement { } + + private const float ListWidth = 100f; + private const float ListHeight = 100f; // RowHeight 10 => 10 visible rows + private const float RowHeight = 10f; + private const int RowCount = 40; + + // Non-zero nesting offsets at every level (panel > group > list), mirroring + // MossTank's own panel(28,42) > group(8,42) > list(4,24) structure. + private const float PanelLeft = 50f, PanelTop = 60f; + private const float GroupLeft = 10f, GroupTop = 20f; + private const float ListLeft = 5f, ListTop = 15f; + + private static (uint tex, int w, int h) Resolve(uint id) => (id, 16, 16); + + private sealed class Harness + { + public readonly UiRoot Root = new() { Width = 1000f, Height = 800f }; + public readonly TestElement Panel; + public readonly TestElement Group; + public readonly UiMarkupList List; + + public Harness(UiMarkupList list) + { + List = list; + Panel = new TestElement + { + Left = PanelLeft, Top = PanelTop, Width = 900f, Height = 700f, + Draggable = true, + }; + Group = new TestElement + { + Left = GroupLeft, Top = GroupTop, Width = 800f, Height = 600f, + }; + List.Left = ListLeft; List.Top = ListTop; + Group.AddChild(List); + Panel.AddChild(Group); + Root.AddChild(Panel); + } + + /// Absolute root-space screen coords for a point expressed in + /// the list's own local space — the same translation + /// performs by walking every + /// ancestor's Left/Top. + public (int x, int y) ToScreen(float localX, float localY) => + ((int)(PanelLeft + GroupLeft + ListLeft + localX), + (int)(PanelTop + GroupTop + ListTop + localY)); + + public void Click(float localX, float localY) + { + var (x, y) = ToScreen(localX, localY); + Root.OnMouseDown(UiMouseButton.Left, x, y); + Root.OnMouseUp(UiMouseButton.Left, x, y); + } + + public void PressMoveRelease(float downLocalX, float downLocalY, float moveLocalX, float moveLocalY) + { + var (dx, dy) = ToScreen(downLocalX, downLocalY); + Root.OnMouseDown(UiMouseButton.Left, dx, dy); + var (mx, my) = ToScreen(moveLocalX, moveLocalY); + Root.OnMouseMove(mx, my); + Root.OnMouseUp(UiMouseButton.Left, mx, my); + } + } + + // ── Single-column mode ─────────────────────────────────────────────── + + [Fact] + public void SingleColumn_DownArrowClick_TopRowSurvivesTheNextDraw() + { + var list = new UiMarkupList + { + Width = ListWidth, Height = ListHeight, RowHeight = RowHeight, + SpriteResolve = Resolve, + // A REALISTIC stable selection (unlike the -1 the existing + // scrollbar tests use) — the user has row 0 selected and is not + // touching selection while scrolling elsewhere via the bar. + SelectedIndexSource = () => 0, + ItemsSource = () => Enumerable.Range(0, RowCount).Select(i => $"row{i}").ToArray(), + }; + var h = new Harness(list); + h.Root.DrawSelfAndChildren(NullCtx()); + + // Down-arrow occupies the bottom 16px of the reserved scrollbar + // column: x in [84,100), y in [84,100). + h.Click(localX: 90, localY: 90); + + // Simulate the NEXT render frame — this is where the unconditional + // reveal-selected clamp (pre-fix) reverts the scroll. + h.Root.DrawSelfAndChildren(NullCtx()); + + int? selected = null; + list.SelectionChanged = row => selected = row; + // Click the first visible row (local y in row 0's band). + h.Click(localX: 10, localY: 2); + + Assert.Equal(1, selected); + } + + [Fact] + public void SingleColumn_ThumbDrag_TopRowAdvancesThreeRows_AndSurvivesTheNextDraw() + { + var list = new UiMarkupList + { + Width = ListWidth, Height = ListHeight, RowHeight = RowHeight, + SpriteResolve = Resolve, + SelectedIndexSource = () => 0, + ItemsSource = () => Enumerable.Range(0, RowCount).Select(i => $"row{i}").ToArray(), + }; + var h = new Harness(list); + h.Root.DrawSelfAndChildren(NullCtx()); + + // Track: y in [16,84) (Height 100 - 16 up - 16 down). Thumb starts at + // the very top (topRow=0): height = max(8, 68*10/40=17) = 17, so + // y in [16,33). Press at the thumb's own top edge (y=16, zero drag + // offset) and drag down 5px — by the exact geometry in this file's + // header comment, this lands on topRow=3. + h.PressMoveRelease(downLocalX: 90, downLocalY: 16, moveLocalX: 90, moveLocalY: 21); + + h.Root.DrawSelfAndChildren(NullCtx()); + + int? selected = null; + list.SelectionChanged = row => selected = row; + h.Click(localX: 10, localY: 2); + + Assert.Equal(3, selected); + } + + // ── Multi-column mode ──────────────────────────────────────────────── + + [Fact] + public void Columns_DownArrowClick_TopRowSurvivesTheNextDraw() + { + var list = new UiMarkupList + { + Width = ListWidth, Height = ListHeight, RowHeight = RowHeight, + SpriteResolve = Resolve, + SelectedIndexSource = () => 0, + Columns = new[] + { + UiMarkupListColumn.Text( + ListWidth, () => Enumerable.Range(0, RowCount).Select(i => $"row{i}").ToArray(), null), + }, + }; + var h = new Harness(list); + h.Root.DrawSelfAndChildren(NullCtx()); + + h.Click(localX: 90, localY: 90); + h.Root.DrawSelfAndChildren(NullCtx()); + + int? selected = null; + list.SelectionChanged = row => selected = row; + h.Click(localX: 10, localY: 2); + + Assert.Equal(1, selected); + } + + [Fact] + public void Columns_ThumbDrag_TopRowAdvancesThreeRows_AndSurvivesTheNextDraw() + { + var list = new UiMarkupList + { + Width = ListWidth, Height = ListHeight, RowHeight = RowHeight, + SpriteResolve = Resolve, + SelectedIndexSource = () => 0, + Columns = new[] + { + UiMarkupListColumn.Text( + ListWidth, () => Enumerable.Range(0, RowCount).Select(i => $"row{i}").ToArray(), null), + }, + }; + var h = new Harness(list); + h.Root.DrawSelfAndChildren(NullCtx()); + + h.PressMoveRelease(downLocalX: 90, downLocalY: 16, moveLocalX: 90, moveLocalY: 21); + h.Root.DrawSelfAndChildren(NullCtx()); + + int? selected = null; + list.SelectionChanged = row => selected = row; + h.Click(localX: 10, localY: 2); + + Assert.Equal(3, selected); + } + + private static UiRenderContext NullCtx() + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(1000f, 800f)); + return new UiRenderContext(renderer, new Vector2(1000f, 800f)); + } + + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } +} From b6ccb261e4019b86672e127c650bf7476e282c7d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 18:27:46 +0200 Subject: [PATCH 12/12] =?UTF-8?q?docs(vt):=20slice=207=20ledger=20?= =?UTF-8?q?=E2=80=94=20round=20E=20landed,=20scrollbar=20fix=20merged,=20r?= =?UTF-8?q?ound=20F=20dispatched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/plans/2026-09-07-campaign-vt-slice7-tabs.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md index 65259e2d..839b1c57 100644 --- a/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md +++ b/docs/plans/2026-09-07-campaign-vt-slice7-tabs.md @@ -108,3 +108,4 @@ re-review, merge to the campaign branch, then the owner's visual gate. - 2026-09-07 18:40 resemblance re-check at `2b79ca325`: all 13 earlier items CLOSED; owner asks 1–3 CLOSED, 4–5 PARTIAL. NOT ready for the gate on: (1) the enlarged default never reaches a machine with a stored layout — plugin windows register with `authoredGeometryRevision = 0`, so the saved 856×236 always wins (App-side fix dispatched on its own worktree: derive the revision from the authored geometry; #490 part 2); (2) the recall combo's full spell names overflow the 120 px box on most of the 27 entries — VTank's `cmbRecallType` uses terse captions ("Primary", "LS Sending", "PtOIA", "Fac. Hub"…): show those, keep the full name in the waypoint and `.af`; (3) Monsters' move-down arrow drifted ~67 px from move-up because the last (auto) column absorbed the new width — add a trailing filler column; (4) Advanced Options popup not resizable (round E D-3). Nits for the same round: single-column `items=` lists still draw the selection band while grids do not; Buffs' 136 px middle gap and the orphaned Coverage label; the Loot Editor's leftover Back button; Route's count column squeezing to 17 px when the list scrolls. Six of nine tabs keep dead margin at 984×271 (Route 366 px right, Profiles 244) — accepted for now under "polish later", noted for the owner. Round F queued after round E. - 2026-09-07 19:10 App-side merges: `7b7e5e579` plugin `` draws no selection band by default (`selectionband="true"` opts in); `3d7065ebc` #490 part 2 fixed — plugin windows carry a geometry-hash `authoredGeometryRevision`, so a changed authored size replaces a stored size once (position kept, clamped); App window/markup/list filter 254/254. Round F (after round E on the panel worktree): merge the campaign branch in; VTank's terse `cmbRecallType` captions in the recall combo (full spell name stays in the waypoint and `.af`); Monsters trailing filler column so the arrows stay adjacent; Buffs middle gap + Coverage label; Loot Editor's leftover Back; Route count column min width. Then one combined final re-check, merge, gate build. - 2026-09-07 18:15 OWNER LIVE LOOK at `2b79ca325`: (1) Advanced Options — the whole bottom block ("Editing X." notice + the MossTank Extras section with the Route extras) must go; (2) Advanced Options — the list scrollbar shows but clicks/drags work only sometimes (App-side hit-testing bug; dispatched); (3) `BuffProfile-Prots`/`-Banes` appear "duplicated" beside `BuffProfile_Prots`/`_Banes` and only the enum ones respond — the hyphen ones are `tString`, which VTank's Advanced Options never lists (KB 01, `db.cs:132-166`): hide `tString` settings from the list; (4) Items tab — remove the MossTank block right of the list (Weapon/Offhand readout, Refill Worn Mana slider, status, hint); (5) Meta "Create" should open a VTank-style rule editor — deferred by the owner, filed as #493 (with the loot-rule editor). Items 1, 3, 4 join round F on the panel worktree. +- 2026-09-07 19:40 round E landed on the panel worktree (`3178d9202` unresolved recall refused, `a9d1d0a82` legacy JSON recall ordinals mapped explicitly, `3fe304359` combo shows VTank names, `64b62fea3` Advanced Options popup really resizable, `1a0b0e014` refresh on open, `7badbee88` resolved-geometry pin at 856×236 and 1100×320; MossTank suite 715 → 722, App filter 243 → 246). Scrollbar snap-back fixed App-side and merged (`b4fe18007`: the list re-centred on its selected row every frame). Round F dispatched (merges the campaign branch; the owner's second-look items 1/3/4 + VTank's terse recall captions at 120 wide + Monsters filler column + Buffs/Loot Editor/Route nits). After F: one combined final re-check, merge, gate build.