fix(vt): G real mineOnly predicate, .cdf character binding, .ast naming

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 23:07:11 +02:00
parent 2040f2bcfb
commit e53507396f
2 changed files with 241 additions and 6 deletions

View file

@ -101,16 +101,30 @@ internal static class VtankProfileDirectory
/// VTank's settings-profile list (<c>a0()</c>,
/// <c>uTank2/PluginCore.cs:7057-7115</c>): seeds
/// <see cref="DefaultLabel"/> and <see cref="ByCharacterLabel"/> first,
/// then every non-<c>--</c> <c>.usd</c> file (optionally filtered to
/// only this character's own, the "Mine only" checkbox), plus every one
/// of this character's own <c>--Name_Server_*</c> sub-profiles shown as
/// then every non-<c>--</c> <c>.usd</c> file, plus every one of this
/// character's own <c>--Name_Server_*</c> sub-profiles shown as
/// <c>[Char] suffix</c>.
/// </summary>
/// <param name="mineOnly">
/// The "Mine only" checkbox (<c>cSettingsShowAll</c>/field <c>a9</c>).
/// VTank's own predicate (<c>uTank2/PluginCore.cs:7020-7024</c>) 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.
/// </param>
/// <param name="currentFileName">
/// The file name currently assigned to <paramref name="characterName"/>
/// (if any), exempted from <paramref name="mineOnly"/> filtering per the
/// rule above. Comparison is ordinal (VTank's own file names are
/// case-sensitive on the filesystems it ships for).
/// </param>
public static IReadOnlyList<ProfileEntry> ListSettingsProfiles(
IPluginStorage storage,
string characterName,
string server,
bool mineOnly)
bool mineOnly,
string? currentFileName = null)
{
var entries = new List<ProfileEntry>
{
@ -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;
}
/// <summary>
/// The per-character spell-tracking cache filename (<c>dm</c> class,
/// <c>refs/vtank/decompiled/dm.cs:391</c>): always
/// <c>CharacterName_Server.ast</c> — no <c>"--"</c> prefix (unlike the
/// auto settings/nav/meta files), not user-selectable, and note the
/// concatenation ORDER (Name first, then Server) is the reverse of
/// <see cref="CdfFileName"/>'s Server-then-Name order.
/// </summary>
public static string AstFileName(string characterName, string server) =>
$"{characterName}_{server}.ast";
/// <summary>
/// The per-character binding file's own name (<c>da</c> class,
/// <c>refs/vtank/decompiled/da.cs:110</c>): always
/// <c>Server_CharacterName.cdf</c> — Server first, then Name (the
/// reverse of <see cref="AstFileName"/> and of the auto
/// <c>--Name_Server.usd</c> naming).
/// </summary>
public static string CdfFileName(string characterName, string server) =>
$"{server}_{characterName}.cdf";
/// <summary>The literal version header a valid <c>.cdf</c> starts with (<c>da.cs:15</c>).</summary>
internal const string CdfHeader = "uTank2 CDF 1.0";
/// <summary>
/// One character's currently-assigned profile filenames, read from its
/// <c>.cdf</c> (<c>da.e()</c>, <c>da.cs:105-154</c>). <c>Meta</c> is
/// <see langword="null"/> when the file is short (line 5 is present
/// only when the stream isn't already at EOF, <c>da.cs:125-128</c> —
/// an older <c>.cdf</c> predating meta support has no line 5 at all).
/// </summary>
public readonly record struct VtankCharacterBinding(
string SettingsFileName,
string LootFileName,
string NavFileName,
string? MetaFileName);
/// <summary>
/// Reads <paramref name="characterName"/>/<paramref name="server"/>'s
/// <c>.cdf</c> through <paramref name="storage"/>. Returns
/// <see langword="null"/> when the storage is unavailable, the file is
/// missing, or the file does not start with <see cref="CdfHeader"/> —
/// real VTank treats a version mismatch identically to a missing file
/// and falls back to that character's auto-created per-character
/// defaults (<c>da.cs:113-121</c>), which this method leaves entirely
/// to the caller rather than fabricating a default binding itself. The
/// legacy <c>.uts</c>→<c>.usd</c> settings-filename rewrite
/// (<c>da.cs:130-141</c>) is applied here so callers never see a
/// <c>.uts</c> name.
/// </summary>
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);
}
/// <summary>
/// Lists root-level file names matching <paramref name="extension"/>
/// through <see cref="IPluginStorage.List"/> alone — no

View file

@ -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<VtankProfileDirectory.ProfileEntry> 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<VtankProfileDirectory.ProfileEntry> 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<VtankProfileDirectory.ProfileEntry> 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<string, string> _text = new(StringComparer.Ordinal);