From e53507396f9723907ef39cf696de58e75a34399b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 6 Sep 2026 23:07:11 +0200 Subject: [PATCH] fix(vt): G real mineOnly predicate, .cdf character binding, .ast naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item G (slice-1 fix round). - ListSettingsProfiles's "Mine only" filter previously hid EVERY shared (non-sub-profile) file when checked. Real VTank's predicate (uTank2/PluginCore.cs:7020-7024, cSettingsShowAll/field a9) is "!checked || file == current" — a shared file is hidden only when the box is checked AND it is not the file currently assigned to the character, so the profile actually in use never disappears out from under the user just because they ticked the box. New optional currentFileName parameter carries that exemption. - New VtankProfileDirectory.CdfFileName/TryReadCharacterBinding: the real per-character binding file (da class, da.cs:15,105-164) — filename Server_CharacterName.cdf (Server-then-Name order), literal "uTank2 CDF 1.0" version-header line 1 (a mismatch is treated identically to a missing file, per da.cs:113-121, and the method returns null in both cases rather than fabricating a default binding), lines 2-4 the settings/loot/nav filenames currently assigned to that character, optional line 5 the meta filename (present only when the stream wasn't already at EOF — an older .cdf predating meta support has no line 5 at all). The legacy .uts->.usd settings-filename rewrite (da.cs:130-141) is applied here so callers never see a stale extension. - New VtankProfileDirectory.AstFileName: the per-character spell- tracking cache name (dm class, dm.cs:391) — CharacterName_Server.ast, no "--" prefix, not user-selectable. Note the concatenation order is the REVERSE of CdfFileName's (Name-then-Server vs Server-then-Name) — both are pinned by dedicated tests so a future edit can't silently swap one for the other. - New RealAstFixturesParseAsTheSpellsTable theory over the three committed owner-{a,b,c}.ast fixtures: parses via the existing VtankDatabase.Parse (same "y" grammar as .usd) and asserts the real Spells table's four columns (SpellID/EndTime/Target/CastTime), per live inspection of +Horan_sawato.ast recorded in docs/research/vtank-kb/01-settings-and-profiles.md section 3. No production caller of these new members exists yet — same as item F, this is contract/rule-implementation work per A2's "foundation only, not yet wired into the profile stores" scope; wiring belongs to the store cutover explicitly deferred to round 2. Full MossTank suite: 562 -> 574 (12 new tests, no regressions). App.Tests (Plugin|LaunchOptions filter): 82/82. Co-Authored-By: Claude Fable 5.1 --- .../VtankProfileDirectory.cs | 104 ++++++++++++- .../VtankProfileDirectoryTests.cs | 143 ++++++++++++++++++ 2 files changed, 241 insertions(+), 6 deletions(-) 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);