fix(vt): F host-composed VtankProfiles storage replaces raw path string

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>
This commit is contained in:
Erik 2026-09-06 23:03:04 +02:00
parent 8de69b9741
commit 2040f2bcfb
18 changed files with 318 additions and 164 deletions

View file

@ -162,6 +162,12 @@ namespace nor a machine-specific path. Hosts without durable storage expose
The additive `List(prefix)` operation enumerates only keys inside that same
authenticated namespace, allowing plugins to discover explicit import/export
files without receiving a filesystem path or crossing plugin ownership.
`IPluginHost.VtankProfiles` is a second, unscoped `IPluginStorage` — one
shared external location (a real installed VTank's own profile folder, or a
host-composed portable default) rather than per-plugin data — so a
VTank-compatible plugin (`AcDream.Plugins.MossTank.VtankProfileDirectory`)
can enumerate real `.usd`/`.ast`/`.af` files through the same BCL-only
contract without ever touching `System.IO` or resolving its own path.
`IPluginHost.Automation` is the additive gameplay-automation projection. Its
character, spell, magic, chat, combat, equipment, item, loot, fellowship,

View file

@ -238,6 +238,7 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv"
| `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=<feature-name>` (case-insensitive property name, e.g. `MultiDrawIndirect`) | Test knob (Slice V5): clears one named required Vulkan feature from the capability record to synthetically fail the gate, so the `NotSupportedException` → exit-code-4 → report path can be exercised on hardware that actually supports everything. | Deliberately breaks Vulkan startup when set to a matched feature name — this is a "make it fail on purpose" gate-testing flag, never appropriate for a normal or measurement run. | `null` → real capabilities used unmodified | `RuntimeOptions.VulkanForcedUnsupportedFeature``VulkanCapabilityRecord.Without` (`VulkanCapabilityRecord.cs:113-119`), consumed at `VulkanGraphicsContext.cs:339` |
| `ACDREAM_VULKAN_PROBE` | `=1` | Runs the standalone Vulkan capability-probe/bring-up harness (opens its own window, runs the capability gate, presents synthetic V6c/V6d verification scenes, captures one screenshot) **instead of** the real client composition host, then exits. | This flag ALONE gates entry (`GameWindow.cs:828`); the former `ACDREAM_RENDER_BACKEND=vulkan` co-requisite died with the OpenGL backend (its class doc was corrected 2026-08-24). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe``GameWindow.cs:828``VulkanBringUpHost` |
| `ACDREAM_VULKAN_PROBE_FRAMES` | `=<int>` (non-negative) | Bounds the bring-up probe harness to N presented frames so it can run unattended in CI, instead of presenting until a human closes the window. | The frame budget never cuts a pending screenshot capture short — the loop stays open until the screenshot has been attempted even past the budget, so an unattended run's whole product (a PNG) is guaranteed. Zero (unset/unparseable/explicit `0`) keeps the interactive wait-for-close behavior. | `0` → interactive (wait for window close) | `RuntimeOptions.VulkanCapabilityProbeFrames``VulkanBringUpHost.cs:141-249` |
| `ACDREAM_VTANK_PROFILE_DIR` | `=<path>` | Overrides the directory `IPluginHost.VtankProfiles` (a VTank-compatible plugin's real `.usd`/`.ast`/`.af` profile storage — see `AcDream.Plugins.MossTank.VtankProfileDirectory`) is rooted at, composed as a `FilePluginStorage`. Set it to a real installed VTank's own profile folder (e.g. `C:\Games\VirindiPlugins\VirindiTank`) for direct interop. | Redirects only that one plugin-storage root; no other startup behavior changes. An unset/empty-string value is treated as "no override" (`NullIfEmpty`); whitespace-only is NOT special-cased (matches every other `NullIfEmpty`-read flag, e.g. `ACDREAM_AC_DIR`/`ACDREAM_UI_PROBE_SCRIPT`) and would be used as a literal (almost certainly invalid) root. | unset → `<ApplicationPathSet.DataDirectory>/vtank` | `RuntimeOptions.VtankProfileDirectoryOverride``Program.cs` (composes `AppPluginHost`'s `vtankProfiles` argument). The Headless host has no equivalent override (its path overrides are `HeadlessPathOverrides`, not env vars) and always uses `HeadlessPathSet.VtankProfilesDirectory` (`<DataDirectory>/vtank`). |
| `ACDREAM_DUMP_MOVE_TRUTH` | `=1` | Emits one `move-truth OUT` line per outbound movement record (MoveToState / AutonomousPosition): local resolved position vs the wire position/cell, ground contact, velocity (`MovementTruthDiagnosticController`). | **Automation apparatus, NOT a spent probe** — the canonical nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on ≥2 of these lines per destination as its proof that production input produced outbound movement traffic; deleting it fails the soak at every stop (#437, deleted-and-restored 2026-08-24). Print volume follows the outbound send cadence. | off | `RuntimeOptions.DumpMoveTruth``GameWindow.cs``MovementTruthDiagnosticController` |
## Permanent diagnostics

View file

@ -13,7 +13,8 @@ public sealed class AppPluginHost : IPluginHost
IAutomationSurface automation,
IPluginStorage? storage = null,
IPluginCommandRegistry? commands = null,
IPluginLootClassifierRegistry? lootClassifiers = null)
IPluginLootClassifierRegistry? lootClassifiers = null,
IPluginStorage? vtankProfiles = null)
{
Log = log;
State = state;
@ -25,6 +26,7 @@ public sealed class AppPluginHost : IPluginHost
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
LootClassifiers = lootClassifiers
?? NoOpPluginLootClassifierRegistry.Instance;
VtankProfiles = vtankProfiles ?? NoOpPluginStorage.Instance;
}
public bool HasUi => true;
@ -37,4 +39,5 @@ public sealed class AppPluginHost : IPluginHost
public IPluginStorage Storage { get; }
public IPluginCommandRegistry Commands { get; }
public IPluginLootClassifierRegistry LootClassifiers { get; }
public IPluginStorage VtankProfiles { get; }
}

View file

@ -26,7 +26,12 @@ internal sealed class FilePluginStorage : IPluginStorage
public IReadOnlyList<string> List(string prefix)
{
string directory = Resolve(prefix);
ArgumentNullException.ThrowIfNull(prefix);
// An empty prefix means "the storage root itself" — Resolve()
// rejects an empty/whitespace key (every other caller of it means
// one specific file or sub-directory), so this is handled directly
// rather than relaxing that guard for every other use.
string directory = prefix.Length == 0 ? _root : Resolve(prefix);
if (!Directory.Exists(directory))
return Array.Empty<string>();
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)

View file

@ -185,7 +185,10 @@ var host = new AppPluginHost(
new FilePluginStorage(
Path.Combine(applicationPaths.ConfigDirectory, "plugins")),
automation.PluginCommands,
lootClassifiers);
lootClassifiers,
new FilePluginStorage(
runtimeOptions.VtankProfileDirectoryOverride
?? Path.Combine(applicationPaths.DataDirectory, "vtank")));
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
applicationPaths,
runtimeOptions.Plugins,

View file

@ -136,6 +136,17 @@ public sealed record RuntimeOptions(
/// process configuration directly.</summary>
public IReadOnlyList<string> PluginTags { get; init; } = [];
/// <summary>
/// <c>ACDREAM_VTANK_PROFILE_DIR</c> override for the directory a
/// VTank-compatible plugin's <c>IPluginHost.VtankProfiles</c> storage is
/// rooted at (real <c>.usd</c>/<c>.ast</c>/<c>.af</c> files — e.g. a real
/// installed VTank's own profile folder for direct interop).
/// <see langword="null"/> (the default) means "no opinion": the host
/// composes <c>applicationPaths.DataDirectory/vtank</c> instead. See
/// <c>docs/launch-options.md</c>.
/// </summary>
public string? VtankProfileDirectoryOverride { get; init; }
/// <summary>
/// Build options from the process environment. Used by
/// <c>Program.cs</c> at startup.
@ -271,6 +282,7 @@ public sealed record RuntimeOptions(
LoginCommandDelayMs: 500)
{
PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")),
VtankProfileDirectoryOverride = NullIfEmpty(env("ACDREAM_VTANK_PROFILE_DIR")),
};
}

View file

@ -46,6 +46,14 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
public ISelectionService Selection => _selection;
public IUiRegistry Ui => _ui;
public IPluginStorage Storage => _storage;
/// <summary>
/// Forwarded, not scoped, unlike <see cref="Storage"/>: the VTank
/// profile folder is one shared external location (real VTank's own
/// files, or a host-composed portable default), not per-plugin data —
/// scoping it under this plugin's manifest id would defeat the whole
/// point of pointing it at a real installed VTank profile directory.
/// </summary>
public IPluginStorage VtankProfiles => _inner.VtankProfiles;
public IPluginCommandRegistry Commands => _commands;
public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers;

View file

@ -61,6 +61,8 @@ internal sealed class HeadlessProcessHost : IDisposable
Path.Combine(AppContext.BaseDirectory, "plugins"),
paths.PluginsDirectory,
];
var vtankProfiles = new AcDream.Headless.Plugins.FilePluginStorage(
paths.VtankProfilesDirectory);
HeadlessProcessContentOwner? content = null;
HeadlessProcessResourceSampler? resources = null;
// FA6: constructed unconditionally — cheap, and every non-gate
@ -110,7 +112,8 @@ internal sealed class HeadlessProcessHost : IDisposable
timeProvider,
contentLease: contentLease,
gateCoordinator: gateCoordinator,
pluginRoots: pluginRoots));
pluginRoots: pluginRoots,
vtankProfiles: vtankProfiles));
}
catch
{

View file

@ -3,6 +3,7 @@ using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics;
using AcDream.Headless.Plugins;
using AcDream.Headless.Policies;
using AcDream.Plugin.Abstractions;
using AcDream.Content.CharGen;
using AcDream.Core.Chat;
using AcDream.Core.Net.Messages;
@ -266,7 +267,8 @@ internal sealed class HeadlessSessionHost : IDisposable
IHeadlessBotPolicy? policyOverride = null,
IRuntimePlacementProjectionSink? placementSinkOverride = null,
FellowshipAllegianceGateCoordinator? gateCoordinator = null,
IEnumerable<string>? pluginRoots = null)
IEnumerable<string>? pluginRoots = null,
IPluginStorage? vtankProfiles = null)
{
_descriptor = descriptor
?? throw new ArgumentNullException(nameof(descriptor));
@ -367,7 +369,8 @@ internal sealed class HeadlessSessionHost : IDisposable
descriptor.Id,
pluginRoots ?? [],
descriptor.Plugins,
pluginCommands);
pluginCommands,
vtankProfiles);
var liveSession = new LiveSessionHost(
runtime.Session,
new LiveSessionHostBindings(

View file

@ -11,6 +11,17 @@ internal sealed record HeadlessPathSet(
internal string PluginsDirectory =>
Path.Combine(DataDirectory, "plugins");
/// <summary>
/// Default root for <see cref="AcDream.Plugin.Abstractions.IPluginHost.VtankProfiles"/>
/// (Campaign VT slice-1 fix round, item F). No override mechanism of its
/// own yet — unlike the graphical host's <c>ACDREAM_VTANK_PROFILE_DIR</c>,
/// which only exists on <c>AcDream.App.RuntimeOptions</c> — because
/// headless path overrides already go through <c>HeadlessPathOverrides</c>
/// (config file / <c>--data-dir</c>), not environment variables.
/// </summary>
internal string VtankProfilesDirectory =>
Path.Combine(DataDirectory, "vtank");
internal static HeadlessPathSet Resolve(
HeadlessPathOverrides overrides,
IHeadlessPlatformEnvironment? platform = null)

View file

@ -0,0 +1,99 @@
using System.Text;
using AcDream.Plugin.Abstractions;
namespace AcDream.Headless.Plugins;
/// <summary>
/// Crash-safe filesystem <see cref="IPluginStorage"/> — byte-identical
/// contract to <c>AcDream.App.Plugins.FilePluginStorage</c>. Duplicated
/// rather than shared: <c>AcDream.Headless</c> does not (and per the
/// no-window/graphical layer split should not) reference <c>AcDream.App</c>,
/// and no shared "platform plugins" library exists yet to host one copy of
/// this ~70-line class for both hosts. Promoting it there is a reasonable
/// future cleanup, not required for Campaign VT slice-1 item F.
/// </summary>
internal sealed class FilePluginStorage : IPluginStorage
{
private readonly string _root;
internal FilePluginStorage(string root)
{
ArgumentException.ThrowIfNullOrWhiteSpace(root);
_root = Path.GetFullPath(root);
}
public bool IsAvailable => true;
public string? ReadText(string key)
{
string path = Resolve(key);
return File.Exists(path)
? File.ReadAllText(path, Encoding.UTF8)
: null;
}
public IReadOnlyList<string> List(string prefix)
{
ArgumentNullException.ThrowIfNull(prefix);
// An empty prefix means "the storage root itself" — Resolve()
// rejects an empty/whitespace key (every other caller of it means
// one specific file or sub-directory), so this is handled directly
// rather than relaxing that guard for every other use.
string directory = prefix.Length == 0 ? _root : Resolve(prefix);
if (!Directory.Exists(directory))
return Array.Empty<string>();
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
.Select(path => Path.GetRelativePath(_root, path)
.Replace(Path.DirectorySeparatorChar, '/'))
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public void WriteText(string key, string content)
{
ArgumentNullException.ThrowIfNull(content);
string path = Resolve(key);
string directory = Path.GetDirectoryName(path)!;
Directory.CreateDirectory(directory);
string temporary = Path.Combine(
directory,
$".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
File.WriteAllText(temporary, content, new UTF8Encoding(false));
File.Move(temporary, path, overwrite: true);
}
finally
{
if (File.Exists(temporary))
File.Delete(temporary);
}
}
public bool Delete(string key)
{
string path = Resolve(key);
if (!File.Exists(path))
return false;
File.Delete(path);
return true;
}
private string Resolve(string key)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Path.IsPathRooted(key))
throw new ArgumentException("Plugin storage keys must be relative.", nameof(key));
string path = Path.GetFullPath(Path.Combine(_root, key));
string relative = Path.GetRelativePath(_root, path);
if (Path.IsPathRooted(relative)
|| relative.Equals("..", StringComparison.Ordinal)
|| relative.StartsWith(
".." + Path.DirectorySeparatorChar,
StringComparison.Ordinal))
{
throw new ArgumentException("Plugin storage key escapes its root.", nameof(key));
}
return path;
}
}

View file

@ -39,17 +39,20 @@ internal sealed class HeadlessPluginHost
internal HeadlessPluginHost(
GameRuntime runtime,
IPluginLogger logger,
IPluginCommandRegistry? commands = null)
IPluginCommandRegistry? commands = null,
IPluginStorage? vtankProfiles = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
Log = logger ?? throw new ArgumentNullException(nameof(logger));
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
VtankProfiles = vtankProfiles ?? NoOpPluginStorage.Instance;
_eventSubscription = runtime.Subscribe(this);
}
public bool HasUi => false;
public IPluginLogger Log { get; }
public IPluginCommandRegistry Commands { get; }
public IPluginStorage VtankProfiles { get; }
public IGameState State => this;
public IEvents Events => this;
public ISelectionService Selection => _runtime.ActionOwner.Selection;

View file

@ -45,7 +45,8 @@ internal sealed class HeadlessPluginSession : IDisposable
string sessionId,
IEnumerable<string> roots,
IReadOnlyList<string>? allowList,
IPluginCommandRegistry? commands = null)
IPluginCommandRegistry? commands = null,
IPluginStorage? vtankProfiles = null)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(diagnostics);
@ -59,7 +60,8 @@ internal sealed class HeadlessPluginSession : IDisposable
diagnostics,
sessionId,
() => runtime.Generation.Value),
commands);
commands,
vtankProfiles);
var plugins = new PluginSession(
host,
status => Report(statusWriter, sessionId, status),

View file

@ -44,16 +44,20 @@ public interface IPluginHost
IAutomationSurface Automation { get; }
/// <summary>
/// Absolute filesystem directory a VTank-compatible plugin should treat
/// as the VTank profile folder (real <c>.usd</c>/<c>.ast</c>/<c>.af</c>
/// files, VTank's own naming rules). <see langword="null"/> when the
/// host has no opinion, in which case the plugin falls back to its own
/// portable per-OS default (never a hard-coded Windows path) — see
/// <c>AcDream.Plugins.MossTank.VtankProfileDirectory</c>. A graphical
/// host may point this at a real installed VTank's own profile
/// directory for direct interop; that platform-specific discovery
/// belongs entirely to the host composing this property, never to the
/// plugin reading it.
/// Storage rooted at the VTank profile folder (real <c>.usd</c>/
/// <c>.ast</c>/<c>.af</c> files, VTank's own naming rules) rather than
/// this plugin's own scoped <see cref="Storage"/> directory — see
/// <c>AcDream.Plugins.MossTank.VtankProfileDirectory</c>, which
/// enumerates through this property exclusively (no <c>System.IO</c>,
/// no per-OS portable-default fallback of its own) so directory
/// discovery stays entirely host-composed. Defaults to the inert
/// <see cref="NoOpPluginStorage"/> (<c>IsAvailable</c> false) when the
/// host has no opinion. A graphical host may root this at a real
/// installed VTank's own profile directory for direct interop, or at
/// its own portable per-OS default under
/// <c>ApplicationPathSet.DataDirectory</c>; that discovery belongs
/// entirely to the host composing this property, never to the plugin
/// reading it.
/// </summary>
string? VtankProfileDirectory => null;
IPluginStorage VtankProfiles => NoOpPluginStorage.Instance;
}

View file

@ -3,27 +3,24 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Resolves the on-disk VTank profile directory and implements VTank's real
/// naming/selection rules (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c>
/// section 3): the per-character auto file, the longer <c>--Name_Server_</c>
/// Implements VTank's real profile-directory naming/selection rules
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3):
/// the per-character auto file, the longer <c>--Name_Server_</c>
/// sub-profile prefix, and which filenames a given character can see.
///
/// The directory itself is never hard-coded here: <see cref="Resolve"/>
/// prefers <see cref="IPluginHost.VtankProfileDirectory"/> (an App-composed
/// path — on Windows that may be a real installed VTank's own profile
/// folder for direct interop; that discovery belongs to the host, not this
/// plugin) and only falls back to a portable, cross-platform default under
/// the user's own local-app-data directory when the host has no opinion.
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/> resolves to
/// <c>%LOCALAPPDATA%</c> on Windows and (via .NET's XDG-aware base-directory
/// resolution) <c>$XDG_DATA_HOME</c> (or <c>~/.local/share</c>) on Linux —
/// built exclusively with <see cref="Path.Combine(string, string)"/>, so it
/// never contains a literal backslash.
/// The directory itself is never resolved here: every listing method reads
/// through <see cref="IPluginHost.VtankProfiles"/> (an <see cref="IPluginStorage"/>
/// the App/Headless host composes — real installed VTank's own profile
/// folder for direct interop, or a portable per-OS default under
/// <c>ApplicationPathSet.DataDirectory</c>). This class has no
/// <c>System.IO</c> dependency and no portable-default fallback of its
/// own: when the host has no opinion, <see cref="IPluginHost.VtankProfiles"/>
/// defaults to the inert <c>NoOpPluginStorage</c> (<c>IsAvailable</c>
/// false), and every listing here degrades to just its built-in entries,
/// exactly like a missing directory used to.
/// </summary>
internal static class VtankProfileDirectory
{
private const string PortableFolderName = "vtank";
/// <summary>The real VTank "--" reserved-prefix marker (section 3).</summary>
internal const string HiddenPrefix = "--";
@ -38,19 +35,6 @@ internal static class VtankProfileDirectory
internal const string DefaultLabel = "[Default]";
internal const string NoneLabel = "[None]";
public static string Resolve(IPluginHost host)
{
ArgumentNullException.ThrowIfNull(host);
return !string.IsNullOrWhiteSpace(host.VtankProfileDirectory)
? host.VtankProfileDirectory
: PortableDefault();
}
internal static string PortableDefault() => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"acdream",
PortableFolderName);
/// <summary>
/// VTank's single per-character default filename
/// (<c>uTank2/PluginCore.cs:3863-3865</c>): <c>--Name_Server.ext</c>.
@ -123,7 +107,7 @@ internal static class VtankProfileDirectory
/// <c>[Char] suffix</c>.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListSettingsProfiles(
string directory,
IPluginStorage storage,
string characterName,
string server,
bool mineOnly)
@ -133,7 +117,7 @@ internal static class VtankProfileDirectory
new(string.Empty, DefaultLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(directory, "*.usd"))
foreach (string fileName in EnumerateFileNames(storage, ".usd"))
{
string? subProfileDisplay = TryDisplayName(fileName, characterName, server);
if (subProfileDisplay is not null)
@ -156,14 +140,14 @@ internal static class VtankProfileDirectory
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
/// <c>.af</c> file that starts with neither <c>--</c> nor <c>~~</c>.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(string directory)
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(IPluginStorage storage)
{
var entries = new List<ProfileEntry>
{
new(string.Empty, NoneLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(directory, "*.af"))
foreach (string fileName in EnumerateFileNames(storage, ".af"))
{
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|| fileName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal))
@ -185,14 +169,14 @@ internal static class VtankProfileDirectory
/// itself distinguished by the separate <c>.met</c>/<c>.nav</c>
/// extensions).
/// </summary>
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(string directory)
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(IPluginStorage storage)
{
var entries = new List<ProfileEntry>
{
new(string.Empty, NoneLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(directory, "*.af"))
foreach (string fileName in EnumerateFileNames(storage, ".af"))
{
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
continue;
@ -201,14 +185,24 @@ internal static class VtankProfileDirectory
return entries;
}
private static IEnumerable<string> EnumerateFileNames(string directory, string searchPattern)
/// <summary>
/// Lists root-level file names matching <paramref name="extension"/>
/// through <see cref="IPluginStorage.List"/> alone — no
/// <c>System.IO</c>. VTank's own profile directory is flat, so a key
/// containing '/' (meaning it lives in some deeper storage
/// implementation's sub-directory) is not one of these files and is
/// skipped rather than surfaced as a bogus profile name.
/// </summary>
private static IEnumerable<string> EnumerateFileNames(IPluginStorage storage, string extension)
{
if (!Directory.Exists(directory))
if (!storage.IsAvailable)
yield break;
foreach (string path in Directory.EnumerateFiles(directory, searchPattern)
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase))
foreach (string key in storage.List(string.Empty)
.Where(key => !key.Contains('/', StringComparison.Ordinal))
.Where(key => key.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase))
{
yield return Path.GetFileName(path);
yield return key;
}
}
}

View file

@ -169,6 +169,7 @@ public sealed class RuntimeOptionsTests
Assert.False(opts.UiProbeEnabled);
Assert.False(opts.HasLiveCredentials);
Assert.Empty(opts.PluginTags);
Assert.Null(opts.VtankProfileDirectoryOverride);
}
[Fact]
@ -186,6 +187,26 @@ public sealed class RuntimeOptionsTests
Assert.Equal(["healer", "Leader", "scout"], options.PluginTags);
}
[Fact]
public void VtankProfileDirectoryOverrideIsNullUnlessSet()
{
RuntimeOptions blank = RuntimeOptions.Parse(
AnyDatDir,
Env(new() { ["ACDREAM_VTANK_PROFILE_DIR"] = "" }));
Assert.Null(blank.VtankProfileDirectoryOverride);
RuntimeOptions set = RuntimeOptions.Parse(
AnyDatDir,
Env(new()
{
["ACDREAM_VTANK_PROFILE_DIR"] =
"C:/Games/VirindiPlugins/VirindiTank",
}));
Assert.Equal(
"C:/Games/VirindiPlugins/VirindiTank",
set.VtankProfileDirectoryOverride);
}
[Fact]
public void PreparedAssetPath_DefaultsBesideDats_AndAllowsOneOverride()
{

View file

@ -35,6 +35,26 @@ public sealed class PluginSessionTests
alpha.Storage.WriteText("../escape.json", "bad"));
}
// Item F (Campaign VT slice-1 fix round): VtankProfiles must be
// forwarded UNSCOPED, unlike Storage — it names one shared external
// location (a real VTank profile folder, or a host-composed portable
// default), not per-plugin data, so prefixing it by manifest id would
// defeat the point of pointing it at a real installed VTank directory.
[Fact]
public void ScopedHostForwardsVtankProfilesUnscoped()
{
var vtankProfiles = new MemoryStorage();
using var scope = new ScopedPluginHost(
new StubHost(vtankProfiles: vtankProfiles),
"acdream.alpha",
"Alpha");
scope.VtankProfiles.WriteText("Shared.usd", "content");
Assert.Same(vtankProfiles, scope.VtankProfiles);
Assert.Equal("content", vtankProfiles.Text["Shared.usd"]);
}
[Fact]
public void ScopedHostNamespacesAndUnregistersLootClassifierOnDispose()
{
@ -364,7 +384,8 @@ public sealed class PluginSessionTests
private sealed class StubHost(
IPluginStorage? storage = null,
IPluginLootClassifierRegistry? lootClassifiers = null) : IPluginHost
IPluginLootClassifierRegistry? lootClassifiers = null,
IPluginStorage? vtankProfiles = null) : IPluginHost
{
public bool HasUi => false;
public IPluginLogger Log { get; } = new StubLogger();
@ -377,6 +398,8 @@ public sealed class PluginSessionTests
storage ?? NoOpPluginStorage.Instance;
public IPluginLootClassifierRegistry LootClassifiers { get; } =
lootClassifiers ?? NoOpPluginLootClassifierRegistry.Instance;
public IPluginStorage VtankProfiles { get; } =
vtankProfiles ?? NoOpPluginStorage.Instance;
}
private sealed class KeepClassifier : IPluginLootClassifier

View file

@ -4,51 +4,6 @@ namespace AcDream.Plugins.MossTank.Tests;
public sealed class VtankProfileDirectoryTests
{
// Linux-path test: the portable default must be built with Path.Combine
// only (never a hard-coded Windows-style backslash path), so it stays
// correct on Linux CI. This does not merely check for backslashes (a
// Windows machine's own LocalApplicationData root may legitimately
// contain one) — it asserts the *plugin-owned suffix* is combined with
// forward-slash-safe path segments by re-deriving it the same way and
// comparing, so a future hard-coded "acdream\vtank" typo would fail this
// test on any OS.
[Fact]
public void PortableDefaultIsBuiltWithPathCombineOnly()
{
string expected = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"acdream",
"vtank");
Assert.Equal(expected, VtankProfileDirectory.PortableDefault());
Assert.DoesNotContain("acdream\\vtank", VtankProfileDirectory.PortableDefault()
.Replace(Path.DirectorySeparatorChar, '/'), StringComparison.Ordinal);
}
[Fact]
public void ResolvePrefersHostSuppliedDirectoryOverPortableDefault()
{
var host = new FakeHost("C:/Games/VirindiPlugins/VirindiTank");
Assert.Equal(
"C:/Games/VirindiPlugins/VirindiTank",
VtankProfileDirectory.Resolve(host));
}
[Fact]
public void ResolveFallsBackToPortableDefaultWhenHostHasNoOpinion()
{
var host = new FakeHost(null);
Assert.Equal(VtankProfileDirectory.PortableDefault(), VtankProfileDirectory.Resolve(host));
}
[Theory]
[InlineData("", false)]
[InlineData(null, false)]
public void ResolveTreatsBlankHostDirectoryAsNoOpinion(string? value, bool _)
{
var host = new FakeHost(value);
Assert.Equal(VtankProfileDirectory.PortableDefault(), VtankProfileDirectory.Resolve(host));
}
[Fact]
public void AutoCharacterFileNameMatchesRealInstalledConvention()
{
@ -97,84 +52,82 @@ public sealed class VtankProfileDirectoryTests
[Fact]
public void ListSettingsProfilesSeedsDefaultAndByCharFirst()
{
string directory = Path.Combine(Path.GetTempPath(), "acdream-vt-tests-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
try
{
File.WriteAllText(Path.Combine(directory, "Shared.usd"), "1\r\n");
File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve.usd"), "1\r\n");
File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve_Base.usd"), "1\r\n");
File.WriteAllText(Path.Combine(directory, "--Someone_Coldeve.usd"), "1\r\n");
var storage = new MemoryStorage();
storage.WriteText("Shared.usd", "1\r\n");
storage.WriteText("--Barris_Coldeve.usd", "1\r\n");
storage.WriteText("--Barris_Coldeve_Base.usd", "1\r\n");
storage.WriteText("--Someone_Coldeve.usd", "1\r\n");
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
VtankProfileDirectory.ListSettingsProfiles(
directory, "Barris", "Coldeve", mineOnly: false);
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
VtankProfileDirectory.ListSettingsProfiles(
storage, "Barris", "Coldeve", mineOnly: false);
Assert.Equal(VtankProfileDirectory.DefaultLabel, entries[0].DisplayName);
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
Assert.Contains(entries, static e => e.DisplayName == "Shared.usd");
Assert.Contains(entries, static e => e.DisplayName == "[Char] Base");
Assert.DoesNotContain(entries, static e => e.FileName == "--Someone_Coldeve.usd");
Assert.DoesNotContain(entries, static e => e.FileName == "--Barris_Coldeve.usd");
}
finally
{
Directory.Delete(directory, recursive: true);
}
Assert.Equal(VtankProfileDirectory.DefaultLabel, entries[0].DisplayName);
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
Assert.Contains(entries, static e => e.DisplayName == "Shared.usd");
Assert.Contains(entries, static e => e.DisplayName == "[Char] Base");
Assert.DoesNotContain(entries, static e => e.FileName == "--Someone_Coldeve.usd");
Assert.DoesNotContain(entries, static e => e.FileName == "--Barris_Coldeve.usd");
}
[Fact]
public void ListNavigationProfilesFiltersBothReservedPrefixes()
{
string directory = Path.Combine(Path.GetTempPath(), "acdream-vt-tests-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
try
{
File.WriteAllText(Path.Combine(directory, "Hunt.af"), "1\r\n");
File.WriteAllText(Path.Combine(directory, "--Barris_Coldeve.af"), "1\r\n");
File.WriteAllText(Path.Combine(directory, "~~backup.af"), "1\r\n");
var storage = new MemoryStorage();
storage.WriteText("Hunt.af", "1\r\n");
storage.WriteText("--Barris_Coldeve.af", "1\r\n");
storage.WriteText("~~backup.af", "1\r\n");
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
VtankProfileDirectory.ListNavigationProfiles(directory);
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
VtankProfileDirectory.ListNavigationProfiles(storage);
Assert.Equal(VtankProfileDirectory.NoneLabel, entries[0].DisplayName);
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
Assert.Contains(entries, static e => e.DisplayName == "Hunt.af");
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal));
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("~~", StringComparison.Ordinal));
}
finally
{
Directory.Delete(directory, recursive: true);
}
Assert.Equal(VtankProfileDirectory.NoneLabel, entries[0].DisplayName);
Assert.Equal(VtankProfileDirectory.ByCharacterLabel, entries[1].DisplayName);
Assert.Contains(entries, static e => e.DisplayName == "Hunt.af");
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("--", StringComparison.Ordinal));
Assert.DoesNotContain(entries, static e => e.FileName.StartsWith("~~", StringComparison.Ordinal));
}
[Fact]
public void ListingsOnMissingDirectoryOnlySeedTheBuiltInEntries()
public void ListingsOnUnavailableStorageOnlySeedTheBuiltInEntries()
{
string missing = Path.Combine(Path.GetTempPath(), "acdream-vt-missing-" + Guid.NewGuid());
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
VtankProfileDirectory.ListSettingsProfiles(missing, "Barris", "Coldeve", mineOnly: false);
VtankProfileDirectory.ListSettingsProfiles(
NoOpPluginStorage.Instance, "Barris", "Coldeve", mineOnly: false);
Assert.Equal(2, entries.Count);
}
private sealed class FakeHost(string? vtankProfileDirectory) : IPluginHost
// Item F (Campaign VT slice-1 fix round): a nested-path key (as a real
// IPluginStorage implementation might return for a sub-directory) must
// never surface as a bogus profile name — VTank's own profile
// directory is flat.
[Fact]
public void NestedPathKeysAreNotTreatedAsProfileFiles()
{
public bool HasUi => false;
public IPluginLogger Log => NoOpLogger.Instance;
public IGameState State => throw new NotSupportedException();
public IEvents Events => throw new NotSupportedException();
public ISelectionService Selection => throw new NotSupportedException();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
public string? VtankProfileDirectory => vtankProfileDirectory;
var storage = new MemoryStorage();
storage.WriteText("subdir/Nested.usd", "1\r\n");
storage.WriteText("Flat.usd", "1\r\n");
IReadOnlyList<VtankProfileDirectory.ProfileEntry> entries =
VtankProfileDirectory.ListSettingsProfiles(
storage, "Barris", "Coldeve", mineOnly: false);
Assert.Contains(entries, static e => e.FileName == "Flat.usd");
Assert.DoesNotContain(entries, static e => e.FileName.Contains('/'));
}
private sealed class NoOpLogger : IPluginLogger
private sealed class MemoryStorage : IPluginStorage
{
public static NoOpLogger Instance { get; } = new();
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? exception = null) { }
private readonly Dictionary<string, string> _text = new(StringComparer.Ordinal);
public bool IsAvailable => true;
public string? ReadText(string key) =>
_text.TryGetValue(key, out string? value) ? value : null;
public IReadOnlyList<string> List(string prefix) => _text.Keys
.Where(key => prefix.Length == 0
|| key.StartsWith(prefix + "/", StringComparison.Ordinal))
.OrderBy(static key => key, StringComparer.Ordinal)
.ToArray();
public void WriteText(string key, string content) => _text[key] = content;
public bool Delete(string key) => _text.Remove(key);
}
}