feat(vt): A2 VTank profile directory resolution + naming rules

Campaign VT slice 1 Part A, deliverable 2 (foundation only - see the
closeout note in the final report for what is not yet wired up).

VtankProfileDirectory.cs resolves the on-disk VTank profile directory
through IPluginHost.VtankProfileDirectory (a new, minimal, default-null
interface member - never a hard-coded Windows path in the plugin itself;
an App-composed host may point it at a real installed VTank's own profile
folder for direct interop, but that discovery belongs entirely to the
host) and falls back to a portable default built with Path.Combine only
(LocalApplicationData/acdream/vtank, which resolves through .NET's
XDG-aware base-directory logic on Linux). It also ports VTank's real
naming/selection rules from docs/research/vtank-kb/01-settings-and-
profiles.md section 3: the per-character auto file (--Name_Server.ext),
the longer --Name_Server_ sub-profile prefix and its "[Char] suffix"
display form, the "--"/"~~" hidden-prefix filtering for settings/nav/meta
profile listings, and the seeded [Default]/[By char]/[None] entries -
verified against the owner's own live directory listing
(--Barris_Coldeve*.usd family).

Owed: this lands the directory+naming foundation and its own test
coverage, but does not yet wire MossTankProfileStore's Create/Select/Load/
Save (still JSON-indexed) to read/write real .usd files through it, nor
MossTankMetaProfileStore/MossTankRouteProfileStore to make .af their
primary directory-backed storage rather than a legacy-export sidecar
(commit 3ff9461ef). That deeper rewrite of already-widely-used,
already-tested profile stores was judged too large a change to land
correctly under this slice's remaining time without a real risk of
destabilizing them; flagged in the closeout for the owner/next slice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 21:03:16 +02:00
parent 3ff9461efe
commit 0d10399e0e
4 changed files with 408 additions and 190 deletions

View file

@ -42,4 +42,18 @@ public interface IPluginHost
/// host kind.
/// </summary>
IAutomationSurface Automation { get; }
/// <summary>
/// Absolute filesystem directory a VTank-compatible plugin should treat
/// as the VTank profile folder (real <c>.usd</c>/<c>.ast</c>/<c>.af</c>
/// files, VTank's own naming rules). <see langword="null"/> 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
/// <c>AcDream.Plugins.MossTank.VtankProfileDirectory</c>. 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.
/// </summary>
string? VtankProfileDirectory => null;
}

View file

@ -0,0 +1,214 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Resolves the on-disk VTank profile directory and implements VTank's real
/// naming/selection rules (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c>
/// section 3): the per-character auto file, the longer <c>--Name_Server_</c>
/// sub-profile prefix, and which filenames a given character can see.
///
/// The directory itself is never hard-coded here: <see cref="Resolve"/>
/// prefers <see cref="IPluginHost.VtankProfileDirectory"/> (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.
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/> resolves to
/// <c>%LOCALAPPDATA%</c> on Windows and (via .NET's XDG-aware base-directory
/// resolution) <c>$XDG_DATA_HOME</c> (or <c>~/.local/share</c>) on Linux —
/// built exclusively with <see cref="Path.Combine(string, string)"/>, so it
/// never contains a literal backslash.
/// </summary>
internal static class VtankProfileDirectory
{
private const string PortableFolderName = "vtank";
/// <summary>The real VTank "--" reserved-prefix marker (section 3).</summary>
internal const string HiddenPrefix = "--";
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// VTank's single per-character default filename
/// (<c>uTank2/PluginCore.cs:3863-3865</c>): <c>--Name_Server.ext</c>.
/// </summary>
public static string AutoCharacterFileName(
string characterName,
string server,
string extension) =>
$"{HiddenPrefix}{characterName}_{server}.{extension.TrimStart('.')}";
/// <summary>
/// The longer, trailing-underscore prefix
/// (<c>uTank2/PluginCore.cs:933,3866</c>, field <c>dw</c>) that marks a
/// *named sub-profile* belonging to one character, distinct from that
/// character's single auto file above.
/// </summary>
public static string SubProfilePrefix(string characterName, string server) =>
$"{HiddenPrefix}{characterName}_{server}_";
/// <summary>
/// True when <paramref name="fileName"/> must be hidden from the
/// cross-character profile picker for <paramref name="characterName"/>/
/// <paramref name="server"/>: any <c>--</c>-prefixed name that is not
/// this character's own sub-profile family
/// (<c>uTank2/PluginCore.cs:7020,7072,7144</c>).
/// </summary>
public static bool IsHiddenFromOtherCharacters(
string fileName,
string characterName,
string server) =>
fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
&& !fileName.StartsWith(
SubProfilePrefix(characterName, server),
StringComparison.Ordinal);
/// <summary>
/// The "[Char] suffix" display form for one of this character's own
/// sub-profiles (<c>uTank2/PluginCore.cs:7016-7046</c>): the
/// <c>--Name_Server_</c> prefix and the file extension are both
/// stripped. Returns <see langword="null"/> for a filename that is not
/// one of this character's sub-profiles.
/// </summary>
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}";
}
/// <summary>
/// One entry in a profile picker: the real on-disk file name, and the
/// label VTank would show for it.
/// </summary>
public readonly record struct ProfileEntry(string FileName, string DisplayName);
/// <summary>
/// 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
/// <c>[Char] suffix</c>.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListSettingsProfiles(
string directory,
string characterName,
string server,
bool mineOnly)
{
var entries = new List<ProfileEntry>
{
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;
}
/// <summary>
/// VTank's navigation-profile list (<c>l()</c>,
/// <c>uTank2/PluginCore.cs:7156-7185</c>): seeds
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
/// <c>.af</c> file that starts with neither <c>--</c> nor <c>~~</c>.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(string directory)
{
var entries = new List<ProfileEntry>
{
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;
}
/// <summary>
/// VTank's meta-profile list (<c>ac()</c>,
/// <c>uTank2/PluginCore.cs:7187+</c>): seeds
/// <see cref="NoneLabel"/>/<see cref="ByCharacterLabel"/>, then every
/// non-<c>--</c> file (a meta and a nav profile share the same
/// directory and extension here — <c>.af</c> — so callers pass a
/// distinguishing sub-extension convention if they need one; VTank
/// itself distinguished by the separate <c>.met</c>/<c>.nav</c>
/// extensions).
/// </summary>
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(string directory)
{
var entries = new List<ProfileEntry>
{
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<string> 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);
}
}
}