Owner live-client report 2026-09-07: "For scrollable dropdown or the meta
window we use the same assets as we do in for example chat or inventory
window."
<menu> markup wiring (MarkupDocument.cs): a plugin <menu> is now always
Scrollable (single-column, VTank HudCombo shape) instead of wrapping
overflow into more grid columns, with PopupScrollbarHideWhenDisabled=true
so the bar is entirely absent while the item count fits the "rows"
window. RetailScrollbarChrome.ApplyToMenuPopup wires the same chrome ids
the previous commit taught DrawScrollablePopupPlain to draw, for both
style="plain" and style="retail" markup menus.
<list> markup (UiMarkupList.cs / MarkupDocument.cs): a plugin <list>
(single-column or <column> multi-column) that overflows its own row
viewport now draws the retail scrollbar chrome at its right edge (VVS's
own placement, 16px wide) instead of being wheel-scroll-only with no
visible bar. The reserved 16px column only exists while rows actually
overflow, in both column-layout modes (ComputeColumnLayout receives the
already-shrunk width so the last/auto column absorbs the remainder
correctly); the bar is fully interactive (up/down arrows, track paging,
thumb drag) via a small UiScrollable projection kept in sync with the
list's own _topRow, which stays the single source of truth. Wheel
scrolling and a no-resolver hand-built list (draws nothing, no crash) are
unchanged.
Mutation shown to fail first: new
UiMarkupListScrollbarTests/MarkupDocumentTests cases were written against
pre-change UiMarkupList/MarkupDocument and failed (no scrollbar sprites
ever emitted since UiMarkupList had no SpriteResolve property at all, and
<menu> markup never set Scrollable) before the implementation landed;
after: SingleColumn_Overflowing_DrawsRetailScrollbarChromeAtRightEdge and
Columns_Overflowing_ReservesSixteenPixels_LastColumnShrinksAccordingly
pin sprite ids + exact reserved-width geometry,
*_ContentFits_DrawsNo(Scrollbar|ReservationLastColumnKeepsFullRemainder)
pin the no-overflow/no-bar case, *_UpArrowClick_ScrollsUpByOneRow and
ThumbDrag_MovesTopRowAndIsReadableByASubsequentClick pin interactivity via
a following row click resolving to the moved position (mirroring
MarkupListColumnsTests' own wheel-scroll pin), and the four new
MarkupDocumentTests menu cases pin Scrollable/PopupScrollbarHideWhenDisabled/
the six chrome-id properties plus an end-to-end open-popup draw for both
the overflowing (draws chrome) and non-overflowing (draws none) cases.
Every pre-existing MarkupListColumnsTests/MarkupDocumentTests case stays
green unchanged (none of their fixtures overflow their own viewport).
docs/plugin-ui-markup.md updated: the <menu> style paragraph and a new
<list> "Scrollbar" section describe the new chrome + auto-reservation, and
the PITCH-transcription guidance is corrected to say the 16px scrollbar
column is now automatic (no more manual fold-in/double-reservation advice).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner live-client report 2026-09-07: "For scrollable dropdown or the meta
window we use the same assets as we do in for example chat or inventory
window." The plain-style <menu> popup's scrollable-overflow scrollbar
(DrawScrollablePopupPlain / DrawPopupScrollbarPlain in UiMenu.cs) drew a
home-made flat 1px track + flat thumb instead of the gold track + up/down
arrow buttons + thumb the chat SpewBox and inventory UiItemList already
use through RetailScrollbarChrome. The owner only ever objected to the
retail ROW art (checkmark glyph, gradient panel) — the bar itself was
never in scope for the plain-row fix, so this change touches only the
scrollbar draw call and leaves the plain row rendering untouched.
DrawScrollablePopupPlain now calls the existing DrawPopupScrollbar helper
(the same procedural sprite-chrome draw VendorUiController/
ConfigOptionsPageController already use) whenever a SpriteResolve is
wired, falling back to the old flat DrawPopupScrollbarPlain only for a
hand-built UiMenu with no resolver at all. New
RetailScrollbarChrome.ApplyToMenuPopup(UiMenu) wires the same vertical
skin ids (Track/Up/Down/ThumbTop/Mid/Bot Normal) the chat/inventory
scrollbar uses onto a menu's own ScrollTrackSprite/etc properties.
Mutation shown to fail first: UiMenuPlainStyleTests's
Plain_OpenPopup_ScrollableOverflow_DrawsPlainTrackAndFlatThumb_NoDatArt
and Plain_ScrollablePopup_ContentFits_DrawsTrackWithNoThumb asserted
resolveCalls==0 and an all-fill scrollbar — both failed (6 resolve calls,
6 sprite quads instead of 0) against the new DrawPopupScrollbar call
before being rewritten to
Plain_OpenPopup_ScrollableOverflow_DrawsRetailScrollbarChrome_RowsStayPlain
and Plain_ScrollablePopup_ContentFits_DrawsNoScrollbarAtAll, which pin the
new sprite-chrome behavior (6 resolved ids on overflow: track, up, down,
thumb top/mid/bottom; 3 on content-fits: track+up+down, no thumb; 0 on a
menu built with no resolver) while re-asserting the rows are still plain
fills with zero retail row-sprite quads. Retail's own
RetailButtonArt=true popup path (DrawGridPopup/DrawScrollablePopup) is
untouched — its regression golden
(Retail_OpenPopup_DrawIsByteForByteUnchanged_RegressionGolden) still
passes byte-for-byte.
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 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>
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>
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>
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>
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>
Console mode for the headless bot host, enabled by --console or
ACDREAM_HEADLESS_CONSOLE=1 (default: on when stdin is a real terminal,
off when redirected — a background reader thread blocked on ReadLine
would otherwise sit idle against a script/CI pipe).
- HeadlessConsoleInputReader: one background thread per console,
reading lines into a ConcurrentQueue. It only ever enqueues — no
dispatch code runs on it — so input queued while the session tick is
busy is guaranteed to execute on the tick thread, never the reader
thread, when drained.
- HeadlessConsoleController: drains the queue via
HeadlessSessionHost.ConsolePump (a new no-op-by-default tick hook),
handles /quit (cancels a CancellationTokenSource) and /status
(prints a caller-supplied status string), and routes everything else
through SubmitConsoleLine.
- HeadlessConsoleChatFormatter/HeadlessConsoleRenderer: render the K2
bot event stream (IRuntimeEventObserver — the same interface a bot
policy subscribes) as bracket-labelled lines ([Tell] Bob: hi,
[Fellowship] ..., [Local] ...) plus lifecycle/portal/rejected-command
lines. A deliberate departure from ChatVM.FormatEntry's retail prose:
Headless cannot reference AcDream.UI.Abstractions (the dependency-
boundary test), so this is its own terminal-shaped rendering using
the same channel-name strings, not a byte-for-byte port.
- HeadlessProcessHost: attaches the console only for a single-session
run (multi-session is out of scope for this cut per the plan),
constructed AFTER every session's own credential resolution so the
reader thread never races a StandardInput-provider password prompt
on the same stdin stream. /quit's CancellationTokenSource is linked
into the scheduler's run token, so it exits through the exact same
graceful path an external Ctrl+C/SIGTERM already takes.
Mutation check (reverted before commit): removing the reader thread's
Enqueue call and removing _quitRequested.Cancel() each turn a
still-to-be-added HeadlessConsoleTests case red.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HeadlessSessionHost already built ChatCommandRouter.Submit's exact
dependencies (LiveChatCommandSurface over the plugin-verb registry, a
RuntimeChatCommandFeedback) for LoginCommandSequence — that pipeline is
the same one every graphical chat window calls. No lift was needed;
this promotes those two ctor locals to fields and adds
SubmitConsoleLine, the console's one entry point for a typed line.
HeadlessConsoleChatFeedback decorates the real feedback so the
console ALSO sees retail's transient SpewBox/ClientLocal interface
text (bad-args refusals, unknown-command text) — that path never
touches ChatLog, so it never reaches the K2 event-stream renderer
added in the next commit.
Mutation check (reverted before commit): commenting out
_onInterfaceText(text) in HeadlessConsoleChatFeedback.ShowInterfaceText,
and swapping SubmitConsoleLine's ChatChannelKind.Say for .Tell, each
turn a still-to-be-added HeadlessConsoleTests case red.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds the first piece of the interactive headless console (docs/plans/
2026-09-07-headless-console.md): HeadlessConsoleOptions.Resolve picks
--console, then ACDREAM_HEADLESS_CONSOLE=1, then a terminal-shaped
default, matching the project's typed-options-object convention rather
than a scattered env-var read. HeadlessCommandLine.Parse now accepts
--console as a bare flag (no value token) alongside the existing
paired options. Both new launch-options.md rows are added in this
commit per LaunchOptionsDocumentationTests' bidirectional rule.
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>
bk.a (refs/vtank/decompiled/bk.cs:6-35) appends a brand-new Settings row
([0]=name, [1]=value, every other column a void gy() cell) when a .usd
file's Settings table has no row for that name at all, and reports
"Done saving setting X to all profiles. (Changed N profiles)" with N =
every .usd file scanned. MossTankProfileStore.SetOptionInAll instead
silently skipped any file missing the row, and MossTankPanel's message
("Set option X in N profile(s) = value") matched neither retail text nor
count semantics.
Fixed SetOptionInAll to append (VtankCell { Tag = "0" } for every column,
then overwrite [nameColumn]/[valueColumn]) and to return the total scanned
file count instead of a per-row "changed" tally; changed the setinall
success message to retail's exact text.
Also added real type validation to /vt opt set|setinall: TryParseOptionValue
now takes the catalog's declared VtankSettingValueType and fails with
retail's exact "Option set: Invalid value specified. Proper type of X is
Y." text (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612) when
the typed value doesn't parse as that type — previously any non-empty
string silently succeeded regardless of the option's real type. The
Advanced Options editor keeps the original lax free-form parse (no
catalog type to check against there).
Mutation: reverted MossTankProfileStore.cs/MossTankCommands.cs to HEAD
(keeping only the new tests) and ran all four new/changed tests — all
four failed (old "Set option..." message text, no append, no type
validation) — confirming they exercise the bug before the fix.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Retail's real 2000 ms jump-charge ceiling (refs/vtank/decompiled/bi.cs:502-505,
bi.a) is enforced at the moment a jump STARTS charging, not by the storage
format — metaf's own NJump (metaf_monolithic.py:11708-11820) and VTank's
.nav both round-trip the authored value unclamped. The prior slice-1 port
misread this as a LOAD-time clamp: MetafSerializer's .af "jmp" parser and
the legacy-JSON route migration both clamped JumpChargeMilliseconds on
read, silently rewriting an authored 5000 ms waypoint down to 2000 ms even
when the route is never executed.
Removed both load-time clamps; NavigationController.TickJump now clamps
the EFFECTIVE charge duration (Math.Clamp(..., 0, 2000)) only at the one
place retail actually enforces it — the charge-hold comparison during
execution — leaving the stored/authored value untouched.
Renamed MetafSerializerTests.JumpNodeClampsChargeMillisecondsTo2000 to
JumpNodeLoadPreservesAuthoredChargeMillisecondsAboveRetailCeiling (now
asserts the 5000 ms value survives the .af load) and added a save+load
round-trip test and a Navigation execution test asserting the jump
releases at ~2000 ms of in-game charging despite a 5000 ms authored value.
Mutation: reverted MetafSerializer.cs/MossTankRouteProfileStore.cs/
Navigation.cs to HEAD (keeping only the new/changed tests) and ran the
three new/renamed tests — all three failed (load clamped to 2000,
execution never released before 5000 ms) — confirming they exercise the
bug before the fix.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Item 3: MossTankRouteProfileStore's per-character auto route file was named
nav_--Name_Server.af (NavPrefix + AutoCharacterFileName) — the "nav_" marker
came BEFORE the "--" hidden prefix, so the whole filename does not start
with "--" and defeats every StartsWith("--") hidden-file check in
VtankProfileDirectory, leaking another character's private route binding
into both the nav and meta pickers. Chose hidden-prefix-first naming
(--nav_Name_Server.af, matching VTank's own "--" convention with the nav_
kind marker second) via a new AutoCharacterFileName(name, server, ext,
marker) overload; applied only to the per-character auto file — named
routes keep their existing nav_Name.af (shared/visible) shape.
Item 4: ListNavigationProfiles and ListMetaProfiles shared the same flat
.af directory with no marker check at all, so each picker returned the
other's files too (a Meta profile appeared in the nav picker and vice
versa). ListNavigationProfiles now requires the nav_ marker;
ListMetaProfiles now excludes it.
Mutation: reverted VtankProfileDirectory.cs and MossTankRouteProfileStore.cs
to HEAD (keeping only the new/changed tests) — the build failed outright
(VtankProfileDirectory has no NavMarker/marker-overload for the new tests to
call), and the two pre-existing tests this round updated
(ListNavigationProfilesFiltersBothReservedPrefixes,
RouteStoreLeavesLegacyJsonUntouchedWhenAfCounterpartExists) independently
failed at runtime against their OLD un-marked/mis-ordered fixtures once
this round's marker/ordering requirement was pinned, confirming both are
exercising real, fixed behavior.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pre-cutover roster documents (profiles/{index,meta/index,route/index}.json)
carried EVERY named profile a character had, but each store's cutover only
ever converted the ONE currently-selected profile (MigrateLegacyIfNeeded) —
every other named profile was silently orphaned: never converted to the new
.usd/.af format, never listed again. Worse, MossTankProfileStore actively
reused "profiles/index.json" for a MineOnly-only shape, so the very next
SetMineOnly() save would have permanently discarded the roster.
Added a one-time SweepLegacyRosterIfNeeded() to all three stores
(settings/meta/route), guarded by a per-instance flag so repeat LoadCurrent
calls are a no-op:
- Settings: entries owned by the currently-bound character convert to their
real per-(character,server) sub-profile .usd + side-car; entries owned by
a different character are written back to the roster for that character's
own future session. MineOnly now lives at a NEW dedicated key
(profiles/macro/preferences.json) so the old roster key is never again
flattened/overwritten.
- Meta/route: named profiles were never owner-scoped (one shared, globally-
hashed key per name), so every roster entry converts unconditionally.
Mutation: reverted all three store .cs files to HEAD (keeping only the new
tests) and ran SettingsRosterSweepConvertsEveryNamedLegacyProfileOnce,
MetaRosterSweepConvertsEveryNamedLegacyProfileOnce, and
RouteRosterSweepConvertsEveryNamedLegacyProfileOnce — all three failed
(missing converted .usd/.af files) — confirming the tests exercise the bug
before the fix.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MossTankProfileStore's SideCarDocument.InventoryEnableLooting duplicated
the real "EnableLooting" .usd Settings row, and the side-car's own Apply()
ran AFTER the .usd was applied in LoadCurrent — so a drop-in profile with
looting on loaded with it silently off, clobbered by whatever the side-car
happened to hold (default false for a profile with no side-car at all).
Deleted the duplicate field; settings.Inventory.Loot.Enabled is now owned
exclusively by the .usd row. Added a reflection test proving no SideCarDocument
field (typed or with its Combat/Buff/Vitals/Inventory group prefix stripped)
collides with a VtankOptionCatalog name, and a drop-in regression test:
a real .usd fixture with EnableLooting=True and NO side-car now loads with
panel.LootEnabled == true.
Mutation: reverted MossTankProfileStore.cs to HEAD (keeping only the new
tests) and ran both new tests — SideCarDocumentHasNoFieldNamedForARealVtankSetting
failed with `["InventoryEnableLooting"]`, DropInUsdWithLootingEnabledAndNoSideCarLoadsLootingEnabled
failed with Expected True/Actual False — confirming both tests exercise the bug
before the fix.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Campaign VT slice 1 Part A round 2 step 5: the Profiles tab's action set
(Create/Select/Save/Delete/name field/mine-only) was missing Delete
entirely — every profile family only ever had Create/Copy/Clear. Adds
MossTankProfileStore.Delete (removes the selected named profile's real
.usd file and its side-car, falls back to "By char"; refuses for "By
char" itself, which has nothing to delete — see ClearCurrent for that
case) and wires it through MossTankPanel.DeleteProfile to a new "Delete"
button in mosstank.xml, next to "Clear profile!".
Create/Select/the name field/the mine-only toggle already bind to the
directory-backed store from steps 1-4; this closes the one missing verb.
Mutation shown to fail: Delete short-circuited to always refuse made
DeleteProfileRemovesTheRealFileAndFallsBackToByCharacter fail (selection
stayed on the named file instead of falling back); restored, it passes
along with the By-char refusal companion test. The markup contract's
interactive-control count was updated for the new button (190 -> 191).
595 MossTank tests passing (was 593).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MossTankMetaProfileStore and MossTankRouteProfileStore now store their
profiles as real .af files through IPluginHost.VtankProfiles, named and
listed by VtankProfileDirectory's rules, instead of a hashed JSON
document plus a stale "exports/" mirror (WriteLegacyExport deleted from
both). Named Meta profiles are plain shared files (matching VTank's own
ac() picker, which has no per-character sub-profile carve-out); named
route profiles carry a "nav_" prefix (metaf's own observed convention
for a stand-alone nav .af, confirmed against the committed nav_*.af
fixtures) so a route and a Meta profile sharing a user-typed name never
collide in the shared VtankProfiles directory. Both stores' Selected/
AvailableNames strip the file extension (and the route store's "nav_"
prefix) for display, matching the bare names users type at /vt meta|nav.
The route store now persists only the fields metaf's NAV: grammar
actually carries (Mode, Waypoints, FollowTarget) — Enabled/Priority/
MinimumDistanceMeters/FollowAroundCorners/OpenDoors/Door* are real VTank
Settings-table rows already owned end-to-end by MossTankProfileStore's
.usd profile (round 2 step 1), matching real VTank's own split between
global nav prefs and the per-route file; ClearCurrent and LoadCurrent
were narrowed to match. LoadCurrent gained an ISpellCatalog parameter
(TryLoadNav's own requirement); both call sites now pass
host.Automation.Spells.
Both stores gained the same one-time legacy-JSON migration as the
Settings store: first load converts a not-yet-migrated JSON profile to
.af and deletes the JSON key, leaving an existing .af counterpart (and
its stale JSON) untouched. The Meta store's SaveCurrent/Create now
refuse (return false, set SaveNotice, leave the prior .af content in
place) rather than silently drop a disabled rule that metaf/.af cannot
represent — MossTankPanel's four rule-editing call sites were updated to
prefer that refusal notice over their own generic success message.
Mutations shown to fail: MossTankMetaProfileStore.MigrateLegacyIfNeeded
stubbed to a no-op made MetaStoreMigratesLegacyJsonProfileToAfAndDeletesTheJsonKey
fail (legacy key was not deleted); SaveCurrent's SaveMeta call switched to
dropDisabledRules:true made MetaStoreRefusesToSaveADisabledRuleAndKeepsThePriorAfContent
fail (the disabled rule was silently written). Both restored and green.
NavigationTests.RouteProfilesRoundTripEveryWaypointField (asserting the
pre-cutover JSON-carries-everything behavior) was split into
RouteProfilesRoundTripWaypointFieldsButLeaveSettingsOwnedFieldsAlone and
FollowModeRouteRoundTripsTheFollowTargetThroughAf, and now also
documents two pre-existing, already-recorded metaf representational
gaps discovered by routing this path through .af for the first time:
JumpDirection has no .af representation at all (MetafSerializer.cs:924)
and a "jmp" node carries no cell id (six bare fields, no hex component).
MossTankPanelTests' nav/meta export-path tests were updated from the
retired "exports/meta|nav/" mirror to the real file locations.
591 MossTank tests passing (was 588 after step 1's commit, +3 new tests
net of the two renamed/retired ones).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MossTankProfileStore now stores the VTank-catalog Settings (Combat/Buffs/
Vitals/Inventory/Navigation) as a real .usd database read/written through
VtankSettingsProfileSerializer, listed via VtankProfileDirectory's real
naming rules (auto "By char" file, "--Name_Server_" sub-profiles, .cdf
per-character binding) instead of a hashed JSON document. Only state with
no VTank setting name (item/consumable lists, the monster-rule table, and
a handful of MossTank-only knobs) still lives in a small JSON side-car
keyed by the real .usd file name.
A one-time migration converts a not-yet-migrated legacy JSON profile into
its .usd + side-car pair on first load and deletes the JSON key, leaving
an existing .usd counterpart (and its stale JSON) untouched. `opt setinall`
now patches every known .usd file's Settings row directly, keeping each
file's side-car DynamicSettings mirror in sync so MossTankPanel's existing
option-override replay doesn't clobber the freshly patched value on the
next load.
VtankProfileDirectory gained WriteCharacterBinding (the .cdf writer
counterpart to the existing reader). VtankSettingsProfileSerializer.Apply
is now internal so the store can seed live settings directly from a
parsed database without a text round-trip.
Mutation shown to fail: MigrateLegacyIfNeeded stubbed to a no-op made
FirstLoadMigratesLegacyJsonMacroProfileToUsdAndDeletesTheJsonKey fail
(legacy key was not deleted); restored, it passes along with the
ExistingUsdCounterpartLeavesLegacyJsonUntouchedAndUnread companion test.
585 -> 587 MossTank tests passing (net +2 after adjusting three existing
tests to the new, more retail-faithful defaults/identity: a brand-new
profile now seeds from VTank's own shipped defaultsettings.usd rather
than a MossTank-guessed CLR default, and Selected/MacroProfileNames now
surface the real VTank file name instead of a bare invented name).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Item J (slice-1 fix round), five sub-parts (item 12's sixth, the
VtankCellBuilder/VtankCellFactory note, required no change — both are
genuinely in use):
1. MobsInDist_Priority's regex-table entry gained an argument-order
comment (count, distance, priority — cross-referenced against Meta.cs's
runtime evaluation, CountMonstersByPriority(priority, distance) >=
count) plus a new synthesized round-trip test
(MobsInDistPriorityRoundTripsAllThreeNumbersDistinctly): this condition
is never exercised by any committed real fixture (only its name
appears, in the auto-completion header banner text), so nothing
previously caught an accidental swap of any two of its three numeric
fields.
2. VtankDatabase.Render() now explicitly sorts tables by name
(StringComparer.Ordinal) before writing — VTank's own `y` class holds
tables in a SortedDictionary, so a real .usd/.ast always emits
table-name order. This port's own Tables is an insertion-ordered list,
so every committed fixture happened to round-trip in order today
purely because it was already sorted the last time real VTank wrote it
(confirmed: defaultsettings.usd's own first five tables are already
alphabetical). New RenderEmitsTablesInNameOrderRegardlessOfInsertionOrder
adds three tables in deliberately reversed order to prove the sort,
not just re-check an already-sorted fixture.
3. SaveMeta's NAV: block write order now comes from a List<(MetaAction,
string)> populated in AssignEmbedTags's own traversal order, not
Dictionary<MetaAction,string> enumeration — Dictionary enumeration
order happens to match insertion order in the current runtime absent
removals, but that is an implementation detail, never a documented BCL
contract. embedTags stays a Dictionary purely for WriteAction's O(1)
lookup; embedOrder is the sole source of write order.
4. "/vt nav save Foo.af" and "/vt meta save Foo.af" used to keep the
".af" suffix (only ".nav"/".met" were stripped from the argument),
producing a doubled "exports/nav/Foo.af.af" / "exports/meta/Foo.af.af"
export instead of "exports/nav/Foo.af" / "exports/meta/Foo.af" — .af
is the only VTank-compatible storage format now, so both commands
strip it too. NavCommandsImportAndExportExactVtankNavFiles/
MetaCommandsImportAndExportExactVtankMetFiles renamed to
NavCommandsImportLegacyAndExportAf/MetaCommandsImportLegacyAndExportAf
(the writer is real metaf output now, not a byte-exact pass-through of
the imported .nav/.met, so the old names overstated what they prove);
new NavSaveAcceptsAnAfSuffixedNameWithoutDoublingIt/
MetaSaveAcceptsAnAfSuffixedNameWithoutDoublingIt pin the fix.
5. VtankLootRequirementEvaluator's IntKeyExists/DoubleKeyExists were each
an independently hand-maintained duplicate of IntValue/DoubleValue's
own named-field key list — a key added to one switch and forgotten in
the other would silently make BuffedInt/BuffedDouble's KeyExists gate
treat a real, always-present field as "raw property bag only". New
TryIntValue/TryDoubleValue are the single source of truth for both
"what is this key's value" and "does it exist at all"; IntValue/
DoubleValue and IntKeyExists/DoubleKeyExists are now both thin wrappers
over them. The two double-side "virtual field" keys (VtankDoubleBase+12/+14,
which always exist regardless of whether their remapped raw key is
present) needed an explicit comment to preserve that exact semantic
through the consolidation.
Full MossTank suite: 583 -> 585 (2 new tests from item 4; items 1-3 and 5
added/renamed tests without net new count beyond that). App.Tests
(Plugin|LaunchOptions filter): 84/84.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Item I (slice-1 fix round, finishing item 1). CaptureMatchesDeclaredSettingTypeAndValue's
~1.5e-7 float-round-trip tolerance was masking a real cause, not a real
value change: every tDouble-declared distance setting whose live field
was actually a float (CombatSettings.MaximumRange/MinimumRange/
ApproachDistance/RingDistance/ArcRange/TargetSelectAngleRange/
PetCustomRange/CollisionProjectileRadius/CollisionStepDistance/
SpellRangeFudge, Looting.CorpseApproachRange/CorpseMinimumApproachRange,
VitalPlan.HelperHealthDistance/HelperStaminaDistance/HelperManaDistance)
lost precision below float's ~7-significant-digit guarantee on every
load, since a loaded .usd's real value is a double.
- All 15 fields widened from float to double, matching their declared
tDouble type. Every physics/combat call site that genuinely needs a
float (IProjectileAutomation.EvaluatePath/EvaluatePathWithDiagnostics,
ICombatAutomation.CaptureHostileTargets/CaptureCorpses, the
fellow-distance Lowest() helper) now casts explicitly at that one use
site (CombatController.cs, Looting.cs, VitalRecharge.cs,
MossTankCommands.cs, PetAutomation.cs) instead of the field itself
being narrowed everywhere it's stored.
- MossTankProfileStore's JSON DTOs (InventoryProfileDocument.
CorpseApproachRange, CombatProfileDocument.MaximumRange/
ApproachDistance/TargetSelectAngleRange/ArcRange/RingDistance/
PetCustomRange) widened to match, so MossTank's own persisted profiles
keep full precision too — their Apply()-side Math.Clamp calls needed no
changes (the float literal bounds like 2f/100f already widen to the
double overload implicitly).
- VtankSettingsProfileSerializer.Apply's `(float)(cell.AsDouble() * 240d)`
casts and `cell.AsFloat()` calls for these 15 settings are now plain
`cell.AsDouble()` / `cell.AsDouble() * 240d` — no narrowing at all.
- ValuesEqual's "d"/"f" branch dropped DoublesEqual/FloatRoundTripTolerance
entirely: `a.AsDouble() == b.AsDouble()`, exact, matching every other
tag. CaptureMatchesDeclaredSettingTypeAndValue's own test-side tolerance
(a second, independently-tolerant comparison) removed the same way —
all 135 catalog names now pass under Assert.Equal(exact) with zero
special-casing.
Full MossTank suite: 581/581 (no count change — this is a precision fix,
not new coverage; CaptureMatchesDeclaredSettingTypeAndValue's own 135
cases already existed and now pass exactly instead of within tolerance).
App.Tests (Plugin|LaunchOptions filter): 84/84.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Item H (slice-1 fix round), six sub-parts:
1. Restored VtankMetaProfileSerializerTests.LoadsKnownTypedCondActRecord
and SignedHighBitLandblockIdRoundTripsExactly (the latter adapted from
the deleted RoundTripPreservesEveryVtankConditionActionAndEmbeddedNav's
LandblockEquals/LandcellEquals coverage of unchecked((int)0x8B370000u)),
both deleted as collateral damage of an unrelated file move in commit
0d10399e0. Neither calls the deleted VtankMetaProfileSerializer.Save
writer (demoted to import-only in 3ff9461ef) — both are pure reader
assertions.
2. MetafSerializer.SaveMeta now refuses (throws InvalidOperationException
naming the count) to silently drop a disabled MetaRule: real VTank/
metaf has zero concept of "disabled" (confirmed: metaf_monolithic.py
has no "enabled"/"disabled" occurrences anywhere), so
MetaRule.Enabled is a MossTank-only extension with no metaf-compatible
marker. A new SaveMeta(profile, dropDisabledRules: true) overload lets
a caller accept the loss explicitly.
MossTankMetaProfileStore.WriteLegacyExport (the .af convenience mirror
beside MossTank's own fully-fidelity JSON storage) deliberately does
NOT opt in — it leaves that mirror stale and logs a warning via its
existing try/catch rather than losing the rule. Recorded as gap 6 in
docs/research/vtank-kb/07-meta-and-expressions.md section 5.
3. New VtankProfilesDefault (src/AcDream.App/Plugins/): the graphical
host's default VtankProfiles root (<DataDirectory>/vtank), extracted
out of Program.cs's inline Path.Combine call into its own pure,
injectable-root function specifically so the "Path.Combine only, never
a hard-coded Windows path" guarantee is a real, failable Linux-path
unit test (VtankProfilesDefaultTests.ResolveIsBuiltWithPathCombineOnly)
rather than something only checkable by reading the source — the
pattern item F's VtankProfileDirectory rewrite removed when
Resolve/PortableDefault moved out of the plugin.
4. New BuffedDoubleRequirementDoesNotApplyBonusWhenBaseKeyIsAbsent: the
KeyExistsDouble gate (ComputedItemInfo.cs:234) already existed in
BuffedDouble, but only the int side
(BuffedIntRequirementDoesNotApplyBonusWhenBaseKeyIsAbsent) had a
pinning test.
5/6. DoubleSpellBonuses gained an explicit Change field (KB doc 05
section 2.2: "additive unless the static table's Change==1, in which
case multiplicative", ComputedItemInfo.cs:244), replacing
BuffedDouble's prior `(int)bonus.Bonus == 1` magnitude-based proxy —
that proxy only worked because every multiplicative bonus in the
current 19 rows happens to fall in [1.0, 2.0) and every additive one
happens to be under 1.0; it would have silently mis-branched on a
future row like an additive 1.5 or a multiplicative 2.0+. Every
existing row's Change value was derived mechanically from its old
proxy result (no behavior change for the current table), and
BuffedDoubleRequirementAppliesAdditiveBonusWhenBaseKeyExists/
AppliesMultiplicativeBonusWhenChangeIsSet pin both branches through
the real named field.
Full MossTank suite: 574 -> 581. App.Tests
(Plugin|LaunchOptions|RuntimeOptions filter): 135 -> 137. Core.Tests
(Plugin filter): 50/50 (no change, no Core-side edits this item).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Item G (slice-1 fix round).
- ListSettingsProfiles's "Mine only" filter previously hid EVERY shared
(non-sub-profile) file when checked. Real VTank's predicate
(uTank2/PluginCore.cs:7020-7024, cSettingsShowAll/field a9) is
"!checked || file == current" — a shared file is hidden only when the
box is checked AND it is not the file currently assigned to the
character, so the profile actually in use never disappears out from
under the user just because they ticked the box. New optional
currentFileName parameter carries that exemption.
- New VtankProfileDirectory.CdfFileName/TryReadCharacterBinding: the
real per-character binding file (da class, da.cs:15,105-164) —
filename Server_CharacterName.cdf (Server-then-Name order), literal
"uTank2 CDF 1.0" version-header line 1 (a mismatch is treated
identically to a missing file, per da.cs:113-121, and the method
returns null in both cases rather than fabricating a default binding),
lines 2-4 the settings/loot/nav filenames currently assigned to that
character, optional line 5 the meta filename (present only when the
stream wasn't already at EOF — an older .cdf predating meta support
has no line 5 at all). The legacy .uts->.usd settings-filename
rewrite (da.cs:130-141) is applied here so callers never see a stale
extension.
- New VtankProfileDirectory.AstFileName: the per-character spell-
tracking cache name (dm class, dm.cs:391) — CharacterName_Server.ast,
no "--" prefix, not user-selectable. Note the concatenation order is
the REVERSE of CdfFileName's (Name-then-Server vs Server-then-Name) —
both are pinned by dedicated tests so a future edit can't silently
swap one for the other.
- New RealAstFixturesParseAsTheSpellsTable theory over the three
committed owner-{a,b,c}.ast fixtures: parses via the existing
VtankDatabase.Parse (same "y" grammar as .usd) and asserts the real
Spells table's four columns (SpellID/EndTime/Target/CastTime), per
live inspection of +Horan_sawato.ast recorded in
docs/research/vtank-kb/01-settings-and-profiles.md section 3.
No production caller of these new members exists yet — same as item F,
this is contract/rule-implementation work per A2's "foundation only, not
yet wired into the profile stores" scope; wiring belongs to the store
cutover explicitly deferred to round 2.
Full MossTank suite: 562 -> 574 (12 new tests, no regressions). App.Tests
(Plugin|LaunchOptions filter): 82/82.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Item F (slice-1 fix round). IPluginHost.VtankProfileDirectory handed the
plugin a raw string path and told it to fall back to its own
System.IO-based portable default when null — a plugin reading and
resolving filesystem paths itself, which is exactly the seam the rest of
IPluginHost.Storage deliberately avoids (Core.Plugins.ScopedPluginHost
scopes/validates every key; the plugin never sees a path).
- IPluginHost: VtankProfileDirectory (string?) deleted; new VtankProfiles
(IPluginStorage, defaults to NoOpPluginStorage) added — a second,
UNSCOPED storage instance (unlike Storage, which Core scopes per
plugin manifest id) rooted at a host-composed VTank-compatible
directory.
- ScopedPluginHost.VtankProfiles forwards _inner.VtankProfiles directly
(no scoping — it names one shared external location, not per-plugin
data). New PluginSessionTests.ScopedHostForwardsVtankProfilesUnscoped
proves the forwarded instance is the exact same object (Assert.Same),
not a wrapper.
- AppPluginHost/Program.cs: new vtankProfiles constructor parameter,
composed as FilePluginStorage(runtimeOptions.VtankProfileDirectoryOverride
?? Path.Combine(applicationPaths.DataDirectory, "vtank")).
- RuntimeOptions.VtankProfileDirectoryOverride: new init-only property
parsed from ACDREAM_VTANK_PROFILE_DIR (row added to
docs/launch-options.md, side-effects column states the redirect is the
only effect and documents the NullIfEmpty whitespace-not-special-cased
quirk it shares with every other path-override flag). New
RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet.
- FilePluginStorage.List(prefix): empty prefix now means "the storage
root itself" instead of throwing (Resolve() rejects empty/whitespace
keys, which is correct for every OTHER caller but wrong for "list
everything" — VtankProfileDirectory needs exactly that).
- Headless: HeadlessPluginHost gained the same VtankProfiles
property/constructor param, threaded through HeadlessPluginSession.Create
-> HeadlessSessionHost -> HeadlessProcessHost, composed from the new
HeadlessPathSet.VtankProfilesDirectory (<DataDirectory>/vtank, no
ACDREAM_VTANK_PROFILE_DIR-equivalent override — Headless path overrides
are HeadlessPathOverrides/CLI flags, not env vars). A small
AcDream.Headless.Plugins.FilePluginStorage duplicates the App
implementation byte-for-byte (Headless does not reference AcDream.App
and no shared "platform plugins" library exists yet to host one copy;
documented as a reasonable future consolidation, not required here).
- VtankProfileDirectory.cs rewritten: Resolve/PortableDefault deleted
outright (no more System.IO, no plugin-owned portable-default fallback);
ListSettingsProfiles/ListNavigationProfiles/ListMetaProfiles now take
IPluginStorage and enumerate through EnumerateFileNames, which calls
storage.List(string.Empty) and skips any key containing '/' (VTank's
profile directory is flat; a nested key from some other IPluginStorage
implementation is not a profile file). VtankProfileDirectoryTests
rewritten against an in-memory IPluginStorage fake instead of real
temp directories; new NestedPathKeysAreNotTreatedAsProfileFiles pins
that skip. The prior Resolve/PortableDefault-specific tests (Linux-path
guarantee, host-override-vs-portable-default) are superseded by
RuntimeOptionsTests.VtankProfileDirectoryOverrideIsNullUnlessSet plus
the RuntimeOptions.FromEnvironment Path.Combine-only composition in
Program.cs.
- docs/architecture/acdream-architecture.md: one sentence in the
Storage/List(prefix) paragraph naming VtankProfiles as the second,
unscoped storage.
No production caller of VtankProfileDirectory's listing methods exists
yet (A2's foundation is not wired into MossTankProfileStore/
MossTankMetaProfileStore/MossTankRouteProfileStore's own selection —
per that slice's own ledger note), so this is a contract + plumbing
change with no MossTank runtime behavior change.
MossTank suite: 562/562. Core.Tests (Plugin filter): 50/50. App.Tests
(Plugin|LaunchOptions|RuntimeOptions filter): 135/135. Headless.Tests:
173/174 (the one failure, HeadlessCredentialResolverTests.
LinuxRejectsGroupOrOtherCredentialPermissions, is a pre-existing
Linux-only lane gate that throws PlatformNotSupportedException on this
Windows host — unrelated to this change).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Item E (slice-1 fix round). SaveMeta/SaveNav emitted no header at all
and no per-STATE/per-NAV editor-fold comment pair, so proof (4)
(byte-identity against metaf's own canonical emission) could only ever
pass after stripping every "~~" line — which hid that metaf's own
ExportToMetAF DOES mechanically emit both:
- OutputText.metaHeader/navHeader (metaf_monolithic.py:365-434): a fixed
auto-completion-assistance banner, prepended to every meta/nav-only
file respectively.
- State.ExportToMetAF / Nav.ExportToMetAF (py:12050-12054,12463-12467):
every STATE:/NAV: block wrapped in "~~ {" ... "~~ }", unconditionally
(including the single-node Target/follow NAV case).
- Meta.ExportToMetAF (py:12775-12794): when any NAV exists, a blank
line, the exact separator
"~~========================= ONLY NAVS APPEAR BELOW THIS LINE =========================~~"
(no space beside either "~~"), and another blank line, before the
first NAV: block.
Changes:
- MetafSerializer: MetaHeaderLines/NavOutputHeaderLines — the header
text copied byte-for-byte from Fixtures/vtank/af/bella.af (meta) and
nav_ab.af (nav-only) rather than retyped from the Python source, per
the slice-1 contract. Joined with bare "\n" (matching metaf's own
multi-line string constant, written as ONE f.line entry) plus a
trailing "\n" — combined with this writer's own "\r\n" join separator,
reproduces the single blank line real output has between the header
and the first STATE:/NAV: line exactly.
- SaveMeta: prepends MetaHeader; wraps every STATE: block in "~~ {"/
"~~ }"; emits the "ONLY NAVS APPEAR BELOW..." separator only when at
least one embedded Nav tag exists.
- WriteNavBlock: wraps every NAV: block (both waypoint-list and
Target/follow modes) in "~~ {"/"~~ }".
- SaveNav: prepends NavOutputHeader; writes the sole nav under tag
"nav0" (matching metaf's Meta.GenerateUniqueNavTag counter starting at
0 — every committed nav_*.af fixture's tag is literally "nav0";
the prior tag "route" was a MossTank invention).
Proof (4) is now REAL byte identity (Assert.Equal(original, rewritten),
no comment stripping, no blank-line normalization) plus a new nav-only
case (WriterOutputMatchesMetafCanonicalEmissionNavOnly, against
nav_ab.af). Investigating the raw bytes of every candidate fixture
found real, PRE-EXISTING header divergence unrelated to this writer:
aphus.af/neftet.af/follower.af open "~~ {\r\n~~ " (CRLF) where a fresh
metaf conversion's header is bare-LF internally ("~~ {\n~~ ",
confirmed against augments.af/bella.af/gauntlet_leader.af/
empyrean_facility.af/example_sort_meta.af) — evidence of a re-save by
something other than metaf itself (e.g. a text editor normalizing every
line ending). hunting.af and lockandkey.af carry a wholly custom
hand-written banner instead of metaf's own. ByteIdenticalFixtureData is
narrowed to the five fixtures whose header IS metaf's fresh canonical
form (bella, gauntlet_leader, empyrean_facility, augments,
example_sort_meta — still five, per the slice-1 contract's floor);
aphus/neftet/hunting/follower/lockandkey keep exercising every other
proof (parse, parse-write-parse, and the ptl/tlk direct assertions from
the item-A fix) normally, with the header divergence documented at the
exclusion site rather than asserted away.
Three MossTankPanelTests assertions changed from StartsWith to Contains
("STATE: "/"NAV: ") since exported .af content is no longer the first
thing in the file.
Full MossTank suite: 570 -> 566 (5 fixtures dropped from the
byte-identity theory, replaced by 1 new nav-only fact: -4 net).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Item D (slice-1 fix round). CreateNew built ONLY a bespoke 4-column
Settings table (empty Description, hardcoded SettingType=1/Bool for
every row regardless of the setting's real declared type) and had no
representation of the other nine tables VTank ships at all (MyMonsters,
GemFoodItems, ExtraBuffSpells, AntiExtraBuffSpells, ItemUseSpecifiers,
SettingsCategories, SettingsEnumInfo, AssistItems, BuffedItems).
Separately, VitalSettings.RechargeHandlerRows defaulted to an empty
list, so a freshly-constructed VitalSettings (not loaded from a real
.usd) always fell back to VitalRechargePlanner's own hand-ported
handler-order switch instead of VTank's real 26-row table.
- VtankDefaultSettings.usd: the real uTank2.Resources.defaultsettings.usd
(already committed as a test fixture) copied into the plugin project as
an EmbeddedResource, following the existing VtankCraftRecipes.tsv/
VtankAmmunitionOptions.tsv pattern.
- New VtankDefaultSettingsDatabase: loads the embedded text once (Lazy),
exposes Parse() (a fresh, independently mutable VtankDatabase per call)
and DefaultRechargeHandlerRows (the real RechargeHandlerSet table,
parsed once via VtankSettingsProfileSerializer.ParseRechargeHandlerSet).
- VtankSettingsProfileSerializer.CreateNew now parses the embedded
document and calls the existing Save(document, source) against it —
every table, every row's real Description/SettingType survive
untouched; only the Settings table's Value cells get overwritten from
the live source, exactly as re-saving an existing profile would.
DefaultCell (the hand-typed per-catalog-kind cell builder CreateNew
used before) is deleted as dead code.
- VitalSettings.RechargeHandlerRows now defaults to
VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows instead of [].
VitalRechargePlanner.Handlers' hardcoded switch-based defaults are kept
(not deleted) as a documented, genuinely defensive fallback for the
case where a caller explicitly clears the row list or an
(vital, stance, percent) combination the real table happens not to
cover — no longer the primary source of truth it used to be, and the
doc comment says so.
- RenderRechargeHandlerSet (a write-back helper for the RechargeHandlerSet
table, never called anywhere) is deleted rather than wired into
Capture(): docs/research/vtank-kb/01-settings-and-profiles.md section 2
row 137 confirms real VTank has NO save path for this table at all
(tCustom has no case in the Advanced Options editor or "/vt opt set";
it's owned by a dedicated cRechargeManager object). Wiring a write-back
would be a MossTank invention, not a retail port, so Capture() keeps
deliberately leaving this row untouched.
New CreateNewHasTheSameTableSetAsTheDefaultFixture test: asserts
CreateNew's table set matches defaultsettings.usd's own table set
exactly, and that Settings rows carry real (non-empty) Description and a
real (non-Bool-for-everything) SettingType. Verified failing against the
prior 4-column-only implementation (temporarily restored, ran, reverted):
"Expected: [AntiExtraBuffSpells, AssistItems, ...] / Actual: [Settings]".
Full MossTank suite: 570/570 passing (569 -> 570, one new test).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Item C (slice-1 fix round). MossTankMetaProfileStore and
MossTankRouteProfileStore both wrote their .af exports into the same
flat "exports/" directory keyed only by profile name — a Meta profile
and a route (Navigation) profile sharing a name (e.g. both named
"Same") would silently clobber each other's .af file on save, with no
error and no warning.
- MossTankMetaProfileStore.WriteLegacyExport now writes to
"exports/meta/{name}.af".
- MossTankRouteProfileStore.WriteLegacyExport now writes to
"exports/nav/{name}.af".
- Both class docs updated to name the collision this avoids and point at
the sibling store's subdirectory.
- MossTankPanelTests: existing NavCommandsImportAndExportExactVtankNavFiles
and MetaCommandsImportAndExportExactVtankMetFiles updated to the new
paths. New MetaAndRouteExportsWithTheSameNameDoNotCollide saves a Meta
and a route profile both named "Same" and asserts both .af files exist
with their own correct content — verified failing before the fix
(asserted false on the meta file's existence once both paths were
reverted to the flat "exports/" root, confirming the collision is real
and this test catches it).
Full MossTank suite: 569/569 passing (568 -> 569, one new test).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Item B (slice-1 fix round). A LoadEmbeddedNavigationRoute action carried
a synthesized "uTank2 NAV 1.2" TEXT blob in MetaAction.Text that both
MetaEngine and the runtime consumer (MossTankPanel) had to re-parse on
every load — an unnecessary re-parse of already-typed data, and the
reason proof (3)'s AssertActionsEqual skipped comparing embedded routes
entirely (the blob's exact byte shape wasn't a meaningful comparison
target).
- MetaAction: new EmbeddedRoute (NavigationSettings?) property replaces
the blob. Null only for a genuinely unresolved/never-defined Nav tag.
- MetaServices.LoadEmbeddedNavigationRoute: Action<string> ->
Action<NavigationSettings?>; MetaEngine's dispatch passes
action.EmbeddedRoute directly.
- MetafSerializer.ResolveEmbeddedNavs (.af importer) builds the
NavigationSettings directly via the existing ApplyNavBody helper
(renamed SynthesizeNavBlob -> BuildNavSettings) instead of serializing
it back into text. WriteBinaryNavBlob/WriteBinaryWaypoint/
FormatBinaryDouble are deleted outright (the only caller was the
now-removed blob synthesis).
- SaveMeta had its own now-dead re-parse of action.Text via
VtankNavRouteSerializer.TryLoad to rebuild each embedded NAV: block on
save; this silently started producing EMPTY NAV: blocks once Text
stopped carrying the blob (Text is now always cleared for this action
kind), caught immediately by the full suite: MetaParseWriteParseIsIdentical
started failing "Expected: Once / Actual: Circular" (a re-parsed route
falling back to NavigationSettings' default Mode because its NAV: block
vanished). Fixed by writing straight from action.EmbeddedRoute.
- VtankMetaProfileSerializer (.met importer): TryLoad gained an
ISpellCatalog overload (threaded through ReadAction/ReadEmbeddedNavigation);
ReadEmbeddedNavigation now parses its reassembled blob text through
VtankNavRouteSerializer.TryLoad into a real NavigationSettings instead
of handing the raw text to the caller. The existing 2-arg TryLoad
overload defers to MetafSerializer.NoOpSpells.Instance (promoted from
private to internal) so both test call sites and MossTankMetaProfileStore
(which now passes _host.Automation.Spells) keep working.
- VtankNavRouteSerializer.Apply promoted from private to internal so
MossTankPanel.LoadEmbeddedNavigationRoute can copy an already-typed
NavigationSettings into the live _navigationSettings instance directly,
replacing its own VtankNavRouteSerializer.TryLoad(string, ...) re-parse.
- MetafSerializerTests: AssertActionsEqual now asserts
EmbeddedRoute is non-null on both sides and calls AssertNavigationEqual
on them (waypoint-by-waypoint) for LoadEmbeddedNavigationRoute actions,
instead of skipping the comparison. PtlNodeKeepsBothCoordinateTriplesDistinct's
FindNavWaypoint helper reads action.EmbeddedRoute directly instead of
re-parsing action.Text (which is now empty).
Full MossTank suite: 568/568 passing (net zero change in count — this
is a representation change, not new coverage, though the AssertActionsEqual
tightening now exercises real waypoint comparisons on every fixture with
an EmbedNav action that it previously skipped).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Item A (slice-1 fix round). VTank/metaf's Portal2/UseNPC nav nodes carry
TWO coordinate triples (metaf_monolithic.py:356-357,11482,11618 —
"FORMAT: ptl/tlk myx myy myz tgtx tgty tgtz tgtObjectClass tgtName"): the
outer header ("myxyz", retail's own dead-weight last-save player position
per docs/research/vtank-kb/06-navigation-and-nav.md section 1.2) and the
embedded d-record ("tgtxyz", the real target coordinate used to match a
live world object by name+class+proximity). The prior port's
RouteWaypoint had a single Position field, so both the .af reader
(MetafSerializer.ReadNavNode) and the binary .nav reader
(VtankNavRouteSerializer.ReadWaypoint, case 6/7) overwrote "myxyz" with
"tgtxyz" on load, and the .af writer echoed the same Position value for
BOTH triples on save — a real .af round trip of the same waypoint was
lossy, which is why aphus/augments/lockandkey/neftet were excluded from
the byte-identity proof.
- RouteWaypoint: new ReferencePosition field (Position stays "myxyz",
ReferencePosition is "tgtxyz"); included in Clone().
- MetafSerializer.ReadNavNode/RenderNavNode: ptl/tlk read/write both
triples distinctly. WriteBinaryNavBlob's embedded-route writer (the
MossTank runtime blob EmbedNav actions carry) fixed the same way — it
was echoing Position for the reference triple too.
- VtankNavRouteSerializer.ReadWaypoint case 6/7: keep the header triple in
Position, read the trailing triple into ReferencePosition instead of
overwriting Position.
- Navigation.TickUse: TryFindObject now searches near ReferencePosition
(the real target coordinate) instead of Position, preserving the
correct runtime search behavior now that Position no longer aliases it.
- MossTankPanel.AddSelectedObjectWaypoint: new Portal2/UseNPC waypoints
now set Position from the live snapshot (matching retail's own
"wherever the character stood") and ReferencePosition from the selected
object's live position (the real search anchor) — previously both were
set from the object's position.
- MossTankRouteProfileStore's WaypointDocument DTO carries the reference
triple too, so MossTank's own JSON-persisted routes round-trip it.
- MetafSerializerTests: un-excluded aphus/augments/lockandkey/neftet.af
from the byte-identity proof (they all embed a ptl/tlk node and now
round-trip correctly) and added example_sort_meta.af, which also
passes. bore_quest.af was NOT added despite the slice-1 contract's
ask: it is hand-edited the same way as the already-excluded
bore_enhanced.af (space instead of tab between "IF:"/"DO:" and the
following keyword, confirmed at bore_quest.af line 9 — metaf's own
Rule.ExportToMetAF always joins with a tab, metaf_monolithic.py:12371),
so it can never byte-match; documented alongside bore_enhanced's
existing exclusion note instead. New PtlNodeKeepsBothCoordinateTriplesDistinct
test pins the two-triple split directly (failed before this change:
Position held the second triple with nowhere to read the first triple
back from). VtankNavRouteSerializerTests updated to assert the split
instead of the old collapsed value.
- jmp direction: metaf's NJump class has no strafe-direction field at all
(metaf_monolithic.py:11708-11821, confirmed reading ImportFromMetAF/
ExportToMetAF end to end) — the .af format cannot represent
RouteWaypoint.JumpDirection, full stop. ReadNavNode no longer assigns
JumpDirection = Forward explicitly (the model's own default), and the
loss is now recorded as gap 9 in docs/research/vtank-kb/
06-navigation-and-nav.md section 6.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fidelity blocker 6: VtankLineCursor pre-normalized every "\r\n" to "\n"
across the WHOLE document before splitting into lines, then ReadBlob
re-joined consumed lines with a single '\n'. VTank's real reader
(f6.cs:10-17) is `TextReader.Read(array, 0, num)` — it reads exactly N
raw characters straight off the stream, so an embedded CRLF inside a
"ba" blob costs 2 characters toward that length, not 1. The prior
cursor silently dropped that extra character, corrupting any blob
whose content used CRLF line breaks and desyncing the parse position
for everything that follows it in the same row/table.
Separately, VtankDatabaseReader.ReadCell's default case unconditionally
consumed a "value" line for ANY unrecognized tag. VTank's own reader
(gy.cs:50-55) and writer (gy.cs:98-101) treat every tag outside
{d,i,u,f,s,b,TABLE,ba} as void/unrecognized (y.cs:28-41 registers only
TABLE and ba as named custom types) and consume/emit ONLY the tag line
— no value line either way. The prior default case would misread the
next cell's own tag as this cell's bogus value, corrupting the rest of
the row exactly like the blob bug above.
- VtankLineCursor now indexes directly into the original (unnormalized)
text: ReadLine() scans for '\n' and strips one trailing '\r' per line
(matching StreamReader.ReadLine() semantics); ReadBlob(length) takes
exactly `length` raw characters from the current position with zero
reinterpretation.
- VtankCell.WriteTo (and Row/Table/Database) now build a single
StringBuilder instead of a `List<string> lines` that assumed one
entry == one line: a "ba" blob writes its length line then the RAW
blob content with NO added line terminator (matching f6.cs's
WriteLine(length) + Write(content), not WriteLine(content)) — the
next structural token continues immediately after the blob's last
character, exactly like real VTank output.
- ReadCell's default case now builds a bare unknown-tag cell (no
ScalarText/BlobText, consuming nothing further); WriteTo mirrors this
by emitting nothing after such a tag's own line.
- Bonus (adjacent, from the same gy.cs read): VtankCell.String now
strips embedded '\n' on write (gy.cs:84's `text.Replace("\n", "")`),
matching VTank's own string-cell writer exactly — a partial down
payment on item 12's "strip \n from string cells on write" nit.
New tests (tests/AcDream.Plugins.MossTank.Tests/VtankUsdDocumentTests.cs):
BaBlobWithEmbeddedCrlfRoundTripsExactCharacterCount,
BaBlobLengthCountsEmbeddedCrAndLfAsSeparateCharacters, and
UnrecognizedTagConsumesOnlyItsOwnLineNotTheNextCellsValue. All three
were verified failing against the pre-fix VtankUsdDocument.cs (restored
from HEAD, tests re-run, then reverted back) before this change:
the first two threw FormatException "unexpected end of file", the
third read "s" (the next cell's own tag) as ScalarText instead of null.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fidelity blocker 2: VTank's UseArcs is 1=No / 2=AtRange / 3=Yes
(refs/vtank/decompiled/hi.cs:515-538, switch on f3.f("UseArcs")): No
always picks the direct-shape spell, AtRange picks arc only once the
target reaches ArcRange, and Yes always picks arc regardless of
distance. The prior port collapsed this onto CombatSettings.UseArcs
(bool), which can represent No and (an approximation of) AtRange but
has no way to express Yes — a profile with UseArcs=3 could never
actually always-arc, and Capture() had to fudge 1<->3 on save,
which is why UntouchedRoundTripIsByteIdentical needed a NormalizeUseArcs
special case.
- CombatSettings: new UseArcsMode enum (No=1, AtRange=2, Yes=3);
UseArcs is now UseArcsMode (default AtRange, matching the previous
bool default's runtime behavior).
- AttackSpellCatalog.ShouldUseArc implements the real 3-way switch,
replacing the `settings.UseArcs && target.Distance >= settings.ArcRange`
expression at both call sites (Yes now genuinely always arcs).
- VtankSettingsProfileSerializer Apply/Capture "usearcs" cases now
cast directly to/from UseArcsMode instead of the `!= 0` / `? 3 : 1`
bool collapse.
- MossTankPanel GetMetaOption/SetMetaOption "usearcs" now exposes the
raw 1-3 value (ExpressionValue.Number / AsInt32 clamped 1-3),
matching the existing pattern for DebuffEachFirst/DebuffSelectionMethod,
instead of ExpressionValue.Boolean/IsTruthy.
- MossTankProfileStore's JSON DTO field type follows suit (System.Text.Json
already serializes CombatSettings' other enum settings the same way).
- Deleted NormalizeUseArcs from UntouchedRoundTripIsByteIdentical —
the test now asserts full byte-identity with no special case, and
CaptureMatchesDeclaredSettingTypeAndValue (added in the previous
commit) no longer needs to skip "UseArcs".
Verification: reverting to the bool model reproduces exactly one
theory failure (CaptureMatchesDeclaredSettingTypeAndValue("UseArcs"):
expected 1, actual 3) confirming this is the only affected setting;
after this change the full 559-test suite passes with zero special
cases.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fidelity blocker 1: 17 tInt Settings rows (Recharge-*,
IdleBuffTopoffTimeSeconds, RebuffTimeRemainingSeconds,
BlacklistMonsterTimeoutSeconds, GhostDeleteHPTrackerSeconds,
DebuffPrecastSeconds, BuffCastRecast(Reset)_Seconds,
BlacklistCorpseOpenTimeoutSeconds) were written as VtankCell "d" and 3
tSingle rows (CollisionProjectileRadius, CollisionStepDistance,
SpellRangeFudge) were written as "f", while VTank's shipped
defaultsettings.usd Settings.SettingType column declares them tInt(3)
and tDouble(2) respectively (refs/vtank/decompiled/uTank2/eSettingValueType.cs).
VTank's gy.e/gy.f unbox helpers (refs/vtank/decompiled/gy.cs) throw
InvalidCastException reading a mistagged cell, so a profile MossTank
wrote back would corrupt the next VTank load.
Verified against the real fixture with a small parser
(defaultsettings.usd's own SettingType column) rather than trusting a
second hand-written table: exactly 20 mismatches, matching the review's
count precisely.
- VtankOptionCatalog: added VtankSettingValueType (VTank's
eSettingValueType) and a 137-row DeclaredType lookup transcribed
verbatim from the fixture's SettingType column.
- VtankSettingsProfileSerializer.Capture: every numeric arm now routes
through Num(name, value), which wraps the value using
VtankOptionCatalog.DeclaredType(name) instead of a hardcoded
VtankCell.Int/Double/Float call.
- VtankCell.FormatDouble now formats "G15" (VTank's own
Convert.ToString(double) under classic .NET Framework), not .NET's
shortest-round-trippable default — verified against every "d" value
in the real fixture.
- ValuesEqual is now an exact per-tag compare (bool/int/uint/string
exact; double/single bounded by a named float-round-trip epsilon
documented as such, not an arbitrary tolerance) instead of lumping
every numeric tag into one loose comparison.
New test CaptureMatchesDeclaredSettingTypeAndValue is a theory over all
135 catalog names with a live write path (skipping EnableMeta,
RechargeHandlerSet, and UseArcs — pre-existing exclusions), asserting
Capture()'s tag and value against defaultsettings.usd's own Settings
row rather than a second hand-authored expectation table. It failed on
the 20 known-bad names before this fix (see conversation record) and
passes now.
Deliberate, documented deviation from the review's literal "delete the
1e-6 tolerance" instruction: several distance settings (AttackDistance,
ArcRange, …) are declared tDouble but still round-trip their live value
through a CombatSettings `float` field for the physics/combat math that
consumes it; a fully exact compare would make Save() rewrite those rows
on every untouched load due to sub-15-significant-digit float noise,
regressing the class's own byte-identity goal. The retained tolerance
is now named (FloatRoundTripTolerance, float's ~7-digit relative
epsilon) and scoped to only the "d"/"f" tags, not blanket over
every numeric tag as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
New internal static UiCheckLamp (LampSize=11f, the four checked/
unchecked colors, Draw) is the ONE definition of the five-band lamp
glyph, promoted out of UiMarkupToggle's private/internal fields —
UiMarkupToggle.OnDraw and UiMarkupList.DrawCheckCell both call
UiCheckLamp.Draw now instead of each carrying (or one exposing to the
other) its own copy of the same five DrawFill calls and four colors.
DrawCheckCell also centers the lamp horizontally in its cell instead of
drawing it flush left at cellX+1 — matching DrawIconCell, which already
centers its sprite. A check column declared wider than the glyph itself
(routine under fix item 11's PITCH-based authoring convention) no longer
strands the glyph in the cell's left edge.
UiMarkupListColumn's settable members are now internal init (Kind/Width
lost their `required` modifier — C# forbids `required` pairing with a
setter less visible than the containing public type, CS9032 — every
factory already sets both unconditionally, so this is a compiler-level
demotion, not a behavior change) — the type is constructible only
through its Text/Check/Icon factories from any external assembly (a
plugin) with no InternalsVisibleTo grant, so it can never assemble an
inconsistent instance via object-initializer syntax.
Replaced the inert draw-offset assertion in
Columns_CheckThenIcon_EachCellDrawsInsideItsOwnColumnBounds (check
column FIRST, so its own-cell assertion held trivially even with
completely broken column offsets) with
Columns_IconThenCheck_EachCellDrawsInsideItsOwnColumnBounds — check
column now SECOND, so the assertion can only pass if the glyph actually
moved into its own [20,100) cell.
New test: a single 50px-wide check column's glyph lands near the cell's
midpoint (~22.5) rather than the old flush-left x=4 — shown to fail
first before the centering change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
DrawCheckCell drew nothing for a row past its own column's bound data —
inconsistent with docs/plugin-ui-markup.md's own description of VVS's
HudList, which materializes every cell in a row regardless of which
columns actually have data for it. Text/icon cells rightly stay
no-draw past their own count (no sensible default string or icon), but
a check cell always has one: unchecked.
DrawCheckCell now computes isChecked defensively (false when the flags
array is null or too short) instead of returning early, so a short
check column draws the unchecked lamp for every row past its own data
just like every other row.
New test: a 3-row list (driven by a longer text column) with a check
column bound to only 1 row now shows the unchecked lamp for rows 1 and
2 — shown to fail first (0 unchecked lamp quads, since DrawCheckCell
returned early past index 0).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>