feat(vt): A1 .usd engine + A3 metaf .af engine, real VTank/metas fixtures

Campaign VT slice 1 Part A. Two new file-format ports, both against real
committed fixtures (owner's own VTank profiles + the metas repo's af/met/nav
corpus), not synthetic data.

A1 — VtankUsdDocument.cs is a from-scratch port of VTank's gy/bd/cw/y
self-describing text-database grammar (docs/research/vtank-kb/01-settings-
and-profiles.md section 1), preserving every table/row/cell it doesn't
understand byte-for-byte. VtankSettingsProfileSerializer.cs maps all 137
Settings rows (VtankOptionCatalog.Names) onto CombatSettings/BuffSettings/
VitalSettings/InventorySettings/NavigationSettings using the exact unit
transforms already verified in MossTankPanel.SetMetaOption/GetMetaOption
(the *240/100 scaling, the TargetSelectMethod +1/-1 offset, etc.) — cited as
the oracle rather than re-derived, since a file-format serializer must not
depend on a live session. RechargeHandlerSet's real 5-column nested table
(26 rows, not the 24 the KB doc estimated) now drives
VitalRechargePlanner.Handlers via a new RechargeHandlerRow, replacing the
hand-typed default replica; the parsed defaults corrected one real
discrepancy (magic-mode Health<=15% never included Kit) while confirming
the rest matched. BuffProfileDocument gained the 10 fields it was silently
dropping (KB doc 01 section 5 gap 2). Save() only rewrites a Settings row
when the live value differs from what was parsed (a tolerant numeric
compare, not exact-text), so an untouched profile round-trips byte-for-byte
even where VTank's own older double formatting differs from .NET 10's.

A3 — MetafSerializer.cs ports metaf's STATE:/IF:/DO:/NAV: text grammar
(github.com/JJEII/metaf, metaf_monolithic.py, GPLv3 — grammar read and
cited by line, never copied) onto the existing Meta.cs/Navigation.cs
models. All 28 conditions, 16 actions, and 10 nav-node types; strict
All/Any child-depth nesting; Not's real same-line (not depth+1) operand
placement, discovered by testing against real fixtures after an initial
wrong read of the collapsed IF:/DO: layout; EmbedNav's separate NAV: block
with tag cross-referencing, including re-synthesizing the "uTank2 NAV 1.2"
blob MetaEngine already expects. The jump-charge 2000ms clamp (KB doc 06
row 5) is applied at .af load. All four contract proofs pass: every real
.af parses; parse-write-parse is model-identical; our binary-.met and
.nav import matches the same content loaded from metaf's own .af
conversion; the writer's output is byte-identical (after comment-stripping)
to metaf's own canonical emission for 5 real fixtures, once two real metaf
quirks were matched (ADestroyView's literal double space; the
GenerateUniqueNavTag "nav{n}__name" tag scheme) and the two files with a
pre-existing single-Position-field limitation in RouteWaypoint (ptl/tlk
inside an embedded nav) were excluded with a documented reason.

Also: the .utl BuffedInt/BuffedDouble base-key-exists gate (KB doc 05
section 2.2 / gap 4) — a spell bonus no longer applies to a value the item
never had a base key for.

Fixtures: the owner's own defaultsettings.usd/owner-{a,b,c}.usd+.ast,
4 .utl loot profiles, and a hand-picked set of real metas-repo .af/.met/.nav
files chosen by grepping metas/af for keyword coverage (every condition,
action, and nav-node type actually present in that corpus; GetOpt/flw/jmp
appear in none of it, so those three are covered by one small hand-authored
fixture instead, called out in its own test). Two met/nav pairs were
swapped for a fresh selection after their timestamps proved the shipped
.af had drifted from a since-re-recorded .nav (a real data-consistency
issue in the source repo, not a port bug).

Shown to fail by: VtankSettingsProfileSerializerTests (temporarily reverting
the ValuesEqual numeric-tolerance compare made the untouched-round-trip
test fail with a real text diff); MetafSerializerTests (every proof
genuinely failed against the real fixtures until the Not/nav-blank-skip/
pau-scaling/EmbedNav-tag bugs below were fixed, confirmed failing at each
step during authoring); VtankLootRequirementEvaluatorTests (confirmed via
`git stash` on VtankLootRequirementEvaluator.cs that
BuffedIntRequirementDoesNotApplyBonusWhenBaseKeyIsAbsent fails without the
gate).

Deviation: added -text entries to the root .gitattributes, scoped to only
the new tests/AcDream.Plugins.MossTank.Tests/Fixtures/vtank/** paths, so
these CRLF-exact fixtures survive a checkout on any OS/core.autocrlf
setting instead of being silently normalized — flagged per the contract's
"stay inside the plugin/tests trees" rule since this one line is outside
both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 20:52:51 +02:00
parent 1b81836747
commit 9a7e7c4ae8
56 changed files with 54687 additions and 3 deletions

File diff suppressed because it is too large Load diff

View file

@ -819,6 +819,21 @@ internal sealed class MossTankProfileStore
public bool BuffOther { get; set; }
public bool BuffTrainedSkillsOnly { get; set; } = true;
// VTank-catalog fields SetMetaOption already writes at runtime
// (MossTankPanel.cs:2942-2973,3297-3317) but this document never
// captured, so they silently reset to class defaults on the next
// save/load round-trip (KB doc 01 section 5, gap #2).
public double BuffCastRecastSeconds { get; set; } = 30d;
public double BuffCastRecastResetSeconds { get; set; } = 30d;
public bool FastCastBuffs { get; set; }
public bool RandomHelperBuffs { get; set; }
public double RandomHelperIntervalSeconds { get; set; } = 5d;
public string BlacklistedSpellComponents { get; set; } = string.Empty;
public string ProtectionElements { get; set; } = "ALFCBPS";
public int ProtectionProfileMode { get; set; } = 2;
public string BaneElements { get; set; } = "ALFCBPS";
public int BaneProfileMode { get; set; } = 2;
public static BuffProfileDocument Capture(BuffSettings value) => new()
{
Enabled = value.Enabled,
@ -833,6 +848,16 @@ internal sealed class MossTankProfileStore
BuffRegeneration = value.BuffRegeneration,
BuffOther = value.BuffOther,
BuffTrainedSkillsOnly = value.BuffTrainedSkillsOnly,
BuffCastRecastSeconds = value.BuffCastRecastSeconds,
BuffCastRecastResetSeconds = value.BuffCastRecastResetSeconds,
FastCastBuffs = value.FastCastBuffs,
RandomHelperBuffs = value.RandomHelperBuffs,
RandomHelperIntervalSeconds = value.RandomHelperIntervalSeconds,
BlacklistedSpellComponents = value.BlacklistedSpellComponents,
ProtectionElements = value.ProtectionElements,
ProtectionProfileMode = value.ProtectionProfileMode,
BaneElements = value.BaneElements,
BaneProfileMode = value.BaneProfileMode,
};
public void Apply(BuffSettings value)
@ -852,6 +877,19 @@ internal sealed class MossTankProfileStore
value.BuffRegeneration = BuffRegeneration;
value.BuffOther = BuffOther;
value.BuffTrainedSkillsOnly = BuffTrainedSkillsOnly;
value.BuffCastRecastSeconds = Math.Clamp(
BuffCastRecastSeconds, 0d, 3600d);
value.BuffCastRecastResetSeconds = Math.Clamp(
BuffCastRecastResetSeconds, 0d, 3600d);
value.FastCastBuffs = FastCastBuffs;
value.RandomHelperBuffs = RandomHelperBuffs;
value.RandomHelperIntervalSeconds = Math.Clamp(
RandomHelperIntervalSeconds, 0.25d, 3600d);
value.BlacklistedSpellComponents = BlacklistedSpellComponents ?? string.Empty;
value.ProtectionElements = ProtectionElements ?? "ALFCBPS";
value.ProtectionProfileMode = Math.Clamp(ProtectionProfileMode, 1, 8);
value.BaneElements = BaneElements ?? "ALFCBPS";
value.BaneProfileMode = Math.Clamp(BaneProfileMode, 1, 8);
}
}

View file

@ -46,6 +46,14 @@ 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), parsed from a
/// loaded <c>.usd</c> profile by <see cref="VtankSettingsProfileSerializer"/>.
/// Empty until a profile with this table is loaded; <see cref="VitalRechargePlanner"/>
/// falls back to its hand-ported default ordering when this is empty.
/// </summary>
public IReadOnlyList<RechargeHandlerRow> RechargeHandlerRows { get; set; } = [];
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();
@ -209,8 +249,20 @@ internal static class VitalRechargePlanner
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 +340,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,

View file

@ -418,6 +418,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 +444,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)
@ -447,6 +458,37 @@ internal static class VtankLootRequirementEvaluator
return value;
}
/// <summary>
/// Mirrors <see cref="IntValue"/>'s switch: every named-field key is a
/// concrete <see cref="PluginInventoryItem"/> property and always
/// "exists"; anything else falls through to the raw property-bag
/// lookup, where existence means the bag actually carries that key.
/// </summary>
private static bool IntKeyExists(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) => key switch
{
5 or 19 or 105 or 107 or 108 or 131
or VtankIntBase + 0 or VtankIntBase + 2 or VtankIntBase + 4
or VtankIntBase + 5 or VtankIntBase + 6 or VtankIntBase + 7
or VtankIntBase + 8 or VtankIntBase + 9 or VtankIntBase + 10
or VtankIntBase + 11 or VtankIntBase + 14 or VtankIntBase + 18
or VtankIntBase + 23 or VtankIntBase + 31 or VtankIntBase + 32
or VtankIntBase + 33 or VtankIntBase + 34 or VtankIntBase + 38 => true,
_ => properties.Ints?.ContainsKey(key) == true,
};
private static bool DoubleKeyExists(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) => key switch
{
VtankDoubleBase + 9 or VtankDoubleBase + 11
or VtankDoubleBase + 12 or VtankDoubleBase + 14 => true,
_ => properties.Floats?.ContainsKey(key) == true,
};
private static double MinimumDamage(in PluginInventoryItem item) =>
item.Damage - (item.DamageVariance * item.Damage);

View file

@ -0,0 +1,547 @@
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 (Settings table only) seeded from <paramref name="source"/>.</summary>
public static VtankDatabase CreateNew(AllSettings source)
{
var table = new VtankTable();
table.ColumnNames.AddRange(["Setting", "Value", "Description", "SettingType"]);
table.IndexFlags.AddRange([true, false, false, false]);
foreach (string name in VtankOptionCatalog.Names)
{
VtankCell value = Capture(name, source) ?? DefaultCell(name);
var row = new VtankRow();
row.Cells.Add(VtankCell.String(name));
row.Cells.Add(value);
row.Cells.Add(VtankCell.String(string.Empty));
row.Cells.Add(VtankCell.Int(1));
table.Rows.Add(row);
}
var database = new VtankDatabase();
database.Tables.Add((SettingsTable, table));
return database;
}
private static VtankCell DefaultCell(string name)
{
MonsterValue value = VtankOptionCatalog.Default(name);
return value.Kind switch
{
MonsterValueKind.Boolean => VtankCell.Bool(value.Boolean),
MonsterValueKind.Text => VtankCell.String(value.Text),
_ => VtankCell.Double(value.Number),
};
}
private static readonly HashSet<string> NumericTags = ["d", "i", "u", "f"];
/// <summary>
/// Compares by parsed VALUE, not raw tag: a setting's real VTank type
/// (e.g. the Recharge-* percentages are stored as plain <c>i</c> ints
/// in <c>defaultsettings.usd</c>, not <c>d</c> doubles) does not need to
/// be re-derived exactly by <see cref="Capture"/> for an untouched
/// value to round-trip byte-for-byte — any numeric tag comparing equal
/// means <see cref="Save"/> keeps the ORIGINAL cell object untouched.
/// </summary>
private static bool ValuesEqual(VtankCell a, VtankCell b)
{
if (NumericTags.Contains(a.Tag) && NumericTags.Contains(b.Tag))
{
double left = a.AsDouble();
double right = b.AsDouble();
// Several distance settings pass through a float (e.g.
// CombatSettings.MaximumRange) on their way back to a double
// .usd cell; VTank's own (older, 15-16 significant digit)
// double formatting and .NET 10's shortest-round-trippable
// formatting can render the SAME conceptual value with a
// last-digit difference that survives the float<->double
// round trip as a genuinely different double bit pattern.
// A tiny relative tolerance treats that as unchanged so Save()
// keeps VTank's own original cell text rather than needlessly
// reformatting it.
double tolerance = Math.Max(Math.Abs(left), Math.Abs(right)) * 1e-6 + 1e-9;
return Math.Abs(left - right) <= tolerance;
}
if (a.Tag != b.Tag)
return false;
return a.Tag switch
{
"b" => a.AsBool() == b.AsBool(),
"s" => a.AsString() == b.AsString(),
_ => ReferenceEquals(a, b),
};
}
// ------------------------------------------------------------------
// Apply: .usd cell -> live setting.
// ------------------------------------------------------------------
private 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 = (float)(cell.AsDouble() * 240d); break;
case "attackminimumdistance": c.MinimumRange = (float)(cell.AsDouble() * 240d); break;
case "approachdistance": c.ApproachDistance = (float)(cell.AsDouble() * 240d); break;
case "ringdistance": c.RingDistance = (float)(cell.AsDouble() * 240d); break;
case "corpseapproachrange-max": i.Loot.CorpseApproachRange = (float)(cell.AsDouble() * 240d); break;
case "corpseapproachrange-min": i.Loot.CorpseMinimumApproachRange = (float)(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 = (float)(cell.AsDouble() * 240d); break;
case "helperdistancestam": v.HelperStaminaDistance = (float)(cell.AsDouble() * 240d); break;
case "helperdistancemana": v.HelperManaDistance = (float)(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 = cell.AsInt() != 0; break;
case "arcrange": c.ArcRange = (float)(cell.AsDouble() * 240d); break;
case "targetselectmethod": c.SelectionMethod = (TargetSelectionMethod)(cell.AsInt() - 1); break;
case "targetselectanglerange": c.TargetSelectAngleRange = (float)(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 = (float)(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.AsFloat(); break;
case "collisionstepdistance": c.CollisionStepDistance = cell.AsFloat(); break;
case "showcollisiondebug": c.ShowCollisionDebug = cell.AsBool(); break;
case "maximumcollisioncheckspertick": c.MaximumCollisionChecksPerTick = cell.AsInt(); break;
case "spellrangefudge": c.SpellRangeFudge = cell.AsFloat(); 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" => VtankCell.Int(c.HuntSkillExcessOverDifficulty),
"spelldiffexcessthreshold-buff" => VtankCell.Int(b.SkillExcessOverDifficulty),
"arrowheadfletchdiffexcessthreshold" => VtankCell.Int(i.ArrowheadFletchDifficultyExcess),
"recharge-norm-hitp" => VtankCell.Double(v.NormalHealth * 100d),
"recharge-norm-stam" => VtankCell.Double(v.NormalStamina * 100d),
"recharge-norm-mana" => VtankCell.Double(v.NormalMana * 100d),
"recharge-notarg-hitp" => VtankCell.Double(v.NoTargetHealth * 100d),
"recharge-notarg-stam" => VtankCell.Double(v.NoTargetStamina * 100d),
"recharge-notarg-mana" => VtankCell.Double(v.NoTargetMana * 100d),
"recharge-helper-hitp" => VtankCell.Double(v.HelperHealth * 100d),
"recharge-helper-stam" => VtankCell.Double(v.HelperStamina * 100d),
"recharge-helper-mana" => VtankCell.Double(v.HelperMana * 100d),
"dohelp" => VtankCell.Bool(v.HelpOthers),
"attackdistance" => VtankCell.Double(c.MaximumRange / 240d),
"attackminimumdistance" => VtankCell.Double(c.MinimumRange / 240d),
"approachdistance" => VtankCell.Double(c.ApproachDistance / 240d),
"ringdistance" => VtankCell.Double(c.RingDistance / 240d),
"corpseapproachrange-max" => VtankCell.Double(i.Loot.CorpseApproachRange / 240d),
"corpseapproachrange-min" => VtankCell.Double(i.Loot.CorpseMinimumApproachRange / 240d),
"navclosestoprange" => VtankCell.Double(n.MinimumDistanceMeters / 240d),
"navfarstoprange" => VtankCell.Double(n.MaximumDistanceMeters / 240d),
"useportaldistance" => VtankCell.Double(n.PortalUseDistanceMeters / 240d),
"helperdistancehitp" => VtankCell.Double(v.HelperHealthDistance / 240d),
"helperdistancestam" => VtankCell.Double(v.HelperStaminaDistance / 240d),
"helperdistancemana" => VtankCell.Double(v.HelperManaDistance / 240d),
"minimumringtargets" => VtankCell.Int(c.MinimumRingTargets),
"defaultmeleeattackheight" => VtankCell.Int((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" => VtankCell.Double(b.RandomHelperIntervalSeconds),
"idlepeacemode" => VtankCell.Bool(c.IdlePeaceMode),
"targetlock" => VtankCell.Bool(c.TargetLock),
"stopmacroondeath" => VtankCell.Bool(c.StopMacroOnDeath),
"usearcs" => VtankCell.Int(c.UseArcs ? 3 : 1),
"arcrange" => VtankCell.Double(c.ArcRange / 240d),
"targetselectmethod" => VtankCell.Int((int)c.SelectionMethod + 1),
"targetselectanglerange" => VtankCell.Double(c.TargetSelectAngleRange / 240d),
"idlebufftopoff" => VtankCell.Bool(b.IdleBuffTopoff),
"idlebufftopofftimeseconds" => VtankCell.Double(b.IdleBuffTopoffSeconds),
"rebufftimeremainingseconds" => VtankCell.Double(b.RebuffWhenUnderSeconds),
"refillwornmana" => VtankCell.Bool(i.RefillWornMana),
"refillwornmana-item-manapercent" => VtankCell.Int(i.RefillWornManaPercent),
"buffprofile-prots" => VtankCell.String(b.ProtectionElements),
"buffprofile-banes" => VtankCell.String(b.BaneElements),
"buffprofile_prots" => VtankCell.Int(b.ProtectionProfileMode),
"buffprofile_banes" => VtankCell.Int(b.BaneProfileMode),
"debuffeachfirst" => VtankCell.Int((int)c.DebuffEachFirst),
"autoattackpower" => VtankCell.Bool(c.AutoAttackPower),
"lootpriorityboost" => VtankCell.Bool(i.Loot.PriorityBoost),
"corpsecachetimeoutminutes" => VtankCell.Double(i.Loot.CorpseCacheTimeoutMinutes),
"corpseitemappearancetimeoutseconds" => VtankCell.Double(i.Loot.CorpseItemAppearanceTimeoutSeconds),
"corpseitemidtimeoutseconds" => VtankCell.Double(i.Loot.CorpseItemIdentifyTimeoutSeconds),
"debuffselectionmethod" => VtankCell.Int((int)c.DebuffSelectionMethod),
"manastonelootcount" => VtankCell.Int(i.Loot.ManaStoneLootCount),
"manatankminimummana" => VtankCell.Int(i.Loot.ManaTankMinimumMana),
"splitpeas" => VtankCell.Bool(i.SplitPeas),
"spellcompmin-critical" => VtankCell.Int(i.CriticalComponentMinimum),
"spellcompmin-normal" => VtankCell.Int(i.NormalComponentMinimum),
"spellcompmin-idle" => VtankCell.Int(i.IdleComponentMinimum),
"rechargeboosttimeseconds" => VtankCell.Double(v.RechargeBoostTimeSeconds),
"rechargeboostamount" => VtankCell.Int(v.RechargeBoostAmount),
"usespecialammo" => VtankCell.Int(c.UseSpecialAmmo),
"opendoors" => VtankCell.Bool(n.OpenDoors),
"dooridrange" => VtankCell.Double(n.DoorIdentifyRangeMeters / 240d),
"dooropenrange" => VtankCell.Double(n.DoorOpenRangeMeters / 240d),
"doorlockpickdiffexcessthreshold" => VtankCell.Int(n.DoorLockpickExcessThreshold),
"manachargeswhenoff" => VtankCell.Bool(i.ManaChargesWhenOff),
"autofellowmanagement" => VtankCell.Bool(c.AutoFellowManagement),
"minimumhealkitsuccesschance" => VtankCell.Int(v.MinimumHealKitSuccessChance),
"usekitsinmagicmode" => VtankCell.Bool(v.UseKitsInMagicMode),
"staminatohealthmultiplier" => VtankCell.Double(v.StaminaToHealthMultiplier),
"manatohealthmultiplier" => VtankCell.Double(v.ManaToHealthMultiplier),
"navpriorityboost" => VtankCell.Bool(n.Priority),
"deleteghostmonsters" => VtankCell.Bool(c.DeleteGhostMonsters),
"ghostmonsterspellattemptcount" => VtankCell.Int(c.GhostMonsterSpellAttemptCount),
"whoyougonnacall" => VtankCell.Bool(c.WhoYouGonnaCall),
"blacklistmonsterattemptcount" => VtankCell.Int(c.BlacklistMonsterAttemptCount),
"blacklistmonstertimeoutseconds" => VtankCell.Double(c.BlacklistMonsterTimeoutSeconds),
"combinesalvage" => VtankCell.Bool(i.Loot.CombineSalvage),
"lootonlyrarecorpses" => VtankCell.Bool(i.Loot.LootOnlyRareCorpses),
"deleteghostmonstersbyhptracker" => VtankCell.Bool(c.DeleteGhostMonstersByHealthTracker),
"ghostdeletehptrackerseconds" => VtankCell.Double(c.GhostDeleteHealthTrackerSeconds),
"gotopeacemodetousekits" => VtankCell.Bool(v.GoToPeaceModeToUseKits),
"userecklessness" => VtankCell.Bool(c.UseRecklessness),
"debuffprecastseconds" => VtankCell.Double(c.DebuffPrecastSeconds),
"clearlevelboostflagoncast" => VtankCell.Bool(v.ClearLevelBoostFlagOnCast),
"idlecraftcount_healthkits" => VtankCell.Int(i.IdleHealthKitCount),
"idlecraftcount_stamkits" => VtankCell.Int(i.IdleStaminaKitCount),
"idlecraftcount_manakits" => VtankCell.Int(i.IdleManaKitCount),
"idlecraftcount_healthfood" => VtankCell.Int(i.IdleHealthFoodCount),
"idlecraftcount_stamfood" => VtankCell.Int(i.IdleStaminaFoodCount),
"idlecraftcount_manafood" => VtankCell.Int(i.IdleManaFoodCount),
"buffcastrecast_seconds" => VtankCell.Double(b.BuffCastRecastSeconds),
"buffcastrecastreset_seconds" => VtankCell.Double(b.BuffCastRecastResetSeconds),
"blacklistedspellcomps" => VtankCell.String(b.BlacklistedSpellComponents),
"droptopeacemoderetrycount" => VtankCell.Int(v.DropToPeaceModeRetryCount),
"followaroundcorners" => VtankCell.Bool(n.FollowAroundCorners),
"blacklistcorpseopenattemptcount" => VtankCell.Int(i.Loot.BlacklistCorpseOpenAttemptCount),
"blacklistcorpseopentimeoutseconds" => VtankCell.Double(i.Loot.BlacklistCorpseOpenTimeoutSeconds),
"summonpets" => VtankCell.Bool(c.SummonPets),
"petrangemode" => VtankCell.Int((int)c.PetRangeMode),
"petcustomrange" => VtankCell.Double(c.PetCustomRange / 240d),
"petrefillcount-idle" => VtankCell.Int(c.PetRefillCountIdle),
"petrefillcount-normal" => VtankCell.Int(c.PetRefillCountNormal),
"corpseopentimeoutseconds" => VtankCell.Double(i.Loot.CorpseOpenTimeoutSeconds),
"petmonsterdensity" => VtankCell.Int(c.PetMonsterDensity),
"corpselootitemmaxattempts" => VtankCell.Int(i.Loot.CorpseLootItemMaxAttempts),
"fastcastbuffs" => VtankCell.Bool(b.FastCastBuffs),
"usebreakableturnto" => VtankCell.Bool(c.UseBreakableTurnTo),
"useprojectileawareness" => VtankCell.Bool(c.UseProjectileAwareness),
"collisionprojectileradius" => VtankCell.Float(c.CollisionProjectileRadius),
"collisionstepdistance" => VtankCell.Float(c.CollisionStepDistance),
"showcollisiondebug" => VtankCell.Bool(c.ShowCollisionDebug),
"maximumcollisioncheckspertick" => VtankCell.Int(c.MaximumCollisionChecksPerTick),
"spellrangefudge" => VtankCell.Float(c.SpellRangeFudge),
"buffwithuntrained-item" => VtankCell.Int(b.BuffWithUntrainedItemSkill),
"buffwithuntrained-creature" => VtankCell.Int(b.BuffWithUntrainedCreatureSkill),
"buffwithuntrained-life" => VtankCell.Int(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;
}
internal static VtankTable RenderRechargeHandlerSet(IReadOnlyList<RechargeHandlerRow> rows)
{
var table = new VtankTable();
table.ColumnNames.AddRange(["Vital", "HandlerString", "MinPercent", "MaxPercent", "Stance"]);
table.IndexFlags.AddRange([false, false, false, false, false]);
foreach (RechargeHandlerRow row in rows)
{
var vtankRow = new VtankRow();
vtankRow.Cells.Add(VtankCell.Int(row.Vital));
vtankRow.Cells.Add(VtankCell.String(row.HandlerString));
vtankRow.Cells.Add(VtankCell.Int(row.MinPercent));
vtankRow.Cells.Add(VtankCell.Int(row.MaxPercent));
vtankRow.Cells.Add(VtankCell.Int(row.Stance));
table.Rows.Add(vtankRow);
}
return table;
}
}

View file

@ -0,0 +1,354 @@
using System.Globalization;
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). Any other tag is preserved
/// verbatim as an opaque single-line scalar so an unrecognized custom type
/// still round-trips byte-for-byte.
/// </summary>
internal sealed class VtankCell
{
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",
ScalarText = value,
};
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 build does: .NET's
/// invariant-culture round-trippable <c>ToString()</c>. Verified against
/// real fixture values (e.g. 5/240 renders as
/// <c>0.0208333333333333</c>, matching <c>defaultsettings.usd</c>).
/// </summary>
internal static string FormatDouble(double value) =>
value.ToString(CultureInfo.InvariantCulture);
internal void WriteTo(List<string> lines)
{
lines.Add(Tag);
switch (Tag)
{
case "TABLE":
Table!.WriteTo(lines);
break;
case "ba":
string blob = BlobText ?? string.Empty;
lines.Add(blob.Length.ToString(CultureInfo.InvariantCulture));
AppendBlobLines(lines, blob);
break;
default:
lines.Add(ScalarText ?? string.Empty);
break;
}
}
// "ba" blobs are a raw character count, not a line count: the blob may
// contain embedded newlines. FileLines (this reader) is line-oriented,
// so a blob is folded back into whole lines for storage and the reader
// re-joins with the writer's own newline so the character count is
// reproduced exactly on save.
private static void AppendBlobLines(List<string> lines, string blob)
{
foreach (string part in blob.Split('\n'))
lines.Add(part.EndsWith('\r') ? part[..^1] : part);
}
}
internal sealed class VtankRow
{
public List<VtankCell> Cells { get; } = [];
internal void WriteTo(List<string> lines)
{
foreach (VtankCell cell in Cells)
cell.WriteTo(lines);
}
}
/// <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(List<string> lines)
{
lines.Add(ColumnNames.Count.ToString(CultureInfo.InvariantCulture));
foreach (string column in ColumnNames)
lines.Add(column);
foreach (bool flag in IndexFlags)
lines.Add(flag ? "y" : "n");
lines.Add(Rows.Count.ToString(CultureInfo.InvariantCulture));
foreach (VtankRow row in Rows)
row.WriteTo(lines);
}
}
/// <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;
}
public string Render()
{
var lines = new List<string>
{
Tables.Count.ToString(CultureInfo.InvariantCulture),
};
foreach ((string name, VtankTable table) in Tables)
{
lines.Add(name);
table.WriteTo(lines);
}
var builder = new StringBuilder();
foreach (string line in lines)
builder.Append(line).Append("\r\n");
return builder.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();
default:
return new VtankCellBuilder(tag) { ScalarText = cursor.ReadLine() }.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>
/// Line cursor over the CRLF-or-LF text of a <c>.usd</c>/<c>.ast</c> file.
/// VTank's own reader is a strict token stream (<c>StreamReader.ReadLine()</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.
/// </summary>
internal sealed class VtankLineCursor
{
private readonly string[] _lines;
private int _index;
public VtankLineCursor(string text)
{
ArgumentNullException.ThrowIfNull(text);
_lines = text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
}
public int LineNumber => _index + 1;
public string ReadLine()
{
if (_index >= _lines.Length)
{
throw new FormatException(
$"line {LineNumber}: unexpected end of file (expected another line).");
}
return _lines[_index++];
}
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, re-joining folded
/// lines with <c>\n</c> until the count is satisfied (the writer folds
/// on <c>\n</c> too, so this round-trips the exact character count VTank
/// itself would report for embedded newlines).
/// </summary>
public string ReadBlob(int length)
{
if (length <= 0)
return string.Empty;
var builder = new StringBuilder(length);
while (builder.Length < length)
{
if (builder.Length > 0)
builder.Append('\n');
builder.Append(ReadLine());
}
return builder.ToString(0, length);
}
}