merge(vt): slice 1 Part A — VTank .usd/.utl drop-in and metaf .af for metas and routes (review-closed)

Campaign VT slice 1 Part A: the .usd document model + 137-setting
serializer with declared type tags and exact compare, metaf .af reader/
writer for metas and nav routes with real byte identity against the
owner's fixtures, .utl gate fixes, the VtankProfiles host storage
(ACDREAM_VTANK_PROFILE_DIR), and the cutover of all four profile stores
to real VTank files with one-time JSON migration. Two Opus lenses, three
fix rounds, two narrow re-reviews, final re-check: MERGE-READY.
Contract-doc ledger conflict resolved by keeping the campaign branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 02:25:28 +02:00
commit f680bf234a
108 changed files with 62970 additions and 1860 deletions

View file

@ -13,7 +13,8 @@ public sealed class AppPluginHost : IPluginHost
IAutomationSurface automation,
IPluginStorage? storage = null,
IPluginCommandRegistry? commands = null,
IPluginLootClassifierRegistry? lootClassifiers = null)
IPluginLootClassifierRegistry? lootClassifiers = null,
IPluginStorage? vtankProfiles = null)
{
Log = log;
State = state;
@ -25,6 +26,7 @@ public sealed class AppPluginHost : IPluginHost
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
LootClassifiers = lootClassifiers
?? NoOpPluginLootClassifierRegistry.Instance;
VtankProfiles = vtankProfiles ?? NoOpPluginStorage.Instance;
}
public bool HasUi => true;
@ -37,4 +39,5 @@ public sealed class AppPluginHost : IPluginHost
public IPluginStorage Storage { get; }
public IPluginCommandRegistry Commands { get; }
public IPluginLootClassifierRegistry LootClassifiers { get; }
public IPluginStorage VtankProfiles { get; }
}

View file

@ -26,7 +26,12 @@ internal sealed class FilePluginStorage : IPluginStorage
public IReadOnlyList<string> List(string prefix)
{
string directory = Resolve(prefix);
ArgumentNullException.ThrowIfNull(prefix);
// An empty prefix means "the storage root itself" — Resolve()
// rejects an empty/whitespace key (every other caller of it means
// one specific file or sub-directory), so this is handled directly
// rather than relaxing that guard for every other use.
string directory = prefix.Length == 0 ? _root : Resolve(prefix);
if (!Directory.Exists(directory))
return Array.Empty<string>();
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)

View file

@ -0,0 +1,16 @@
namespace AcDream.App.Plugins;
/// <summary>
/// The graphical host's default root for <c>IPluginHost.VtankProfiles</c>
/// when <c>RuntimeOptions.VtankProfileDirectoryOverride</c>
/// (<c>ACDREAM_VTANK_PROFILE_DIR</c>) is unset — extracted out of
/// <c>Program.cs</c>'s inline composition into its own pure, injectable-root
/// function so the "built with <see cref="Path.Combine(string, string)"/>
/// only, never a hard-coded Windows path" guarantee is a real, failable unit
/// test rather than something only checkable by reading the source.
/// </summary>
internal static class VtankProfilesDefault
{
internal static string Resolve(string dataDirectory) =>
Path.Combine(dataDirectory, "vtank");
}

View file

@ -185,7 +185,10 @@ var host = new AppPluginHost(
new FilePluginStorage(
Path.Combine(applicationPaths.ConfigDirectory, "plugins")),
automation.PluginCommands,
lootClassifiers);
lootClassifiers,
new FilePluginStorage(
runtimeOptions.VtankProfileDirectoryOverride
?? VtankProfilesDefault.Resolve(applicationPaths.DataDirectory)));
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
applicationPaths,
runtimeOptions.Plugins,

View file

@ -136,6 +136,17 @@ public sealed record RuntimeOptions(
/// process configuration directly.</summary>
public IReadOnlyList<string> PluginTags { get; init; } = [];
/// <summary>
/// <c>ACDREAM_VTANK_PROFILE_DIR</c> override for the directory a
/// VTank-compatible plugin's <c>IPluginHost.VtankProfiles</c> storage is
/// rooted at (real <c>.usd</c>/<c>.ast</c>/<c>.af</c> files — e.g. a real
/// installed VTank's own profile folder for direct interop).
/// <see langword="null"/> (the default) means "no opinion": the host
/// composes <c>applicationPaths.DataDirectory/vtank</c> instead. See
/// <c>docs/launch-options.md</c>.
/// </summary>
public string? VtankProfileDirectoryOverride { get; init; }
/// <summary>
/// Build options from the process environment. Used by
/// <c>Program.cs</c> at startup.
@ -271,6 +282,7 @@ public sealed record RuntimeOptions(
LoginCommandDelayMs: 500)
{
PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")),
VtankProfileDirectoryOverride = NullIfEmpty(env("ACDREAM_VTANK_PROFILE_DIR")),
};
}

View file

@ -46,6 +46,14 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
public ISelectionService Selection => _selection;
public IUiRegistry Ui => _ui;
public IPluginStorage Storage => _storage;
/// <summary>
/// Forwarded, not scoped, unlike <see cref="Storage"/>: the VTank
/// profile folder is one shared external location (real VTank's own
/// files, or a host-composed portable default), not per-plugin data —
/// scoping it under this plugin's manifest id would defeat the whole
/// point of pointing it at a real installed VTank profile directory.
/// </summary>
public IPluginStorage VtankProfiles => _inner.VtankProfiles;
public IPluginCommandRegistry Commands => _commands;
public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers;

View file

@ -61,6 +61,8 @@ internal sealed class HeadlessProcessHost : IDisposable
Path.Combine(AppContext.BaseDirectory, "plugins"),
paths.PluginsDirectory,
];
var vtankProfiles = new AcDream.Headless.Plugins.FilePluginStorage(
paths.VtankProfilesDirectory);
HeadlessProcessContentOwner? content = null;
HeadlessProcessResourceSampler? resources = null;
// FA6: constructed unconditionally — cheap, and every non-gate
@ -110,7 +112,8 @@ internal sealed class HeadlessProcessHost : IDisposable
timeProvider,
contentLease: contentLease,
gateCoordinator: gateCoordinator,
pluginRoots: pluginRoots));
pluginRoots: pluginRoots,
vtankProfiles: vtankProfiles));
}
catch
{

View file

@ -3,6 +3,7 @@ using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics;
using AcDream.Headless.Plugins;
using AcDream.Headless.Policies;
using AcDream.Plugin.Abstractions;
using AcDream.Content.CharGen;
using AcDream.Core.Chat;
using AcDream.Core.Net.Messages;
@ -266,7 +267,8 @@ internal sealed class HeadlessSessionHost : IDisposable
IHeadlessBotPolicy? policyOverride = null,
IRuntimePlacementProjectionSink? placementSinkOverride = null,
FellowshipAllegianceGateCoordinator? gateCoordinator = null,
IEnumerable<string>? pluginRoots = null)
IEnumerable<string>? pluginRoots = null,
IPluginStorage? vtankProfiles = null)
{
_descriptor = descriptor
?? throw new ArgumentNullException(nameof(descriptor));
@ -367,7 +369,8 @@ internal sealed class HeadlessSessionHost : IDisposable
descriptor.Id,
pluginRoots ?? [],
descriptor.Plugins,
pluginCommands);
pluginCommands,
vtankProfiles);
var liveSession = new LiveSessionHost(
runtime.Session,
new LiveSessionHostBindings(

View file

@ -11,6 +11,17 @@ internal sealed record HeadlessPathSet(
internal string PluginsDirectory =>
Path.Combine(DataDirectory, "plugins");
/// <summary>
/// Default root for <see cref="AcDream.Plugin.Abstractions.IPluginHost.VtankProfiles"/>
/// (Campaign VT slice-1 fix round, item F). No override mechanism of its
/// own yet — unlike the graphical host's <c>ACDREAM_VTANK_PROFILE_DIR</c>,
/// which only exists on <c>AcDream.App.RuntimeOptions</c> — because
/// headless path overrides already go through <c>HeadlessPathOverrides</c>
/// (config file / <c>--data-dir</c>), not environment variables.
/// </summary>
internal string VtankProfilesDirectory =>
Path.Combine(DataDirectory, "vtank");
internal static HeadlessPathSet Resolve(
HeadlessPathOverrides overrides,
IHeadlessPlatformEnvironment? platform = null)

View file

@ -0,0 +1,99 @@
using System.Text;
using AcDream.Plugin.Abstractions;
namespace AcDream.Headless.Plugins;
/// <summary>
/// Crash-safe filesystem <see cref="IPluginStorage"/> — byte-identical
/// contract to <c>AcDream.App.Plugins.FilePluginStorage</c>. Duplicated
/// rather than shared: <c>AcDream.Headless</c> does not (and per the
/// no-window/graphical layer split should not) reference <c>AcDream.App</c>,
/// and no shared "platform plugins" library exists yet to host one copy of
/// this ~70-line class for both hosts. Promoting it there is a reasonable
/// future cleanup, not required for Campaign VT slice-1 item F.
/// </summary>
internal sealed class FilePluginStorage : IPluginStorage
{
private readonly string _root;
internal FilePluginStorage(string root)
{
ArgumentException.ThrowIfNullOrWhiteSpace(root);
_root = Path.GetFullPath(root);
}
public bool IsAvailable => true;
public string? ReadText(string key)
{
string path = Resolve(key);
return File.Exists(path)
? File.ReadAllText(path, Encoding.UTF8)
: null;
}
public IReadOnlyList<string> List(string prefix)
{
ArgumentNullException.ThrowIfNull(prefix);
// An empty prefix means "the storage root itself" — Resolve()
// rejects an empty/whitespace key (every other caller of it means
// one specific file or sub-directory), so this is handled directly
// rather than relaxing that guard for every other use.
string directory = prefix.Length == 0 ? _root : Resolve(prefix);
if (!Directory.Exists(directory))
return Array.Empty<string>();
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
.Select(path => Path.GetRelativePath(_root, path)
.Replace(Path.DirectorySeparatorChar, '/'))
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public void WriteText(string key, string content)
{
ArgumentNullException.ThrowIfNull(content);
string path = Resolve(key);
string directory = Path.GetDirectoryName(path)!;
Directory.CreateDirectory(directory);
string temporary = Path.Combine(
directory,
$".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
File.WriteAllText(temporary, content, new UTF8Encoding(false));
File.Move(temporary, path, overwrite: true);
}
finally
{
if (File.Exists(temporary))
File.Delete(temporary);
}
}
public bool Delete(string key)
{
string path = Resolve(key);
if (!File.Exists(path))
return false;
File.Delete(path);
return true;
}
private string Resolve(string key)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Path.IsPathRooted(key))
throw new ArgumentException("Plugin storage keys must be relative.", nameof(key));
string path = Path.GetFullPath(Path.Combine(_root, key));
string relative = Path.GetRelativePath(_root, path);
if (Path.IsPathRooted(relative)
|| relative.Equals("..", StringComparison.Ordinal)
|| relative.StartsWith(
".." + Path.DirectorySeparatorChar,
StringComparison.Ordinal))
{
throw new ArgumentException("Plugin storage key escapes its root.", nameof(key));
}
return path;
}
}

View file

@ -39,17 +39,20 @@ internal sealed class HeadlessPluginHost
internal HeadlessPluginHost(
GameRuntime runtime,
IPluginLogger logger,
IPluginCommandRegistry? commands = null)
IPluginCommandRegistry? commands = null,
IPluginStorage? vtankProfiles = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
Log = logger ?? throw new ArgumentNullException(nameof(logger));
Commands = commands ?? NoOpPluginCommandRegistry.Instance;
VtankProfiles = vtankProfiles ?? NoOpPluginStorage.Instance;
_eventSubscription = runtime.Subscribe(this);
}
public bool HasUi => false;
public IPluginLogger Log { get; }
public IPluginCommandRegistry Commands { get; }
public IPluginStorage VtankProfiles { get; }
public IGameState State => this;
public IEvents Events => this;
public ISelectionService Selection => _runtime.ActionOwner.Selection;

View file

@ -45,7 +45,8 @@ internal sealed class HeadlessPluginSession : IDisposable
string sessionId,
IEnumerable<string> roots,
IReadOnlyList<string>? allowList,
IPluginCommandRegistry? commands = null)
IPluginCommandRegistry? commands = null,
IPluginStorage? vtankProfiles = null)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(diagnostics);
@ -59,7 +60,8 @@ internal sealed class HeadlessPluginSession : IDisposable
diagnostics,
sessionId,
() => runtime.Generation.Value),
commands);
commands,
vtankProfiles);
var plugins = new PluginSession(
host,
status => Report(statusWriter, sessionId, status),

View file

@ -42,4 +42,22 @@ public interface IPluginHost
/// host kind.
/// </summary>
IAutomationSurface Automation { get; }
/// <summary>
/// Storage rooted at the VTank profile folder (real <c>.usd</c>/
/// <c>.ast</c>/<c>.af</c> files, VTank's own naming rules) rather than
/// this plugin's own scoped <see cref="Storage"/> directory — see
/// <c>AcDream.Plugins.MossTank.VtankProfileDirectory</c>, which
/// enumerates through this property exclusively (no <c>System.IO</c>,
/// no per-OS portable-default fallback of its own) so directory
/// discovery stays entirely host-composed. Defaults to the inert
/// <see cref="NoOpPluginStorage"/> (<c>IsAvailable</c> false) when the
/// host has no opinion. A graphical host may root this at a real
/// installed VTank's own profile directory for direct interop, or at
/// its own portable per-OS default under
/// <c>ApplicationPathSet.DataDirectory</c>; that discovery belongs
/// entirely to the host composing this property, never to the plugin
/// reading it.
/// </summary>
IPluginStorage VtankProfiles => NoOpPluginStorage.Instance;
}

View file

@ -24,5 +24,6 @@
</None>
<EmbeddedResource Include="VtankCraftRecipes.tsv" />
<EmbeddedResource Include="VtankAmmunitionOptions.tsv" />
<EmbeddedResource Include="VtankDefaultSettings.usd" />
</ItemGroup>
</Project>

View file

@ -174,11 +174,11 @@ internal sealed class AttackSpellCatalog
{
if (shape == AttackSpellShape.Streak)
return 1;
if (settings.UseArcs && target.Distance >= settings.ArcRange)
if (ShouldUseArc(settings, target))
return shape == AttackSpellShape.Arc ? 2 : 3;
return shape == AttackSpellShape.Direct ? 2 : 3;
}
if (settings.UseArcs && target.Distance >= settings.ArcRange)
if (ShouldUseArc(settings, target))
{
if (shape == AttackSpellShape.Arc)
return 1;
@ -203,6 +203,19 @@ internal sealed class AttackSpellCatalog
};
}
/// <summary>
/// VTank's real 3-way UseArcs switch (<c>refs/vtank/decompiled/hi.cs:515-538</c>):
/// <c>No</c> never arcs, <c>Yes</c> always arcs, <c>AtRange</c> arcs only
/// once the target is at or beyond <see cref="CombatSettings.ArcRange"/>.
/// </summary>
private static bool ShouldUseArc(CombatSettings settings, PluginCombatTarget target) =>
settings.UseArcs switch
{
UseArcsMode.Yes => true,
UseArcsMode.AtRange => target.Distance >= settings.ArcRange,
_ => false,
};
private static bool MatchesPrimaryShape(
AttackSpellShape shape,
MonsterRuleActions actions,

View file

@ -243,11 +243,11 @@ internal sealed class CombatController
_untilScan -= Math.Max(0d, elapsedSeconds);
if (_untilScan <= 0d)
{
float acquisitionRange = navigationEnabled
double acquisitionRange = navigationEnabled
? Math.Max(_settings.MaximumRange, _settings.ApproachDistance)
: _settings.MaximumRange;
_targets = _host.Automation.Combat.CaptureHostileTargets(
acquisitionRange);
(float)acquisitionRange);
foreach (uint ghost in _failures.ObserveTargets(
_targets,
_now,
@ -588,7 +588,7 @@ internal sealed class CombatController
float maximumRange = spell.BaseRangeConstant
+ (spell.BaseRangeModifier * skill.Current)
- _settings.SpellRangeFudge;
- (float)_settings.SpellRangeFudge;
return maximumRange <= 0f
|| target.ObjectId == 0u
|| target.Distance <= MathF.Min(75f, maximumRange);
@ -1087,15 +1087,15 @@ internal sealed class CombatController
targetObjectId,
kind,
height,
_settings.CollisionProjectileRadius,
_settings.CollisionStepDistance,
(float)_settings.CollisionProjectileRadius,
(float)_settings.CollisionStepDistance,
_settings.MaximumCollisionChecksPerTick)
: _host.Automation.Projectiles.EvaluatePath(
targetObjectId,
kind,
height,
_settings.CollisionProjectileRadius,
_settings.CollisionStepDistance,
(float)_settings.CollisionProjectileRadius,
(float)_settings.CollisionStepDistance,
_settings.MaximumCollisionChecksPerTick);
if (_settings.ShowCollisionDebug && result.DebugSamples.Count > 0)
{

View file

@ -22,6 +22,23 @@ internal enum DebuffSelectionMethod
Skill = 2,
}
/// <summary>
/// VTank's real 3-way "UseArcs" enum
/// (<c>refs/vtank/decompiled/hi.cs:515-538</c>, switch on
/// <c>f3.f("UseArcs")</c>): <c>No</c> always picks the direct-shape spell,
/// <c>AtRange</c> picks the arc shape only once the target is at or beyond
/// <c>ArcRange</c>, and <c>Yes</c> always picks the arc shape regardless of
/// distance. A prior port collapsed this onto a single bool (effectively
/// only distinguishing "No" from "AtRange"), which cannot represent "Yes"
/// at all — see <see cref="AttackSpellCatalog"/>.
/// </summary>
internal enum UseArcsMode
{
No = 1,
AtRange = 2,
Yes = 3,
}
internal enum PetRangeMode
{
AttackDistance = 0,
@ -52,25 +69,30 @@ internal sealed class CombatSettings
public bool Enabled { get; set; } = true;
/// <summary>VTank's hunt-cast skill margin.</summary>
public int HuntSkillExcessOverDifficulty { get; set; } = 25;
public float MaximumRange { get; set; } = 5f;
// Declared tDouble in VTank's own Settings table (VtankOptionCatalog):
// double, not float, so a loaded .usd round-trips this setting exactly
// instead of losing precision below float's ~7-digit guarantee (item I,
// slice-1 fix round). Physics/combat consumers that want a float cast
// at their own use site.
public double MaximumRange { get; set; } = 5d;
/// <summary>
/// Monsters nearer than this are not valid attack targets. VTank applies
/// this before priority and angle/range ranking.
/// </summary>
public float MinimumRange { get; set; }
public double MinimumRange { get; set; }
/// <summary>
/// VTank's Approach Distance. Zero disables monster approach; otherwise
/// navigation may close a selected target from this range down to
/// <see cref="MaximumRange"/>.
/// </summary>
public float ApproachDistance { get; set; }
public double ApproachDistance { get; set; }
public bool IdlePeaceMode { get; set; }
public bool StopMacroOnDeath { get; set; } = true;
public bool JumpOutWandCasting { get; set; }
public bool DoJiggle { get; set; }
public TargetSelectionMethod SelectionMethod { get; set; } =
TargetSelectionMethod.Both;
public float TargetSelectAngleRange { get; set; } = 5f;
public double TargetSelectAngleRange { get; set; } = 5d;
public bool TargetLock { get; set; }
public PluginAttackHeight AttackHeight { get; set; } =
PluginAttackHeight.Medium;
@ -83,16 +105,16 @@ internal sealed class CombatSettings
DebuffSelectionMethod.Skill;
public double DebuffPrecastSeconds { get; set; } = 5d;
public bool SwitchWandsToDebuff { get; set; }
public bool UseArcs { get; set; } = true;
public float SpellRangeFudge { get; set; } = 1f;
public UseArcsMode UseArcs { get; set; } = UseArcsMode.AtRange;
public double SpellRangeFudge { get; set; } = 1d;
public bool UseBreakableTurnTo { get; set; } = true;
public bool UseProjectileAwareness { get; set; } = true;
public float CollisionProjectileRadius { get; set; } = 0.4f;
public float CollisionStepDistance { get; set; } = 0.7f;
public double CollisionProjectileRadius { get; set; } = 0.4d;
public double CollisionStepDistance { get; set; } = 0.7d;
public bool ShowCollisionDebug { get; set; }
public int MaximumCollisionChecksPerTick { get; set; } = 500;
public float ArcRange { get; set; } = 5f;
public float RingDistance { get; set; } = 5f;
public double ArcRange { get; set; } = 5d;
public double RingDistance { get; set; } = 5d;
public int MinimumRingTargets { get; set; } = 4;
public bool DeleteGhostMonsters { get; set; } = true;
public int GhostMonsterSpellAttemptCount { get; set; } = 200;
@ -102,7 +124,7 @@ internal sealed class CombatSettings
public double GhostDeleteHealthTrackerSeconds { get; set; } = 30d;
public bool SummonPets { get; set; } = true;
public PetRangeMode PetRangeMode { get; set; } = PetRangeMode.AttackDistance;
public float PetCustomRange { get; set; } = 5f;
public double PetCustomRange { get; set; } = 5d;
public int PetMonsterDensity { get; set; } = 1;
public int PetRefillCountIdle { get; set; } = 3;
public int PetRefillCountNormal { get; set; } = 1;

View file

@ -89,8 +89,10 @@ internal sealed class LootSettings
public bool CombineSalvage { get; set; } = true;
public int ManaStoneLootCount { get; set; } = 4;
public int ManaTankMinimumMana { get; set; } = 1000;
public float CorpseApproachRange { get; set; } = 40f;
public float CorpseMinimumApproachRange { get; set; } = 3.36f;
// Declared tDouble in VTank's own Settings table (item I, slice-1 fix
// round) — see CombatSettings.MaximumRange's doc comment for why.
public double CorpseApproachRange { get; set; } = 40d;
public double CorpseMinimumApproachRange { get; set; } = 3.36d;
public double CorpseOpenTimeoutSeconds { get; set; } = 1.5d;
public double CorpseItemAppearanceTimeoutSeconds { get; set; } = 6d;
public double CorpseItemIdentifyTimeoutSeconds { get; set; } = 60d;
@ -524,7 +526,7 @@ internal sealed class LootController
_scanRemaining = Math.Clamp(_settings.ScanIntervalSeconds, 0.05d, 5d);
IReadOnlyList<PluginLootContainer> corpses = loot.CaptureCorpses(
Math.Clamp(_settings.CorpseApproachRange, 2f, 100f));
(float)Math.Clamp(_settings.CorpseApproachRange, 2d, 100d));
PruneCorpseCache();
foreach (PluginLootContainer seen in corpses)
_corpseFirstSeen.TryAdd(seen.ObjectId, _lifetime);

View file

@ -77,6 +77,17 @@ internal sealed class MetaAction
public double Number { get; set; }
public double SecondaryNumber { get; set; }
public List<MetaAction> Children { get; set; } = [];
/// <summary>
/// The parsed route for a <see cref="MetaActionKind.LoadEmbeddedNavigationRoute"/>
/// action. Replaces a prior port's binary "uTank2 NAV 1.2" text blob
/// carried in <see cref="Text"/>: both the <c>.af</c> importer
/// (<c>MetafSerializer.ResolveEmbeddedNavs</c>) and the <c>.met</c>
/// importer (<c>VtankMetaProfileSerializer.ReadEmbeddedNavigation</c>)
/// now produce this typed model directly, and
/// <c>MetaEngine.LoadEmbeddedNavigationRoute</c> consumes it without a
/// re-parse. Null only for an unresolved/never-defined tag.
/// </summary>
public NavigationSettings? EmbeddedRoute { get; set; }
}
internal sealed class MetaRule
@ -102,7 +113,7 @@ internal sealed class MetaServices
static () => double.PositiveInfinity;
public Func<int, double, int> CountMonstersByPriority { get; init; } =
static (_, _) => 0;
public Action<string> LoadEmbeddedNavigationRoute { get; init; } = static _ => { };
public Action<NavigationSettings?> LoadEmbeddedNavigationRoute { get; init; } = static _ => { };
public Func<string, ExpressionValue> GetOption { get; init; } =
static _ => ExpressionValue.Zero;
public Func<string, ExpressionValue, bool> SetOption { get; init; } =
@ -379,7 +390,7 @@ internal sealed class MetaEngine
}
return true;
case MetaActionKind.LoadEmbeddedNavigationRoute:
_services.LoadEmbeddedNavigationRoute(action.Text);
_services.LoadEmbeddedNavigationRoute(action.EmbeddedRoute);
return true;
case MetaActionKind.CallMetaState:
if (_callStack.Count >= MaximumCallDepth)

File diff suppressed because it is too large Load diff

View file

@ -241,10 +241,7 @@ internal sealed partial class MossTankPanel
if (_profiles.Create(
name,
copyCurrent: true,
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_allSettings,
_noBuffItemNames,
out string notice))
{
@ -271,7 +268,17 @@ internal sealed partial class MossTankPanel
WriteVtank("Usage: /vt nav [save/load] [filename]");
return;
}
name = StripExtension(name, ".nav");
// Item J (slice-1 fix round): .af is the only VTank-compatible
// storage format now (Campaign VT slice 1 Part A); ".nav" is
// stripped for legacy-typed names, ".af" for names copy-pasted
// from a real .af file (the VtankProfiles directory, or an
// imports/ drop-in for TryImportLegacy below) — without both,
// "/vt nav save Foo.af" would have produced a doubled "Foo.af.af"
// file. Round 3 item 12: this comment previously cited an
// "exports/nav/" mirror directory that no longer exists — route
// profiles have written directly to their real .af file since the
// round 2 step 2-3 cutover, with no separate export copy.
name = StripExtension(name, ".nav", ".af");
if (operation == "save")
{
_routeProfiles.Create(
@ -353,7 +360,9 @@ internal sealed partial class MossTankPanel
WriteVtank("Usage: /vt meta [save/load] [filename]");
return;
}
name = StripExtension(name, ".met", ".json");
// Item J (slice-1 fix round): .af is the only VTank-compatible
// storage format now — see the analogous nav-command comment above.
name = StripExtension(name, ".met", ".json", ".af");
if (operation == "save")
{
_metaProfiles.Create(
@ -420,19 +429,28 @@ internal sealed partial class MossTankPanel
WriteVtank("Option set: Invalid option specified.");
return;
}
if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, out ExpressionValue value))
canonical = VtankOptionCatalog.Canonical(name);
VtankSettingValueType declaredType = VtankOptionCatalog.DeclaredType(canonical);
if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, declaredType, out ExpressionValue value))
{
WriteVtank("Option set: Invalid value specified.");
// Round 3 item 6: retail's exact failure text
// (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612)
// names the option and the CLR type its Settings row
// actually declares (gy's own m_a, ToString()'d).
WriteVtank(
"Option set: Invalid value specified. Proper type of "
+ canonical + " is " + DeclaredClrTypeName(declaredType) + ".");
return;
}
canonical = VtankOptionCatalog.Canonical(name);
SetMetaOption(canonical, value);
if (operation.Equals("setinall", StringComparison.OrdinalIgnoreCase))
{
int count = _profiles.SetOptionInAll(
canonical,
ToMonsterValue(value));
WriteVtank($"Set option {canonical} in {count} profile(s) = {GetMetaOption(canonical).ToDisplayString()}");
// Round 3 item 6: retail's exact bk.a text
// (refs/vtank/decompiled/bk.cs:32) — no "= value" suffix,
// and the count is every .usd file scanned, not just the
// ones that already had a row for this name.
int count = _profiles.SetOptionInAll(canonical, _allSettings);
WriteVtank($"Done saving setting {canonical} to all profiles. (Changed {count} profiles)");
}
else
{
@ -754,9 +772,9 @@ internal sealed partial class MossTankPanel
private void TestPet()
{
IReadOnlyList<PluginCombatTarget> targets = _host.Automation.Combat
.CaptureHostileTargets(_combatSettings.PetRangeMode == PetRangeMode.Custom
.CaptureHostileTargets((float)(_combatSettings.PetRangeMode == PetRangeMode.Custom
? _combatSettings.PetCustomRange
: _combatSettings.MaximumRange);
: _combatSettings.MaximumRange));
PetAutomationChoice choice = PetAutomation.Select(
_host.Automation.Items.CaptureOwnedItems(),
targets,
@ -962,22 +980,107 @@ internal sealed partial class MossTankPanel
return result;
}
private static bool TryParseOptionValue(string source, out ExpressionValue value)
/// <summary>
/// Free-form parse (no catalog type to check against) for callers like
/// the Advanced Options editor, which can target names outside the 137-
/// row catalog: best-effort bool, then number, then string.
/// </summary>
private static bool TryParseOptionValue(string source, out ExpressionValue value) =>
TryParseOptionValue(source, declaredType: null, out value);
/// <summary>
/// Round 3 item 6: <c>/vt opt set</c>/<c>setinall</c> validate the typed
/// value against the catalog's OWN declared <see cref="VtankSettingValueType"/>
/// (retail's <c>eSettingValueType</c>) rather than accepting anything —
/// a value that does not parse as that type fails with retail's exact
/// "Proper type of X is Y." text (<see cref="HandleOptionCommand"/>).
/// <paramref name="declaredType"/> is <see langword="null"/> only for
/// the free-form Advanced Options editor, which keeps the original lax
/// best-effort behavior.
/// </summary>
private static bool TryParseOptionValue(
string source,
VtankSettingValueType? declaredType,
out ExpressionValue value)
{
if (bool.TryParse(source, out bool boolean))
if (source.Length == 0)
{
value = ExpressionValue.Boolean(boolean);
return true;
value = default;
return false;
}
if (double.TryParse(source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number))
switch (declaredType)
{
value = ExpressionValue.Number(number);
return true;
case VtankSettingValueType.Bool:
if (bool.TryParse(source, out bool boolean))
{
value = ExpressionValue.Boolean(boolean);
return true;
}
value = default;
return false;
case VtankSettingValueType.Int:
case VtankSettingValueType.Enum:
if (int.TryParse(
source, NumberStyles.Integer, CultureInfo.InvariantCulture, out int integer))
{
value = ExpressionValue.Number(integer);
return true;
}
value = default;
return false;
case VtankSettingValueType.Double:
case VtankSettingValueType.Single:
if (double.TryParse(
source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number))
{
value = ExpressionValue.Number(number);
return true;
}
value = default;
return false;
case VtankSettingValueType.String:
value = ExpressionValue.String(source);
return true;
default:
// No declared type (free-form editor) or VtankSettingValueType.Custom
// (the one non-scalar row, RechargeHandlerSet, never reached
// through /vt opt set): keep the original lax parse.
if (bool.TryParse(source, out bool freeBoolean))
{
value = ExpressionValue.Boolean(freeBoolean);
return true;
}
if (double.TryParse(
source, NumberStyles.Float, CultureInfo.InvariantCulture, out double freeNumber))
{
value = ExpressionValue.Number(freeNumber);
return true;
}
value = ExpressionValue.String(source);
return true;
}
value = ExpressionValue.String(source);
return source.Length != 0;
}
/// <summary>
/// The CLR type name retail's exact failure text names
/// (<c>refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612</c>:
/// <c>cw2[1].b().ToString()</c> — the Settings row's underlying
/// <c>gy</c> cell type, <see cref="Type.ToString"/>'d). Retail stores an
/// Enum-declared row as a plain int cell (confirmed against the
/// UseArcs row in the shipped defaultsettings.usd fixture), so it also
/// reads "System.Int32".
/// </summary>
private static string DeclaredClrTypeName(VtankSettingValueType type) => type switch
{
VtankSettingValueType.Bool => "System.Boolean",
VtankSettingValueType.Double => "System.Double",
VtankSettingValueType.Int => "System.Int32",
VtankSettingValueType.Single => "System.Single",
VtankSettingValueType.String => "System.String",
VtankSettingValueType.Enum => "System.Int32",
_ => "System.Object",
};
private bool TryParseCoordinates(string source, out PluginNavigationPosition position)
{
position = default;

View file

@ -1,3 +1,4 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@ -6,13 +7,32 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Independent VTank loot-profile lifecycle. Macro settings select this
/// profile by character, but its ordered rules live in their own document.
/// VTank-compatible loot profile lifecycle. <c>.utl</c> (VTClassic's real
/// public format, <see cref="VtankLootProfileSerializer"/>) is the ONLY
/// storage/authoring format — round 3 item 9 cutover, matching the
/// Settings/.usd, Route/.af, and Meta/.af cutovers already landed. Real
/// VTank's own loot picker (<c>aa()</c>, <c>uTank2/PluginCore.cs:7127-7154</c>)
/// seeds ONLY <c>[None]</c> — there is no per-character auto loot file and
/// no "mine only" filter for loot at all
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3).
/// MossTank keeps its own established <see cref="ByCharacter"/> convention
/// (already load-bearing for the other three stores) as a recorded
/// adaptation rather than a retail behavior.
/// </summary>
internal sealed class MossTankLootProfileStore
{
public const string ByCharacter = "By char";
private const string IndexKey = "profiles/loot/index.json";
// The pre-cutover roster (round 3 item 9): before this cutover, this
// key was the LIVE index (Names + SelectedByCharacter) — unlike Meta/
// Route's already-abandoned pre-round-2 rosters, loot's roster was
// still actively read/written until this exact commit. Read-only now,
// consulted ONLY by the one-time SweepLegacyRosterIfNeeded (which also
// converts the SELECTED profile, so there is no separate
// MigrateLegacyIfNeeded here the way Settings/Meta/Route each have —
// the sweep already covers every named profile, selected or not).
private const string LegacyRosterKey = "profiles/loot/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
@ -20,62 +40,89 @@ internal sealed class MossTankLootProfileStore
};
private readonly IPluginHost _host;
private IndexDocument _index;
private string _characterName = string.Empty;
private string _selected = ByCharacter;
private bool _rosterSwept;
public MossTankLootProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
_index.Names ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
}
public string Selected => _selected;
/// <summary>
/// The bare name a user typed to select/create this profile (the file
/// name minus its <c>.utl</c> extension) — internal identity
/// (<see cref="_selected"/>) always carries the real, on-disk file
/// name; only display strips it, matching
/// <see cref="MossTankMetaProfileStore.Selected"/>'s own convention.
/// </summary>
public string Selected => StripUtl(_selected);
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
.Concat(_index.Names)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
private static string StripUtl(string name) => name.Equals(
ByCharacter, StringComparison.OrdinalIgnoreCase)
? name
: name.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)
? name[..^4]
: name;
private string Server => _host.Automation.Character.WorldName;
private IPluginStorage VtankStorage => _host.VtankProfiles;
private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0;
public IReadOnlyList<string> AvailableNames
{
get
{
var names = new List<string> { ByCharacter };
foreach (VtankProfileDirectory.ProfileEntry entry in
VtankProfileDirectory.ListLootProfiles(VtankStorage))
{
if (entry.FileName.Length == 0)
continue; // VTank's own "[None]"/MossTank's "[By char]" sentinels.
names.Add(StripUtl(entry.FileName));
}
return names;
}
}
public bool BindCharacter(string? characterName)
{
string normalized = string.IsNullOrWhiteSpace(characterName)
? string.Empty
: characterName.Trim();
if (string.Equals(
normalized,
_characterName,
StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
return false;
}
_characterName = normalized;
_selected = _index.SelectedByCharacter.TryGetValue(
SelectionKey(),
out string? selected)
&& IsKnown(selected)
? CanonicalName(selected)
: ByCharacter;
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
: null;
_selected = binding is { LootFileName.Length: > 0 } bound
? bound.LootFileName
: ByCharacter;
return true;
}
public bool Select(string? name)
{
string normalized = name?.Trim() ?? string.Empty;
if (!IsKnown(normalized))
if (normalized.Length == 0)
return false;
_selected = CanonicalName(normalized);
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
return true;
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
_selected = ByCharacter;
WriteBinding();
return true;
}
string candidate = ToFileName(normalized);
if (VtankStorage.IsAvailable && VtankStorage.ReadText(candidate) is not null)
{
_selected = candidate;
WriteBinding();
return true;
}
return false;
}
public bool Create(
@ -97,32 +144,17 @@ internal sealed class MossTankLootProfileStore
return false;
}
LootProfileDocument? currentDocument = copyCurrent
? Read<LootProfileDocument>(CurrentKey())
: null;
var document = new LootProfileDocument
string fileName = ToFileName(normalized);
var profile = new VtankLootProfile
{
Rules = copyCurrent
? current.Select(LootRuleDocument.From).ToArray()
: [],
Rules = copyCurrent ? current.ToList() : [],
SalvageCombine = copyCurrent
? (settings?.SalvageCombine.Clone()
?? currentDocument?.SalvageCombine?.Clone()
?? new VtankSalvageCombineSettings())
? settings?.SalvageCombine.Clone() ?? new VtankSalvageCombineSettings()
: new VtankSalvageCombineSettings(),
UnknownBlocks = copyCurrent
? currentDocument?.UnknownBlocks ?? []
: [],
};
Write(ProfileKey(normalized, byCharacter: false), document);
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
WriteLegacyExport(_selected, document);
WriteUtl(fileName, profile);
_selected = fileName;
WriteBinding();
notice = copyCurrent
? $"Copied loot rules to {_selected}."
: $"Created loot profile {_selected}.";
@ -133,18 +165,23 @@ internal sealed class MossTankLootProfileStore
public bool LoadCurrent(List<LootRule> target, LootSettings? settings = null)
{
ArgumentNullException.ThrowIfNull(target);
LootProfileDocument? document = Read<LootProfileDocument>(CurrentKey());
if (document is null)
SweepLegacyRosterIfNeeded();
string fileName = CurrentFileName();
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (text is null)
return false;
target.Clear();
foreach (LootRuleDocument rule in document.Rules ?? [])
target.Add(rule.ToRule());
if (settings is not null)
if (!VtankLootProfileSerializer.TryRead(text, out VtankLootProfile profile, out string error))
{
settings.SalvageCombine =
document.SalvageCombine?.Clone()
?? new VtankSalvageCombineSettings();
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "loot", fileName, text, new FormatException(error));
_host.Log.Warn(RecoveryNotice);
return false;
}
ApplyMossTankExpressions(profile);
target.Clear();
target.AddRange(profile.Rules);
if (settings is not null)
settings.SalvageCombine = profile.SalvageCombine.Clone();
return true;
}
@ -160,20 +197,22 @@ internal sealed class MossTankLootProfileStore
string normalized = name?.Trim() ?? string.Empty;
if (normalized.EndsWith(".utl", StringComparison.OrdinalIgnoreCase))
normalized = normalized[..^4];
if (!IsKnown(normalized))
if (normalized.Length == 0)
return false;
string canonical = CanonicalName(normalized);
string key = canonical.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? ProfileKey(_characterName, byCharacter: true)
: ProfileKey(canonical, byCharacter: false);
LootProfileDocument? document = Read<LootProfileDocument>(key);
if (document is null)
string fileName = normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? CurrentFileName()
: ToFileName(normalized);
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (text is null || !VtankLootProfileSerializer.TryRead(
text, out VtankLootProfile profile, out _))
{
return false;
}
ApplyMossTankExpressions(profile);
target.Clear();
foreach (LootRuleDocument rule in document.Rules ?? [])
target.Add(rule.ToRule());
target.AddRange(profile.Rules);
return true;
}
@ -181,17 +220,45 @@ internal sealed class MossTankLootProfileStore
IReadOnlyList<LootRule> rules,
LootSettings? settings = null)
{
LootProfileDocument? existing = Read<LootProfileDocument>(CurrentKey());
var document = new LootProfileDocument
string fileName = CurrentFileName();
var profile = new VtankLootProfile
{
Rules = rules.Select(LootRuleDocument.From).ToArray(),
SalvageCombine = settings?.SalvageCombine.Clone()
?? existing?.SalvageCombine?.Clone()
?? new VtankSalvageCombineSettings(),
UnknownBlocks = existing?.UnknownBlocks ?? [],
Rules = rules.ToList(),
SalvageCombine = settings?.SalvageCombine.Clone() ?? new VtankSalvageCombineSettings(),
};
Write(CurrentKey(), document);
WriteLegacyExport(LegacyProfileName(), document);
// Preserve UnknownBlocks/SourceVersion from whatever is already on
// disk (a drop-in real .utl may carry blocks MossTank does not
// understand yet) rather than discarding them on every save.
string? existingText = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (existingText is not null
&& VtankLootProfileSerializer.TryRead(existingText, out VtankLootProfile existing, out _))
{
profile.UnknownBlocks = existing.UnknownBlocks;
}
WriteUtl(fileName, profile);
}
/// <summary>
/// Round 3 item 10: deletes the currently selected named loot profile's
/// real <c>.utl</c> file, then falls back to <see cref="ByCharacter"/>.
/// Refuses for <see cref="ByCharacter"/> itself — there is no file to
/// delete, only a reset (see <see cref="ClearCurrent"/>), matching
/// <see cref="MossTankProfileStore.Delete"/>'s own contract.
/// </summary>
public bool Delete(out string notice)
{
if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
notice = "'By char' is the built-in loot profile and cannot be deleted.";
return false;
}
string fileName = _selected;
if (VtankStorage.IsAvailable)
VtankStorage.Delete(fileName);
_selected = ByCharacter;
WriteBinding();
notice = $"Deleted loot profile {StripUtl(fileName)}.";
return true;
}
public void ClearCurrent(List<LootRule> target, LootSettings? settings = null)
@ -239,47 +306,271 @@ internal sealed class MossTankLootProfileStore
return false;
}
var document = new LootProfileDocument
{
Rules = imported.Rules.Select(LootRuleDocument.From).ToArray(),
SalvageCombine = imported.SalvageCombine.Clone(),
UnknownBlocks = imported.UnknownBlocks.Select(
VtankLootExtraBlockDocument.From).ToArray(),
};
Write(ProfileKey(normalized, byCharacter: false), document);
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
string fileName = ToFileName(normalized);
WriteUtl(fileName, imported);
_selected = fileName;
WriteBinding();
target.Clear();
target.AddRange(imported.Rules);
if (settings is not null)
settings.SalvageCombine = imported.SalvageCombine.Clone();
WriteLegacyExport(_selected, document);
notice = $"Imported VTClassic loot profile {_selected}.";
return true;
}
private bool IsKnown(string? name) => name is not null
&& (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|| _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase));
// ------------------------------------------------------------------
// Legacy JSON -> .utl migration (round 3 item 9).
// ------------------------------------------------------------------
private string CanonicalName(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ByCharacter
: _index.Names.First(entry => entry.Equals(
name,
StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Round 3 item 9: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
/// Settings/Meta/Route's own per-selection migration (which only ever
/// converted whichever ONE profile happened to be selected), this
/// single pass converts EVERY named profile the roster lists AND this
/// character's own "By char" JSON document, since the roster was still
/// the live mechanism right up to this cutover — there is no separate
/// "already migrated one, sweep the rest" split here.
/// </summary>
private void SweepLegacyRosterIfNeeded()
{
if (_rosterSwept)
return;
_rosterSwept = true;
if (!_host.Storage.IsAvailable)
return;
private string CurrentKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ProfileKey(_characterName, byCharacter: true)
: ProfileKey(_selected, byCharacter: false);
// This character's own "By char" JSON document, if not already a
// real .utl file.
string byCharacterFileName = CurrentFileName();
if (VtankStorage.ReadText(byCharacterFileName) is null)
{
LootProfileDocument? byCharacter = ReadLegacyJson(
ProfileKey(_characterName, byCharacter: true));
if (byCharacter is not null)
{
WriteUtl(byCharacterFileName, byCharacter.ToVtankProfile());
_host.Storage.Delete(ProfileKey(_characterName, byCharacter: true));
}
}
LegacyRosterDocument? roster = ReadLegacyRoster();
if (roster?.Names is not { Count: > 0 } names)
return;
var remaining = new List<string>();
int migrated = 0;
foreach (string rawName in names)
{
string name = (rawName ?? string.Empty).Trim();
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
continue; // stale/invalid row; drop it rather than loop on it forever.
string legacyKey = ProfileKey(name, byCharacter: false);
LootProfileDocument? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
continue; // already converted (or never existed); drop the row.
string fileName = ToFileName(name);
if (VtankStorage.ReadText(fileName) is null)
WriteUtl(fileName, legacy.ToVtankProfile());
_host.Storage.Delete(legacyKey);
migrated++;
}
if (migrated == 0)
return;
if (remaining.Count > 0)
{
_host.Storage.WriteText(
LegacyRosterKey,
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, Options));
}
else
{
_host.Storage.Delete(LegacyRosterKey);
}
_host.Log.Warn($"Migrated {migrated} legacy MossTank named loot profile(s) from the old roster.");
}
private LegacyRosterDocument? ReadLegacyRoster()
{
string? json = null;
try
{
json = _host.Storage.ReadText(LegacyRosterKey);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, Options);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "loot", LegacyRosterKey, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private LootProfileDocument? ReadLegacyJson(string key)
{
if (!_host.Storage.IsAvailable)
return null;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<LootProfileDocument>(json, Options);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "loot", key, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
// ------------------------------------------------------------------
// File naming, storage plumbing.
// ------------------------------------------------------------------
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "utl")
: _selected;
private static string ToFileName(string bareName) =>
bareName.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)
? bareName
: bareName + ".utl";
private void WriteBinding()
{
if (!CanBindFiles || !VtankStorage.IsAvailable)
return;
VtankProfileDirectory.VtankCharacterBinding existing =
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
?? new VtankProfileDirectory.VtankCharacterBinding(
string.Empty, CurrentFileName(), string.Empty, null);
VtankProfileDirectory.WriteCharacterBinding(
VtankStorage,
_characterName,
Server,
existing with { LootFileName = CurrentFileName() });
}
private void WriteUtl(string fileName, VtankLootProfile profile)
{
if (!VtankStorage.IsAvailable)
return;
AttachMossTankExpressions(profile);
try
{
VtankStorage.WriteText(fileName, VtankLootProfileSerializer.Write(profile));
}
catch (Exception error)
{
_host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}");
}
}
// ------------------------------------------------------------------
// MossTank rule-expression preservation (round 3 item 9): VTClassic's
// real .utl requirement grammar cannot express an arbitrary MossTank
// Expression string at all (VtankLootProfileSerializer.ExportRequirements
// replaces an empty VtankRequirements list with a "safely disabled"
// placeholder requirement for VTClassic compatibility) — fine when
// .utl was only ever a courtesy export mirror alongside the
// authoritative JSON store, but silently DESTRUCTIVE now that .utl is
// the SOLE store: every MossTank-authored rule's Expression would be
// permanently lost on its very first save/reload cycle. Preserved
// instead in a MossTank-owned length-delimited block
// (VtankLootProfileSerializer's own UnknownBlocks mechanism already
// round-trips any block type it doesn't recognize as "SalvageCombine"
// verbatim — exactly the "a foreign reader keeps what it doesn't
// understand" contract this needs) so a real VTClassic reading the
// same file simply ignores it as an unknown block, the same as any
// other extension block.
// ------------------------------------------------------------------
private const string MossTankRulesBlockType = "MossTankRuleExpressions";
private static void AttachMossTankExpressions(VtankLootProfile profile)
{
profile.UnknownBlocks.RemoveAll(static block =>
string.Equals(block.Type, MossTankRulesBlockType, StringComparison.Ordinal));
var payload = new StringBuilder();
payload.Append(profile.Rules.Count.ToString(CultureInfo.InvariantCulture)).Append("\r\n");
foreach (LootRule rule in profile.Rules)
{
// A rule with real VtankRequirements (imported from a genuine
// VTClassic file, never touched by MossTank's own editor) has
// nothing of ours to preserve — record an empty slot so load
// leaves its VtankRequirements-derived state alone.
string expression = rule.VtankRequirements.Count > 0 ? string.Empty : rule.Expression;
payload.Append(expression.Length.ToString(CultureInfo.InvariantCulture)).Append("\r\n");
payload.Append(expression);
}
profile.UnknownBlocks.Add(new VtankLootExtraBlock
{
Type = MossTankRulesBlockType,
Payload = payload.ToString(),
});
}
private static void ApplyMossTankExpressions(VtankLootProfile profile)
{
VtankLootExtraBlock? block = profile.UnknownBlocks.FirstOrDefault(candidate =>
string.Equals(candidate.Type, MossTankRulesBlockType, StringComparison.Ordinal));
if (block is null)
return;
profile.UnknownBlocks.Remove(block);
string payload = block.Payload ?? string.Empty;
int position = 0;
if (!TryReadPayloadLine(payload, ref position, out string countText)
|| !int.TryParse(countText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int count))
{
return; // Corrupt/foreign block reusing our type name; ignore rather than throw.
}
int limit = Math.Min(count, profile.Rules.Count);
for (int index = 0; index < limit; index++)
{
if (!TryReadPayloadLine(payload, ref position, out string lengthText)
|| !int.TryParse(lengthText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int length)
|| length < 0
|| length > payload.Length - position)
{
return; // Truncated/corrupt; stop applying rather than throw.
}
string expression = payload.Substring(position, length);
position += length;
if (expression.Length == 0)
continue;
profile.Rules[index].Expression = expression;
profile.Rules[index].VtankRequirements.Clear();
}
}
private static bool TryReadPayloadLine(string text, ref int position, out string line)
{
if (position > text.Length)
{
line = string.Empty;
return false;
}
int start = position;
while (position < text.Length && text[position] is not ('\r' or '\n'))
position++;
line = text[start..position];
if (position < text.Length && text[position] == '\r')
position++;
if (position < text.Length && text[position] == '\n')
position++;
return true;
}
private static string ProfileKey(string value, bool byCharacter)
{
@ -290,90 +581,13 @@ internal sealed class MossTankLootProfileStore
return $"profiles/loot/{hash}.json";
}
private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
? "_default"
: _characterName;
// ------------------------------------------------------------------
// Migration-only shapes: the OLD JSON roster and per-profile document,
// kept solely so SweepLegacyRosterIfNeeded can recover them once.
// Nothing else in this file writes either shape again.
// ------------------------------------------------------------------
private T? Read<T>(string key) where T : class
{
if (!_host.Storage.IsAvailable)
return null;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<T>(json, Options);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"loot",
key,
json,
error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private void Write<T>(string key, T document)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
}
catch (Exception error)
{
_host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}");
}
}
private void SaveIndex() => Write(IndexKey, _index);
private void WriteLegacyExport(string name, LootProfileDocument document)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(
$"exports/{LegacyFileName(name)}.utl",
VtankLootProfileSerializer.Write(document.ToVtankProfile()));
}
catch (Exception error)
{
_host.Log.Warn(
$"MossTank VTClassic loot export could not be saved: {error.Message}");
}
}
private string LegacyProfileName() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? string.IsNullOrWhiteSpace(_characterName)
? ByCharacter
: _characterName
: _selected;
private static string LegacyFileName(string name)
{
char[] invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(name.Length);
foreach (char value in name.Trim())
{
result.Append(value is '/' or '\\' || invalid.Contains(value)
? '_'
: value);
}
return result.Length == 0 ? "Loot" : result.ToString();
}
private sealed class IndexDocument
private sealed class LegacyRosterDocument
{
public int Version { get; set; } = 1;
public List<string> Names { get; set; } = [];

View file

@ -5,47 +5,76 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>Independent VTank-style By-char/named Meta profile lifetime.</summary>
/// <summary>
/// VTank-compatible Meta profile lifecycle. <c>.af</c> (metaf's human-
/// readable grammar, see <see cref="MetafSerializer"/>) is the ONLY storage
/// and authoring format — real VTank's own <c>ac()</c> meta-profile picker
/// (<c>docs/research/vtank-kb/01-settings-and-profiles.md</c> section 3)
/// seeds <c>[None]</c>/<c>[By char]</c> then every non-<c>--</c> file: named
/// Meta profiles are plain shared files (unlike the Settings picker, there
/// is no per-character <c>[Char] suffix</c> sub-profile carve-out here),
/// while the per-character auto file uses VTank's <c>--Name_Server.af</c>
/// naming and is therefore hidden from that listing by construction.
/// </summary>
internal sealed class MossTankMetaProfileStore
{
public const string ByCharacter = "By char";
private const string IndexKey = "profiles/meta/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
// The pre-cutover roster (round 3 item 2): before the .af cutover this
// store kept a flat Names list here (unlike the settings roster, named
// Meta profiles were never owner-scoped — one shared, globally-hashed
// key per name). The cutover stopped reading this key at all, orphaning
// every named profile except whichever one a character had selected
// (that one alone still converts, via MigrateLegacyIfNeeded). Read-only:
// consulted ONLY by the one-time SweepLegacyRosterIfNeeded.
private const string LegacyRosterKey = "profiles/meta/index.json";
private readonly IPluginHost _host;
private IndexDocument _index;
private string _character = string.Empty;
private string _selected = ByCharacter;
private string? _pendingLegacyBareName;
private bool _rosterSwept;
public MossTankMetaProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
_index.Names ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter ?? [],
StringComparer.OrdinalIgnoreCase);
}
private List<string> Names => _index.Names ??= [];
private Dictionary<string, string> SelectedByCharacter =>
_index.SelectedByCharacter ??= new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
public string Selected => _selected;
/// <summary>
/// The bare name a user typed to select/create this profile (the file
/// name minus its <c>.af</c> extension) — internal identity (<see cref="_selected"/>)
/// always carries the real, on-disk file name; only display strips it.
/// </summary>
public string Selected => StripAf(_selected);
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
.Concat(Names)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static name => name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
public string? SaveNotice { get; private set; }
private string Server => _host.Automation.Character.WorldName;
private IPluginStorage VtankStorage => _host.VtankProfiles;
private bool CanBindFiles => _character.Length > 0 && Server.Length > 0;
private static string StripAf(string name) => name.Equals(
ByCharacter, StringComparison.OrdinalIgnoreCase)
? name
: name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? name[..^3]
: name;
public IReadOnlyList<string> AvailableNames
{
get
{
var names = new List<string> { ByCharacter };
foreach (VtankProfileDirectory.ProfileEntry entry in
VtankProfileDirectory.ListMetaProfiles(VtankStorage))
{
if (entry.FileName.Length == 0)
continue; // VTank's own "[None]"/"[By char]" sentinels.
names.Add(StripAf(entry.FileName));
}
return names;
}
}
public bool BindCharacter(string? characterName)
{
@ -55,33 +84,106 @@ internal sealed class MossTankMetaProfileStore
if (normalized.Equals(_character, StringComparison.OrdinalIgnoreCase))
return false;
_character = normalized;
_selected = SelectedByCharacter.TryGetValue(
CharacterKey(),
out string? selected)
&& IsKnown(selected)
? Canonical(selected)
: ByCharacter;
_pendingLegacyBareName = null;
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server)
: null;
_selected = binding is { MetaFileName.Length: > 0 } bound
? bound.MetaFileName
: ByCharacter;
return true;
}
public MetaProfile LoadCurrent() =>
Read<MetaProfile>(CurrentKey()) ?? new MetaProfile();
public void SaveCurrent(MetaProfile profile)
public MetaProfile LoadCurrent()
{
Write(CurrentKey(), profile);
WriteLegacyExport(LegacyProfileName(), profile);
SweepLegacyRosterIfNeeded();
MigrateLegacyIfNeeded();
string fileName = CurrentFileName();
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (text is null)
return new MetaProfile();
if (!MetafSerializer.TryLoadMeta(text, _host.Automation.Spells, out MetaProfile profile, out string error))
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "meta", fileName, text, new FormatException(error));
_host.Log.Warn(RecoveryNotice);
return new MetaProfile();
}
return profile;
}
/// <summary>
/// Saves <paramref name="profile"/> as this selection's <c>.af</c> file.
/// Real VTank/metaf has no way to represent <see cref="MetaRule.Enabled"/>
/// <see langword="false"/> (a MossTank-only extension) — rather than
/// silently dropping a disabled rule, the save is refused entirely (the
/// file on disk keeps its last good content) and <see cref="SaveNotice"/>
/// carries a user-visible reason. Returns <see langword="true"/> only
/// when the file was actually written.
/// </summary>
public bool SaveCurrent(MetaProfile profile)
{
string fileName = CurrentFileName();
string text;
try
{
text = MetafSerializer.SaveMeta(profile);
}
catch (InvalidOperationException error)
{
SaveNotice = $"Meta profile '{fileName}' was NOT saved: {error.Message}";
_host.Log.Warn(SaveNotice);
return false;
}
if (!VtankStorage.IsAvailable)
return false;
try
{
VtankStorage.WriteText(fileName, text);
}
catch (Exception error)
{
SaveNotice = $"Meta profile '{fileName}' could not be saved: {error.Message}";
_host.Log.Warn(SaveNotice);
return false;
}
SaveNotice = null;
return true;
}
public bool Select(string? name)
{
string normalized = Normalize(name);
if (!IsKnown(normalized))
if (normalized.Length == 0)
return false;
_selected = Canonical(normalized);
SelectedByCharacter[CharacterKey()] = _selected;
SaveIndex();
return true;
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
_selected = ByCharacter;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
string plain = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
if (VtankStorage.IsAvailable && VtankStorage.ReadText(plain) is not null)
{
_selected = plain;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
if (_host.Storage.IsAvailable
&& _host.Storage.ReadText(LegacyNamedKey(normalized)) is not null)
{
_selected = plain;
_pendingLegacyBareName = normalized;
WriteBinding();
return true;
}
return false;
}
public bool Create(
@ -97,19 +199,18 @@ internal sealed class MossTankMetaProfileStore
notice = "Enter a unique Meta profile name (1-64 characters).";
return false;
}
MetaProfile document = copyCurrent
? Clone(current)
: new MetaProfile();
Write(NamedKey(normalized), document);
if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
Names.Add(normalized);
_selected = normalized;
SelectedByCharacter[CharacterKey()] = normalized;
SaveIndex();
WriteLegacyExport(normalized, document);
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
MetaProfile document = copyCurrent ? Clone(current) : new MetaProfile();
if (!SaveTo(fileName, document, out notice))
return false;
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = copyCurrent
? $"Copied Meta profile to {normalized}."
: $"Created Meta profile {normalized}.";
? $"Copied Meta profile to {fileName}."
: $"Created Meta profile {fileName}.";
return true;
}
@ -139,20 +240,48 @@ internal sealed class MossTankMetaProfileStore
notice = $"VTank Meta file '{normalized}.met' was not found in imports.";
return false;
}
if (!VtankMetaProfileSerializer.TryLoad(source, out profile, out string error))
if (!VtankMetaProfileSerializer.TryLoad(
source, _host.Automation.Spells, out profile, out string error))
{
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;
}
if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
Names.Add(normalized);
_selected = Names.First(existing => existing.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
SelectedByCharacter[CharacterKey()] = _selected;
SaveIndex();
SaveCurrent(profile);
notice = $"Imported VTank Meta profile {_selected}.";
string fileName = normalized.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? normalized
: normalized + ".af";
if (!SaveTo(fileName, profile, out string saveNotice))
{
notice = saveNotice;
return false;
}
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Imported VTank Meta profile {fileName}.";
return true;
}
/// <summary>
/// Round 3 item 10: deletes the currently selected named Meta profile's
/// real <c>.af</c> file, then falls back to <see cref="ByCharacter"/>.
/// Refuses for <see cref="ByCharacter"/> itself — there is no file to
/// delete, only a reset (see <see cref="ClearCurrent"/>), matching
/// <see cref="MossTankProfileStore.Delete"/>'s own contract.
/// </summary>
public bool Delete(out string notice)
{
if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
notice = "'By char' is the built-in Meta profile and cannot be deleted.";
return false;
}
string fileName = _selected;
if (VtankStorage.IsAvailable)
VtankStorage.Delete(fileName);
_selected = ByCharacter;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Deleted Meta profile {fileName}.";
return true;
}
@ -163,124 +292,237 @@ internal sealed class MossTankMetaProfileStore
return empty;
}
private string CurrentKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? $"profiles/meta/by-character/{Hash(_character)}.json"
: NamedKey(_selected);
// ------------------------------------------------------------------
// Legacy JSON -> .af migration.
// ------------------------------------------------------------------
private static string NamedKey(string name) =>
$"profiles/meta/named/{Hash(name)}.json";
private string CharacterKey() =>
string.IsNullOrWhiteSpace(_character) ? "anonymous" : _character;
private bool IsKnown(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
|| Names.Contains(name, StringComparer.OrdinalIgnoreCase);
private string Canonical(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ByCharacter
: Names.First(existing => existing.Equals(
name,
StringComparison.OrdinalIgnoreCase));
private void SaveIndex() => Write(IndexKey, _index);
private void WriteLegacyExport(string name, MetaProfile profile)
/// <summary>
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
/// ONE currently-selected profile — this converts every OTHER named
/// Meta profile the old roster still lists. Named Meta profiles were
/// never owner-scoped (one shared, globally-hashed key per name), so —
/// unlike the settings roster — every entry converts regardless of
/// which character is currently bound.
/// </summary>
private void SweepLegacyRosterIfNeeded()
{
if (_rosterSwept)
return;
_rosterSwept = true;
if (!_host.Storage.IsAvailable)
return;
try
List<string>? names = ReadRosterNames();
if (names is not { Count: > 0 })
return;
var remaining = new List<string>();
int migrated = 0;
foreach (string rawName in names)
{
string name = (rawName ?? string.Empty).Trim();
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
continue; // stale/invalid row; drop it rather than loop on it forever.
string legacyKey = LegacyNamedKey(name);
MetaProfile? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
continue; // already converted (or never existed); drop the row.
string fileName = name.EndsWith(".af", StringComparison.OrdinalIgnoreCase)
? name
: name + ".af";
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
{
if (!SaveTo(fileName, legacy, out string notice))
{
// Representational loss (a disabled rule) — same gate as
// MigrateLegacyIfNeeded: keep the row so this can be
// retried once the user resolves it.
_host.Log.Warn($"MossTank could not sweep legacy Meta profile '{name}': {notice}");
remaining.Add(name);
continue;
}
}
_host.Storage.Delete(legacyKey);
migrated++;
}
if (migrated == 0)
return;
if (remaining.Count > 0)
{
_host.Storage.WriteText(
$"exports/{LegacyFileName(name)}.met",
VtankMetaProfileSerializer.Save(profile));
LegacyRosterKey,
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions));
}
else
{
_host.Storage.Delete(LegacyRosterKey);
}
_host.Log.Warn($"Migrated {migrated} legacy MossTank named Meta profile(s) from the old roster.");
}
private List<string>? ReadRosterNames()
{
string? json = null;
try
{
json = _host.Storage.ReadText(LegacyRosterKey);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, JsonOptions)?.Names;
}
catch (Exception error)
{
_host.Log.Warn(
$"MossTank VTank Meta export could not be saved: {error.Message}");
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "meta", LegacyRosterKey, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private string LegacyProfileName() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? string.IsNullOrWhiteSpace(_character) ? ByCharacter : _character
: _selected;
private static string LegacyFileName(string name)
private void MigrateLegacyIfNeeded()
{
char[] invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(name.Length);
foreach (char value in name.Trim())
string fileName = CurrentFileName();
if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null)
{
result.Append(value is '/' or '\\' || invalid.Contains(value)
? '_'
: value);
_pendingLegacyBareName = null;
return;
}
return result.Length == 0 ? "Meta" : result.ToString();
string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName
? LegacyNamedKey(bareName)
: LegacyByCharacterKey();
MetaProfile? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
{
_pendingLegacyBareName = null;
return;
}
if (!SaveTo(fileName, legacy, out string notice))
{
// Representational loss (a disabled rule) blocks the migration
// outright rather than silently dropping it: the legacy JSON
// stays put (still fully readable) until the user resolves it.
_host.Log.Warn($"MossTank could not migrate legacy Meta profile: {notice}");
_pendingLegacyBareName = null;
return;
}
if (_host.Storage.IsAvailable)
_host.Storage.Delete(legacyKey);
_pendingLegacyBareName = null;
_host.Log.Warn($"Migrated legacy MossTank Meta profile '{legacyKey}' to '{fileName}'.");
}
private T? Read<T>(string key)
private MetaProfile? ReadLegacyJson(string key)
{
if (!_host.Storage.IsAvailable)
return default;
return null;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? default
: JsonSerializer.Deserialize<T>(json, Options);
? null
: JsonSerializer.Deserialize<MetaProfile>(json, JsonOptions);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"meta",
key,
json,
error);
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "meta", key, json, error);
_host.Log.Error(RecoveryNotice, error);
return default;
return null;
}
}
private void Write<T>(string key, T value)
private bool SaveTo(string fileName, MetaProfile profile, out string notice)
{
if (!_host.Storage.IsAvailable)
return;
string text;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(value, Options));
text = MetafSerializer.SaveMeta(profile);
}
catch (InvalidOperationException error)
{
notice = $"Meta profile '{fileName}' was NOT saved: {error.Message}";
SaveNotice = notice;
_host.Log.Warn(notice);
return false;
}
if (!VtankStorage.IsAvailable)
{
notice = "VTank Meta storage is unavailable.";
return false;
}
try
{
VtankStorage.WriteText(fileName, text);
}
catch (Exception error)
{
_host.Log.Error($"Unable to save MossTank Meta profile '{key}'.", error);
notice = $"Meta profile '{fileName}' could not be saved: {error.Message}";
SaveNotice = notice;
_host.Log.Warn(notice);
return false;
}
SaveNotice = null;
notice = string.Empty;
return true;
}
// ------------------------------------------------------------------
// File naming, storage plumbing.
// ------------------------------------------------------------------
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? VtankProfileDirectory.AutoCharacterFileName(_character, Server, "af")
: _selected;
private void WriteBinding()
{
if (!CanBindFiles || !VtankStorage.IsAvailable)
return;
VtankProfileDirectory.VtankCharacterBinding existing =
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _character, Server)
?? new VtankProfileDirectory.VtankCharacterBinding(
string.Empty, string.Empty, string.Empty, CurrentFileName());
VtankProfileDirectory.WriteCharacterBinding(
VtankStorage,
_character,
Server,
existing with { MetaFileName = CurrentFileName() });
}
private static MetaProfile Clone(MetaProfile profile) =>
JsonSerializer.Deserialize<MetaProfile>(
JsonSerializer.Serialize(profile, Options),
Options) ?? new MetaProfile();
JsonSerializer.Serialize(profile, JsonOptions),
JsonOptions) ?? new MetaProfile();
private static string Normalize(string? name) => name?.Trim() ?? string.Empty;
private string LegacyByCharacterKey() =>
$"profiles/meta/by-character/{Hash(_character)}.json";
private static string LegacyNamedKey(string name) =>
$"profiles/meta/named/{Hash(name)}.json";
private static string Hash(string value)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToLowerInvariant()));
return Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant();
}
private sealed class IndexDocument
private static readonly JsonSerializerOptions JsonOptions = new()
{
public List<string>? Names { get; set; } = [];
public Dictionary<string, string>? SelectedByCharacter { get; set; } = [];
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
// Read-only: the pre-cutover roster shape at LegacyRosterKey, kept
// solely so SweepLegacyRosterIfNeeded can recover it once.
private sealed class LegacyRosterDocument
{
public List<string> Names { get; set; } = [];
}
}

View file

@ -48,6 +48,14 @@ internal sealed partial class MossTankPanel
private readonly CombatSettings _combatSettings = new();
private readonly InventorySettings _inventorySettings = new();
private readonly NavigationSettings _navigationSettings = new();
/// <summary>
/// One stable bundle of every live settings object the VTank-catalog
/// Settings profile (<c>.usd</c>) covers, built once so
/// <see cref="MossTankProfileStore"/>'s Load/Save/Create/ClearCurrent/
/// SetOptionInAll surface never has to repeat five constructor
/// parameters at every call site.
/// </summary>
private readonly VtankSettingsProfileSerializer.AllSettings _allSettings;
private readonly MossTankProfileStore _profiles;
private readonly MossTankLootProfileStore _lootProfiles;
private readonly MossTankRouteProfileStore _routeProfiles;
@ -182,14 +190,17 @@ internal sealed partial class MossTankPanel
{
_host = host;
_firstRunGuidancePending = NeedsFirstRunGuidance(host);
_allSettings = new VtankSettingsProfileSerializer.AllSettings
{
Combat = _combatSettings,
Buffs = _buffSettings,
Vitals = _vitalSettings,
Inventory = _inventorySettings,
Navigation = _navigationSettings,
};
_profiles = new MossTankProfileStore(host);
_profiles.BindCharacter(host.Automation.Character.Name);
_profiles.LoadCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.LoadCurrent(_allSettings, _noBuffItemNames);
_lootProfiles = new MossTankLootProfileStore(host);
_lootProfiles.BindCharacter(host.Automation.Character.Name);
if (!_lootProfiles.LoadCurrent(
@ -202,7 +213,7 @@ internal sealed partial class MossTankPanel
}
_routeProfiles = new MossTankRouteProfileStore(host);
_routeProfiles.BindCharacter(host.Automation.Character.Name);
if (!_routeProfiles.LoadCurrent(_navigationSettings))
if (!_routeProfiles.LoadCurrent(_navigationSettings, host.Automation.Spells))
_routeProfiles.SaveCurrent(_navigationSettings);
_combat = new CombatController(host, _combatSettings, _vitalSettings);
_buffCasterPreparer = new BuffCasterPreparer(
@ -583,6 +594,7 @@ internal sealed partial class MossTankPanel
public Action CopyLootProfile => () =>
CreateLootProfileCore(copyCurrent: true);
public Action ClearLootProfile => ClearLootProfileCore;
public Action DeleteLootProfile => DeleteLootProfileCore;
public Action CloseLootEditor => () => _lootEditorVisible = false;
public Action<int> SelectLootRule => SelectLootRuleCore;
public Action<string> SetLootExpressionDraft => value =>
@ -745,6 +757,7 @@ internal sealed partial class MossTankPanel
public Action CopyRouteProfile => () =>
CreateRouteProfileCore(copyCurrent: true);
public Action ClearRouteProfile => ClearRouteProfileCore;
public Action DeleteRouteProfile => DeleteRouteProfileCore;
public Action SetFollowTarget => CaptureFollowTarget;
// ── Meta profile / editor ────────────────────────────────────────────
@ -815,6 +828,7 @@ internal sealed partial class MossTankPanel
public Action CreateMetaProfile => () => CreateMetaProfileCore(copyCurrent: false);
public Action CopyMetaProfile => () => CreateMetaProfileCore(copyCurrent: true);
public Action ClearMetaProfile => ClearMetaProfileCore;
public Action DeleteMetaProfile => DeleteMetaProfileCore;
// ── Profiles tab ─────────────────────────────────────────────────────
public IReadOnlyList<string> MacroProfileNames => _profiles.AvailableNames;
@ -839,6 +853,7 @@ internal sealed partial class MossTankPanel
public Action CreateProfile => () => CreateProfileCore(copyCurrent: false);
public Action CopyProfile => () => CreateProfileCore(copyCurrent: true);
public Action ClearProfile => ClearProfileCore;
public Action DeleteProfile => DeleteProfileCore;
public Action ToggleMineOnly => () =>
{
string before = _profiles.Selected;
@ -1413,6 +1428,17 @@ internal sealed partial class MossTankPanel
_lootEditorNotice = $"Cleared {_lootProfiles.Selected}.";
}
private void DeleteLootProfileCore()
{
if (!_lootProfiles.Delete(out string notice))
{
_lootEditorNotice = notice;
return;
}
LoadLootProfile();
_lootEditorNotice = notice;
}
private void LoadLootProfile()
{
if (!_lootProfiles.LoadCurrent(
@ -1587,7 +1613,15 @@ internal sealed partial class MossTankPanel
AddRouteWaypoint(new RouteWaypoint
{
Type = type,
Position = target.Position,
// Retail's own header x/y/z for Portal2/UseNPC is dead weight —
// e9/fa extend `at`, whose position accessor is the player's
// OWN live position, never read back into anything meaningful
// (docs/research/vtank-kb/06-navigation-and-nav.md section 1.2).
// ReferencePosition is the embedded d-record: the real target
// coordinate matched against live world objects by name+class+
// proximity (Navigation.cs's TickUse).
Position = _host.Automation.Navigation.Snapshot.Position,
ReferencePosition = target.Position,
ObjectId = target.ObjectId,
ObjectName = target.Name,
});
@ -1764,9 +1798,20 @@ internal sealed partial class MossTankPanel
_routeNotice = $"Cleared {_routeProfiles.Selected}.";
}
private void DeleteRouteProfileCore()
{
if (!_routeProfiles.Delete(out string notice))
{
_routeNotice = notice;
return;
}
LoadRouteProfile();
_routeNotice = notice;
}
private void LoadRouteProfile()
{
if (!_routeProfiles.LoadCurrent(_navigationSettings))
if (!_routeProfiles.LoadCurrent(_navigationSettings, _host.Automation.Spells))
_routeProfiles.SaveCurrent(_navigationSettings);
if (_initialized)
ApplyPersistedOptionOverrides();
@ -2225,9 +2270,9 @@ internal sealed partial class MossTankPanel
}
_metaProfile.Rules.Add(rule);
_selectedMetaRule = _metaProfile.Rules.Count - 1;
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Added rule in {rule.State}.";
if (SaveMetaProfile())
_metaNotice = $"Added rule in {rule.State}.";
}
private void ApplyMetaRuleCore()
@ -2244,9 +2289,9 @@ internal sealed partial class MossTankPanel
}
replacement.Id = current.Id;
_metaProfile.Rules[_selectedMetaRule] = replacement;
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Updated rule in {replacement.State}.";
if (SaveMetaProfile())
_metaNotice = $"Updated rule in {replacement.State}.";
}
private bool TryBuildMetaRule(out MetaRule rule, out string error)
@ -2301,9 +2346,9 @@ internal sealed partial class MossTankPanel
_selectedMetaRule = Math.Min(
_selectedMetaRule,
Math.Max(0, _metaProfile.Rules.Count - 1));
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Removed rule from {selected.State}.";
if (SaveMetaProfile())
_metaNotice = $"Removed rule from {selected.State}.";
}
private void MoveMetaRule(int direction)
@ -2318,9 +2363,9 @@ internal sealed partial class MossTankPanel
_metaProfile.Rules.RemoveAt(_selectedMetaRule);
_metaProfile.Rules.Insert(destination, rule);
_selectedMetaRule = destination;
SaveMetaProfile();
RefreshMetaEditor();
_metaNotice = $"Moved {rule.State} rule.";
if (SaveMetaProfile())
_metaNotice = $"Moved {rule.State} rule.";
}
private void SelectMetaProfileCore(string name)
@ -2360,6 +2405,17 @@ internal sealed partial class MossTankPanel
_metaNotice = $"Cleared Meta profile {_metaProfiles.Selected}.";
}
private void DeleteMetaProfileCore()
{
if (!_metaProfiles.Delete(out string notice))
{
_metaNotice = notice;
return;
}
LoadMetaProfile();
_metaNotice = notice;
}
private void LoadMetaProfile()
{
_metaProfile = _metaProfiles.LoadCurrent();
@ -2370,7 +2426,22 @@ internal sealed partial class MossTankPanel
RefreshMetaEditor();
}
private void SaveMetaProfile() => _metaProfiles.SaveCurrent(_metaProfile);
/// <summary>
/// Saves the current Meta profile as <c>.af</c>. Returns
/// <see langword="false"/> when the save was refused (a disabled rule
/// has no metaf-compatible representation) — callers must not overwrite
/// <see cref="_metaNotice"/> with a generic success message in that
/// case, since <see cref="MossTankMetaProfileStore.SaveNotice"/> already
/// carries the user-visible reason.
/// </summary>
private bool SaveMetaProfile()
{
if (_metaProfiles.SaveCurrent(_metaProfile))
return true;
if (_metaProfiles.SaveNotice is { } notice)
_metaNotice = notice;
return false;
}
private static string DescribeMetaCondition(MetaCondition condition) =>
condition.Kind switch
@ -2408,19 +2479,16 @@ internal sealed partial class MossTankPanel
return nearest;
}
private void LoadEmbeddedNavigationRoute(string source)
private void LoadEmbeddedNavigationRoute(NavigationSettings? route)
{
_navigation.Reset();
if (!VtankNavRouteSerializer.TryLoad(
source,
_navigationSettings,
_host.Automation.Spells,
out string error))
if (route is null)
{
_routeNotice = $"Embedded route rejected: {error}";
_host.Log.Warn($"MossTank Meta embedded route rejected: {error}");
_routeNotice = "Embedded route rejected: unresolved Nav tag.";
_host.Log.Warn("MossTank Meta embedded route rejected: unresolved Nav tag.");
return;
}
VtankNavRouteSerializer.Apply(route, _navigationSettings);
_routeProfiles.SaveCurrent(_navigationSettings);
_selectedRouteWaypoint = 0;
RefreshRouteEditor();
@ -2528,7 +2596,7 @@ internal sealed partial class MossTankPanel
_combatSettings.DebuffPrecastSeconds),
"switchwandstodebuff" => ExpressionValue.Boolean(
_combatSettings.SwitchWandsToDebuff),
"usearcs" => ExpressionValue.Boolean(_combatSettings.UseArcs),
"usearcs" => ExpressionValue.Number((int)_combatSettings.UseArcs),
"deleteghostmonsters" => ExpressionValue.Boolean(
_combatSettings.DeleteGhostMonsters),
"ghostmonsterspellattemptcount" => ExpressionValue.Number(
@ -2998,7 +3066,8 @@ internal sealed partial class MossTankPanel
_combatSettings.SwitchWandsToDebuff = value.IsTruthy;
break;
case "usearcs":
_combatSettings.UseArcs = value.IsTruthy;
_combatSettings.UseArcs = (UseArcsMode)Math.Clamp(
value.AsInt32("UseArcs"), 1, 3);
break;
case "deleteghostmonsters":
_combatSettings.DeleteGhostMonsters = value.IsTruthy;
@ -3414,10 +3483,7 @@ internal sealed partial class MossTankPanel
if (!_profiles.Create(
_profileNameDraft,
copyCurrent,
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_allSettings,
_noBuffItemNames,
out string notice))
{
@ -3431,24 +3497,25 @@ internal sealed partial class MossTankPanel
private void ClearProfileCore()
{
_profiles.ClearCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.ClearCurrent(_allSettings, _noBuffItemNames);
_profileLifecycleNotice = $"Cleared {_profiles.Selected} to VTank defaults.";
ResetProfileConsumers();
}
private void DeleteProfileCore()
{
if (!_profiles.Delete(out string notice))
{
_profileLifecycleNotice = notice;
return;
}
LoadSelectedProfile();
_profileLifecycleNotice = notice;
}
private void LoadSelectedProfile()
{
_profiles.LoadCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.LoadCurrent(_allSettings, _noBuffItemNames);
LoadLootProfile();
LoadRouteProfile();
ApplyPersistedOptionOverrides();
@ -3532,12 +3599,7 @@ internal sealed partial class MossTankPanel
private void SaveProfile()
{
_profiles.SaveCurrent(
_combatSettings,
_buffSettings,
_vitalSettings,
_inventorySettings,
_noBuffItemNames);
_profiles.SaveCurrent(_allSettings, _noBuffItemNames);
_lootProfiles.SaveCurrent(
_inventorySettings.Loot.Rules,
_inventorySettings.Loot);

File diff suppressed because it is too large Load diff

View file

@ -6,45 +6,86 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Independent VTank navigation-profile lifecycle. The selected route is
/// remembered per character; "By char" is a private route document and named
/// profiles are reusable copies.
/// VTank-compatible navigation-route lifecycle. <c>.af</c> is the ONLY
/// storage/authoring format — but only for the fields metaf's own
/// <c>NAV:</c> grammar actually represents (<see cref="NavigationSettings.Mode"/>,
/// <see cref="NavigationSettings.Waypoints"/>,
/// <see cref="NavigationSettings.FollowTargetObjectId"/>/
/// <see cref="NavigationSettings.FollowTargetName"/> — see
/// <see cref="MetafSerializer.SaveNav"/>/<see cref="MetafSerializer.TryLoadNav"/>).
/// Every other <see cref="NavigationSettings"/> field
/// (Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/OpenDoors/
/// Door*) is a real VTank Settings-table row with its own catalog name
/// (<c>EnableNav</c>, <c>NavPriorityBoost</c>, <c>NavCloseStopRange</c>,
/// …) already owned end-to-end by <see cref="MossTankProfileStore"/>'s
/// <c>.usd</c> profile — this store never touches them, matching real
/// VTank's own split between the Settings-scoped nav prefs and the
/// per-route file.
///
/// Named route files carry a <c>nav_</c> prefix (metaf's own observed
/// convention for a stand-alone nav-only <c>.af</c>, see the committed
/// <c>nav_*.af</c> fixtures) so a route and a Meta profile sharing the same
/// user-typed name never collide in the shared <see cref="IPluginHost.VtankProfiles"/>
/// directory.
/// </summary>
internal sealed class MossTankRouteProfileStore
{
public const string ByCharacter = "By char";
private const string IndexKey = "profiles/route/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private const string NavPrefix = "nav_";
// The pre-cutover roster (round 3 item 2): before the .af cutover this
// store kept a flat Names list here (named routes were never
// owner-scoped — one shared, globally-hashed key per name). The
// cutover stopped reading this key at all, orphaning every named route
// except whichever one a character had selected (that one alone still
// converts, via MigrateLegacyIfNeeded). Read-only: consulted ONLY by
// the one-time SweepLegacyRosterIfNeeded.
private const string LegacyRosterKey = "profiles/route/index.json";
private readonly IPluginHost _host;
private IndexDocument _index;
private string _characterName = string.Empty;
private string _selected = ByCharacter;
private string? _pendingLegacyBareName;
private bool _rosterSwept;
public MossTankRouteProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
_index.Names ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
}
public string Selected => _selected;
public string Selected => Strip(_selected);
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
.Concat(_index.Names)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
private string Server => _host.Automation.Character.WorldName;
private IPluginStorage VtankStorage => _host.VtankProfiles;
private bool CanBindFiles => _characterName.Length > 0 && Server.Length > 0;
private static string Strip(string fileName)
{
if (fileName.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
return fileName;
string value = fileName;
if (value.StartsWith(NavPrefix, StringComparison.Ordinal))
value = value[NavPrefix.Length..];
if (value.EndsWith(".af", StringComparison.OrdinalIgnoreCase))
value = value[..^3];
return value;
}
public IReadOnlyList<string> AvailableNames
{
get
{
var names = new List<string> { ByCharacter };
foreach (VtankProfileDirectory.ProfileEntry entry in
VtankProfileDirectory.ListNavigationProfiles(VtankStorage))
{
if (entry.FileName.Length == 0)
continue; // VTank's own "[None]"/"[By char]" sentinels.
names.Add(Strip(entry.FileName));
}
return names;
}
}
public bool BindCharacter(string? characterName)
{
@ -54,24 +95,46 @@ internal sealed class MossTankRouteProfileStore
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
return false;
_characterName = normalized;
_selected = _index.SelectedByCharacter.TryGetValue(
SelectionKey(),
out string? selected)
&& IsKnown(selected)
? CanonicalName(selected)
: ByCharacter;
_pendingLegacyBareName = null;
VtankProfileDirectory.VtankCharacterBinding? binding = CanBindFiles
? VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
: null;
_selected = binding is { NavFileName.Length: > 0 } bound
? bound.NavFileName
: ByCharacter;
return true;
}
public bool Select(string? name)
{
string normalized = name?.Trim() ?? string.Empty;
if (!IsKnown(normalized))
string normalized = Normalize(name);
if (normalized.Length == 0)
return false;
_selected = CanonicalName(normalized);
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
return true;
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
_selected = ByCharacter;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
string candidate = ToFileName(normalized);
if (VtankStorage.IsAvailable && VtankStorage.ReadText(candidate) is not null)
{
_selected = candidate;
_pendingLegacyBareName = null;
WriteBinding();
return true;
}
if (_host.Storage.IsAvailable
&& _host.Storage.ReadText(LegacyProfileKey(normalized, byCharacter: false)) is not null)
{
_selected = candidate;
_pendingLegacyBareName = normalized;
WriteBinding();
return true;
}
return false;
}
public bool Create(
@ -80,7 +143,7 @@ internal sealed class MossTankRouteProfileStore
NavigationSettings current,
out string notice)
{
string normalized = name?.Trim() ?? string.Empty;
string normalized = Normalize(name);
if (normalized.Length is < 1 or > 64)
{
notice = "Enter a route profile name (1-64 characters).";
@ -91,46 +154,40 @@ internal sealed class MossTankRouteProfileStore
notice = "'By char' is the built-in route profile.";
return false;
}
Write(
ProfileKey(normalized, byCharacter: false),
copyCurrent
? RouteDocument.Capture(current)
: new RouteDocument());
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
WriteLegacyExport(_selected, copyCurrent ? current : new NavigationSettings());
string fileName = ToFileName(normalized);
NavigationSettings source = copyCurrent ? current : new NavigationSettings();
WriteAf(fileName, MetafSerializer.SaveNav(source));
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = copyCurrent
? $"Copied route to {_selected}."
: $"Created route profile {_selected}.";
? $"Copied route to {Strip(fileName)}."
: $"Created route profile {Strip(fileName)}.";
return true;
}
public bool LoadCurrent(NavigationSettings target)
public bool LoadCurrent(NavigationSettings target, ISpellCatalog spells)
{
ArgumentNullException.ThrowIfNull(target);
RouteDocument? document = Read<RouteDocument>(CurrentKey());
if (document is null)
ArgumentNullException.ThrowIfNull(spells);
SweepLegacyRosterIfNeeded();
MigrateLegacyIfNeeded(target, spells);
string fileName = CurrentFileName();
string? text = VtankStorage.IsAvailable ? VtankStorage.ReadText(fileName) : null;
if (text is null)
return false;
document.Apply(target);
if (!MetafSerializer.TryLoadNav(text, target, spells, out string error))
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host, "route", fileName, text, new FormatException(error));
_host.Log.Warn(RecoveryNotice);
return false;
}
return true;
}
public void SaveCurrent(NavigationSettings settings)
{
Write(CurrentKey(), RouteDocument.Capture(settings));
WriteLegacyExport(
_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? string.IsNullOrWhiteSpace(_characterName)
? ByCharacter
: _characterName
: _selected,
settings);
}
public void SaveCurrent(NavigationSettings settings) =>
WriteAf(CurrentFileName(), MetafSerializer.SaveNav(settings));
public bool TryImportLegacy(
string? name,
@ -138,7 +195,7 @@ internal sealed class MossTankRouteProfileStore
ISpellCatalog spells,
out string notice)
{
string normalized = name?.Trim() ?? string.Empty;
string normalized = Normalize(name);
if (!_host.Storage.IsAvailable || normalized.Length == 0)
{
notice = "Legacy navigation storage is unavailable.";
@ -162,64 +219,163 @@ internal sealed class MossTankRouteProfileStore
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;
}
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
SaveCurrent(target);
notice = $"Imported VTank navigation profile {_selected}.";
string fileName = ToFileName(normalized);
WriteAf(fileName, MetafSerializer.SaveNav(target));
_selected = fileName;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Imported VTank navigation profile {Strip(fileName)}.";
return true;
}
/// <summary>
/// Round 3 item 10: deletes the currently selected named route's real
/// <c>.af</c> file, then falls back to <see cref="ByCharacter"/>.
/// Refuses for <see cref="ByCharacter"/> itself — there is no file to
/// delete, only a reset (see <see cref="ClearCurrent"/>), matching
/// <see cref="MossTankProfileStore.Delete"/>'s own contract.
/// </summary>
public bool Delete(out string notice)
{
if (_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
notice = "'By char' is the built-in route profile and cannot be deleted.";
return false;
}
string fileName = _selected;
if (VtankStorage.IsAvailable)
VtankStorage.Delete(fileName);
_selected = ByCharacter;
_pendingLegacyBareName = null;
WriteBinding();
notice = $"Deleted route profile {Strip(fileName)}.";
return true;
}
/// <summary>
/// Resets only the fields this store owns (Mode/Waypoints/FollowTarget)
/// — Enabled/Priority/MinimumDistanceMeters/FollowAroundCorners/
/// OpenDoors/Door* belong to the Settings profile and are left alone.
/// </summary>
public void ClearCurrent(NavigationSettings target)
{
target.Enabled = false;
target.Priority = false;
target.Mode = RouteMode.Circular;
target.MinimumDistanceMeters = 2d;
target.FollowTargetObjectId = 0u;
target.FollowTargetName = string.Empty;
target.FollowAroundCorners = true;
target.OpenDoors = false;
target.Waypoints.Clear();
SaveCurrent(target);
}
private bool IsKnown(string? name) => name is not null
&& (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|| _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase));
// ------------------------------------------------------------------
// Legacy JSON -> .af migration.
// ------------------------------------------------------------------
private string CanonicalName(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ByCharacter
: _index.Names.First(entry => entry.Equals(
name,
StringComparison.OrdinalIgnoreCase));
private string CurrentKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ProfileKey(_characterName, byCharacter: true)
: ProfileKey(_selected, byCharacter: false);
private static string ProfileKey(string value, bool byCharacter)
/// <summary>
/// Round 3 item 2: a ONE-TIME sweep (guarded by <see cref="_rosterSwept"/>)
/// over the pre-cutover roster at <see cref="LegacyRosterKey"/>. Unlike
/// <see cref="MigrateLegacyIfNeeded"/> — which only ever converts the
/// ONE currently-selected route — this converts every OTHER named route
/// the old roster still lists, using a fresh <see cref="NavigationSettings"/>
/// per entry so the live/selected route is never touched. Named routes
/// were never owner-scoped (one shared, globally-hashed key per name),
/// so every entry converts regardless of which character is currently
/// bound.
/// </summary>
private void SweepLegacyRosterIfNeeded()
{
string identity = (byCharacter ? "char:" : "named:")
+ value.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
return $"profiles/route/{hash}.json";
if (_rosterSwept)
return;
_rosterSwept = true;
if (!_host.Storage.IsAvailable)
return;
List<string>? names = ReadRosterNames();
if (names is not { Count: > 0 })
return;
var remaining = new List<string>();
int migrated = 0;
foreach (string rawName in names)
{
string name = (rawName ?? string.Empty).Trim();
if (name.Length == 0 || name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
continue; // stale/invalid row; drop it rather than loop on it forever.
string legacyKey = LegacyProfileKey(name, byCharacter: false);
LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
continue; // already converted (or never existed); drop the row.
string fileName = ToFileName(name);
if (VtankStorage.IsAvailable && VtankStorage.ReadText(fileName) is null)
{
var scratch = new NavigationSettings();
legacy.ApplyRouteOnly(scratch);
WriteAf(fileName, MetafSerializer.SaveNav(scratch));
}
_host.Storage.Delete(legacyKey);
migrated++;
}
if (migrated == 0)
return;
if (remaining.Count > 0)
{
_host.Storage.WriteText(
LegacyRosterKey,
JsonSerializer.Serialize(new LegacyRosterDocument { Names = remaining }, JsonOptions));
}
else
{
_host.Storage.Delete(LegacyRosterKey);
}
_host.Log.Warn($"Migrated {migrated} legacy MossTank named route profile(s) from the old roster.");
}
private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
? "_default"
: _characterName;
private List<string>? ReadRosterNames()
{
string? json = null;
try
{
json = _host.Storage.ReadText(LegacyRosterKey);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<LegacyRosterDocument>(json, JsonOptions)?.Names;
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", LegacyRosterKey, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private T? Read<T>(string key) where T : class
private void MigrateLegacyIfNeeded(NavigationSettings target, ISpellCatalog spells)
{
string fileName = CurrentFileName();
if (!VtankStorage.IsAvailable || VtankStorage.ReadText(fileName) is not null)
{
_pendingLegacyBareName = null;
return;
}
string legacyKey = _pendingLegacyBareName is { Length: > 0 } bareName
? LegacyProfileKey(bareName, byCharacter: false)
: LegacyProfileKey(_characterName, byCharacter: true);
LegacyRouteDocument? legacy = ReadLegacyJson(legacyKey);
if (legacy is null)
{
_pendingLegacyBareName = null;
return;
}
legacy.ApplyRouteOnly(target);
WriteAf(fileName, MetafSerializer.SaveNav(target));
if (_host.Storage.IsAvailable)
_host.Storage.Delete(legacyKey);
_pendingLegacyBareName = null;
_host.Log.Warn($"Migrated legacy MossTank route profile '{legacyKey}' to '{fileName}'.");
}
private LegacyRouteDocument? ReadLegacyJson(string key)
{
if (!_host.Storage.IsAvailable)
return null;
@ -229,28 +385,56 @@ internal sealed class MossTankRouteProfileStore
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<T>(json, Options);
: JsonSerializer.Deserialize<LegacyRouteDocument>(json, JsonOptions);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"route",
key,
json,
error);
RecoveryNotice = MossTankProfileRecovery.Preserve(_host, "route", key, json, error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private void Write<T>(string key, T document)
// ------------------------------------------------------------------
// File naming, storage plumbing.
// ------------------------------------------------------------------
// Round 3 item 3: the hidden "--" prefix MUST come before the "nav_"
// kind marker (--nav_Name_Server.af) — putting the marker first
// (nav_--Name_Server.af, the pre-fix shape) means the filename does not
// start with "--" at all, defeating the StartsWith("--") hidden-file
// check both ListNavigationProfiles and ListMetaProfiles rely on and
// leaking this character's private per-character route to every other
// character's picker.
private string CurrentFileName() => _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? VtankProfileDirectory.AutoCharacterFileName(_characterName, Server, "af", NavPrefix)
: _selected;
private static string ToFileName(string bareName) =>
NavPrefix + bareName + ".af";
private void WriteBinding()
{
if (!_host.Storage.IsAvailable)
if (!CanBindFiles || !VtankStorage.IsAvailable)
return;
VtankProfileDirectory.VtankCharacterBinding existing =
VtankProfileDirectory.TryReadCharacterBinding(VtankStorage, _characterName, Server)
?? new VtankProfileDirectory.VtankCharacterBinding(
string.Empty, string.Empty, CurrentFileName(), null);
VtankProfileDirectory.WriteCharacterBinding(
VtankStorage,
_characterName,
Server,
existing with { NavFileName = CurrentFileName() });
}
private void WriteAf(string fileName, string text)
{
if (!VtankStorage.IsAvailable)
return;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
VtankStorage.WriteText(fileName, text);
}
catch (Exception error)
{
@ -258,47 +442,35 @@ internal sealed class MossTankRouteProfileStore
}
}
private void SaveIndex() => Write(IndexKey, _index);
private static string Normalize(string? name) => name?.Trim() ?? string.Empty;
private void WriteLegacyExport(string name, NavigationSettings settings)
private static string LegacyProfileKey(string value, bool byCharacter)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(
$"exports/{LegacyFileName(name)}.nav",
VtankNavRouteSerializer.Save(settings));
}
catch (Exception error)
{
_host.Log.Warn(
$"MossTank VTank navigation export could not be saved: {error.Message}");
}
string identity = (byCharacter ? "char:" : "named:") + value.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
return $"profiles/route/{hash}.json";
}
private static string LegacyFileName(string name)
private static readonly JsonSerializerOptions JsonOptions = new()
{
char[] invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(name.Length);
foreach (char value in name.Trim())
{
result.Append(value is '/' or '\\' || invalid.Contains(value)
? '_'
: value);
}
return result.Length == 0 ? "Route" : result.ToString();
}
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private sealed class IndexDocument
// Read-only: the pre-cutover roster shape at LegacyRosterKey, kept
// solely so SweepLegacyRosterIfNeeded can recover it once.
private sealed class LegacyRosterDocument
{
public int Version { get; set; } = 1;
public List<string> Names { get; set; } = [];
public Dictionary<string, string> SelectedByCharacter { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
}
private sealed class RouteDocument
// ------------------------------------------------------------------
// Migration-only: the OLD full route document shape, kept solely so a
// not-yet-migrated JSON profile can still be read once and converted.
// Only the fields this store still owns (Mode/Waypoints/FollowTarget)
// are applied — the rest belonged to the Settings profile even before
// the cutover and are left to whatever that profile already holds.
private sealed class LegacyRouteDocument
{
public int Version { get; set; } = 1;
public bool Enabled { get; set; }
@ -312,50 +484,20 @@ internal sealed class MossTankRouteProfileStore
public double DoorIdentifyRangeMeters { get; set; } = 20d;
public double DoorOpenRangeMeters { get; set; } = 4d;
public int DoorLockpickExcessThreshold { get; set; } = -50;
public WaypointDocument[] Waypoints { get; set; } = [];
public LegacyWaypointDocument[] Waypoints { get; set; } = [];
public static RouteDocument Capture(NavigationSettings value) => new()
public void ApplyRouteOnly(NavigationSettings value)
{
Enabled = value.Enabled,
Priority = value.Priority,
Mode = value.Mode,
MinimumDistanceMeters = value.MinimumDistanceMeters,
FollowTargetObjectId = value.FollowTargetObjectId,
FollowTargetName = value.FollowTargetName,
FollowAroundCorners = value.FollowAroundCorners,
OpenDoors = value.OpenDoors,
DoorIdentifyRangeMeters = value.DoorIdentifyRangeMeters,
DoorOpenRangeMeters = value.DoorOpenRangeMeters,
DoorLockpickExcessThreshold = value.DoorLockpickExcessThreshold,
Waypoints = value.Waypoints.Select(WaypointDocument.From).ToArray(),
};
public void Apply(NavigationSettings value)
{
value.Enabled = Enabled;
value.Priority = Priority;
value.Mode = Enum.IsDefined(Mode) ? Mode : RouteMode.Circular;
value.MinimumDistanceMeters = Math.Clamp(
MinimumDistanceMeters,
0.5d,
50d);
value.FollowTargetObjectId = FollowTargetObjectId;
value.FollowTargetName = FollowTargetName ?? string.Empty;
value.FollowAroundCorners = FollowAroundCorners;
value.OpenDoors = OpenDoors;
value.DoorIdentifyRangeMeters = Math.Clamp(
DoorIdentifyRangeMeters, 1d, 100d);
value.DoorOpenRangeMeters = Math.Clamp(
DoorOpenRangeMeters, 0.5d, value.DoorIdentifyRangeMeters);
value.DoorLockpickExcessThreshold = Math.Clamp(
DoorLockpickExcessThreshold, -500, 500);
value.Waypoints.Clear();
foreach (WaypointDocument waypoint in Waypoints ?? [])
foreach (LegacyWaypointDocument waypoint in Waypoints ?? [])
value.Waypoints.Add(waypoint.ToWaypoint());
}
}
private sealed class WaypointDocument
private sealed class LegacyWaypointDocument
{
public RouteWaypointType Type { get; set; }
public uint CellId { get; set; }
@ -364,6 +506,12 @@ internal sealed class MossTankRouteProfileStore
public double Elevation { get; set; }
public float HeadingDegrees { get; set; }
public bool IsOutdoor { get; set; }
public uint ReferenceCellId { get; set; }
public double ReferenceEastWest { get; set; }
public double ReferenceNorthSouth { get; set; }
public double ReferenceElevation { get; set; }
public float ReferenceHeadingDegrees { get; set; }
public bool ReferenceIsOutdoor { get; set; }
public uint ObjectId { get; set; }
public string ObjectName { get; set; } = string.Empty;
public int LegacyObjectClass { get; set; }
@ -378,40 +526,18 @@ internal sealed class MossTankRouteProfileStore
public int JumpChargeMilliseconds { get; set; } = 1000;
public RouteJumpDirection JumpDirection { get; set; }
public static WaypointDocument From(RouteWaypoint value) => new()
{
Type = value.Type,
CellId = value.Position.CellId,
EastWest = value.Position.EastWest,
NorthSouth = value.Position.NorthSouth,
Elevation = value.Position.Elevation,
HeadingDegrees = value.Position.HeadingDegrees,
IsOutdoor = value.Position.IsOutdoor,
ObjectId = value.ObjectId,
ObjectName = value.ObjectName,
LegacyObjectClass = value.LegacyObjectClass,
LegacyReferenceValid = value.LegacyReferenceValid,
Text = value.Text,
DurationMilliseconds = value.DurationMilliseconds,
Recall = value.Recall,
RecallSpellId = value.RecallSpellId,
RecallSpellName = value.RecallSpellName,
JumpHeadingDegrees = value.JumpHeadingDegrees,
JumpRun = value.JumpRun,
JumpChargeMilliseconds = value.JumpChargeMilliseconds,
JumpDirection = value.JumpDirection,
};
public RouteWaypoint ToWaypoint() => new()
{
Type = Enum.IsDefined(Type) ? Type : RouteWaypointType.Point,
Position = new PluginNavigationPosition(
CellId,
EastWest,
NorthSouth,
Elevation,
HeadingDegrees,
IsOutdoor),
CellId, EastWest, NorthSouth, Elevation, HeadingDegrees, IsOutdoor),
ReferencePosition = new PluginNavigationPosition(
ReferenceCellId,
ReferenceEastWest,
ReferenceNorthSouth,
ReferenceElevation,
ReferenceHeadingDegrees,
ReferenceIsOutdoor),
ObjectId = ObjectId,
ObjectName = ObjectName ?? string.Empty,
LegacyObjectClass = LegacyObjectClass,
@ -421,17 +547,15 @@ internal sealed class MossTankRouteProfileStore
Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone,
RecallSpellId = RecallSpellId,
RecallSpellName = RecallSpellName ?? string.Empty,
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees)
? JumpHeadingDegrees
: 0f,
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) ? JumpHeadingDegrees : 0f,
JumpRun = JumpRun,
JumpChargeMilliseconds = Math.Clamp(
JumpChargeMilliseconds,
0,
10_000),
JumpDirection = Enum.IsDefined(JumpDirection)
? JumpDirection
: RouteJumpDirection.Forward,
// Round 3 item 5: no load-time clamp here either — the real
// bi.a 2000 ms ceiling is retail's EXECUTION-time behavior
// (NavigationController.TickJump), not a storage-format limit;
// this legacy migration path preserves whatever the pre-cutover
// JSON authored, exactly like the .af load path now does.
JumpChargeMilliseconds = JumpChargeMilliseconds,
JumpDirection = Enum.IsDefined(JumpDirection) ? JumpDirection : RouteJumpDirection.Forward,
};
}
}

View file

@ -44,6 +44,17 @@ internal sealed class RouteWaypoint
{
public RouteWaypointType Type { get; set; }
public PluginNavigationPosition Position { get; set; }
/// <summary>
/// The second of the two coordinate triples VTank's own Portal2/UseNPC
/// waypoint records carry (metaf's "ptl"/"tlk" nodes: "myx myy myz tgtx
/// tgty tgtz" — <c>metaf_monolithic.py:356-357,11482,11618</c>; the
/// binary <c>.nav</c> record's own trailing xyz). <see cref="Position"/>
/// is where the bot stands to use the object ("myxyz"); this is the
/// portal/NPC object's own recorded position ("objxyz"), used to
/// disambiguate which object at that name to interact with. Unused for
/// every other waypoint type.
/// </summary>
public PluginNavigationPosition ReferencePosition { get; set; }
public uint ObjectId { get; set; }
public string ObjectName { get; set; } = string.Empty;
/// <summary>Decal ObjectClass retained for exact VTank NAV interchange.</summary>
@ -64,6 +75,7 @@ internal sealed class RouteWaypoint
{
Type = Type,
Position = Position,
ReferencePosition = ReferencePosition,
ObjectId = ObjectId,
ObjectName = ObjectName,
LegacyObjectClass = LegacyObjectClass,
@ -185,6 +197,13 @@ internal sealed class NavigationController
private const double RecallExitDistanceMeters = 2.4d;
private const double JumpLaunchGraceSeconds = 0.25d;
private const double JumpCompletionTimeoutSeconds = 3d;
// Round 3 item 5: retail's real jump-charge ceiling
// (refs/vtank/decompiled/bi.cs:502-505, bi.a — "if (A_2 > 2000.0) A_2 =
// 2000.0" at the moment the jump starts charging) belongs HERE, at
// execution, not at .af/legacy-JSON load — a waypoint's authored
// JumpChargeMilliseconds round-trips through storage unclamped; only
// the actual in-game charge duration is capped.
private const int JumpChargeCeilingMilliseconds = 2000;
private const double CheckpointRetrySeconds = 15d;
private const double FollowBreadcrumbSpacingMeters = 0.096d;
private const double FollowPathCaptureRangeMeters = 240d;
@ -711,9 +730,15 @@ internal sealed class NavigationController
StringComparison.OrdinalIgnoreCase));
if (!currentStillExists)
{
// Search near the object's own recorded position ("tgtxyz"
// in metaf's ptl/tlk grammar), not where the bot was
// standing when the waypoint was authored ("myxyz" —
// waypoint.Position): the object can be meaningfully far
// from the approach point (e.g. a portal at the far end of
// a room).
if (!_host.Automation.Navigation.TryFindObject(
waypoint.ObjectName,
waypoint.Position,
waypoint.ReferencePosition,
ObjectReacquireRadiusMeters,
out PluginNavigationObject reacquired))
{
@ -941,15 +966,16 @@ internal sealed class NavigationController
}
_jumpChargeElapsed += elapsedSeconds;
bool hold = _jumpChargeElapsed * 1000d
< Math.Max(0, waypoint.JumpChargeMilliseconds);
int effectiveChargeMilliseconds = Math.Clamp(
waypoint.JumpChargeMilliseconds, 0, JumpChargeCeilingMilliseconds);
bool hold = _jumpChargeElapsed * 1000d < effectiveChargeMilliseconds;
if (hold)
{
PluginMovementIntent intent = JumpIntent(waypoint, jump: true);
_hadMovementIntent = _host.Automation.Navigation
.SetMovementIntent(intent)
== PluginNavigationCommandStatus.Accepted;
_status = $"Charging jump: {waypoint.JumpChargeMilliseconds}ms.";
_status = $"Charging jump: {effectiveChargeMilliseconds}ms.";
return true;
}
PluginMovementIntent release = JumpIntent(waypoint, jump: false);

View file

@ -117,9 +117,9 @@ internal sealed class PetAutomation
if (!settings.SummonPets || activeOwnedPetCount > 0)
return PetAutomationChoice.None;
float range = settings.PetRangeMode == PetRangeMode.Custom
float range = (float)(settings.PetRangeMode == PetRangeMode.Custom
? settings.PetCustomRange
: settings.MaximumRange;
: settings.MaximumRange);
int density = Math.Max(1, settings.PetMonsterDensity);
var eligible = new List<(PluginCombatTarget Target, ResolvedMonsterRule Rule)>();
foreach (PluginCombatTarget target in targets)

View file

@ -35,9 +35,11 @@ public sealed class VitalSettings
public double HelperHealth { get; set; } = 0.20;
public double HelperStamina { get; set; } = 0.01;
public double HelperMana { get; set; } = 0.01;
public float HelperHealthDistance { get; set; } = 59.6f;
public float HelperStaminaDistance { get; set; } = 59.6f;
public float HelperManaDistance { get; set; } = 32f;
// Declared tDouble in VTank's own Settings table (item I, slice-1 fix
// round) — see CombatSettings.MaximumRange's doc comment for why.
public double HelperHealthDistance { get; set; } = 59.6d;
public double HelperStaminaDistance { get; set; } = 59.6d;
public double HelperManaDistance { get; set; } = 32d;
public bool HelpOthers { get; set; } = true;
public bool UseHealersHeart { get; set; } = true;
@ -46,6 +48,19 @@ public sealed class VitalSettings
public bool ClearLevelBoostFlagOnCast { get; set; } = true;
public int DropToPeaceModeRetryCount { get; set; } = 34;
public string RechargeHandlerSet { get; set; } = "RechargeHandlerSet";
/// <summary>
/// The real VTank <c>RechargeHandlerSet</c> nested table (5 columns:
/// Vital/HandlerString/MinPercent/MaxPercent/Stance). Defaults to VTank's
/// own shipped 26-row table (<see cref="VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows"/>),
/// not an independently hand-typed replica; loading a real <c>.usd</c>
/// profile via <see cref="VtankSettingsProfileSerializer"/> replaces it
/// with that profile's own table. <see cref="VitalRechargePlanner"/>'s
/// hand-ported switch-based ordering is retained only as a defensive
/// fallback for the case where every row happens to have been cleared
/// explicitly and none match a given (vital, stance, percent) band.
/// </summary>
public IReadOnlyList<RechargeHandlerRow> RechargeHandlerRows { get; set; } =
VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows;
public bool UseKitsInMagicMode { get; set; } = true;
public bool GoToPeaceModeToUseKits { get; set; }
public int MinimumHealKitSuccessChance { get; set; } = 95;

View file

@ -33,6 +33,43 @@ internal enum VitalRechargeMethod
Food,
}
/// <summary>
/// One row of VTank's real <c>RechargeHandlerSet</c> nested table
/// (<c>refs/vtank/uTank2.Resources.defaultsettings.usd</c>, Settings row
/// 137's Value cell — a 5-column TABLE: Vital/HandlerString/MinPercent/
/// MaxPercent/Stance, 26 seed rows verified against the live fixture).
/// <c>Vital</c> uses VTank's own raw encoding (1=Health, 2=Stamina,
/// 3=Mana — distinct from this port's <see cref="VitalKind"/> which reuses
/// AC property-id values); <c>Stance</c> is 1=Magic, 2=Melee/Missile
/// (matches this planner's <c>magicMode</c> bool exactly, confirmed by
/// reproducing every existing hand-ported default list from the parsed
/// table). <c>MinPercent</c>/<c>MaxPercent</c> bound the current-vital
/// percentage this row applies at.
/// </summary>
public readonly record struct RechargeHandlerRow(
int Vital,
string HandlerString,
int MinPercent,
int MaxPercent,
int Stance)
{
internal static VitalKind? ToVitalKind(int vital) => vital switch
{
1 => VitalKind.Health,
2 => VitalKind.Stamina,
3 => VitalKind.Mana,
_ => null,
};
internal static int FromVitalKind(VitalKind vital) => vital switch
{
VitalKind.Health => 1,
VitalKind.Stamina => 2,
VitalKind.Mana => 3,
_ => 0,
};
}
/// <summary>
/// VTank's default <c>RechargeHandlerSet</c>, including its stance- and
/// current-percentage-dependent order. The host supplies raw inventory and
@ -60,7 +97,10 @@ internal static class VitalRechargePlanner
vital,
mode == PluginCombatMode.Magic,
percent,
settings.RechargeHandlerSet);
settings.RechargeHandlerSet,
settings.RechargeHandlerRows.Count != 0
? settings.RechargeHandlerRows
: null);
IReadOnlyList<PluginInventoryItem> items =
automation.Items.CaptureOwnedItems();
@ -107,7 +147,7 @@ internal static class VitalRechargePlanner
IReadOnlyList<PluginFellowMember> members =
automation.Fellowship.CaptureMembers();
foreach ((VitalKind vital, double threshold, float distance, uint baseSpell)
foreach ((VitalKind vital, double threshold, double distance, uint baseSpell)
in new[]
{
(VitalKind.Health, settings.HelperHealth,
@ -118,7 +158,7 @@ internal static class VitalRechargePlanner
settings.HelperManaDistance, (uint)SpellId.GiftOfEssence),
})
{
PluginFellowMember? target = Lowest(members, vital, threshold, distance);
PluginFellowMember? target = Lowest(members, vital, threshold, (float)distance);
if (target is not { } fellow)
continue;
if (vital == VitalKind.Health
@ -205,12 +245,36 @@ internal static class VitalRechargePlanner
return true;
}
/// <summary>
/// <paramref name="handlerRows"/> is <see cref="VitalSettings.RechargeHandlerRows"/>,
/// which defaults to VTank's own shipped 26-row table
/// (<see cref="VtankDefaultSettingsDatabase.DefaultRechargeHandlerRows"/>)
/// rather than an empty list, so <see cref="TryHandlersFromRows"/> is the
/// path every ordinary call takes. The hardcoded <c>defaults</c> switch
/// below is retained ONLY as a defensive net for a caller that
/// deliberately passes an empty/null row set or an unrecognized
/// (vital, stance, percent) band the real table happens not to cover —
/// not as the primary source of truth it used to be before the real
/// table became parseable.
/// </summary>
internal static IReadOnlyList<VitalRechargeMethod> Handlers(
VitalKind vital,
bool magicMode,
int currentPercent,
string? handlerSet = null)
string? handlerSet = null,
IReadOnlyList<RechargeHandlerRow>? handlerRows = null)
{
if (handlerRows is { Count: > 0 }
&& TryHandlersFromRows(
vital,
magicMode,
currentPercent,
handlerRows,
out VitalRechargeMethod[] fromRows))
{
return fromRows;
}
IReadOnlyList<VitalRechargeMethod> defaults;
if (magicMode)
{
@ -288,6 +352,81 @@ internal static class VitalRechargePlanner
: defaults;
}
/// <summary>
/// VTank's real table-driven handler-order lookup: collect every row
/// matching (Vital, Stance) whose [MinPercent,MaxPercent] band contains
/// the current percentage, IN FILE ORDER (duplicates included — VTank's
/// own shipped table has them, e.g. row 17 of the default table repeats
/// "Recharge With Food" for the non-magic Health band; a duplicate is
/// harmless, since re-trying the same handler a second time just fails
/// identically). Confirmed against every hand-ported default list this
/// planner shipped before this table was parseable — see
/// <c>docs/research/vtank-kb/01-settings-and-profiles.md</c> row 137 and
/// the RechargeHandlerSet fixture. Unknown HandlerString tokens are
/// skipped (forward-compatible with a future VTank build's new method).
/// </summary>
private static bool TryHandlersFromRows(
VitalKind vital,
bool magicMode,
int currentPercent,
IReadOnlyList<RechargeHandlerRow> rows,
out VitalRechargeMethod[] handlers)
{
int vitalCode = RechargeHandlerRow.FromVitalKind(vital);
int stance = magicMode ? 1 : 2;
var matched = new List<VitalRechargeMethod>();
foreach (RechargeHandlerRow row in rows)
{
if (row.Vital != vitalCode
|| row.Stance != stance
|| currentPercent < row.MinPercent
|| currentPercent > row.MaxPercent)
{
continue;
}
if (TryParseHandlerToken(row.HandlerString, out VitalRechargeMethod method))
matched.Add(method);
}
handlers = [.. matched];
return handlers.Length != 0;
}
private static bool TryParseHandlerToken(string source, out VitalRechargeMethod method)
{
string normalized = source.Replace(" ", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal)
.ToLowerInvariant();
switch (normalized)
{
case "regularspell":
method = VitalRechargeMethod.RegularSpell;
return true;
case "staminatohealth":
method = VitalRechargeMethod.StaminaToHealth;
return true;
case "manatohealth":
method = VitalRechargeMethod.ManaToHealth;
return true;
case "healthtostamina":
method = VitalRechargeMethod.HealthToStamina;
return true;
case "healthtomana":
method = VitalRechargeMethod.HealthToMana;
return true;
case "kit":
case "kitrecharge":
method = VitalRechargeMethod.Kit;
return true;
case "food":
case "rechargewithfood":
method = VitalRechargeMethod.Food;
return true;
default:
method = default;
return false;
}
}
private static string HandlerContext(
VitalKind vital,
bool magicMode,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,67 @@
using System.Reflection;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// The complete <c>.usd</c> shipped by VTank as
/// <c>uTank2.Resources.defaultsettings.usd</c> — every table (Settings,
/// MyMonsters, GemFoodItems, ExtraBuffSpells, AntiExtraBuffSpells,
/// ItemUseSpecifiers, SettingsCategories, SettingsEnumInfo, AssistItems,
/// BuffedItems, RechargeHandlerSet, …), byte-for-byte, not a hand-typed
/// C# replica. <see cref="VtankSettingsProfileSerializer.CreateNew"/> seeds
/// a brand-new profile from this document (every table, every row's real
/// <c>Description</c>/<c>SettingType</c>) and
/// <see cref="VitalSettings"/>'s default <c>RechargeHandlerRows</c> is
/// parsed from its <c>RechargeHandlerSet</c> table — both replace a prior
/// port's independently hand-maintained defaults, which could (and did:
/// see the 8fa70c3e4 "26 rows, not 24" fix) silently drift from what VTank
/// itself ships.
/// </summary>
internal static class VtankDefaultSettingsDatabase
{
private const string ResourceSuffix = ".VtankDefaultSettings.usd";
private static readonly Lazy<string> RawText = new(LoadText);
private static readonly Lazy<RechargeHandlerRow[]> DefaultRows = new(LoadDefaultRows);
/// <summary>A fresh, independently mutable parse of the embedded document — every call gets its own object graph.</summary>
public static VtankDatabase Parse() => VtankDatabase.Parse(RawText.Value);
/// <summary>VTank's own shipped <c>RechargeHandlerSet</c> rows (26, in file order).</summary>
public static IReadOnlyList<RechargeHandlerRow> DefaultRechargeHandlerRows => DefaultRows.Value;
private static string LoadText()
{
Assembly assembly = typeof(VtankDefaultSettingsDatabase).Assembly;
string resource = assembly.GetManifestResourceNames().Single(
static name => name.EndsWith(ResourceSuffix, StringComparison.Ordinal));
using Stream stream = assembly.GetManifestResourceStream(resource)
?? throw new InvalidOperationException(
"The embedded VTank default settings (.usd) document is missing.");
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static RechargeHandlerRow[] LoadDefaultRows()
{
VtankDatabase database = VtankDatabase.Parse(RawText.Value);
VtankTable? settings = database.Find("Settings");
if (settings is null)
return [];
int nameColumn = settings.ColumnIndex("Setting");
int valueColumn = settings.ColumnIndex("Value");
if (nameColumn < 0 || valueColumn < 0)
return [];
foreach (VtankRow row in settings.Rows)
{
if (!row.Cells[nameColumn].AsString().Equals(
"RechargeHandlerSet", StringComparison.OrdinalIgnoreCase))
{
continue;
}
VtankCell cell = row.Cells[valueColumn];
if (cell.Tag == "TABLE" && cell.Table is { } table)
return VtankSettingsProfileSerializer.ParseRechargeHandlerSet(table);
}
return [];
}
}

View file

@ -23,20 +23,33 @@ internal static class VtankLootRequirementEvaluator
[6095] = (28, 80),
};
private static readonly IReadOnlyDictionary<uint, (uint Key, double Bonus)>
DoubleSpellBonuses = new Dictionary<uint, (uint, double)>
/// <summary>
/// VTClassic's static double-spell-bonus table (<c>ComputedItemInfo.cs:88-139</c>).
/// <c>Change</c> is the real per-row selector KB doc 05 section 2.2 names
/// ("additive unless the static table's <c>Change==1</c>, in which case
/// multiplicative", <c>ComputedItemInfo.cs:244</c>) — carried explicitly
/// here rather than inferred from whether <c>Bonus</c>'s truncated
/// integer part happens to equal 1 (a prior port's proxy, which this
/// item replaced: it only worked because every multiplicative bonus in
/// this table happens to be in [1.0, 2.0) and every additive one happens
/// to be under 1.0 — a coincidence of the current 19 rows, not a rule,
/// and it would have silently mis-branched on a future row like an
/// additive 1.5 or a multiplicative 2.0+).
/// </summary>
private static readonly IReadOnlyDictionary<uint, (uint Key, double Bonus, bool Change)>
DoubleSpellBonuses = new Dictionary<uint, (uint, double, bool)>
{
[3251] = (152, .01), [3250] = (152, .03),
[4670] = (152, .05), [6098] = (152, .07),
[2603] = (VtankDoubleBase + 12, .03),
[2591] = (VtankDoubleBase + 12, .05),
[4666] = (VtankDoubleBase + 12, .07),
[6094] = (VtankDoubleBase + 12, .09),
[2600] = (29, .03), [3985] = (29, .04),
[2588] = (29, .05), [4663] = (29, .07), [6091] = (29, .09),
[3201] = (144, 1.05), [3199] = (144, 1.10),
[3202] = (144, 1.15), [3200] = (144, 1.20),
[6086] = (144, 1.25), [6087] = (144, 1.30),
[3251] = (152, .01, false), [3250] = (152, .03, false),
[4670] = (152, .05, false), [6098] = (152, .07, false),
[2603] = (VtankDoubleBase + 12, .03, false),
[2591] = (VtankDoubleBase + 12, .05, false),
[4666] = (VtankDoubleBase + 12, .07, false),
[6094] = (VtankDoubleBase + 12, .09, false),
[2600] = (29, .03, false), [3985] = (29, .04, false),
[2588] = (29, .05, false), [4663] = (29, .07, false), [6091] = (29, .09, false),
[3201] = (144, 1.05, true), [3199] = (144, 1.10, true),
[3202] = (144, 1.15, true), [3200] = (144, 1.20, true),
[6086] = (144, 1.25, true), [6087] = (144, 1.30, true),
};
private static readonly IReadOnlyDictionary<string, int[]> ArmorColorSlots =
@ -363,54 +376,103 @@ internal static class VtankLootRequirementEvaluator
: string.Empty,
};
/// <summary>
/// The single source of truth for both "what is this int-typed key's
/// value" (<see cref="IntValue"/>) and "does this item actually carry a
/// base value for this key at all" (<see cref="IntKeyExists"/>) — item J
/// (slice-1 fix round) consolidated these from two independently
/// hand-maintained key lists that had to be kept in exact sync by hand:
/// a named field added to one switch and forgotten in the other would
/// silently let <c>BuffedInt</c>'s KeyExists gate treat a real,
/// always-present field as "raw property bag only", which for a key the
/// item's bag never happens to carry would wrongly refuse a spell bonus
/// it should apply. Returns <see langword="false"/> only when
/// <paramref name="key"/> is not one of the named
/// <see cref="PluginInventoryItem"/> fields AND the raw property bag
/// does not carry it either.
/// </summary>
private static bool TryIntValue(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties,
out int value)
{
switch (key)
{
case 5: value = item.Burden; return true;
case 19: value = item.Value; return true;
case 105: value = checked((int)item.Workmanship); return true;
case 107: value = item.ItemCurrentMana; return true;
case 108: value = item.ItemMaximumMana; return true;
case 131: value = checked((int)item.MaterialType); return true;
case VtankIntBase + 0: value = checked((int)item.WeenieClassId); return true;
case VtankIntBase + 2: value = checked((int)item.ContainerObjectId); return true;
case VtankIntBase + 4: value = item.ItemsCapacity; return true;
case VtankIntBase + 5: value = item.ContainersCapacity; return true;
case VtankIntBase + 6: value = item.StackSize; return true;
case VtankIntBase + 7: value = item.MaximumStackSize; return true;
case VtankIntBase + 8: value = checked((int)item.SpellId); return true;
case VtankIntBase + 9: value = item.ContainerSlot; return true;
case VtankIntBase + 10: value = checked((int)item.WielderObjectId); return true;
case VtankIntBase + 11: value = checked((int)item.EquippedLocation); return true;
case VtankIntBase + 14: value = checked((int)item.ValidLocations); return true;
case VtankIntBase + 18: value = checked((int)item.Useability); return true;
case VtankIntBase + 23: value = checked((int)item.PublicFlags); return true;
case VtankIntBase + 31: value = item.CombatUse; return true;
case VtankIntBase + 32: value = item.WeaponSkill; return true;
case VtankIntBase + 33: value = item.DamageType; return true;
case VtankIntBase + 34: value = item.Damage; return true;
case VtankIntBase + 38: value = item.AppraisedSpellIds.Count; return true;
default:
value = 0;
return properties.Ints?.TryGetValue(key, out value) == true;
}
}
private static int IntValue(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) => key switch
in PluginItemProperties properties) =>
TryIntValue(key, item, properties, out int value) ? value : 0;
/// <summary>See <see cref="TryDoubleValue"/> — the double-side equivalent of <see cref="TryIntValue"/>'s consolidation.</summary>
private static bool TryDoubleValue(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties,
out double value)
{
5 => item.Burden,
19 => item.Value,
105 => checked((int)item.Workmanship),
107 => item.ItemCurrentMana,
108 => item.ItemMaximumMana,
131 => checked((int)item.MaterialType),
VtankIntBase + 0 => checked((int)item.WeenieClassId),
VtankIntBase + 2 => checked((int)item.ContainerObjectId),
VtankIntBase + 4 => item.ItemsCapacity,
VtankIntBase + 5 => item.ContainersCapacity,
VtankIntBase + 6 => item.StackSize,
VtankIntBase + 7 => item.MaximumStackSize,
VtankIntBase + 8 => checked((int)item.SpellId),
VtankIntBase + 9 => item.ContainerSlot,
VtankIntBase + 10 => checked((int)item.WielderObjectId),
VtankIntBase + 11 => checked((int)item.EquippedLocation),
VtankIntBase + 14 => checked((int)item.ValidLocations),
VtankIntBase + 18 => checked((int)item.Useability),
VtankIntBase + 23 => checked((int)item.PublicFlags),
VtankIntBase + 31 => item.CombatUse,
VtankIntBase + 32 => item.WeaponSkill,
VtankIntBase + 33 => item.DamageType,
VtankIntBase + 34 => item.Damage,
VtankIntBase + 38 => item.AppraisedSpellIds.Count,
_ => properties.Ints?.TryGetValue(key, out int value) == true
? value
: 0,
};
switch (key)
{
case VtankDoubleBase + 9: value = item.Workmanship; return true;
case VtankDoubleBase + 11: value = item.DamageVariance; return true;
// These two are named/virtual fields (like the int side's
// VtankIntBase+N cases) that always "exist", regardless of
// whether the underlying remapped raw key (62/63) happens to
// be present in the property bag — matching the prior
// DoubleKeyExists switch's explicit "=> true" for both.
case VtankDoubleBase + 12:
TryRawFloat(properties, 62, out value);
return true;
case VtankDoubleBase + 14:
TryRawFloat(properties, 63, out value);
return true;
default:
return TryRawFloat(properties, key, out value);
}
}
private static double DoubleValue(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) => key switch
{
VtankDoubleBase + 9 => item.Workmanship,
VtankDoubleBase + 11 => item.DamageVariance,
VtankDoubleBase + 12 => RawFloat(properties, 62),
VtankDoubleBase + 14 => RawFloat(properties, 63),
_ => RawFloat(properties, key),
};
in PluginItemProperties properties) =>
TryDoubleValue(key, item, properties, out double value) ? value : 0d;
private static double RawFloat(in PluginItemProperties properties, uint key) =>
properties.Floats?.TryGetValue(key, out double value) == true ? value : 0d;
private static bool TryRawFloat(in PluginItemProperties properties, uint key, out double value)
{
value = 0d;
return properties.Floats?.TryGetValue(key, out value) == true;
}
private static int BuffedInt(
uint key,
@ -418,6 +480,15 @@ internal static class VtankLootRequirementEvaluator
in PluginItemProperties properties)
{
int value = IntValue(key, item, properties);
// VTClassic's ComputedItemInfo.GetBuffedLogValueKey only adds the
// spell bonus if the item already carries the base key at all
// (ComputedItemInfo.cs:205, the KeyExistsInt gate — KB doc 05
// section 2.2 and the section-5 gap-4 finding). Without this gate,
// an item that lacks the key entirely (falls through to the
// zero-defaulted "_ =>" branch below) would still receive a spell
// bonus it never had a base value to buff.
if (!IntKeyExists(key, item, properties))
return value;
foreach (uint spellId in item.AppraisedSpellIds)
{
if (IntSpellBonuses.TryGetValue(spellId, out var bonus)
@ -435,6 +506,8 @@ internal static class VtankLootRequirementEvaluator
in PluginItemProperties properties)
{
double value = DoubleValue(key, item, properties);
if (!DoubleKeyExists(key, item, properties))
return value;
foreach (uint spellId in item.AppraisedSpellIds)
{
if (!DoubleSpellBonuses.TryGetValue(spellId, out var bonus)
@ -442,11 +515,28 @@ internal static class VtankLootRequirementEvaluator
{
continue;
}
value = (int)bonus.Bonus == 1 ? value * bonus.Bonus : value + bonus.Bonus;
value = bonus.Change ? value * bonus.Bonus : value + bonus.Bonus;
}
return value;
}
/// <summary>
/// Now driven by <see cref="TryIntValue"/> (item J, slice-1 fix round)
/// instead of an independently hand-maintained duplicate of its key
/// list — see that method's doc for why the duplication was a hazard.
/// </summary>
private static bool IntKeyExists(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) =>
TryIntValue(key, item, properties, out _);
private static bool DoubleKeyExists(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) =>
TryDoubleValue(key, item, properties, out _);
private static double MinimumDamage(in PluginInventoryItem item) =>
item.Damage - (item.DamageVariance * item.Damage);

View file

@ -1,11 +1,17 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Reads and writes VTank's exact line-encoded <c>CondAct</c> Meta database.
/// The format is the public interchange contract used by legacy <c>.met</c>
/// profiles; it is deliberately independent from MossTank's native JSON store.
/// Reads VTank's exact line-encoded <c>CondAct</c> Meta database — a
/// one-shot import path only. Per the owner's 2026-09-06 "MossTank does not
/// implement <c>.met</c>" direction (Campaign VT slice 1 Part A), the
/// writer that used to live here was deleted: <c>.af</c>
/// (<see cref="MetafSerializer"/>) is the only storage/authoring format for
/// meta profiles now. This class only converts a legacy binary <c>.met</c>
/// file into a <see cref="MetaProfile"/> so it can be saved straight back
/// out as <c>.af</c>.
/// </summary>
internal static class VtankMetaProfileSerializer
{
@ -20,8 +26,16 @@ internal static class VtankMetaProfileSerializer
private const int MaximumRules = 100_000;
private const int MaximumNesting = 256;
public static bool TryLoad(string source, out MetaProfile profile, out string error)
public static bool TryLoad(string source, out MetaProfile profile, out string error) =>
TryLoad(source, MetafSerializer.NoOpSpells.Instance, out profile, out error);
public static bool TryLoad(
string source,
ISpellCatalog spells,
out MetaProfile profile,
out string error)
{
ArgumentNullException.ThrowIfNull(spells);
try
{
var reader = new LineReader(source);
@ -38,7 +52,7 @@ internal static class VtankMetaProfileSerializer
reader.Expect("i");
int actionType = reader.ReadInt();
MetaCondition condition = ReadCondition(reader, conditionType, 0);
MetaAction action = ReadAction(reader, actionType, 0);
MetaAction action = ReadAction(reader, actionType, 0, spells);
reader.Expect("s");
parsed.Rules.Add(new MetaRule
{
@ -62,23 +76,6 @@ internal static class VtankMetaProfileSerializer
}
}
public static string Save(MetaProfile source)
{
ArgumentNullException.ThrowIfNull(source);
var writer = new LineWriter();
writer.Add(Header);
MetaRule[] rules = source.Rules.Where(static rule => rule.Enabled).ToArray();
writer.Add(rules.Length);
foreach (MetaRule rule in rules)
{
writer.Add("i", ConditionType(rule.Condition.Kind), "i", ActionType(rule.Action.Kind));
WriteCondition(writer, rule.Condition, 0);
WriteAction(writer, rule.Action, 0);
writer.Add("s", rule.State ?? string.Empty);
}
return writer.Finish();
}
private static MetaCondition ReadCondition(LineReader reader, int type, int depth)
{
CheckDepth(reader, depth);
@ -172,7 +169,7 @@ internal static class VtankMetaProfileSerializer
}
}
private static MetaAction ReadAction(LineReader reader, int type, int depth)
private static MetaAction ReadAction(LineReader reader, int type, int depth, ISpellCatalog spells)
{
CheckDepth(reader, depth);
var value = new MetaAction { Kind = ActionKind(type) };
@ -191,11 +188,11 @@ internal static class VtankMetaProfileSerializer
for (int index = 0; index < count; index++)
{
reader.Expect("i");
value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1));
value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1, spells));
}
break;
case 4:
ReadEmbeddedNavigation(reader, value);
ReadEmbeddedNavigation(reader, value, spells);
break;
case 5:
reader.Expect(TablePrefix, "2", "s", "st", "s");
@ -247,7 +244,7 @@ internal static class VtankMetaProfileSerializer
return value;
}
private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target)
private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target, ISpellCatalog spells)
{
reader.Expect("ba");
int serializedCharacters = reader.ReadCount();
@ -255,7 +252,7 @@ internal static class VtankMetaProfileSerializer
int statedNodeCount = reader.ReadCount();
if (serializedCharacters <= 5)
{
target.Text = EmptyNavigation();
target.EmbeddedRoute = new NavigationSettings();
return;
}
@ -282,7 +279,15 @@ internal static class VtankMetaProfileSerializer
}
if (actualNodeCount != statedNodeCount)
throw reader.Error("Embedded VTank navigation node counts do not match.");
target.Text = string.Join("\r\n", lines) + "\r\n";
// Parse the reassembled "uTank2 NAV 1.2" text through the same
// reader a top-level binary .nav import uses (item B: MetaAction
// carries a typed NavigationSettings, not a re-parse-me blob).
string blob = string.Join("\r\n", lines) + "\r\n";
var route = new NavigationSettings();
if (!VtankNavRouteSerializer.TryLoad(blob, route, spells, out string navError))
throw reader.Error($"Embedded VTank navigation route is invalid: {navError}");
target.EmbeddedRoute = route;
}
private static void ReadNavigationNode(LineReader reader, List<string> lines)
@ -304,195 +309,10 @@ internal static class VtankMetaProfileSerializer
lines.Add(reader.Read());
}
private static void WriteCondition(LineWriter writer, MetaCondition value, int depth)
{
CheckDepth(depth);
int type = ConditionType(value.Kind);
switch (type)
{
case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20:
writer.Add("i", "0");
break;
case 2 or 3:
writer.Add(RecursiveTablePrefix);
writer.Add(value.Children.Count);
foreach (MetaCondition child in value.Children)
{
writer.Add("i", ConditionType(child.Kind));
WriteCondition(writer, child, depth + 1);
}
break;
case 4:
writer.Add("s", value.Text);
break;
case 5 or 6 or 17 or 18 or 22 or 24:
writer.Add("i", IntValue(value.Number));
break;
case 11 or 12:
writer.Add(TablePrefix, "2", "s", "n", "s", value.Text,
"s", "c", "i", IntValue(value.Number));
break;
case 13:
writer.Add(TablePrefix, "3", "s", "n", "s", value.Text,
"s", "c", "i", IntValue(value.Number),
"s", "r", "d", Number(value.SecondaryNumber));
break;
case 14:
writer.Add(TablePrefix, "3", "s", "p", "i", IntValue(value.TertiaryNumber),
"s", "c", "i", IntValue(value.Number),
"s", "r", "d", Number(value.SecondaryNumber));
break;
case 16:
writer.Add(TablePrefix, "1", "s", "r", "d", Number(value.Number));
break;
case 21:
if (value.Children.Count != 1)
throw new InvalidOperationException("VTank Meta Not requires exactly one condition.");
writer.Add(RecursiveTablePrefix, "1", "i", ConditionType(value.Children[0].Kind));
WriteCondition(writer, value.Children[0], depth + 1);
break;
case 23:
writer.Add(TablePrefix, "2", "s", "sid", "i", IntValue(value.Number),
"s", "sec", "i", IntValue(value.SecondaryNumber));
break;
case 25:
writer.Add(TablePrefix, "1", "s", "dist", "d", Number(value.Number));
break;
case 26:
writer.Add(TablePrefix, "1", "s", "e", "s", value.Text);
break;
case 28:
writer.Add(TablePrefix, "2", "s", "p", "s", value.Text,
"s", "c", "s", value.SecondaryText);
break;
default:
throw new InvalidOperationException($"Unknown VTank Meta condition type {type}.");
}
}
private static void WriteAction(LineWriter writer, MetaAction value, int depth)
{
CheckDepth(depth);
int type = ActionType(value.Kind);
switch (type)
{
case 0 or 6:
writer.Add("i", "0");
break;
case 1 or 2:
writer.Add("s", value.Text);
break;
case 3:
writer.Add(RecursiveTablePrefix);
writer.Add(value.Children.Count);
foreach (MetaAction child in value.Children)
{
writer.Add("i", ActionType(child.Kind));
WriteAction(writer, child, depth + 1);
}
break;
case 4:
WriteEmbeddedNavigation(writer, value);
break;
case 5:
writer.Add(TablePrefix, "2", "s", "st", "s", value.Text,
"s", "ret", "s", value.SecondaryText);
break;
case 7 or 8:
writer.Add(TablePrefix, "1", "s", "e", "s", value.Text);
break;
case 9:
writer.Add(TablePrefix, "3", "s", "s", "s", value.Text,
"s", "r", "d", Number(value.Number),
"s", "t", "d", Number(value.SecondaryNumber));
break;
case 10 or 15:
writer.Add(TablePrefix, "0");
break;
case 11 or 12:
writer.Add(TablePrefix, "2", "s", "o", "s", value.Text,
"s", "v", "s", value.SecondaryText);
break;
case 13:
writer.Add(TablePrefix, "2", "s", "n", "s", value.Text,
"s", "x", "ba", value.SecondaryText.Length);
writer.AddBuggedByteArray(value.SecondaryText);
break;
case 14:
writer.Add(TablePrefix, "1", "s", "n", "s", value.Text);
break;
default:
throw new InvalidOperationException($"Unknown VTank Meta action type {type}.");
}
}
private static void WriteEmbeddedNavigation(LineWriter writer, MetaAction value)
{
string nav = string.IsNullOrWhiteSpace(value.Text) ? EmptyNavigation() : value.Text;
string normalized = NormalizeNewlines(nav);
string[] navLines = normalized.Split('\n', StringSplitOptions.None);
if (navLines.Length != 0 && navLines[^1].Length == 0)
navLines = navLines[..^1];
int nodes = NavigationNodeCount(navLines);
string name = string.IsNullOrEmpty(value.SecondaryText) ? "[None]" : value.SecondaryText;
int characters = name.Length + 2
+ nodes.ToString(CultureInfo.InvariantCulture).Length + 2
+ navLines.Sum(static line => line.Length + 2);
writer.Add("ba", characters, name, nodes);
writer.Add(navLines);
}
private static int NavigationNodeCount(string[] lines)
{
if (lines.Length < 2 || !lines[0].Equals("uTank2 NAV 1.2", StringComparison.Ordinal))
throw new InvalidOperationException("Embedded Meta route is not uTank2 NAV 1.2 data.");
int mode = int.Parse(lines[1], NumberStyles.Integer, CultureInfo.InvariantCulture);
if (mode == 3)
return 1;
if (mode is not (1 or 2 or 4) || lines.Length < 3)
throw new InvalidOperationException("Embedded Meta route has an invalid navigation type.");
return int.Parse(lines[2], NumberStyles.Integer, CultureInfo.InvariantCulture);
}
private static string EmptyNavigation() => "uTank2 NAV 1.2\r\n1\r\n0\r\n";
private static string NormalizeNewlines(string value) => value
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n');
private static int ConditionType(MetaConditionKind kind) => kind switch
{
MetaConditionKind.Never => 0,
MetaConditionKind.Always => 1,
MetaConditionKind.All => 2,
MetaConditionKind.Any => 3,
MetaConditionKind.ChatMessage => 4,
MetaConditionKind.PackSlotsLessThanOrEqual => 5,
MetaConditionKind.SecondsInStateGreaterThanOrEqual => 6,
MetaConditionKind.NavigationRouteEmpty => 7,
MetaConditionKind.CharacterDeath => 8,
MetaConditionKind.AnyVendorOpen => 9,
MetaConditionKind.VendorClosed => 10,
MetaConditionKind.InventoryItemCountLessThanOrEqual => 11,
MetaConditionKind.InventoryItemCountGreaterThanOrEqual => 12,
MetaConditionKind.MonsterNameCountWithinDistance => 13,
MetaConditionKind.MonsterPriorityCountWithinDistance => 14,
MetaConditionKind.NeedToBuff => 15,
MetaConditionKind.NoMonstersWithinDistance => 16,
MetaConditionKind.LandblockEquals => 17,
MetaConditionKind.LandcellEquals => 18,
MetaConditionKind.PortalspaceEntered => 19,
MetaConditionKind.PortalspaceExited => 20,
MetaConditionKind.Not => 21,
MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual => 22,
MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual => 23,
MetaConditionKind.BurdenPercentGreaterThanOrEqual => 24,
MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual => 25,
MetaConditionKind.Expression => 26,
MetaConditionKind.ChatMessageCapture => 28,
_ => throw new InvalidOperationException($"Unsupported Meta condition {kind}."),
};
private static MetaConditionKind ConditionKind(int type) => type switch
{
0 => MetaConditionKind.Never,
@ -526,27 +346,6 @@ internal static class VtankMetaProfileSerializer
_ => throw new FormatException($"Unknown VTank Meta condition type {type}."),
};
private static int ActionType(MetaActionKind kind) => kind switch
{
MetaActionKind.None => 0,
MetaActionKind.SetMetaState => 1,
MetaActionKind.ChatCommand => 2,
MetaActionKind.All => 3,
MetaActionKind.LoadEmbeddedNavigationRoute => 4,
MetaActionKind.CallMetaState => 5,
MetaActionKind.ReturnFromCall => 6,
MetaActionKind.ExpressionAction => 7,
MetaActionKind.ChatExpression => 8,
MetaActionKind.SetWatchdog => 9,
MetaActionKind.ClearWatchdog => 10,
MetaActionKind.GetVtankOption => 11,
MetaActionKind.SetVtankOption => 12,
MetaActionKind.CreateView => 13,
MetaActionKind.DestroyView => 14,
MetaActionKind.DestroyAllViews => 15,
_ => throw new InvalidOperationException($"Unsupported Meta action {kind}."),
};
private static MetaActionKind ActionKind(int type) => type switch
{
0 => MetaActionKind.None,
@ -568,32 +367,12 @@ internal static class VtankMetaProfileSerializer
_ => throw new FormatException($"Unknown VTank Meta action type {type}."),
};
private static string Number(double value)
{
if (!double.IsFinite(value))
throw new InvalidOperationException("VTank Meta numbers must be finite.");
return value.ToString("R", CultureInfo.InvariantCulture);
}
private static int IntValue(double value)
{
if (!double.IsFinite(value) || value != Math.Truncate(value))
throw new InvalidOperationException("VTank Meta integer fields require whole numbers.");
return checked((int)value);
}
private static void CheckDepth(LineReader reader, int depth)
{
if (depth > MaximumNesting)
throw reader.Error("VTank Meta nesting is too deep.");
}
private static void CheckDepth(int depth)
{
if (depth > MaximumNesting)
throw new InvalidOperationException("VTank Meta nesting is too deep.");
}
private sealed class LineReader
{
private readonly List<string> _lines;
@ -703,40 +482,4 @@ internal static class VtankMetaProfileSerializer
public FormatException Error(string message) =>
new($"VTank Meta line {Math.Min(_index + 1, _lines.Count + 1)}: {message}");
}
private sealed class LineWriter
{
private readonly List<string> _lines = [];
private readonly List<int> _buggedByteArrays = [];
public void Add(params object?[] values)
{
foreach (object? value in values)
_lines.Add(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty);
}
public void Add(string[] first, params object?[] rest)
{
Add(first.Cast<object?>().ToArray());
Add(rest);
}
public void AddBuggedByteArray(string value)
{
_buggedByteArrays.Add(_lines.Count);
_lines.Add(value ?? string.Empty);
}
public string Finish()
{
foreach (int index in _buggedByteArrays.OrderDescending())
{
if (index + 1 >= _lines.Count)
throw new InvalidOperationException("CreateView cannot terminate a VTank Meta record.");
_lines[index] += _lines[index + 1];
_lines.RemoveAt(index + 1);
}
return string.Join("\r\n", _lines) + "\r\n";
}
}
}

View file

@ -3,40 +3,27 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>Reader for VTank's verbatim <c>uTank2 NAV 1.2</c> format.</summary>
/// <summary>
/// Reader for VTank's verbatim <c>uTank2 NAV 1.2</c> format — a one-shot
/// import path only. Per the owner's 2026-09-06 "MossTank does not author
/// <c>.nav</c>" direction (Campaign VT slice 1 Part A), the writer that
/// used to live here was deleted: <c>.af</c> (<see cref="MetafSerializer"/>)
/// is the only storage/authoring format for navigation routes now.
/// <see cref="MetaEngine"/>'s embedded-navigation contract (a Meta rule's
/// <see cref="MetaActionKind.LoadEmbeddedNavigationRoute"/> action,
/// <c>.af</c> tag <c>EmbedNav</c>) is a typed
/// <see cref="MetaAction.EmbeddedRoute"/> <see cref="NavigationSettings"/>
/// now (round 2 step B, replacing an earlier binary-blob shape), saved and
/// loaded through the SAME <see cref="MetafSerializer.SaveNav"/>/
/// <see cref="MetafSerializer.TryLoadNav"/> grammar this class no longer
/// owns any writer for — round 3 item 12 cleanup: this doc comment
/// previously cited a "<c>WriteBinaryNavBlob</c>" method that does not
/// exist anywhere in the codebase.
/// </summary>
internal static class VtankNavRouteSerializer
{
private const string Header = "uTank2 NAV 1.2";
public static string Save(NavigationSettings source)
{
ArgumentNullException.ThrowIfNull(source);
var writer = new StringWriter(CultureInfo.InvariantCulture)
{
NewLine = "\r\n",
};
writer.WriteLine(Header);
writer.WriteLine(source.Mode switch
{
RouteMode.Circular => 1,
RouteMode.Linear => 2,
RouteMode.Target => 3,
RouteMode.Once => 4,
_ => throw new InvalidOperationException("Unknown navigation type."),
});
if (source.Mode == RouteMode.Target)
{
writer.WriteLine(source.FollowTargetName ?? string.Empty);
writer.WriteLine(unchecked((int)source.FollowTargetObjectId));
return writer.ToString();
}
writer.WriteLine(source.Waypoints.Count);
foreach (RouteWaypoint waypoint in source.Waypoints)
WriteWaypoint(writer, waypoint);
return writer.ToString();
}
public static bool TryLoad(
string source,
NavigationSettings target,
@ -145,13 +132,23 @@ internal static class VtankNavRouteSerializer
break;
case 6:
case 7:
// The record's leading eastWest/northSouth/elevation (read
// above into waypoint.Position) is "myxyz" — where the
// character stood when the waypoint was authored. This
// trailing triple is the portal/NPC object's own recorded
// position ("tgtxyz" in metaf's parallel ptl/tlk grammar —
// see MetafSerializer.ReadNavNode), kept separately so a
// reload can still disambiguate which object at that name
// to interact with, matching the record shape exactly:
// the pre-existing single-Position port used to overwrite
// "myxyz" with this value instead of keeping both.
waypoint.ObjectName = ReadLine(reader);
waypoint.LegacyObjectClass = ReadInt(reader);
waypoint.LegacyReferenceValid = ReadBoolean(reader);
double referenceEastWest = ReadDouble(reader);
double referenceNorthSouth = ReadDouble(reader);
double referenceElevation = ReadDouble(reader);
waypoint.Position = Position(
waypoint.ReferencePosition = Position(
referenceEastWest,
referenceNorthSouth,
referenceElevation);
@ -165,76 +162,6 @@ internal static class VtankNavRouteSerializer
return waypoint;
}
private static void WriteWaypoint(TextWriter writer, RouteWaypoint waypoint)
{
int type = (int)waypoint.Type;
writer.WriteLine(type.ToString(CultureInfo.InvariantCulture));
WriteDouble(writer, waypoint.Position.EastWest);
WriteDouble(writer, waypoint.Position.NorthSouth);
WriteDouble(writer, waypoint.Position.Elevation);
writer.WriteLine("0");
switch (waypoint.Type)
{
case RouteWaypointType.Point:
case RouteWaypointType.Checkpoint:
break;
case RouteWaypointType.Portal:
writer.WriteLine(unchecked((int)waypoint.ObjectId)
.ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.Recall:
writer.WriteLine(waypoint.RecallSpellId
.ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.Pause:
writer.WriteLine(waypoint.DurationMilliseconds
.ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.ChatCommand:
writer.WriteLine(waypoint.Text ?? string.Empty);
break;
case RouteWaypointType.OpenVendor:
writer.WriteLine(unchecked((int)waypoint.ObjectId)
.ToString(CultureInfo.InvariantCulture));
writer.WriteLine(waypoint.ObjectName ?? string.Empty);
break;
case RouteWaypointType.PortalByName:
case RouteWaypointType.UseNpc:
writer.WriteLine(waypoint.ObjectName ?? string.Empty);
int objectClass = waypoint.LegacyObjectClass != 0
? waypoint.LegacyObjectClass
: waypoint.Type == RouteWaypointType.PortalByName ? 14 : 37;
writer.WriteLine(objectClass.ToString(CultureInfo.InvariantCulture));
writer.WriteLine(waypoint.LegacyReferenceValid
.ToString(CultureInfo.InvariantCulture));
WriteDouble(writer, waypoint.Position.EastWest);
WriteDouble(writer, waypoint.Position.NorthSouth);
WriteDouble(writer, waypoint.Position.Elevation);
break;
case RouteWaypointType.Jump:
WriteDouble(writer, waypoint.JumpHeadingDegrees);
writer.WriteLine(waypoint.JumpRun.ToString(CultureInfo.InvariantCulture));
string suffix = waypoint.JumpDirection switch
{
RouteJumpDirection.StrafeLeft => "4",
RouteJumpDirection.StrafeRight => "5",
_ => "3",
};
writer.WriteLine(
waypoint.JumpChargeMilliseconds.ToString(
"0.0000",
CultureInfo.InvariantCulture)
+ suffix);
break;
default:
throw new InvalidOperationException(
$"Unknown waypoint type {waypoint.Type}.");
}
}
private static void WriteDouble(TextWriter writer, double value) =>
writer.WriteLine(Convert.ToString(value, CultureInfo.InvariantCulture));
private static void ParseJump(string source, RouteWaypoint target)
{
string value = source.Trim();
@ -277,7 +204,14 @@ internal static class VtankNavRouteSerializer
0f,
IsOutdoor: true);
private static void Apply(NavigationSettings source, NavigationSettings target)
/// <summary>
/// Copies every field <see cref="NavigationSettings"/> owns from
/// <paramref name="source"/> into the live <paramref name="target"/>
/// instance (internal, not private, so <c>MetaEngine</c>'s embedded-
/// route consumer can reuse the exact same copy the top-level import
/// path uses instead of re-parsing already-typed data).
/// </summary>
internal static void Apply(NavigationSettings source, NavigationSettings target)
{
target.Mode = source.Mode;
target.FollowTargetObjectId = source.FollowTargetObjectId;

View file

@ -5,6 +5,26 @@ namespace AcDream.Plugins.MossTank;
/// <c>uTank2.Resources.defaultsettings.usd</c>. Order is retained because
/// <c>/vt opt list</c> presents the database order four entries per line.
/// </summary>
/// <summary>
/// VTank's <c>uTank2.eSettingValueType</c> enum
/// (<c>refs/vtank/decompiled/uTank2/eSettingValueType.cs</c>) — the value
/// each Settings row's own <c>SettingType</c> cell declares. This is the
/// authority for which <see cref="VtankCell"/> type tag a Settings row's
/// Value cell must use; it is independent of the CLR type of the C# field
/// the live setting is stored in (e.g. every Recharge-* percentage is a
/// <c>tInt</c> row even though the live field is a <c>double</c> fraction).
/// </summary>
internal enum VtankSettingValueType
{
Bool = 1,
Double = 2,
Int = 3,
Single = 4,
String = 5,
Enum = 6,
Custom = 7,
}
internal static class VtankOptionCatalog
{
internal static readonly string[] Names =
@ -208,6 +228,151 @@ internal static class VtankOptionCatalog
["RechargeHandlerSet"] = MonsterValue.FromText("RechargeHandlerSet"),
};
// Exact per-row SettingType from the shipped defaultsettings.usd
// Settings table (verified 2026-09-06 against the committed
// Fixtures/vtank/defaultsettings.usd, all 137 rows).
private static readonly IReadOnlyDictionary<string, VtankSettingValueType> DeclaredTypes =
new Dictionary<string, VtankSettingValueType>(StringComparer.OrdinalIgnoreCase)
{
["EnableLooting"] = VtankSettingValueType.Bool,
["EnableNav"] = VtankSettingValueType.Bool,
["EnableBuffing"] = VtankSettingValueType.Bool,
["EnableCombat"] = VtankSettingValueType.Bool,
["SpellDiffExcessThreshold-Hunt"] = VtankSettingValueType.Int,
["SpellDiffExcessThreshold-Buff"] = VtankSettingValueType.Int,
["ArrowheadFletchDiffExcessThreshold"] = VtankSettingValueType.Int,
["Recharge-Norm-HitP"] = VtankSettingValueType.Int,
["Recharge-Norm-Stam"] = VtankSettingValueType.Int,
["Recharge-Norm-Mana"] = VtankSettingValueType.Int,
["Recharge-NoTarg-HitP"] = VtankSettingValueType.Int,
["Recharge-NoTarg-Stam"] = VtankSettingValueType.Int,
["Recharge-NoTarg-Mana"] = VtankSettingValueType.Int,
["Recharge-Helper-HitP"] = VtankSettingValueType.Int,
["Recharge-Helper-Stam"] = VtankSettingValueType.Int,
["Recharge-Helper-Mana"] = VtankSettingValueType.Int,
["DoHelp"] = VtankSettingValueType.Bool,
["AttackDistance"] = VtankSettingValueType.Double,
["AttackMinimumDistance"] = VtankSettingValueType.Double,
["ApproachDistance"] = VtankSettingValueType.Double,
["RingDistance"] = VtankSettingValueType.Double,
["CorpseApproachRange-Max"] = VtankSettingValueType.Double,
["CorpseApproachRange-Min"] = VtankSettingValueType.Double,
["NavCloseStopRange"] = VtankSettingValueType.Double,
["NavFarStopRange"] = VtankSettingValueType.Double,
["UsePortalDistance"] = VtankSettingValueType.Double,
["HelperDistanceHitP"] = VtankSettingValueType.Double,
["HelperDistanceStam"] = VtankSettingValueType.Double,
["HelperDistanceMana"] = VtankSettingValueType.Double,
["MinimumRingTargets"] = VtankSettingValueType.Int,
["DefaultMeleeAttackHeight"] = VtankSettingValueType.Int,
["CastDispelSelf"] = VtankSettingValueType.Bool,
["UseDispelItems"] = VtankSettingValueType.Bool,
["AutoCram"] = VtankSettingValueType.Bool,
["AutoStack"] = VtankSettingValueType.Bool,
["ReadUnknownScrolls"] = VtankSettingValueType.Bool,
["UseDispelDrum"] = VtankSettingValueType.Bool,
["SwitchWandsToDebuff"] = VtankSettingValueType.Bool,
["AutoCraftItems"] = VtankSettingValueType.Bool,
["UseHealersHeart"] = VtankSettingValueType.Bool,
["JumpOutWandCasting"] = VtankSettingValueType.Bool,
["LootAllCorpses"] = VtankSettingValueType.Bool,
["LootFellowCorpses"] = VtankSettingValueType.Bool,
["DoJiggle"] = VtankSettingValueType.Bool,
["RandomHelperBuffs"] = VtankSettingValueType.Bool,
["RandomHelperIntervalSeconds"] = VtankSettingValueType.Double,
["IdlePeaceMode"] = VtankSettingValueType.Bool,
["TargetLock"] = VtankSettingValueType.Bool,
["StopMacroOnDeath"] = VtankSettingValueType.Bool,
["UseArcs"] = VtankSettingValueType.Enum,
["ArcRange"] = VtankSettingValueType.Double,
["TargetSelectMethod"] = VtankSettingValueType.Enum,
["TargetSelectAngleRange"] = VtankSettingValueType.Double,
["IdleBuffTopoff"] = VtankSettingValueType.Bool,
["IdleBuffTopoffTimeSeconds"] = VtankSettingValueType.Int,
["RebuffTimeRemainingSeconds"] = VtankSettingValueType.Int,
["RefillWornMana"] = VtankSettingValueType.Bool,
["RefillWornMana-Item-ManaPercent"] = VtankSettingValueType.Int,
["BuffProfile-Prots"] = VtankSettingValueType.String,
["BuffProfile-Banes"] = VtankSettingValueType.String,
["BuffProfile_Prots"] = VtankSettingValueType.Enum,
["BuffProfile_Banes"] = VtankSettingValueType.Enum,
["DebuffEachFirst"] = VtankSettingValueType.Enum,
["AutoAttackPower"] = VtankSettingValueType.Bool,
["LootPriorityBoost"] = VtankSettingValueType.Bool,
["CorpseCacheTimeoutMinutes"] = VtankSettingValueType.Double,
["CorpseItemAppearanceTimeoutSeconds"] = VtankSettingValueType.Double,
["CorpseItemIDTimeoutSeconds"] = VtankSettingValueType.Double,
["DebuffSelectionMethod"] = VtankSettingValueType.Enum,
["ManaStoneLootCount"] = VtankSettingValueType.Int,
["ManaTankMinimumMana"] = VtankSettingValueType.Int,
["SplitPeas"] = VtankSettingValueType.Bool,
["SpellCompMin-Critical"] = VtankSettingValueType.Int,
["SpellCompMin-Normal"] = VtankSettingValueType.Int,
["SpellCompMin-Idle"] = VtankSettingValueType.Int,
["RechargeBoostTimeSeconds"] = VtankSettingValueType.Double,
["RechargeBoostAmount"] = VtankSettingValueType.Int,
["UseSpecialAmmo"] = VtankSettingValueType.Enum,
["OpenDoors"] = VtankSettingValueType.Bool,
["DoorIDRange"] = VtankSettingValueType.Double,
["DoorOpenRange"] = VtankSettingValueType.Double,
["DoorLockpickDiffExcessThreshold"] = VtankSettingValueType.Int,
["ManaChargesWhenOff"] = VtankSettingValueType.Bool,
["AutoFellowManagement"] = VtankSettingValueType.Bool,
["MinimumHealKitSuccessChance"] = VtankSettingValueType.Int,
["UseKitsInMagicMode"] = VtankSettingValueType.Bool,
["StaminaToHealthMultiplier"] = VtankSettingValueType.Double,
["ManaToHealthMultiplier"] = VtankSettingValueType.Double,
["NavPriorityBoost"] = VtankSettingValueType.Bool,
["DeleteGhostMonsters"] = VtankSettingValueType.Bool,
["GhostMonsterSpellAttemptCount"] = VtankSettingValueType.Int,
["WhoYouGonnaCall"] = VtankSettingValueType.Bool,
["BlacklistMonsterAttemptCount"] = VtankSettingValueType.Int,
["BlacklistMonsterTimeoutSeconds"] = VtankSettingValueType.Int,
["CombineSalvage"] = VtankSettingValueType.Bool,
["LootOnlyRareCorpses"] = VtankSettingValueType.Bool,
["DeleteGhostMonstersByHPTracker"] = VtankSettingValueType.Bool,
["GhostDeleteHPTrackerSeconds"] = VtankSettingValueType.Int,
["GoToPeaceModeToUseKits"] = VtankSettingValueType.Bool,
["UseRecklessness"] = VtankSettingValueType.Bool,
["DebuffPrecastSeconds"] = VtankSettingValueType.Int,
["ClearLevelBoostFlagOnCast"] = VtankSettingValueType.Bool,
["IdleCraftCount_HealthKits"] = VtankSettingValueType.Int,
["IdleCraftCount_StamKits"] = VtankSettingValueType.Int,
["IdleCraftCount_ManaKits"] = VtankSettingValueType.Int,
["IdleCraftCount_HealthFood"] = VtankSettingValueType.Int,
["IdleCraftCount_StamFood"] = VtankSettingValueType.Int,
["IdleCraftCount_ManaFood"] = VtankSettingValueType.Int,
["BuffCastRecast_Seconds"] = VtankSettingValueType.Int,
["BuffCastRecastReset_Seconds"] = VtankSettingValueType.Int,
["EnableMeta"] = VtankSettingValueType.Bool,
["BlacklistedSpellComps"] = VtankSettingValueType.String,
["DropToPeaceModeRetryCount"] = VtankSettingValueType.Int,
["FollowAroundCorners"] = VtankSettingValueType.Bool,
["BlacklistCorpseOpenAttemptCount"] = VtankSettingValueType.Int,
["BlacklistCorpseOpenTimeoutSeconds"] = VtankSettingValueType.Int,
["SummonPets"] = VtankSettingValueType.Bool,
["PetRangeMode"] = VtankSettingValueType.Enum,
["PetCustomRange"] = VtankSettingValueType.Double,
["PetRefillCount-Idle"] = VtankSettingValueType.Int,
["PetRefillCount-Normal"] = VtankSettingValueType.Int,
["CorpseOpenTimeoutSeconds"] = VtankSettingValueType.Double,
["PetMonsterDensity"] = VtankSettingValueType.Int,
["CorpseLootItemMaxAttempts"] = VtankSettingValueType.Int,
["FastCastBuffs"] = VtankSettingValueType.Bool,
["UseBreakableTurnTo"] = VtankSettingValueType.Bool,
["UseProjectileAwareness"] = VtankSettingValueType.Bool,
["CollisionProjectileRadius"] = VtankSettingValueType.Double,
["CollisionStepDistance"] = VtankSettingValueType.Double,
["ShowCollisionDebug"] = VtankSettingValueType.Bool,
["MaximumCollisionChecksPerTick"] = VtankSettingValueType.Int,
["SpellRangeFudge"] = VtankSettingValueType.Double,
["BuffWithUntrained-Item"] = VtankSettingValueType.Int,
["BuffWithUntrained-Creature"] = VtankSettingValueType.Int,
["BuffWithUntrained-Life"] = VtankSettingValueType.Int,
["AllowDebuffFallback"] = VtankSettingValueType.Bool,
["RechargeHandlerSet"] = VtankSettingValueType.Custom,
};
internal static bool IsKnown(string name) =>
Names.Contains(name, StringComparer.OrdinalIgnoreCase);
@ -218,4 +383,10 @@ internal static class VtankOptionCatalog
Defaults.TryGetValue(name, out MonsterValue value)
? value
: MonsterValue.FromNumber(0d);
/// <summary>The <c>eSettingValueType</c> VTank's own Settings row declares for this name.</summary>
internal static VtankSettingValueType DeclaredType(string name) =>
DeclaredTypes.TryGetValue(name, out VtankSettingValueType type)
? type
: VtankSettingValueType.Double;
}

View file

@ -0,0 +1,404 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Implements VTank's real profile-directory 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 resolved here: every listing method reads
/// through <see cref="IPluginHost.VtankProfiles"/> (an <see cref="IPluginStorage"/>
/// the App/Headless host composes — real installed VTank's own profile
/// folder for direct interop, or a portable per-OS default under
/// <c>ApplicationPathSet.DataDirectory</c>). This class has no
/// <c>System.IO</c> dependency and no portable-default fallback of its
/// own: when the host has no opinion, <see cref="IPluginHost.VtankProfiles"/>
/// defaults to the inert <c>NoOpPluginStorage</c> (<c>IsAvailable</c>
/// false), and every listing here degrades to just its built-in entries,
/// exactly like a missing directory used to.
/// </summary>
internal static class VtankProfileDirectory
{
/// <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]";
/// <summary>
/// The <c>nav_</c> marker (round 3 items 3/4) that distinguishes a
/// stand-alone route <c>.af</c> from a Meta profile sharing the same
/// flat <see cref="IPluginHost.VtankProfiles"/> directory and extension
/// — metaf's own observed convention for a nav-only <c>.af</c> (see the
/// committed <c>nav_*.af</c> fixtures). MUST be checked AFTER stripping
/// <see cref="HiddenPrefix"/> when the marker is combined with it (see
/// <see cref="AutoCharacterFileName(string,string,string,string)"/>'s
/// <c>marker</c> parameter): a hidden per-character route file is named
/// <c>--nav_Name_Server.af</c> — hidden prefix FIRST, marker SECOND —
/// never <c>nav_--Name_Server.af</c>, which does not start with
/// <see cref="HiddenPrefix"/> at all and so defeats every
/// <c>StartsWith("--")</c> hidden-file check in this class.
/// </summary>
internal const string NavMarker = "nav_";
/// <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>
/// Overload for a per-character auto file that ALSO carries a kind
/// marker (currently only <see cref="NavMarker"/>, for
/// <see cref="MossTankRouteProfileStore"/>'s route <c>.af</c>): the
/// hidden prefix always comes first so the file stays hidden from every
/// other character's picker exactly like the unmarked overload above —
/// <c>--{marker}Name_Server.ext</c>, e.g. <c>--nav_Name_Server.af</c>.
/// </summary>
public static string AutoCharacterFileName(
string characterName,
string server,
string extension,
string marker) =>
$"{HiddenPrefix}{marker}{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, 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,
string? currentFileName = null)
{
var entries = new List<ProfileEntry>
{
new(string.Empty, DefaultLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(storage, ".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
&& !fileName.Equals(currentFileName, StringComparison.Ordinal))
{
continue;
}
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
/// <see cref="NavMarker"/>-marked <c>.af</c> file that starts with
/// neither <c>--</c> nor <c>~~</c>. Round 3 item 4: a Meta profile and a
/// route share this same flat, single-extension directory — without
/// the marker check this returned every Meta <c>.af</c> too.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListNavigationProfiles(IPluginStorage storage)
{
var entries = new List<ProfileEntry>
{
new(string.Empty, NoneLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(storage, ".af"))
{
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal)
|| fileName.StartsWith(NavHiddenPrefix, StringComparison.Ordinal))
{
continue;
}
if (!fileName.StartsWith(NavMarker, 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>, non-<see cref="NavMarker"/>-marked file (a meta and a
/// nav profile share the same directory and extension here —
/// <c>.af</c> — so the marker is what VTank's own separate
/// <c>.met</c>/<c>.nav</c> extensions used to provide; round 3 item 4:
/// without excluding <see cref="NavMarker"/> files this returned every
/// route <c>.af</c> too).
/// </summary>
public static IReadOnlyList<ProfileEntry> ListMetaProfiles(IPluginStorage storage)
{
var entries = new List<ProfileEntry>
{
new(string.Empty, NoneLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(storage, ".af"))
{
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
continue;
if (fileName.StartsWith(NavMarker, StringComparison.Ordinal))
continue;
entries.Add(new ProfileEntry(fileName, fileName));
}
return entries;
}
/// <summary>
/// VTank's real loot-profile list (<c>aa()</c>,
/// <c>uTank2/PluginCore.cs:7127-7154</c>) seeds ONLY
/// <see cref="NoneLabel"/> — retail has no per-character auto loot
/// file and no "mine only" filter for loot at all (docs/research/vtank-kb/01-settings-and-profiles.md
/// section 3: "the loot default has no equivalent auto-name; loot
/// profiles default to none") — then every non-<c>--</c> file across
/// whatever loot-profile extension family that build understands.
/// Round 3 item 9: MossTank keeps its own established
/// <see cref="ByCharacterLabel"/> convention (already load-bearing for
/// Settings/Nav/Meta) as a SECOND seed entry for internal consistency
/// across all four stores — a recorded adaptation (register), not a
/// retail behavior — while still seeding retail's own
/// <see cref="NoneLabel"/> first.
/// </summary>
public static IReadOnlyList<ProfileEntry> ListLootProfiles(IPluginStorage storage)
{
var entries = new List<ProfileEntry>
{
new(string.Empty, NoneLabel),
new(string.Empty, ByCharacterLabel),
};
foreach (string fileName in EnumerateFileNames(storage, ".utl"))
{
if (fileName.StartsWith(HiddenPrefix, StringComparison.Ordinal))
continue;
entries.Add(new ProfileEntry(fileName, fileName));
}
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>
/// Writes <paramref name="binding"/> as <paramref name="characterName"/>/
/// <paramref name="server"/>'s <c>.cdf</c> (<c>da.q()</c>,
/// <c>refs/vtank/decompiled/da.cs:156-164</c>): the version header, then
/// settings/loot/nav on lines 2-4, then the meta filename on line 5 only
/// when <see cref="VtankCharacterBinding.MetaFileName"/> is non-empty —
/// matching <see cref="TryReadCharacterBinding"/>'s own line-5-is-optional
/// parsing exactly. No-ops when the storage is unavailable.
/// </summary>
public static void WriteCharacterBinding(
IPluginStorage storage,
string characterName,
string server,
VtankCharacterBinding binding)
{
ArgumentNullException.ThrowIfNull(storage);
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
ArgumentException.ThrowIfNullOrWhiteSpace(server);
if (!storage.IsAvailable)
return;
var lines = new List<string>
{
CdfHeader,
binding.SettingsFileName,
binding.LootFileName,
binding.NavFileName,
};
if (!string.IsNullOrEmpty(binding.MetaFileName))
lines.Add(binding.MetaFileName);
storage.WriteText(
CdfFileName(characterName, server),
string.Join("\r\n", lines) + "\r\n");
}
/// <summary>
/// Lists root-level file names matching <paramref name="extension"/>
/// through <see cref="IPluginStorage.List"/> alone — no
/// <c>System.IO</c>. VTank's own profile directory is flat, so a key
/// containing '/' (meaning it lives in some deeper storage
/// implementation's sub-directory) is not one of these files and is
/// skipped rather than surfaced as a bogus profile name.
/// </summary>
private static IEnumerable<string> EnumerateFileNames(IPluginStorage storage, string extension)
{
if (!storage.IsAvailable)
yield break;
foreach (string key in storage.List(string.Empty)
.Where(key => !key.Contains('/', StringComparison.Ordinal))
.Where(key => key.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
.OrderBy(static key => key, StringComparer.OrdinalIgnoreCase))
{
yield return key;
}
}
}

View file

@ -0,0 +1,542 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Loads/saves VTank's real <c>.usd</c> settings profile (the self-describing
/// <c>gy</c>/<c>bd</c>/<c>y</c> grammar in <see cref="VtankDatabase"/>),
/// mapping every one of the 137 <c>Settings</c> table rows
/// (<see cref="VtankOptionCatalog.Names"/>) onto the live
/// <see cref="CombatSettings"/>/<see cref="BuffSettings"/>/
/// <see cref="VitalSettings"/>/<see cref="InventorySettings"/>/
/// <see cref="NavigationSettings"/> fields by VTank name.
///
/// The value transforms (unit scaling, enum offsets) are the exact ones
/// already verified in <c>MossTankPanel.SetMetaOption</c>/<c>GetMetaOption</c>
/// (<c>MossTankPanel.cs:2442-3377</c>) against the decompiled VTank source
/// per <c>docs/research/vtank-kb/01-settings-and-profiles.md</c> — this
/// class duplicates those transforms rather than calling into
/// <c>MossTankPanel</c> because the panel's methods operate on a live
/// session (chat, expression state) that a file-format serializer must not
/// depend on.
///
/// Every table other than <c>Settings</c> (MyMonsters, GemFoodItems,
/// ExtraBuffSpells, AntiExtraBuffSpells, ItemUseSpecifiers,
/// SettingsCategories, SettingsEnumInfo, AssistItems, BuffedItems, …) is
/// preserved byte-for-byte: <see cref="Save"/> mutates the exact
/// <see cref="VtankDatabase"/> object <see cref="Load"/> returned, and only
/// replaces a <c>Settings</c> row's Value cell when the live setting's
/// current value differs from what was parsed (a value-equality compare,
/// not a text compare, so an untouched profile round-trips byte-for-byte
/// even if VTank's own float formatting differs subtly from ours).
/// </summary>
internal static class VtankSettingsProfileSerializer
{
private const string SettingsTable = "Settings";
internal sealed class AllSettings
{
public required CombatSettings Combat { get; init; }
public required BuffSettings Buffs { get; init; }
public required VitalSettings Vitals { get; init; }
public required InventorySettings Inventory { get; init; }
public required NavigationSettings Navigation { get; init; }
}
/// <summary>
/// Parses <paramref name="text"/> and applies every recognized Settings
/// row onto <paramref name="target"/>. Returns the parsed
/// <see cref="VtankDatabase"/> so a later <see cref="Save"/> can
/// preserve every other table (and every unrecognized Settings row)
/// byte-for-byte. Throws <see cref="FormatException"/> with a
/// <c>line N:</c>-prefixed message on the first structural error.
/// </summary>
public static VtankDatabase Load(string text, AllSettings target)
{
ArgumentNullException.ThrowIfNull(target);
VtankDatabase database = VtankDatabase.Parse(text);
VtankTable? settings = database.Find(SettingsTable);
if (settings is null)
throw new FormatException("missing required 'Settings' table.");
int nameColumn = settings.ColumnIndex("Setting");
int valueColumn = settings.ColumnIndex("Value");
if (nameColumn < 0 || valueColumn < 0)
throw new FormatException("'Settings' table is missing Setting/Value columns.");
foreach (VtankRow row in settings.Rows)
{
string name = row.Cells[nameColumn].AsString();
Apply(name, row.Cells[valueColumn], target);
}
return database;
}
/// <summary>
/// Regenerates the <c>Settings</c> table's Value cells from
/// <paramref name="source"/>'s current values (skipping any row whose
/// value is unchanged from what <paramref name="document"/> already
/// holds) and re-renders the whole database. Every other table, and any
/// Settings row this serializer does not recognize, is untouched.
/// </summary>
public static string Save(VtankDatabase document, AllSettings source)
{
ArgumentNullException.ThrowIfNull(document);
ArgumentNullException.ThrowIfNull(source);
VtankTable? settings = document.Find(SettingsTable);
if (settings is null)
throw new FormatException("missing required 'Settings' table.");
int nameColumn = settings.ColumnIndex("Setting");
int valueColumn = settings.ColumnIndex("Value");
foreach (VtankRow row in settings.Rows)
{
string name = row.Cells[nameColumn].AsString();
VtankCell? captured = Capture(name, source);
if (captured is null)
continue;
VtankCell current = row.Cells[valueColumn];
if (ValuesEqual(current, captured))
continue;
row.Cells[valueColumn] = captured;
}
return document.Render();
}
/// <summary>
/// Builds a brand-new database seeded from VTank's own shipped
/// <c>defaultsettings.usd</c> (<see cref="VtankDefaultSettingsDatabase"/>)
/// — every table (MyMonsters, GemFoodItems, ExtraBuffSpells,
/// AntiExtraBuffSpells, ItemUseSpecifiers, SettingsCategories,
/// SettingsEnumInfo, AssistItems, BuffedItems, RechargeHandlerSet, …),
/// not a hand-typed 4-column Settings-only replica: a prior version of
/// this method built only the Settings table, with an empty
/// Description and a hardcoded SettingType of 1 (Bool) for every row
/// regardless of the setting's real declared type. <see cref="Save"/>
/// then rewrites every Settings row's Value cell from
/// <paramref name="source"/>'s current values, exactly as it would for
/// an existing profile being re-saved untouched.
/// </summary>
public static VtankDatabase CreateNew(AllSettings source)
{
ArgumentNullException.ThrowIfNull(source);
VtankDatabase database = VtankDefaultSettingsDatabase.Parse();
_ = Save(database, source);
return database;
}
/// <summary>
/// Converts a live numeric setting value to the exact
/// <see cref="VtankCell"/> shape its declared <c>SettingType</c>
/// requires — the fix for the fidelity defect where 17 <c>tInt</c>
/// settings were written as <c>d</c> and 3 <c>tSingle</c> settings were
/// written as <c>f</c> (VTank's <c>gy.e</c>/<c>gy.f</c> unbox helpers
/// throw <see cref="InvalidCastException"/> on a tag/CLR-type mismatch).
/// The tag is driven by <see cref="VtankOptionCatalog.DeclaredType"/>,
/// never by the CLR type of the C# field the caller happens to store
/// the value in.
/// </summary>
private static VtankCell Num(string name, double value) =>
VtankOptionCatalog.DeclaredType(name) switch
{
VtankSettingValueType.Bool => VtankCell.Bool(value != 0d),
VtankSettingValueType.Int or VtankSettingValueType.Enum =>
VtankCell.Int((int)Math.Round(value, MidpointRounding.AwayFromZero)),
VtankSettingValueType.Single => VtankCell.Float((float)value),
VtankSettingValueType.String => VtankCell.String(value.ToString(CultureInfo.InvariantCulture)),
_ => VtankCell.Double(value),
};
/// <summary>
/// Exact compare on the cell's own type, with no tolerance anywhere —
/// a <see cref="Capture"/> tag mismatch is a real bug, not something to
/// paper over. Item I (slice-1 fix round) removed the one narrow
/// exception this used to carry: every <c>tDouble</c>-declared distance
/// setting (AttackDistance, ArcRange, …) that used to round-trip
/// through a <c>float</c>-typed live field (CombatSettings.MaximumRange
/// etc., costing precision below float's ~7-digit guarantee) now stores
/// that value as a real <c>double</c> — the field widened to match its
/// declared type instead of the compare being loosened to match the
/// field. Physics/combat consumers that want a <c>float</c> cast it at
/// their own use site.
/// </summary>
private static bool ValuesEqual(VtankCell a, VtankCell b)
{
if (a.Tag != b.Tag)
return false;
return a.Tag switch
{
"b" => a.AsBool() == b.AsBool(),
"s" => a.AsString() == b.AsString(),
"i" => a.AsInt() == b.AsInt(),
"u" => a.AsUInt() == b.AsUInt(),
"d" or "f" => a.AsDouble() == b.AsDouble(),
_ => ReferenceEquals(a, b),
};
}
// ------------------------------------------------------------------
// Apply: .usd cell -> live setting.
// ------------------------------------------------------------------
/// <summary>Internal (not private) so the profile store can seed live settings directly from a parsed database (fresh/default profiles, migration).</summary>
internal static void Apply(string rawName, VtankCell cell, AllSettings s)
{
if (!VtankOptionCatalog.IsKnown(rawName))
return;
string name = VtankOptionCatalog.Canonical(rawName).ToLowerInvariant();
CombatSettings c = s.Combat;
BuffSettings b = s.Buffs;
VitalSettings v = s.Vitals;
InventorySettings i = s.Inventory;
NavigationSettings n = s.Navigation;
switch (name)
{
case "enablelooting": i.Loot.Enabled = cell.AsBool(); break;
case "enablenav": n.Enabled = cell.AsBool(); break;
case "enablebuffing": b.Enabled = cell.AsBool(); break;
case "enablecombat": c.Enabled = cell.AsBool(); break;
case "spelldiffexcessthreshold-hunt": c.HuntSkillExcessOverDifficulty = cell.AsInt(); break;
case "spelldiffexcessthreshold-buff": b.SkillExcessOverDifficulty = cell.AsInt(); break;
case "arrowheadfletchdiffexcessthreshold": i.ArrowheadFletchDifficultyExcess = cell.AsInt(); break;
case "recharge-norm-hitp": v.NormalHealth = cell.AsDouble() / 100d; break;
case "recharge-norm-stam": v.NormalStamina = cell.AsDouble() / 100d; break;
case "recharge-norm-mana": v.NormalMana = cell.AsDouble() / 100d; break;
case "recharge-notarg-hitp": v.NoTargetHealth = cell.AsDouble() / 100d; break;
case "recharge-notarg-stam": v.NoTargetStamina = cell.AsDouble() / 100d; break;
case "recharge-notarg-mana": v.NoTargetMana = cell.AsDouble() / 100d; break;
case "recharge-helper-hitp": v.HelperHealth = cell.AsDouble() / 100d; break;
case "recharge-helper-stam": v.HelperStamina = cell.AsDouble() / 100d; break;
case "recharge-helper-mana": v.HelperMana = cell.AsDouble() / 100d; break;
case "dohelp": v.HelpOthers = cell.AsBool(); break;
case "attackdistance": c.MaximumRange = cell.AsDouble() * 240d; break;
case "attackminimumdistance": c.MinimumRange = cell.AsDouble() * 240d; break;
case "approachdistance": c.ApproachDistance = cell.AsDouble() * 240d; break;
case "ringdistance": c.RingDistance = cell.AsDouble() * 240d; break;
case "corpseapproachrange-max": i.Loot.CorpseApproachRange = cell.AsDouble() * 240d; break;
case "corpseapproachrange-min": i.Loot.CorpseMinimumApproachRange = cell.AsDouble() * 240d; break;
case "navclosestoprange": n.MinimumDistanceMeters = cell.AsDouble() * 240d; break;
case "navfarstoprange": n.MaximumDistanceMeters = cell.AsDouble() * 240d; break;
case "useportaldistance": n.PortalUseDistanceMeters = cell.AsDouble() * 240d; break;
case "helperdistancehitp": v.HelperHealthDistance = cell.AsDouble() * 240d; break;
case "helperdistancestam": v.HelperStaminaDistance = cell.AsDouble() * 240d; break;
case "helperdistancemana": v.HelperManaDistance = cell.AsDouble() * 240d; break;
case "minimumringtargets": c.MinimumRingTargets = cell.AsInt(); break;
case "defaultmeleeattackheight": c.AttackHeight = (PluginAttackHeight)cell.AsInt(); break;
case "castdispelself": v.CastDispelSelf = cell.AsBool(); break;
case "usedispelitems": v.UseDispelItems = cell.AsBool(); break;
case "autocram": i.AutoCram = cell.AsBool(); break;
case "autostack": i.AutoStack = cell.AsBool(); break;
case "readunknownscrolls": i.Loot.ReadUnknownScrolls = cell.AsBool(); break;
case "usedispeldrum": v.UseDispelDrum = cell.AsBool(); break;
case "switchwandstodebuff": c.SwitchWandsToDebuff = cell.AsBool(); break;
case "autocraftitems": i.AutoCraftItems = cell.AsBool(); break;
case "usehealersheart": v.UseHealersHeart = cell.AsBool(); break;
case "jumpoutwandcasting": c.JumpOutWandCasting = cell.AsBool(); break;
case "lootallcorpses": i.Loot.LootAllCorpses = cell.AsBool(); break;
case "lootfellowcorpses": i.Loot.LootFellowCorpses = cell.AsBool(); break;
case "dojiggle": c.DoJiggle = cell.AsBool(); break;
case "randomhelperbuffs": b.RandomHelperBuffs = cell.AsBool(); break;
case "randomhelperintervalseconds": b.RandomHelperIntervalSeconds = cell.AsDouble(); break;
case "idlepeacemode": c.IdlePeaceMode = cell.AsBool(); break;
case "targetlock": c.TargetLock = cell.AsBool(); break;
case "stopmacroondeath": c.StopMacroOnDeath = cell.AsBool(); break;
case "usearcs": c.UseArcs = (UseArcsMode)cell.AsInt(); break;
case "arcrange": c.ArcRange = cell.AsDouble() * 240d; break;
case "targetselectmethod": c.SelectionMethod = (TargetSelectionMethod)(cell.AsInt() - 1); break;
case "targetselectanglerange": c.TargetSelectAngleRange = cell.AsDouble() * 240d; break;
case "idlebufftopoff": b.IdleBuffTopoff = cell.AsBool(); break;
case "idlebufftopofftimeseconds": b.IdleBuffTopoffSeconds = cell.AsDouble(); break;
case "rebufftimeremainingseconds": b.RebuffWhenUnderSeconds = cell.AsDouble(); break;
case "refillwornmana": i.RefillWornMana = cell.AsBool(); break;
case "refillwornmana-item-manapercent": i.RefillWornManaPercent = cell.AsInt(); break;
case "buffprofile-prots": b.ProtectionElements = cell.AsString(); break;
case "buffprofile-banes": b.BaneElements = cell.AsString(); break;
case "buffprofile_prots": b.ProtectionProfileMode = cell.AsInt(); break;
case "buffprofile_banes": b.BaneProfileMode = cell.AsInt(); break;
case "debuffeachfirst": c.DebuffEachFirst = (DebuffEachFirst)cell.AsInt(); break;
case "autoattackpower": c.AutoAttackPower = cell.AsBool(); break;
case "lootpriorityboost": i.Loot.PriorityBoost = cell.AsBool(); break;
case "corpsecachetimeoutminutes": i.Loot.CorpseCacheTimeoutMinutes = cell.AsDouble(); break;
case "corpseitemappearancetimeoutseconds": i.Loot.CorpseItemAppearanceTimeoutSeconds = cell.AsDouble(); break;
case "corpseitemidtimeoutseconds": i.Loot.CorpseItemIdentifyTimeoutSeconds = cell.AsDouble(); break;
case "debuffselectionmethod": c.DebuffSelectionMethod = (DebuffSelectionMethod)cell.AsInt(); break;
case "manastonelootcount": i.Loot.ManaStoneLootCount = cell.AsInt(); break;
case "manatankminimummana": i.Loot.ManaTankMinimumMana = cell.AsInt(); break;
case "splitpeas": i.SplitPeas = cell.AsBool(); break;
case "spellcompmin-critical": i.CriticalComponentMinimum = cell.AsInt(); break;
case "spellcompmin-normal": i.NormalComponentMinimum = cell.AsInt(); break;
case "spellcompmin-idle": i.IdleComponentMinimum = cell.AsInt(); break;
case "rechargeboosttimeseconds": v.RechargeBoostTimeSeconds = cell.AsDouble(); break;
case "rechargeboostamount": v.RechargeBoostAmount = cell.AsInt(); break;
case "usespecialammo": c.UseSpecialAmmo = cell.AsInt(); break;
case "opendoors": n.OpenDoors = cell.AsBool(); break;
case "dooridrange": n.DoorIdentifyRangeMeters = cell.AsDouble() * 240d; break;
case "dooropenrange": n.DoorOpenRangeMeters = cell.AsDouble() * 240d; break;
case "doorlockpickdiffexcessthreshold": n.DoorLockpickExcessThreshold = cell.AsInt(); break;
case "manachargeswhenoff": i.ManaChargesWhenOff = cell.AsBool(); break;
case "autofellowmanagement": c.AutoFellowManagement = cell.AsBool(); break;
case "minimumhealkitsuccesschance": v.MinimumHealKitSuccessChance = cell.AsInt(); break;
case "usekitsinmagicmode": v.UseKitsInMagicMode = cell.AsBool(); break;
case "staminatohealthmultiplier": v.StaminaToHealthMultiplier = cell.AsDouble(); break;
case "manatohealthmultiplier": v.ManaToHealthMultiplier = cell.AsDouble(); break;
case "navpriorityboost": n.Priority = cell.AsBool(); break;
case "deleteghostmonsters": c.DeleteGhostMonsters = cell.AsBool(); break;
case "ghostmonsterspellattemptcount": c.GhostMonsterSpellAttemptCount = cell.AsInt(); break;
case "whoyougonnacall": c.WhoYouGonnaCall = cell.AsBool(); break;
case "blacklistmonsterattemptcount": c.BlacklistMonsterAttemptCount = cell.AsInt(); break;
case "blacklistmonstertimeoutseconds": c.BlacklistMonsterTimeoutSeconds = cell.AsDouble(); break;
case "combinesalvage": i.Loot.CombineSalvage = cell.AsBool(); break;
case "lootonlyrarecorpses": i.Loot.LootOnlyRareCorpses = cell.AsBool(); break;
case "deleteghostmonstersbyhptracker": c.DeleteGhostMonstersByHealthTracker = cell.AsBool(); break;
case "ghostdeletehptrackerseconds": c.GhostDeleteHealthTrackerSeconds = cell.AsDouble(); break;
case "gotopeacemodetousekits": v.GoToPeaceModeToUseKits = cell.AsBool(); break;
case "userecklessness": c.UseRecklessness = cell.AsBool(); break;
case "debuffprecastseconds": c.DebuffPrecastSeconds = cell.AsDouble(); break;
case "clearlevelboostflagoncast": v.ClearLevelBoostFlagOnCast = cell.AsBool(); break;
case "idlecraftcount_healthkits": i.IdleHealthKitCount = cell.AsInt(); break;
case "idlecraftcount_stamkits": i.IdleStaminaKitCount = cell.AsInt(); break;
case "idlecraftcount_manakits": i.IdleManaKitCount = cell.AsInt(); break;
case "idlecraftcount_healthfood": i.IdleHealthFoodCount = cell.AsInt(); break;
case "idlecraftcount_stamfood": i.IdleStaminaFoodCount = cell.AsInt(); break;
case "idlecraftcount_manafood": i.IdleManaFoodCount = cell.AsInt(); break;
case "buffcastrecast_seconds": b.BuffCastRecastSeconds = cell.AsDouble(); break;
case "buffcastrecastreset_seconds": b.BuffCastRecastResetSeconds = cell.AsDouble(); break;
case "enablemeta": break; // live MetaEngine.Enabled, not a stored settings field.
case "blacklistedspellcomps":
b.BlacklistedSpellComponents = cell.AsString();
c.BlacklistedSpellComponents = cell.AsString();
break;
case "droptopeacemoderetrycount": v.DropToPeaceModeRetryCount = cell.AsInt(); break;
case "followaroundcorners": n.FollowAroundCorners = cell.AsBool(); break;
case "blacklistcorpseopenattemptcount": i.Loot.BlacklistCorpseOpenAttemptCount = cell.AsInt(); break;
case "blacklistcorpseopentimeoutseconds": i.Loot.BlacklistCorpseOpenTimeoutSeconds = cell.AsDouble(); break;
case "summonpets": c.SummonPets = cell.AsBool(); break;
case "petrangemode": c.PetRangeMode = (PetRangeMode)cell.AsInt(); break;
case "petcustomrange": c.PetCustomRange = cell.AsDouble() * 240d; break;
case "petrefillcount-idle": c.PetRefillCountIdle = cell.AsInt(); break;
case "petrefillcount-normal": c.PetRefillCountNormal = cell.AsInt(); break;
case "corpseopentimeoutseconds": i.Loot.CorpseOpenTimeoutSeconds = cell.AsDouble(); break;
case "petmonsterdensity": c.PetMonsterDensity = cell.AsInt(); break;
case "corpselootitemmaxattempts": i.Loot.CorpseLootItemMaxAttempts = cell.AsInt(); break;
case "fastcastbuffs": b.FastCastBuffs = cell.AsBool(); break;
case "usebreakableturnto": c.UseBreakableTurnTo = cell.AsBool(); break;
case "useprojectileawareness": c.UseProjectileAwareness = cell.AsBool(); break;
case "collisionprojectileradius": c.CollisionProjectileRadius = cell.AsDouble(); break;
case "collisionstepdistance": c.CollisionStepDistance = cell.AsDouble(); break;
case "showcollisiondebug": c.ShowCollisionDebug = cell.AsBool(); break;
case "maximumcollisioncheckspertick": c.MaximumCollisionChecksPerTick = cell.AsInt(); break;
case "spellrangefudge": c.SpellRangeFudge = cell.AsDouble(); break;
case "buffwithuntrained-item": b.BuffWithUntrainedItemSkill = cell.AsInt(); break;
case "buffwithuntrained-creature": b.BuffWithUntrainedCreatureSkill = cell.AsInt(); break;
case "buffwithuntrained-life": b.BuffWithUntrainedLifeSkill = cell.AsInt(); break;
case "allowdebufffallback": c.AllowDebuffFallback = cell.AsBool(); break;
case "rechargehandlerset":
if (cell.Tag == "TABLE" && cell.Table is { } table)
v.RechargeHandlerRows = ParseRechargeHandlerSet(table);
break;
default: break;
}
}
// ------------------------------------------------------------------
// Capture: live setting -> .usd cell. Mirror of Apply above; a name
// Apply ignores (enablemeta, rechargehandlerset) also returns null here
// so Save leaves that row's original cell untouched.
// ------------------------------------------------------------------
/// <summary>Internal (not private) solely so the 137-setting coverage test can call it directly.</summary>
internal static VtankCell? Capture(string rawName, AllSettings s)
{
if (!VtankOptionCatalog.IsKnown(rawName))
return null;
string name = VtankOptionCatalog.Canonical(rawName).ToLowerInvariant();
CombatSettings c = s.Combat;
BuffSettings b = s.Buffs;
VitalSettings v = s.Vitals;
InventorySettings i = s.Inventory;
NavigationSettings n = s.Navigation;
return name switch
{
"enablelooting" => VtankCell.Bool(i.Loot.Enabled),
"enablenav" => VtankCell.Bool(n.Enabled),
"enablebuffing" => VtankCell.Bool(b.Enabled),
"enablecombat" => VtankCell.Bool(c.Enabled),
"spelldiffexcessthreshold-hunt" => Num(name, c.HuntSkillExcessOverDifficulty),
"spelldiffexcessthreshold-buff" => Num(name, b.SkillExcessOverDifficulty),
"arrowheadfletchdiffexcessthreshold" => Num(name, i.ArrowheadFletchDifficultyExcess),
"recharge-norm-hitp" => Num(name, v.NormalHealth * 100d),
"recharge-norm-stam" => Num(name, v.NormalStamina * 100d),
"recharge-norm-mana" => Num(name, v.NormalMana * 100d),
"recharge-notarg-hitp" => Num(name, v.NoTargetHealth * 100d),
"recharge-notarg-stam" => Num(name, v.NoTargetStamina * 100d),
"recharge-notarg-mana" => Num(name, v.NoTargetMana * 100d),
"recharge-helper-hitp" => Num(name, v.HelperHealth * 100d),
"recharge-helper-stam" => Num(name, v.HelperStamina * 100d),
"recharge-helper-mana" => Num(name, v.HelperMana * 100d),
"dohelp" => VtankCell.Bool(v.HelpOthers),
"attackdistance" => Num(name, c.MaximumRange / 240d),
"attackminimumdistance" => Num(name, c.MinimumRange / 240d),
"approachdistance" => Num(name, c.ApproachDistance / 240d),
"ringdistance" => Num(name, c.RingDistance / 240d),
"corpseapproachrange-max" => Num(name, i.Loot.CorpseApproachRange / 240d),
"corpseapproachrange-min" => Num(name, i.Loot.CorpseMinimumApproachRange / 240d),
"navclosestoprange" => Num(name, n.MinimumDistanceMeters / 240d),
"navfarstoprange" => Num(name, n.MaximumDistanceMeters / 240d),
"useportaldistance" => Num(name, n.PortalUseDistanceMeters / 240d),
"helperdistancehitp" => Num(name, v.HelperHealthDistance / 240d),
"helperdistancestam" => Num(name, v.HelperStaminaDistance / 240d),
"helperdistancemana" => Num(name, v.HelperManaDistance / 240d),
"minimumringtargets" => Num(name, c.MinimumRingTargets),
"defaultmeleeattackheight" => Num(name, (int)c.AttackHeight),
"castdispelself" => VtankCell.Bool(v.CastDispelSelf),
"usedispelitems" => VtankCell.Bool(v.UseDispelItems),
"autocram" => VtankCell.Bool(i.AutoCram),
"autostack" => VtankCell.Bool(i.AutoStack),
"readunknownscrolls" => VtankCell.Bool(i.Loot.ReadUnknownScrolls),
"usedispeldrum" => VtankCell.Bool(v.UseDispelDrum),
"switchwandstodebuff" => VtankCell.Bool(c.SwitchWandsToDebuff),
"autocraftitems" => VtankCell.Bool(i.AutoCraftItems),
"usehealersheart" => VtankCell.Bool(v.UseHealersHeart),
"jumpoutwandcasting" => VtankCell.Bool(c.JumpOutWandCasting),
"lootallcorpses" => VtankCell.Bool(i.Loot.LootAllCorpses),
"lootfellowcorpses" => VtankCell.Bool(i.Loot.LootFellowCorpses),
"dojiggle" => VtankCell.Bool(c.DoJiggle),
"randomhelperbuffs" => VtankCell.Bool(b.RandomHelperBuffs),
"randomhelperintervalseconds" => Num(name, b.RandomHelperIntervalSeconds),
"idlepeacemode" => VtankCell.Bool(c.IdlePeaceMode),
"targetlock" => VtankCell.Bool(c.TargetLock),
"stopmacroondeath" => VtankCell.Bool(c.StopMacroOnDeath),
"usearcs" => Num(name, (int)c.UseArcs),
"arcrange" => Num(name, c.ArcRange / 240d),
"targetselectmethod" => Num(name, (int)c.SelectionMethod + 1),
"targetselectanglerange" => Num(name, c.TargetSelectAngleRange / 240d),
"idlebufftopoff" => VtankCell.Bool(b.IdleBuffTopoff),
"idlebufftopofftimeseconds" => Num(name, b.IdleBuffTopoffSeconds),
"rebufftimeremainingseconds" => Num(name, b.RebuffWhenUnderSeconds),
"refillwornmana" => VtankCell.Bool(i.RefillWornMana),
"refillwornmana-item-manapercent" => Num(name, i.RefillWornManaPercent),
"buffprofile-prots" => VtankCell.String(b.ProtectionElements),
"buffprofile-banes" => VtankCell.String(b.BaneElements),
"buffprofile_prots" => Num(name, b.ProtectionProfileMode),
"buffprofile_banes" => Num(name, b.BaneProfileMode),
"debuffeachfirst" => Num(name, (int)c.DebuffEachFirst),
"autoattackpower" => VtankCell.Bool(c.AutoAttackPower),
"lootpriorityboost" => VtankCell.Bool(i.Loot.PriorityBoost),
"corpsecachetimeoutminutes" => Num(name, i.Loot.CorpseCacheTimeoutMinutes),
"corpseitemappearancetimeoutseconds" => Num(name, i.Loot.CorpseItemAppearanceTimeoutSeconds),
"corpseitemidtimeoutseconds" => Num(name, i.Loot.CorpseItemIdentifyTimeoutSeconds),
"debuffselectionmethod" => Num(name, (int)c.DebuffSelectionMethod),
"manastonelootcount" => Num(name, i.Loot.ManaStoneLootCount),
"manatankminimummana" => Num(name, i.Loot.ManaTankMinimumMana),
"splitpeas" => VtankCell.Bool(i.SplitPeas),
"spellcompmin-critical" => Num(name, i.CriticalComponentMinimum),
"spellcompmin-normal" => Num(name, i.NormalComponentMinimum),
"spellcompmin-idle" => Num(name, i.IdleComponentMinimum),
"rechargeboosttimeseconds" => Num(name, v.RechargeBoostTimeSeconds),
"rechargeboostamount" => Num(name, v.RechargeBoostAmount),
"usespecialammo" => Num(name, c.UseSpecialAmmo),
"opendoors" => VtankCell.Bool(n.OpenDoors),
"dooridrange" => Num(name, n.DoorIdentifyRangeMeters / 240d),
"dooropenrange" => Num(name, n.DoorOpenRangeMeters / 240d),
"doorlockpickdiffexcessthreshold" => Num(name, n.DoorLockpickExcessThreshold),
"manachargeswhenoff" => VtankCell.Bool(i.ManaChargesWhenOff),
"autofellowmanagement" => VtankCell.Bool(c.AutoFellowManagement),
"minimumhealkitsuccesschance" => Num(name, v.MinimumHealKitSuccessChance),
"usekitsinmagicmode" => VtankCell.Bool(v.UseKitsInMagicMode),
"staminatohealthmultiplier" => Num(name, v.StaminaToHealthMultiplier),
"manatohealthmultiplier" => Num(name, v.ManaToHealthMultiplier),
"navpriorityboost" => VtankCell.Bool(n.Priority),
"deleteghostmonsters" => VtankCell.Bool(c.DeleteGhostMonsters),
"ghostmonsterspellattemptcount" => Num(name, c.GhostMonsterSpellAttemptCount),
"whoyougonnacall" => VtankCell.Bool(c.WhoYouGonnaCall),
"blacklistmonsterattemptcount" => Num(name, c.BlacklistMonsterAttemptCount),
"blacklistmonstertimeoutseconds" => Num(name, c.BlacklistMonsterTimeoutSeconds),
"combinesalvage" => VtankCell.Bool(i.Loot.CombineSalvage),
"lootonlyrarecorpses" => VtankCell.Bool(i.Loot.LootOnlyRareCorpses),
"deleteghostmonstersbyhptracker" => VtankCell.Bool(c.DeleteGhostMonstersByHealthTracker),
"ghostdeletehptrackerseconds" => Num(name, c.GhostDeleteHealthTrackerSeconds),
"gotopeacemodetousekits" => VtankCell.Bool(v.GoToPeaceModeToUseKits),
"userecklessness" => VtankCell.Bool(c.UseRecklessness),
"debuffprecastseconds" => Num(name, c.DebuffPrecastSeconds),
"clearlevelboostflagoncast" => VtankCell.Bool(v.ClearLevelBoostFlagOnCast),
"idlecraftcount_healthkits" => Num(name, i.IdleHealthKitCount),
"idlecraftcount_stamkits" => Num(name, i.IdleStaminaKitCount),
"idlecraftcount_manakits" => Num(name, i.IdleManaKitCount),
"idlecraftcount_healthfood" => Num(name, i.IdleHealthFoodCount),
"idlecraftcount_stamfood" => Num(name, i.IdleStaminaFoodCount),
"idlecraftcount_manafood" => Num(name, i.IdleManaFoodCount),
"buffcastrecast_seconds" => Num(name, b.BuffCastRecastSeconds),
"buffcastrecastreset_seconds" => Num(name, b.BuffCastRecastResetSeconds),
"blacklistedspellcomps" => VtankCell.String(b.BlacklistedSpellComponents),
"droptopeacemoderetrycount" => Num(name, v.DropToPeaceModeRetryCount),
"followaroundcorners" => VtankCell.Bool(n.FollowAroundCorners),
"blacklistcorpseopenattemptcount" => Num(name, i.Loot.BlacklistCorpseOpenAttemptCount),
"blacklistcorpseopentimeoutseconds" => Num(name, i.Loot.BlacklistCorpseOpenTimeoutSeconds),
"summonpets" => VtankCell.Bool(c.SummonPets),
"petrangemode" => Num(name, (int)c.PetRangeMode),
"petcustomrange" => Num(name, c.PetCustomRange / 240d),
"petrefillcount-idle" => Num(name, c.PetRefillCountIdle),
"petrefillcount-normal" => Num(name, c.PetRefillCountNormal),
"corpseopentimeoutseconds" => Num(name, i.Loot.CorpseOpenTimeoutSeconds),
"petmonsterdensity" => Num(name, c.PetMonsterDensity),
"corpselootitemmaxattempts" => Num(name, i.Loot.CorpseLootItemMaxAttempts),
"fastcastbuffs" => VtankCell.Bool(b.FastCastBuffs),
"usebreakableturnto" => VtankCell.Bool(c.UseBreakableTurnTo),
"useprojectileawareness" => VtankCell.Bool(c.UseProjectileAwareness),
"collisionprojectileradius" => Num(name, c.CollisionProjectileRadius),
"collisionstepdistance" => Num(name, c.CollisionStepDistance),
"showcollisiondebug" => VtankCell.Bool(c.ShowCollisionDebug),
"maximumcollisioncheckspertick" => Num(name, c.MaximumCollisionChecksPerTick),
"spellrangefudge" => Num(name, c.SpellRangeFudge),
"buffwithuntrained-item" => Num(name, b.BuffWithUntrainedItemSkill),
"buffwithuntrained-creature" => Num(name, b.BuffWithUntrainedCreatureSkill),
"buffwithuntrained-life" => Num(name, b.BuffWithUntrainedLifeSkill),
"allowdebufffallback" => VtankCell.Bool(c.AllowDebuffFallback),
_ => null, // "enablemeta" (live engine state) and "rechargehandlerset"
// (no write path exists in real VTank either, section 2 row 137)
// are deliberately left untouched.
};
}
/// <summary>
/// Parses the RechargeHandlerSet nested TABLE (5 columns:
/// Vital/HandlerString/MinPercent/MaxPercent/Stance — verified against
/// the live <c>defaultsettings.usd</c> fixture, 26 rows).
/// </summary>
internal static RechargeHandlerRow[] ParseRechargeHandlerSet(VtankTable table)
{
int vitalColumn = table.ColumnIndex("Vital");
int handlerColumn = table.ColumnIndex("HandlerString");
int minColumn = table.ColumnIndex("MinPercent");
int maxColumn = table.ColumnIndex("MaxPercent");
int stanceColumn = table.ColumnIndex("Stance");
if (vitalColumn < 0 || handlerColumn < 0 || minColumn < 0
|| maxColumn < 0 || stanceColumn < 0)
{
return [];
}
var rows = new RechargeHandlerRow[table.Rows.Count];
for (int i = 0; i < table.Rows.Count; i++)
{
VtankRow row = table.Rows[i];
rows[i] = new RechargeHandlerRow(
row.Cells[vitalColumn].AsInt(),
row.Cells[handlerColumn].AsString(),
row.Cells[minColumn].AsInt(),
row.Cells[maxColumn].AsInt(),
row.Cells[stanceColumn].AsInt());
}
return rows;
}
// No RenderRechargeHandlerSet/write-back helper exists here deliberately:
// real VTank has NO save path for this table at all (docs/research/
// vtank-kb/01-settings-and-profiles.md section 2, row 137 — tCustom has
// no case in the Advanced Options editor's display/edit switch or in
// "/vt opt set"; the table is owned by a dedicated cRechargeManager
// object, not any control that persists through the generic Settings
// save path). Writing it back would be a MossTank invention, not a
// retail port. A prior version of this file had an unused
// RenderRechargeHandlerSet helper for exactly that invention; it was
// deleted rather than wired in for this reason.
}

View file

@ -0,0 +1,438 @@
using System.Globalization;
using System.Linq;
using System.Text;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// The self-describing recursive text-database grammar VTank uses for
/// <c>.usd</c> settings profiles and <c>.ast</c> per-character caches. Ported
/// from the decompiled <c>gy</c>/<c>cw</c>/<c>bd</c>/<c>y</c> classes
/// documented in <c>docs/research/vtank-kb/01-settings-and-profiles.md</c>
/// section 1: <c>refs/vtank/decompiled/gy.cs:20-56</c> (typed cell),
/// <c>cw.cs:9-40</c> (row), <c>bd.cs:364-395</c> (table), <c>y.cs:87-104</c>
/// (whole database). Plain text, CRLF line endings, no compression, no
/// checksum, no top-level version header.
///
/// One cell (<c>gy</c>) is a type-tag line followed by zero or more value
/// lines: <c>d</c>=double, <c>i</c>=int, <c>u</c>=uint, <c>f</c>=float,
/// <c>s</c>=string (the raw next line, possibly empty), <c>b</c>=bool
/// (<c>"True"</c>/<c>"False"</c>), <c>TABLE</c>=a nested table (recurse into
/// <see cref="VtankTable"/>), <c>ba</c>=a length-prefixed raw-character blob
/// (an int line for the character count, then that many raw characters,
/// not lines, so embedded newlines survive — see <see cref="VtankLineCursor.ReadBlob"/>).
/// Any other tag (<c>y.cs:28-41</c> only ever registers <c>TABLE</c> and
/// <c>ba</c> as named custom types) is genuinely unrecognized to VTank
/// itself: <c>gy.cs:50-55</c> (read) consumes ONLY the tag line and
/// <c>gy.cs:98-101</c> (write) emits ONLY a tag line ("0" for a void cell)
/// — no value line either way. This is preserved verbatim so an
/// unrecognized custom type still round-trips byte-for-byte.
/// </summary>
internal sealed class VtankCell
{
// The only tags VTank's gy.a(TextReader)/gy.a(TextWriter) treat as a
// tag+value pair (gy.cs:20-56, gy.cs:60-92). TABLE and ba are each
// handled by their own dedicated branch; everything else is an
// unrecognized/void tag that carries no value line at all.
private static readonly HashSet<string> KnownScalarTags = ["d", "i", "u", "f", "s", "b"];
internal VtankCell() { }
/// <summary>The raw type tag: <c>d</c>/<c>i</c>/<c>u</c>/<c>f</c>/<c>s</c>/<c>b</c>/<c>TABLE</c>/<c>ba</c>/other.</summary>
public required string Tag { get; init; }
/// <summary>Raw text of the single value line, for every scalar tag except <c>ba</c>.</summary>
public string? ScalarText { get; set; }
/// <summary>The nested table payload, only when <see cref="Tag"/> is <c>TABLE</c>.</summary>
public VtankTable? Table { get; set; }
/// <summary>The raw character blob, only when <see cref="Tag"/> is <c>ba</c>.</summary>
public string? BlobText { get; set; }
public static VtankCell Double(double value) => new()
{
Tag = "d",
ScalarText = FormatDouble(value),
};
public static VtankCell Int(int value) => new()
{
Tag = "i",
ScalarText = value.ToString(CultureInfo.InvariantCulture),
};
public static VtankCell UInt(uint value) => new()
{
Tag = "u",
ScalarText = value.ToString(CultureInfo.InvariantCulture),
};
public static VtankCell Float(float value) => new()
{
Tag = "f",
ScalarText = FormatDouble(value),
};
public static VtankCell String(string value) => new()
{
Tag = "s",
// VTank strips embedded newlines from a string cell on write
// (gy.cs:84: `text.Replace("\n", "")`) — a string cell is always
// exactly one line; a blob that needs embedded newlines uses "ba".
ScalarText = value.Replace("\n", string.Empty, StringComparison.Ordinal),
};
public static VtankCell Bool(bool value) => new()
{
Tag = "b",
ScalarText = value ? "True" : "False",
};
public static VtankCell NestedTable(VtankTable table) => new()
{
Tag = "TABLE",
Table = table,
};
public double AsDouble() => double.Parse(
ScalarText ?? "0",
NumberStyles.Float,
CultureInfo.InvariantCulture);
public int AsInt() => int.Parse(
ScalarText ?? "0",
NumberStyles.Integer,
CultureInfo.InvariantCulture);
public uint AsUInt() => uint.Parse(
ScalarText ?? "0",
NumberStyles.Integer,
CultureInfo.InvariantCulture);
public float AsFloat() => float.Parse(
ScalarText ?? "0",
NumberStyles.Float,
CultureInfo.InvariantCulture);
public string AsString() => ScalarText ?? string.Empty;
/// <summary>
/// VTank's <c>gy</c> bool tag stores the literal text <c>"True"</c> or
/// <c>"False"</c> (C#'s default <see cref="bool"/> formatting).
/// </summary>
public bool AsBool() => string.Equals(ScalarText, "True", StringComparison.Ordinal);
/// <summary>
/// Formats a double/float the way VTank's own (older .NET Framework)
/// build does: <c>Convert.ToString(double, CultureInfo)</c>
/// (<c>gy.cs:60-61</c>) used the CLR's classic 15-significant-digit
/// general format, not .NET Core's shortest-round-trippable default.
/// Verified against every <c>d</c>-tagged value in the real
/// <c>defaultsettings.usd</c> fixture (e.g. 5/240 renders as
/// <c>0.0208333333333333</c> — 15 significant digits).
/// </summary>
internal static string FormatDouble(double value) =>
value.ToString("G15", CultureInfo.InvariantCulture);
internal void WriteTo(StringBuilder sb)
{
VtankWriter.AppendLine(sb, Tag);
if (Tag == "TABLE")
{
Table!.WriteTo(sb);
}
else if (Tag == "ba")
{
// A "ba" blob is a raw character count, not a line count: the
// blob may contain embedded newlines. VTank's own writer
// (f6.cs:20-24) WriteLine()s only the length, then Write()s the
// raw characters with NO trailing line terminator — whatever
// structural token follows continues immediately after the
// blob's last character, exactly like the real client's output.
string blob = BlobText ?? string.Empty;
VtankWriter.AppendLine(sb, blob.Length.ToString(CultureInfo.InvariantCulture));
sb.Append(blob);
}
else if (KnownScalarTags.Contains(Tag))
{
VtankWriter.AppendLine(sb, ScalarText ?? string.Empty);
}
// else: an unrecognized/void tag is the WHOLE cell (gy.cs:98-101
// writes only "0", no value line) — nothing further to emit.
}
}
/// <summary>Shared line-terminator helper for every <c>WriteTo</c> in this file (CRLF, matching VTank's own writer).</summary>
internal static class VtankWriter
{
internal static void AppendLine(StringBuilder sb, string text) =>
sb.Append(text).Append("\r\n");
}
internal sealed class VtankRow
{
public List<VtankCell> Cells { get; } = [];
internal void WriteTo(StringBuilder sb)
{
foreach (VtankCell cell in Cells)
cell.WriteTo(sb);
}
}
/// <summary>
/// One <c>bd</c> table: a column-name list, a hash-index flag per column,
/// and a row list. Row length is NOT fixed at parse time — a <c>TABLE</c> or
/// <c>ba</c> cell recurses, so the reader must walk cell-by-cell rather than
/// assume a fixed line stride per row (<c>bd.cs:364-395</c>).
/// </summary>
internal sealed class VtankTable
{
public List<string> ColumnNames { get; } = [];
public List<bool> IndexFlags { get; } = [];
public List<VtankRow> Rows { get; } = [];
public int ColumnIndex(string name) => ColumnNames.FindIndex(
column => column.Equals(name, StringComparison.Ordinal));
internal static VtankTable Read(VtankLineCursor cursor)
{
var table = new VtankTable();
int columnCount = cursor.ReadInt();
for (int i = 0; i < columnCount; i++)
table.ColumnNames.Add(cursor.ReadLine());
for (int i = 0; i < columnCount; i++)
table.IndexFlags.Add(cursor.ReadLine() == "y");
int rowCount = cursor.ReadInt();
for (int r = 0; r < rowCount; r++)
{
var row = new VtankRow();
for (int c = 0; c < columnCount; c++)
row.Cells.Add(VtankDatabaseReader.ReadCell(cursor));
table.Rows.Add(row);
}
return table;
}
internal void WriteTo(StringBuilder sb)
{
VtankWriter.AppendLine(sb, ColumnNames.Count.ToString(CultureInfo.InvariantCulture));
foreach (string column in ColumnNames)
VtankWriter.AppendLine(sb, column);
foreach (bool flag in IndexFlags)
VtankWriter.AppendLine(sb, flag ? "y" : "n");
VtankWriter.AppendLine(sb, Rows.Count.ToString(CultureInfo.InvariantCulture));
foreach (VtankRow row in Rows)
row.WriteTo(sb);
}
}
/// <summary>The whole <c>y</c> database: an ordered set of named tables.</summary>
internal sealed class VtankDatabase
{
public List<(string Name, VtankTable Table)> Tables { get; } = [];
public VtankTable? Find(string name) => Tables
.Where(entry => entry.Name.Equals(name, StringComparison.Ordinal))
.Select(static entry => entry.Table)
.FirstOrDefault();
public static VtankDatabase Parse(string text)
{
var cursor = new VtankLineCursor(text);
var database = new VtankDatabase();
int tableCount = cursor.ReadInt();
for (int i = 0; i < tableCount; i++)
{
string name = cursor.ReadLine();
VtankTable table = VtankTable.Read(cursor);
database.Tables.Add((name, table));
}
return database;
}
/// <summary>
/// VTank's own <c>y</c> class holds its tables in a
/// <c>SortedDictionary&lt;string, bd&gt;</c> (item J, slice-1 fix
/// round), so a real <c>.usd</c>/<c>.ast</c> always emits its tables in
/// table-name order — confirmed against the committed
/// <c>defaultsettings.usd</c> fixture, whose first several tables
/// (AntiExtraBuffSpells, AssistItems, BuffedItems, ExtraBuffSpells,
/// GemFoodItems, …) are already alphabetical. This port's own
/// <see cref="Tables"/> is an insertion-ordered list rather than a
/// sorted structure, so every committed real fixture happens to
/// round-trip in order today purely because it was ALREADY sorted the
/// last time real VTank wrote it — this explicit sort on render makes
/// that invariant a property of the writer itself rather than an
/// accident of whatever order the source data happened to arrive in
/// (no observable behavior change for any fixture today; it only
/// matters the day a future code path adds or reorders a table).
///
/// Round 3 item 12: <see cref="StringComparer.Ordinal"/> is used here
/// as a stand-in for .NET Framework's <c>SortedDictionary&lt;string, bd&gt;</c>
/// default key order — that equivalence is confirmed only for the
/// plain-ASCII, single-case table names VTank itself ships
/// (AntiExtraBuffSpells, MyMonsters, Settings, …), not as a general
/// claim that ordinal and .NET's culture-aware default string
/// comparer agree for every possible string; a hypothetical future
/// table name using non-ASCII characters or mixed-width comparisons
/// could sort differently under the two. No behavior change is
/// implied or made by this note.
/// </summary>
public string Render()
{
var sb = new StringBuilder();
var ordered = Tables
.OrderBy(static entry => entry.Name, StringComparer.Ordinal)
.ToArray();
VtankWriter.AppendLine(sb, ordered.Length.ToString(CultureInfo.InvariantCulture));
foreach ((string name, VtankTable table) in ordered)
{
VtankWriter.AppendLine(sb, name);
table.WriteTo(sb);
}
return sb.ToString();
}
}
/// <summary>Reads one <see cref="VtankCell"/>, recursing into nested tables and blobs.</summary>
internal static class VtankDatabaseReader
{
public static VtankCell ReadCell(VtankLineCursor cursor)
{
string tag = cursor.ReadLine();
switch (tag)
{
case "TABLE":
return VtankCell.NestedTable(VtankTable.Read(cursor));
case "ba":
int length = cursor.ReadInt();
return new VtankCellBuilder(tag) { BlobText = cursor.ReadBlob(length) }
.Build();
case "d" or "i" or "u" or "f" or "s" or "b":
return new VtankCellBuilder(tag) { ScalarText = cursor.ReadLine() }.Build();
default:
// Unrecognized/void tag (refs/vtank/decompiled/y.cs:28-41
// only ever registers TABLE and ba as named custom types):
// VTank's own reader (gy.cs:50-55) consumes ONLY the tag
// line here — no value line follows.
return new VtankCellBuilder(tag).Build();
}
}
// Small builder so VtankCell's constructor can stay private to the file
// (its public factory methods are the intended typed-construction path)
// while the reader still needs to synthesize an arbitrary/unknown tag.
private readonly struct VtankCellBuilder(string tag)
{
public string? ScalarText { get; init; }
public string? BlobText { get; init; }
public VtankCell Build() => VtankCellFactory.Create(tag, ScalarText, BlobText);
}
}
/// <summary>Internal factory seam so the reader can build cells with an arbitrary tag.</summary>
internal static class VtankCellFactory
{
public static VtankCell Create(string tag, string? scalarText, string? blobText) => new()
{
Tag = tag,
ScalarText = scalarText,
BlobText = blobText,
};
}
/// <summary>
/// Character-oriented cursor over the exact (unnormalized) text of a
/// <c>.usd</c>/<c>.ast</c> file. VTank's own reader is a strict token stream
/// (<c>StreamReader.ReadLine()</c>/<c>TextReader.Read(char[], int, int)</c>
/// calls in a fixed order) — there is no way to resynchronize after a parse
/// error, so every read here throws immediately with a file:line-shaped
/// message on the first unexpected token.
///
/// The cursor deliberately keeps the ORIGINAL string, never pre-splitting on
/// <c>'\n'</c>: a <c>ba</c> blob (<see cref="ReadBlob"/>) reads exactly N raw
/// characters straight out of that string, embedded <c>\r</c>/<c>\n</c>
/// included, matching <c>f6.cs:10-17</c>'s <c>TextReader.Read(array, 0, num)</c>
/// exactly — a CRLF-terminated line INSIDE a blob counts as 2 characters
/// toward that length, same as it would for VTank itself.
/// </summary>
internal sealed class VtankLineCursor
{
private readonly string _text;
private int _position;
private int _lineNumber = 1;
public VtankLineCursor(string text)
{
ArgumentNullException.ThrowIfNull(text);
_text = text;
}
public int LineNumber => _lineNumber;
public string ReadLine()
{
if (_position >= _text.Length)
{
throw new FormatException(
$"line {LineNumber}: unexpected end of file (expected another line).");
}
int newlineIndex = _text.IndexOf('\n', _position);
string line;
if (newlineIndex < 0)
{
line = _text[_position..];
_position = _text.Length;
}
else
{
line = _text[_position..newlineIndex];
_position = newlineIndex + 1;
}
if (line.EndsWith('\r'))
line = line[..^1];
_lineNumber++;
return line;
}
public int ReadInt()
{
string line = ReadLine();
if (!int.TryParse(line, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value))
{
throw new FormatException(
$"line {LineNumber - 1}: expected an integer, got '{line}'.");
}
return value;
}
/// <summary>
/// Reads a <c>ba</c> blob's exact character count straight out of the
/// underlying text with no line-oriented interpretation whatsoever
/// (<c>f6.cs:10-17</c>: <c>TextReader.Read(array, 0, num)</c> starting
/// immediately after the length line's terminator) — embedded
/// <c>\r</c>/<c>\n</c> characters are counted and returned verbatim, not
/// folded or re-joined.
/// </summary>
public string ReadBlob(int length)
{
if (length <= 0)
return string.Empty;
if (_position + length > _text.Length)
{
throw new FormatException(
$"line {LineNumber}: blob of {length} characters exceeds remaining input.");
}
string blob = _text.Substring(_position, length);
_position += length;
foreach (char c in blob)
{
if (c == '\n')
_lineNumber++;
}
return blob;
}
}

View file

@ -136,6 +136,9 @@
onclick="{CreateProfile}" />
<button x="448" y="36" w="112" h="23" text="Clear profile!"
onclick="{ClearProfile}" />
<button x="568" y="36" w="76" h="23" text="Delete"
onclick="{DeleteProfile}"
tooltip="Delete the selected named profile's real .usd file. Not available for By char." />
<toggle x="580" y="4" w="120" h="20" text="Mine only"
checked="{MineOnlyEnabled}" onclick="{ToggleMineOnly}" />
@ -155,6 +158,9 @@
onclick="{CreateRouteProfile}" />
<button x="560" y="64" w="84" h="22" text="Clear route"
onclick="{ClearRouteProfile}" />
<button x="650" y="64" w="70" h="22" text="Delete"
onclick="{DeleteRouteProfile}"
tooltip="Delete the selected named route's real .af file. Not available for By char." />
<toggle x="4" y="92" w="150" h="20" text="Enable Looting"
checked="{LootEnabled}" onclick="{ToggleLooting}" />
<menu x="156" y="90" w="116" h="22" items="{LootProfileNames}"
@ -454,9 +460,12 @@
onclick="{CopyMetaProfile}" />
<button x="378" y="0" w="48" h="21" text="Clear"
onclick="{ClearMetaProfile}" />
<toggle x="446" y="2" w="112" h="20" text="Enable Meta"
<button x="428" y="0" w="66" h="21" text="Delete"
onclick="{DeleteMetaProfile}"
tooltip="Delete the selected named Meta profile's real .af file. Not available for By char." />
<toggle x="500" y="2" w="112" h="20" text="Enable Meta"
checked="{MetaEnabled}" onclick="{ToggleMeta}" />
<label x="566" y="4" text="{MetaStateText}" color="#FFE8DEC3" />
<label x="620" y="4" text="{MetaStateText}" color="#FFE8DEC3" />
<list x="4" y="26" w="776" h="68" rowheight="17"
items="{MetaRows}" selected="{SelectedMetaRuleIndex}"
@ -530,6 +539,9 @@
onclick="{CopyLootProfile}" />
<button x="494" y="0" w="58" h="21" text="Clear"
onclick="{ClearLootProfile}" />
<button x="560" y="0" w="64" h="21" text="Delete"
onclick="{DeleteLootProfile}"
tooltip="Delete the selected named loot profile's real .utl file. Not available for By char." />
<button x="684" y="0" w="92" h="21" text="Back"
onclick="{CloseLootEditor}" />
<list x="4" y="24" w="520" h="82" rowheight="17"