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);