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:
parent
1b81836747
commit
9a7e7c4ae8
56 changed files with 54687 additions and 3 deletions
1153
src/AcDream.Plugins.MossTank/MetafSerializer.cs
Normal file
1153
src/AcDream.Plugins.MossTank/MetafSerializer.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
547
src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs
Normal file
547
src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
354
src/AcDream.Plugins.MossTank/VtankUsdDocument.cs
Normal file
354
src/AcDream.Plugins.MossTank/VtankUsdDocument.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue