diff --git a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
index d481edd1..38eee97b 100644
--- a/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
+++ b/src/AcDream.Plugins.MossTank/VtankProfileDirectory.cs
@@ -101,16 +101,30 @@ internal static class VtankProfileDirectory
/// 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
+ /// then every non--- .usd file, plus every one of this
+ /// character's own --Name_Server_* sub-profiles shown as
/// [Char] suffix.
///
+ ///
+ /// The "Mine only" checkbox (cSettingsShowAll/field a9).
+ /// VTank's own predicate (uTank2/PluginCore.cs:7020-7024) is NOT
+ /// "hide every shared file" — a shared (non-sub-profile) file is hidden
+ /// only when it is neither unchecked NOR the currently selected file,
+ /// so the profile actually in use never vanishes out from under the
+ /// user just because they ticked the box afterward.
+ ///
+ ///
+ /// The file name currently assigned to
+ /// (if any), exempted from filtering per the
+ /// rule above. Comparison is ordinal (VTank's own file names are
+ /// case-sensitive on the filesystems it ships for).
+ ///
public static IReadOnlyList ListSettingsProfiles(
IPluginStorage storage,
string characterName,
string server,
- bool mineOnly)
+ bool mineOnly,
+ string? currentFileName = null)
{
var entries = new List
{
@@ -127,8 +141,11 @@ internal static class VtankProfileDirectory
}
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.
+ if (mineOnly
+ && !fileName.Equals(currentFileName, StringComparison.Ordinal))
+ {
+ continue;
+ }
entries.Add(new ProfileEntry(fileName, fileName));
}
return entries;
@@ -185,6 +202,81 @@ internal static class VtankProfileDirectory
return entries;
}
+ ///
+ /// The per-character spell-tracking cache filename (dm class,
+ /// refs/vtank/decompiled/dm.cs:391): always
+ /// CharacterName_Server.ast — no "--" prefix (unlike the
+ /// auto settings/nav/meta files), not user-selectable, and note the
+ /// concatenation ORDER (Name first, then Server) is the reverse of
+ /// 's Server-then-Name order.
+ ///
+ public static string AstFileName(string characterName, string server) =>
+ $"{characterName}_{server}.ast";
+
+ ///
+ /// The per-character binding file's own name (da class,
+ /// refs/vtank/decompiled/da.cs:110): always
+ /// Server_CharacterName.cdf — Server first, then Name (the
+ /// reverse of and of the auto
+ /// --Name_Server.usd naming).
+ ///
+ public static string CdfFileName(string characterName, string server) =>
+ $"{server}_{characterName}.cdf";
+
+ /// The literal version header a valid .cdf starts with (da.cs:15).
+ internal const string CdfHeader = "uTank2 CDF 1.0";
+
+ ///
+ /// One character's currently-assigned profile filenames, read from its
+ /// .cdf (da.e(), da.cs:105-154). Meta is
+ /// when the file is short (line 5 is present
+ /// only when the stream isn't already at EOF, da.cs:125-128 —
+ /// an older .cdf predating meta support has no line 5 at all).
+ ///
+ public readonly record struct VtankCharacterBinding(
+ string SettingsFileName,
+ string LootFileName,
+ string NavFileName,
+ string? MetaFileName);
+
+ ///
+ /// Reads /'s
+ /// .cdf through . Returns
+ /// when the storage is unavailable, the file is
+ /// missing, or the file does not start with —
+ /// real VTank treats a version mismatch identically to a missing file
+ /// and falls back to that character's auto-created per-character
+ /// defaults (da.cs:113-121), which this method leaves entirely
+ /// to the caller rather than fabricating a default binding itself. The
+ /// legacy .uts→.usd settings-filename rewrite
+ /// (da.cs:130-141) is applied here so callers never see a
+ /// .uts name.
+ ///
+ public static VtankCharacterBinding? TryReadCharacterBinding(
+ IPluginStorage storage,
+ string characterName,
+ string server)
+ {
+ ArgumentNullException.ThrowIfNull(storage);
+ ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(server);
+ if (!storage.IsAvailable)
+ return null;
+ string? text = storage.ReadText(CdfFileName(characterName, server));
+ if (text is null)
+ return null;
+ string[] lines = text
+ .Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Split('\n');
+ if (lines.Length < 4 || !string.Equals(lines[0], CdfHeader, StringComparison.Ordinal))
+ return null;
+ string settings = lines[1].EndsWith(".uts", StringComparison.OrdinalIgnoreCase)
+ ? string.Concat(lines[1].AsSpan(0, lines[1].Length - 4), ".usd")
+ : lines[1];
+ string? meta = lines.Length >= 5 && lines[4].Length > 0 ? lines[4] : null;
+ return new VtankCharacterBinding(settings, lines[2], lines[3], meta);
+ }
+
///
/// Lists root-level file names matching
/// through alone — no
diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs
index 18b3270b..f5907cf3 100644
--- a/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs
+++ b/tests/AcDream.Plugins.MossTank.Tests/VtankProfileDirectoryTests.cs
@@ -116,6 +116,149 @@ public sealed class VtankProfileDirectoryTests
Assert.DoesNotContain(entries, static e => e.FileName.Contains('/'));
}
+ // Item G (Campaign VT slice-1 fix round): VTank's real "Mine only"
+ // predicate (uTank2/PluginCore.cs:7020-7024) is "!checked || file ==
+ // current", not "hide every shared file" — the profile actually
+ // assigned to the character must stay visible even under the
+ // checkbox, or the user's own current selection would vanish out of
+ // the list the moment they ticked it.
+ [Fact]
+ public void MineOnlyKeepsTheCurrentlySelectedSharedFileVisible()
+ {
+ var storage = new MemoryStorage();
+ storage.WriteText("Shared.usd", "1\r\n");
+ storage.WriteText("OtherShared.usd", "1\r\n");
+
+ IReadOnlyList withoutCurrent =
+ VtankProfileDirectory.ListSettingsProfiles(
+ storage, "Barris", "Coldeve", mineOnly: true);
+ Assert.DoesNotContain(withoutCurrent, static e => e.FileName == "Shared.usd");
+ Assert.DoesNotContain(withoutCurrent, static e => e.FileName == "OtherShared.usd");
+
+ IReadOnlyList withCurrent =
+ VtankProfileDirectory.ListSettingsProfiles(
+ storage, "Barris", "Coldeve", mineOnly: true, currentFileName: "Shared.usd");
+ Assert.Contains(withCurrent, static e => e.FileName == "Shared.usd");
+ Assert.DoesNotContain(withCurrent, static e => e.FileName == "OtherShared.usd");
+ }
+
+ [Fact]
+ public void MineOnlyUncheckedIgnoresCurrentFileAndKeepsEverything()
+ {
+ var storage = new MemoryStorage();
+ storage.WriteText("Shared.usd", "1\r\n");
+
+ IReadOnlyList entries =
+ VtankProfileDirectory.ListSettingsProfiles(
+ storage, "Barris", "Coldeve", mineOnly: false, currentFileName: null);
+
+ Assert.Contains(entries, static e => e.FileName == "Shared.usd");
+ }
+
+ [Fact]
+ public void AstFileNameHasNoHiddenPrefixAndNameServerOrder()
+ {
+ Assert.Equal(
+ "Barris_Coldeve.ast",
+ VtankProfileDirectory.AstFileName("Barris", "Coldeve"));
+ }
+
+ [Fact]
+ public void CdfFileNameUsesServerNameOrderReversedFromAst()
+ {
+ Assert.Equal(
+ "Coldeve_Barris.cdf",
+ VtankProfileDirectory.CdfFileName("Barris", "Coldeve"));
+ }
+
+ [Fact]
+ public void TryReadCharacterBindingParsesAllFourLines()
+ {
+ var storage = new MemoryStorage();
+ storage.WriteText(
+ "Coldeve_Barris.cdf",
+ "uTank2 CDF 1.0\r\n--Barris_Coldeve.usd\r\nLoot.utl\r\n--Barris_Coldeve.nav\r\nHunt.met\r\n");
+
+ VtankProfileDirectory.VtankCharacterBinding? binding =
+ VtankProfileDirectory.TryReadCharacterBinding(storage, "Barris", "Coldeve");
+
+ Assert.NotNull(binding);
+ Assert.Equal("--Barris_Coldeve.usd", binding!.Value.SettingsFileName);
+ Assert.Equal("Loot.utl", binding.Value.LootFileName);
+ Assert.Equal("--Barris_Coldeve.nav", binding.Value.NavFileName);
+ Assert.Equal("Hunt.met", binding.Value.MetaFileName);
+ }
+
+ [Fact]
+ public void TryReadCharacterBindingWithoutMetaLineLeavesMetaNull()
+ {
+ var storage = new MemoryStorage();
+ storage.WriteText(
+ "Coldeve_Barris.cdf",
+ "uTank2 CDF 1.0\r\n--Barris_Coldeve.usd\r\nLoot.utl\r\n--Barris_Coldeve.nav\r\n");
+
+ VtankProfileDirectory.VtankCharacterBinding? binding =
+ VtankProfileDirectory.TryReadCharacterBinding(storage, "Barris", "Coldeve");
+
+ Assert.NotNull(binding);
+ Assert.Null(binding!.Value.MetaFileName);
+ }
+
+ [Fact]
+ public void TryReadCharacterBindingRewritesLegacyUtsSettingsExtension()
+ {
+ var storage = new MemoryStorage();
+ storage.WriteText(
+ "Coldeve_Barris.cdf",
+ "uTank2 CDF 1.0\r\n--Barris_Coldeve.uts\r\nLoot.utl\r\n--Barris_Coldeve.nav\r\n");
+
+ VtankProfileDirectory.VtankCharacterBinding? binding =
+ VtankProfileDirectory.TryReadCharacterBinding(storage, "Barris", "Coldeve");
+
+ Assert.Equal("--Barris_Coldeve.usd", binding!.Value.SettingsFileName);
+ }
+
+ [Fact]
+ public void TryReadCharacterBindingReturnsNullOnHeaderMismatch()
+ {
+ var storage = new MemoryStorage();
+ storage.WriteText(
+ "Coldeve_Barris.cdf",
+ "uTank2 CDF 0.9\r\n--Barris_Coldeve.usd\r\nLoot.utl\r\n--Barris_Coldeve.nav\r\n");
+
+ Assert.Null(VtankProfileDirectory.TryReadCharacterBinding(storage, "Barris", "Coldeve"));
+ }
+
+ [Fact]
+ public void TryReadCharacterBindingReturnsNullWhenFileMissing()
+ {
+ Assert.Null(VtankProfileDirectory.TryReadCharacterBinding(
+ new MemoryStorage(), "Barris", "Coldeve"));
+ }
+
+ // Real owner-*.ast fixtures (Fixtures/vtank/owner-{a,b,c}.ast): same
+ // "y" database grammar as .usd, one "Spells" table, four columns
+ // (docs/research/vtank-kb/01-settings-and-profiles.md section 3,
+ // dm.cs:391 for the naming, live inspection of +Horan_sawato.ast for
+ // the schema).
+ [Theory]
+ [InlineData("owner-a.ast")]
+ [InlineData("owner-b.ast")]
+ [InlineData("owner-c.ast")]
+ public void RealAstFixturesParseAsTheSpellsTable(string fileName)
+ {
+ string text = File.ReadAllText(
+ Path.Combine(AppContext.BaseDirectory, "Fixtures", "vtank", fileName));
+
+ VtankDatabase database = VtankDatabase.Parse(text);
+
+ VtankTable? spells = database.Find("Spells");
+ Assert.NotNull(spells);
+ Assert.Equal(
+ ["SpellID", "EndTime", "Target", "CastTime"],
+ spells!.ColumnNames);
+ }
+
private sealed class MemoryStorage : IPluginStorage
{
private readonly Dictionary _text = new(StringComparer.Ordinal);