merge(vt): slice 7 — the nine MossTank tabs at VTank's layout (review-closed)

Campaign VT slice 7: window at VTank's proportions (resizable, anchored),
Options/Profiles/Vitals/Monsters/Items/Consumables/Buffs/Route/Meta
transcribed from mainView.xml with VTank's per-cell behaviors, Advanced
Options in VTank's click model, the buff picker and meta-editor popups,
VTank's 27 recalls wired to routes, plain dropdowns/sliders with retail
scrollbar chrome. Two Opus lenses + rounds A–F + owner live looks; final
combined re-check MERGE-READY.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

# Conflicts:
#	docs/plans/2026-09-07-campaign-vt-slice7-tabs.md
This commit is contained in:
Erik 2026-09-07 19:42:29 +02:00
commit 4a825c23ca
45 changed files with 6412 additions and 957 deletions

View file

@ -91,22 +91,62 @@ re-review, merge to the campaign branch, then the owner's visual gate.
## Ledger
- 2026-09-07 planned.
- 2026-09-07 S7.1/S7.2 landed on `worktree-agent-a46673911c3cc2a31` (`e72a64a31` slider min/max + menu scroll, `f6eebf8c5` TryLoadNav refuses STATE:, `a7b132282` window + Options/Profiles/Vitals; MossTank suite 640 → 645). Lead's note for the review: the implementer made the panel 856×350 to fit the Advanced Options / Loot Editor groups in-panel; VTank's main window is 856×210 with those two as SEPARATE popups. Fix-round candidate: register them as separate plugin panels (own windows via `IUiRegistry`) so the main panel returns to 210 tall. Screenshots not captured — the implementer (and the lead) probed 127.0.0.1:9000 over TCP; ACE listens on UDP and was up the whole time. Lesson: never probe ACE with `Test-NetConnection`/TCP — launch the client and read its log. Screenshots are owed at the next worktree boundary. S7.3 (Monsters) dispatched on the same worktree.
- 2026-09-07 S7.1/S7.2 implemented in worktree `agent-a46673911c3cc2a31`
(branch `worktree-agent-a46673911c3cc2a31`, base `7cfe57827`, current
`fbdbbc7f9`). Three commits: `e72a64a31` (`<slider min max>` +
`<menu scroll>` markup additions), `f6eebf8c5` (unrelated slice-1c
review fix, `TryLoadNav` refuses `STATE:`), `a7b132282` (the S7.1/S7.2
window + Options/Profiles/Vitals rework). 856x350 window (350, not
VTank's 210, only because the Advanced Options/Loot Editor popups
moved to VTank's own 392x300/268x300 geometry as separate groups);
Options/Profiles/Vitals transcribed control-for-control from KB 08 §1.
Full suite green (645/645 MossTank, 183/183 App markup/menu/slider,
3 pre-existing skips). S7.3-S7.6 (Monsters/Items/Consumables/Buffs/
Route/Meta) and S7.7 (gate script) are NOT done — out of this
sub-slice's scope, group width widened only so they aren't clipped.
Screenshots NOT captured — ACE (127.0.0.1:9000) was not listening at
implementation time, so the live-client UI-probe recipe was skipped
per the owning instruction rather than looping on a stuck connect.
Owner's visual gate against a running VTank is still owed.
- 2026-09-07 S7.3 implemented in worktree `agent-a46673911c3cc2a31`
(branch `worktree-agent-a46673911c3cc2a31`, on top of `89a49836c`).
One commit `57ced0aff`: the Monsters tab body is now VTank's own
23-column HudList grid (14 check + 7 text + 2 icon columns, 15 header
`<label>`s carrying the decompile's own tooltip text) transcribed
from `docs/research/vtank-kb/08-ui-views.md` §1 and
`PluginCore.cs:7885-8105`'s per-cell handler, replacing the old
select-then-edit adaptation entirely. Deviation: Weapon/Offhand
columns cycle the registered weapon roster (Items tab) rather than
VTank's own opaque weapon-TYPE id list, since MossTank models
equipment as concrete owned items, not types — see the commit body
for the full reasoning. Every new/changed pin shown to fail against a
targeted mutation first. Full suite green (651/651 MossTank — net +6
over S7.1/S7.2's 645, 109/109 App markup-filtered). S7.4-S7.6 (Items/
Consumables/Buffs/Route/Meta) and S7.7 (gate script) remain out of
this sub-slice's scope. Screenshots NOT captured (same ACE-down
reason as S7.1/S7.2). Owner's visual gate against a running VTank is
still owed for both S7.1/S7.2 and S7.3.
- 2026-09-07 07:55 live screenshots of `89a49836c` captured (gate worktree, ACE up — the earlier "ACE down" was a TCP probe against a UDP server) → `docs/research/2026-09-07-slice7-screenshots/` (cropped to the panel, 864×360). Lead's read for the review + fix round: (1) Vitals reads like VTank; (2) Options has the right arrangement but VTank's literal `L,T,W,H` do not fit our DAT font — captions overlap the next row ("Follow/Nav Min Distance" over "Enable Navigation"): scale VTank's geometry to our font (row pitch from the DAT font's line height instead of VVS's 16 px; widths likewise), keep the proportions, never pixel-match; (3) Profiles still shows legacy controls (three stacked "New" buttons, the Loot engine row, the macro-profile path string drawn twice) — delete them; (4) the window is 856×350 with ~180 px of dead space below the content: bring the main panel back to VTank's 210-tall proportion (scaled) and make Advanced Options / Loot Editor their own plugin panels (separate windows, like VTank's popups) instead of in-panel groups; (5) the Route, Meta and Loot-editor pages show blank trailing button slots.
- 2026-09-07 08:05 owner looked at the live gate build: (a) "The Options tab labels overlap, fix that"; (b) "Those BIG gold/yellow buttons HAS to go. That is not how vtank looks." — the plugin `<menu>` renders retail's gold pushbutton art; VTank's combos are plain (flat dark box, thin border, value text, small arrow at right — the same look as its lists). Fix round: plugin-markup `<menu>` draws the plain combo by default (a `style="retail"` opt-in keeps the gold art for anyone who wants it), plus the geometry scaling, the Profiles leftovers, the 210-tall main window and the two popups as separate panels.
- 2026-09-07 09:10 S7.3 Monsters landed on the panel worktree (`57ced0aff`, `c3b4f7862`; MossTank suite 645 → 651) — same commits as the entry above; recorded again here because the plain-menu-style branch (`cfa703065`) merged into the panel worktree afterward to pick up fix (b) before fix round A started. Fix round A (grid scaling, Profiles leftovers, 260-tall window, Advanced Options / Loot Editor as their own panels, blank trailing slots, fresh screenshots) dispatched on the same worktree after merging the plain-menu style in. S7.4S7.6 follow.
- 2026-09-07 fix round A landed on the panel worktree, four commits: `045cd0a19` (merge `claude/latest-main-sync-497549`, bringing the plain-`<menu>`-style fix (b) in — resolved the ledger/markup/test conflicts by keeping both sides' content), `565a33d78` (grid scaling + Profiles cleanup + popups split into their own panels), `e414b2f56` (AcDream.App.csproj's CopyMossTankPlugin* targets hardcoded mosstank.xml as the only file to copy into `plugins/AcDream.Plugins.MossTank/` — the two new popup markup files silently landed in the App's own bin root instead and would have thrown `FileNotFoundException` on load; caught before any screenshot by inspecting the build output layout, not by a test), `78b42a519` (StartVisible=true fix for both popups — `StartVisible=false` left `PluginWindowVisibilityController`'s "requested visible" axis permanently false with no shelf entry to ever call `OnShown()`, so neither popup ever rendered despite a checked/green checkbox; plus dropped each popup's now-redundant in-content title label, and repositioned both away from the overlapping (440,60) placeholder). Real DAT-font measurements (`AcDream.Cli dump-font-atlas` against the installed DAT: font 0x40000000 MaxCharHeight=16, matching VVS's own assumed row height exactly) replaced the "sy row-pitch" theory in the 07:55 lead's read — the actual fix is a translation of the columns after each overflowing caption (Options +62px, Profiles +16px), not a font-driven vertical scale. Fresh screenshots recaptured end-to-end against a live local ACE with an isolated `ACDREAM_CONFIG_DIR` (stale persisted popup window positions from earlier probe runs would otherwise have overridden the new authored defaults forever — `RetailWindowLayoutPersistence` has no revision bump wired for plugin windows). All six requested screenshots (Options/Profiles/Vitals/Monsters/both popups) confirm: no overlap, no gold buttons, no stacked New/Loot-engine/path-string leftovers, both popups open as genuinely separate windows with clean titles, and the Route/Meta/Loot-editor move-up/move-down slots render real DAT icons instead of blank buttons. MossTank suite 651 → 654 (three new pins: the two-file `SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves` theory cases + `NoButtonAnywhereUsesTheUnrenderableArrowGlyphs`); App markup/plugin/menu filter holds 237/237. Deviation carried forward: Macro/Nav CopyTo lost their only in-UI target-name entry (the deleted 3-row block was their sole source; Meta already has one on its own tab) — matches VTank's own Profiles table having no name-draft control at all, but is a real, accepted capability regression pending a future naming-UX slice. Owner's connected visual gate is the next step.
- 2026-09-07 S7.4S7.6 implemented on the panel worktree (base `66b070def`), three commits: `f5409530f` (S7.4 — Items' 2-column name/hands grid, Consumables' "Excluded Scarab Types" icon+text grid and "Add Selected" button, Buffs' Extra Buff Spells / Blacklisted Buff Families lists plus a shared `mosstank-buffpicker.xml` SelfBuffChoiceView-style picker popup registered the same way fix round A's two popups are), `6118062a7` (S7.5 — Route's clWP/clWPc 2-column waypoint grid, the "Follow" nav-mode display remap, `scroll="true"` on the recall menu, and a third nav image button for "Select Nearest Point"), `cc323f6a5` (S7.6 — Meta's 6-column lstMetaRules grid: delete/move-up/move-down cells plus State/Condition/Action text cells opening the existing rule editor). Deviations documented at their own binding site: Items' Hands column is session-local only (no backing wieldable-handedness data anywhere in the plugin surface); Consumables' "Add Selected" accepts any selected owned item rather than requiring VTank's own SpellComponent object-class check (no classifier surface exists for plugins); ExtraBuffSpellNames/BlacklistedBuffFamilyNames (BuffPlan.cs) add storage + UI only, not wired into `BuffPlan.Build`'s cast selection (real casting-algorithm behavior, owned by a future Campaign VT behavior slice); Route's recall menu keeps its real 4 kinds rather than VTank's 27 named recalls (needs real per-recall spell-id data); Route's "Select Nearest Point" moves the tab's own edit selection rather than VTank's live navigation cursor (no mutable cursor exposed to a plugin); Meta's delete cell is a text "X" rather than an icon (no retail DAT delete-glyph id confirmed anywhere in this codebase, unlike the established move-up/move-down `0x060028FC`/`0x060028FD` pair). Every new/changed pin (contract control count 167→177→180→186, the new `mosstank-buffpicker.xml` popup pin, six new `MossTankPanelTests` interaction tests) was shown to fail against a targeted mutation before being confirmed green. MossTank suite 654 → 660; App markup/plugin filter holds 192/192; full solution builds clean in Release. Fresh live screenshots recaptured against the same local ACE recipe fix round A established (isolated `ACDREAM_CONFIG_DIR`/`ACDREAM_DATA_DIR`, an `ACDREAM_UI_PROBE_SCRIPT` route through the five changed tabs plus the new buff picker popup) → `docs/research/2026-09-07-slice7-screenshots/` (`tab-items.png`, `tab-consumables.png`, `tab-buffs.png`, `tab-route.png`, `tab-meta.png` recaptured at 900×300; `popup-buffpicker.png` added at 940×715). All six confirm: plain (non-gold) controls throughout, no overlapping captions, the two new Consumables/Buffs grids and the buff picker popup render correctly, and the Route/Meta move icons render real DAT art. No crashes or ungraceful exits across the probe runs. S7.7 (the gate script) and the owner's connected visual gate remain.
- 2026-09-07 09:10 S7.3 Monsters landed on the panel worktree (`57ced0aff`, `c3b4f7862`; MossTank suite 645 → 651): the 23-column grid with VTank's exact cycle lists (P 1…4; Dmg type 14 values; Ex. Vuln 9; PetDmg 10; name click deletes; arrows reorder with DEFAULT pinned). Implementer deviations for the review: Weapon/Offhand cycle MossTank's registered item roster instead of VTank's opaque weapon-type ids (MossTank models concrete owned items); the move-up/down DEFAULT guard is symmetric. Fix round A (grid scaling, Profiles leftovers, 260-tall window, Advanced Options / Loot Editor as their own panels, blank trailing slots, fresh screenshots) dispatched on the same worktree after merging the plain-menu style in. S7.4S7.6 follow.
- 2026-09-07 10:10 fix round A landed on the panel worktree (`045cd0a19` merge of the plain menu, `565a33d78` column shifts + Profiles cleanup + 236-tall window + popup panel files, `e414b2f56` csproj plugin-copy fix, `78b42a519` popups actually render (`StartVisible` gotcha) + fresh screenshots, `66b070def` ledger; MossTank suite 651 → 654). Owner's two complaints verified fixed on the new screenshots. Deviation for the review: Macro/Nav CopyTo lost their in-UI target-name field with the deleted block (VTank has none either). S7.4S7.6 dispatched on the same worktree.
- 2026-09-07 10:20 owner, live: "Drop down menus look horrible, there is also a checkmark on the text there." — the OPEN popup still draws retail art (tan gradient panel, ornate gold scrollbar, checkmark on the selected row). Plain open state (dark list rows, selected fill, plain scrollbar, no checkmark) dispatched on the plain-menu worktree; merges to the campaign branch, then into the panel worktree at fix round B.
- 2026-09-07 11:20 S7.4S7.6 landed on the panel worktree (`f5409530f` Items/Consumables/Buffs + buff picker popup, `6118062a7` Route grid, `cc323f6a5` Meta grid, `41fc1d88d` screenshots; MossTank suite 654 → 660); the plain popup merged in at `dbdde0783`. Both Opus lenses dispatched on the whole slice. Lead's read of the new Route and Meta screenshots for fix round B: VTank's Route tab is the waypoint list + one small row under it (nav-type menu, insert-mode menu, three icon buttons) + the right-hand Add grid — ours still carries MossTank-only controls on the tab (Checkpoint / Jump / Remove / Set Follow Target / Follow target / Nav Priority / Follow Corners / Open Doors / Use NPC / Follow-Nav Min Distance spinners; "Add to End" rendered as a button instead of the insert-mode menu) that VTank does not show there — move them out (Advanced Options popup, or drop what VTank has no equivalent for). VTank's Meta tab is the six-column grid + Create + "Current State:" menu — ours adds a profile row (By char / New / CopyTo / Clear / Delete / Enable Meta / State) that belongs on Profiles, and an INLINE rule editor (state, condition/action menus, three fields, N/N2 spinners, Apply/Add/Remove, arrows) where VTank opens a separate rule-editor view on click — make it a popup panel like the buff picker. Implementer deviations to judge: Hands column session-local; extra/blacklisted buff names stored but not consumed by the plan; recall menu 4 kinds vs VTank's 27; "Select Nearest Point" moves the edit selection; Meta delete cell is a text "X".
- 2026-09-07 12:00 both Opus lenses on `dbdde0783`. Resemblance: APPROVE-WITH-FIXES — Options, Vitals and the buff picker read as VTank; Monsters one header fix away; Buffs (VTank's two lists shrunk for a MossTank block, a new "+"/"Add…" overlap) and Meta (grid buried under a toolbar and an inline editor; "Current State:" not settable) do not read as VTank; clipped status lines on three tabs + the Loot Editor; "CopyTo" overflowing its button; Route's grid re-flowed 3-across with "Add NPC Talk" renamed and the nav icons moved; the gold slider track; the 4-vs-27 recall menu is a data hole (slice 6). Owner complaints 1 and 2 closed on the screenshots; 3 closed in code but needs a dropdown-open re-capture. Architecture: APPROVE-WITH-FIXES — one RED App test at HEAD (the `<menu scroll>` sprite pin invalidated by the plain popup merge; the ledger's 192/192 was stale), `ExcludedComponentIcons` captures the whole inventory per row per frame, derived columns allocate per frame, the markup file list hardcoded in four places, the buff lists look live but are neither consumed nor persisted, two host defects found live (filed as #490), the `MarkupDocument.Build`-over-real-files seam untested. Lead decisions for round B: VTank's own controls never shrunk/moved for MossTank extras (extras → Advanced Options popup or dropped); plain sliders; no grid selection band; the Meta rule editor as its own popup; the four Deletes stay, aligned. #491 filed for the buff-list wiring (slice 4). Fix round B (18 items) dispatched.
- 2026-09-07 14:50 fix round B landed 16/18 on the panel worktree (`040d5f3d8``8d3c6ad7c`; MossTank suite → 676, App markup/plugin filter 285/3 skipped); the agent was stopped after three hours failing the screenshot step (malformed session config, disk-wide search) — nothing lost, tree clean. Architecture narrow re-check at `8d3c6ad7c`: NOT merge-ready — D1 the Advanced Options popup re-materializes the 163-entry catalog per frame (the class item 12 killed elsewhere), D2 `/mt refresh` mutates Rules without `RefreshMonsterEditor()`, D3 the bottom-band pin skips unsized children; carried nits (hands column per frame, popup-size theory hardcoded, #491 not cited, mutable bool[]). Fix round C dispatched. Screenshots: replaced by the owner testing LIVE from the gate build at `8d3c6ad7c` (all five plugin windows load); the owner's findings feed round C/D; the resemblance re-check runs on the final state.
- 2026-09-07 14:50 fix round B landed 16/18 on the panel worktree (`040d5f3d8``8d3c6ad7c`; MossTank suite → 676, App markup/plugin filter 285/3 skipped); the agent was stopped after three hours failing the screenshot step (malformed session config, disk-wide search) — nothing lost, tree clean. Item 16 was the dedupe, tab-switch popup cleanup, and slider validation. Architecture narrow re-check at `8d3c6ad7c`: NOT merge-ready — D1 the Advanced Options popup re-materializes the 163-entry catalog per frame (the class item 12 killed elsewhere), D2 `/mt refresh` mutates Rules without `RefreshMonsterEditor()`, D3 the bottom-band pin skips unsized children; carried nits (hands column per frame, popup-size theory hardcoded, #491 not cited, mutable bool[]). Fix round C dispatched. Screenshots: replaced by the owner testing LIVE from the gate build at `8d3c6ad7c` (all five plugin windows load); the owner's findings feed round C/D; the resemblance re-check runs on the final state.
- 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 `<menu>` popups and overflowing `<list>`s (chat/inventory skin ids `0x06004C5F`/`6C`/`69`/`60`/`63`/`66`; markup menus are one-column scrollable), `2e63391cc` `<panel resizable minw minh>` + `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 16:30 fix round C (architecture re-check of fix round B `8d3c6ad7c`) landed on the panel worktree, seven commits: `466fac426` (D1 — Advanced Options popup's four bindings, previously re-filtering the 163-entry VtankOptionCatalog and re-running GetMetaOption/ToDisplayString per row on every draw, materialized once by a new RefreshAdvancedOptions() called from category toggle/edit-apply/selection-change/profile-load), `23d4376cc` (D4 — AdvancedOptionCategoryEnabled no longer hands out the mutable backing bool[] directly; a ReadOnlyCollection view built once over the same array), `f15667db5` (D2 — `/mt refresh` now calls RefreshMonsterEditor after EnsureDefaultMonsterRule, the one gap found auditing every `_combatSettings.Rules` mutation site in the plugin), `3b8d02194` (D3 — AssertWithinParent's bottom-band check now counts each widget kind's own implicit default height when `h` is absent, label/field 16 toggle 20 button 16, instead of reading an absent `h` as zero), `37055bddc` (F7+F12 — ItemHandsColumn materialized once in RefreshItemEditors from the SAME base-name array that builds the decorated `_itemRows`, replacing a per-frame `.Select().ToArray()` plus a suffix-reparsing BaseItemName helper with one forward-only decoration; CycleItemHandsAtCore now refreshes the cache it mutates), `0724761ad` (F3 — SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves discovers popup files with the same glob the sibling-overlap and arrow-glyph tests already use instead of a hand-maintained InlineData list), `d20ad47c5` (F4 — the ExtraBuffSpellNames/BlacklistedBuffFamilyNames field comments and their two Buffs-tab tooltips now cite issue #491). D3's grep audit of every real `mosstank*.xml` file found no label/toggle/field/button missing `h`, so the stricter bottom-band check introduced zero new failures against shipped markup. Every new/changed pin was shown to fail against a targeted mutation (property/getter reverted, or the assertion target broken) before the fix was restored; D1's own fix broke one PRE-EXISTING test (`AdvancedOptionValueColumnMirrorsTheLiveSettingValue`, which expected a setting toggled OUTSIDE the popup's own mutators to appear on the very next read) — updated it to exercise the "selection change" mutator afterward, with a comment explaining the narrowed contract. MossTank suite 677 (D1) → 678 (D4) → 679 (D2) → 680 (D3) → 681 (F7+F12) → 678 (F3, four InlineData cases collapsed into one Fact, no coverage loss) → 678 (F4, comment/tooltip only) → 678 (F13, no new test, an assertion removed). Full solution build green after every commit; App markup/plugin filter held 203/203 throughout.
- 2026-09-07 fix round C item F13 folded the `EveryInteractiveControlDeclaresARealHandlerBinding` test's exact-control-count history here, in the same commit that removed it from the test file: Round 3 item 10 added +3 for the Route/Loot/Meta Delete buttons (Settings already had one from round 2 step 5). Campaign VT slice 7 S7.1/S7.2 transcribed Options/Profiles/Vitals control-for-control from VTank's own tabs (docs/research/vtank-kb/08-ui-views.md §1) and repositioned the Advanced Options/Loot Editor popups to VTank's exact 392x300/268x300 geometry — net 194 → 205. The Options tab gained four direct checkboxes for settings that already existed but were only reachable through the generic Advanced Options key-value editor (Enable Autostack, Fastcast Buffs, Don't Shoot at Walls, Fallback Debuffs if Blocked); the Profiles tab gained a real Meta-profile combo/CopyTo row (previously a stub label) and per-type name fields/New buttons, and lost the Loot Priority Boost toggle as a duplicate of the Options-tab control of the same name; the Loot Editor popup lost LootEditorNotice (a status label, not an action). S7.3 replaced the whole Monsters tab body with VTank's own 23-column grid: the select-then-edit surface (1 list + 1 field + 8 buttons + 3 menus + 14 toggles + 3 equip buttons = 30 controls) is gone, replaced by 1 list + 23 columns + 1 field + 2 buttons (27) — net 205 → 202. Fix round A (2026-09-07) moved the Advanced Options and Loot Editor groups OUT of mosstank.xml entirely into their own plugin panels and deleted the Profiles tab's three stacked name-draft field+"New"-button rows and the acdream-only "Loot engine" classifier menu — net 202 → 167. S7.4 replaced the Items tab's single-column list with VTank's own clWeaponName/clHandedness 2-column grid (+2), added the Consumables tab's "Excluded Scarab Types" 2-column grid plus its "Add Selected" button (+4), and added the Buffs tab's Extra Buff Spells / Blacklisted Buff Families lists plus their two "Add..." buttons (+4) — net 167 → 177. S7.5 replaced the Route tab's single-column waypoint list with VTank's own clWP/clWPc 2-column grid (+2) and added the "Select Nearest Point" button (+1) — net 177 → 180. S7.6 replaced the Meta tab's single-column rules list with VTank's own 6-column lstMetaRules grid (+6) — net 180 → 186. Fix round B item 5: VTank's real Meta tab is only 5 controls (the grid, "Create", a caption, and the settable current-state menu). Removed entirely: the profile toolbar (menu/name-draft/New/CopyTo/Clear/Delete, 6) and "Enable Meta" (1) — 7 controls gone for good. Moved into mosstank-metaeditor.xml (a separate file this test doesn't scan): the State/Condition/Action editor (2 fields + 2 menus + 1 field for state + 1 secondary-text field + 4 numeric-stepper buttons + Apply/Remove/MoveUp/MoveDown, 14). Added: the settable cmbMetaCurrentState menu (+1). Net 186 → 166 (-7 -14 +1). Fix round B item 8: the Route tab collapses to VTank's own 2-across button grid + bottom nav-control row, 28 → 18 interactive controls within mosstank.xml. Checkpoint/Jump/Remove/Set Follow Target/Follow Corners/Open Doors/Nav Priority/the Follow-Nav-Min-Distance stepper moved to mosstank-advanced.xml's own new "MossTank Extras" section. The 2-state ToggleRouteAddPosition button became the real 3-option cmbNavInsertMode `<menu>`, and the pause "-"/"+" stepper became one editable field. Net 166 → 156 (the count the test asserted immediately before this fix round removed the assertion). The test's own per-control handler/enabled loop remains the live guarantee; a bare `Assert.NotEmpty(controls)` guards the selector itself. 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 round D landed on the panel worktree (four commits, this session, base `6b42fd68e`): merged `claude/latest-main-sync-497549` first (`4ba0a557f`, bringing in `b71a8ea37` retail scrollbar chrome and `2e63391cc` resizable/anchor markup — resolved the ledger/plugin-ui-markup/UiMarkupList/MarkupDocument/MarkupDocumentTests conflicts keeping both sides' intent, one Advanced Options popup design collision found and resolved in the merge itself: the sync branch's always-scrollable `<menu>` model won over this branch's opt-in `scroll=` attribute, since it is the owner-driven shipped design). Item 1 (`ebe670adf`): Advanced Options' category filter shows VTank's real names (Misc/Recharge/MeleeCombat/SpellCombat/Ranges/Navigation/Buffing/Crafting/Looting) instead of raw bitmask hex, derived from real single-category KB rows rather than a typed-in literal. Item 2 (`435ced86f`): VTank's click model — a `tBool` value flips in place, a `tEnum` value cycles to the next label (VtankDefaultSettingsDatabase.SettingEnumValues, parsed from the embedded .usd's own SettingsEnumInfo table), everything else selects into the edit field; Apply/Back buttons removed (retail has neither); panel height 476->450. Item 3 (`5318adbb3`): RouteRecallKind replaced with VTank's real 26-recall cmbRecallType table (metaf's NRecall) plus Marketplace kept as its own slash-command entry (27 total) — deviation: the old Lifestone slash-command member is dropped rather than duplicating the new spell-based LifestoneRecall's "Lifestone Recall" label; Primary/SecondaryPortal upgraded from runtime spell-name lookup to their real hardcoded ids (48/2647); AddRouteRecallCore now writes RecallSpellId+RecallSpellName onto the waypoint so a route-tab-added recall casts exactly like a metaf-imported one. Item 4 (`2d626971d`): main panel `resizable="true"` floored at the pre-round-D authored size (856x236 minw/minh), default enlarged ~15% to 984x271; all nine tab groups anchor="left top right bottom" and grow by the same 128x35 delta; Monsters/Meta's lists get full left/right/top/bottom stretch (nothing beside them); Items/Consumables/Buffs/Route's lists grow height-only, pinned away from a sibling list/button column; a right-pinned sibling list (Consumables' Excluded Scarab, Buffs' Blacklisted Buff Families) repositions +128 so it already sits flush-right at the enlarged default. Every new/changed pin shown to fail against a targeted mutation first (category-name projection reverted, ClickAdvancedOptionValue reduced to a bare select, SubmitRecall reduced to `return false`, enum-order swap, Monsters' anchor attribute removed) and restored green. MossTank suite 678 -> 682 (item 2) -> 713 (item 3) -> 715 (item 4); App markup/plugin filter 242 -> 243 (item 4's real-file re-layout test); full solution builds clean in Release throughout. Owner's connected visual gate against these five fixes remains the next step; #491 (buff-list wiring) and the pre-cutover-JSON RouteRecallKind-ordinal migration risk (documented at its own binding site in MossTankRouteProfileStore.cs) are the carried, accepted deviations from this round. 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 13 CLOSED, 45 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 `<list>` 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.
- 2026-09-07 20:50 round F landed on the panel worktree (`279de7c2d` merge of the campaign branch; `ad3f4df57` Advanced Options bottom block gone, popup 392×300 resizable; `9e23f0acb` `tString` hidden; `179e6f339` Items tab VTank-only (`RefillWornMana*` stay catalog settings); `6ef6eb751` recall combo 120 wide with VTank's 27 terse captions; `bae34aaa8` Monsters filler column; `837610593` Buffs strip / Loot Editor Back removed / Route count column 30 px; `13a304a92` ledger; MossTank suite 722 → 726, App filter 258 → 262). Deviation: the Buffs extras strip is centred at the 856 floor, not at the 984 default (a fixed strip can be centred at one width only; accepted under "polish later"). Final combined re-check (both lenses, E+F) dispatched; then merge + gate build.
- 2026-09-07 round E (architecture re-check of round D) landed on the same worktree, six commits, base `2b79ca325`: `3178d9202` (D-4 — an .af "rcl" node whose spell name fails to resolve leaves RecallSpellId at 0 and Recall at its default ordinal, PrimaryPortalRecall; SubmitRecall's own Recall-based fallback used to cast spell 48 for it regardless, since MetafSerializer.ReadNavNode never touches the enum on that path — TickRecall now refuses and skips instead, naming the unresolved spell in the status notice; the dead fallback branch is removed). `a9d1d0a82` (D-1 — the pre-cutover RouteRecallKind was {Lifestone=0, Marketplace=1, PrimaryPortal=2, SecondaryPortal=3}, completely different kinds at the SAME ordinals as the round-D enum, and Enum.IsDefined never caught the silent remap since 0..3 are all still valid new members; LegacyWaypointDocument.Recall is now a bare int translated through an explicit MapLegacyRecall table that also derives RecallSpellId/RecallSpellName). `3fe304359` (D-2 — RouteRecallNames/SelectedRouteRecall showed Enum.GetNames/ToString()'s bare C# identifiers; both now route through RouteWaypoint.RecallDisplayName like RouteInsertModeNames/RouteModeNames already do, SelectRouteRecall parses the same display string back, and the combo widened 120->300 to fit VTank's own full metaf-sourced names, e.g. "Paradox-touched Olthoi Infested Area Recall" — VTank's real combo is also 120 wide only because ITS captions are hand-abbreviated). `64b62fea3` (D-3 — mosstank.xml's own top comment already claimed the Advanced Options popup got resizable+anchored treatment; the popup file itself still carried the dead resize="none", never actually resizable; now resizable="true" minw/minh floored at the pre-fix authored 392x450, lOptionList grows WIDTH ONLY since the value field/description/notice sit directly below with zero vertical slack and none of them anchor to shift down, lFilterList tracks the growing right edge in lockstep so the widening list never walks into it; AdvancedOptionDescription is declared anchor="left top right" for consistency but documented as a real no-op — UiLabel.OnDraw always overwrites Width/Height to the measured text extent every draw, so nothing a label's anchor computes is ever visible). `1a0b0e014` (D-5 — RefreshAdvancedOptions' own doc comment lists every mutation site that must call it, but ShowAdvancedOptions itself was missing one, so a setting changed via /vt opt set or an Options-tab checkbox while the popup was closed stayed stale until some in-popup interaction refreshed it; ShowAdvancedOptions now calls RefreshAdvancedOptions). `7badbee88` (D-6 — a new whole-tree resolved-geometry pin at the main panel's minw/minh floor (856x236) and one enlarged size (1100x320), adapting AssertNoSiblingOverlap/AssertWithinParent's rectangle-overlap algorithm from the authored XElement tree to the resolved UiElement tree for each of the nine tabs in turn; building the test surfaced and fixed two real test-methodology bugs along the way — reusing one built tree across tabs let an earlier tab's resize corrupt a later tab's anchor-capture baseline, and walking into an invisible tab group's never-anchored-for-this-size descendants misreported stale geometry as broken — plus one genuine test-harness false positive (UiLabel's font-dependent measured box, excluded from the size checks for the same reason D-3's own commit documents). Every new/changed test shown to fail first via a targeted revert-then-reapply (git checkout + saved patch) or a deliberate markup mutation (D-6's Monsters-anchor removal), each restored green afterward. MossTank suite 715 (pre-round-E baseline, matching round D's close) -> 716 (D-4) -> 720 (D-1) -> 721 (D-2) -> 721 (D-3, App-side only) -> 722 (D-5); App markup/plugin filter 243 -> 243 (D-2 widened menu, no new pin) -> 244 (D-3) -> 246 (D-6, two theory cases). Full solution builds clean in Release throughout. Carried, accepted deviations unchanged from round D: #491 (buff-list wiring) and (now closed by D-1) the former pre-cutover-JSON RouteRecallKind-ordinal migration risk. 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.
- 2026-09-07 round F landed on the panel worktree, six commits, base `279de7c2d` (merge of `claude/latest-main-sync-497549``7b7e5e579` no selection band by default on plugin lists, `3d7065ebc` geometry-hash window revision, `b4fe18007` scrollbar snap-back fix; resolved the UiMarkupList/plugin-ui-markup/ledger conflicts keeping both sides' intent, including retiring the now-dead opt-in `<menu scroll="true">` doc section in favor of "menus always scroll"). Item 1 (`ad3f4df57`): the whole Advanced Options bottom block — the "Editing X." notice and the "MossTank Extras" section (Checkpoint/Jump/Remove/Set Follow Target/Follow Corners/Open Doors/Nav Priority/Follow-Nav min distance) — is gone; popup back to VTank's 392x300. Every removed control had a real path already: Checkpoint/Jump via `/mt addnavcheckpoint`/`/mt addnavjump`, Remove via the Route tab's own click-to-delete grid (DeleteRouteWaypointAt — identical logic to the removed RemoveRouteWaypointCore), Set Follow Target via re-selecting "Follow" in the Route tab's own nav-mode menu (already calls CaptureFollowTarget), and the four toggles/stepper via their own real VtankOptionCatalog entries (FollowAroundCorners/OpenDoors/NavPriorityBoost/NavCloseStopRange), still listed in THIS SAME shrunk popup. Item 2 (`9e23f0acb`): `FilteredAdvancedOptionNames` now excludes every `tString` row (BuffProfile-Prots/-Banes, BlacklistedSpellComps) — VTank's own Advanced Options list has no case for tString at all (KB 01, db.cs:132-166/:201-203) — closing the owner's "duplicated" BuffProfile report; the enum counterparts stay listed. Item 3 (`179e6f339`): Items tab's right-of-list MossTank block (Weapon/Offhand readout, Refill Worn Mana toggle/slider/status, the notice hint) is gone; RefillWornMana/RefillWornMana-Item-ManaPercent remain real catalog settings, editable in Advanced Options. The pre-campaign "Remove" button (traced to the plugin's very first commit, `4e6e9bc9d`) stays as the accepted slice-1 exception. Item 4 (`6ef6eb751`): the recall combo shows VTank's own terse cmbRecallType captions (RecallShortCaption, transcribed from the KB's mainView.xml citation since this worktree has no refs/vtank/ checkout) instead of round E's full spell names, back at VTank's real 120px width; the full name still lands on the waypoint/grid row/.af via RecallDisplayName, untouched. Item 5 (`bae34aaa8`): a 24th, genuinely-handled trailing filler column after Monsters' MoveDownIcons so it is no longer the grid's LAST column (docs/plugin-ui-markup.md's "last column always auto" rule) and keeps its declared 23px pitch — the arrows now stay a fixed ~23px apart at both 984 and 1100 wide, proven wrong first (89.5px/147.5px gaps against the unmodified file). Item 6 (`837610593`, three nits): Loot Editor's leftover "Back" button is gone (the title bar closes it, matching every other popup since round D); Route's count column narrows the text column 337->324 AND adds a trailing filler so 324+30 exactly matches the scrollbar-reduced 354px width (a filler alone was proved insufficient by mutation — still clamped to 17px); Buffs' Coverage label moved from the group's anchored bottom edge (a 55px hole) to sit right under the Buff button row. Buffs' "136px gap" turned out geometrically unclosable at the 984 default without breaking the D-6 floor-safety invariant (Extra Buff Spells never moves, Blacklisted Buff Families anchors to the growing right edge, a 128px swing) — discovered by writing x=397 first and watching the existing whole-tree overlap pin catch it; the strip is now centered at the panel's own tested FLOOR instead (x=333, ~1px from where fix round B item 2 already had it), with the residual 984-default asymmetry accepted as the same "dead margin, polish later" trade already applied to six other tabs. Every new/changed pin shown to fail against a targeted mutation first (temporarily re-adding removed markup, or reverting a property to its pre-fix form, then restoring). MossTank suite 722 (round F start, matching round E's close) -> 722 (item 1, existing pins updated in place, no new Fact) -> 723 (item 2, +1) -> 724 (item 3, +1) -> 724 (item 4, one Fact replaced/renamed not added) -> 724 (item 5, the 23->24 column-count update lives in an EXISTING Fact — item 5's own new pin is App-side) -> 726 (item 6, +2: LootEditorHasNoLeftoverBackButton, BuffsExtrasStripIsCenteredAtThePanelsFloorAndCoverageSitsUnderTheButtonRow). App markup/plugin filter 258 (round F start — already ahead of the other worktree's last-recorded 254 by the time this branch merged the sync branch) -> 258 (items 1-4, no App-side pin) -> 260 (item 5's two-width Theory) -> 262 (item 6, +2: RouteShapedGrid_CountColumnStaysThirtyPxWhenTheListScrolls, BuffsExtrasStripStaysCenteredAndNonOverlappingAtTheFloor). Full solution builds clean in Release throughout. Deviations carried forward, none new this round. Next: one combined final re-check, merge, gate build.

View file

@ -107,7 +107,7 @@ 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 anchor` |
| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color anchor` |
| `slider` | Horizontal scalar | `x y w h value onchange anchor` |
| `slider` | Horizontal scalar | `x y w h value onchange min max style 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 selectionband anchor` + either the single-column `items colors icons iconkind`, or one-to-many `<column>` children (see "Columns" below) — never both |
@ -138,6 +138,14 @@ at all. `style="retail"` keeps the sprite popup rows (gradient panel,
checkmark-bearing row art) exactly as before, unchanged — only the
scrollbar chrome is shared between the two styles.
`slider style` is the same `plain`/`retail` grammar (fix round B item 11,
2026-09-07): `plain` (the default) draws a flat dark track, a 1px border,
and a small flat nub — no DAT scrollbar art, no `SpriteResolve` dependency
at all — via `UiScrollbar.RetailArt=false`'s `DrawPlainScalar`; `style=
"retail"` keeps `RetailScrollbarChrome.ApplyHorizontal`'s sprite track/thumb
exactly as before. Any other value throws `FormatException` at `Build`,
same rule as `menu style`.
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
name), `visible` (literal `true`/`false` or a bound `bool` property),
`enabled` (same rule), `tooltip` (a literal string or `{Binding}` shown
@ -584,6 +592,56 @@ single-text-column widget — every existing panel (including every current
MossTank tab) keeps working unchanged; `<column>` is additive, not a
migration.
## Slider range and scrollable menus (Campaign VT slice 7)
Two small `<slider>`/`<menu>` attributes, both closing gaps identified in
`docs/research/vtank-kb/08-ui-views.md` §3 while porting VTank's own Vitals
and Profiles tabs.
### `<slider min max>`
VVS's `HudHSlider`/`LinearPositionControl` expose an arbitrary `Min`/`Max`
range (VTank's own nine Vitals sliders are `minimum="0" maximum="100"`).
acdream's `<slider>` always bound a fixed 0.01.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.01.0 (drag math, click-to-jump, mouse-wheel are all unchanged):
```xml
<slider x="96" y="0" w="144" h="16" min="0" max="100"
value="{HealPercent}" onchange="{SetHealPercent}"/>
```
`HealPercent`/`SetHealPercent` read and write a plain `0..100` value — no
`/100f` scaling shim in the plugin's own ViewModel. Omitting both attributes
(every `<slider>` written before this slice) keeps the exact historical
0.01.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.
### `<menu>` is always a single scrolling column
VVS's `HudCombo` popup is always exactly one scrolling column, at most 10
rows visible before a scrollbar appears (`HudCombo.cs:35,102-146`). An
earlier revision of this slice gated that behind an opt-in `scroll="true"`
attribute; the owner's live-client report on 2026-09-07 ("scrollable
dropdowns... use the SAME scrollbar/window assets as we do in for example
chat or inventory window") made the scrolling, chrome-matching popup the
one and only shape a plugin `<menu>` draws — `MarkupDocument` now sets
`UiMenu.Scrollable = true` and applies `RetailScrollbarChrome` for every
`<menu>` unconditionally, so a VTank `Choice` with many entries (the
27-option recall menu, a long named-profile list) always keeps VVS's
one-column look instead of ever fanning out sideways:
```xml
<menu x="188" y="64" w="120" h="22" items="{RouteProfileNames}"
selected="{SelectedRouteProfile}" onchange="{SelectRouteProfile}"
rows="7"/>
```
There is no `scroll` attribute to set or omit — every `<menu>`, old and
new, gets the scrolling popup.
## The plugin shelf (Slice A)
The shelf (`AcDream.App.UI.PluginSidePanel`) is the right-edge strip of

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 1,011 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 1,006 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 335 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 307 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Before After
Before After

View file

@ -101,14 +101,23 @@
LogicalName="AcDream.App.Rendering.Icons.acdream-client-256.png" />
</ItemGroup>
<!-- MossTank ships as a copy-only plugin, plus its panel markup:
MossTankPlugin resolves mosstank.xml relative to its own assembly, so the
two files must land in the same plugin directory. -->
MossTankPlugin resolves mosstank.xml and every mosstank-*.xml popup
panel relative to its own assembly, so all of them must land in the
same plugin directory. Fix round B item 13: globbed (mosstank*.xml)
instead of named one by one — a new popup used to need a matching
manual edit in both Copy targets below, the plugin csproj's own
CopyToOutputDirectory list, and the test project's Link list; a
missed one silently dropped a panel at mount instead of failing the
build. -->
<ItemGroup>
<ProjectReference Include="..\AcDream.Plugins.MossTank\AcDream.Plugins.MossTank.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<_MossTankPluginMarkup Include="$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank*.xml" />
</ItemGroup>
<Target
Name="CopyMossTankPluginToBuildOutput"
AfterTargets="Build"
@ -124,7 +133,7 @@
</MSBuild>
<MakeDir Directories="$(_MossTankDestDir)" />
<Copy
SourceFiles="@(_MossTankPluginBuildTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
SourceFiles="@(_MossTankPluginBuildTarget);@(_MossTankPluginMarkup)"
DestinationFolder="$(_MossTankDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile
@ -148,7 +157,7 @@
</MSBuild>
<MakeDir Directories="$(_MossTankPublishDestDir)" />
<Copy
SourceFiles="@(_MossTankPluginPublishTarget);$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/mosstank.xml"
SourceFiles="@(_MossTankPluginPublishTarget);@(_MossTankPluginMarkup)"
DestinationFolder="$(_MossTankPublishDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile

View file

@ -406,6 +406,38 @@ public static class MarkupDocument
+ $"Action<float> 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 <slider> historically only ever
// bound a fixed 0.0-1.0 value. Omitting both attributes keeps
// that exact identity range so every pre-existing <slider>
// (which never sets min/max) is byte-for-byte unaffected.
float sliderMin = FOr(el, "min", 0f);
float sliderMax = FOr(el, "max", 1f);
// Fix round B item 16: max<=min used to silently fall back to
// a range of 1 (via the old "== 0f" check) rather than being
// caught as an authoring error — a max<min case slipped
// through entirely, producing a slider whose drag direction
// is inverted from its declared range. Both are now a
// build-time author error, same rule as every other
// <slider>/<menu> attribute-format check in this file.
if (sliderMax <= sliderMin)
{
throw new FormatException(
$"<slider min=\"{sliderMin}\" max=\"{sliderMax}\"> must have max > min");
}
float sliderRange = sliderMax - sliderMin;
Func<float?> sliderValueSource = BindFloat(
(string?)el.Attribute("value"),
binding);
// Fix round B item 11: <slider style="..."> mirrors <menu
// style> — plain (default) draws a flat track/nub with no
// DAT art at all; style="retail" opts into the sprite chrome
// RetailScrollbarChrome.ApplyHorizontal applies below.
bool sliderRetailArt = ValidateArtStyle("slider", (string?)el.Attribute("style"));
var slider = new UiScrollbar
{
Left = F(el, "x"),
@ -414,12 +446,18 @@ public static class MarkupDocument
Height = F(el, "h"),
Horizontal = true,
SpriteResolve = resolve,
ScalarPositionSource = BindFloat(
(string?)el.Attribute("value"),
binding),
ScalarChanged = changed,
RetailArt = sliderRetailArt,
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);
if (sliderRetailArt)
RetailScrollbarChrome.ApplyHorizontal(slider);
ApplyCommon(slider, el, binding);
parent.AddChild(slider);
break;
@ -486,7 +524,7 @@ public static class MarkupDocument
Func<string?> menuSelected = BindString(
(string?)el.Attribute("selected"),
binding);
bool menuRetailButtonArt = ValidateMenuStyle((string?)el.Attribute("style"));
bool menuRetailButtonArt = ValidateArtStyle("menu", (string?)el.Attribute("style"));
var menu = new UiMenu
{
Left = F(el, "x"),
@ -499,6 +537,14 @@ public static class MarkupDocument
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
ColumnWidth = Math.Max(20f, F(el, "w")),
OpenUpward = B(el, "openupward", 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,
@ -687,21 +733,22 @@ public static class MarkupDocument
/// <summary>
/// Owner live-client report 2026-09-07 ("Those BIG gold/yellow buttons HAS
/// to go. That is not how vtank looks."): validates <c>&lt;menu
/// style="..."&gt;</c> and returns the <see cref="UiMenu.RetailButtonArt"/>
/// value it selects. Default (attribute absent, or explicit
/// <c>style="plain"</c>) is the flat VTank/Decal <c>HudCombo</c> box
/// (<c>false</c>) — retail's gold pushbutton art is now an explicit
/// <c>style="retail"</c> opt-in for a plugin panel that genuinely wants
/// it. Any other value is a Build-time author error, same rule as
/// <see cref="ValidateIconKind"/>.
/// to go. That is not how vtank looks."): validates <c>style="..."</c> on
/// <c>&lt;menu&gt;</c> (returning <see cref="UiMenu.RetailButtonArt"/>'s
/// value) and, since fix round B item 11, <c>&lt;slider&gt;</c> (returning
/// <see cref="UiScrollbar.RetailArt"/>'s value) — the same grammar, the
/// same default. Default (attribute absent, or explicit
/// <c>style="plain"</c>) is the flat VTank/Decal look (<c>false</c>) —
/// retail's gold/sprite art is an explicit <c>style="retail"</c> opt-in
/// for a plugin panel that genuinely wants it. Any other value is a
/// Build-time author error, same rule as <see cref="ValidateIconKind"/>.
/// </summary>
private static bool ValidateMenuStyle(string? style) => style switch
private static bool ValidateArtStyle(string elementName, string? style) => style switch
{
null or "plain" => false,
"retail" => true,
var other => throw new FormatException(
$"<menu style=\"{other}\"> must be plain or retail"),
$"<{elementName} style=\"{other}\"> must be plain or retail"),
};
/// <summary>

View file

@ -425,6 +425,14 @@ public sealed class UiMarkupList : UiElement
context.DrawFill(0f, 0f, Width, Height, BackgroundColor);
context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
// Fix round B item 10 (owner/oracle: VVS's own HudList grids have NO
// row-selection highlight at all — Monsters/Meta/Route/Items and
// every other <list><column> grid). SelectedIndexSource above still
// drives scroll-into-view; the SelectedColor band draw uses the same
// SelectionBandEnabled gate as the legacy single-column path below
// and defaults to false, so plugin lists keep no band unless a
// caller opts in. Every per-cell onclick/onchange callback is
// unchanged — only the visual band is conditional.
int end = Math.Min(rowCount, _topRow + visibleRows);
for (int index = _topRow; index < end; index++)
{

View file

@ -45,6 +45,25 @@ public sealed class UiScrollbar : UiElement
public Func<float?>? ScalarPositionSource { get; set; }
public bool Horizontal { get; set; }
/// <summary>
/// Fix round B item 11: mirrors <see cref="UiMenu.RetailButtonArt"/> —
/// true (the default, preserving every existing retail scrollbar/slider
/// byte-for-byte) draws the sprite chrome below; false draws a plain
/// track/nub instead (<see cref="DrawPlainScalar"/>), with no
/// <see cref="SpriteResolve"/> dependency at all. Only plugin markup's
/// <c>&lt;slider&gt;</c> (MarkupDocument) ever sets this false by
/// default — every other caller of this widget (retail LayoutDesc
/// import, the chat opacity sliders, etc.) leaves it at the default
/// true and is completely unaffected.
/// </summary>
public bool RetailArt { get; set; } = true;
// ── Plain scalar chrome (RetailArt = false). Colors mirror UiMenu's own
// Plain* palette for a consistent "no DAT art" look across widgets. ──
public Vector4 PlainTrackColor { get; set; } = new(0f, 0f, 0f, 0.6f);
public Vector4 PlainBorderColor { get; set; } = new(0.46f, 0.37f, 0.16f, 1f);
public Vector4 PlainNubColor { get; set; } = new(0.72f, 0.62f, 0.34f, 1f);
/// <summary>True while a thumb drag is in progress (between a thumb-hit
/// <c>MouseDown</c>/drag-start and the matching <c>MouseUp</c>). OP5 review
/// fix S1, 2026-08-11: lets a consumer distinguish a per-tick drag edit
@ -274,6 +293,11 @@ public sealed class UiScrollbar : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
if (!IsPresentationVisible) return;
if (!RetailArt)
{
DrawPlainScalar(ctx);
return;
}
if (SpriteResolve is not { } resolve) return;
if (Horizontal)
{
@ -470,6 +494,40 @@ public sealed class UiScrollbar : UiElement
DrawSprite(ctx, resolve, ActiveThumbSprite, 0f, y, Width, thumbHeight);
}
/// <summary>
/// Fix round B item 11: <see cref="RetailArt"/> = false counterpart of
/// the horizontal/vertical scalar sprite paths above — a flat
/// <see cref="PlainTrackColor"/> fill, a 1px <see cref="PlainBorderColor"/>
/// outline, and a small flat <see cref="PlainNubColor"/> nub at the
/// current <see cref="ScalarPosition"/>. No <see cref="SpriteResolve"/>
/// dependency at all — a plain slider with no icon resolver wired up
/// still draws. Only handles the scalar shape plugin markup's
/// <c>&lt;slider&gt;</c> actually builds (Horizontal true or false,
/// <see cref="ScalarChanged"/> set); the paged <see cref="Model"/>-driven
/// scrollbar shape is unreached by <c>&lt;slider&gt;</c> and keeps its
/// existing sprite-only rendering regardless of this flag.
/// </summary>
private void DrawPlainScalar(UiRenderContext ctx)
{
ctx.DrawFill(0f, 0f, Width, Height, PlainTrackColor);
ctx.DrawRectOutline(0f, 0f, Width, Height, PlainBorderColor, 1f);
if (Horizontal)
{
float nubWidth = MathF.Min(6f, Width);
float travel = MathF.Max(0f, Width - nubWidth);
float x = travel * ScalarPosition;
ctx.DrawFill(x, 0f, nubWidth, Height, PlainNubColor);
}
else
{
float nubHeight = MathF.Min(6f, Height);
float travel = MathF.Max(0f, Height - nubHeight);
float y = travel * ScalarPosition;
ctx.DrawFill(0f, y, Width, nubHeight, PlainNubColor);
}
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)

View file

@ -11,6 +11,14 @@
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Plugins.MossTank.Tests" />
<!-- Fix round B item 15: AcDream.App.Tests constructs a real
MossTankPanel directly (MossTankMarkupBuildOverRealFilesTests) so
MarkupDocument.Build validates every mosstank*.xml's actual
attribute/binding grammar, not just reflection-visible property
names/types. A test-only exception to the plugin/host compile-time
boundary (AcDream.App itself never links this assembly — see
AcDream.App.csproj's ReferenceOutputAssembly=false copy target). -->
<InternalsVisibleTo Include="AcDream.App.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
@ -19,7 +27,13 @@
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Update="mosstank.xml">
<!-- Fix round B item 13: glob every mosstank*.xml plugin-panel file
instead of naming each one — a new popup (mosstank-metaeditor.xml,
item 5) used to need a matching manual edit here, in
AcDream.App.csproj's two Copy targets, and in the test project's
Link list; missing one silently dropped a panel at mount instead
of failing the build. -->
<None Update="mosstank*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<EmbeddedResource Include="VtankCraftRecipes.tsv" />

View file

@ -84,6 +84,30 @@ public sealed class BuffSettings
public int BuffWithUntrainedItemSkill { get; set; } = 80;
public int BuffWithUntrainedCreatureSkill { get; set; } = 80;
public int BuffWithUntrainedLifeSkill { get; set; } = 80;
/// <summary>
/// VTank's ExtraBuffSpells table (single-field ExemplarId list,
/// defaultsettings.usd:21-25) — named spell exemplars to cast "best
/// similar" beyond the school-driven picks above. Campaign VT slice 7
/// S7.4 adds the storage and the Buffs-tab UI (list + the
/// SelfBuffChoiceView-style picker) but does NOT wire this set into
/// <see cref="BuffPlan.Build"/>'s cast selection — resolving an
/// exemplar name to a real castable line/tier is genuine casting-
/// algorithm behavior, out of this UI-parity slice's scope. Tracked as
/// issue #491 ("shown and persisted, not consumed by BuffPlan.Build
/// until slice 4"), owned by a future Campaign VT behavior slice.
/// </summary>
public ISet<string> ExtraBuffSpellNames { get; } =
new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// VTank's AntiExtraBuffSpells table (defaultsettings.usd:1-6) — named
/// buff families to never cast even if the school-driven rules above
/// would otherwise want them. Same storage-only scope note as
/// <see cref="ExtraBuffSpellNames"/> — issue #491.
/// </summary>
public ISet<string> BlacklistedBuffFamilyNames { get; } =
new HashSet<string>(StringComparer.Ordinal);
}
/// <summary>

View file

@ -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<string, (string NavType, List<RouteWaypoint> Nodes, uint FollowTargetId, string FollowTargetName)> navs,

View file

@ -172,6 +172,18 @@ internal sealed partial class MossTankPanel
WriteVtank($"Portal space toggle count: {_commandPortalCount}");
return;
case "refresh":
// Fix round C item D2: EnsureDefaultMonsterRule() mutates
// _combatSettings.Rules (adds the DEFAULT row when the list
// is empty) but this handler never re-materialized the
// Monsters grid's cached columns afterward — every OTHER
// _combatSettings.Rules mutator (AddMonsterRuleCore,
// DeleteMonsterRuleAtCore, MoveMonsterRuleAtCore,
// UpdateMonsterActionsAt) already calls RefreshMonsterEditor
// right after mutating, as do the constructor and
// ResetProfileConsumers's own EnsureDefaultMonsterRule call
// — this was the one remaining gap found by auditing every
// _combatSettings.Rules mutation site in the plugin.
EnsureDefaultMonsterRule();
RefreshMonsterEditor();
RefreshItemEditors();
RefreshLootEditor();

File diff suppressed because it is too large Load diff

View file

@ -52,6 +52,79 @@ public sealed class MossTankPlugin : IAcDreamPlugin
Path.Combine(directory, "mosstank.xml"),
_panel);
// Fix round A (2026-09-07): Advanced Options / Loot Editor were
// in-panel groups sharing the main window (forcing it to 350px
// tall with dead space under the nine normal tabs); they are now
// their own plugin panels, matching VTank's own separate popup
// windows (docs/research/vtank-kb/08-ui-views.md §1's secondary-
// view table). ShowInSidePanel=false — VTank's popups have no
// independent shelf/taskbar entry, they only exist while opened
// from the Options/Profiles tab checkbox
// (AdvancedOptionsVisible/LootEditorVisible). Each popup's own
// <panel visible="{...}"> binding is the SAME flag the checkbox
// flips and the popup's own "Back" button clears, so closing the
// popup unchecks the box for free — no new IUiRegistry show/hide
// API needed.
//
// StartVisible=true (NOT false) here — confirmed against a live
// build after StartVisible=false left both popups permanently
// invisible despite AdvancedOptionsVisible/LootEditorVisible
// reading true. RetailUiRuntime.MountPlugins wraps every plugin
// window's markup-level visible={Prop} ("availability") in a
// PluginWindowVisibilityController that ANDs it with a SEPARATE
// "requested visible" axis seeded from StartVisible and only ever
// flipped back to true by OnShown() (the shelf's un-minimize
// click). A ShowInSidePanel=false window has no shelf entry to
// click, so StartVisible=false would leave "requested visible"
// permanently false and availability's AND can never rescue it.
// StartVisible=true keeps "requested visible" true from the start
// (and PluginWindowVisibilityController.OnHidden only ever latches
// it back to false on a genuine minimize — it explicitly no-ops
// when hidden because availability itself went false, which is
// exactly what closing via "Back" does), leaving availability
// (AdvancedOptionsVisible/LootEditorVisible) as the sole effective
// gate, same as the main panel's own always-true
// ShowInSidePanel=true default.
_host.Ui.AddPanel(
new PluginPanelDescriptor("advanced-options", "MossTank Advanced Options")
{
StartVisible = true,
ShowInSidePanel = false,
},
Path.Combine(directory, "mosstank-advanced.xml"),
_panel);
_host.Ui.AddPanel(
new PluginPanelDescriptor("loot-editor", "MossTank Loot Editor")
{
StartVisible = true,
ShowInSidePanel = false,
},
Path.Combine(directory, "mosstank-loot-editor.xml"),
_panel);
// Campaign VT S7.4: the Buffs tab's "Add..." picker (VTank's own
// SelfBuffChoiceView), same StartVisible=true/ShowInSidePanel=false
// popup pattern as the two panels above — see their own comments
// for why StartVisible must be true.
_host.Ui.AddPanel(
new PluginPanelDescriptor("buff-picker", "MossTank Add Buff")
{
StartVisible = true,
ShowInSidePanel = false,
},
Path.Combine(directory, "mosstank-buffpicker.xml"),
_panel);
// Fix round B item 5: the Meta tab's inline rule editor moved to its
// own popup, same StartVisible=true/ShowInSidePanel=false pattern
// as the three panels above.
_host.Ui.AddPanel(
new PluginPanelDescriptor("meta-editor", "MossTank Meta Rule Editor")
{
StartVisible = true,
ShowInSidePanel = false,
},
Path.Combine(directory, "mosstank-metaeditor.xml"),
_panel);
_commandRegistration = _host.Commands.Register(
"vt",
_panel.ExecuteVtankCommand);

View file

@ -762,6 +762,17 @@ internal sealed class MossTankProfileStore
public bool BuffRegeneration { get; set; } = true;
public bool BuffOther { get; set; }
public bool BuffTrainedSkillsOnly { get; set; } = true;
// Fix round B item 14: these two Buffs-tab sets (Campaign VT S7.4)
// had no VTank Settings-table row of their own (VTank's
// ExtraBuffSpells/AntiExtraBuffSpells are separate tables, not
// scalar Settings rows) and were never captured by this side-car
// either — a restart or profile switch silently dropped them.
// BuffPlan.Build still does not READ either set when choosing what
// to cast (a real, separately tracked wiring gap — see
// BuffSettings' own doc comments); this only fixes the storage/
// display honesty.
public string[] BuffExtraSpellNames { get; set; } = [];
public string[] BuffBlacklistedFamilyNames { get; set; } = [];
public bool VitalsEnabled { get; set; } = true;
public double InventoryScanIntervalSeconds { get; set; } = 0.25d;
// InventoryEnableLooting was deleted here (round 3, item 1): it
@ -802,6 +813,8 @@ internal sealed class MossTankProfileStore
BuffRegeneration = settings.Buffs.BuffRegeneration,
BuffOther = settings.Buffs.BuffOther,
BuffTrainedSkillsOnly = settings.Buffs.BuffTrainedSkillsOnly,
BuffExtraSpellNames = Sorted(settings.Buffs.ExtraBuffSpellNames),
BuffBlacklistedFamilyNames = Sorted(settings.Buffs.BlacklistedBuffFamilyNames),
VitalsEnabled = settings.Vitals.Enabled,
InventoryScanIntervalSeconds = settings.Inventory.ScanIntervalSeconds,
InventoryLootClassifierId = settings.Inventory.Loot.ExternalClassifierId,
@ -870,6 +883,12 @@ internal sealed class MossTankProfileStore
settings.Buffs.BuffRegeneration = BuffRegeneration;
settings.Buffs.BuffOther = BuffOther;
settings.Buffs.BuffTrainedSkillsOnly = BuffTrainedSkillsOnly;
// Fix round B item 14: clear-then-repopulate (Replace, same
// helper CombatItemNames/ConsumableNames/NoBuffItemNames use) —
// a profile switch must not carry a stale entry over from
// whichever profile was loaded before.
Replace(settings.Buffs.ExtraBuffSpellNames, BuffExtraSpellNames);
Replace(settings.Buffs.BlacklistedBuffFamilyNames, BuffBlacklistedFamilyNames);
settings.Vitals.Enabled = VitalsEnabled;

View file

@ -574,7 +574,19 @@ internal sealed class MossTankRouteProfileStore
public bool LegacyReferenceValid { get; set; } = true;
public string Text { get; set; } = string.Empty;
public int DurationMilliseconds { get; set; } = 5000;
public RouteRecallKind Recall { get; set; }
// Round E item D-1: this field used to be typed RouteRecallKind,
// which let System.Text.Json deserialize the pre-cutover JSON's
// bare integer straight into TODAY's enum. The pre-cutover enum
// was {Lifestone=0, Marketplace=1, PrimaryPortal=2,
// SecondaryPortal=3} — completely different kinds at the SAME
// ordinals as the round-D enum (PrimaryPortalRecall=0,
// SecondaryPortalRecall=1, LifestoneRecall=2, LifestoneSending=3,
// ...), so every legacy route silently remapped to the WRONG new
// recall and Enum.IsDefined never caught it — 0..3 are all still
// valid members of the new enum, just for different spells. Read
// as a bare ordinal here instead and translate it explicitly via
// MapLegacyRecall in ToWaypoint().
public int Recall { get; set; }
public uint RecallSpellId { get; set; }
public string RecallSpellName { get; set; } = string.Empty;
public float JumpHeadingDegrees { get; set; }
@ -582,36 +594,60 @@ internal sealed class MossTankRouteProfileStore
public int JumpChargeMilliseconds { get; set; } = 1000;
public RouteJumpDirection JumpDirection { get; set; }
public RouteWaypoint ToWaypoint() => new()
public RouteWaypoint ToWaypoint()
{
Type = Enum.IsDefined(Type) ? Type : RouteWaypointType.Point,
Position = new PluginNavigationPosition(
CellId, EastWest, NorthSouth, Elevation, HeadingDegrees, IsOutdoor),
ReferencePosition = new PluginNavigationPosition(
ReferenceCellId,
ReferenceEastWest,
ReferenceNorthSouth,
ReferenceElevation,
ReferenceHeadingDegrees,
ReferenceIsOutdoor),
ObjectId = ObjectId,
ObjectName = ObjectName ?? string.Empty,
LegacyObjectClass = LegacyObjectClass,
LegacyReferenceValid = LegacyReferenceValid,
Text = Text ?? string.Empty,
DurationMilliseconds = Math.Clamp(DurationMilliseconds, 0, 3_600_000),
Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone,
RecallSpellId = RecallSpellId,
RecallSpellName = RecallSpellName ?? string.Empty,
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) ? JumpHeadingDegrees : 0f,
JumpRun = JumpRun,
// Round 3 item 5: no load-time clamp here either — the real
// bi.a 2000 ms ceiling is retail's EXECUTION-time behavior
// (NavigationController.TickJump), not a storage-format limit;
// this legacy migration path preserves whatever the pre-cutover
// JSON authored, exactly like the .af load path now does.
JumpChargeMilliseconds = JumpChargeMilliseconds,
JumpDirection = Enum.IsDefined(JumpDirection) ? JumpDirection : RouteJumpDirection.Forward,
RouteRecallKind recall = MapLegacyRecall(Recall);
return new RouteWaypoint
{
Type = Enum.IsDefined(Type) ? Type : RouteWaypointType.Point,
Position = new PluginNavigationPosition(
CellId, EastWest, NorthSouth, Elevation, HeadingDegrees, IsOutdoor),
ReferencePosition = new PluginNavigationPosition(
ReferenceCellId,
ReferenceEastWest,
ReferenceNorthSouth,
ReferenceElevation,
ReferenceHeadingDegrees,
ReferenceIsOutdoor),
ObjectId = ObjectId,
ObjectName = ObjectName ?? string.Empty,
LegacyObjectClass = LegacyObjectClass,
LegacyReferenceValid = LegacyReferenceValid,
Text = Text ?? string.Empty,
DurationMilliseconds = Math.Clamp(DurationMilliseconds, 0, 3_600_000),
Recall = recall,
// The pre-cutover JSON schema predates RecallSpellId/
// RecallSpellName entirely (both are a round-D addition),
// so a legacy document never carries real values for
// them — derive both from the SAME mapped kind, the one
// table this migration path uses end-to-end.
RecallSpellId = RouteWaypoint.SpellIdForRecall(recall),
RecallSpellName = RouteWaypoint.RecallDisplayName(recall),
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) ? JumpHeadingDegrees : 0f,
JumpRun = JumpRun,
// Round 3 item 5: no load-time clamp here either — the real
// bi.a 2000 ms ceiling is retail's EXECUTION-time behavior
// (NavigationController.TickJump), not a storage-format limit;
// this legacy migration path preserves whatever the pre-cutover
// JSON authored, exactly like the .af load path now does.
JumpChargeMilliseconds = JumpChargeMilliseconds,
JumpDirection = Enum.IsDefined(JumpDirection) ? JumpDirection : RouteJumpDirection.Forward,
};
}
// Round E item D-1: the pre-cutover RouteRecallKind ordinals,
// mapped to today's real spell-based kinds. Anything outside that
// 4-value range is impossible (the old enum only ever had 4
// members) so it falls back to PrimaryPortalRecall — the same
// "still a valid, harmless recall kind" sentinel role the old
// Enum.IsDefined guard played before this fix.
private static RouteRecallKind MapLegacyRecall(int legacyOrdinal) => legacyOrdinal switch
{
0 => RouteRecallKind.LifestoneRecall, // old Lifestone
1 => RouteRecallKind.Marketplace, // old Marketplace
2 => RouteRecallKind.PrimaryPortalRecall, // old PrimaryPortal
3 => RouteRecallKind.SecondaryPortalRecall, // old SecondaryPortal
_ => RouteRecallKind.PrimaryPortalRecall,
};
}
}

View file

@ -25,12 +25,65 @@ internal enum RouteWaypointType
Jump = 9,
}
/// <summary>
/// VTank's own <c>cmbRecallType</c> combo (Route tab, docs/research/
/// vtank-kb/08-ui-views.md §1) — 26 real castable recall spells (metaf's
/// own <c>NRecall</c> table, <c>metaf_monolithic.py:10981-11008</c> in
/// `C:\Users\erikn\source\repos\metas`) plus VTank's own
/// <c>Marketplace Recall</c>, which has no spell and is issued as the
/// <c>/marketplace</c> slash command instead — appended last since it is
/// not one of the 26 named spells. Declared in VTank's own combo order so
/// <c>Enum.GetNames</c>/<c>GetValues</c> (which sort by underlying VALUE,
/// not declaration order) reproduce that order directly: every member's
/// value is a plain sequential index (0..26), never a spell id — spell
/// ids live in the separate <see cref="RouteWaypoint.SpellIdForRecall(RouteRecallKind)"/>
/// lookup so this enum's own values stay index-shaped and sortable.
///
/// Round D item 3 (owner: "Under Route, Add recall is missing a lot of
/// recalls that VTank has and they do not work in routes yet.") replaces
/// the old 4-member enum (Lifestone, Marketplace, PrimaryPortal,
/// SecondaryPortal). Deviation: the old <c>Lifestone</c> member (which
/// issued the <c>/lifestone</c> slash command) is DROPPED rather than kept
/// alongside the new spell-based <see cref="LifestoneRecall"/> — VTank's
/// real combo has ONE "Lifestone Recall" entry and it is a real castable
/// spell (id 1635, confirmed by metaf's own NRecall table), so keeping
/// both would show two menu rows reading "Lifestone Recall" with
/// different behavior. <see cref="Marketplace"/> is the one member kept
/// as a slash command, per the explicit "keep ours" instruction (VTank's
/// own Marketplace Recall has no spell to cast either). PrimaryPortal and
/// SecondaryPortal are renamed to <see cref="PrimaryPortalRecall"/> and
/// <see cref="SecondaryPortalRecall"/> and now use their real spell ids
/// (48, 2647) instead of the old runtime KnownSelfBuffs name lookup.
/// </summary>
internal enum RouteRecallKind
{
Lifestone,
PrimaryPortalRecall,
SecondaryPortalRecall,
LifestoneRecall,
LifestoneSending,
PortalRecall,
RecallAphusLassel,
RecallTheSanctuary,
RecallToTheSingularityCaul,
GlendenWoodRecall,
AerlintheRecall,
MountLetheRecall,
UlgrimsRecall,
BurRecall,
ParadoxTouchedOlthoiInfestedAreaRecall,
CallOfTheMhoireForge,
ColosseumRecall,
FacilityHubRecall,
GearKnightInvasionAreaCampRecall,
LostCityOfNeftetRecall,
ReturnToTheKeep,
RynthidRecall,
ViridianRiseRecall,
ViridianRiseGreatTreeRecall,
CelestialHandStrongholdRecall,
RadiantBloodStrongholdRecall,
EldrytchWebStrongholdRecall,
Marketplace,
PrimaryPortal,
SecondaryPortal,
}
internal enum RouteJumpDirection
@ -40,6 +93,21 @@ internal enum RouteJumpDirection
StrafeRight,
}
/// <summary>
/// VTank's own cmbNavInsertMode (docs/research/vtank-kb/08-ui-views.md §1
/// "Tab: Route": "options: Add to End / Insert Above / Insert Below").
/// Replaces the old two-state _routeAddToEnd bool (fix round B item 8) —
/// InsertAbove/InsertBelow are both relative to the currently selected
/// waypoint, distinguishing "before" from "after" rather than collapsing
/// both non-append cases into one.
/// </summary>
internal enum RouteInsertMode
{
AddToEnd,
InsertAbove,
InsertBelow,
}
internal sealed class RouteWaypoint
{
public RouteWaypointType Type { get; set; }
@ -137,15 +205,140 @@ internal sealed class RouteWaypoint
+ ")";
}
/// <summary>VTank's own <c>cmbRecallType</c> label text (docs/research/vtank-kb/08-ui-views.md §1) for each <see cref="RouteRecallKind"/>.</summary>
internal static string RecallDisplayName(RouteRecallKind value) => value switch
{
RouteRecallKind.Lifestone => "Lifestone Recall",
RouteRecallKind.PrimaryPortalRecall => "Primary Portal Recall",
RouteRecallKind.SecondaryPortalRecall => "Secondary Portal Recall",
RouteRecallKind.LifestoneRecall => "Lifestone Recall",
RouteRecallKind.LifestoneSending => "Lifestone Sending",
RouteRecallKind.PortalRecall => "Portal Recall",
RouteRecallKind.RecallAphusLassel => "Recall Aphus Lassel",
RouteRecallKind.RecallTheSanctuary => "Recall the Sanctuary",
RouteRecallKind.RecallToTheSingularityCaul => "Recall to the Singularity Caul",
RouteRecallKind.GlendenWoodRecall => "Glenden Wood Recall",
RouteRecallKind.AerlintheRecall => "Aerlinthe Recall",
RouteRecallKind.MountLetheRecall => "Mount Lethe Recall",
RouteRecallKind.UlgrimsRecall => "Ulgrim's Recall",
RouteRecallKind.BurRecall => "Bur Recall",
RouteRecallKind.ParadoxTouchedOlthoiInfestedAreaRecall =>
"Paradox-touched Olthoi Infested Area Recall",
RouteRecallKind.CallOfTheMhoireForge => "Call of the Mhoire Forge",
RouteRecallKind.ColosseumRecall => "Colosseum Recall",
RouteRecallKind.FacilityHubRecall => "Facility Hub Recall",
RouteRecallKind.GearKnightInvasionAreaCampRecall =>
"Gear Knight Invasion Area Camp Recall",
RouteRecallKind.LostCityOfNeftetRecall => "Lost City of Neftet Recall",
RouteRecallKind.ReturnToTheKeep => "Return to the Keep",
RouteRecallKind.RynthidRecall => "Rynthid Recall",
RouteRecallKind.ViridianRiseRecall => "Viridian Rise Recall",
RouteRecallKind.ViridianRiseGreatTreeRecall => "Viridian Rise Great Tree Recall",
RouteRecallKind.CelestialHandStrongholdRecall => "Celestial Hand Stronghold Recall",
RouteRecallKind.RadiantBloodStrongholdRecall => "Radiant Blood Stronghold Recall",
RouteRecallKind.EldrytchWebStrongholdRecall => "Eldrytch Web Stronghold Recall",
RouteRecallKind.Marketplace => "Marketplace Recall",
RouteRecallKind.PrimaryPortal => "Primary Portal Recall",
RouteRecallKind.SecondaryPortal => "Secondary Portal Recall",
_ => value.ToString(),
};
/// <summary>
/// VTank's own <c>cmbRecallType</c> combo CAPTION (docs/research/
/// vtank-kb/08-ui-views.md §1's Route table, transcribed from
/// <c>refs/vtank/uTank2.ViewXML.mainView.xml</c>'s 27-string Choice
/// list) for each <see cref="RouteRecallKind"/> — used ONLY by the
/// combo's own display/parse round-trip (MossTankPanel.RouteRecallNames/
/// SelectedRouteRecall/SelectRouteRecall); the waypoint text, the
/// Route grid row, and the .af file keep showing <see
/// cref="RecallDisplayName"/>'s full name via <see cref="RecallLabel"/>,
/// untouched by this method (round F item 4 — resemblance re-check:
/// "the recall combo's full spell names overflow the 120 px box...
/// VTank's cmbRecallType uses terse captions").
///
/// The UI file's own 27 caption strings carry a genuine VTank
/// authoring duplicate — "Fac. Hub" at position 17 and "FacHub"
/// again at position 21 (KB's own noted "authoring duplicate") —
/// which pushes every later caption one slot further than the 26
/// real (unique) castable recalls it captions. Removing that
/// duplicate restores an exact 1:1, in-order match onto
/// <see cref="RouteRecallKind"/>'s 26 real spells: "Candeth" for
/// Return to the Keep (Candeth Keep IS the keep that spell returns
/// to), "Graveyard" for Call of the Mhoire Forge, and the three
/// "Soc. *" captions for the Society stronghold recalls.
/// <see cref="RouteRecallKind.Marketplace"/> was never a
/// cmbRecallType option at all (it is VTank's one slash-command-only
/// recall, per RecallDisplayName's own doc history) — it keeps its
/// own literal caption here since MossTank's combo still lists it.
/// </summary>
internal static string RecallShortCaption(RouteRecallKind value) => value switch
{
RouteRecallKind.PrimaryPortalRecall => "Primary",
RouteRecallKind.SecondaryPortalRecall => "Secondary",
RouteRecallKind.LifestoneRecall => "LS",
RouteRecallKind.LifestoneSending => "LS Sending",
RouteRecallKind.PortalRecall => "Portal",
RouteRecallKind.RecallAphusLassel => "Aphus",
RouteRecallKind.RecallTheSanctuary => "Sanctuary",
RouteRecallKind.RecallToTheSingularityCaul => "Caul",
RouteRecallKind.GlendenWoodRecall => "GW",
RouteRecallKind.AerlintheRecall => "Aerlinthe",
RouteRecallKind.MountLetheRecall => "Mt. Lethe",
RouteRecallKind.UlgrimsRecall => "Ulgrim's",
RouteRecallKind.BurRecall => "Bur",
RouteRecallKind.ParadoxTouchedOlthoiInfestedAreaRecall => "PtOIA",
RouteRecallKind.CallOfTheMhoireForge => "Graveyard",
RouteRecallKind.ColosseumRecall => "Colosseum",
RouteRecallKind.FacilityHubRecall => "Fac. Hub",
RouteRecallKind.GearKnightInvasionAreaCampRecall => "Gear K. Camp",
RouteRecallKind.LostCityOfNeftetRecall => "Neftet",
RouteRecallKind.ReturnToTheKeep => "Candeth",
RouteRecallKind.RynthidRecall => "Rynthid",
RouteRecallKind.ViridianRiseRecall => "VR Rocks",
RouteRecallKind.ViridianRiseGreatTreeRecall => "VR Tree",
RouteRecallKind.CelestialHandStrongholdRecall => "Soc. CH",
RouteRecallKind.RadiantBloodStrongholdRecall => "Soc. RB",
RouteRecallKind.EldrytchWebStrongholdRecall => "Soc. EW",
RouteRecallKind.Marketplace => "Marketplace",
_ => value.ToString(),
};
/// <summary>
/// The real retail spell id VTank casts for each recall (metaf's own
/// <c>NRecall</c> table). <see cref="RouteRecallKind.Marketplace"/> has
/// no spell — it returns 0, the same "no spell, use the slash command"
/// sentinel <see cref="RouteWaypoint.RecallSpellId"/> and
/// <c>NavigationController.SubmitRecall</c> already treat as
/// "unset/none" everywhere else in this file.
/// </summary>
internal static uint SpellIdForRecall(RouteRecallKind value) => value switch
{
RouteRecallKind.PrimaryPortalRecall => 48u,
RouteRecallKind.SecondaryPortalRecall => 2647u,
RouteRecallKind.LifestoneRecall => 1635u,
RouteRecallKind.LifestoneSending => 1636u,
RouteRecallKind.PortalRecall => 2645u,
RouteRecallKind.RecallAphusLassel => 2931u,
RouteRecallKind.RecallTheSanctuary => 2023u,
RouteRecallKind.RecallToTheSingularityCaul => 2943u,
RouteRecallKind.GlendenWoodRecall => 3865u,
RouteRecallKind.AerlintheRecall => 2041u,
RouteRecallKind.MountLetheRecall => 2813u,
RouteRecallKind.UlgrimsRecall => 2941u,
RouteRecallKind.BurRecall => 4084u,
RouteRecallKind.ParadoxTouchedOlthoiInfestedAreaRecall => 4198u,
RouteRecallKind.CallOfTheMhoireForge => 4128u,
RouteRecallKind.ColosseumRecall => 4213u,
RouteRecallKind.FacilityHubRecall => 5175u,
RouteRecallKind.GearKnightInvasionAreaCampRecall => 5330u,
RouteRecallKind.LostCityOfNeftetRecall => 5541u,
RouteRecallKind.ReturnToTheKeep => 4214u,
RouteRecallKind.RynthidRecall => 6150u,
RouteRecallKind.ViridianRiseRecall => 6321u,
RouteRecallKind.ViridianRiseGreatTreeRecall => 6322u,
RouteRecallKind.CelestialHandStrongholdRecall => 6325u,
RouteRecallKind.RadiantBloodStrongholdRecall => 6327u,
RouteRecallKind.EldrytchWebStrongholdRecall => 6326u,
_ => 0u,
};
private static string JumpDirectionDisplayName(RouteJumpDirection value) =>
value switch
{
@ -876,6 +1069,31 @@ internal sealed class NavigationController
RouteWaypoint waypoint,
in PluginNavigationSnapshot navigation)
{
// D-4 (round E architecture re-check): an .af "rcl" node whose
// spell name did not resolve against the character's known spells
// (MetafSerializer.ReadNavNode's "rcl" case, MetafSerializer.cs:
// 883-885) leaves RecallSpellId at 0 and Recall at its default
// ordinal (RouteRecallKind.PrimaryPortalRecall) — it never touches
// the enum. Every path that DOES intend a real cast
// (AddRouteRecallCore, the binary .nav loader, the D-1 legacy-
// JSON-route migration) always writes RecallSpellId directly, so
// no waypoint can legitimately reach this point with
// RecallSpellId == 0 unless it is Marketplace (which has no spell
// by design) or an unresolved import. Refuse the cast outright —
// SubmitRecall's old fallback (RouteWaypoint.SpellIdForRecall
// (waypoint.Recall)) would silently cast Primary Portal Recall
// (spell 48) for a waypoint that was never authored as one — and
// skip it the same way TickUse already refuses an unavailable
// object (waypoint.ObjectId == 0u, above).
if (waypoint.RecallSpellId == 0u && waypoint.Recall != RouteRecallKind.Marketplace)
{
_status = string.IsNullOrWhiteSpace(waypoint.RecallSpellName)
? "Recall waypoint has no spell; skipping."
: $"Recall spell '{waypoint.RecallSpellName}' not found; skipping waypoint.";
CompleteAction();
return true;
}
if (navigation.IsPortalSpace)
_sawPortalSpace = true;
if (_sawPortalSpace && !navigation.IsPortalSpace)
@ -912,30 +1130,29 @@ internal sealed class NavigationController
return true;
}
/// <summary>
/// Round D item 3: every recall except <see cref="RouteRecallKind.Marketplace"/>
/// now has a real spell id (either recorded directly on the waypoint —
/// AddRouteRecallCore populates it for anything added through the Route
/// tab's own combo, and the metaf/binary-.nav loaders populate it from
/// the file itself) so the common path is just "cast that spell,"
/// exactly like every other retail spell-cast action in this plugin.
/// Round E item D-4 removed the Recall-enum fallback that used to run
/// here for a zero RecallSpellId (it silently cast Primary Portal
/// Recall for any waypoint whose recorded name failed to resolve,
/// since <see cref="RouteRecallKind"/>'s default ordinal IS
/// <see cref="RouteRecallKind.PrimaryPortalRecall"/>) — TickRecall now
/// refuses and skips that case before this method is ever called, so
/// the only way to reach here with RecallSpellId == 0 is Marketplace,
/// which has no spell to cast (VTank's own combo issues it as a slash
/// command too).
/// </summary>
private bool SubmitRecall(RouteWaypoint waypoint)
{
if (waypoint.RecallSpellId != 0u)
return _host.Automation.Magic.Cast(waypoint.RecallSpellId);
RouteRecallKind recall = waypoint.Recall;
string? command = recall switch
{
RouteRecallKind.Lifestone => "/lifestone",
RouteRecallKind.Marketplace => "/marketplace",
_ => null,
};
if (command is not null)
return _host.Automation.Chat.Submit(command);
string needle = recall == RouteRecallKind.PrimaryPortal
? "Primary Portal Recall"
: "Secondary Portal Recall";
PluginSpellInfo? spell = _host.Automation.Spells.KnownSelfBuffs
.FirstOrDefault(value => value.Name.Equals(
needle,
StringComparison.OrdinalIgnoreCase));
return spell is { SpellId: not 0u } found
&& _host.Automation.Magic.Cast(found.SpellId);
return _host.Automation.Chat.Submit("/marketplace");
}
private bool TickJump(

View file

@ -22,6 +22,12 @@ internal static class VtankDefaultSettingsDatabase
private const string ResourceSuffix = ".VtankDefaultSettings.usd";
private static readonly Lazy<string> RawText = new(LoadText);
private static readonly Lazy<RechargeHandlerRow[]> DefaultRows = new(LoadDefaultRows);
private static readonly Lazy<IReadOnlyDictionary<string, int>> CategoryBitmasksByName =
new(LoadCategoryBitmasks);
private static readonly Lazy<IReadOnlyDictionary<string, string>> DescriptionsByName =
new(LoadDescriptions);
private static readonly Lazy<IReadOnlyDictionary<string, IReadOnlyList<VtankEnumValue>>>
EnumValuesByName = new(LoadEnumValues);
/// <summary>A fresh, independently mutable parse of the embedded document — every call gets its own object graph.</summary>
public static VtankDatabase Parse() => VtankDatabase.Parse(RawText.Value);
@ -29,6 +35,111 @@ internal static class VtankDefaultSettingsDatabase
/// <summary>VTank's own shipped <c>RechargeHandlerSet</c> rows (26, in file order).</summary>
public static IReadOnlyList<RechargeHandlerRow> DefaultRechargeHandlerRows => DefaultRows.Value;
/// <summary>
/// VTank's own <c>SettingsCategories</c> table (2 columns, 136 rows:
/// <c>Setting</c> name, <c>Categories</c> bitmask) — the data
/// <c>lFilterList</c> (docs/research/vtank-kb/08-ui-views.md §1's
/// AdvancedOptionsView table) filters <c>lOptionList</c> by. This
/// worktree has no access to VTank's own category NAME strings (no
/// <c>refs/vtank/</c> checkout here — see fix round B item 9's own
/// binding-site comment); only the raw per-setting bitmask is real
/// data we can port from the embedded .usd.
/// </summary>
public static IReadOnlyDictionary<string, int> SettingCategoryBitmasks => CategoryBitmasksByName.Value;
/// <summary>
/// VTank's own <c>Settings</c> table <c>Description</c> column (93 of
/// 137 rows non-empty) — real retail help text, e.g. "DoHelp" =>
/// "If true, allies are healed/restamed/given mana. The fellowship
/// window must be open to help fellows." Powers fix round B item 9's
/// <c>txtInfo</c> readout.
/// </summary>
public static IReadOnlyDictionary<string, string> SettingDescriptions => DescriptionsByName.Value;
/// <summary>
/// VTank's own <c>SettingsEnumInfo</c> table (3 columns, 33 rows:
/// <c>Setting</c> name, integer <c>Value</c>, <c>EnumValue</c> label —
/// e.g. <c>UseArcs</c> = 1 "No", 2 "At Range", 3 "Yes"). Owner-driven
/// Advanced Options interaction model (2026-09-07, VTank's own
/// <c>db.cs:118-165</c> click behavior): clicking a <c>tEnum</c> row's
/// VALUE cell cycles to the NEXT row here (wrapping), and the value
/// column shows the label, not the raw stored integer. Rows are
/// returned in the file's own order, which is already ascending by
/// <c>Value</c> within each setting's group.
/// </summary>
public static IReadOnlyDictionary<string, IReadOnlyList<VtankEnumValue>> SettingEnumValues =>
EnumValuesByName.Value;
private static IReadOnlyDictionary<string, IReadOnlyList<VtankEnumValue>> LoadEnumValues()
{
VtankDatabase database = VtankDatabase.Parse(RawText.Value);
VtankTable? enumInfo = database.Find("SettingsEnumInfo");
var map = new Dictionary<string, List<VtankEnumValue>>(StringComparer.OrdinalIgnoreCase);
if (enumInfo is null)
return map.ToDictionary(
static pair => pair.Key,
static pair => (IReadOnlyList<VtankEnumValue>)pair.Value,
StringComparer.OrdinalIgnoreCase);
int nameColumn = enumInfo.ColumnIndex("Setting");
int valueColumn = enumInfo.ColumnIndex("Value");
int labelColumn = enumInfo.ColumnIndex("EnumValue");
if (nameColumn < 0 || valueColumn < 0 || labelColumn < 0)
return map.ToDictionary(
static pair => pair.Key,
static pair => (IReadOnlyList<VtankEnumValue>)pair.Value,
StringComparer.OrdinalIgnoreCase);
foreach (VtankRow row in enumInfo.Rows)
{
string name = row.Cells[nameColumn].AsString();
var entry = new VtankEnumValue(
row.Cells[valueColumn].AsInt(),
row.Cells[labelColumn].AsString());
if (!map.TryGetValue(name, out List<VtankEnumValue>? list))
map[name] = list = [];
list.Add(entry);
}
return map.ToDictionary(
static pair => pair.Key,
static pair => (IReadOnlyList<VtankEnumValue>)pair.Value,
StringComparer.OrdinalIgnoreCase);
}
private static IReadOnlyDictionary<string, string> LoadDescriptions()
{
VtankDatabase database = VtankDatabase.Parse(RawText.Value);
VtankTable? settings = database.Find("Settings");
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (settings is null)
return map;
int nameColumn = settings.ColumnIndex("Setting");
int descriptionColumn = settings.ColumnIndex("Description");
if (nameColumn < 0 || descriptionColumn < 0)
return map;
foreach (VtankRow row in settings.Rows)
{
string description = row.Cells[descriptionColumn].AsString();
if (!string.IsNullOrWhiteSpace(description))
map[row.Cells[nameColumn].AsString()] = description;
}
return map;
}
private static IReadOnlyDictionary<string, int> LoadCategoryBitmasks()
{
VtankDatabase database = VtankDatabase.Parse(RawText.Value);
VtankTable? categories = database.Find("SettingsCategories");
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
if (categories is null)
return map;
int nameColumn = categories.ColumnIndex("Setting");
int bitsColumn = categories.ColumnIndex("Categories");
if (nameColumn < 0 || bitsColumn < 0)
return map;
foreach (VtankRow row in categories.Rows)
map[row.Cells[nameColumn].AsString()] = row.Cells[bitsColumn].AsInt();
return map;
}
private static string LoadText()
{
Assembly assembly = typeof(VtankDefaultSettingsDatabase).Assembly;
@ -65,3 +176,6 @@ internal static class VtankDefaultSettingsDatabase
return [];
}
}
/// <summary>One row of VTank's <c>SettingsEnumInfo</c> table: an enum-typed setting's integer code and its display label.</summary>
internal readonly record struct VtankEnumValue(int Value, string Label);

View file

@ -389,4 +389,71 @@ internal static class VtankOptionCatalog
DeclaredTypes.TryGetValue(name, out VtankSettingValueType type)
? type
: VtankSettingValueType.Double;
/// <summary>
/// Every individual bit (power of two) actually used across
/// <see cref="VtankDefaultSettingsDatabase.SettingCategoryBitmasks"/>'s
/// values, ascending — the real filter groups fix round B item 9's
/// <c>lFilterList</c> checklist offers, since a row's own mask can be a
/// sum of several category bits (e.g. 12 = 4+8).
/// </summary>
internal static readonly int[] CategoryBits = VtankDefaultSettingsDatabase
.SettingCategoryBitmasks.Values
.SelectMany(DecomposeBits)
.Distinct()
.Order()
.ToArray();
/// <summary>
/// VTank's own <c>lFilterList</c> checklist NAMES (owner live report
/// 2026-09-07: the popup showed raw bitmask hex — "0x1", "0x2" — instead
/// of category names). This worktree has no <c>refs/vtank/</c> checkout
/// to read VTank's own name/enum string literal for each bit directly,
/// so each name is instead anchored to a real setting whose Category
/// column in docs/research/vtank-kb/01-settings-and-profiles.md §2
/// names EXACTLY ONE category (no <c>|</c> combination), then read back
/// THAT setting's own recorded bitmask from the embedded .usd
/// (<see cref="VtankDefaultSettingsDatabase.SettingCategoryBitmasks"/>)
/// — never a typed-in hex literal, so the mapping cannot silently drift
/// from what the shipped database actually encodes. Anchors used (KB §2
/// row, single-category setting, resulting bit): row 45
/// RandomHelperBuffs→Misc(1), row 8 Recharge-Norm-HitP→Recharge(2), row
/// 31 DefaultMeleeAttackHeight→MeleeCombat(4), row 5
/// SpellDiffExcessThreshold-Hunt→SpellCombat(8), row 18
/// AttackDistance→Ranges(16), row 2 EnableNav→Navigation(32), row 3
/// EnableBuffing→Buffing(64), row 7
/// ArrowheadFletchDiffExcessThreshold→Crafting(128), row 1
/// EnableLooting→Looting(256) — all 9 of <see cref="CategoryBits"/>.
/// </summary>
internal static readonly IReadOnlyDictionary<int, string> CategoryNamesByBit =
BuildCategoryNamesByBit();
private static IReadOnlyDictionary<int, string> BuildCategoryNamesByBit()
{
IReadOnlyDictionary<string, int> bitmasks = VtankDefaultSettingsDatabase.SettingCategoryBitmasks;
(string Setting, string Category)[] anchors =
[
("RandomHelperBuffs", "Misc"),
("Recharge-Norm-HitP", "Recharge"),
("DefaultMeleeAttackHeight", "MeleeCombat"),
("SpellDiffExcessThreshold-Hunt", "SpellCombat"),
("AttackDistance", "Ranges"),
("EnableNav", "Navigation"),
("EnableBuffing", "Buffing"),
("ArrowheadFletchDiffExcessThreshold", "Crafting"),
("EnableLooting", "Looting"),
];
var map = new Dictionary<int, string>();
foreach ((string setting, string category) in anchors)
if (bitmasks.TryGetValue(setting, out int bit))
map[bit] = category;
return map;
}
private static IEnumerable<int> DecomposeBits(int mask)
{
for (int bit = 1; bit != 0 && bit <= mask; bit <<= 1)
if ((mask & bit) != 0)
yield return bit;
}
}

View file

@ -0,0 +1,195 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Advanced Options popup — VTank's own AdvancedOptionsView is a separate
392x300 OS-level window (docs/research/vtank-kb/08-ui-views.md §1's
secondary-view table), opened/closed by the Options tab's own
"Advanced Options" checkbox (mosstank.xml, cShowAdvanced/
ToggleAdvancedOptionsVisible). Fix round A (2026-09-07) moved this OUT of
the main mosstank.xml panel (where it used to live as an in-panel group
sharing the main window, forcing the whole window to grow to 350px tall
with ~180px of dead space under the nine normal tabs) into its own
top-level plugin panel, registered as a SECOND AddPanel call from
MossTankPlugin.cs with ShowInSidePanel=false (VTank's popup has no
independent shelf/taskbar entry — it only exists while opened from the
checkbox). This panel's own visible="{AdvancedOptionsVisible}" binding is
the SAME flag the checkbox flips and this popup's own "Back" button clears
(HideAdvancedOptions => _advancedOptionsVisible = false) — closing the
popup unchecks the box for free, no new host show/hide API needed:
MarkupDocument.Build already re-evaluates a root <panel>'s VisibleSource
every frame (proven by the main panel's own visible="{WindowAvailable}").
VTank's own popup has no Apply/Back buttons at all (edits are
destructive-live in retail); acdream keeps its own explicit Apply/Back
pair since our draft-then-submit editing flow needs them.
Fix round A also dropped the in-content "Advanced Options — complete
VTank settings table" label: it duplicated the window's own OS-level
title bar (MarkupDocument.Build auto-renders the <panel title="..."> at
a fixed (8,4) — the same corner the in-content label used to occupy back
when this was an in-panel group with no title bar of its own), so the two
overlapped and read as garbled text on screen once this became a
top-level window (live-verified, screenshot before the fix showed
"AdvancedOptionsced Options — complete VTank settings table").
Fix round B item 8: below the retail key-value editor is a "MossTank
Extras" section — controls with no VTank-tab counterpart, moved here per
the owner's silhouette rule (VTank's own controls are never
shrunk/moved/reordered to make room for MossTank extras) rather than
crowding the Route tab: Checkpoint/Jump (AddRouteCheckpoint/
AddRouteJump), Remove (RemoveRouteWaypoint), Set Follow Target +
its status label, Follow Corners/Open Doors (navigation toggles with no
Route-tab VTank equivalent), Nav Priority (a real second copy of
Options' own "Boost Nav. Priority" — same NavigationPriorityEnabled/
ToggleNavigationPriority binding, kept here too rather than assuming the
user remembers Options has it), and the Follow/Nav Min Distance +/-
stepper (a real duplicate of Options' own editable
FollowNavMinimumValueText field — kept for symmetry with the other
extras, not deleted, since both write the same
NavigationSettings.MinimumDistanceMeters).
Fix round B item 9 reshuffled the retail portion to match
AdvancedOptionsView's own geometry (docs/research/vtank-kb/
08-ui-views.md §1's secondary-view table) instead of the single-column
name-only list item 8 left in place: lOptionList is now a real 2-column
grid (clOpt name + clVal live value, PITCH 180+7=187 / 62+7=69) sitting
beside lFilterList (268,4,120,180), a real category checklist
(check + text columns) that filters lOptionList by VTank's own
SettingsCategories bitmask (VtankDefaultSettingsDatabase — 136 rows
straight from the embedded .usd, 9 distinct bits). Round D item 1
replaced lFilterList's raw bit-value labels ("0x04" etc.) with VTank's
own category names (Misc, Recharge, MeleeCombat, SpellCombat, Ranges,
Navigation, Buffing, Crafting, Looting — VtankOptionCatalog.
CategoryNamesByBit). The description readout (AdvancedOptionDescription)
fills VTank's exact 384x80 txtInfo and surfaces VTank's own real
per-setting help text (Settings.Description, 93 of 137 rows non-empty,
e.g. DoHelp's own retail wording) prefixed with the option's name.
Round D item 2 (owner live report 2026-09-07, screenshots 2/3 —
relayed VTank click model, this worktree has no refs/vtank/ checkout to
read db.cs:118-165 directly): clicking a VALUE cell now dispatches by
the setting's declared type instead of always just selecting the row.
A tBool value flips in place; a tEnum value cycles to the next label
(VtankDefaultSettingsDatabase.SettingEnumValues, VTank's own
SettingsEnumInfo table); everything else (int/double/single/string)
selects the row and loads its current value into the field below for
typing — the SAME behavior every click used to have, now narrowed to
only the types that actually need an edit box. Apply/Back are REMOVED
entirely: retail's own AdvancedOptionsView has neither (VTank edits are
destructive-live), and acdream's own draft-then-submit flow no longer
needs a button once Enter (the field's own onsubmit) is the only path
a number/string value ever takes — the popup closes from its own
title bar or the Options tab's "Advanced Options" toggle, both already
wired independently of this content (see the top-of-file note on
AdvancedOptionsVisible/PluginWindowVisibilityController). Removing the
23px button row plus its 3px gap to the notice label reclaims 26px:
the notice label and every "MossTank Extras" control below it moved up
by that amount, and the panel's own height shrinks 476->450 to match
(438px of real content + 12px bottom padding, the same margin the
file used before this round).
Fix round B item 8's own "MossTank Extras" section (controls with no
VTank-tab counterpart, moved here per the owner's silhouette rule)
is otherwise unchanged: Checkpoint/Jump (AddRouteCheckpoint/
AddRouteJump), Remove (RemoveRouteWaypoint), Set Follow Target + its
status label, Follow Corners/Open Doors (navigation toggles with no
Route-tab VTank equivalent), Nav Priority (a real second copy of
Options' own "Boost Nav. Priority"), and the Follow/Nav Min Distance
+/- stepper (a real duplicate of Options' own editable
FollowNavMinimumValueText field). Bold text isn't representable in the
plain retail UI font (0x40000000 has no bold face) — the section
header uses the same bright caption color other headers use instead.
D-3 (round E architecture re-check): mosstank.xml's own top-of-file
comment already claimed this popup "gets its own resizable=\"true\" +
anchored lists" — this file never actually carried that; "resize" is a
vestigial axis-lock attribute that only means anything once
resizable="true" already arms the master switch (MarkupDocument.cs),
so resize="none" alone left the popup permanently fixed-size. Now
resizable="true" with minw/minh floored at the pre-fix authored size
(392x450, the same "never shrink below the tested layout" rule the
main panel's own minw/minh follow).
lOptionList grows WIDTH ONLY (anchor="left top right"), NOT height:
the value-edit field/description/notice/"MossTank Extras" section all
sit directly below it with no vertical slack (field starts at y=168,
4px under the list's own y=4+h=164) and none of them are anchored to
shift down in lockstep, so any HEIGHT growth here would walk the list
straight into the field below it — the main panel's own
Items/Consumables/Buffs/Route lists avoid exactly this by growing
height-only where nothing sits below, and by growing WIDTH-only here
instead, this popup gets the same "the growth direction with nothing
in its path" safety. lFilterList (the category checklist, sitting
immediately to the option list's right) is repositioned to track the
right edge via its own anchor="right" so the widening option list
never grows into it — the same "right-pinned sibling repositions"
pattern the main panel's Consumables/Buffs lists already use.
AdvancedOptionDescription is declared anchor="left top right" too, for
markup consistency with the option list above it, but this is a
documented NO-OP for a <label>: UiLabel.OnDraw (UiPanel.cs) always
overwrites Width/Height to the CURRENT measured text extent on every
single draw, immediately after the parent's own anchor pass runs (both
happen inside the same DrawSelfAndChildren frame) — so whatever a
label's anchor computes is discarded before it is ever visible.
Round F item 1 (owner's second live look, 2026-09-07: "The whole bottom
window of advanced options should not be there"): deleted the
AdvancedOptionNotice label ("Editing X.") and the ENTIRE "MossTank
Extras" section fix round B item 8/9 put below it. VTank's own
AdvancedOptionsView is list + category list + edit field + description
only (docs/research/vtank-kb/08-ui-views.md §1) — no notice line, no
extra controls — and every action the Extras section exposed already
has a real path that does not need this popup at all:
- Checkpoint/Jump -> `/mt addnavcheckpoint` / `/mt addnavjump`
(documented in the /vt help text; AddRouteCheckpointCore/
AddRouteJumpCore were never anything but thin wrappers the button
called).
- Remove -> click any row of the Route tab's own waypoint grid
(DeleteRouteWaypointAt) — RemoveRouteWaypointCore and
DeleteRouteWaypointAtCore are the same removal logic (clamp index,
RemoveAt, reset nav, refresh, save), so the button was a pure
duplicate of a control that already exists.
- Set Follow Target -> re-select "Follow" in the Route tab's own
nav-mode menu (SelectRouteMode unconditionally calls
CaptureFollowTarget whenever RouteMode.Target is (re-)selected) —
SetFollowTarget/CaptureFollowTarget were never anything but a
manual re-invocation of the same call.
- Follow Corners / Open Doors / Nav Priority / Follow-Nav min
distance -> real VtankOptionCatalog entries (FollowAroundCorners,
OpenDoors, NavPriorityBoost, NavCloseStopRange) already listed and
editable in THIS popup's own lOptionList/field above, or via
`/vt opt set <Name> <value>` — the toggles/stepper below were
exact duplicates of rows already present in the retail list.
This popup no longer owns any of that state; every field/setting it
duplicated is a plain property on MossTankPanel untouched by this
round (NavigationSettings.FollowAroundCorners/OpenDoors/Priority/
MinimumDistanceMeters, plus RemoveRouteWaypoint/SetFollowTarget/
AddRouteCheckpoint/AddRouteJump themselves) — nothing was deleted from
the plugin, only the second, redundant UI surface for it. Panel height
shrinks 450->300 to match VTank's real 392x300 AdvancedOptionsView
footprint (minh follows); minw stays 392. -->
<panel x="253" y="405" w="392" h="300" title="MossTank Advanced Options"
visible="{AdvancedOptionsVisible}" resizable="true" minw="392" minh="300">
<list x="4" y="4" w="256" h="160" rowheight="17" anchor="left top right"
selected="{SelectedAdvancedOptionIndex}" onchange="{SelectAdvancedOption}"
tooltip="Select one of Virindi Tank's 137 advanced options.">
<column type="text" width="187" items="{AdvancedOptionNames}" onclick="{SelectAdvancedOption}" />
<column type="text" width="69" items="{AdvancedOptionValueColumn}" onclick="{ClickAdvancedOptionValue}" />
</list>
<!-- Fix round B item 15's own build-over-real-files test caught a real
bug here: <list> markup requires a "selected" int binding even for a
pure checklist with no real selection concept — see
SelectedAdvancedOptionCategoryIndex's own doc comment. -->
<list x="268" y="4" w="120" h="180" rowheight="18" anchor="right"
selected="{SelectedAdvancedOptionCategoryIndex}"
tooltip="Uncheck a category to hide its settings from the list on the left.">
<column type="check" width="20" values="{AdvancedOptionCategoryEnabled}" onchange="{ToggleAdvancedOptionCategoryAt}" />
<column type="text" width="*" items="{AdvancedOptionCategoryNames}" onclick="{ToggleAdvancedOptionCategoryAt}" />
</list>
<field x="4" y="168" w="260" h="16" text="{AdvancedOptionValueDraft}"
onchange="{SetAdvancedOptionValueDraft}" onsubmit="{SubmitAdvancedOption}"
maxlength="160" clearonsubmit="false" background="#E6000000"
tooltip="Edit the selected advanced-option value and press Enter to apply." />
<label x="4" y="188" w="384" h="80" text="{AdvancedOptionDescription}" color="#FFE8DEC3"
anchor="left top right" />
</panel>

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Buff picker popup — VTank's own SelfBuffChoiceView (268x236, icon 8241,
docs/research/vtank-kb/08-ui-views.md §1's secondary-view table): a
search-filtered single-column exemplar list, opened from the Buffs tab's
two "Add..." buttons (cmdNewBuffSpell/cmdNewAntiBuffSpell — one picker
type, two destination lists, disambiguated at open time by which button
called ShowExtraBuffPicker/ShowBlacklistedBuffPicker). Same top-level
plugin-panel pattern fix round A established for mosstank-advanced.xml/
mosstank-loot-editor.xml: StartVisible=true, ShowInSidePanel=false, this
panel's own visible="{BuffPickerVisible}" is the SAME flag both "Add..."
buttons set and this popup's own "Cancel" button clears.
The KB's transcribed lblSearch/txtSearch geometry (4,168,384,16 /
4,188,260,16) carries VTank's own field width past its 268px window —
a source-XML artifact, not a real VVS layout (VVS itself would clip it
the same way our own contract test would reject it) — so both are
narrowed to fit the popup's own 268px canvas here, keeping VTank's
vertical rhythm (list, then "Search:", then the field) exactly.
KB's own lExemplarList has no explicit onchange row-click behavior
documented (only that txtSearch live-filters it) — picking a row here
commits it directly to whichever list is being edited and closes the
popup (PickBuffAt), rather than requiring a separate "OK" button VTank's
own view doesn't have either.
-->
<panel x="253" y="405" w="268" h="236" title="MossTank Add Buff"
visible="{BuffPickerVisible}" resize="none">
<list x="4" y="22" w="260" h="160" rowheight="17"
items="{BuffPickerRows}" selected="{SelectedBuffPickerIndex}"
onchange="{PickBuffAt}"
tooltip="Select a spell to add and close this picker." />
<label x="4" y="188" w="60" h="16" text="Search:" color="#FFE8DEC3" />
<field x="64" y="186" w="200" h="18" text="{BuffPickerSearchText}"
onchange="{SetBuffPickerSearchText}" maxlength="64"
clearonsubmit="false" background="#E6000000" color="#FFE8DEC3"
tooltip="Live-filters the list above by name." />
<button x="4" y="210" w="260" h="20" text="Cancel"
onclick="{HideBuffPicker}" />
</panel>

View file

@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Loot Editor popup — VTank's own FriendlyLootEditorView is a separate
268x300 OS-level window (docs/research/vtank-kb/08-ui-views.md §1's
secondary-view table) whose retail "Rules" page is a literal stub ("This
page not yet completed"); our own editor is genuinely more complete
(KB §5). Opened/closed by the Profiles tab's own "Show Editor" checkbox
(mosstank.xml, cShowLootEditor/ToggleLootEditorVisible).
Fix round A (2026-09-07) moved this OUT of the main mosstank.xml panel
(an in-panel group forcing the whole window to grow to 350px tall with
dead space under the nine normal tabs) into its own top-level plugin
panel, registered as a THIRD AddPanel call from MossTankPlugin.cs with
ShowInSidePanel=false (VTank's popup has no independent shelf entry). This
panel's own visible="{LootEditorVisible}" binding is the SAME flag the
checkbox flips and CloseLootEditor clears
(CloseLootEditor => _lootEditorVisible = false) — closing the popup
unchecks the box for free (see mosstank-advanced.xml's comment for why no
new host show/hide API was needed).
Round F item 6 (resemblance re-check: "Loot Editor: drop the leftover
'Back' button — the window closes from its title bar like the
others"): the standalone "Back" button bound directly to
CloseLootEditor is gone — the window's own OS-level title bar already
closes it, the same shape every other plugin popup (Advanced Options
included) has used with no Back button of its own since round D.
CloseLootEditor stays real: ToggleLootEditorVisible (the Profiles-tab
"Show Editor" checkbox's own close path) still calls it directly.
Fix round A also replaced the "↑"/"↓" text buttons (MoveLootRuleUp/Down)
with the same DAT move-up/move-down icon glyphs the Monsters grid already
uses (0x060028FC / 0x060028FD) — the retail default UI font (0x40000000)
has no glyph for U+2191/U+2193, so these rendered as blank button slots
(owner's 07:55 screenshot read).
LootEditorNotice (a status label, not an action) is the one control
dropped to make everything else fit without clipping — a deliberate,
narrow trim given how much richer this editor is than the 268-wide popup
VTank itself budgets for.
Fix round A also dropped the in-content "Loot profile rules (first match
wins)" label: it duplicated the window's own OS-level title bar
(MarkupDocument.Build auto-renders the <panel title="..."> at a fixed
(8,4) — the same corner the in-content label used to occupy back when
this was an in-panel group with no title bar of its own), so the two
overlapped and read as garbled text on screen once this became a
top-level window (live-verified, screenshot before the fix showed
"Loot profileRules(first match wins)").
-->
<panel x="660" y="405" w="268" h="300" title="MossTank Loot Editor"
visible="{LootEditorVisible}" resize="none">
<field x="4" y="22" w="260" h="18" text="{LootProfileNameDraft}"
onchange="{SetLootProfileNameDraft}"
onsubmit="{CreateNamedLootProfile}" maxlength="64"
background="#E6000000" color="#FFE8DEC3"
tooltip="Name a new or copied loot profile." />
<button x="4" y="44" w="58" h="18" text="New"
onclick="{CreateLootProfile}" />
<button x="66" y="44" w="58" h="18" text="CopyTo"
onclick="{CopyLootProfile}" />
<button x="128" y="44" w="58" h="18" text="Clear"
onclick="{ClearLootProfile}" />
<button x="190" y="44" w="70" h="18" text="Delete"
onclick="{DeleteLootProfile}"
tooltip="Delete the selected named loot profile's real .utl file. Not available for By char." />
<list x="4" y="66" w="260" h="68" rowheight="17"
items="{LootRuleRows}" selected="{SelectedLootRuleIndex}"
onchange="{SelectLootRule}" tooltip="Select an ordered loot rule to edit." />
<menu x="4" y="138" w="260" h="18" items="{LootActionNames}"
selected="{SelectedLootAction}" onchange="{SelectLootAction}"
rows="9" scroll="true" tooltip="Choose the action for matching loot." />
<label x="4" y="160" w="140" h="16" text="{LootPriorityText}" color="#FFC7B98F" />
<button x="148" y="160" w="22" h="18" text="-"
onclick="{LootPriorityDown}" tooltip="Decrease loot rule priority." />
<button x="174" y="160" w="22" h="18" text="+"
onclick="{LootPriorityUp}" tooltip="Increase loot rule priority." />
<label x="4" y="180" w="140" h="16" text="{LootKeepCountText}" color="#FFC7B98F" />
<button x="148" y="180" w="22" h="18" text="-"
onclick="{LootKeepCountDown}" tooltip="Decrease the KeepUpTo count." />
<button x="174" y="180" w="22" h="18" text="+"
onclick="{LootKeepCountUp}" tooltip="Increase the KeepUpTo count." />
<field x="4" y="200" w="190" h="18" text="{LootExpressionDraft}"
onchange="{SetLootExpressionDraft}" onsubmit="{ApplyLootExpression}"
maxlength="256" background="#E6000000" color="#FFE8DEC3"
tooltip="VTClassic or UtilityBelt-compatible loot match expression." />
<button x="198" y="200" w="64" h="18" text="Apply"
onclick="{ApplyLootRule}" />
<button x="4" y="222" w="54" h="18" text="Add"
onclick="{AddLootRule}" />
<button x="60" y="222" w="64" h="18" text="Remove"
onclick="{RemoveLootRule}" />
<button x="128" y="222" w="26" h="18" icon="0x060028FC" iconkind="did"
onclick="{MoveLootRuleUp}" tooltip="Move the selected loot rule up." />
<button x="158" y="222" w="26" h="18" icon="0x060028FD" iconkind="did"
onclick="{MoveLootRuleDown}" tooltip="Move the selected loot rule down." />
<toggle x="4" y="244" w="128" h="18" text="Loot All Corpses"
checked="{LootAllCorpsesEnabled}" onclick="{ToggleLootAllCorpses}" />
<toggle x="136" y="244" w="128" h="18" text="Loot Fellow Corpses"
checked="{LootFellowCorpsesEnabled}"
onclick="{ToggleLootFellowCorpses}" />
<toggle x="4" y="264" w="128" h="18" text="Loot Only Rare Corpses"
checked="{LootOnlyRareCorpsesEnabled}"
onclick="{ToggleLootOnlyRareCorpses}" />
<toggle x="136" y="264" w="128" h="18" text="Read Unknown Scrolls"
checked="{ReadUnknownScrollsEnabled}"
onclick="{ToggleReadUnknownScrolls}" />
<label x="4" y="282" w="140" h="16" text="{LootRangeText}" color="#FFC7B98F" />
<button x="148" y="282" w="22" h="16" text="-"
onclick="{LootRangeDown}" tooltip="Decrease corpse-looting range." />
<button x="174" y="282" w="22" h="16" text="+"
onclick="{LootRangeUp}" tooltip="Increase corpse-looting range." />
</panel>

View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Meta rule editor popup — Campaign VT slice 7 fix round B item 5. VTank's
own Meta tab (docs/research/vtank-kb/08-ui-views.md §1 "Tab: Meta") is
only 5 controls: the lstMetaRules grid, "Create", a bold "Current State:"
caption, and a settable cmbMetaCurrentState choice — no inline editor at
all. MossTank's previous inline State/Condition/Action editor (fields,
menus, numeric steppers, Apply/Add/Remove/Move) sat below the grid on the
Meta tab itself; this pulls that whole editor into its own popup, same
top-level plugin-panel pattern as mosstank-buffpicker.xml (StartVisible=
true, ShowInSidePanel=false, this panel's own visible="{MetaEditorVisible}"
is the SAME flag a grid text-cell click (State/Condition/Action columns)
or the tab's own "Create" button sets, and this popup's own Apply/Cancel
clear.
Remove/MoveUp/MoveDown stay here (not just in the grid's own delete/move
icon columns) as a convenience while a rule is already open for editing —
VTank's KB gives no ground truth for its own editing surface (not
documented in the tab tables at all), so this is MossTank's own design,
not a retail transcription.
-->
<panel x="300" y="380" w="630" h="160" title="MossTank Meta Rule Editor"
visible="{MetaEditorVisible}" resize="none">
<field x="4" y="8" w="118" h="21" text="{MetaStateDraft}"
onchange="{SetMetaStateDraft}" onsubmit="{SetMetaStateDraft}"
maxlength="64" clearonsubmit="false" background="#E6000000"
color="#FFE8DEC3" tooltip="State in which this meta rule is evaluated." />
<menu x="128" y="8" w="244" h="21" items="{MetaConditionNames}"
selected="{SelectedMetaCondition}" onchange="{SelectMetaCondition}"
rows="12" openupward="false" tooltip="Choose the rule condition." />
<menu x="378" y="8" w="204" h="21" items="{MetaActionNames}"
selected="{SelectedMetaAction}" onchange="{SelectMetaAction}"
rows="10" openupward="false" tooltip="Choose the action performed when the condition matches." />
<field x="4" y="34" w="244" h="21" text="{MetaConditionTextDraft}"
onchange="{SetMetaConditionTextDraft}"
onsubmit="{SetMetaConditionTextDraft}" maxlength="256"
clearonsubmit="false" background="#E6000000" color="#FFE8DEC3"
tooltip="Condition text or UtilityBelt-compatible expression." />
<field x="254" y="34" w="244" h="21" text="{MetaActionTextDraft}"
onchange="{SetMetaActionTextDraft}"
onsubmit="{SetMetaActionTextDraft}" maxlength="256"
clearonsubmit="false" background="#E6000000" color="#FFE8DEC3"
tooltip="Primary action argument or expression." />
<field x="504" y="34" w="110" h="21" text="{MetaSecondaryTextDraft}"
onchange="{SetMetaSecondaryTextDraft}"
onsubmit="{SetMetaSecondaryTextDraft}" maxlength="128"
clearonsubmit="false" background="#E6000000" color="#FFE8DEC3"
tooltip="Secondary action argument." />
<label x="4" y="64" w="60" h="16" text="{MetaNumberLabel}" color="#FFC7B98F" />
<button x="70" y="59" w="28" h="21" text="-" onclick="{MetaNumberDown}"
tooltip="Decrease the primary numeric argument." />
<button x="104" y="59" w="28" h="21" text="+" onclick="{MetaNumberUp}"
tooltip="Increase the primary numeric argument." />
<label x="144" y="64" w="76" h="16" text="{MetaSecondaryNumberLabel}"
color="#FFC7B98F" />
<button x="224" y="59" w="28" h="21" text="-"
onclick="{MetaSecondaryNumberDown}" tooltip="Decrease the secondary numeric argument." />
<button x="258" y="59" w="28" h="21" text="+"
onclick="{MetaSecondaryNumberUp}" tooltip="Increase the secondary numeric argument." />
<label x="306" y="64" w="200" h="16" text="{MetaStatus}" color="#FFC7B98F" />
<button x="4" y="90" w="80" h="22" text="Apply" onclick="{ApplyMetaRule}" />
<button x="90" y="90" w="80" h="22" text="Remove" onclick="{RemoveMetaRule}" />
<button x="176" y="90" w="28" h="22" icon="0x060028FC" iconkind="did"
onclick="{MoveMetaRuleUp}" tooltip="Move the selected meta rule up." />
<button x="210" y="90" w="28" h="22" icon="0x060028FD" iconkind="did"
onclick="{MoveMetaRuleDown}" tooltip="Move the selected meta rule down." />
<button x="250" y="90" w="80" h="22" text="Cancel" onclick="{HideMetaEditor}" />
<label x="4" y="120" w="600" h="16" text="{MetaNotice}" color="#FF9B9072" />
</panel>

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,15 @@
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.App\AcDream.App.csproj" />
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
<!-- Fix round B item 15: a real compile-time reference, unlike the
ReferenceOutputAssembly=false plugin fixtures below (those exercise
the PRODUCTION collectible-ALC loader and deliberately stay
decoupled at compile time). MossTankMarkupBuildOverRealFilesTests
constructs a real MossTankPanel directly so MarkupDocument.Build
validates every mosstank*.xml's actual attribute/binding grammar —
a test-only exception to the plugin/host compile-time boundary,
not a production coupling. -->
<ProjectReference Include="..\..\src\AcDream.Plugins.MossTank\AcDream.Plugins.MossTank.csproj" />
</ItemGroup>
<ItemGroup>
@ -52,6 +61,11 @@
<None Include="UI\Layout\fixtures\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Fix round B item 15: same glob pattern item 13 established for the
plugin's own test project. -->
<None Include="..\..\src\AcDream.Plugins.MossTank\mosstank*.xml"
Link="MossTank\%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -123,8 +123,10 @@ public sealed class LinuxPlatformBoundaryTests
2,
project.Split("Targets=\"GetTargetPath\"", StringSplitOptions.None)
.Length - 1);
// Fix round B item 13: globbed (mosstank*.xml) rather than named
// one file at a time — see AcDream.App.csproj's own comment.
Assert.Contains(
"../AcDream.Plugins.MossTank/mosstank.xml",
"../AcDream.Plugins.MossTank/mosstank*.xml",
project,
StringComparison.Ordinal);
Assert.DoesNotContain(

View file

@ -320,6 +320,284 @@ public class MarkupDocumentTests
Assert.Equal(0.78f, slider.ScalarPositionSource!());
}
// ── Campaign VT slice 7 S7.2: <slider min max> (KB 08 §3 gap) ────────
private sealed class RangeBinding
{
public float Value { get; private set; } = 50f;
public Action<float> SetValue => value => Value = value;
}
[Fact]
public void Build_SliderWithNoMinMax_KeepsTheHistoricZeroToOneIdentityRange()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"160\" h=\"20\">" +
"<slider x=\"0\" y=\"0\" w=\"150\" h=\"16\" value=\"{Value}\" " +
"onchange=\"{SetValue}\"/>" +
"</panel>";
// Value defaults to 50 in RangeBinding, but with no declared range a
// pre-existing <slider> 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<UiScrollbar>(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 =
"<panel x=\"0\" y=\"0\" w=\"160\" h=\"20\">" +
"<slider x=\"0\" y=\"0\" w=\"150\" h=\"16\" min=\"0\" max=\"200\" " +
"value=\"{Value}\" onchange=\"{SetValue}\"/>" +
"</panel>";
var binding = new RangeBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, _ => (1u, 16, 16));
var slider = Assert.IsType<UiScrollbar>(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<float>.
slider.ScalarChanged!(0.6f);
Assert.Equal(120f, binding.Value, 3);
}
[Theory]
[InlineData("100", "0")] // max < min
[InlineData("50", "50")] // max == min
public void Build_SliderWithMaxLessThanOrEqualToMin_Throws(string min, string max)
{
// Fix round B item 16: max<=min used to silently fall back to a
// range of 1 (max==min) or produce an inverted-drag-direction
// slider (max<min) instead of being caught as an authoring error.
string xml =
"<panel x=\"0\" y=\"0\" w=\"160\" h=\"20\">" +
$"<slider x=\"0\" y=\"0\" w=\"150\" h=\"16\" min=\"{min}\" max=\"{max}\" " +
"value=\"{Value}\" onchange=\"{SetValue}\"/>" +
"</panel>";
var binding = new RangeBinding();
Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, binding, _ => (1u, 16, 16)));
}
// Fix round B item 11: <slider> now defaults to the PLAIN style
// (RetailArt=false), so a markup slider with no style attribute never
// reaches RetailScrollbarChrome.ApplyHorizontal's sprite thumb any more
// — it draws UiScrollbar.DrawPlainScalar's flat nub instead. This test
// now opts INTO style="retail" to keep exercising the retail sprite
// path; the plain default gets its own sibling below.
[Fact]
public void Slider_MinMax_DrawsTheThumbAtTheRescaledNormalizedPosition()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"160\" h=\"20\">" +
"<slider x=\"0\" y=\"0\" w=\"150\" h=\"16\" min=\"0\" max=\"200\" " +
"value=\"{Value}\" style=\"retail\"/>" +
"</panel>";
var binding = new RangeBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, id => (id, 16, 16));
var slider = Assert.IsType<UiScrollbar>(panel.Children[0]);
Assert.True(slider.RetailArt);
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}");
}
[Fact]
public void Slider_NoStyleAttribute_DrawsAPlainFlatNubAtTheRescaledNormalizedPosition()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"160\" h=\"20\">" +
"<slider x=\"0\" y=\"0\" w=\"150\" h=\"16\" min=\"0\" max=\"200\" " +
"value=\"{Value}\"/>" +
"</panel>";
var binding = new RangeBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, id => (id, 16, 16));
var slider = Assert.IsType<UiScrollbar>(panel.Children[0]);
Assert.False(slider.RetailArt);
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));
slider.TickSelfAndChildren(0);
Assert.Equal(0.25f, slider.ScalarPosition, 3);
slider.DrawSelfAndChildren(ctx);
// An untextured segment batches every DrawFill call in submission
// order together (background track + nub both use texture=0), so
// scan per-quad (6 verts x 8 floats = 48 floats) for one whose
// color is PlainNubColor and whose left edge sits right of the
// origin (proof the 25% position, not 0 or 1.0, drove the nub).
const int floatsPerQuad = 6 * 8;
bool foundOffsetNub = renderer.DebugSpriteSegmentVerts.Any(s =>
{
if (s.Texture != 0u) return false;
for (int q = 0; q + floatsPerQuad <= s.Verts.Count; q += floatsPerQuad)
{
float xMin = float.MaxValue;
for (int v = 0; v < 6; v++)
xMin = MathF.Min(xMin, s.Verts[q + v * 8]);
float r = s.Verts[q + 4], g = s.Verts[q + 5], b = s.Verts[q + 6], a = s.Verts[q + 7];
bool isNubColor = MathF.Abs(r - slider.PlainNubColor.X) < 0.01f
&& MathF.Abs(g - slider.PlainNubColor.Y) < 0.01f
&& MathF.Abs(b - slider.PlainNubColor.Z) < 0.01f
&& MathF.Abs(a - slider.PlainNubColor.W) < 0.01f;
if (isNubColor && xMin > 0f)
return true;
}
return false;
});
Assert.True(foundOffsetNub, "expected a plain flat nub offset right of the origin at 25%");
}
// ── Campaign VT slice 7 S7.2: <menu scroll="true"> (KB 08 §3 gap) ────
//
// Superseded by the retail-scrollbar-chrome merge (`b71a8ea37`, slice 7
// round D): a markup <menu> is now ALWAYS Scrollable (VTank's HudCombo
// is always a single scrolling column, never a wrapping grid), so the
// `scroll` attribute this comment used to gate no longer changes
// anything — see Menu_Markup_IsAlwaysScrollable_WithRetailScrollbarChromeWired
// below for the current contract. `Build_MenuWithNoScrollAttribute_
// KeepsScrollableFalse` (which asserted the opt-in default) was removed
// here in the same commit that folded the two branches together, since
// it now directly contradicts the shipped behavior.
[Fact]
public void Build_MenuWithScrollAttribute_SetsUiMenuScrollableAndItsChromeSprites()
{
const string xml = """
<panel x="0" y="0" w="160" h="40">
<menu x="4" y="4" w="120" h="20" items="{Choices}"
selected="{Selected}" onchange="{SelectChoice}"
scroll="true" />
</panel>
""";
var binding = new EditorBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, _ => (1u, 32, 32));
var menu = Assert.IsType<UiMenu>(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);
}
// Campaign VT slice 7 fix round B item 1: <menu> now defaults to the
// PLAIN style (RetailButtonArt=false — see the "BIG gold/yellow buttons"
// owner fix), so a markup menu with no style attribute never reaches the
// retail sprite-scrollbar path (DrawPopupScrollbar) any more — it draws
// DrawPopupScrollbarPlain's flat DrawFill thumb instead. This test now
// opts INTO style="retail" to keep exercising the retail sprite path;
// the plain default gets its own sibling below.
[Fact]
public void Menu_Scroll_DrawsAScrollbarWhenTheMarkupItemCountOverflowsTheVisibleRows()
{
const string xml = """
<panel x="0" y="0" w="160" h="40">
<menu x="4" y="4" w="120" h="18" items="{ManyChoices}"
selected="{Selected}" onchange="{SelectChoice}"
rows="6" rowheight="18" scroll="true" style="retail" />
</panel>
""";
var binding = new ManyChoicesBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, id => (id, 16, 16));
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
Assert.True(menu.RetailButtonArt);
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);
}
// Plain-style scrollbar chrome (default style, no style attribute): this
// used to assert a flat untextured DrawFill thumb (UiMenu.
// DrawPopupScrollbarPlain). Superseded by the retail-scrollbar-chrome
// merge (`b71a8ea37`, slice 7 round D, owner: "we use the same assets as
// we do in for example chat or inventory window") — a plain-style
// popup's scrollbar now draws the SAME sprite chrome (track/up/down/
// three-part thumb) the chat/inventory scrollbar resolves through,
// regardless of RetailButtonArt; only the ROW fills stay plain. See
// UiMenuPlainStyleTests.Plain_OpenPopup_ScrollableOverflow_
// DrawsRetailScrollbarChrome_RowsStayPlain for the current coverage of
// this exact scenario (plain menu, overflowing markup item count).
private sealed class ManyChoicesBinding
{
public string Selected { get; private set; } = "Item 0";
public IReadOnlyList<string> ManyChoices { get; } =
Enumerable.Range(0, 18).Select(i => $"Item {i}").ToArray();
public Action<string> SelectChoice => value => Selected = value;
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
// ── S7 fix ("BIG gold/yellow buttons has to go" — owner live-client
// report 2026-09-07): <menu style="..."> selects UiMenu.RetailButtonArt.

View file

@ -551,6 +551,50 @@ public sealed class MarkupListColumnsTests
"expected col0's icon clamped inside the 50px list width");
}
[Fact]
public void ColumnGrids_NeverDrawARowSelectionBand()
{
// Fix round B item 10 (owner/oracle: VVS's own HudList grids —
// Monsters/Meta/Route/Items and every other <list><column> grid —
// have no row-selection highlight at all; only the per-cell click
// callbacks survive). DrawColumns must never paint SelectedColor,
// even when SelectedIndexSource reports a real in-range row.
var list = new UiMarkupList
{
Width = 100f, Height = 60f, RowHeight = 18f,
SelectedIndexSource = () => 1,
BackgroundColor = new Vector4(0f, 0f, 0f, 1f),
BorderColor = default,
SelectedColor = new Vector4(1f, 0f, 0f, 1f), // distinct, unmistakable
Columns = new[]
{
UiMarkupListColumn.Text(100f, () => new[] { "Row0", "Row1", "Row2" }, null),
},
};
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
bool anySelectedFill = renderer.DebugSpriteSegmentVerts.Any(s =>
{
if (s.Texture != 0u)
return false;
for (int q = 0; q + 48 <= s.Verts.Count; q += 48)
{
float r = s.Verts[q + 4], g = s.Verts[q + 5], b = s.Verts[q + 6], a = s.Verts[q + 7];
if (MathF.Abs(r - list.SelectedColor.X) < 0.01f
&& MathF.Abs(g - list.SelectedColor.Y) < 0.01f
&& MathF.Abs(b - list.SelectedColor.Z) < 0.01f
&& MathF.Abs(a - list.SelectedColor.W) < 0.01f)
{
return true;
}
}
return false;
});
Assert.False(anySelectedFill, "expected no SelectedColor fill in a column-based grid");
}
// ── Draw-level: column x-offsets, check glyph, icon, clipping ────────────
private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h)
@ -1416,6 +1460,58 @@ public sealed class MarkupListColumnsTests
Assert.Equal(new[] { 0 }, binding.ActionClicks);
}
private sealed class RouteShapedBinding
{
public IReadOnlyList<string> Names { get; } =
Enumerable.Range(0, 9).Select(static i => $"WP{i}").ToArray();
public IReadOnlyList<string> Counts { get; } =
Enumerable.Range(0, 9).Select(static i => i.ToString()).ToArray();
public IReadOnlyList<string> Filler { get; } = Array.Empty<string>();
public int Selected { get; set; } = -1;
public Action<int> Click => static _ => { };
}
/// <summary>
/// Round F item 6 (resemblance re-check: "Route's count column
/// squeezes to 17 when the list scrolls"). Mirrors the Route tab's
/// EXACT real shape (mosstank.xml: a 370px-wide list, columns
/// 324/30/*) with enough rows (9, past the 151/17≈8 visible-row
/// ceiling) to force the scrollbar — the count column's declared
/// 30px was previously only honored while the scrollbar was hidden;
/// with it showing, 337(the OLD text width)+30 already exceeded
/// 370-16=354, clamping the count column to 17px. Reflects into
/// UiMarkupList's own cached column layout (the same one
/// DrawColumns/OnEventColumns use) rather than measuring glyphs —
/// this is a pure geometry question, no font involved.
/// </summary>
[Fact]
public void RouteShapedGrid_CountColumnStaysThirtyPxWhenTheListScrolls()
{
var binding = new RouteShapedBinding();
const string xml =
"<panel x=\"0\" y=\"0\" w=\"400\" h=\"200\">" +
"<list x=\"0\" y=\"0\" w=\"370\" h=\"151\" rowheight=\"17\" " +
"selected=\"{Selected}\">" +
" <column type=\"text\" width=\"324\" items=\"{Names}\" onclick=\"{Click}\"/>" +
" <column type=\"text\" width=\"30\" items=\"{Counts}\" onclick=\"{Click}\"/>" +
" <column type=\"text\" width=\"*\" items=\"{Filler}\"/>" +
"</list></panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
var (renderer, ctx) = MakeContext(800f, 400f);
list.DrawSelfAndChildren(ctx);
System.Reflection.FieldInfo layoutField = typeof(UiMarkupList).GetField(
"_cachedLayout",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var layout = ((float x, float w)[])layoutField.GetValue(list)!;
Assert.Equal(3, layout.Length);
Assert.Equal(30f, layout[1].w, 3);
}
private sealed class MonsterShapedBinding
{
public IReadOnlyList<bool> Checks { get; } = new[] { true, false };

View file

@ -0,0 +1,555 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank;
using Xunit;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Fix round B item 15. Every existing MossTank markup pin
/// (<c>MossTankMarkupContractTests</c>) validates <c>mosstank*.xml</c>
/// against <see cref="MossTankPanel"/> through reflection alone — "does a
/// public property with this name and this CLR type exist" — never through
/// <see cref="MarkupDocument.Build"/> itself, the code that actually mounts
/// a plugin panel at runtime. <c>MarkupDocument.Build</c> has its own
/// validation a reflection-only check can't see (attribute-format
/// exceptions like <c>ValidateArtStyle</c>'s "must be plain or retail",
/// delegate-shape mismatches surfaced as thrown <see cref="FormatException"/>s
/// rather than a missing property, numeric-attribute parsing, column-type
/// dispatch). Before this test, a markup bug of that shape would throw
/// inside <c>RetailUiRuntime.MountPlugins</c>'s own try/catch and the panel
/// would simply not appear — no test failure, no visible error short of a
/// live client screenshot. This builds every real <c>mosstank*.xml</c> file
/// against a REAL <see cref="MossTankPanel"/> (a stub <see cref="IPluginHost"/>,
/// same shape as <c>MossTankMarkupContractTests.StubHost</c>) so a bad
/// attribute fails a test instead of dropping the panel silently at mount.
/// </summary>
public sealed class MossTankMarkupBuildOverRealFilesTests
{
private static string MossTankMarkupDirectory =>
Path.Combine(AppContext.BaseDirectory, "MossTank");
public static IEnumerable<object[]> MossTankMarkupFiles() =>
Directory.GetFiles(MossTankMarkupDirectory, "mosstank*.xml")
.OrderBy(static path => path, StringComparer.Ordinal)
.Select(static path => new object[] { path });
[Theory]
[MemberData(nameof(MossTankMarkupFiles))]
public void EveryMossTankPanelFileBuildsAgainstARealPanelWithNoException(string path)
{
string xml = File.ReadAllText(path);
var panel = new MossTankPanel(new StubHost());
UiNineSlicePanel built = MarkupDocument.Build(xml, panel, static id => (id, 32, 32));
Assert.NotNull(built);
Assert.NotEmpty(built.Children);
}
/// <summary>
/// Round D item 4's own re-layout proof: the Monsters tab's real
/// mosstank.xml list carries <c>anchor="left right top bottom"</c>
/// (EveryStretchingListDeclaresARealAnchor, MossTankMarkupContractTests,
/// pins the attribute is present; this proves the attribute actually
/// DOES something through the real anchor machinery) — widening the
/// built root panel widens the Monsters list in turn, the same
/// mechanism MarkupResizableAnchorTests.ResizingPanel_LeftRightList_
/// WidensWithThePanel proves against synthetic markup, now proven
/// against the real shipped file.
/// </summary>
[Fact]
public void WideningTheRealMainPanelWidensTheRealMonstersList()
{
string xml = File.ReadAllText(
Path.Combine(MossTankMarkupDirectory, "mosstank.xml"));
var panel = new MossTankPanel(new StubHost());
UiNineSlicePanel built = MarkupDocument.Build(xml, panel, static id => (id, 32, 32));
// Every tab's group is visibility-bound ("visible={XVisible}") to
// StubHost's own IsAvailable=false automation, so relying on the
// normal VisibleSource/TickSelfAndChildren reconciliation would
// hide every tab (including the root). This test cares about the
// anchor mechanism, not the tab-switching one — it makes the
// Monsters group (the 4th of the nine tab groups in file order:
// Options/Profiles/Vitals/Monsters/...) visible directly. Exact
// type match, not OfType<UiPanel>() — UiSimpleButton/
// UiMarkupTabButton (the tab strip) are ALSO UiPanel subtypes;
// only a bare <group> compiles to the base UiPanel type itself.
UiPanel[] tabGroups = built.Children
.Where(static child => child.GetType() == typeof(UiPanel))
.Cast<UiPanel>()
.ToArray();
Assert.Equal(9, tabGroups.Length);
foreach (UiPanel group in tabGroups)
group.Visible = false;
UiPanel monstersGroup = tabGroups[3];
monstersGroup.Visible = true;
UiMarkupList monstersList = Assert.Single(monstersGroup.Children.OfType<UiMarkupList>());
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(1400f, 900f));
var ctx = new UiRenderContext(renderer, new Vector2(1400f, 900f));
// First draw at the authored (already-enlarged, 984 wide) default
// captures the list's anchor baseline.
built.DrawSelfAndChildren(ctx);
float widthAtAuthoredDefault = monstersList.Width;
// A live drag-resize (RetailWindowManager.ResizeTo) mutates Width
// directly; the next draw re-applies the captured margins against
// the NEW panel width.
built.Width += 100f;
built.DrawSelfAndChildren(ctx);
Assert.True(
monstersList.Width > widthAtAuthoredDefault,
$"Monsters list width did not grow: {widthAtAuthoredDefault} -> {monstersList.Width}");
}
/// <summary>
/// D-3 (round E architecture re-check): mosstank.xml's own top-of-file
/// comment already claimed the Advanced Options popup "gets its own
/// resizable=\"true\" + anchored lists" — the popup file itself still
/// carried the dead <c>resize="none"</c> attribute (only meaningful
/// once <c>resizable="true"</c> already arms the master switch,
/// MarkupDocument.cs), so the popup was permanently fixed-size. This
/// proves the fix through the real anchor machinery (the same
/// mechanism <see cref="WideningTheRealMainPanelWidensTheRealMonstersList"/>
/// proves for the main panel): lOptionList grows in WIDTH ONLY
/// (mosstank-advanced.xml's own comment explains why height growth is
/// unsafe here — the value field sits directly below with no slack),
/// and lFilterList tracks the growing right edge in lockstep so the
/// widening option list never walks into it (the same "right-pinned
/// sibling repositions" pattern the main panel already uses).
/// </summary>
[Fact]
public void WideningTheRealAdvancedOptionsPopupGrowsTheOptionListWithoutOverlappingItsSibling()
{
string xml = File.ReadAllText(
Path.Combine(MossTankMarkupDirectory, "mosstank-advanced.xml"));
var panel = new MossTankPanel(new StubHost());
UiNineSlicePanel built = MarkupDocument.Build(xml, panel, static id => (id, 32, 32));
Assert.True(built.Resizable);
Assert.Equal(392f, built.MinWidth);
// Round F item 1 shrunk the popup back to VTank's real
// AdvancedOptionsView footprint (392x300) after deleting the
// "Editing X." notice and the "MossTank Extras" section.
Assert.Equal(300f, built.MinHeight);
// Bypass the VisibleSource binding (bound to AdvancedOptionsVisible,
// false on the stub automation) the same way the Monsters test
// bypasses tab visibility — DrawSelfAndChildren's own anchor pass
// never runs for an invisible element.
built.Visible = true;
UiMarkupList optionList = Assert.Single(
built.Children.OfType<UiMarkupList>(), static list => list.Width == 256f);
UiMarkupList categoryList = Assert.Single(
built.Children.OfType<UiMarkupList>(), static list => list.Width == 120f);
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(1400f, 900f));
var ctx = new UiRenderContext(renderer, new Vector2(1400f, 900f));
built.DrawSelfAndChildren(ctx);
float optionListWidthBefore = optionList.Width;
float optionListHeightBefore = optionList.Height;
float categoryListLeftBefore = categoryList.Left;
float categoryListWidthBefore = categoryList.Width;
built.Width += 100f;
built.DrawSelfAndChildren(ctx);
Assert.True(
optionList.Width > optionListWidthBefore,
$"Option list width did not grow: {optionListWidthBefore} -> {optionList.Width}");
Assert.Equal(optionListHeightBefore, optionList.Height); // height fixed
Assert.True(
categoryList.Left > categoryListLeftBefore,
$"Category list did not track the growing right edge: {categoryListLeftBefore} -> {categoryList.Left}");
Assert.Equal(categoryListWidthBefore, categoryList.Width); // width fixed, only repositions
Assert.True(
optionList.Left + optionList.Width <= categoryList.Left,
$"Widened option list (right edge {optionList.Left + optionList.Width}) overlaps "
+ $"the repositioned category list (left edge {categoryList.Left}).");
}
/// <summary>
/// D-6 (round E architecture re-check): every real-file re-layout pin
/// so far only inspects ONE named element after a resize (the
/// Monsters list, the Advanced Options option/category lists). This
/// proves the whole resolved tree, at both ends of the main panel's
/// declared resize range — the floor (minw/minh, 856x236) and one
/// enlarged size (1100x320) past the 984x271 authored default — has
/// no sibling overlap and no child crossing its parent's edge, for
/// EACH of the nine tabs in turn (only the active tab's own subtree
/// gets a real anchor pass; DrawSelfAndChildren returns early for an
/// invisible element, matching <see cref="WideningTheRealMainPanelWidensTheRealMonstersList"/>'s
/// own reasoning). Reuses the same rectangle-overlap/parent-bounds
/// algorithm <c>MossTankMarkupContractTests</c>' AssertNoSiblingOverlap/
/// AssertWithinParent apply to the AUTHORED XElement tree, adapted
/// here to the RESOLVED UiElement tree (real Left/Top/Width/Height
/// post-anchor, not the raw x/y/w/h attributes) since a resize only
/// exists at this level.
/// </summary>
[Theory]
[InlineData(856f, 236f)] // the panel's own minw/minh floor
[InlineData(1100f, 320f)] // one enlarged size past the 984x271 default
public void ResolvedMainPanelHasNoOverlapOrOutOfBoundsChildAtThisSize(
float width, float height)
{
string xml = File.ReadAllText(
Path.Combine(MossTankMarkupDirectory, "mosstank.xml"));
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(1400f, 900f));
var ctx = new UiRenderContext(renderer, new Vector2(1400f, 900f));
// A fresh MarkupDocument.Build per tab, not one shared tree reused
// across all nine: each element's anchor margins are captured ONCE,
// on its very first ApplyAnchor call, and never recomputed after —
// reusing one tree across tabs would let an earlier tab's resize
// leave the shared root already at the target size by the time a
// LATER tab's own descendants take their "first" (authored-default)
// capture, corrupting their baseline.
for (int tabIndex = 0; tabIndex < 9; tabIndex++)
{
var panel = new MossTankPanel(new StubHost());
UiNineSlicePanel built = MarkupDocument.Build(xml, panel, static id => (id, 32, 32));
built.Visible = true;
UiPanel[] tabGroups = built.Children
.Where(static child => child.GetType() == typeof(UiPanel))
.Cast<UiPanel>()
.ToArray();
Assert.Equal(9, tabGroups.Length);
foreach (UiPanel group in tabGroups)
group.Visible = false;
tabGroups[tabIndex].Visible = true;
// First draw at the authored default captures every anchored
// descendant's baseline margins (matching a live window that
// just opened); only THEN does resizing to the target size
// reapply those captured margins against the new bounds —
// the same two-draw shape WideningTheRealMainPanelWidensTheRealMonstersList
// uses.
built.DrawSelfAndChildren(ctx);
built.Width = width;
built.Height = height;
built.DrawSelfAndChildren(ctx);
AssertResolvedWithinParent(built);
AssertResolvedNoSiblingOverlap(built);
}
}
/// <summary>
/// Round F item 6 (resemblance re-check: "Buffs' 136 px middle gap").
/// <see cref="ResolvedMainPanelHasNoOverlapOrOutOfBoundsChildAtThisSize"/>
/// already proves the extras strip never overlaps the Blacklisted
/// Buff Families list at the floor or the enlarged size (that generic
/// sibling-overlap pass is exactly what caught the original x=397
/// attempt at true 984-default centering colliding with the list's
/// own floor position, x=524). This is the narrower, POSITIVE claim
/// that overlap pass alone doesn't make: the strip is actually
/// CENTERED at the panel's floor (856x236) — the one width at which
/// centering and non-overlap can both hold, since Extra Buff Spells
/// never moves but Blacklisted Buff Families tracks the growing
/// right edge.
/// </summary>
[Fact]
public void BuffsExtrasStripStaysCenteredAndNonOverlappingAtTheFloor()
{
string xml = File.ReadAllText(
Path.Combine(MossTankMarkupDirectory, "mosstank.xml"));
var panel = new MossTankPanel(new StubHost());
UiNineSlicePanel built = MarkupDocument.Build(xml, panel, static id => (id, 32, 32));
UiPanel[] tabGroups = built.Children
.Where(static child => child.GetType() == typeof(UiPanel))
.Cast<UiPanel>()
.ToArray();
Assert.Equal(9, tabGroups.Length);
foreach (UiPanel group in tabGroups)
group.Visible = false;
UiPanel buffsGroup = tabGroups[6];
buffsGroup.Visible = true;
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(1400f, 900f));
var ctx = new UiRenderContext(renderer, new Vector2(1400f, 900f));
// First draw at the authored default captures anchor baselines;
// only then resize down to the panel's own tested floor.
built.DrawSelfAndChildren(ctx);
built.Width = 856f;
built.Height = 236f;
built.DrawSelfAndChildren(ctx);
UiMarkupList[] lists = buffsGroup.Children.OfType<UiMarkupList>().ToArray();
Assert.Equal(2, lists.Length);
UiMarkupList leftList = lists.Single(static l => l.Left < 300f);
UiMarkupList rightList = lists.Single(static l => l.Left >= 300f);
UiMarkupToggle trainedToggle = Assert.Single(
buffsGroup.Children.OfType<UiMarkupToggle>(), static t => t.Text == "Trained skills only");
UiSimpleButton rebuffUpButton = Assert.Single(
buffsGroup.Children.OfType<UiSimpleButton>(), static b => b.Text == "+" && b.Top > 120f);
float leftMargin = trainedToggle.Left - (leftList.Left + leftList.Width);
float rightMargin = rightList.Left - (rebuffUpButton.Left + rebuffUpButton.Width);
Assert.True(
rightMargin >= 0f,
$"Extras strip (right edge {rebuffUpButton.Left + rebuffUpButton.Width}) "
+ $"overlaps the right list (left edge {rightList.Left}) at the floor.");
Assert.True(
MathF.Abs(leftMargin - rightMargin) <= 4f,
$"Extras strip not centered at the floor: left margin "
+ $"{leftMargin}px, right margin {rightMargin}px.");
}
private static void AssertResolvedWithinParent(UiElement parent)
{
foreach (UiElement child in parent.Children)
{
// An invisible child's OWN box (e.g. an inactive tab group,
// whose top-level anchor still tracks the resize regardless
// of visibility) is still worth checking, but DrawSelfAndChildren
// returns before applying anchor to ITS descendants while it
// stays invisible — recursing further would compare stale,
// never-resolved-for-this-size geometry and misreport it as
// broken. Skip descending past an invisible node; the tab it
// belongs to gets its own real pass in this test's own loop
// once it becomes the active one.
if (!child.Visible)
continue;
// UiLabel is excluded from the size checks (not from
// recursion): UiLabel.OnDraw (UiPanel.cs, the CT-GF1 fix)
// deliberately overwrites Width/Height to the CURRENT measured
// text extent on every draw, ignoring its own authored w/h —
// "a markup <label> authors position only". This test's stub
// renderer has no real DatFont, so a label falls through to
// OnDraw's crude "text.Length * 7f" placeholder, which reports
// a box far wider than the authored geometry (or the real DAT
// font) ever would and would misreport as crossing a parent
// edge or overlapping a sibling — noise from the test harness,
// not a real layout bug. Left/Top (position) are unaffected by
// OnDraw and still worth checking through the parent-bounds
// recursion.
if (child is not UiLabel)
{
if (child.Width > 0f)
{
Assert.True(
child.Left + child.Width <= parent.Width + 0.01f,
$"{child.GetType().Name} @ ({child.Left},{child.Top},{child.Width},"
+ $"{child.Height}) crosses the right edge of {parent.GetType().Name} "
+ $"(w={parent.Width}).");
}
if (child.Height > 0f)
{
Assert.True(
child.Top + child.Height <= parent.Height + 0.01f,
$"{child.GetType().Name} @ ({child.Left},{child.Top},{child.Width},"
+ $"{child.Height}) crosses the bottom edge of {parent.GetType().Name} "
+ $"(h={parent.Height}).");
}
}
AssertResolvedWithinParent(child);
}
}
/// <summary>
/// Round F item 5 (resemblance re-check: "Monsters' move-down arrow
/// drifted ~67 px from move-up because the last (auto) column
/// absorbed the new width — add a trailing filler column").
/// MonsterMoveDownIcons used to be the grid's LAST &lt;column&gt;,
/// and docs/plugin-ui-markup.md's own "Width semantics" rule makes
/// the LAST column ALWAYS auto regardless of its own declared width
/// — so it silently absorbed every pixel of window growth instead of
/// staying at its declared 23px pitch, dragging the down-arrow away
/// from the up-arrow as the panel widened. Proves the two icons stay
/// the same fixed ~23px apart at both the authored default (984) and
/// the D-6 enlarged size (1100), through the SAME real mosstank.xml
/// file and MossTankPanel this file's other real-file tests use.
/// </summary>
[Theory]
[InlineData(984f)]
[InlineData(1100f)]
public void MonstersMoveUpAndMoveDownIconsStayAdjacentAtEveryWidth(float width)
{
string xml = File.ReadAllText(
Path.Combine(MossTankMarkupDirectory, "mosstank.xml"));
var panel = new MossTankPanel(new StubHost());
// The MoveUp/MoveDown icon columns need a real IMarkupIconResolver
// wired (unlike every other test in this file, which only cares
// about geometry) — MarkupDocument.Build leaves <column type="icon">
// silently undrawn without one, so an identity stub is required
// here to actually exercise DrawIconCell.
UiNineSlicePanel built = MarkupDocument.Build(
xml, panel, static id => (id, 32, 32), icons: new IdentityIconResolver());
UiPanel[] tabGroups = built.Children
.Where(static child => child.GetType() == typeof(UiPanel))
.Cast<UiPanel>()
.ToArray();
Assert.Equal(9, tabGroups.Length);
foreach (UiPanel group in tabGroups)
group.Visible = false;
UiPanel monstersGroup = tabGroups[3];
monstersGroup.Visible = true;
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(1400f, 900f));
var ctx = new UiRenderContext(renderer, new Vector2(1400f, 900f));
// First draw at the authored default captures every anchored
// descendant's baseline margins; only THEN resize to the target
// width and redraw — the same two-draw shape every other
// real-file re-layout test in this file uses. Both draws record
// sprite calls into the SAME device, so each icon's quad appears
// TWICE (once per draw) — .Last() reads the final, resized-layout
// draw, not the stale authored-default one.
built.DrawSelfAndChildren(ctx);
built.Width = width;
built.DrawSelfAndChildren(ctx);
var moveUpQuad = renderer.DebugSpriteSegmentVerts
.Last(static s => s.Texture == 0x060028FCu);
var moveDownQuad = renderer.DebugSpriteSegmentVerts
.Last(static s => s.Texture == 0x060028FDu);
float gap = moveDownQuad.Verts[0] - moveUpQuad.Verts[0];
Assert.True(
gap is >= 20f and <= 26f,
$"MoveUp/MoveDown icons are {gap}px apart at width {width} — "
+ "expected VTank's fixed ~23px icon pitch, not the growing "
+ "gap a still-last, still-auto MoveDown column would produce.");
}
private static void AssertResolvedNoSiblingOverlap(UiElement container)
{
// Same "invisible subtree never got a real anchor pass" reasoning
// as AssertResolvedWithinParent — only compare/recurse into
// children that are actually visible.
UiElement[] children = container.Children
.Where(static child => child.Visible)
.ToArray();
for (int i = 0; i < children.Length; i++)
{
for (int j = i + 1; j < children.Length; j++)
{
UiElement a = children[i], b = children[j];
// Exact type match, not "is UiPanel": bare <group> tab
// pages deliberately occupy the identical rect (only one
// is Visible at a time) — subtypes like UiMarkupTabButton
// are real siblings that must not overlap. UiLabel is
// excluded for the same font-dependent-box reason
// AssertResolvedWithinParent excludes it (see its comment).
if (a.GetType() == typeof(UiPanel) && b.GetType() == typeof(UiPanel))
continue;
if (a is UiLabel || b is UiLabel)
continue;
Assert.True(
!ResolvedRectanglesOverlap(a, b),
$"{a.GetType().Name} @ ({a.Left},{a.Top},{a.Width},{a.Height}) overlaps "
+ $"sibling {b.GetType().Name} @ ({b.Left},{b.Top},{b.Width},{b.Height}).");
}
}
foreach (UiElement child in children)
AssertResolvedNoSiblingOverlap(child);
}
private static bool ResolvedRectanglesOverlap(UiElement a, UiElement b)
{
if (a.Width <= 0f || a.Height <= 0f || b.Width <= 0f || b.Height <= 0f)
return false; // an element with no resolved size never "occupies" space
return a.Left < b.Left + b.Width && b.Left < a.Left + a.Width
&& a.Top < b.Top + b.Height && b.Top < a.Top + a.Height;
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
/// <summary>Identity <see cref="IMarkupIconResolver"/> for icon-column
/// draw tests — every non-zero id "resolves" to itself as the texture
/// (16x16), matching <c>MarkupListColumnsTests.FakeIconResolver</c>'s
/// own convention so a real DID like the Monsters grid's move-up/
/// move-down pair (0x060028FC/FD) round-trips as its own texture id.</summary>
private sealed class IdentityIconResolver : IMarkupIconResolver
{
public (uint tex, int w, int h) ResolveDid(uint did) =>
did == 0u ? (0u, 0, 0) : (did, 16, 16);
public (uint tex, int w, int h) ResolveSpell(uint spellId) =>
spellId == 0u ? (0u, 0, 0) : (spellId, 16, 16);
public (uint tex, int w, int h) ResolveItem(uint objectId) =>
objectId == 0u ? (0u, 0, 0) : (objectId, 16, 16);
}
private sealed class StubHost : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents();
public ISelectionService Selection { get; } = new StubSelection();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
}
private sealed class StubLogger : IPluginLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
}
private sealed class StubState : IGameState
{
public IReadOnlyList<WorldEntitySnapshot> Entities => [];
}
private sealed class StubEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class StubSelection : ISelectionService
{
public uint? SelectedObjectId => null;
public uint? PreviousObjectId => null;
public event Action<SelectionChangedEvent> Changed
{
add { }
remove { }
}
public bool Select(uint objectId) => false;
public bool Clear() => false;
}
}

View file

@ -448,6 +448,12 @@
"acdream.plugin.abstractions": {
"type": "Project"
},
"acdream.plugins.mosstank": {
"type": "Project",
"dependencies": {
"AcDream.Plugin.Abstractions": "[1.0.0, )"
}
},
"acdream.runtime": {
"type": "Project",
"dependencies": {

View file

@ -20,8 +20,13 @@
<ProjectReference Include="..\..\src\AcDream.Plugins.MossTank\AcDream.Plugins.MossTank.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\src\AcDream.Plugins.MossTank\mosstank.xml"
Link="mosstank.xml"
<!-- Fix round B item 13: glob every mosstank*.xml plugin-panel file
(see AcDream.Plugins.MossTank.csproj's own comment) — %(Filename)
%(Extension) batching keeps each file's Link its own bare name
(mosstank-metaeditor.xml, etc.) instead of the source-relative path
MSBuild would otherwise use. -->
<None Include="..\..\src\AcDream.Plugins.MossTank\mosstank*.xml"
Link="%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>

View file

@ -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);
}
/// <summary>
/// The "flw" nav node's own parsing coverage, split out of
/// <see cref="SynthesizedGetOptFollowAndJumpFixtureRoundTrips"/> 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.
/// </summary>
[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);
@ -617,6 +647,58 @@ public sealed class MetafSerializerTests
Assert.Equal(5000, waypoint.JumpChargeMilliseconds);
}
/// <summary>
/// Round D item 3: ".af rcl round-trips by NAME (metaf writes the
/// name)" — SaveNav's "rcl" node carries ONLY RecallSpellName
/// (MetafSerializer's own "rcl {waypoint.RecallSpellName}" format),
/// and TryLoadNav resolves RecallSpellId back from that name against
/// the supplied ISpellCatalog. This proves three of VTank's 26 real
/// recalls (one from the start, middle, and end of the new table)
/// round-trip both the name AND — given a catalog that actually knows
/// the spell, unlike NoOpSpellCatalog — the correct real spell id.
/// </summary>
[Fact]
public void RecallNodeRoundTripsByNameAndResolvesTheRealSpellIdFromTheCatalog()
{
var source = new NavigationSettings { Mode = RouteMode.Once };
RouteRecallKind[] kinds =
[
RouteRecallKind.PrimaryPortalRecall,
RouteRecallKind.MountLetheRecall,
RouteRecallKind.EldrytchWebStrongholdRecall,
];
foreach (RouteRecallKind kind in kinds)
{
source.Waypoints.Add(new RouteWaypoint
{
Type = RouteWaypointType.Recall,
Recall = kind,
RecallSpellName = RouteWaypoint.RecallDisplayName(kind),
RecallSpellId = RouteWaypoint.SpellIdForRecall(kind),
});
}
string af = MetafSerializer.SaveNav(source);
var catalog = new FakeSpellCatalog(kinds.Select(
kind => new PluginSpellInfo(
RouteWaypoint.SpellIdForRecall(kind),
RouteWaypoint.RecallDisplayName(kind),
Family: 0, Tier: 1, Difficulty: 1, ManaCost: 0,
DurationSeconds: 0f, School: 0, Description: string.Empty,
IsSelfTargeted: true, IsBeneficial: true)));
var target = new NavigationSettings();
Assert.True(MetafSerializer.TryLoadNav(af, target, catalog, out string error), error);
Assert.Equal(kinds.Length, target.Waypoints.Count);
for (int i = 0; i < kinds.Length; i++)
{
RouteWaypoint waypoint = target.Waypoints[i];
Assert.Equal(RouteWaypoint.RecallDisplayName(kinds[i]), waypoint.RecallSpellName);
Assert.Equal(RouteWaypoint.SpellIdForRecall(kinds[i]), waypoint.RecallSpellId);
}
}
private static void AssertProfilesEqual(MetaProfile expected, MetaProfile actual)
{
Assert.Equal(expected.Rules.Count, actual.Rules.Count);
@ -724,4 +806,30 @@ public sealed class MetafSerializerTests
return false;
}
}
/// <summary>
/// Round D item 3: unlike <see cref="NoOpSpellCatalog"/>, this one
/// actually knows the spells handed to it — needed to prove
/// TryLoadNav's name->id resolution (MetafSerializer's own
/// ResolveSpellIdByName, which only checks KnownSelfBuffs and
/// KnownCombatSpells) works for a real recall spell, not just that it
/// harmlessly returns 0 for an unknown one.
/// </summary>
private sealed class FakeSpellCatalog(IEnumerable<PluginSpellInfo> selfBuffs) : ISpellCatalog
{
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; } = selfBuffs.ToArray();
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
foreach (PluginSpellInfo spell in KnownSelfBuffs)
{
if (spell.SpellId == spellId)
{
info = spell;
return true;
}
}
info = default;
return false;
}
}
}

View file

@ -164,9 +164,17 @@ public sealed class MossTankMarkupContractTests
XElement[] controls = root.Descendants()
.Where(element => interactive.Contains(element.Name.LocalName))
.ToArray();
// Round 3 item 10: +3 for the Route/Loot/Meta Delete buttons
// (Settings already had one from round 2 step 5).
Assert.Equal(194, controls.Length);
// Fix round C item F13: this used to also assert an exact control
// count, with ~60 lines of running commentary on every markup edit
// that bumped the number. That count was a pure change detector —
// it carried no signal EveryInteractiveControlDeclaresARealHandlerBinding
// couldn't already fail on its own, and it forced an edit to THIS
// test every time an unrelated tab gained or lost a control. The
// per-tab control-count history now lives in the slice 7 plan
// ledger (docs/plans/2026-09-07-campaign-vt-slice7-tabs.md)
// instead. The loop below is the real assertion: every interactive
// control must have a real handler and must not be disabled.
Assert.NotEmpty(controls);
foreach (XElement control in controls)
{
@ -194,6 +202,201 @@ public sealed class MossTankMarkupContractTests
Assert.True(panel.MetaTabEnabled);
}
[Fact]
public void MonstersGridHasVtanksTwentyThreeColumnsInOrderWithRetailHeaderTooltips()
{
// Campaign VT S7.3: pins the transcription against
// docs/research/vtank-kb/08-ui-views.md §1 "Tab: Monsters" (column
// type/order) and the decompile's own tooltip strings
// (refs/vtank/decompiled/uTank2/PluginCore.cs:1693-1707).
//
// Round F item 5 appended a 24th column: a genuinely inert
// trailing filler (empty items, no onclick) so the icon column
// before it (MoveDownIcons) is no longer the grid's LAST column
// and keeps its own declared 23px width instead of silently
// absorbing every pixel of window growth (docs/plugin-ui-markup.md's
// "Width semantics" — the last column is ALWAYS auto). It has no
// VTank counterpart; VTank's own 23-column shape is unchanged.
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
XElement monstersGroup = root.Elements("group")
.Single(static g => (string?)g.Attribute("visible") == "{MonstersVisible}");
XElement list = Assert.Single(monstersGroup.Elements("list"));
XElement[] columns = list.Elements("column").ToArray();
Assert.Equal(24, columns.Length);
string[] expectedTypes =
[
"check", "check", "check", "check", "check", "check", "check",
"check", "check", "check", "check", "check", "check", "check",
"text", "text", "text", "text", "text", "text", "text",
"icon", "icon", "text",
];
Assert.Equal(expectedTypes, columns.Select(c => (string?)c.Attribute("type")));
// The 14 check columns' header letters carry VTank's own decompiled
// tooltip text on the <label> placed above them (columns themselves
// have no tooltip grammar — docs/plugin-ui-markup.md's "Columns").
Dictionary<string, string?> tooltipsByHeaderText = monstersGroup.Elements("label")
.ToDictionary(
static l => (string?)l.Attribute("text") ?? string.Empty,
static l => (string?)l.Attribute("tooltip"));
Assert.Equal("Fester", tooltipsByHeaderText["F"]);
Assert.Equal("Broadside of a Barn", tooltipsByHeaderText["B"]);
Assert.Equal("Gravity Well", tooltipsByHeaderText["G"]);
Assert.Equal("Imperil", tooltipsByHeaderText["I"]);
Assert.Equal("Yield", tooltipsByHeaderText["Y"]);
Assert.Equal("Vuln (Element)", tooltipsByHeaderText["V"]);
Assert.Equal("Attack", tooltipsByHeaderText["A"]);
Assert.Equal("Ring Spell", tooltipsByHeaderText["R"]);
Assert.Equal("Streak", tooltipsByHeaderText["S"]);
Assert.Equal("Weakening Curse", tooltipsByHeaderText["WC"]);
Assert.Equal("Festering Curse", tooltipsByHeaderText["FC"]);
Assert.Equal("Corruption", tooltipsByHeaderText["Cp"]);
Assert.Equal("Destructive Curse", tooltipsByHeaderText["DC"]);
Assert.Equal("Corrosion", tooltipsByHeaderText["Cs"]);
Assert.Equal("Priority", tooltipsByHeaderText["P"]);
}
/// <summary>
/// Round F item 3 (owner's second live look: "On items tab, we show
/// some options there right of the items we add. That should not be
/// there. Remove that."). VTank's own Items tab is exactly 6 controls
/// (docs/research/vtank-kb/08-ui-views.md §1 "Tab: Items" — 2 labels,
/// the 2-column list, Add, Add (no buffs)) with nothing to the right
/// of the list. MossTank's pre-existing "Remove" button traces back
/// to the plugin's very first automation PoC commit (`4e6e9bc9d`,
/// long before Campaign VT slice 7), so it is the accepted
/// "slice-1 Delete" this round keeps; everything else that used to
/// sit right of the list (Weapon/Offhand readout, Refill Worn Mana
/// toggle/slider/status, the notice hint) is gone.
/// </summary>
[Fact]
public void ItemsTabIsVtankOnlyPlusTheAcceptedSliceOneRemoveButton()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
XElement itemsGroup = root.Elements("group")
.Single(static g => (string?)g.Attribute("visible") == "{ItemsVisible}");
Assert.Single(itemsGroup.Elements("list"));
string[] buttonTexts = itemsGroup.Elements("button")
.Select(static b => (string?)b.Attribute("text") ?? string.Empty)
.ToArray();
Assert.Equal(["Add", "Add (no buffs)", "Remove"], buttonTexts);
// Nothing else — no toggle, no slider, and no label reads a
// MossTank-only status/notice property.
Assert.Empty(itemsGroup.Elements("toggle"));
Assert.Empty(itemsGroup.Elements("slider"));
string[] labelBindings = itemsGroup.Elements("label")
.Select(static l => (string?)l.Attribute("text") ?? string.Empty)
.ToArray();
Assert.DoesNotContain("{MonsterEquipmentText}", labelBindings);
Assert.DoesNotContain("{RefillWornManaText}", labelBindings);
Assert.DoesNotContain("{ItemManaRechargeStatus}", labelBindings);
Assert.DoesNotContain("{ProfileNotice}", labelBindings);
}
/// <summary>
/// Round F item 6 (resemblance re-check nits: "Buffs' 136 px middle
/// gap and the orphaned Coverage label"). Coverage sits right under
/// the Buff button row instead of anchored to the group's bottom
/// edge, which used to leave a 55px empty hole above it as the panel
/// grew.
///
/// The middle-gap half has a hard geometric ceiling documented at
/// mosstank.xml's own Buffs comment: Extra Buff Spells never moves
/// (x=4..324) but Blacklisted Buff Families tracks the growing right
/// edge (anchor="right"), sitting at x=524 at the panel's own tested
/// FLOOR (856x236) and x=652 (its authored declaration below) at the
/// 984-wide enlarged default — a 128px swing this campaign's own
/// Round D item 4 introduced. A single FIXED-position strip can only
/// be centered relative to ONE of those two widths; centering
/// against the 984 default would put the strip's right edge INSIDE
/// where the list sits at the floor — a real overlap
/// ResolvedMainPanelHasNoOverlapOrOutOfBoundsChildAtThisSize's own
/// 856x236 case would catch. This pins centering against the FLOOR
/// value instead (declared x minus the 128px Round D growth delta —
/// 984-856) — the widest safe position a fixed strip can take,
/// verified against the real-file resolved tree at both sizes by
/// <c>MossTankMarkupBuildOverRealFilesTests.BuffsExtrasStripStaysCenteredAndNonOverlappingAtTheFloor</c>
/// in the App test project.
/// </summary>
[Fact]
public void BuffsExtrasStripIsCenteredAtThePanelsFloorAndCoverageSitsUnderTheButtonRow()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
XElement buffsGroup = root.Elements("group")
.Single(static g => (string?)g.Attribute("visible") == "{BuffsVisible}");
XElement[] lists = buffsGroup.Elements("list").ToArray();
Assert.Equal(2, lists.Length);
float leftListRightEdge = Number(lists[0], "x") + Number(lists[0], "w");
// Round D item 4's own +128 growth: the declared x below is the
// 984-wide authored default; subtracting it back out gives the
// resolved position at the panel's tested floor (856x236).
const float roundDGrowthDelta = 984f - 856f;
float rightListLeftEdgeAtFloor = Number(lists[1], "x") - roundDGrowthDelta;
XElement trainedToggle = buffsGroup.Elements("toggle")
.Single(static t => (string?)t.Attribute("onclick") == "{ToggleTrainedOnly}");
XElement rebuffUpButton = buffsGroup.Elements("button")
.Single(static b => (string?)b.Attribute("onclick") == "{RebuffUp}");
float extrasLeft = Number(trainedToggle, "x");
float extrasRight = Number(rebuffUpButton, "x") + Number(rebuffUpButton, "w");
float leftMargin = extrasLeft - leftListRightEdge;
float rightMarginAtFloor = rightListLeftEdgeAtFloor - extrasRight;
Assert.True(
rightMarginAtFloor >= 0f,
$"Extras strip (right edge {extrasRight}) overlaps the "
+ $"Blacklisted Buff Families list at the panel's floor "
+ $"(left edge {rightListLeftEdgeAtFloor}).");
Assert.True(
MathF.Abs(leftMargin - rightMarginAtFloor) <= 4f,
$"Extras strip not centered at the panel's floor: left margin "
+ $"{leftMargin}px, right margin {rightMarginAtFloor}px.");
XElement buffButton = buffsGroup.Elements("button")
.Single(static b => (string?)b.Attribute("onclick") == "{Buff}");
XElement coverage = buffsGroup.Elements("label")
.Single(static l => (string?)l.Attribute("text") == "{Coverage}");
float rowBottom = Number(buffButton, "y") + Number(buffButton, "h");
float coverageGap = Number(coverage, "y") - rowBottom;
Assert.True(
coverageGap is >= 0f and <= 12f,
$"Coverage sits {coverageGap}px below the Buff button row — "
+ "expected <=12px, not the old 55px hole from anchoring it to "
+ "the group's bottom edge instead.");
}
/// <summary>
/// Round F item 6 (resemblance re-check: "Loot Editor: drop the
/// leftover 'Back' button — the window closes from its title bar
/// like the others"). The standalone button is gone;
/// CloseLootEditor stays a real, bound property (still called by
/// ToggleLootEditorVisible, the Profiles-tab checkbox's own close
/// path).
/// </summary>
[Fact]
public void LootEditorHasNoLeftoverBackButton()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank-loot-editor.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
Assert.DoesNotContain(
root.Elements("button"),
static b => (string?)b.Attribute("text") == "Back");
Assert.NotNull(typeof(MossTankPanel).GetProperty("CloseLootEditor"));
}
[Fact]
public void AuthoredShellFitsTheMinimumCanvasAndEverySizedChildFitsItsParent()
{
@ -201,11 +404,318 @@ public sealed class MossTankMarkupContractTests
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
Assert.Equal(800f, Number(root, "w"));
Assert.Equal(244f, Number(root, "h"));
// Round D item 4 (owner: "The size of the entire window needs to
// be enlarged for default and should also be resizeable"): the
// authored default grew ~15% in both directions from the S7.1/
// fix-round-A proportion (856x236, still the panel's own
// minw/minh floor — see PanelIsResizableFlooredAtThePreRoundDAuthoredSize
// below) to 984x271. See mosstank.xml's own Round D item 4
// panel-level comment for the exact delta math (128 wide, 35
// tall) every tab group and stretching list shares.
Assert.Equal(984f, Number(root, "w"));
Assert.Equal(271f, Number(root, "h"));
AssertWithinParent(root);
}
/// <summary>
/// Round D item 4: the panel itself is resizable, floored at the
/// PRE-round-D authored size (856x236) — a drag-resize or a
/// persisted-layout restore must never shrink it past what every tab
/// was actually authored/tested at.
/// </summary>
[Fact]
public void PanelIsResizableFlooredAtThePreRoundDAuthoredSize()
{
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
Assert.Equal("true", (string?)root.Attribute("resizable"));
Assert.Equal(856f, Number(root, "minw"));
Assert.Equal(236f, Number(root, "minh"));
}
/// <summary>
/// Round D item 4's own contract pin: every list this round calls out
/// as "should grow" (Monsters, Items, Consumables x2, Buffs x2, Route,
/// Meta) declares a real anchor — not the silent "left top" default,
/// which would leave it a fixed size forever regardless of how far the
/// user drags the window. Doesn't prescribe WHICH edges (Monsters/Meta
/// stretch both axes; the others grow height-only, pinned to whichever
/// horizontal edge a sibling list/button column doesn't block — see
/// mosstank.xml's own Round D item 4 comment for why per list).
/// </summary>
[Fact]
public void EveryStretchingListDeclaresARealAnchor()
{
(string GroupVisible, int ExpectedListCount)[] stretchingListGroups =
[
("MonstersVisible", 1),
("ItemsVisible", 1),
("ConsumablesVisible", 2),
("BuffsVisible", 2),
("RouteVisible", 1),
("MetaVisible", 1),
];
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root);
foreach ((string groupVisible, int expectedListCount) in stretchingListGroups)
{
XElement group = root.Elements("group")
.Single(g => (string?)g.Attribute("visible") == $"{{{groupVisible}}}");
XElement[] lists = group.Elements("list").ToArray();
Assert.Equal(expectedListCount, lists.Length);
foreach (XElement list in lists)
{
string? anchor = (string?)list.Attribute("anchor");
Assert.False(
string.IsNullOrEmpty(anchor),
$"A <list> in the {groupVisible} tab has no anchor attribute — "
+ "it will never grow with the window.");
}
}
}
/// <summary>
/// Fix round C item D3: direct pin on <see cref="AssertWithinParent"/>'s
/// height check, independent of any real markup file. A
/// <c>&lt;label y="190"&gt;</c> with no declared <c>h</c> inside a
/// 194-tall group clips 12px past the bottom edge once the label's
/// real 16px default row height is counted (190 + 16 = 206 > 194) —
/// before this fix, an absent <c>h</c> read as zero height and the
/// check silently passed.
/// </summary>
[Fact]
public void AssertWithinParent_CatchesAnUnsizedLabelNearTheBottomEdge()
{
var group = new XElement("group",
new XAttribute("w", "848"), new XAttribute("h", "194"),
new XElement("label", new XAttribute("x", "4"), new XAttribute("y", "190"),
new XAttribute("text", "Notice")));
Assert.Throws<Xunit.Sdk.TrueException>(() => AssertWithinParent(group));
}
/// <summary>
/// Fix round B item 2: <see cref="AssertWithinParent"/> only ever proved
/// a control fits inside its OWN parent's declared bounds — it never
/// caught two SIBLINGS overlapping each other while each individually
/// still fit. That's exactly how the Buffs tab's Difficulty "+" button
/// (338,108,26,20) ended up drawn on top of the Extra Buff Spells
/// "Add..." button (336,102,120,16) before this fix round, and how the
/// Options tab's "Pet Min. Monsters:" label (614,48,125,16) overlapped
/// the very field it labels (734,48,40,16) by 5px. This walks every
/// container in every plugin panel file and asserts no two positioned
/// children overlap — except two <c>&lt;group&gt;</c> elements (VTank
/// tab pages are mutually exclusive via their own <c>visible="{...}"</c>
/// binding, so sharing the same rectangle is by design) and
/// <c>&lt;column&gt;</c> (Campaign VT slice 1's <c>&lt;list&gt;&lt;column&gt;</c>
/// children have no <c>x</c>/<c>y</c> grammar at all — see
/// docs/plugin-ui-markup.md's "Columns" — so every column reads as
/// (0,0) and would trivially "overlap" every other column). Fix round B
/// item 13: the file list is discovered (Directory.GetFiles) rather than
/// named one by one, so a new mosstank-*.xml popup is covered for free.
/// </summary>
[Fact]
public void AuthoredControlsInTheSameContainerNeverOverlapASibling()
{
foreach (string path in Directory.GetFiles(AppContext.BaseDirectory, "mosstank*.xml"))
{
XDocument document = XDocument.Load(path);
XElement root = Assert.IsType<XElement>(document.Root);
AssertNoSiblingOverlap(root);
}
}
/// <summary>Direct pin on the overlap PREDICATE itself (independent of
/// any real markup file): two rectangles that truly overlap must be
/// caught, touching-but-not-overlapping edges must not be a false
/// positive, and two sibling &lt;group&gt;s sharing one rectangle (the
/// normal VTank-tab-page shape) must be ignored.</summary>
[Fact]
public void AssertNoSiblingOverlap_CatchesARealOverlapAndIgnoresGroupPagesAndTouchingEdges()
{
var overlapping = new XElement("panel",
new XAttribute("w", "848"), new XAttribute("h", "194"),
new XElement("button", new XAttribute("x", "336"), new XAttribute("y", "102"),
new XAttribute("w", "120"), new XAttribute("h", "16")),
new XElement("button", new XAttribute("x", "338"), new XAttribute("y", "108"),
new XAttribute("w", "26"), new XAttribute("h", "20")));
Assert.Throws<Xunit.Sdk.TrueException>(() => AssertNoSiblingOverlap(overlapping));
var touchingEdges = new XElement("panel",
new XAttribute("w", "848"), new XAttribute("h", "194"),
new XElement("button", new XAttribute("x", "0"), new XAttribute("y", "0"),
new XAttribute("w", "100"), new XAttribute("h", "20")),
new XElement("button", new XAttribute("x", "100"), new XAttribute("y", "0"),
new XAttribute("w", "100"), new XAttribute("h", "20")));
AssertNoSiblingOverlap(touchingEdges); // must not throw
var twoGroupPages = new XElement("panel",
new XAttribute("w", "848"), new XAttribute("h", "236"),
new XElement("group", new XAttribute("x", "8"), new XAttribute("y", "42"),
new XAttribute("w", "848"), new XAttribute("h", "194")),
new XElement("group", new XAttribute("x", "8"), new XAttribute("y", "42"),
new XAttribute("w", "848"), new XAttribute("h", "194")));
AssertNoSiblingOverlap(twoGroupPages); // must not throw
}
private static void AssertNoSiblingOverlap(XElement container)
{
XElement[] children = container.Elements()
.Where(static child => child.Name.LocalName != "column")
.ToArray();
for (int i = 0; i < children.Length; i++)
{
for (int j = i + 1; j < children.Length; j++)
{
XElement a = children[i], b = children[j];
if (a.Name.LocalName == "group" && b.Name.LocalName == "group")
continue;
Assert.True(
!RectanglesOverlap(a, b),
$"<{a.Name}> text='{(string?)a.Attribute("text")}' @ "
+ $"({Number(a, "x")},{Number(a, "y")},{Number(a, "w")},{Number(a, "h")}) "
+ $"overlaps sibling <{b.Name}> text='{(string?)b.Attribute("text")}' @ "
+ $"({Number(b, "x")},{Number(b, "y")},{Number(b, "w")},{Number(b, "h")}).");
}
}
foreach (XElement child in children)
AssertNoSiblingOverlap(child);
}
private static bool RectanglesOverlap(XElement a, XElement b)
{
float aw = Number(a, "w"), ah = Number(a, "h");
float bw = Number(b, "w"), bh = Number(b, "h");
if (aw <= 0f || ah <= 0f || bw <= 0f || bh <= 0f)
return false; // an element with no declared size never "occupies" space
float ax = Number(a, "x"), ay = Number(a, "y");
float bx = Number(b, "x"), by = Number(b, "y");
return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah;
}
/// <summary>
/// Fix round A (2026-09-07): the Advanced Options and Loot Editor popups
/// moved out of mosstank.xml into their own plugin panel files. This
/// pins that every secondary popup file still parses, still declares
/// VTank's own popup footprint (docs/research/vtank-kb/08-ui-views.md
/// §1's secondary-view table), every child fits its own declared bounds,
/// and every markup binding still resolves against a real
/// <see cref="MossTankPanel"/> property — the same three guarantees
/// <see cref="VtankTabOrderAndEveryBindingResolveAgainstTheLivePanel"/>
/// and <see cref="AuthoredShellFitsTheMinimumCanvasAndEverySizedChildFitsItsParent"/>
/// give the main panel, now split across multiple files instead of one.
/// Fix round C item F3: the file list used to be a hardcoded
/// <c>[InlineData]</c> set that drifted the moment a popup was added or
/// renamed without a matching row here. It is now discovered with the
/// exact same glob the two "covers a new popup for free" tests already
/// use (<see cref="AuthoredControlsInTheSameContainerNeverOverlapASibling"/>,
/// <see cref="NoButtonAnywhereUsesTheUnrenderableArrowGlyphs"/>),
/// excluding mosstank.xml itself (the main panel, covered by the two
/// tests cited above). A newly discovered popup with no entry in
/// <see cref="ExpectedPopupBounds"/> fails loudly, naming the file,
/// instead of being silently skipped.
/// </summary>
private static readonly Dictionary<string, (float Width, float Height)> ExpectedPopupBounds =
new(StringComparer.OrdinalIgnoreCase)
{
// Round D item 2: removing the Apply/Back button row (retail
// has neither) reclaimed 26px, shrinking the panel 476->450.
// Round F item 1 (owner's second live look: "The whole bottom
// window of advanced options should not be there") deleted the
// "Editing X." notice label and the entire "MossTank Extras"
// section below it, shrinking the popup back to VTank's real
// AdvancedOptionsView footprint (392x300,
// docs/research/vtank-kb/08-ui-views.md §1) — 450->300.
["mosstank-advanced.xml"] = (392f, 300f),
["mosstank-loot-editor.xml"] = (268f, 300f),
["mosstank-buffpicker.xml"] = (268f, 236f),
["mosstank-metaeditor.xml"] = (630f, 160f),
};
[Fact]
public void SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves()
{
PropertyInfo[] properties = typeof(MossTankPanel).GetProperties(
BindingFlags.Instance | BindingFlags.Public);
var byName = properties.ToDictionary(
static property => property.Name,
StringComparer.Ordinal);
string[] popupFileNames = Directory.GetFiles(AppContext.BaseDirectory, "mosstank*.xml")
.Select(static path => Path.GetFileName(path)!)
.Where(static name => !string.Equals(
name, "mosstank.xml", StringComparison.OrdinalIgnoreCase))
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
Assert.NotEmpty(popupFileNames);
foreach (string fileName in popupFileNames)
{
Assert.True(
ExpectedPopupBounds.TryGetValue(fileName, out (float Width, float Height) expected),
$"{fileName} has no expected width/height entry in "
+ $"{nameof(ExpectedPopupBounds)} — add one (VTank's own "
+ "secondary-view footprint from docs/research/vtank-kb/"
+ "08-ui-views.md §1) before this popup can be trusted.");
XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, fileName));
XElement root = Assert.IsType<XElement>(document.Root);
Assert.Equal(expected.Width, Number(root, "w"));
Assert.Equal(expected.Height, Number(root, "h"));
AssertWithinParent(root);
foreach (XAttribute attribute in root.DescendantsAndSelf().Attributes())
{
string value = attribute.Value;
if (!value.Contains('{', StringComparison.Ordinal))
continue;
Assert.Matches("^\\{[^{}]+\\}$", value);
string name = value[1..^1];
Assert.True(
byName.ContainsKey(name),
$"Markup binding {value} on <{attribute.Parent?.Name}> in "
+ $"{fileName} has no public MossTankPanel property.");
}
}
}
/// <summary>
/// Fix round A: neither popup's own "↑"/"↓" reorder buttons (Loot
/// Editor's MoveLootRuleUp/Down) render as blank slots — the retail
/// default UI font (0x40000000) has no glyph for those Unicode
/// characters, so they now carry the same DAT move-up/move-down icons
/// (0x060028FC/0x060028FD) the Monsters grid already uses, and no
/// <c>&lt;button&gt;</c> anywhere is left with the literal arrow text.
/// </summary>
[Fact]
public void NoButtonAnywhereUsesTheUnrenderableArrowGlyphs()
{
// Fix round B item 13: discovered file list (Directory.GetFiles)
// instead of a named-one-by-one array, so a new mosstank-*.xml
// popup is covered for free.
foreach (string path in Directory.GetFiles(AppContext.BaseDirectory, "mosstank*.xml"))
{
string fileName = Path.GetFileName(path);
XDocument document = XDocument.Load(path);
XElement root = Assert.IsType<XElement>(document.Root);
foreach (XElement button in root.Descendants("button"))
{
string? text = (string?)button.Attribute("text");
Assert.False(
text is "↑" or "↓",
$"<button> in {fileName} still uses the unrenderable "
+ $"'{text}' glyph instead of a DAT icon.");
}
}
}
[Fact]
public void TextlessAndAbbreviatedControlsHaveAccessibleRetailTooltips()
{
@ -224,6 +734,16 @@ public sealed class MossTankMarkupContractTests
element.Name.LocalName,
StringComparer.Ordinal)))
{
// Campaign VT S7.3: <column> has no text/tooltip grammar at all
// (docs/plugin-ui-markup.md's "Columns" section — a column's
// attribute set is items/values/onclick/onchange/iconkind, never
// text or tooltip). Every column's "caption" is the header
// <label> placed above the <list> instead, which — being a
// plain label, not one of InteractiveElementNames — this loop
// never visits; the Monsters grid's own header labels carry
// real tooltip text for the abbreviated ones (see mosstank.xml).
if (element.Name.LocalName == "column")
continue;
string? text = (string?)element.Attribute("text");
if (!string.IsNullOrWhiteSpace(text) && !terse.Contains(text))
continue;
@ -259,7 +779,7 @@ public sealed class MossTankMarkupContractTests
foreach (XElement child in parent.Elements())
{
float width = Number(child, "w");
float height = Number(child, "h");
float height = EffectiveHeight(child);
if (width > 0f)
{
Assert.True(
@ -276,6 +796,38 @@ public sealed class MossTankMarkupContractTests
}
}
/// <summary>
/// Fix round C item D3: an element's declared <c>h</c> only tells the
/// whole story for widget kinds that never rely on an implicit
/// default. <c>&lt;label&gt;</c>/<c>&lt;field&gt;</c> without <c>h</c>
/// still occupy one DAT-font text row (16px) at runtime, and
/// <c>&lt;toggle&gt;</c> still occupies the checkbox lamp's own 20px
/// footprint — <see cref="AssertWithinParent"/> used to treat any of
/// those as zero-height (h absent) and silently pass an unsized notice
/// label positioned right up against a group's bottom edge, which
/// actually clips in the running client. <c>&lt;button&gt;</c> has no
/// single canonical default (real buttons in this markup range from
/// 16 to 25px and always declare h explicitly), so 16 — the same
/// floor as label/field — is used defensively; <c>&lt;list&gt;</c>/
/// <c>&lt;menu&gt;</c> have no implicit default at all and keep the
/// old "0 when absent" behavior.
/// </summary>
private static float EffectiveHeight(XElement element)
{
float declared = Number(element, "h");
if (declared > 0f)
return declared;
return element.Name.LocalName switch
{
"label" => 16f,
"field" => 16f,
"toggle" => 20f,
"button" => 16f,
_ => 0f,
};
}
private static float Number(XElement element, string attribute) =>
float.TryParse(
(string?)element.Attribute(attribute),

File diff suppressed because it is too large Load diff

View file

@ -436,7 +436,7 @@ public sealed class NavigationTests
ObjectName = "Portal",
Text = "/say hello",
DurationMilliseconds = 1234,
Recall = RouteRecallKind.SecondaryPortal,
Recall = RouteRecallKind.SecondaryPortalRecall,
JumpHeadingDegrees = 271.5f,
JumpRun = true,
JumpChargeMilliseconds = 875,
@ -567,6 +567,72 @@ public sealed class NavigationTests
Assert.Single(target.Waypoints);
}
/// <summary>
/// D-1 (round E architecture re-check): the pre-cutover
/// RouteRecallKind was {Lifestone=0, Marketplace=1, PrimaryPortal=2,
/// SecondaryPortal=3} — completely different kinds at the SAME
/// ordinals as today's much larger enum (PrimaryPortalRecall=0,
/// SecondaryPortalRecall=1, LifestoneRecall=2, LifestoneSending=3,
/// ...). Before this fix, deserializing a legacy JSON route's bare
/// "Recall" integer straight into the new enum type silently remapped
/// every one of the four old ordinals to the WRONG new recall (and
/// Enum.IsDefined never caught it, since 0..3 are all still valid new
/// members). Each of the four legacy ordinals must migrate to its
/// real VTank recall.
///
/// Migration writes the converted route straight to the real .af file
/// (<c>MigrateLegacyIfNeeded</c>), and <c>LoadCurrent</c> immediately
/// re-parses THAT file to build the returned <see cref="NavigationSettings"/>
/// — so asserting against the re-parsed waypoint would really be
/// testing the .af "rcl" node's own name-only round-trip (a pre-
/// existing, unrelated limitation: it carries no spell id at all, and
/// "Marketplace Recall" can never resolve through ANY catalog because
/// it is not a real spell — see <see cref="RecallWaypointForMarketplaceSubmitsTheSlashCommandNotACast"/>),
/// not this fix. The direct, catalog-independent proof that
/// <c>MapLegacyRecall</c> chose the right kind is the WRITTEN .af
/// text itself: its "rcl" line carries exactly
/// <see cref="RouteWaypoint.RecallDisplayName"/> for that kind.
/// </summary>
[Theory]
[InlineData(0, (int)RouteRecallKind.LifestoneRecall)] // old Lifestone
[InlineData(1, (int)RouteRecallKind.Marketplace)] // old Marketplace
[InlineData(2, (int)RouteRecallKind.PrimaryPortalRecall)] // old PrimaryPortal
[InlineData(3, (int)RouteRecallKind.SecondaryPortalRecall)] // old SecondaryPortal
public void LegacyJsonRouteMigratesOldRecallOrdinalToTheRightNewKind(
int legacyOrdinal,
int expectedKindOrdinal)
{
// xunit only discovers PUBLIC [Theory] methods, and RouteRecallKind
// is internal — a public parameter of that type is a compile error
// (CS0051), so InlineData passes ordinals (public ints) and the
// enum is recovered here instead (matching
// RecallNameAndSpellIdTablesAgree's own pattern above).
var expectedKind = (RouteRecallKind)expectedKindOrdinal;
var storage = new MemoryStorage();
string legacyKey = LegacyRouteByCharacterKey("Barris");
storage.Text[legacyKey] = $$"""
{
"Mode": 1,
"Waypoints": [
{ "Type": 2, "EastWest": 1.0, "NorthSouth": 2.0, "Recall": {{legacyOrdinal}} }
]
}
""";
var store = new MossTankRouteProfileStore(new FakeHost(new FakeAutomation(), storage));
Assert.True(store.BindCharacter("Barris"));
var target = new NavigationSettings();
Assert.True(store.LoadCurrent(target, MetafSerializer.NoOpSpells.Instance));
RouteWaypoint waypoint = Assert.Single(target.Waypoints);
Assert.Equal(RouteWaypointType.Recall, waypoint.Type);
string fileName = "navs/" + VtankProfileDirectory.AutoCharacterFileName(
"Barris", string.Empty, "af");
Assert.True(storage.Text.TryGetValue(fileName, out string? af));
Assert.Contains($"{{{RouteWaypoint.RecallDisplayName(expectedKind)}}}", af);
}
/// <summary>
/// Reproduces MossTankRouteProfileStore's pre-cutover named-profile JSON
/// hash key (its own <c>LegacyProfileKey</c> is private; the format is
@ -806,6 +872,203 @@ public sealed class NavigationTests
Assert.Equal("Leader", target.FollowTargetName);
}
// ── Round D item 3: RouteRecallKind's full VTank table ──────────────
/// <summary>
/// The exact order VTank's own cmbRecallType combo lists its 26 real
/// recall spells (metaf's own NRecall table,
/// docs/plans/2026-09-07-campaign-vt-slice7-tabs.md's Round D task
/// list), with Marketplace Recall appended last (the one entry with
/// no spell — see RouteRecallKind's own doc comment for why the old
/// Lifestone slash-command member is gone instead of duplicated).
/// </summary>
[Fact]
public void RouteRecallKindListsVTanksTwentySixRecallsInOrderPlusMarketplaceLast()
{
Assert.Equal(
[
"PrimaryPortalRecall", "SecondaryPortalRecall", "LifestoneRecall",
"LifestoneSending", "PortalRecall", "RecallAphusLassel",
"RecallTheSanctuary", "RecallToTheSingularityCaul", "GlendenWoodRecall",
"AerlintheRecall", "MountLetheRecall", "UlgrimsRecall", "BurRecall",
"ParadoxTouchedOlthoiInfestedAreaRecall", "CallOfTheMhoireForge",
"ColosseumRecall", "FacilityHubRecall", "GearKnightInvasionAreaCampRecall",
"LostCityOfNeftetRecall", "ReturnToTheKeep", "RynthidRecall",
"ViridianRiseRecall", "ViridianRiseGreatTreeRecall",
"CelestialHandStrongholdRecall", "RadiantBloodStrongholdRecall",
"EldrytchWebStrongholdRecall", "Marketplace",
],
Enum.GetNames<RouteRecallKind>());
}
/// <summary>
/// The name<->id table both ways: every non-Marketplace kind's display
/// name (RecallDisplayName) round-trips back to the SAME kind via its
/// spell id (RecallSpellId is exposed nowhere to parse by name, so
/// this proves the two lookups agree with each other rather than one
/// silently drifting).
/// </summary>
[Theory]
[InlineData((int)RouteRecallKind.PrimaryPortalRecall, "Primary Portal Recall", 48u)]
[InlineData((int)RouteRecallKind.SecondaryPortalRecall, "Secondary Portal Recall", 2647u)]
[InlineData((int)RouteRecallKind.LifestoneRecall, "Lifestone Recall", 1635u)]
[InlineData((int)RouteRecallKind.LifestoneSending, "Lifestone Sending", 1636u)]
[InlineData((int)RouteRecallKind.PortalRecall, "Portal Recall", 2645u)]
[InlineData((int)RouteRecallKind.RecallAphusLassel, "Recall Aphus Lassel", 2931u)]
[InlineData((int)RouteRecallKind.RecallTheSanctuary, "Recall the Sanctuary", 2023u)]
[InlineData((int)RouteRecallKind.RecallToTheSingularityCaul, "Recall to the Singularity Caul", 2943u)]
[InlineData((int)RouteRecallKind.GlendenWoodRecall, "Glenden Wood Recall", 3865u)]
[InlineData((int)RouteRecallKind.AerlintheRecall, "Aerlinthe Recall", 2041u)]
[InlineData((int)RouteRecallKind.MountLetheRecall, "Mount Lethe Recall", 2813u)]
[InlineData((int)RouteRecallKind.UlgrimsRecall, "Ulgrim's Recall", 2941u)]
[InlineData((int)RouteRecallKind.BurRecall, "Bur Recall", 4084u)]
[InlineData((int)RouteRecallKind.ParadoxTouchedOlthoiInfestedAreaRecall,
"Paradox-touched Olthoi Infested Area Recall", 4198u)]
[InlineData((int)RouteRecallKind.CallOfTheMhoireForge, "Call of the Mhoire Forge", 4128u)]
[InlineData((int)RouteRecallKind.ColosseumRecall, "Colosseum Recall", 4213u)]
[InlineData((int)RouteRecallKind.FacilityHubRecall, "Facility Hub Recall", 5175u)]
[InlineData((int)RouteRecallKind.GearKnightInvasionAreaCampRecall,
"Gear Knight Invasion Area Camp Recall", 5330u)]
[InlineData((int)RouteRecallKind.LostCityOfNeftetRecall, "Lost City of Neftet Recall", 5541u)]
[InlineData((int)RouteRecallKind.ReturnToTheKeep, "Return to the Keep", 4214u)]
[InlineData((int)RouteRecallKind.RynthidRecall, "Rynthid Recall", 6150u)]
[InlineData((int)RouteRecallKind.ViridianRiseRecall, "Viridian Rise Recall", 6321u)]
[InlineData((int)RouteRecallKind.ViridianRiseGreatTreeRecall, "Viridian Rise Great Tree Recall", 6322u)]
[InlineData((int)RouteRecallKind.CelestialHandStrongholdRecall, "Celestial Hand Stronghold Recall", 6325u)]
[InlineData((int)RouteRecallKind.RadiantBloodStrongholdRecall, "Radiant Blood Stronghold Recall", 6327u)]
[InlineData((int)RouteRecallKind.EldrytchWebStrongholdRecall, "Eldrytch Web Stronghold Recall", 6326u)]
[InlineData((int)RouteRecallKind.Marketplace, "Marketplace Recall", 0u)]
public void RecallNameAndSpellIdTablesAgree(int kindOrdinal, string name, uint spellId)
{
// xunit only discovers PUBLIC [Theory] methods, and RouteRecallKind
// is internal — a public parameter of that type is a compile error
// (CS0051), so InlineData passes the ordinal (a public int) and the
// enum is recovered here instead.
var kind = (RouteRecallKind)kindOrdinal;
Assert.Equal(name, RouteWaypoint.RecallDisplayName(kind));
Assert.Equal(spellId, RouteWaypoint.SpellIdForRecall(kind));
}
/// <summary>
/// Round D item 3 execution test: a recall waypoint whose spell id is
/// non-zero must actually issue the cast for that id (owner: "they do
/// not work in routes yet") — the FakeAutomation's Magic is now a
/// tracking fake instead of NoOpAutomationSurface's always-refuse
/// stub, so this is the first real coverage of the recall action
/// dispatch path.
/// </summary>
[Fact]
public void RecallWaypointWithNonZeroSpellIdCastsThatSpell()
{
var magic = new FakeMagic();
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d)),
Magic = magic,
};
var waypoint = new RouteWaypoint
{
Type = RouteWaypointType.Recall,
Recall = RouteRecallKind.AerlintheRecall,
RecallSpellId = RouteWaypoint.SpellIdForRecall(RouteRecallKind.AerlintheRecall),
RecallSpellName = RouteWaypoint.RecallDisplayName(RouteRecallKind.AerlintheRecall),
Position = Position(0d, 0d),
};
NavigationController controller = Controller(automation, RouteMode.Once, waypoint);
Assert.True(controller.Tick(0.1d, canAct: true));
Assert.Contains(2041u, magic.CastSpellIds);
}
/// <summary>
/// Marketplace has no spell (RecallSpellId stays 0) — it must still
/// dispatch through the chat command, exactly as it did before this
/// round, never attempt a cast.
/// </summary>
[Fact]
public void RecallWaypointForMarketplaceSubmitsTheSlashCommandNotACast()
{
var magic = new FakeMagic();
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d)),
Magic = magic,
};
var waypoint = new RouteWaypoint
{
Type = RouteWaypointType.Recall,
Recall = RouteRecallKind.Marketplace,
RecallSpellId = RouteWaypoint.SpellIdForRecall(RouteRecallKind.Marketplace),
RecallSpellName = RouteWaypoint.RecallDisplayName(RouteRecallKind.Marketplace),
Position = Position(0d, 0d),
};
NavigationController controller = Controller(automation, RouteMode.Once, waypoint);
Assert.True(controller.Tick(0.1d, canAct: true));
Assert.Empty(magic.CastSpellIds);
Assert.Contains("/marketplace", automation.SubmittedChat);
}
/// <summary>
/// D-4 (round E architecture re-check): an .af "rcl" node whose spell
/// name did not resolve against the character's known spells leaves
/// RecallSpellId at 0 and Recall at its default ordinal
/// (RouteRecallKind.PrimaryPortalRecall, MetafSerializer.cs:883-885
/// only ever assigns RecallSpellName/RecallSpellId, never the enum).
/// Before this fix SubmitRecall's Recall-based fallback silently cast
/// Primary Portal Recall (spell 48) for a waypoint that was never
/// authored as one. It must instead refuse the cast, name the
/// unresolved spell in the status notice, and skip the waypoint (the
/// same "refuse and continue" shape TickUse already uses for an
/// unavailable object).
/// </summary>
[Fact]
public void RecallWaypointWithUnresolvedSpellNameRefusesAndSkipsWithoutCasting()
{
var magic = new FakeMagic();
var automation = new FakeAutomation
{
NavigationSnapshot = Snapshot(Position(0d, 0d)),
Magic = magic,
};
var waypoint = new RouteWaypoint
{
Type = RouteWaypointType.Recall,
// Recall left at its default (PrimaryPortalRecall) — exactly
// what MetafSerializer.ReadNavNode's "rcl" case leaves it at
// when the imported name fails to resolve.
RecallSpellId = 0u,
RecallSpellName = "NotARealSpell",
Position = Position(0d, 0d),
};
NavigationController controller = Controller(automation, RouteMode.Once, waypoint);
Assert.True(controller.Tick(0.1d, canAct: true));
Assert.Empty(magic.CastSpellIds);
Assert.Empty(automation.SubmittedChat);
Assert.Contains("NotARealSpell", controller.Status);
// The waypoint was skipped (not retried): the Once route's only
// waypoint is gone, so the very next tick reports completion.
Assert.False(controller.Tick(0.1d, canAct: true));
Assert.Equal("Once route complete.", controller.Status);
}
private sealed class FakeMagic : IMagicCommands
{
public List<uint> CastSpellIds { get; } = [];
public bool IsCasting => false;
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Refused;
public bool Cast(uint spellId)
{
CastSpellIds.Add(spellId);
return true;
}
}
private static NavigationController Controller(
FakeAutomation automation,
RouteMode mode,
@ -877,7 +1140,9 @@ public sealed class NavigationTests
public bool IsAvailable => true;
public ICharacterInfo Character => NoOpAutomationSurface.Instance;
public ISpellCatalog Spells => NoOpAutomationSurface.Instance;
public IMagicCommands Magic => NoOpAutomationSurface.Instance;
// Round D item 3: settable so a recall-execution test can inject a
// tracking fake instead of NoOpAutomationSurface's always-false Cast.
public IMagicCommands Magic { get; set; } = NoOpAutomationSurface.Instance;
public IPluginChat Chat => this;
public IItemAutomation Items => this;
public INavigationAutomation Navigation => this;