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>
First consumer of acdream's plugin automation surface, and the first slice of
the VTank-class plugin milestone
(docs/research/2026-07-29-vtank-plugin-automation-requirements.md).
MossTank shows a panel with a Buff button; clicking it casts every self-buff
the character is missing, skips what is already in force at an equal or higher
tier, and refreshes what is nearly expired.
The host/plugin line is the load-bearing decision here. The host publishes
spell DATA -- family, tier, difficulty, mana, duration -- plus a cast
primitive with a preflight gate. The plugin owns the POLICY. That is the
architectural conclusion the requirements research reached: VTank's engine
lived in plugin-land, built on Decal's primitives, and baking "best buff for
skill X" into the host would start pulling the engine inward one convenience
at a time.
Why the plan is driven off the spellbook rather than off trained skills, which
is the obvious reading of "buff every trained and specialised skill": the
client cannot honestly make that mapping. The link between a spell and the
stat it modifies arrives from the SERVER in the enchantment message and is
absent from the client's own spell table. What the client does know is which
spells the character has learned -- and a character only learns buffs for the
skills they use, so the spellbook reaches the same set without inventing a
mapping the client has no grounds for.
Surface added, all BCL-only so Plugin.Abstractions keeps its zero project
references:
* ICharacterInfo, ISpellCatalog, IMagicCommands, grouped behind one
IAutomationSurface so IPluginHost grows by one member rather than three.
* IEvents.Tick. Automation is sequences, not single calls -- a buff pass casts
several spells and must wait between them. Without a host tick a plugin
would need its own timer thread re-entering the host off its update thread.
* NoOpAutomationSurface for hosts with no live session, so a plugin keeps one
code path and checks IsAvailable.
Markup gained <button> and <label>; it previously supported only <meter>, with
a comment promising the rest. Buttons bind onclick to an Action property and
FAIL THE PANEL LOAD if it does not resolve -- a silently dead button is worse
than a panel that refuses to load, because the user clicks and there is
nothing to diagnose. Labels bind through a Func so a status line tracks its
binding instead of freezing at build time.
Enchantment reads use EnchantmentsInEffectSnapshot rather than the raw active
set: retail leaves a weaker same-family enchantment in the registry while a
stronger one is in force, and a plugin asking "am I buffed?" means in force.
BuffPlan is a pure function of (known buffs, active enchantments) precisely so
it can be tested without a session; 9 tests cover tier supersede, the
family-0 no-stack bucket that must not be de-duplicated, expiry refresh, and
plan stability across the rebuilds the tick loop performs.
Solution builds clean; 14,421 tests pass on the standard hermetic lane filter,
0 failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>