From e72a64a311f3002120fcaa924ee235eb656f5678 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 07:07:42 +0200 Subject: [PATCH 01/57] =?UTF-8?q?feat(ui-markup):=20slice=207=20step=201?= =?UTF-8?q?=20=E2=80=94=20=20and=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign VT slice 7 needs to transcribe VTank's own Vitals sliders (minimum=0/maximum=100) and its long Profiles/Route named-item combos control-for-control (docs/research/vtank-kb/08-ui-views.md §3's two markup gaps). acdream's was hardwired to a 0.0-1.0 scalar and always wrapped overflow into extra columns instead of VVS's single scrolling column. : optional literal attributes declaring the range the bound value/onchange speak in (e.g. min="0" max="100" for a percent), while UiScrollbar itself keeps its existing 0.0-1.0 internal math untouched — MarkupDocument rescales at the binding boundary. Omitting both (every pre-existing ) keeps the exact historical identity range. : wires UiMenu.Scrollable plus the same track/thumb/up/down chrome sprites ConfigOptionsPageController and VendorUiController already apply to their own Scrollable menus, previously only reachable from C#. Omitting scroll keeps the historical column-wrapping default. Both are additive — no existing / markup changes behavior. New pins in MarkupDocumentTests.cs shown to fail against the prior MarkupDocument.cs (5 failures: Build_SliderWithNoMinMax_*, Build_SliderWithMinMax_*, Slider_MinMax_Draws*, Build_MenuWithScrollAttribute_*, Menu_Scroll_Draws*) before this change, all green after. Co-Authored-By: Claude Fable 5.1 --- docs/plugin-ui-markup.md | 53 ++++- src/AcDream.App/UI/MarkupDocument.cs | 43 +++- .../UI/MarkupDocumentTests.cs | 193 ++++++++++++++++++ 3 files changed, 283 insertions(+), 6 deletions(-) diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index ee601902..8e8c7c64 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -106,9 +106,9 @@ vanishing from the built tree. | `meter` | Retail-style nine-slice bar | `x y w h fill cur max color anchor backleft/backtile/backright frontleft/fronttile/frontright` | | `tab` | Selectable tab button | `x y w h text selected onclick` | | `toggle` | Lamp-style checkbox | `x y w h text checked onclick color` | -| `slider` | Horizontal scalar | `x y w h value onchange` | +| `slider` | Horizontal scalar | `x y w h value onchange min max` | | `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background` | -| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward` | +| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward scroll` | | `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight` + either the single-column `items colors icons iconkind`, or one-to-many `` children (see "Columns" below) — never both | Common to every element via `ApplyCommon`: `name`/`id` (a stable control @@ -466,6 +466,55 @@ single-text-column widget — every existing panel (including every current MossTank tab) keeps working unchanged; `` is additive, not a migration. +## Slider range and scrollable menus (Campaign VT slice 7) + +Two small ``/`` attributes, both closing gaps identified in +`docs/research/vtank-kb/08-ui-views.md` §3 while porting VTank's own Vitals +and Profiles tabs. + +### `` + +VVS's `HudHSlider`/`LinearPositionControl` expose an arbitrary `Min`/`Max` +range (VTank's own nine Vitals sliders are `minimum="0" maximum="100"`). +acdream's `` always bound a fixed 0.0–1.0 scalar; `min`/`max` are +now optional literal attributes that declare the range the BOUND `value`/ +`onchange` speak in, while the widget itself keeps working internally in +0.0–1.0 (drag math, click-to-jump, mouse-wheel are all unchanged): + +```xml + +``` + +`HealPercent`/`SetHealPercent` read and write a plain `0..100` value — no +`/100f` scaling shim in the plugin's own ViewModel. Omitting both attributes +(every `` written before this slice) keeps the exact historical +0.0–1.0 identity range — `min`/`max` default to `0`/`1`, so `(value-0)/(1-0)` +and `0+t*(1-0)` are both no-ops. A declared `min == max` falls back to a +range of `1` rather than dividing by zero. + +### `` + +VVS's `HudCombo` popup is always exactly one scrolling column, at most 10 +rows visible before a scrollbar appears (`HudCombo.cs:35,102-146`). +acdream's `` instead wraps overflow into extra columns unless the +markup opts into `UiMenu.Scrollable` — previously only reachable from C# +(`ConfigOptionsPageController`, `VendorUiController`). `scroll="true"` wires +`Scrollable` plus the same track/thumb/up/down chrome sprites those two +controllers already apply, so a VTank `Choice` with many entries (the +27-option recall menu, a long named-profile list) keeps VVS's one-column +look instead of fanning out sideways: + +```xml + +``` + +Omitting `scroll` (every `` written before this slice) keeps +`Scrollable` at its historical `false` default — the column-wrapping +behavior is unchanged. + ## The plugin shelf (Slice A) The shelf (`AcDream.App.UI.PluginSidePanel`) is the right-edge strip of diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index c152a043..6045c091 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -381,6 +381,22 @@ public static class MarkupDocument + $"Action property on {binding.GetType().Name}"); } + // KB 08 §3 gap: VVS's HudHSlider exposes an arbitrary Min/Max + // range (VTank's own Vitals sliders are minimum="0" + // maximum="100"); acdream's historically only ever + // bound a fixed 0.0-1.0 value. Omitting both attributes keeps + // that exact identity range so every pre-existing + // (which never sets min/max) is byte-for-byte unaffected. + float sliderMin = FOr(el, "min", 0f); + float sliderMax = FOr(el, "max", 1f); + float sliderRange = sliderMax - sliderMin; + if (sliderRange == 0f) + sliderRange = 1f; + + Func sliderValueSource = BindFloat( + (string?)el.Attribute("value"), + binding); + var slider = new UiScrollbar { Left = F(el, "x"), @@ -389,10 +405,14 @@ public static class MarkupDocument Height = F(el, "h"), Horizontal = true, SpriteResolve = resolve, - ScalarPositionSource = BindFloat( - (string?)el.Attribute("value"), - binding), - ScalarChanged = changed, + ScalarPositionSource = () => + sliderValueSource() is { } declaredValue + ? Math.Clamp( + (declaredValue - sliderMin) / sliderRange, 0f, 1f) + : (float?)null, + ScalarChanged = changed is null + ? null + : normalized => changed(sliderMin + normalized * sliderRange), }; RetailScrollbarChrome.ApplyHorizontal(slider); ApplyCommon(slider, el, binding); @@ -473,6 +493,21 @@ public static class MarkupDocument RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)), ColumnWidth = Math.Max(20f, F(el, "w")), OpenUpward = B(el, "openupward", false), + // KB 08 §3 gap: VVS's HudCombo is always a single scrolling + // column (HudCombo.cs:35,102-146); acdream's instead + // wraps overflow into extra columns unless the author opts + // into UiMenu.Scrollable. Default false keeps every existing + // (none of which set scroll=) wrapping exactly as + // before. + Scrollable = B(el, "scroll", false), + // Same track/thumb/arrow chrome ConfigOptionsPageController + // and VendorUiController already apply to their own + // Scrollable menus — harmless to set unconditionally since + // a non-scrollable menu never reads these. + ScrollTrackSprite = 0x06004C5Fu, + ScrollThumbSprite = 0x06004C63u, + ScrollUpSprite = RetailScrollbarChrome.UpNormal, + ScrollDownSprite = RetailScrollbarChrome.DownNormal, TextIndent = 6f, ButtonTextIndent = 6f, NormalSprite = 0x06004D65u, diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index bb3cb9b1..a168ee97 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -1,3 +1,8 @@ +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; namespace AcDream.App.Tests.UI; @@ -314,4 +319,192 @@ public class MarkupDocumentTests Assert.Equal(0.78f, binding.AttackPower); Assert.Equal(0.78f, slider.ScalarPositionSource!()); } + + // ── Campaign VT slice 7 S7.2: (KB 08 §3 gap) ──────── + + private sealed class RangeBinding + { + public float Value { get; private set; } = 50f; + public Action SetValue => value => Value = value; + } + + [Fact] + public void Build_SliderWithNoMinMax_KeepsTheHistoricZeroToOneIdentityRange() + { + const string xml = + "" + + "" + + ""; + // Value defaults to 50 in RangeBinding, but with no declared range a + // pre-existing must keep treating it as an already-normalized + // 0-1 scalar (unchanged from before this attribute existed) — clamped, + // not rescaled. + var binding = new RangeBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, _ => (1u, 16, 16)); + var slider = Assert.IsType(panel.Children[0]); + + Assert.Equal(1f, slider.ScalarPositionSource!()); + + slider.ScalarChanged!(0.6f); + Assert.Equal(0.6f, binding.Value, 3); + } + + [Fact] + public void Build_SliderWithMinMax_RescalesTheDeclaredRangeToAndFromTheInternalZeroToOnePosition() + { + const string xml = + "" + + "" + + ""; + var binding = new RangeBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, _ => (1u, 16, 16)); + var slider = Assert.IsType(panel.Children[0]); + + // 50 out of a declared 0-200 range normalizes to 0.25 internally — + // this is the value UiScrollbar's own drag/click math operates on. + Assert.Equal(0.25f, slider.ScalarPositionSource!()!.Value, 3); + + // The reverse direction: an internal drag position of 0.6 (60%) must + // be rescaled back up into the declared 0-200 range before it ever + // reaches the plugin's bound Action. + slider.ScalarChanged!(0.6f); + Assert.Equal(120f, binding.Value, 3); + } + + [Fact] + public void Slider_MinMax_DrawsTheThumbAtTheRescaledNormalizedPosition() + { + const string xml = + "" + + "" + + ""; + var binding = new RangeBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, id => (id, 16, 16)); + var slider = Assert.IsType(panel.Children[0]); + + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(800f, 600f)); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + + // TickSelfAndChildren pulls ScalarPositionSource into ScalarPosition — + // the same per-frame step the real host performs before drawing. + slider.TickSelfAndChildren(0); + Assert.Equal(0.25f, slider.ScalarPosition, 3); + + slider.DrawSelfAndChildren(ctx); + + // The horizontal thumb sprite (RetailScrollbarChrome.ApplyHorizontal's + // HThumbMidNormal) must be drawn strictly right of the left edge — + // proof the 0.25 normalized position (not the raw declared value 50, + // and not a full 1.0) reached the actual draw call. + var thumb = Assert.Single( + renderer.DebugSpriteSegmentVerts, + s => s.Texture == RetailScrollbarChrome.HThumbMidNormal); + float thumbMinX = Enumerable.Range(0, thumb.Verts.Count / 8) + .Min(i => thumb.Verts[i * 8]); + Assert.True( + thumbMinX > 0f, + $"expected the thumb offset right of the origin at 25%, got x={thumbMinX}"); + } + + // ── Campaign VT slice 7 S7.2: (KB 08 §3 gap) ──── + + [Fact] + public void Build_MenuWithNoScrollAttribute_KeepsScrollableFalse() + { + const string xml = """ + + + + """; + var binding = new EditorBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, _ => (1u, 32, 32)); + var menu = Assert.IsType(panel.Children[0]); + + Assert.False(menu.Scrollable); + } + + [Fact] + public void Build_MenuWithScrollAttribute_SetsUiMenuScrollableAndItsChromeSprites() + { + const string xml = """ + + + + """; + var binding = new EditorBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, _ => (1u, 32, 32)); + var menu = Assert.IsType(panel.Children[0]); + + Assert.True(menu.Scrollable); + Assert.NotEqual(0u, menu.ScrollTrackSprite); + Assert.NotEqual(0u, menu.ScrollThumbSprite); + Assert.NotEqual(0u, menu.ScrollUpSprite); + Assert.NotEqual(0u, menu.ScrollDownSprite); + } + + [Fact] + public void Menu_Scroll_DrawsAScrollbarWhenTheMarkupItemCountOverflowsTheVisibleRows() + { + const string xml = """ + + + + """; + var binding = new ManyChoicesBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, binding, id => (id, 16, 16)); + var menu = Assert.IsType(panel.Children[0]); + + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(800f, 600f)); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + + // Open the popup, then a zero-delta wheel configures PopupScroll from + // Items.Count/RowsPerColumn/RowHeight — the same "configure right + // before use" step UiMenuTests' own Scrollable coverage relies on. + menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); + menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0)); + Assert.True(menu.PopupScroll.HasOverflow); + + menu.DrawOverlays(ctx); + + Assert.Contains( + renderer.DebugSpriteSegmentVerts, + s => s.Texture == menu.ScrollThumbSprite); + } + + private sealed class ManyChoicesBinding + { + public string Selected { get; private set; } = "Item 0"; + public IReadOnlyList ManyChoices { get; } = + Enumerable.Range(0, 18).Select(i => $"Item {i}").ToArray(); + public Action SelectChoice => value => Selected = value; + } + + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } } From f6eebf8c50231b51bbec19dc62b6267bd6a83ada Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 07:11:33 +0200 Subject: [PATCH 02/57] fix(vtank): TryLoadNav refuses any STATE: block instead of skipping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1c review D1 / slice 7 item 7: TryLoadNav tolerated (and silently skipped) STATE:/IF:/DO: rule sections so a route load out of a full Meta .af would still succeed against whichever NAV: block came first. That is exactly the failure mode content-sanity elsewhere in this file already guards against (a misplaced file in the wrong folder silently "succeeding" against the wrong document) — a file with a STATE: block is a Meta profile, possibly one with an embedded nav of its own, and belongs in metas/, not navs/. TryLoadNav now throws (refuses) the instant it sees a STATE: line, with a message naming the metas/ folder, instead of walking past it via the now-deleted SkipState helper. SynthesizedGetOptFollowAndJumpFixtureRoundTrips previously used a STATE:+embedded-NAV: fixture to assert TryLoadNav's OLD skip-and-load behavior; that assertion is now inverted to assert refusal (shown to fail against the prior MetafSerializer.cs: True vs expected False). The "flw" nav-node parsing coverage that assertion also carried is preserved in a new dedicated NAV:-only fixture, FollowNavNodeParsesAsANavOnlyDocument. Co-Authored-By: Claude Fable 5.1 --- .../MetafSerializer.cs | 37 +++++++------------ .../MetafSerializerTests.cs | 34 ++++++++++++++++- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src/AcDream.Plugins.MossTank/MetafSerializer.cs b/src/AcDream.Plugins.MossTank/MetafSerializer.cs index 1dd43ec0..83d3e454 100644 --- a/src/AcDream.Plugins.MossTank/MetafSerializer.cs +++ b/src/AcDream.Plugins.MossTank/MetafSerializer.cs @@ -766,15 +766,24 @@ internal static class MetafSerializer { Match lead = LeadIn.Match(cursor.Lines[cursor.L]); if (!lead.Success) - throw cursor.Error("expected 'NAV:' (or a STATE: rule section, ignored for a route load)."); + throw cursor.Error("expected 'NAV:' (or a STATE: rule section, refused for a route load)."); if (lead.Groups["type"].Value == "NAV:") { ReadNavBlock(cursor, navs, spells); continue; } - // A nav route load tolerates (and ignores) STATE:/IF:/DO: - // sections, e.g. loading a route out of a full meta .af. - SkipState(cursor); + // Slice 1c review D1: a file containing a STATE: rule section + // is a Meta profile, possibly one with one or more embedded + // NAV: blocks of its own. TryLoadNav previously skipped every + // STATE:/IF:/DO: rule and silently loaded whichever embedded + // NAV: block happened to come first — a route load that + // "succeeds" against the wrong document instead of refusing + // it outright. Refuse the whole file the same way the + // "no NAV: block found" case below already does. + throw cursor.Error( + "found a STATE: rule section — this is a Meta profile " + + "(possibly with an embedded nav), not a stand-alone " + + "route; load it from the metas/ folder instead."); } if (navs.Count == 0) throw cursor.Error( @@ -803,26 +812,6 @@ internal static class MetafSerializer return string.Join("\r\n", lines) + "\r\n"; } - private static void SkipState(Cursor cursor) - { - Match header = LeadIn.Match(cursor.Lines[cursor.L]); - _ = header; - cursor.L++; - cursor.SkipBlank(); - while (cursor.L < cursor.Lines.Length) - { - Match lead = LeadIn.Match(cursor.Lines[cursor.L]); - if (!lead.Success) - throw cursor.Error("expected 'STATE:', 'IF:', or 'NAV:'."); - if (lead.Groups["type"].Value is "STATE:" or "NAV:") - return; - cursor.C = 0; - _ = ReadTopLevelCondition(cursor); - _ = ReadTopLevelAction(cursor); - cursor.SkipBlank(); - } - } - private static void ReadNavBlock( Cursor cursor, Dictionary Nodes, uint FollowTargetId, string FollowTargetName)> navs, diff --git a/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs index b349de60..9752660e 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MetafSerializerTests.cs @@ -543,10 +543,40 @@ public sealed class MetafSerializerTests Assert.Equal("myvar", getOpt.SecondaryText); Assert.Equal(MetaActionKind.SetVtankOption, setOpt.Kind); + // Slice 1c review D1 / slice 7 item 7: a document with a STATE: + // rule section is a Meta profile — even one with an embedded NAV: + // block of its own — and TryLoadNav must now REFUSE it outright + // rather than silently skip the STATE: rules and load whichever + // NAV: block happened to come first (the prior behavior this same + // fixture used to exercise; see FollowNavNodeParsesAsANavOnlyDocument + // below for the "flw" node's own parsing coverage, now split into a + // standalone NAV:-only fixture). + var nav = new NavigationSettings(); + bool navLoaded = MetafSerializer.TryLoadNav( + af, nav, NoOpSpellCatalog.Instance, out string navError); + Assert.False(navLoaded); + Assert.Contains("STATE:", navError, StringComparison.Ordinal); + } + + /// + /// The "flw" nav node's own parsing coverage, split out of + /// once + /// that fixture became a refusal pin (slice 7 item 7) — a standalone + /// NAV:-only document (no STATE: preamble) is exactly what TryLoadNav + /// is meant to accept. + /// + [Fact] + public void FollowNavNodeParsesAsANavOnlyDocument() + { + string af = string.Join("\r\n", + [ + "NAV: myfollow follow", + "\tflw 00001234 {Some Monster}", + ]) + "\r\n"; var nav = new NavigationSettings(); Assert.True( - MetafSerializer.TryLoadNav(af, nav, NoOpSpellCatalog.Instance, out string navError), - navError); + MetafSerializer.TryLoadNav(af, nav, NoOpSpellCatalog.Instance, out string error), + error); Assert.Equal(RouteMode.Target, nav.Mode); Assert.Equal(0x00001234u, nav.FollowTargetObjectId); Assert.Equal("Some Monster", nav.FollowTargetName); From a7b132282058c5f461f40fdda94b75133c5e43d1 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 07:26:18 +0200 Subject: [PATCH 03/57] =?UTF-8?q?feat(vtank):=20slice=207=20S7.1/S7.2=20?= =?UTF-8?q?=E2=80=94=20856-wide=20window,=20VTank=20Options/Profiles/Vital?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign VT slice 7 (docs/plans/2026-09-07-campaign-vt-slice7-tabs.md), S7.1 (Window) and S7.2 (Options/Profiles/Vitals). Owner's bar: looks basically the same as VTank, judged side by side. S7.1: mosstank.xml's content area is now 856 wide (VTank's own mainView.xml, docs/research/vtank-kb/08-ui-views.md §0), tab order/ widths unchanged (already assessed as reading close to VTank's strip). Panel height grows to 350 (not VTank's 210) only because the Advanced Options and Loot Editor pages move to VTank's own POPUP geometry (392x300 / 268x300, KB §1's secondary-view tables) as separate in-panel groups (KB §5's documented design choice: acdream keeps these in one window rather than VTank's separate OS-level popup windows) — the nine normal tab bodies keep their existing 194-tall content (== VTank's 210 minus its own 16px HudTabView strip) and simply don't fill the extra room the popups need. cShowAdvanced/cShowLootEditor are now VTank's own Checkboxes (open AND close via the same control) instead of separate Show/Hide push buttons — MossTankPanel gained ToggleAdvancedOptionsVisible/ ToggleLootEditorVisible thin wrappers over the existing Show/Hide actions, and CombatMacroRunning (VTank's cOn "Run Macro" is a Checkbox with a static caption, not a dynamic-caption push button). S7.2: Options, Profiles, and Vitals are transcribed control-for-control from KB §1's exact L,T,W,H and captions. Vitals' nine sliders now use the new to bind the percent directly (NormalHealthPercent/SetNormalHealthPercent etc. — thin wrappers over the existing 0.0-1.0 fields, never independent state). Options gained direct checkboxes for four settings that already existed but were only reachable through the generic Advanced Options key-value editor (AutoStackEnabled was already exposed; FastCastBuffsEnabled/ DontShootAtWallsEnabled/DebuffFallbackEnabled are new thin wrappers over _buffSettings.FastCastBuffs/_combatSettings.UseProjectileAwareness/ _combatSettings.AllowDebuffFallback). Profiles gained a real Meta-profile combo/CopyTo row (previously a stub label) and lost the Loot Priority Boost toggle as a duplicate of the Options-tab control of the same name; VTank's own bMetaClearViews ("Del. Meta Windows") has no acdream equivalent — MossTank has no floating meta-debug windows — so that slot is repurposed for our own per-type Delete buttons (slice 1) instead, keeping VTank's row rhythm undisturbed. The Loot Editor popup's LootEditorNotice label is dropped to fit VTank's 268x300 footprint without clipping (a status label, not an action — the one deliberate trim, noted in the group's own comment). Contract test updates (both shown to fail against the prior mosstank.xml: 856/350 vs 800/244, 205 vs 194 controls): AuthoredShellFitsTheMinimum CanvasAndEverySizedChildFitsItsParent's window-size pin, and EveryInteractiveControlDeclaresARealHandlerBinding's control count. New MossTankPanelTests pins: FastCastProjectileAwarenessAndDebuffFallback ToggleTheirVtankDefaultsAndPersist, ToggleAdvancedOptionsAndLootEditor VisibilityFlipBothWays, CombatMacroRunningReflectsTheSameStateAsCombat ButtonText, VitalsPercentWrappersReadAndWriteTheSameFieldAsTheZeroToOnePair. Full suite green: 645/645 MossTank tests, 183/183 (3 pre-existing skips) App markup/menu/slider tests. AssertWithinParent (every nested group/ control fits its parent) and every other contract invariant (tab order, binding shapes, tooltips) pass unchanged. Deviation: S7.3-S7.6 (Monsters/Items/Consumables/Buffs/Route/Meta tab bodies) are explicitly out of this sub-slice's scope per the plan and are untouched beyond the geometry-only group-width widen (784->848, content unchanged) needed so they aren't clipped by the wider window. Co-Authored-By: Claude Fable 5.1 --- src/AcDream.Plugins.MossTank/MossTankPanel.cs | 86 ++++ src/AcDream.Plugins.MossTank/mosstank.xml | 445 +++++++++++------- .../MossTankMarkupContractTests.cs | 29 +- .../MossTankPanelTests.cs | 100 ++++ 4 files changed, 476 insertions(+), 184 deletions(-) diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index a487505b..aa68162d 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -359,6 +359,20 @@ internal sealed partial class MossTankPanel LoadAdvancedOptionDraft(); }; public Action HideAdvancedOptions => () => _advancedOptionsVisible = false; + /// + /// Campaign VT slice 7: VTank's own cShowAdvanced (Options tab, + /// 611,128,233,20) is a single Checkbox that both opens and closes the + /// popup depending on its checked state — our Show/Hide were previously + /// two separate actions with no combined toggle for a control + /// bound to . + /// + public Action ToggleAdvancedOptionsVisible => () => + { + if (_advancedOptionsVisible) + HideAdvancedOptions(); + else + ShowAdvancedOptions(); + }; public Action SelectAdvancedOption => index => { _selectedAdvancedOption = Math.Clamp( @@ -380,6 +394,13 @@ internal sealed partial class MossTankPanel public string BuffButtonText => _running ? "Stop buffing" : "Force buff"; public string BuffStatus => _status; public string CombatButtonText => _combat.ButtonText; + /// + /// Campaign VT slice 7: VTank's own cOn (Options tab, 611,148,233,20 + /// "Run Macro") is a Checkbox with a static caption — our own dynamic + /// "{CombatButtonText}" push-button read the same underlying state but + /// had no bool to bind a to. + /// + public bool CombatMacroRunning => _combat.Enabled; public string CombatStatus => _combat.Status; public string CombatTarget => _combat.TargetText; public string CombatMode => _combat.ModeText; @@ -398,6 +419,22 @@ internal sealed partial class MossTankPanel public bool AutoFellowManagementEnabled => _combatSettings.AutoFellowManagement; public string FellowshipManagerStatus => _fellowshipManager.Status; + /// + /// Campaign VT slice 7: VTank's own Options tab authors cFastCast + /// (386,108,160,20 "Fastcast Buffs"), cCollisionChecks (386,128,160,20 + /// "Don't Shoot at Walls" — bound to UseProjectileAwareness, the + /// inverse-named XML control KB 08 §1 calls out), and cDebuffFallback + /// (386,148,160,20 "Fallback Debuffs if Blocked") as direct checkboxes. + /// The three underlying settings already existed (read/written through + /// the generic Advanced Options key-value editor via SetMetaOption's + /// "fastcastbuffs"/"useprojectileawareness"/"allowdebufffallback" keys) + /// but had no direct bool/Action pair for a markup to bind — + /// these three read/write the SAME backing fields, adding no new + /// setting. + /// + public bool FastCastBuffsEnabled => _buffSettings.FastCastBuffs; + public bool DontShootAtWallsEnabled => _combatSettings.UseProjectileAwareness; + public bool DebuffFallbackEnabled => _combatSettings.AllowDebuffFallback; public bool AutoStackEnabled => _inventorySettings.AutoStack; public bool AutoCramEnabled => _inventorySettings.AutoCram; public bool AutoCraftItemsEnabled => _inventorySettings.AutoCraftItems; @@ -446,6 +483,15 @@ internal sealed partial class MossTankPanel _crafting.Reset(); SaveProfile(); }; + public Action ToggleFastCastBuffs => () => SetMetaOption( + "FastCastBuffs", + ExpressionValue.Boolean(!_buffSettings.FastCastBuffs)); + public Action ToggleDontShootAtWalls => () => SetMetaOption( + "UseProjectileAwareness", + ExpressionValue.Boolean(!_combatSettings.UseProjectileAwareness)); + public Action ToggleDebuffFallback => () => SetMetaOption( + "AllowDebuffFallback", + ExpressionValue.Boolean(!_combatSettings.AllowDebuffFallback)); public Action ToggleCastDispelSelf => () => SetMetaOption( "CastDispelSelf", ExpressionValue.Boolean(!_vitalSettings.CastDispelSelf)); @@ -566,6 +612,18 @@ internal sealed partial class MossTankPanel _lootEditorVisible = true; RefreshLootEditor(); }; + /// + /// Campaign VT slice 7: VTank's own cShowLootEditor (Profiles tab, + /// 440,56,80,20 "Show Editor") is a single Checkbox that both opens and + /// closes the popup depending on its checked state. + /// + public Action ToggleLootEditorVisible => () => + { + if (_lootEditorVisible) + CloseLootEditor(); + else + ShowLootEditor(); + }; public Action SelectLootProfile => SelectLootProfileCore; public Action SelectLootClassifier => value => { @@ -1088,6 +1146,34 @@ internal sealed partial class MossTankPanel public Action SetAttackPower => value => UpdateProfile(() => _combatSettings.AttackPower = Math.Clamp(value, 0f, 1f)); + // Campaign VT slice 7 S7.2: VTank's own nine Vitals sliders + // (docs/research/vtank-kb/08-ui-views.md §1) are minimum="0" + // maximum="100" and their bound Recharge-* settings are literally + // percentages — now lets markup bind that + // directly. The *Value/Set* pair above works in the model's native + // 0.0-1.0 fraction and stays exactly as-is (existing tests pin it); + // these *Percent wrappers read/write the SAME nine fields through the + // existing 0-1 Set* actions, just rescaled, so the two binding shapes + // can never disagree. + public float NormalHealthPercent => NormalHealthValue * 100f; + public float NormalStaminaPercent => NormalStaminaValue * 100f; + public float NormalManaPercent => NormalManaValue * 100f; + public float NoTargetHealthPercent => NoTargetHealthValue * 100f; + public float NoTargetStaminaPercent => NoTargetStaminaValue * 100f; + public float NoTargetManaPercent => NoTargetManaValue * 100f; + public float HelperHealthPercent => HelperHealthValue * 100f; + public float HelperStaminaPercent => HelperStaminaValue * 100f; + public float HelperManaPercent => HelperManaValue * 100f; + public Action SetNormalHealthPercent => value => SetNormalHealth(value / 100f); + public Action SetNormalStaminaPercent => value => SetNormalStamina(value / 100f); + public Action SetNormalManaPercent => value => SetNormalMana(value / 100f); + public Action SetNoTargetHealthPercent => value => SetNoTargetHealth(value / 100f); + public Action SetNoTargetStaminaPercent => value => SetNoTargetStamina(value / 100f); + public Action SetNoTargetManaPercent => value => SetNoTargetMana(value / 100f); + public Action SetHelperHealthPercent => value => SetHelperHealth(value / 100f); + public Action SetHelperStaminaPercent => value => SetHelperStamina(value / 100f); + public Action SetHelperManaPercent => value => SetHelperMana(value / 100f); + public Action DifficultyDown => () => UpdateProfile(() => _buffSettings.SkillExcessOverDifficulty = Math.Max(-100, _buffSettings.SkillExcessOverDifficulty - 5)); diff --git a/src/AcDream.Plugins.MossTank/mosstank.xml b/src/AcDream.Plugins.MossTank/mosstank.xml index 6c697b2d..bf43c153 100644 --- a/src/AcDream.Plugins.MossTank/mosstank.xml +++ b/src/AcDream.Plugins.MossTank/mosstank.xml @@ -4,7 +4,19 @@ live policy. Every visible control is bound to the behavior exercised by the corresponding VTank compatibility lane. --> - + - - -