diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 1670411f..299ffa13 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -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, diff --git a/docs/launch-options.md b/docs/launch-options.md index 7518a463..67d13184 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -238,6 +238,7 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" | `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=` (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` | `=` (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` | `=` | 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 → `/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` (`/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 diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index 06cf8f02..09138531 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -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; } } diff --git a/src/AcDream.App/Plugins/FilePluginStorage.cs b/src/AcDream.App/Plugins/FilePluginStorage.cs index 85a0c60a..70081cbc 100644 --- a/src/AcDream.App/Plugins/FilePluginStorage.cs +++ b/src/AcDream.App/Plugins/FilePluginStorage.cs @@ -26,7 +26,12 @@ internal sealed class FilePluginStorage : IPluginStorage public IReadOnlyList 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(); return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index b736280d..ac54163c 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -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, diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index ea094108..dc8be991 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -136,6 +136,17 @@ public sealed record RuntimeOptions( /// process configuration directly. public IReadOnlyList PluginTags { get; init; } = []; + /// + /// ACDREAM_VTANK_PROFILE_DIR override for the directory a + /// VTank-compatible plugin's IPluginHost.VtankProfiles storage is + /// rooted at (real .usd/.ast/.af files — e.g. a real + /// installed VTank's own profile folder for direct interop). + /// (the default) means "no opinion": the host + /// composes applicationPaths.DataDirectory/vtank instead. See + /// docs/launch-options.md. + /// + public string? VtankProfileDirectoryOverride { get; init; } + /// /// Build options from the process environment. Used by /// Program.cs 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")), }; } diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs index 852bf063..8b75afae 100644 --- a/src/AcDream.Core/Plugins/ScopedPluginHost.cs +++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs @@ -46,6 +46,14 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable public ISelectionService Selection => _selection; public IUiRegistry Ui => _ui; public IPluginStorage Storage => _storage; + /// + /// Forwarded, not scoped, unlike : 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. + /// + public IPluginStorage VtankProfiles => _inner.VtankProfiles; public IPluginCommandRegistry Commands => _commands; public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers; diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index c620c52a..3a73be8b 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -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 { diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 9ff1ed74..0dbde856 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -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? pluginRoots = null) + IEnumerable? 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( diff --git a/src/AcDream.Headless/Platform/HeadlessPathSet.cs b/src/AcDream.Headless/Platform/HeadlessPathSet.cs index e8e110e2..1ee907d9 100644 --- a/src/AcDream.Headless/Platform/HeadlessPathSet.cs +++ b/src/AcDream.Headless/Platform/HeadlessPathSet.cs @@ -11,6 +11,17 @@ internal sealed record HeadlessPathSet( internal string PluginsDirectory => Path.Combine(DataDirectory, "plugins"); + /// + /// Default root for + /// (Campaign VT slice-1 fix round, item F). No override mechanism of its + /// own yet — unlike the graphical host's ACDREAM_VTANK_PROFILE_DIR, + /// which only exists on AcDream.App.RuntimeOptions — because + /// headless path overrides already go through HeadlessPathOverrides + /// (config file / --data-dir), not environment variables. + /// + internal string VtankProfilesDirectory => + Path.Combine(DataDirectory, "vtank"); + internal static HeadlessPathSet Resolve( HeadlessPathOverrides overrides, IHeadlessPlatformEnvironment? platform = null) diff --git a/src/AcDream.Headless/Plugins/FilePluginStorage.cs b/src/AcDream.Headless/Plugins/FilePluginStorage.cs new file mode 100644 index 00000000..34db9b45 --- /dev/null +++ b/src/AcDream.Headless/Plugins/FilePluginStorage.cs @@ -0,0 +1,99 @@ +using System.Text; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Headless.Plugins; + +/// +/// Crash-safe filesystem — byte-identical +/// contract to AcDream.App.Plugins.FilePluginStorage. Duplicated +/// rather than shared: AcDream.Headless does not (and per the +/// no-window/graphical layer split should not) reference AcDream.App, +/// 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. +/// +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 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(); + 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; + } +} diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs index 23909378..807a588a 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -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; diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs index 9110cb07..df9e4be7 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs @@ -45,7 +45,8 @@ internal sealed class HeadlessPluginSession : IDisposable string sessionId, IEnumerable roots, IReadOnlyList? 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), diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index 3c0e0b5f..5e93fff8 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -44,16 +44,20 @@ public interface IPluginHost IAutomationSurface Automation { get; } /// - /// Absolute filesystem directory a VTank-compatible plugin should treat - /// as the VTank profile folder (real .usd/.ast/.af - /// files, VTank's own naming rules). 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 - /// AcDream.Plugins.MossTank.VtankProfileDirectory. 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 .usd/ + /// .ast/.af files, VTank's own naming rules) rather than + /// this plugin's own scoped directory — see + /// AcDream.Plugins.MossTank.VtankProfileDirectory, which + /// enumerates through this property exclusively (no System.IO, + /// no per-OS portable-default fallback of its own) so directory + /// discovery stays entirely host-composed. Defaults to the inert + /// (IsAvailable 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 + /// ApplicationPathSet.DataDirectory; that discovery belongs + /// entirely to the host composing this property, never to the plugin + /// reading it. /// - string? VtankProfileDirectory => null; + IPluginStorage VtankProfiles => NoOpPluginStorage.Instance; } diff --git a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs index c69a8beb..d481edd1 100644 --- a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs +++ b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs @@ -3,27 +3,24 @@ using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; /// -/// Resolves the on-disk VTank profile directory and implements VTank's real -/// naming/selection rules (docs/research/vtank-kb/01-settings-and-profiles.md -/// section 3): the per-character auto file, the longer --Name_Server_ +/// Implements VTank's real profile-directory naming/selection rules +/// (docs/research/vtank-kb/01-settings-and-profiles.md section 3): +/// the per-character auto file, the longer --Name_Server_ /// sub-profile prefix, and which filenames a given character can see. /// -/// The directory itself is never hard-coded here: -/// prefers (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. -/// resolves to -/// %LOCALAPPDATA% on Windows and (via .NET's XDG-aware base-directory -/// resolution) $XDG_DATA_HOME (or ~/.local/share) on Linux — -/// built exclusively with , so it -/// never contains a literal backslash. +/// The directory itself is never resolved here: every listing method reads +/// through (an +/// the App/Headless host composes — real installed VTank's own profile +/// folder for direct interop, or a portable per-OS default under +/// ApplicationPathSet.DataDirectory). This class has no +/// System.IO dependency and no portable-default fallback of its +/// own: when the host has no opinion, +/// defaults to the inert NoOpPluginStorage (IsAvailable +/// false), and every listing here degrades to just its built-in entries, +/// exactly like a missing directory used to. /// internal static class VtankProfileDirectory { - private const string PortableFolderName = "vtank"; - /// The real VTank "--" reserved-prefix marker (section 3). 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); - /// /// VTank's single per-character default filename /// (uTank2/PluginCore.cs:3863-3865): --Name_Server.ext. @@ -123,7 +107,7 @@ internal static class VtankProfileDirectory /// [Char] suffix. /// public static IReadOnlyList 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 /// /, then every /// .af file that starts with neither -- nor ~~. /// - public static IReadOnlyList ListNavigationProfiles(string directory) + public static IReadOnlyList ListNavigationProfiles(IPluginStorage storage) { var entries = new List { 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 .met/.nav /// extensions). /// - public static IReadOnlyList ListMetaProfiles(string directory) + public static IReadOnlyList ListMetaProfiles(IPluginStorage storage) { var entries = new List { 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 EnumerateFileNames(string directory, string searchPattern) + /// + /// Lists root-level file names matching + /// through alone — no + /// System.IO. 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. + /// + private static IEnumerable 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; } } } diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index c5d3a39d..2f7dabea 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -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() { diff --git a/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs index a80f3644..6a7672e5 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginSessionTests.cs @@ -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 diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs index 93c09a08..18b3270b 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs @@ -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 entries = - VtankProfileDirectory.ListSettingsProfiles( - directory, "Barris", "Coldeve", mineOnly: false); + IReadOnlyList 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 entries = - VtankProfileDirectory.ListNavigationProfiles(directory); + IReadOnlyList 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 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 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 _text = new(StringComparer.Ordinal); + public bool IsAvailable => true; + public string? ReadText(string key) => + _text.TryGetValue(key, out string? value) ? value : null; + public IReadOnlyList 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); } }