diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs
index 5b030725..3c0e0b5f 100644
--- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs
+++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs
@@ -42,4 +42,18 @@ public interface IPluginHost
/// host kind.
///
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.
+ ///
+ string? VtankProfileDirectory => null;
}
diff --git a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
new file mode 100644
index 00000000..c69a8beb
--- /dev/null
+++ b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
@@ -0,0 +1,214 @@
+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_
+/// 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.
+///
+internal static class VtankProfileDirectory
+{
+ private const string PortableFolderName = "vtank";
+
+ /// The real VTank "--" reserved-prefix marker (section 3).
+ internal const string HiddenPrefix = "--";
+
+ ///
+ /// The nav-profile-only "~~" reserved prefix (section 3) — VTank's own
+ /// producer/purpose was not determined by the KB research pass either;
+ /// this only reproduces the filter.
+ ///
+ internal const string NavHiddenPrefix = "~~";
+
+ internal const string ByCharacterLabel = "[By char]";
+ 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.
+ ///
+ public static string AutoCharacterFileName(
+ string characterName,
+ string server,
+ string extension) =>
+ $"{HiddenPrefix}{characterName}_{server}.{extension.TrimStart('.')}";
+
+ ///
+ /// The longer, trailing-underscore prefix
+ /// (uTank2/PluginCore.cs:933,3866, field dw) that marks a
+ /// *named sub-profile* belonging to one character, distinct from that
+ /// character's single auto file above.
+ ///
+ public static string SubProfilePrefix(string characterName, string server) =>
+ $"{HiddenPrefix}{characterName}_{server}_";
+
+ ///
+ /// True when must be hidden from the
+ /// cross-character profile picker for /
+ /// : any ---prefixed name that is not
+ /// this character's own sub-profile family
+ /// (uTank2/PluginCore.cs:7020,7072,7144).
+ ///
+ public static bool IsHiddenFromOtherCharacters(
+ string fileName,
+ string characterName,
+ string server) =>
+ fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
+ && !fileName.StartsWith(
+ SubProfilePrefix(characterName, server),
+ StringComparison.Ordinal);
+
+ ///
+ /// The "[Char] suffix" display form for one of this character's own
+ /// sub-profiles (uTank2/PluginCore.cs:7016-7046): the
+ /// --Name_Server_ prefix and the file extension are both
+ /// stripped. Returns for a filename that is not
+ /// one of this character's sub-profiles.
+ ///
+ public static string? TryDisplayName(
+ string fileName,
+ string characterName,
+ string server)
+ {
+ string prefix = SubProfilePrefix(characterName, server);
+ if (!fileName.StartsWith(prefix, StringComparison.Ordinal))
+ return null;
+ string withoutPrefix = fileName[prefix.Length..];
+ int dot = withoutPrefix.LastIndexOf('.');
+ string suffix = dot >= 0 ? withoutPrefix[..dot] : withoutPrefix;
+ return suffix.Length == 0 ? null : $"[Char] {suffix}";
+ }
+
+ ///
+ /// One entry in a profile picker: the real on-disk file name, and the
+ /// label VTank would show for it.
+ ///
+ public readonly record struct ProfileEntry(string FileName, string DisplayName);
+
+ ///
+ /// VTank's settings-profile list (a0(),
+ /// uTank2/PluginCore.cs:7057-7115): seeds
+ /// and first,
+ /// then every non--- .usd file (optionally filtered to
+ /// only this character's own, the "Mine only" checkbox), plus every one
+ /// of this character's own --Name_Server_* sub-profiles shown as
+ /// [Char] suffix.
+ ///
+ public static IReadOnlyList ListSettingsProfiles(
+ string directory,
+ string characterName,
+ string server,
+ bool mineOnly)
+ {
+ var entries = new List
+ {
+ new(string.Empty, DefaultLabel),
+ new(string.Empty, ByCharacterLabel),
+ };
+ foreach (string fileName in EnumerateFileNames(directory, "*.usd"))
+ {
+ string? subProfileDisplay = TryDisplayName(fileName, characterName, server);
+ if (subProfileDisplay is not null)
+ {
+ entries.Add(new ProfileEntry(fileName, subProfileDisplay));
+ continue;
+ }
+ if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
+ continue; // someone else's --Name_Server(.usd|_*) family.
+ if (mineOnly)
+ continue; // "Mine only": only the sub-profiles handled above.
+ entries.Add(new ProfileEntry(fileName, fileName));
+ }
+ return entries;
+ }
+
+ ///
+ /// VTank's navigation-profile list (l(),
+ /// uTank2/PluginCore.cs:7156-7185): seeds
+ /// /, then every
+ /// .af file that starts with neither -- nor ~~.
+ ///
+ public static IReadOnlyList ListNavigationProfiles(string directory)
+ {
+ var entries = new List
+ {
+ new(string.Empty, NoneLabel),
+ new(string.Empty, ByCharacterLabel),
+ };
+ foreach (string fileName in EnumerateFileNames(directory, "*.af"))
+ {
+ if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
+ || fileName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+ entries.Add(new ProfileEntry(fileName, fileName));
+ }
+ return entries;
+ }
+
+ ///
+ /// VTank's meta-profile list (ac(),
+ /// uTank2/PluginCore.cs:7187+): seeds
+ /// /, then every
+ /// non--- file (a meta and a nav profile share the same
+ /// directory and extension here — .af — so callers pass a
+ /// distinguishing sub-extension convention if they need one; VTank
+ /// itself distinguished by the separate .met/.nav
+ /// extensions).
+ ///
+ public static IReadOnlyList ListMetaProfiles(string directory)
+ {
+ var entries = new List
+ {
+ new(string.Empty, NoneLabel),
+ new(string.Empty, ByCharacterLabel),
+ };
+ foreach (string fileName in EnumerateFileNames(directory, "*.af"))
+ {
+ if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
+ continue;
+ entries.Add(new ProfileEntry(fileName, fileName));
+ }
+ return entries;
+ }
+
+ private static IEnumerable EnumerateFileNames(string directory, string searchPattern)
+ {
+ if (!Directory.Exists(directory))
+ yield break;
+ foreach (string path in Directory.EnumerateFiles(directory, searchPattern)
+ .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase))
+ {
+ yield return Path.GetFileName(path);
+ }
+ }
+}
diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs
deleted file mode 100644
index 105abdf0..00000000
--- a/tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs
+++ /dev/null
@@ -1,190 +0,0 @@
-namespace AcDream.Plugins.MossTank.Tests;
-
-public sealed class VtankMetaProfileSerializerTests
-{
- [Fact]
- public void LoadsKnownTypedCondActRecord()
- {
- const string source = "1\r\nCondAct\r\n5\r\nCType\r\nAType\r\n"
- + "CData\r\nAData\r\nState\r\nn\r\nn\r\nn\r\nn\r\nn\r\n1\r\n"
- + "i\r\n1\r\ni\r\n2\r\ni\r\n0\r\ns\r\n/say ready\r\n"
- + "s\r\nDefault\r\n";
-
- Assert.True(VtankMetaProfileSerializer.TryLoad(
- source, out MetaProfile profile, out string error), error);
-
- MetaRule rule = Assert.Single(profile.Rules);
- Assert.Equal(MetaConditionKind.Always, rule.Condition.Kind);
- Assert.Equal(MetaActionKind.ChatCommand, rule.Action.Kind);
- Assert.Equal("/say ready", rule.Action.Text);
- Assert.Equal("Default", rule.State);
- Assert.Equal(source, VtankMetaProfileSerializer.Save(profile));
- }
-
- [Fact]
- public void RoundTripPreservesEveryVtankConditionActionAndEmbeddedNav()
- {
- MetaCondition[] conditions = BuildConditions();
- MetaAction[] actions = BuildActions();
- var profile = new MetaProfile();
- for (int index = 0; index < conditions.Length; index++)
- {
- profile.Rules.Add(new MetaRule
- {
- State = $"State {index}",
- Condition = conditions[index],
- Action = actions[index % actions.Length],
- });
- }
- for (int index = conditions.Length; index < actions.Length; index++)
- {
- profile.Rules.Add(new MetaRule
- {
- State = $"Action {index}",
- Condition = MetaCondition.Always(),
- Action = actions[index],
- });
- }
-
- string first = VtankMetaProfileSerializer.Save(profile);
- Assert.True(VtankMetaProfileSerializer.TryLoad(
- first, out MetaProfile loaded, out string error), error);
- string second = VtankMetaProfileSerializer.Save(loaded);
-
- Assert.Equal(first, second);
- Assert.Equal(profile.Rules.Count, loaded.Rules.Count);
- MetaCondition priority = loaded.Rules.Single(rule =>
- rule.Condition.Kind == MetaConditionKind.MonsterPriorityCountWithinDistance)
- .Condition;
- Assert.Equal(2, priority.Number);
- Assert.Equal(18.5, priority.SecondaryNumber);
- Assert.Equal(7, priority.TertiaryNumber);
- MetaAction embedded = loaded.Rules.Select(static rule => rule.Action)
- .First(action => action.Kind == MetaActionKind.LoadEmbeddedNavigationRoute);
- Assert.Equal("One point", embedded.SecondaryText);
- Assert.Equal("uTank2 NAV 1.2\r\n4\r\n1\r\n0\r\n1\r\n2\r\n3\r\n0\r\n",
- embedded.Text);
- Assert.Contains("s\r\n", first, StringComparison.Ordinal);
- }
-
- [Fact]
- public void DisabledNativeRuleIsNotExportedAsExecutableLegacyRule()
- {
- var profile = new MetaProfile
- {
- Rules =
- [
- new MetaRule
- {
- Enabled = false,
- Condition = MetaCondition.Always(),
- Action = new MetaAction
- {
- Kind = MetaActionKind.ChatCommand,
- Text = "/say must not run",
- },
- },
- ],
- };
-
- string source = VtankMetaProfileSerializer.Save(profile);
- Assert.DoesNotContain("must not run", source, StringComparison.Ordinal);
- Assert.True(VtankMetaProfileSerializer.TryLoad(
- source, out MetaProfile loaded, out string error), error);
- Assert.Empty(loaded.Rules);
- }
-
- private static MetaCondition[] BuildConditions() =>
- [
- C(MetaConditionKind.Never),
- C(MetaConditionKind.Always),
- C(MetaConditionKind.All, children: [C(MetaConditionKind.Always)]),
- C(MetaConditionKind.Any, children: [C(MetaConditionKind.Never)]),
- C(MetaConditionKind.ChatMessage, "^ready$"),
- C(MetaConditionKind.PackSlotsLessThanOrEqual, number: 7),
- C(MetaConditionKind.SecondsInStateGreaterThanOrEqual, number: 12),
- C(MetaConditionKind.NavigationRouteEmpty),
- C(MetaConditionKind.CharacterDeath),
- C(MetaConditionKind.AnyVendorOpen),
- C(MetaConditionKind.VendorClosed),
- C(MetaConditionKind.InventoryItemCountLessThanOrEqual, "Prismatic Taper", 5),
- C(MetaConditionKind.InventoryItemCountGreaterThanOrEqual, "Pyreal", 10),
- C(MetaConditionKind.MonsterNameCountWithinDistance, "Olthoi.*", 3, 22.25),
- C(MetaConditionKind.MonsterPriorityCountWithinDistance,
- number: 2, secondaryNumber: 18.5, tertiaryNumber: 7),
- C(MetaConditionKind.NeedToBuff),
- C(MetaConditionKind.NoMonstersWithinDistance, number: 9.5),
- C(MetaConditionKind.LandblockEquals, number: unchecked((int)0x8B370000u)),
- C(MetaConditionKind.LandcellEquals, number: unchecked((int)0x8B37E3A1u)),
- C(MetaConditionKind.PortalspaceEntered),
- C(MetaConditionKind.PortalspaceExited),
- C(MetaConditionKind.Not, children: [C(MetaConditionKind.Never)]),
- C(MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual, number: 30),
- C(MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual,
- number: 3179, secondaryNumber: 45),
- C(MetaConditionKind.BurdenPercentGreaterThanOrEqual, number: 110),
- C(MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual, number: 13.75),
- C(MetaConditionKind.Expression, "getvar['go']==1"),
- C(MetaConditionKind.ChatMessageCapture, "^(?.+)$", secondary: "2;4"),
- ];
-
- private static MetaAction[] BuildActions() =>
- [
- A(MetaActionKind.None),
- A(MetaActionKind.SetMetaState, "Hunt"),
- A(MetaActionKind.ChatCommand, "/say hello"),
- A(MetaActionKind.All, children:
- [
- A(MetaActionKind.ChatCommand, "/say first"),
- A(MetaActionKind.ExpressionAction, "setvar['done',1]"),
- ]),
- A(MetaActionKind.LoadEmbeddedNavigationRoute,
- "uTank2 NAV 1.2\r\n4\r\n1\r\n0\r\n1\r\n2\r\n3\r\n0\r\n",
- "One point"),
- A(MetaActionKind.CallMetaState, "Worker", "ReturnHere"),
- A(MetaActionKind.ReturnFromCall),
- A(MetaActionKind.ExpressionAction, "setvar['x',2]"),
- A(MetaActionKind.ChatExpression, "cstr[getvar['x']]"),
- A(MetaActionKind.SetWatchdog, "Recover", number: 12.5, secondaryNumber: 4.75),
- A(MetaActionKind.ClearWatchdog),
- A(MetaActionKind.GetVtankOption, "OpenDoors", "doors"),
- A(MetaActionKind.SetVtankOption, "OpenDoors", "istrue[1]"),
- A(MetaActionKind.CreateView, "myview", ""),
- A(MetaActionKind.DestroyView, "myview"),
- A(MetaActionKind.DestroyAllViews),
- ];
-
- private static MetaCondition C(
- MetaConditionKind kind,
- string text = "",
- double number = 0,
- double secondaryNumber = 0,
- double tertiaryNumber = 0,
- string secondary = "",
- List? children = null) => new()
- {
- Kind = kind,
- Text = text,
- SecondaryText = secondary,
- Number = number,
- SecondaryNumber = secondaryNumber,
- TertiaryNumber = tertiaryNumber,
- Children = children ?? [],
- };
-
- private static MetaAction A(
- MetaActionKind kind,
- string text = "",
- string secondary = "",
- double number = 0,
- double secondaryNumber = 0,
- List? children = null) => new()
- {
- Kind = kind,
- Text = text,
- SecondaryText = secondary,
- Number = number,
- SecondaryNumber = secondaryNumber,
- Children = children ?? [],
- };
-}
diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs
new file mode 100644
index 00000000..93c09a08
--- /dev/null
+++ b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs
@@ -0,0 +1,180 @@
+using AcDream.Plugin.Abstractions;
+
+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()
+ {
+ // Confirmed live: --Barris_Coldeve.usd is the auto per-character
+ // settings profile for character "Barris" on server "Coldeve".
+ Assert.Equal(
+ "--Barris_Coldeve.usd",
+ VtankProfileDirectory.AutoCharacterFileName("Barris", "Coldeve", "usd"));
+ Assert.Equal(
+ "--Barris_Coldeve.usd",
+ VtankProfileDirectory.AutoCharacterFileName("Barris", "Coldeve", ".usd"));
+ }
+
+ [Fact]
+ public void SubProfilesAreDisplayedAsCharBracketSuffix()
+ {
+ // Confirmed live: --Barris_Coldeve_Base.usd, --Barris_Coldeve_Blank.usd,
+ // --Barris_Coldeve_viridian.usd are Barris's own named sub-profiles,
+ // distinct from the single auto file --Barris_Coldeve.usd.
+ Assert.Equal(
+ "[Char] Base",
+ VtankProfileDirectory.TryDisplayName("--Barris_Coldeve_Base.usd", "Barris", "Coldeve"));
+ Assert.Equal(
+ "[Char] viridian",
+ VtankProfileDirectory.TryDisplayName("--Barris_Coldeve_viridian.usd", "Barris", "Coldeve"));
+ // The character's own single auto file has nothing after the
+ // trailing "_", so it is not a sub-profile.
+ Assert.Null(
+ VtankProfileDirectory.TryDisplayName("--Barris_Coldeve.usd", "Barris", "Coldeve"));
+ // A different character's file is not a sub-profile of this one.
+ Assert.Null(
+ VtankProfileDirectory.TryDisplayName("--Someone_Coldeve_Base.usd", "Barris", "Coldeve"));
+ }
+
+ [Fact]
+ public void OtherCharactersHiddenPrefixedFilesAreInvisible()
+ {
+ Assert.True(VtankProfileDirectory.IsHiddenFromOtherCharacters(
+ "--Someone_Coldeve.usd", "Barris", "Coldeve"));
+ Assert.False(VtankProfileDirectory.IsHiddenFromOtherCharacters(
+ "--Barris_Coldeve_Base.usd", "Barris", "Coldeve"));
+ Assert.False(VtankProfileDirectory.IsHiddenFromOtherCharacters(
+ "MyNamedProfile.usd", "Barris", "Coldeve"));
+ }
+
+ [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");
+
+ IReadOnlyList entries =
+ VtankProfileDirectory.ListSettingsProfiles(
+ directory, "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);
+ }
+ }
+
+ [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");
+
+ IReadOnlyList entries =
+ VtankProfileDirectory.ListNavigationProfiles(directory);
+
+ 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);
+ }
+ }
+
+ [Fact]
+ public void ListingsOnMissingDirectoryOnlySeedTheBuiltInEntries()
+ {
+ string missing = Path.Combine(Path.GetTempPath(), "acdream-vt-missing-" + Guid.NewGuid());
+ IReadOnlyList entries =
+ VtankProfileDirectory.ListSettingsProfiles(missing, "Barris", "Coldeve", mineOnly: false);
+ Assert.Equal(2, entries.Count);
+ }
+
+ private sealed class FakeHost(string? vtankProfileDirectory) : IPluginHost
+ {
+ 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;
+ }
+
+ private sealed class NoOpLogger : IPluginLogger
+ {
+ public static NoOpLogger Instance { get; } = new();
+ public void Info(string message) { }
+ public void Warn(string message) { }
+ public void Error(string message, Exception? exception = null) { }
+ }
+}