AdvancedOptionNames, AdvancedOptionValueColumn, AdvancedOptionName, and
AdvancedOptionDescription each re-ran FilteredAdvancedOptionNames() (a
Where over the full 163-entry VtankOptionCatalog) on EVERY retained-UI
draw of the popup, and the value column additionally ran
GetMetaOption(name).ToDisplayString() per row (Trim()/ToLowerInvariant()
allocations) every frame. All four are now materialized once into fields
by a new RefreshAdvancedOptions(), called from every real mutation point:
category toggle, edit/apply, selection change, and profile
load/create/clear/delete (via ResetProfileConsumers), plus construction.
Same pattern fix round B item 12 used for the Monsters/Meta/Route grids.
AdvancedOptionValueColumnMirrorsTheLiveSettingValue exercised a settings
change made OUTSIDE the popup's own mutators (ToggleCombatEnabled, a
main Options-tab checkbox) and expected the value column to reflect it
on the very next read — a real behavior this caching model intentionally
narrows (mirrored settings now catch up at the next real popup mutator,
not on every read). Updated the test to re-select the row afterward,
exercising the "selection change" refresh point, and documented why.
Mutation named: reverted the two property getters to their old live-
recomputing form and confirmed the new
AdvancedOptionsPopupBindingsDoNotReallocateOnEveryRead pin fails
(Assert.Same throws — different array instances per read) before
restoring the fix.
MossTank suite 676 -> 677 (one new pin). Full solution build green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three small, unrelated fixes bundled per the fix round's own item list
(the fourth, cropping the three popup screenshots to the panel, belongs to
the live-capture pass, item 17):
- SortedCombatItemNames(): one shared helper for the ordinal-sorted
registered weapon roster, replacing five separate identical
`_combatSettings.CombatItemNames.OrderBy(...).ToArray()` call sites
(RefreshItemEditors, DeleteItemRowAtCore, CycleItemHandsAtCore,
RemoveSelectedItemCore, CycleMonsterEquipmentAt).
- SelectTab already cleared LootEditorVisible/AdvancedOptionsVisible when
the user switched tabs (fix round A). The buff picker (S7.4) and the
Meta rule editor (item 5) are the same "own popup, own window" shape and
were missing from that guard — switching away from Buffs or Meta while
either popup was open left it orphaned, open over whatever tab the user
switched to. Both flags now clear in SelectTab too.
- <slider min max>: max<=min used to silently fall back to a range of 1
(the old `range == 0f` check, covering only max==min) or produce a
slider whose drag direction is inverted from its declared range
(max<min, never caught at all). Both are now a build-time
FormatException, the same rule every other <slider>/<menu>
attribute-format check in this file already follows.
New tests: SwitchingTabsClosesTheBuffPickerAndMetaEditorPopups,
Build_SliderWithMaxLessThanOrEqualToMin_Throws (both max<min and
max==min cases). Mutation checks: removing the two SelectTab clears
turned the first red ("Expected: False, Actual: True"); reverting the
slider validation to the old range==0f fallback turned both Theory cases
of the second red ("No exception was thrown"). Both restored to green.
tests/AcDream.Plugins.MossTank.Tests: 676/676 (was 675/675, +1).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 285/3 skipped/288 (was 283/3/286, +2: the new Theory's 2 cases).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every existing MossTank markup pin (MossTankMarkupContractTests) validates
mosstank*.xml against MossTankPanel through reflection alone — "does a
public property with this name and this CLR type exist" — never through
MarkupDocument.Build itself, the code that actually mounts a plugin panel
at runtime. Build has its own validation a reflection-only check can't
see (attribute-format exceptions, delegate-shape mismatches, numeric-
attribute parsing, column-type dispatch). Before this test, a markup bug
of that shape would throw inside RetailUiRuntime.MountPlugins's own
try/catch and the panel would simply not appear — no test failure, no
visible error short of a live client screenshot.
New MossTankMarkupBuildOverRealFilesTests (AcDream.App.Tests) builds every
real mosstank*.xml file against a REAL MossTankPanel with a stub
IPluginHost (same shape as MossTankMarkupContractTests.StubHost) and a
fake sprite resolver. Required: a real compile-time ProjectReference from
AcDream.App.Tests to AcDream.Plugins.MossTank (test-only exception to the
plugin/host boundary — AcDream.App itself never links this assembly, see
its own ReferenceOutputAssembly=false copy target) plus
InternalsVisibleTo("AcDream.App.Tests") on the plugin project (MossTankPanel
is internal). The test project also globs mosstank*.xml from the plugin's
source directory into its own output, same pattern item 13 established.
This test caught a REAL bug on its first run: mosstank-advanced.xml's new
lFilterList (item 9's category checklist) had no selected="..." attribute.
MarkupDocument.BindRequiredIntReader throws "list selected must be an int
binding" when a <list>'s selected attribute is missing at all — a pure
checklist with no real "selected row" concept still needs one to satisfy
the markup grammar. Fixed by adding SelectedAdvancedOptionCategoryIndex
(always -1; item 10 already removed row-selection-band rendering for
every column grid, so this drives no visible highlight) and wiring
selected="{SelectedAdvancedOptionCategoryIndex}" onto that list.
tests/AcDream.Plugins.MossTank.Tests: 675/675 (unchanged — this item's
scope is entirely App-side plus one real bug fix invisible to MossTank's
own reflection-only pins).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 283/3 skipped/286 (was 278/3/281, +5: the new Theory's 5 file cases).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ExtraBuffSpellNames/BlacklistedBuffFamilyNames (Buffs tab, Campaign VT
S7.4) were never captured by SideCarDocument (the active JSON persistence
alongside the real VTank .usd) — a restart or profile switch silently
dropped them. BuffPlan.Build still does not READ either set when choosing
what to cast — that remains a real, separately tracked wiring gap (see
BuffSettings' own doc comments); this only fixes the storage/display
honesty the owner asked for.
- SideCarDocument gained BuffExtraSpellNames/BuffBlacklistedFamilyNames
(string[]), captured via the same Sorted() helper CombatItemNames/
ConsumableNames use, and applied via the same Replace() clear-then-
repopulate helper — a profile switch can't carry a stale entry over from
whichever profile was loaded before.
- PickBuffAtCore and DeleteExtraBuffAt/DeleteBlacklistedBuffFamilyAt now
call SaveProfile(), matching every other Add/Delete mutator in this file
— without this the newly-wired capture/apply would only fire
opportunistically on some unrelated save.
- Reworded both tab tooltips ("Named spell exemplars added beyond the
school-driven picks" / "Named buff families never cast, even if
otherwise wanted") to state plainly that the lists are stored and shown
but not yet used for casting, instead of implying they already affect
cast selection.
- (False start, reverted: an earlier pass edited LegacyBuffProfileDocument,
which the file's own comment marks migration-only dead code that nothing
else writes any more — the real fix belongs in SideCarDocument, the
active format SaveCurrent/LoadCurrent actually round-trip.)
New test: ExtraBuffAndBlacklistedFamilyNamesPersistAcrossSessions (add via
the picker in one panel, confirm both survive in a second panel sharing
the same storage). Mutation check: commenting out the two Replace() calls
in SideCarDocument.Apply turned it red ("Expected: [Spell 1], Actual: []");
restoring them turns it green.
tests/AcDream.Plugins.MossTank.Tests: 675/675 (was 674/674, +1).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 278/3 skipped/281 (unchanged).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every retained-UI draw re-evaluates every markup-bound column property.
Several of these did real per-row work on EVERY frame instead of returning
a cached field:
- ExcludedComponentIcons (Consumables) called ResolveComponentIcon per row,
which itself calls IAutomation.Items.CaptureOwnedItems() — a live
inventory snapshot — per row per frame.
- All 23 Monsters-grid columns (14 flag columns via MonsterFlagColumn, 7
text columns, 2 move-icon columns) re-ran a LINQ .Select(...).ToArray()
over _combatSettings.Rules every frame; Weapon/Offhand additionally
called ItemDisplayName (another CaptureOwnedItems() scan) per row.
- Meta's MetaStateColumn/MetaConditionColumn/MetaActionColumn/
MetaDeleteColumn/MetaMoveUpIcons/MetaMoveDownIcons and Route's
RouteWaypointCountColumn allocated a fresh array with LINQ every frame.
Fixed by materializing each into a field, computed once at the point of
actual mutation:
- ExcludedComponentIcons: computed in RefreshItemEditors (already the sole
owner of _excludedComponentRows).
- RouteWaypointCountColumn: computed in RefreshRouteEditor (already called
at 13 real mutation points, including profile load).
- Meta's 6 columns: computed in RefreshMetaEditor (already called at every
real mutation point, including profile load).
- Monsters' 23 columns: new RefreshMonsterEditor, wired into the 4 actual
Rules-mutating methods (AddMonsterRuleCore, DeleteMonsterRuleAtCore,
MoveMonsterRuleAtCore, UpdateMonsterActionsAt — the last already covers
every flag toggle and value-cycle action) plus panel construction and
ResetProfileConsumers (profile load/switch). The now-unused
MonsterFlagColumn helper is removed.
New tests (FakeAutomation gained a CaptureOwnedItemsCallCount counter):
ExcludedComponentIconsDoesNotScanLiveInventoryOnEveryRead,
MonsterGridColumnsDoNotScanLiveInventoryOrAllocateOnEveryRead (also asserts
Assert.Same across reads), MetaAndRouteGridColumnsDoNotReallocateOnEveryRead.
All three mutation-checked by temporarily reverting to the old per-read
computation: each turned red (call-count mismatch or Assert.Same failure)
against the reverted code; restoring the cached-field getters turns each
green.
tests/AcDream.Plugins.MossTank.Tests: 674/674 (was 671/671, +3 new tests).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 278/3 skipped/281 (unchanged — App-layer surface untouched by this item).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Plugin markup's <slider> always drew RetailScrollbarChrome's sprite
track/thumb, the same "big gold DAT art next to a plain plugin panel"
mismatch <menu style> already fixed for dropdowns. <slider style="..."> now
uses the identical plain/retail grammar (ValidateArtStyle, renamed and
generalized from the menu-only ValidateMenuStyle): plain (the default)
draws a flat dark track, 1px border, and a small flat nub via new
UiScrollbar.RetailArt=false + DrawPlainScalar — no SpriteResolve dependency
at all; style="retail" keeps RetailScrollbarChrome.ApplyHorizontal exactly
as before. RetailArt defaults to true on UiScrollbar itself, so every
non-plugin caller of this widget (retail LayoutDesc import, chat opacity
sliders, etc.) is byte-for-byte unaffected — only <slider>'s own
MarkupDocument case sets it false by default.
Every existing MossTank <slider> (Vitals' nine sliders, Buffs, Items'
Refill Worn Mana) has no style attribute, so they all switch to the plain
look automatically — consistent with the whole campaign's "no gold art"
direction, no XML changes needed.
Fixed a red pin this change created: Slider_MinMax_DrawsTheThumbAtTheRescaledNormalizedPosition
asserted the retail sprite thumb on a slider with no style attribute, which
now builds plain by default — opted it into style="retail" (same fix
shape as item 1's menu-scroll pin) and added a plain sibling,
Slider_NoStyleAttribute_DrawsAPlainFlatNubAtTheRescaledNormalizedPosition.
Mutation check: hardcoding DrawPlainScalar's horizontal nub x to 0 turned
the new plain test red ("expected a plain flat nub offset right of the
origin at 25%"); restoring the real ScalarPosition-driven x turns it green.
Documented <slider style> in docs/plugin-ui-markup.md, mirroring the
existing <menu style> paragraph.
tests/AcDream.Plugins.MossTank.Tests: 671/671 (unchanged — pure App-layer
rendering change, MossTank markup only sets no/default style).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 278/3 skipped/281 (was 277/3/280, +1 new test).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VVS's own HudList grids (Monsters/Meta/Route/Items and every other
<list><column> grid this codebase has, present and future) have no
row-selection highlight at all — only the per-cell click callbacks are a
real VTank concept. UiMarkupList.DrawColumns painted a SelectedColor band
under the selected row anyway (a holdover from the legacy single-column
list path, which keeps its own band unchanged — that's a plain list, not a
VVS grid). Removed the band draw from DrawColumns only; SelectedIndexSource
still drives scroll-into-view, and every onclick/onchange callback is
untouched.
New test: ColumnGrids_NeverDrawARowSelectionBand (a column list with a
real in-range selected index must never paint SelectedColor). Mutation
check: before the fix this test was RED against the real code
("expected no SelectedColor fill in a column-based grid"); after removing
the band draw it's green.
tests/AcDream.Plugins.MossTank.Tests: 671/671 (unchanged — this is an
App-layer fix, MossTank markup only consumes the existing <column>
grammar).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 277/3 skipped/280 (was 276/3/279, +1 new test).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VTank's own AdvancedOptionsView (docs/research/vtank-kb/08-ui-views.md §1's
secondary-view table): lOptionList is clOpt(180)+clVal(62) two columns, not
a name-only list; lFilterList (268,4,120,180) is a category checklist
(check+text columns) filtering it; txtInfo (4,188,384,80) is a description
readout.
- lOptionList is now a real 2-column <list><column> grid: name (clOpt,
PITCH 187) + live value (clVal, PITCH 69, AdvancedOptionValueColumn via
the existing GetMetaOption/.ToDisplayString() pipeline).
- lFilterList genuinely filters lOptionList: VtankDefaultSettingsDatabase
now parses VTank's own SettingsCategories table (136 rows straight from
the embedded .usd, 2 columns: Setting name + Categories bitmask) into
VtankOptionCatalog.CategoryBits (9 distinct bits, 0x01-0x100). A setting
with no recorded bitmask always shows regardless of filter state. This
worktree has no refs/vtank/ checkout, so VTank's own category NAME
strings for those 9 bits aren't available anywhere in this repo — the
checklist honestly labels each by its raw bit value ("0x04" etc.)
instead of a guessed name.
- AdvancedOptionDescription surfaces VTank's own real Settings.Description
column (93 of 137 rows non-empty, genuine retail help text — e.g.
DoHelp's own wording about fellowship healing) prefixed with the option
name, filling the exact 384x80 readout and replacing the old bare
name-only label.
- SelectAdvancedOption/AdvancedOptionName now resolve through the FILTERED
list (not the raw 137-row catalog) so selection stays correct as the
filter narrows/widens it.
- Apply/Back move beside each other below the two lists to make room;
popup height grows 392 -> 476 for the taller lists + description block,
pushing item 8's "MossTank Extras" section further down (unchanged
internally).
New tests: AdvancedOptionCategoryFilterHidesNonMatchingSettings (unchecking
every category but EnableLooting's own 0x100 hides EnableNav but keeps
EnableLooting; re-checking restores the full list),
AdvancedOptionDescriptionSurfacesRealRetailHelpText,
AdvancedOptionValueColumnMirrorsTheLiveSettingValue. All three mutation-
checked: disabling FilteredAdvancedOptionNames's filter predicate, the
description's name-prefix, and the value column's GetMetaOption call each
turned their test red; restoring each turns it green.
tests/AcDream.Plugins.MossTank.Tests: 671/671 (was 668/668, +3).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 276/3 skipped/279 (unchanged).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Transcribed VTank's own Route tab almost verbatim (docs/research/
vtank-kb/08-ui-views.md §1 "Tab: Route", 20 controls, +4px left margin for
our own convention): the waypoint list, a bottom row (nav-type menu +
insert-mode menu + three nav image buttons), and a right-hand 2-across
button grid in VTank's own row order (Add/Open Vendor; Add Portal-NPC/Add
NPC Talk; Add Recall+its recall-type menu; Add Pause+field+"seconds"
label; Add Chat+field).
- cmbNavInsertMode is now a real 3-option <menu> (RouteInsertMode.AddToEnd/
InsertAbove/InsertBelow, new enum in Navigation.cs) instead of the old
2-state ToggleRouteAddPosition button — InsertAbove/InsertBelow are now
genuinely distinct (before vs. after the selected waypoint), not both
collapsed into "not append".
- The three nav image buttons (advance/regress/nearest-point, DAT ids
0x060028FD/0x060028FC/0x060011F7) move from the waypoint-actions row to
the bottom row beside the nav-type menu, matching VTank's own layout.
- "Use NPC" is recaptioned "Add NPC Talk" (VTank's own cmdNavUseNPC text),
same AddRouteUseSelected binding.
- The pause duration is now a real editable field
(RoutePauseSecondsFieldText, default "5", parsed/clamped 0-3600 by
SetRoutePauseSecondsText) plus a "seconds" label, matching VTank's own
txtPauseWaypointTime/Label52 — replaces the RoutePauseDown/RoutePauseUp
stepper (removed).
- MossTank-only controls with no VTank Route-tab counterpart — Checkpoint,
Jump, Remove, Set Follow Target + its status label, Follow Corners, Open
Doors, 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 field) — move to
mosstank-advanced.xml's new "MossTank Extras" section (popup height
300 -> 392 to fit them without touching the retail editor above).
New tests: RouteInsertModeControlsWhereANewWaypointLands (AddToEnd/
InsertAbove/InsertBelow each produce the correct insertion index),
RoutePauseSecondsFieldParsesAndClampsInput. Both mutation-checked:
hardcoding AddRouteWaypoint's insertion to always-append turned the first
red ("Point: (0N, 0E)" instead of containing "99"); removing the Math.Clamp
in SetRoutePauseSecondsText turned the second red ("99999" instead of the
clamped "3600"). Both restored to green.
EveryInteractiveControlDeclaresARealHandlerBinding: 166 -> 156 (mosstank.xml
Route tab 28 -> 18 controls; the 10 relocated/removed controls are either
gone or moved into mosstank-advanced.xml, a separate file this scan
doesn't cover).
SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves: mosstank-
advanced.xml's expected height 300 -> 392.
tests/AcDream.Plugins.MossTank.Tests: 668/668 (was 666/666, +2 new tests).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 276/3 skipped/279 (unchanged).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Retail ground truth (PluginCore.cs:7683-7700): a click on the Consumables
tab's left list removes that row directly — there is no select-then-press-
Remove step for that list, unlike our previous SelectConsumableRow which
only updated the selection index. SelectConsumableRow now sets the index
AND immediately calls RemoveSelectedConsumableCore, matching the "click
removes" convention the right-hand Excluded Scarab Types list already
uses; the "Remove" button (RemoveSelectedConsumable) stays as a second
path, same as Items/Buffs/Route. Retitled the list's tooltip to match
("Click a row to remove it.").
New test: ConsumablesLeftListRowClickRemovesTheRowDirectly. Mutation
check: commenting out the RemoveSelectedConsumableCore() call turned it
red ("The collection contained 2 items" instead of Assert.Single); restoring
it turns it green.
tests/AcDream.Plugins.MossTank.Tests: 666/666 (was 665/665, +1).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 276/3 skipped/279 (unchanged).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VTank's own Meta tab (docs/research/vtank-kb/08-ui-views.md §1 "Tab: Meta")
is only 5 controls: the lstMetaRules grid, cmdMetaCreate ("Create"), a bold
"Current State:" caption, and a SETTABLE cmbMetaCurrentState choice — no
profile toolbar, no "Enable Meta" checkbox, no inline editor at all.
- Removed from the Meta tab entirely: the profile menu/name-draft field/
New/CopyTo/Clear/Delete row (Profiles already carries the Meta combo/
CopyTo/Delete) and the "Enable Meta" toggle (already on Options) — same
accepted-regression shape as fix round A's Macro/Nav CopyTo-name-field
loss (no Profiles-tab equivalent exists for these, and VTank's own tab
has none either).
- Moved the inline State/Condition/Action editor (fields, condition/action
menus, numeric steppers, Apply/Remove/MoveUp/MoveDown) into a new popup,
mosstank-metaeditor.xml, registered the same StartVisible=true/
ShowInSidePanel=false way as the buff picker. Opened by a grid text-cell
click (SelectMetaRule, still populating the draft via SelectMetaRuleCore)
or the tab's own "Create" button (CreateMetaRule — new, distinct from the
still-existing AddMetaRule the tests call directly); Apply and Cancel
(HideMetaEditor) both close it. DeleteMetaRuleAt/MoveMetaRuleUpAt/
MoveMetaRuleDownAt call SelectMetaRuleCore directly and do NOT open the
popup.
- "Add" -> "Create": CreateMetaRule adds a default rule (delegating to the
existing AddMetaRuleCore) and opens the editor so it isn't left silently
default-valued.
- Added the settable current-state menu: MetaCurrentStateNames/
SelectedMetaCurrentState/SetMetaCurrentState expose MetaEngine's own
already-public States/Transition(string) — choosing a state here forces
the live engine into it, matching VTank's own manual override.
- The grid returns to VTank's full-width 856x116 proportion (848x116 here)
now nothing else shares the tab.
- Bold text isn't representable in the plain retail UI font (0x40000000 has
no bold face); "Current State:" uses the same bright caption color other
tab headers use instead — documented in mosstank.xml's own comment, not
silently dropped.
New tests: MetaEditorPopupOpensOnCellClickOrCreateAndClosesOnApplyOrCancel,
MetaCurrentStateMenuForcesTheLiveEngineIntoTheChosenState. Both mutation-
checked: commenting out SelectMetaRule's `_metaEditorVisible = true` turned
the first red ("Expected: True, Actual: False"); commenting out
SetMetaCurrentState's `_meta.Transition(value)` turned the second red
("Expected: Hunt, Actual: Default"). Restoring both turns them green.
EveryInteractiveControlDeclaresARealHandlerBinding's pinned control count:
186 -> 166 (-7 controls removed for good, -14 moved into the new popup file
this scan doesn't cover, +1 the new current-state menu).
SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves gained
mosstank-metaeditor.xml (630x236... — 630x160, corrected below).
tests/AcDream.Plugins.MossTank.Tests: 665/665 (was 663/663, +2 new tests).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 276/3 skipped/279 (unchanged).
Screenshot with at least two rules (item 17) is owed with the round's other
live captures.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reordered ahead of items 5-12 on purpose: item 5 adds a fourth popup
(mosstank-metaeditor.xml) and would otherwise need the same three-file
manual edit this item retires. Every place a mosstank-*.xml file used to be
named one at a time now globs mosstank*.xml instead, so adding a new popup
panel needs exactly one new file, not four matching edits:
- AcDream.Plugins.MossTank.csproj: <None Update="mosstank*.xml"> replaces
four separate <None Update> entries.
- AcDream.Plugins.MossTank.Tests.csproj: one globbed <None Include> with
Link="%(Filename)%(Extension)" replaces four separate Include/Link pairs.
- AcDream.App.csproj: a new _MossTankPluginMarkup item
(Include="…/mosstank*.xml") replaces the literal four-file semicolon list
in both CopyMossTankPluginToBuildOutput and …ToPublishOutput's Copy
SourceFiles.
- MossTankMarkupContractTests: AuthoredControlsInTheSameContainerNeverOverlapASibling
(item 2's new pin) and NoButtonAnywhereUsesTheUnrenderableArrowGlyphs now
iterate Directory.GetFiles(AppContext.BaseDirectory, "mosstank*.xml")
instead of a hardcoded 4-file array/Theory. Left
SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves alone — it
pairs each file with its OWN expected w/h, which a glob can't supply.
- LinuxPlatformBoundaryTests.ShippedPluginCopiesUseResolvedTargetPathsForBuildAndPublish
now asserts on the glob pattern instead of the literal mosstank.xml
substring the old Copy SourceFiles list contained.
A missed file in any of these four places used to silently drop a panel at
mount (fix round A's own #missing-popup-files near-miss) instead of failing
the build; the glob makes that failure mode structurally impossible.
tests/AcDream.Plugins.MossTank.Tests: 662/662 (665 -> 662: the 4-case
Theory collapsed into 1 Fact with an internal loop — same coverage, 3 fewer
reported xunit tests).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 276/3 skipped/279 (unchanged).
Verified the glob actually copies all four files: `ls
src/AcDream.App/bin/Release/net10.0/plugins/AcDream.Plugins.MossTank/*.xml`
lists all four post-build.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner's silhouette rule: VTank's own controls are never shrunk/moved/
reordered to make room for MossTank extras. The Buffs tab's Difficulty "+"
button (338,108,26,20) overlapped the Extra Buff Spells "Add..." button
(336,102,120,16) because both VTank lists had been narrowed to 246/256x84
to coexist with MossTank's toggle/difficulty/rebuff/Buff-cast-now block in
the same two columns.
Fix: restore both lists to VTank's real 320x116 at the far left (x=4) and
far right (x=524) with their own 120-wide "Add..." row below each list.
Move the MossTank-only extras block (7 toggles, Difficulty +/-, Rebuff +/-,
the Buff button+status, Coverage) into the empty 184px middle strip between
the two lists that VTank's own layout never uses. Coverage moves to y=178
(194-16) per the upcoming bottom-fit rule.
Also fixed: a second real overlap the new pin found on the Options tab —
"Pet Min. Monsters:" (614,48,125,16) overlapped its own field
(734,48,40,16) by 5px; narrowed the label to the same 120px column width
every other label/field pair in that block uses.
Added AuthoredControlsInTheSameContainerNeverOverlapASibling (4 file cases)
plus a direct predicate pin (AssertNoSiblingOverlap_CatchesARealOverlap...)
proving the helper catches a real overlap, ignores touching edges, and
ignores two <group> tab-pages sharing one rectangle (mutually exclusive via
their own visible="{...}" binding) — the existing AssertWithinParent only
ever checked a child against its OWN parent's bounds, never against a
sibling.
Mutation check: setting DifficultyUp's button to (472,112) — the same rect
as DifficultyDown — turned AuthoredControlsInTheSameContainerNeverOverlapASibling
red against the real mosstank.xml ("<button> text='-' ... overlaps sibling
<button> text='+' ..."); reverting turns it green.
tests/AcDream.Plugins.MossTank.Tests: 665/665 (was 660/660, +5: the new
theory's 4 file cases + 1 predicate fact).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 276/3 skipped/279 (unchanged).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Menu_Scroll_DrawsAScrollbarWhenTheMarkupItemCountOverflowsTheVisibleRows was
RED at HEAD: <menu> now defaults to the plain style (RetailButtonArt=false,
the "BIG gold/yellow buttons" owner fix), so a markup menu with no style
attribute draws DrawScrollablePopupPlain's flat DrawFill thumb, never the
retail sprite path (DrawPopupScrollbar) the test asserted on.
Fix: give the existing test style="retail" so it keeps proving the retail
sprite path draws ScrollThumbSprite. Add a plain sibling
(Menu_Scroll_DrawsAPlainFlatThumbFillWhenTheMarkupItemCountOverflowsTheVisibleRows)
that proves the DEFAULT (no style attribute) path draws the flat thumb fill:
an untextured (texture=0) quad sized ScrollbarWidth-2 wide, tinted
PlainBorderColor. Untextured DrawFill calls all batch into one texture=0
render segment, so the assertion scans per-quad (6 verts x 8 floats) inside
each segment rather than treating a whole segment as one quad.
Mutation check: commenting out DrawPopupScrollbarPlain's thumb DrawFill call
turned the new test red ("expected a plain flat thumb fill... among the
drawn segments"); restoring the call turns it green again.
tests/AcDream.Plugins.MossTank.Tests: 660/660 (unchanged).
tests/AcDream.App.Tests --filter Markup|Plugin|UiMenu|Slider: 276 passed / 3
skipped / 279 total (was 274 passed / 1 failed / 3 skipped / 278 total).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VTank's own lstMetaRules grid (delete/move-up/move-down icon-ish cells +
State/Condition/Action text cells, docs/research/vtank-kb/08-ui-views.md
§1 "Tab: Meta"; PluginCore.cs:2218-2247) replaces the padded
single-column rules list: any State/Condition/Action cell click opens
the rule editor below (reusing the existing SelectMetaRule), the
up/down cells reorder using the same 0x060028FC/FD DAT icons the
Monsters and Route grids already established, and the delete cell is a
plain text "X" — no retail DAT delete-glyph id is confirmed anywhere in
this codebase (unlike the already-established move icons), and "X" is
ASCII the default retail font renders without the unrenderable-glyph
risk NoButtonAnywhereUsesTheUnrenderableArrowGlyphs guards against.
The new pin (contract control count 180->186) and the new panel test
were shown to fail against a targeted mutation before being confirmed
green. MossTank suite 659 -> 660; App markup/plugin filter holds 192/192.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VTank's own clWP/clWPc 2-column waypoint grid (text + a 1-based position
counter standing in for VTank's own count column) replaces the padded
single-column list, with any-cell-click delete matching
PluginCore.cs:3576-3599. Also: the recall menu gets scroll="true" for
grammar parity (still 4 real recall kinds — see the deviation note
below); the nav-type menu now shows VTank's own "Follow" caption for the
Target mode via a pure display remap (SelectedRouteMode/SelectRouteMode
translate the string, RouteMode.Target itself is unchanged, zero
behavior change); and a third nav image button (icon 0x060011F7, "Select
Nearest Point") is added.
Deviations, documented at their own binding site:
- VTank's cmbRecallType lists 27 named retail recalls; MossTank's
RouteRecallKind models 4 (Lifestone/Marketplace/Primary/Secondary
Portal). Expanding to 27 needs real per-recall spell-id data, which is
casting-algorithm behavior out of this UI-parity slice's scope.
- VTank's btnNavResetPoint reassigns the LIVE navigation cursor mid-route
(a mutable index MossTank's navigation controller never exposes to a
plugin); SelectNearestRouteWaypoint instead moves the tab's own EDIT
selection to the closest waypoint by horizontal distance — real,
testable UI-only behavior that stays out of live-navigation territory.
The new pin (contract control count 177->180) and the new panel test
were shown to fail against a targeted mutation before being confirmed
green. MossTank suite 658 -> 659; App markup/plugin filter holds 192/192.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VTank's real Items (2-col name/hands), Consumables (adds the right-hand
"Excluded Scarab Types" icon+text grid), and Buffs (Extra Buff Spells /
Blacklisted Buff Families lists + a shared SelfBuffChoiceView-style
picker popup) tabs are transcribed from docs/research/vtank-kb/
08-ui-views.md §1, replacing the old single-column-list adaptations with
real per-cell grids and VTank's own click-to-delete/cycle semantics
(PluginCore.cs:8529-8562 Items, :7683-7776 Consumables, :7323-7355 Buffs).
Deviations, documented at their own binding site:
- Items' Hands column has no backing wieldable-handedness data anywhere
in the plugin surface, so handedness is session-local UI state only
(not persisted across profile save/load) — same "deliberate adaptation"
shape as S7.3's weapon-roster substitution for Monsters' Weapon/Offhand.
- Consumables' "Add Selected" accepts any selected owned item rather than
requiring VTank's own SpellComponent object-class check (no classifier
surface exists for plugins) — the added token is still the item's real
Name, which SpellComponentPolicy already matches against.
- Buffs' ExtraBuffSpellNames/BlacklistedBuffFamilyNames (BuffPlan.cs) add
storage + UI only; wiring them into BuffPlan.Build's cast selection is
real casting-algorithm behavior, out of this UI-parity slice's scope
(tracked in the slice 7 plan ledger as a real, accepted gap for a
future Campaign VT behavior slice).
Every new/changed pin (contract control count 167->177, the new
mosstank-buffpicker.xml popup pin, the three new MossTankPanelTests
interaction tests) was shown to fail against a targeted mutation before
being confirmed green. MossTank suite 654 -> 658; App markup/plugin
filter holds 192/192.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner live-client report 2026-09-07 ("Drop down menus look horrible, there
is also a checkmark on the text there"): the prior S7 fix only replaced the
CLOSED-state button face when RetailButtonArt=false. The OPEN popup still
drew retail's sprite art unconditionally — the tan/orange gradient panel
(PopupBgSprite), the per-row highlight sprite (which bakes a checkbox/
checkmark glyph into its leftmost ~17px, per TextIndent's doc comment), and
the ornate gold scrollbar chrome — regardless of the menu's style.
UiMenu.OnDrawOverlay now branches on RetailButtonArt before ever reading
SpriteResolve: plain mode draws through two new methods (DrawGridPopupPlain,
DrawScrollablePopupPlain) that use only DrawFill/DrawRectOutline — flat
background + 1px border, the current entry filled like a list selection
(PlainSelectedColor, same value as UiMarkupList.SelectedColor), a new
hover fill (PlainHoverColor) for the row under the pointer, and left-aligned
text at PlainPadding. No checkmark is possible by construction since plain
mode never resolves or draws any sprite. Hover tracking needed a small new
mechanism: UiMenu.ReceivesHoverMouseMove now returns true while a plain
popup is open, so UiRoot's hover dispatch keeps delivering MouseMove to
_hoveredPopupIndex (reset on every open/close transition and on
HoverLeave). Scrollbar overflow (DrawPopupScrollbarPlain) draws a 1px-
bordered track and a flat thumb, both in PlainBorderColor, sharing the
exact UiScrollbar.ThumbRect geometry the hit-test math already uses — no
DAT track/thumb/arrow-button art. Hit-testing (OnHitTest/OnEvent's
MouseDown pick logic) is untouched; the retail sprite branch is now a
separate, unmodified path proven byte-identical by a new golden test.
Mutation proof: reverting UiMenu.cs alone (keeping the new tests) fails the
build outright — the six new tests reference PlainSelectedColor/
PlainHoverColor, which only exist after this change (CS1061 with the old
class). Filters run: AcDream.App.Tests Markup|UiMenu|Menu|Scrollbar
(242 passed, 3 pre-existing unrelated Lane=Manual live-DAT-probe failures
that require ACDREAM_PROBE_LIVE_MOUNT=1 and predate this change) and
AcDream.Plugins.MossTank.Tests Markup (9/9 passed).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner's live-client report (2026-09-07 08:05): "The Options tab labels
overlap, fix that" and "Those BIG gold/yellow buttons HAS to go" (the
gold-button half already landed upstream at cfa703065, merged into this
branch first). This commit is the geometry half of that fix round plus
the lead's 07:55 read (docs/plans/2026-09-07-campaign-vt-slice7-tabs.md).
Grid scaling: measured acdream's actual retail DAT font (0x40000000) via
AcDream.Cli's dump-font-atlas against a real installed DAT rather than
guessing — MaxCharHeight=16 matches VVS's own assumed row height exactly,
so no vertical scale was needed, but several captions render wider than
VVS's narrow font assumed ("Follow/Nav Min Distance:" measures 134px real
width vs an 80px-wide retail label column). <label>/<toggle> never clip or
use their declared "w" (UiLabel.OnDraw/UiMarkupToggle.OnDraw draw raw,
unclipped text), so the fix is a plain rightward SHIFT of the columns after
the overflowing captions rather than a width change: Options' columns 2-5
move +62px (90/220/386/552/611 -> 152/282/448/614/673, with the rightmost
Advanced Options/Run Macro/status sub-block also shrinking its declared
width 233->175 to stay inside the group's 848 budget), Profiles' columns
2-5 move +16px (140/280/330/440/530 -> 156/296/346/456/546). Both are pure
translations, so every original inter-column gap is preserved exactly.
Vitals needed no shift (its worst caption already clears its slider by 3px)
but got a small +4px safety margin anyway since it was the tightest
already-OK case measured.
Profiles leftovers deleted (owner + lead, "legacy controls"): the three
stacked ProfileNameDraft/RouteProfileNameDraft/MetaProfileNameDraft
field+"New"-button rows (three 20-tall fields at zero vertical gap that
rendered as one solid black bar behind three stacked "New" buttons — the
owner's screenshot), the acdream-only "Loot engine" classifier row
(no VTank equivalent), and the ProfileLifecycleNotice label (the raw
macro-profile recovery path the owner saw rendered). Deviation: Macro/Nav
CopyTo lose their only in-UI target-name entry point (Meta's naming
already exists on the Meta tab itself, so only Macro/Nav are affected) —
an accepted trade for matching VTank's own Profiles table, which has no
name-draft control at all (VVS's own combo is presumably editable
in-place; acdream's <menu> is not). LootStatus is kept (general feedback,
not itself a reported leftover) but repositioned into the freed space.
Window: Advanced Options and Loot Editor move out of mosstank.xml into
their own top-level plugin panels (mosstank-advanced.xml,
mosstank-loot-editor.xml, MossTankPlugin.cs registers a second and third
AddPanel with ShowInSidePanel=false since VTank's own popups have no
shelf entry). No new IUiRegistry show/hide API was needed: a root <panel>
already supports a data-bound visible="{Prop}" attribute
(MarkupDocument.Build re-evaluates VisibleSource every frame, proven by
the main panel's own visible="{WindowAvailable}"), so each popup's own
visible="{AdvancedOptionsVisible}"/"{LootEditorVisible}" is the SAME flag
the Options/Profiles checkbox flips and the popup's own "Back" button
clears — closing the popup unchecks the box for free. The main window
returns to VTank's 210-ish proportion (236 = 42px header/tab-strip +
194px per-tab content, both unchanged since S7.1) with no dead space
below the tabs. The eight tab-content *Visible getters (MossTankPanel.cs)
drop their `&& !_lootEditorVisible`/`&& !_advancedOptionsVisible` guards,
which only existed because the popups used to share screen space with the
tab content in the same window.
Trailing blank button slots (Route waypoint reorder, Meta rule reorder,
Loot Editor rule reorder): the retail default UI font (0x40000000) has no
glyph for U+2191/U+2193, so the "↑"/"↓" text buttons rendered as blank
slots (owner's screenshot). Replaced with the same DAT move-up/move-down
icons (0x060028FC/0x060028FD) the Monsters grid's own reorder columns
already use.
No remaining <menu style="retail"> in any of the three files (verified).
Pins updated (shown to fail against the old numbers first): the markup
contract's interactive-control count (202 -> 167, the two popups leaving
the file plus the Profiles deletions) and the authored-shell height pin
(350 -> 236). Added SecondaryPopupPanelsFitTheirOwnBoundsAndEveryBindingResolves
(parses both new files, checks their declared 392x300/268x300 footprint,
AssertWithinParent, and every markup binding resolves) and
NoButtonAnywhereUsesTheUnrenderableArrowGlyphs (no <button text="↑"|"↓">
survives anywhere) as new coverage for this fix round.
MossTank suite 651 -> 654 (the two new SecondaryPopupPanels theory cases
+ NoButtonAnywhereUsesTheUnrenderableArrowGlyphs); App markup/plugin/menu
filter stays 237/237.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner 2026-09-07: plugin output and 'Unknown command' land in the chat
scroll, not the SpewBox. Plugin text = Decal/VTank-faithful; the
unknown-command re-route is recorded as AD-124 (retail types it 0x1A).
Bad-argument refusals of real retail commands stay SpewBox-only (AP-183).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings cfa703065's owner-driven fix (plugin <menu> renders VTank's plain
flat combo by default; style="retail" opts back into the gold pushbutton
art) into the S7.1-S7.3 window/tabs work so the fix round below can build
on both. Resolved conflicts: the ledger keeps both worktrees' entries in
chronological order (07:55/08:05 owner-read entries appended after this
worktree's own S7.1/S7.2/S7.3 entries); plugin-ui-markup.md's <menu> row
lists both new attributes (scroll, style); MarkupDocumentTests.cs keeps
both new test blocks (slider min/max + menu scroll from this worktree,
menu style from latest-main-sync).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner direction 2026-09-07 (same instruction as the sibling unknown-
command fix, previous commit): plugin-originated text must land in
the chat window instead of retail's ClientLocal (0x1A) SpewBox-only
channel. AppAutomationSurface.PostSystemMessage -- the production
implementation of IPluginChat.PostSystemMessage that MossTank/VTank-
style plugins call -- now passes RetailLogTextType.Default instead of
ClientLocal to RuntimeCommunicationState.AddText, so the text reaches
the chat transcript via Chat.OnSystemMessage instead of the SpewBox.
This matches Decal's own AddChatText behavior for plugin output.
IPluginChat.PostSystemMessage's doc comment is updated to describe
the new destination instead of the old one. Register row AD-124
(previous commit) already covers this site alongside the sibling
unknown-command change.
Mutation check: temporarily reverted PostSystemMessage's AddText call
back to ClientLocal and confirmed the new
AppAutomationSurfaceTests.PostSystemMessage_RoutesToChatLog_NeverSpewBox
test fails (Assert.Single() on an empty chat log) before restoring the
fix. Also adds ChatVMTests.RecentLines_ShowsPluginSystemMessage_TaggedDefault
pinning that a ChatVM bound to the same ChatLog surfaces the line.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner direction 2026-09-07 (verbatim): "Unknown commands like /vt or
stuff from plugins shall now go to the SpewBox. They should go to the
chatbox." Retail itself types ChatCommandRouter's "Unknown command"
refusals as ClientLocal (0x1A) -- the bit every ChatInterface window's
default filter excludes, so they only ever reached the transient
SpewBox overlay and left no transcript record.
Three call sites in ChatCommandRouter.Submit/EmitVerbHelp now call
IChatCommandFeedback.ShowSystemMessage (chat scroll, retail
Default/0x00) instead of ShowInterfaceText (SpewBox): the degenerate-
prefix "Unknown command: {verb}." guard, EmitVerbHelp's confirmed-
null-help branch, and EmitVerbHelp's unresolved-verb fallback. Every
OTHER 0x1A refusal in this file (AP-183 bad-argument refusals of REAL
retail commands -- lifestone, marketplace, channel list/on/off,
allegiance, house, the generic HandleFailureEvent(0x26) fallback,
DoStupidChannelHack, DoReply) is unchanged and still SpewBox-only --
the owner named only unknown commands and plugin text.
This is a deliberate deviation from retail's own 0x1A typing, recorded
as register row AD-124 (also covers the sibling plugin-text change in
a follow-up commit). docs/ISSUES.md #363/#367 get a one-line note
under each pointing at the re-route; their CLOSED status is untouched.
Mutation check: temporarily reverted all three ShowSystemMessage call
sites back to ShowInterfaceText and confirmed the 3 new/changed pinned
tests fail (Assert.Single() on an empty chat log) while the AP-183
boundary test (real command, bad args, still SpewBox) continues to
pass -- see ChatCommandRouterFeedbackRoutingTests.cs and the updated
ChatCommandRouterTests.cs/RetailCommandHelpTableTests.cs assertions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Campaign VT slice 7 (docs/plans/2026-09-07-campaign-vt-slice7-tabs.md),
S7.3 (Monsters). Owner's bar: looks basically the same as VTank and
behaves the same from the player's seat.
The pre-S7.3 Monsters tab was a select-then-edit adaptation (one
padded-text list + a separate expression/priority/damage-menu/equip
editor row + 14 standalone toggles) forced by acdream's earlier
single-column-only <list>. Campaign VT slice 1 Part B already closed
that gap (the <list><column> markup extension, docs/plugin-ui-markup.md
"Columns"), so this slice replaces the whole tab body with VTank's own
one 23-column HudList grid (docs/research/vtank-kb/08-ui-views.md §1
"Tab: Monsters"): 14 check columns (F B G I Y V A R S WC FC Cp DC Cs,
each 20px = VVS's forced 13px check width + WPadding per the markup
doc's PITCH convention), 7 text columns (Name 127/P 27/Dmg type 63/
Ex. Vuln 63/Weapon 87/Offhand 87/PetDmg 63, each pitch = fixedwidth+7),
2 icon columns (move up/down, DAT ids 0x060028FC/0x060028FD), 15
header <label>s carrying VTank's own decompiled tooltip text
(PluginCore.cs:1693-1707 — corrects "Broadside of a Tumerok" to the
decompile's actual "Broadside of a Barn"), and the txtNewMonster field
+ Add + Add Sel row at VTank's exact geometry (8,136,388,16 /
404,136,50,16 / 462,136,50,16). Selection is unused, matching VTank —
every real cell is its own click target.
Per-cell behavior ported from PluginCore.cs:7885-8105's `b(object,int,
int)` handler: Name click deletes the row (guarded on the rule's own
IsDefault rather than literal row 0, so the invariant holds even if a
caller reorders DEFAULT); P cycles -1->0->1->2->3->4->-1; Dmg type
cycles every eDamageElement 0..13; Ex. Vuln and PetDmg reproduce the
decompile's own non-contiguous jumps (6->8->98 and 6->101->8->98
respectively — eDamageElement.cs); move up/down reorder but never
displace DEFAULT (a deliberate symmetric guard — VTank's own case 21
keeps MoveUp off row 0 but case 22 has no matching MoveDown guard,
which reads as an authoring oversight, not intended asymmetric
behavior, so a literal port was not preserved here). Case 6's "A"
column write is inverted in VTank's own obfuscated field, but that
inversion is an artifact of VTank's internal storage, not the checkbox
the player sees — our Attack flag is already declared un-inverted, so
no inversion is needed for parity.
Deviation: cases 18/19's Weapon/Offhand cycle VTank's own runtime list
of owned weapon-TYPE ids via an opaque item-kind classifier the
decompile alone doesn't resolve. MossTank has no "weapon type"
abstraction — Weapon/Offhand are always concrete owned items — so
these columns instead cycle the same registered weapon roster the
Items tab already maintains (ordinal-sorted for a deterministic
order), preserving the click-to-cycle-through-<AUTO> contract without
inventing a type abstraction outside this slice's scope.
MossTankPanel: the whole select-then-edit surface (SelectedMonsterRule
selection, RefreshMonsterEditor's cached row formatting, Apply/Remove/
priority-stepper/damage-menu/equip-from-selection actions,
HasMonsterFlag/ToggleMonsterFlag) is retired in favor of per-row
column-array properties (14 flag columns, 7 text columns, 2 icon
columns) and per-row mutation actions, all still writing through the
unchanged CombatSettings.Rules/MonsterRule/MonsterRuleActions model.
EnsureDefaultMonsterRule replaces RefreshMonsterEditor's DEFAULT
invariant at the two remaining call sites (ctor init, profile reload,
and the /vt refresh command). MonsterEquipmentText is kept as a
read-only DEFAULT-rule readout for the (out-of-scope) Items tab label
that already depended on it.
Tests (every pin below shown to fail against a targeted mutation,
reverted after confirming the failure — mutations: wrong priority-wrap
boundary, dropped DEFAULT-delete guard, dropped DEFAULT-displacement
guard on move, spliced Harm into the Ex.Vuln cycle, flag toggle
targeting row 0 unconditionally, a wrong header tooltip string; the
xml column-count contract's 205->202 pin was independently shown to
fail against the pre-edit test file and the new markup):
MossTankMarkupContractTests gained
MonstersGridHasVtanksTwentyThreeColumnsInOrderWithRetailHeaderTooltips
(column type/order + the 15 header tooltips) and updated the
205->202 interactive-control count plus the tooltip-check's <column>
skip (columns have no text/tooltip grammar). MossTankPanelTests
replaced the three old select-then-edit tests with
MonstersGridMutationsPersistAcrossSessions,
ToggleMonsterFlagAtWritesOnlyTheTargetedRow,
CycleMonsterPriorityAtWrapsExactlyNegativeOneThroughFour,
MonsterDamageColumnsCycleInTheExactRetailOrder (all three cycles),
DeleteMonsterRuleAtRemovesNonDefaultRowsButNeverDefault,
MoveMonsterRuleAtReordersButNeverDisplacesDefault,
AddMonsterRuleUsesTheDraftTextAndAddSelectedMonsterUsesTheWorldTarget,
and MonsterWeaponColumnCyclesTheRegisteredRosterAndPersistsByName
AcrossSessions, and updated MacroWieldsCasterEntersMagicBuffsThen
WieldsWeaponFightsThenIdlePeace for the new weapon-cycle API.
Full suite green: 651/651 MossTank tests (645 + 6 net), 109/109 App
markup-filtered tests.
Deviation not requiring a divergence-register row: this campaign ports
VTank plugin UI behavior, not retail AC client behavior, so
docs/architecture/retail-divergence-register.md's scope (acdream vs.
retail) does not apply here.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Wires the new UiMenu.RetailButtonArt switch (previous commit) into
plugin markup: <menu style="plain"> (also the default when the
attribute is absent) builds RetailButtonArt=false so a plugin's
dropdown gets the flat VTank-matching box; style="retail" opts a
panel back into the gold pushbutton face. Any other value throws
FormatException at Build naming the element, matching the existing
<icon iconkind> validation convention (ValidateIconKind).
Mutation check: temporarily stubbed ValidateMenuStyle to always return
true (as if the switch didn't exist) — 3 of the 4 new
MarkupDocumentTests.Menu_* tests failed exactly as expected
(Menu_NoStyleAttribute_DefaultsToPlain_RetailButtonArtFalse,
Menu_StylePlain_Explicit_RetailButtonArtFalse,
Menu_UnknownStyle_ThrowsFormatException_NamingTheElement); the
style="retail" test passed trivially either way, as expected for that
case. Restored before committing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner live-client report 2026-09-07: "Those BIG gold/yellow buttons HAS
to go. That is not how vtank looks." VTank/Decal's HudCombo is a flat
dark box (background/border matching its own HudList) with a
left-aligned value and a small down-arrow — retail's gold pushbutton
art (the 3-slice LED-arrow face UiMenu.DrawButtonFace draws) is a
different widget family entirely.
Adds UiMenu.RetailButtonArt (default true, so every existing
non-markup UiMenu caller — chat's channel menu, vendor's category
dropdown, Config's option menus, the retail confirmation dialog, and
DatWidgetFactory's generic Type-6 element — keeps its byte-identical
retail face) plus DrawPlainClosedState/DrawPlainTriangle, which draw
the flat box entirely with UiRenderContext.DrawFill/DrawRectOutline (no
sprite or DAT quad at all) using colors mirroring UiMarkupList's own
chrome (background 0,0,0,0.92; border 0.46,0.37,0.16,1; text
0.91,0.87,0.76,1). Open/pressed only tints the border
(0.70,0.58,0.24,1) — never a sprite swap.
Mutation check: temporarily disabled the new `if (!RetailButtonArt)`
branch in OnDraw (reverting it to the pre-fix unconditional retail
path) — 3 of the 6 new UiMenuPlainStyleTests failed exactly as
expected (Plain_ClosedState_DrawsNoTexturedFaceQuad,
Plain_ClosedState_DrawsFillOutlineTextAndTriangle,
Plain_ClosedState_TriangleSitsRightAligned_TextSitsAtListPadding); the
3 retail-path/default-value tests kept passing since they don't
exercise the removed branch. Restored before committing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessConsoleRenderer dimmed every line uniformly, so chat and
interface text (player-visible content) read the same washed-out weight
as scheduling/session-status noise like "entered world" or "command
rejected: ...". Only lifecycle, command, and portal lines are scheduling
noise; chat and interface text now print at the terminal's default
weight.
ChatAndInterfaceTextPrintAtDefaultWeightNeverDimmed was shown to fail
against the prior dim-everything WriteLine (mutation: dim parameter not
yet threaded through, every call still unconditionally wrapped in the
ANSI dim/reset pair) -- the chat line and interface text both carried
the dim escape sequence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Existing coverage exercises HeadlessSessionHost.SubmitConsoleLine
directly (bypassing the background reader thread and the scheduler) or
HeadlessConsoleController in isolation (a fake submit callback, no real
session). Add one test that drives the actual production wiring: a
plain StringReader feeds "hello" and "/quit" through the real
HeadlessProcessHost constructor (background reader thread -> per-tick
ConsolePump -> ChatCommandRouter.Submit -> the wire), asserting the
outbound Talk action reaches the fixture AND that /quit ends
RunAsync with HeadlessExitCode.Success -- the same graceful path an
external cancellation takes.
Mutation: removed the session.ConsolePump assignment in
HeadlessProcessHost's constructor (never wiring the drain+pump
delegate). The test failed with a TimeoutException -- the queued
console lines were never drained, so /quit's cancellation never fired
and RunAsync ran until the test's own 10s WaitAsync bound.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A configured process with 2+ sessions and --console silently skipped
console attachment (the plan's "single-session only for the first cut"),
indistinguishable from --console simply having worked. Report it
explicitly through the same HeadlessDiagnosticWriter.Message stream
every other structured event already uses.
TwoSessionsWithConsoleFlagReportsSingleSessionOnly was shown to fail
against the silent-skip branch (mutation: the else-branch body removed)
-- the diagnostics stream carried only the ordinary lifecycle/resource
JSON lines, with no "single-session only" message anywhere.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessProcessHost read System.Console.IsOutputRedirected directly to
pick the console renderer's color mode, which only Program.cs (the
executable's own entry point) should ever touch -- the same reasoning
that already put the stdin probe there. Resolve
standardOutputIsTerminal next to the existing !Console.IsInputRedirected
probe in Program.cs and thread it through HeadlessEntryPoint.Run into
HeadlessProcessHost's constructor as a plain parameter.
StandardOutputIsTerminalParameterControlsColorNotTheRealConsole was
shown to fail with the parameter still unused (useColor still reading
the real Console.IsOutputRedirected, which the test host always
redirects): the standardOutputIsTerminal:true case expected dimmed
lifecycle output but got none, since the real console read forced
useColor=false regardless of what the test passed in.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessConsoleChatFeedback wrapped RuntimeChatCommandFeedback per
SubmitConsoleLine call, so it only ever saw interface text produced by
the console's OWN typed line -- a server-driven refusal or a plugin's
own Log/interface-text write (RuntimeCommunicationState.AddText's
ClientLocal branch, called from anywhere else) never reached the
console at all, because that branch enqueues into SpewBoxState and
never touches ChatLog/RuntimeChatDelta.
Delete the decorator. HeadlessConsoleSpewBoxPump instead polls the SAME
SpewBoxState the graphical overlay's SpewBoxController.Tick already
reads, diffing against the previous visible snapshot so it prints only
newly-appeared entries. HeadlessProcessHost's ConsolePump now runs the
input drain and the SpewBox pump together each tick.
HeadlessSessionHost.SubmitConsoleLine drops its onInterfaceText
parameter -- it is just ChatCommandRouter.Submit against a plain
RuntimeChatCommandFeedback now, same as LoginCommandSequence.
N1: also corrected this method's own doc comment, which described
dispatch as "plugin verb registry first, then retail client/server
slash commands" -- the real ChatCommandRouter.Submit order is retail's
catalog, local /help, plugin verbs, the channel-tag fallback, an
explicit server command, then chat.
PumpPrintsInterfaceTextNotOriginatingFromTheConsole was shown to fail
against a no-op Pump() (mutation) -- the enqueued plugin-shaped line
never printed. UnknownVerbProducesTheSameInterfaceTextTheChatBoxShows
was reworked to assert against the real SpewBoxState directly instead
of the deleted decorator's callback.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessConsoleController.Handle called _submit(rawLine) bare: an
UnknownCommand/Dropped outcome printed nothing (the operator had no way
to tell their line did nothing), and any exception from the submit
callback would propagate out of DrainDue into the scheduler's
per-session quarantine catch, faulting the whole session over one
console typo. Wrap the submit in try/catch and report both cases with a
visible line, mirroring LoginCommandSequence.DrainDue's own reporting
for login-line failures.
N1: also corrected this class's own <remarks> doc comment, which
described the dispatch order as "plugin-verb registry -> client/server
slash commands" -- the real ChatCommandRouter.Submit order is retail's
client-command catalog, then local /help, then plugin verbs, then the
unregistered-channel-tag fallback, then an explicit server command, then
chat.
UnknownOrDroppedOutcomePrintsAVisibleLine and
SubmitFailurePrintsALineAndNeverEscapesDrainDue were shown to fail
against the prior bare `_submit(rawLine);` call: the outcome tests found
nothing printed, and the failure test caught the InvalidOperationException
escaping DrainDue itself rather than being reported as a line.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The existing ordering test proves lines drain in order on the calling
thread but only argues "never the reader thread" structurally (the
reader loop has no dispatch code to run). Add ThreadIdRecordingTextReader,
which records the actual managed thread id ReadLine() ran on, and assert
from inside the controller's submit callback that the executing thread is
neither that reader thread nor any other thread -- only the DrainDue
caller's.
Mutation: routed DrainDue's Handle(line) call through a dedicated
new Thread(...).Start()/Join() instead of calling it inline. The new
test failed (drainCallerThreadId != observedSubmitThreadId, off by one
full OS thread) before reverting the mutation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessConsoleOptions.Resolve tested the environment variable against
the literal "1", so ACDREAM_HEADLESS_CONSOLE=0 silently fell through to
the terminal-shaped default (on when stdin is a real console) instead of
acting as an A/B off-switch. Now: once the variable is SET AT ALL, any
value other than "0" enables and "0" disables -- the same idiom
ACDREAM_RETAIL_CLOSE_DEGRADES / ACDREAM_RETAIL_UI already use. An unset
variable still falls through to the terminal default.
Registered the flag as the sixth entry in
LaunchOptionsDocumentationTests.DefaultOnBehaviorFlags and updated the
Conventions section of docs/launch-options.md plus the flag's own row
(side-effects column corrected to describe the real precedence).
ResolvePrefersFlagThenEnvironmentThenTerminalDefault's env="0"/terminal
=true case was shown to fail against the prior `== "1"` implementation
(expected false, old code returned true) before the fix landed; the
env="yes" case also failed on the same mutation (old code required the
literal "1", so "yes" fell through to terminal=false instead of enabling).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
validate never starts a session, so a silently-ignored --console read as
"it worked" to an operator who typo'd their command. Reject with a clear
message instead.
New test ValidateModeRejectsTheConsoleFlag was shown to fail first
(mutation: the guard absent — HeadlessCommandLine.Parse returned normally
for `validate --config bot.json --console` instead of throwing).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"Console" read as if it might mean System.Console; ConsoleEnabled says
what the flag actually gates. Pure rename, no behavior change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Campaign VT slice 7 (docs/plans/2026-09-07-campaign-vt-slice7-tabs.md),
S7.1 (Window) and S7.2 (Options/Profiles/Vitals). Owner's bar: looks
basically the same as VTank, judged side by side.
S7.1: mosstank.xml's content area is now 856 wide (VTank's own
mainView.xml, docs/research/vtank-kb/08-ui-views.md §0), tab order/
widths unchanged (already assessed as reading close to VTank's strip).
Panel height grows to 350 (not VTank's 210) only because the Advanced
Options and Loot Editor pages move to VTank's own POPUP geometry
(392x300 / 268x300, KB §1's secondary-view tables) as separate in-panel
groups (KB §5's documented design choice: acdream keeps these in one
window rather than VTank's separate OS-level popup windows) — the nine
normal tab bodies keep their existing 194-tall content (== VTank's 210
minus its own 16px HudTabView strip) and simply don't fill the extra
room the popups need. cShowAdvanced/cShowLootEditor are now VTank's own
Checkboxes (open AND close via the same control) instead of separate
Show/Hide push buttons — MossTankPanel gained ToggleAdvancedOptionsVisible/
ToggleLootEditorVisible thin wrappers over the existing Show/Hide
actions, and CombatMacroRunning (VTank's cOn "Run Macro" is a Checkbox
with a static caption, not a dynamic-caption push button).
S7.2: Options, Profiles, and Vitals are transcribed control-for-control
from KB §1's exact L,T,W,H and captions. Vitals' nine sliders now use
the new <slider min="0" max="100"> to bind the percent directly
(NormalHealthPercent/SetNormalHealthPercent etc. — thin wrappers over
the existing 0.0-1.0 fields, never independent state). Options gained
direct checkboxes for four settings that already existed but were only
reachable through the generic Advanced Options key-value editor
(AutoStackEnabled was already exposed; FastCastBuffsEnabled/
DontShootAtWallsEnabled/DebuffFallbackEnabled are new thin wrappers over
_buffSettings.FastCastBuffs/_combatSettings.UseProjectileAwareness/
_combatSettings.AllowDebuffFallback). Profiles gained a real Meta-profile
combo/CopyTo row (previously a stub label) and lost the Loot Priority
Boost toggle as a duplicate of the Options-tab control of the same name;
VTank's own bMetaClearViews ("Del. Meta Windows") has no acdream
equivalent — MossTank has no floating meta-debug windows — so that slot
is repurposed for our own per-type Delete buttons (slice 1) instead,
keeping VTank's row rhythm undisturbed. The Loot Editor popup's
LootEditorNotice label is dropped to fit VTank's 268x300 footprint
without clipping (a status label, not an action — the one deliberate
trim, noted in the group's own comment).
Contract test updates (both shown to fail against the prior mosstank.xml:
856/350 vs 800/244, 205 vs 194 controls): AuthoredShellFitsTheMinimum
CanvasAndEverySizedChildFitsItsParent's window-size pin, and
EveryInteractiveControlDeclaresARealHandlerBinding's control count.
New MossTankPanelTests pins: FastCastProjectileAwarenessAndDebuffFallback
ToggleTheirVtankDefaultsAndPersist, ToggleAdvancedOptionsAndLootEditor
VisibilityFlipBothWays, CombatMacroRunningReflectsTheSameStateAsCombat
ButtonText, VitalsPercentWrappersReadAndWriteTheSameFieldAsTheZeroToOnePair.
Full suite green: 645/645 MossTank tests, 183/183 (3 pre-existing skips)
App markup/menu/slider tests. AssertWithinParent (every nested group/
control fits its parent) and every other contract invariant (tab order,
binding shapes, tooltips) pass unchanged.
Deviation: S7.3-S7.6 (Monsters/Items/Consumables/Buffs/Route/Meta tab
bodies) are explicitly out of this sub-slice's scope per the plan and
are untouched beyond the geometry-only group-width widen (784->848,
content unchanged) needed so they aren't clipped by the wider window.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Slice 1c review D1 / slice 7 item 7: TryLoadNav tolerated (and silently
skipped) STATE:/IF:/DO: rule sections so a route load out of a full
Meta .af would still succeed against whichever NAV: block came first.
That is exactly the failure mode content-sanity elsewhere in this file
already guards against (a misplaced file in the wrong folder silently
"succeeding" against the wrong document) — a file with a STATE: block
is a Meta profile, possibly one with an embedded nav of its own, and
belongs in metas/, not navs/.
TryLoadNav now throws (refuses) the instant it sees a STATE: line,
with a message naming the metas/ folder, instead of walking past it
via the now-deleted SkipState helper.
SynthesizedGetOptFollowAndJumpFixtureRoundTrips previously used a
STATE:+embedded-NAV: fixture to assert TryLoadNav's OLD skip-and-load
behavior; that assertion is now inverted to assert refusal (shown to
fail against the prior MetafSerializer.cs: True vs expected False).
The "flw" nav-node parsing coverage that assertion also carried is
preserved in a new dedicated NAV:-only fixture,
FollowNavNodeParsesAsANavOnlyDocument.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Campaign VT slice 7 needs to transcribe VTank's own Vitals sliders
(minimum=0/maximum=100) and its long Profiles/Route named-item combos
control-for-control (docs/research/vtank-kb/08-ui-views.md §3's two
markup gaps). acdream's <slider> was hardwired to a 0.0-1.0 scalar and
<menu> always wrapped overflow into extra columns instead of VVS's
single scrolling column.
<slider min max>: optional literal attributes declaring the range the
bound value/onchange speak in (e.g. min="0" max="100" for a percent),
while UiScrollbar itself keeps its existing 0.0-1.0 internal math
untouched — MarkupDocument rescales at the binding boundary. Omitting
both (every pre-existing <slider>) keeps the exact historical identity
range.
<menu scroll="true">: wires UiMenu.Scrollable plus the same
track/thumb/up/down chrome sprites ConfigOptionsPageController and
VendorUiController already apply to their own Scrollable menus,
previously only reachable from C#. Omitting scroll keeps the
historical column-wrapping default.
Both are additive — no existing <slider>/<menu> markup changes
behavior. New pins in MarkupDocumentTests.cs shown to fail against the
prior MarkupDocument.cs (5 failures: Build_SliderWithNoMinMax_*,
Build_SliderWithMinMax_*, Slider_MinMax_Draws*,
Build_MenuWithScrollAttribute_*, Menu_Scroll_Draws*) before this
change, all green after.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
20 focused tests, no live server: typed-option resolution and
--console flag parsing; the reader-thread ordering guarantee (lines
queued by the background thread drain, in FIFO order, entirely on the
calling thread); the controller's drain/quit/status behavior; the
console chat formatter's per-kind bracket labels; and the full
dispatch pipeline against a real HeadlessSessionHost wired to a
no-network FixtureSessionOperations fixture (mirrors the existing
HeadlessSessionHostTests pattern used for LoginCommandSequence):
/say and plain text both produce the identical outbound Talk action a
graphical /say would send, a registered plugin verb is reached without
touching the wire, and an unknown/degenerate verb produces the same
interface text ChatCommandRouterTests already pins for the graphical
route.
Every test in this file was run against a deliberate one-line mutation
of its own production code first and confirmed red before being
reverted: HeadlessConsoleChatFeedback dropping its interface-text
callback, HeadlessConsoleController skipping
_quitRequested.Cancel(), LiveChatCommandRoute.TryHandlePluginCommand
forced to always return false, HeadlessConsoleInputReader's read loop
dropping its Enqueue call, and SubmitConsoleLine's ChatChannelKind.Say
swapped for .Tell.
dotnet test tests/AcDream.Headless.Tests -c Release: 193 passed, 1
pre-existing failure (LinuxRejectsGroupOrOtherCredentialPermissions -
Linux-only lane, cannot run on this Windows host, unrelated), 194
total. dotnet test tests/AcDream.Runtime.Tests -c Release: 1891/1891.
dotnet test tests/AcDream.App.Tests -c Release --filter
"FullyQualifiedName~Chat|FullyQualifiedName~Command|FullyQualifiedName~LaunchOptions":
414 passed, 2 pre-existing failures (both gated on
ACDREAM_PROBE_LIVE_MOUNT=1, a manual live-DAT probe lane, unrelated),
3 skipped, 419 total.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A route (.af) file placed in metas/ used to silently "succeed" as an empty
MetaProfile: TryLoadMeta's STATE:/NAV: loop never adds a Rule for a NAV:-only
file, so the mistake was invisible. TryLoadMeta now tracks whether it saw
any STATE: block; a file with at least one NAV: block and zero STATE:
blocks throws (same FormatException path every other malformed-content
error already uses), naming the navs/ folder the file actually belongs in.
The opposite direction was already structurally caught by TryLoadNav's
existing "no NAV: block found" throw when a file has zero NAV: blocks (a
Meta profile with no embedded route, placed in navs/) — only the message
text is improved to name the metas/ folder. Both notices flow unchanged
through the existing MossTankProfileRecovery.Preserve/RecoveryNotice path
in MossTankMetaProfileStore.LoadCurrent/MossTankRouteProfileStore.LoadCurrent,
so no store-side code changes were needed for the wiring itself.
Mutation demonstrated: `git stash push -- src/AcDream.Plugins.MossTank/MetafSerializer.cs`
(reverting only the production fix, keeping every new test) reproduced 8
failures — the 5-fixture EveryNavOnlyAfFixtureIsRefusedByTryLoadMeta theory
(every real nav_*.af fixture parsed as a "successful" empty MetaProfile),
MetaOnlyContentIsRefusedByTryLoadNavWithMetasFolderNotice (message lacked
"metas/"), and the two store-level tests
MetaStoreRefusesToLoadANavOnlyFileWithNoticeNamingNavsFolder/
RouteStoreRefusesToLoadAMetaOnlyFileWithNoticeNamingMetasFolder — confirmed
by running the suite with the stash applied, then `git stash pop` to
restore the fix. All 640 tests (632 + 8 new) pass after.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Existing installs may still have real .af files sitting flat at the
VtankProfiles root from before step 1's two-folder cutover. Each store now
runs a one-time (per-instance, guarded) sweep on first LoadCurrent():
MossTankRouteProfileStore moves every flat nav_*.af/--nav_*.af file into
navs/ with the marker stripped (VtankProfileDirectory.StripLegacyNavMarker);
MossTankMetaProfileStore moves every OTHER flat .af file into metas/
unmarked. The partition predicate (IsLegacyFlatRouteFileName) is shared in
VtankProfileDirectory so both stores agree on which file belongs to which
sweep and neither touches the other's share. Both sweeps run before the
older legacy-JSON migrations so those see .af content already at its new
folder-qualified path. Collision rule: when the real destination already
exists, the flat file is left in place untouched (never overwritten) and
the collision is logged with both paths.
Mutation demonstrated: the 7 new tests (MetaStoreMigratesFlatAfFileIntoMetasFolder,
MetaStoreLeavesFlatFileInPlaceWhenMetasDestinationAlreadyExists,
RouteStoreMigratesFlatNavMarkedFileIntoNavsFolderWithMarkerStripped,
RouteStoreMigratesFlatHiddenAutoRouteFileWithMarkerStripped,
RouteStoreLeavesFlatFileInPlaceWhenNavsDestinationAlreadyExists) were run
against the store/VtankProfileDirectory code from the prior commit (no
migration sweep) and failed 4/7 (the other 3 pass vacuously since they only
assert the ABSENCE of cross-contamination, which trivially holds without
any sweep at all) before the MigrateFlatFilesTo*FolderIfNeeded methods were
added. All 632 tests (625 + 7 new) pass after.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner decision 2026-09-07: Meta and Nav profiles both use metaf .af and are
told apart by living in two dedicated VtankProfiles subfolders (metas/,
navs/) instead of the flat-directory nav_/--nav_ marker scheme from slice 1
Part A, which was only ever how the owner happened to name files in their
own metas repo. VtankProfileDirectory.ListMetaProfiles/ListNavigationProfiles
now enumerate metas/ and navs/ respectively via a new folder-scoped
EnumerateFolderFileNames helper; the NavMarker constant and the marker
overload of AutoCharacterFileName are deleted. MossTankMetaProfileStore and
MossTankRouteProfileStore build every real storage key with their folder
prefix (CurrentFileName, Select, Create, TryImportLegacy, the legacy-roster
sweep) and strip it back off for display (StripAf/Strip). The .cdf's Nav/Meta
lines (4-5) now carry the folder-relative key ("metas/Name.af",
"navs/Name.af"); AD-122 and the ACDREAM_VTANK_PROFILE_DIR launch-option row
are updated to describe this.
Mutation demonstrated: reverting VtankProfileDirectory.cs,
MossTankMetaProfileStore.cs, and MossTankRouteProfileStore.cs to HEAD~ (the
flat nav_-marker layout) while keeping the updated tests reproduces 8 test
failures (KeyNotFoundException / Assert.True(false) against the new
"metas/…"/"navs/…" keys the tests now expect, e.g.
MetaSaveAcceptsAnAfSuffixedNameWithoutDoublingIt,
MetaAndRouteProfilesWithTheSameNameDoNotCollide,
NavCommandsImportLegacyAndExportAf, MetaCommandsImportLegacyAndExportAf,
MetaRosterSweepConvertsEveryNamedLegacyProfileOnce,
MetaStoreLeavesLegacyJsonUntouchedWhenAfCounterpartExists,
MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent,
NavSaveAcceptsAnAfSuffixedNameWithoutDoublingIt) — confirmed by running the
suite immediately after the production-code edit, before the test-file
updates landed. All 625 tests pass after both sides of the change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Campaign VT slice 1 Part A: the .usd document model + 137-setting
serializer with declared type tags and exact compare, metaf .af reader/
writer for metas and nav routes with real byte identity against the
owner's fixtures, .utl gate fixes, the VtankProfiles host storage
(ACDREAM_VTANK_PROFILE_DIR), and the cutover of all four profile stores
to real VTank files with one-time JSON migration. Two Opus lenses, three
fix rounds, two narrow re-reviews, final re-check: MERGE-READY.
Contract-doc ledger conflict resolved by keeping the campaign branch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Five small fixes bundled per the round's cleanup item:
- VtankNavRouteSerializer.cs's doc comment cited a "WriteBinaryNavBlob"
method that no longer exists anywhere in the codebase (MetaEngine's
embedded-navigation contract moved to the typed MetaAction.EmbeddedRoute
NavigationSettings, saved/loaded through MetafSerializer.SaveNav/
TryLoadNav, back at round 2 step B) — corrected to name the real
mechanism.
- MossTankCommands.cs:274's comment referenced an "exports/nav/" mirror
directory that stopped existing when route profiles cut over to writing
their real .af file directly (round 2 steps 2-3) — corrected.
- docs/research/vtank-kb/07-meta-and-expressions.md section 5.2 row 6
described the pre-cutover "MossTankMetaProfileStore.WriteLegacyExport
convenience mirror" design; .af is now the SOLE authoritative Meta
store, so a disabled rule's save refusal now blocks the profile itself
— the row now says a disabled rule makes the profile file genuinely
unsaveable, not that a mirror goes stale.
- The two bare `catch (FormatException) { }` blocks that silently dropped
a corrupt monster-rule expression (one in SideCarDocument.Apply, reached
from a corrupt side-car; one in LegacyCombatProfileDocument.Apply,
reached during legacy-JSON migration) now log a warning via the host's
IPluginLogger, threaded through as an optional parameter from every call
site.
- VtankDatabase.Render()'s table-sort doc comment now states explicitly
that StringComparer.Ordinal matching .NET Framework's SortedDictionary
default order is confirmed only for the plain-ASCII table names VTank
ships (AntiExtraBuffSpells, MyMonsters, Settings, …), not as a general
claim for any string — comment only, no behavior change.
Added CorruptSideCarMonsterRuleIsLoggedNotSilentlySwallowed (FakeLogger
now captures Warn() calls via a new FakeHost.Logger property) pinning the
swallow-to-log fix.
Mutation: reverted MossTankProfileStore.cs to HEAD (keeping only the new
test) and ran it — failed with an empty Warnings collection, confirming
the silent-swallow bug before the fix.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Round 2 step 5 gave the settings Profiles tab a Delete action; Meta, Route,
and Loot never got the same verb. Added MossTankMetaProfileStore.Delete,
MossTankRouteProfileStore.Delete, and MossTankLootProfileStore.Delete
(same contract as Settings: remove the selected named profile's real file,
fall back to By char; refuse for By char itself, which has nothing to
delete — see each store's ClearCurrent for that case), wired through
MossTankPanel.DeleteMetaProfile/DeleteRouteProfile/DeleteLootProfile to
three new "Delete" buttons in mosstank.xml (Route tab row, the Meta tab's
button row, and the Loot rule editor's button row).
MossTankMarkupContractTests' interactive-control count moves 191 -> 194
for the three new buttons.
Mutation: reverted all four .cs files and mosstank.xml to HEAD (keeping
only the new/changed tests) — the test project failed to even COMPILE
(DeleteRouteProfile/DeleteMetaProfile/DeleteLootProfile do not exist on
MossTankPanel), confirming the six new behavioral tests
(DeleteRouteProfile/DeleteMetaProfile/DeleteLootProfile, each with a
successful-delete and a refuse-by-char case) and the markup-count update
all depend on this commit's code.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MossTankLootProfileStore now reads/writes real .utl files through
VtankLootProfileSerializer.TryRead/Write in the VtankProfiles storage,
matching the Settings/.usd, Route/.af, and Meta/.af cutovers already
landed: directory-backed listing (new VtankProfileDirectory.ListLootProfiles,
*.utl, "--" hidden rule), .cdf participation (LootFileName, already present
in VtankCharacterBinding but never populated by this store), and a one-time
JSON migration (SweepLegacyRosterIfNeeded) that converts BOTH this
character's own "By char" document and every other named profile the
pre-cutover roster still lists — unlike Meta/Route (whose rosters were
already abandoned pre-round-2), loot's roster was still the LIVE mechanism
right up to this commit, so there is no separate "selected vs the rest"
split the way Settings/Meta/Route each have. WriteLegacyExport is deleted;
exports/ has no remaining loot writer.
Found and fixed a real representational-loss bug the cutover would
otherwise have introduced: VtankLootProfileSerializer.ExportRequirements
replaces an empty VtankRequirements list with a "safely disabled"
VTClassic placeholder requirement — correct when .utl was only ever a
courtesy export mirror alongside the authoritative JSON store, but
silently destructive once .utl becomes the SOLE store, since every
MossTank-authored rule's Expression text would be permanently discarded on
its first save/reload cycle. Added a MossTank-owned length-delimited
"MossTankRuleExpressions" block (using the serializer's own existing
UnknownBlocks round-trip contract — a real VTClassic reader just ignores it
as an unrecognized block, the same as any other extension) that restores
each affected rule's exact Expression text on load.
Filed AD-123: MossTank's own ByCharacter auto-.utl-file convention for loot
(kept for consistency with the other three stores) versus retail's real
loot picker, which seeds only [None] and has no per-character auto file at
all (docs/research/vtank-kb/01-settings-and-profiles.md section 3).
Updated four pre-existing tests for the new storage shape/behavior:
ProfileGiveControllerTests' FakeHost now wires VtankProfiles (the loot
store no longer uses Storage at all); LootProfilesAreIndependentNamedDocuments
and LootCommandsImportAndExportExactVtclassicUtlFiles now assert against
the real file/no-exports-mirror shape instead of the deleted hashed-JSON/
exports-mirror one. Added LootRosterSweepConvertsByCharacterAndEveryNamedLegacyProfileOnce
pinning the migration + its idempotence.
Mutation: reverted MossTankLootProfileStore.cs/VtankProfileDirectory.cs to
HEAD (keeping only the new/changed tests) and ran the three
cutover-dependent tests — all three failed (no real .utl file, no
ListLootProfiles, exports/ mirror still expected) — confirming they
exercise the bug/gap before the fix. LootingUsesVtankDefaultsAndPersistsTheOrderedRuleEditor
(pre-existing, unchanged) independently caught the representational-loss
bug during development before the MossTankRuleExpressions block was added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>