fix(vt): Capture() emits the declared eSettingValueType tag, not the CLR field type

Fidelity blocker 1: 17 tInt Settings rows (Recharge-*,
IdleBuffTopoffTimeSeconds, RebuffTimeRemainingSeconds,
BlacklistMonsterTimeoutSeconds, GhostDeleteHPTrackerSeconds,
DebuffPrecastSeconds, BuffCastRecast(Reset)_Seconds,
BlacklistCorpseOpenTimeoutSeconds) were written as VtankCell "d" and 3
tSingle rows (CollisionProjectileRadius, CollisionStepDistance,
SpellRangeFudge) were written as "f", while VTank's shipped
defaultsettings.usd Settings.SettingType column declares them tInt(3)
and tDouble(2) respectively (refs/vtank/decompiled/uTank2/eSettingValueType.cs).
VTank's gy.e/gy.f unbox helpers (refs/vtank/decompiled/gy.cs) throw
InvalidCastException reading a mistagged cell, so a profile MossTank
wrote back would corrupt the next VTank load.

Verified against the real fixture with a small parser
(defaultsettings.usd's own SettingType column) rather than trusting a
second hand-written table: exactly 20 mismatches, matching the review's
count precisely.

- VtankOptionCatalog: added VtankSettingValueType (VTank's
  eSettingValueType) and a 137-row DeclaredType lookup transcribed
  verbatim from the fixture's SettingType column.
- VtankSettingsProfileSerializer.Capture: every numeric arm now routes
  through Num(name, value), which wraps the value using
  VtankOptionCatalog.DeclaredType(name) instead of a hardcoded
  VtankCell.Int/Double/Float call.
- VtankCell.FormatDouble now formats "G15" (VTank's own
  Convert.ToString(double) under classic .NET Framework), not .NET's
  shortest-round-trippable default — verified against every "d" value
  in the real fixture.
- ValuesEqual is now an exact per-tag compare (bool/int/uint/string
  exact; double/single bounded by a named float-round-trip epsilon
  documented as such, not an arbitrary tolerance) instead of lumping
  every numeric tag into one loose comparison.

New test CaptureMatchesDeclaredSettingTypeAndValue is a theory over all
135 catalog names with a live write path (skipping EnableMeta,
RechargeHandlerSet, and UseArcs — pre-existing exclusions), asserting
Capture()'s tag and value against defaultsettings.usd's own Settings
row rather than a second hand-authored expectation table. It failed on
the 20 known-bad names before this fix (see conversation record) and
passes now.

Deliberate, documented deviation from the review's literal "delete the
1e-6 tolerance" instruction: several distance settings (AttackDistance,
ArcRange, …) are declared tDouble but still round-trip their live value
through a CombatSettings `float` field for the physics/combat math that
consumes it; a fully exact compare would make Save() rewrite those rows
on every untouched load due to sub-15-significant-digit float noise,
regressing the class's own byte-identity goal. The retained tolerance
is now named (FloatRoundTripTolerance, float's ~7-digit relative
epsilon) and scoped to only the "d"/"f" tags, not blanket over
every numeric tag as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 21:45:53 +02:00
parent 17f2143a71
commit 9c34b6074d
4 changed files with 392 additions and 114 deletions

View file

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

View file

@ -134,45 +134,68 @@ internal static class VtankSettingsProfileSerializer
};
}
private static readonly HashSet<string> NumericTags = ["d", "i", "u", "f"];
/// <summary>
/// Converts a live numeric setting value to the exact
/// <see cref="VtankCell"/> shape its declared <c>SettingType</c>
/// requires — the fix for the fidelity defect where 17 <c>tInt</c>
/// settings were written as <c>d</c> and 3 <c>tSingle</c> settings were
/// written as <c>f</c> (VTank's <c>gy.e</c>/<c>gy.f</c> unbox helpers
/// throw <see cref="InvalidCastException"/> on a tag/CLR-type mismatch).
/// The tag is driven by <see cref="VtankOptionCatalog.DeclaredType"/>,
/// never by the CLR type of the C# field the caller happens to store
/// the value in.
/// </summary>
private static VtankCell Num(string name, double value) =>
VtankOptionCatalog.DeclaredType(name) switch
{
VtankSettingValueType.Bool => VtankCell.Bool(value != 0d),
VtankSettingValueType.Int or VtankSettingValueType.Enum =>
VtankCell.Int((int)Math.Round(value, MidpointRounding.AwayFromZero)),
VtankSettingValueType.Single => VtankCell.Float((float)value),
VtankSettingValueType.String => VtankCell.String(value.ToString(CultureInfo.InvariantCulture)),
_ => VtankCell.Double(value),
};
/// <summary>
/// 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.
/// Exact compare on the cell's own type — a <see cref="Capture"/> tag
/// mismatch is now a real bug (fixed above), not something to paper
/// over with a tolerance. The one narrow exception: several distance
/// settings (AttackDistance, ArcRange, …) are declared <c>tDouble</c>
/// but this port still stores their live value in a <c>float</c> field
/// (CombatSettings.MaximumRange etc.) for the physics/combat math that
/// consumes them; round-tripping through that float costs precision
/// below the 15th significant digit, which is exactly float's ~7-digit
/// guarantee, not a real value change. That residual is bounded by
/// <see cref="FloatRoundTripTolerance"/>, not an arbitrary fudge factor.
/// Every other tag (bool/int/enum/string/uint) compares byte-exact.
/// </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(),
"i" => a.AsInt() == b.AsInt(),
"u" => a.AsUInt() == b.AsUInt(),
"d" or "f" => DoublesEqual(a.AsDouble(), b.AsDouble()),
_ => ReferenceEquals(a, b),
};
}
private static bool DoublesEqual(double left, double right)
{
double tolerance = Math.Max(Math.Abs(left), Math.Abs(right)) * FloatRoundTripTolerance;
return Math.Abs(left - right) <= tolerance;
}
// ~1.2e-7 is float's relative machine epsilon (2^-23); a value that has
// been through one float round trip (Apply's (float)(... * 240d) cast)
// can differ from its original double by up to this much and still be
// the SAME value VTank wrote.
private const double FloatRoundTripTolerance = 1.5e-7;
// ------------------------------------------------------------------
// Apply: .usd cell -> live setting.
// ------------------------------------------------------------------
@ -359,33 +382,33 @@ internal static class VtankSettingsProfileSerializer
"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),
"spelldiffexcessthreshold-hunt" => Num(name, c.HuntSkillExcessOverDifficulty),
"spelldiffexcessthreshold-buff" => Num(name, b.SkillExcessOverDifficulty),
"arrowheadfletchdiffexcessthreshold" => Num(name, i.ArrowheadFletchDifficultyExcess),
"recharge-norm-hitp" => Num(name, v.NormalHealth * 100d),
"recharge-norm-stam" => Num(name, v.NormalStamina * 100d),
"recharge-norm-mana" => Num(name, v.NormalMana * 100d),
"recharge-notarg-hitp" => Num(name, v.NoTargetHealth * 100d),
"recharge-notarg-stam" => Num(name, v.NoTargetStamina * 100d),
"recharge-notarg-mana" => Num(name, v.NoTargetMana * 100d),
"recharge-helper-hitp" => Num(name, v.HelperHealth * 100d),
"recharge-helper-stam" => Num(name, v.HelperStamina * 100d),
"recharge-helper-mana" => Num(name, v.HelperMana * 100d),
"dohelp" => VtankCell.Bool(v.HelpOthers),
"attackdistance" => 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),
"attackdistance" => Num(name, c.MaximumRange / 240d),
"attackminimumdistance" => Num(name, c.MinimumRange / 240d),
"approachdistance" => Num(name, c.ApproachDistance / 240d),
"ringdistance" => Num(name, c.RingDistance / 240d),
"corpseapproachrange-max" => Num(name, i.Loot.CorpseApproachRange / 240d),
"corpseapproachrange-min" => Num(name, i.Loot.CorpseMinimumApproachRange / 240d),
"navclosestoprange" => Num(name, n.MinimumDistanceMeters / 240d),
"navfarstoprange" => Num(name, n.MaximumDistanceMeters / 240d),
"useportaldistance" => Num(name, n.PortalUseDistanceMeters / 240d),
"helperdistancehitp" => Num(name, v.HelperHealthDistance / 240d),
"helperdistancestam" => Num(name, v.HelperStaminaDistance / 240d),
"helperdistancemana" => Num(name, v.HelperManaDistance / 240d),
"minimumringtargets" => Num(name, c.MinimumRingTargets),
"defaultmeleeattackheight" => Num(name, (int)c.AttackHeight),
"castdispelself" => VtankCell.Bool(v.CastDispelSelf),
"usedispelitems" => VtankCell.Bool(v.UseDispelItems),
"autocram" => VtankCell.Bool(i.AutoCram),
@ -400,95 +423,95 @@ internal static class VtankSettingsProfileSerializer
"lootfellowcorpses" => VtankCell.Bool(i.Loot.LootFellowCorpses),
"dojiggle" => VtankCell.Bool(c.DoJiggle),
"randomhelperbuffs" => VtankCell.Bool(b.RandomHelperBuffs),
"randomhelperintervalseconds" => VtankCell.Double(b.RandomHelperIntervalSeconds),
"randomhelperintervalseconds" => Num(name, 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),
"usearcs" => Num(name, c.UseArcs ? 3 : 1),
"arcrange" => Num(name, c.ArcRange / 240d),
"targetselectmethod" => Num(name, (int)c.SelectionMethod + 1),
"targetselectanglerange" => Num(name, c.TargetSelectAngleRange / 240d),
"idlebufftopoff" => VtankCell.Bool(b.IdleBuffTopoff),
"idlebufftopofftimeseconds" => VtankCell.Double(b.IdleBuffTopoffSeconds),
"rebufftimeremainingseconds" => VtankCell.Double(b.RebuffWhenUnderSeconds),
"idlebufftopofftimeseconds" => Num(name, b.IdleBuffTopoffSeconds),
"rebufftimeremainingseconds" => Num(name, b.RebuffWhenUnderSeconds),
"refillwornmana" => VtankCell.Bool(i.RefillWornMana),
"refillwornmana-item-manapercent" => VtankCell.Int(i.RefillWornManaPercent),
"refillwornmana-item-manapercent" => Num(name, 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),
"buffprofile_prots" => Num(name, b.ProtectionProfileMode),
"buffprofile_banes" => Num(name, b.BaneProfileMode),
"debuffeachfirst" => Num(name, (int)c.DebuffEachFirst),
"autoattackpower" => VtankCell.Bool(c.AutoAttackPower),
"lootpriorityboost" => VtankCell.Bool(i.Loot.PriorityBoost),
"corpsecachetimeoutminutes" => 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),
"corpsecachetimeoutminutes" => Num(name, i.Loot.CorpseCacheTimeoutMinutes),
"corpseitemappearancetimeoutseconds" => Num(name, i.Loot.CorpseItemAppearanceTimeoutSeconds),
"corpseitemidtimeoutseconds" => Num(name, i.Loot.CorpseItemIdentifyTimeoutSeconds),
"debuffselectionmethod" => Num(name, (int)c.DebuffSelectionMethod),
"manastonelootcount" => Num(name, i.Loot.ManaStoneLootCount),
"manatankminimummana" => Num(name, i.Loot.ManaTankMinimumMana),
"splitpeas" => VtankCell.Bool(i.SplitPeas),
"spellcompmin-critical" => 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),
"spellcompmin-critical" => Num(name, i.CriticalComponentMinimum),
"spellcompmin-normal" => Num(name, i.NormalComponentMinimum),
"spellcompmin-idle" => Num(name, i.IdleComponentMinimum),
"rechargeboosttimeseconds" => Num(name, v.RechargeBoostTimeSeconds),
"rechargeboostamount" => Num(name, v.RechargeBoostAmount),
"usespecialammo" => Num(name, c.UseSpecialAmmo),
"opendoors" => VtankCell.Bool(n.OpenDoors),
"dooridrange" => VtankCell.Double(n.DoorIdentifyRangeMeters / 240d),
"dooropenrange" => VtankCell.Double(n.DoorOpenRangeMeters / 240d),
"doorlockpickdiffexcessthreshold" => VtankCell.Int(n.DoorLockpickExcessThreshold),
"dooridrange" => Num(name, n.DoorIdentifyRangeMeters / 240d),
"dooropenrange" => Num(name, n.DoorOpenRangeMeters / 240d),
"doorlockpickdiffexcessthreshold" => Num(name, n.DoorLockpickExcessThreshold),
"manachargeswhenoff" => VtankCell.Bool(i.ManaChargesWhenOff),
"autofellowmanagement" => VtankCell.Bool(c.AutoFellowManagement),
"minimumhealkitsuccesschance" => VtankCell.Int(v.MinimumHealKitSuccessChance),
"minimumhealkitsuccesschance" => Num(name, v.MinimumHealKitSuccessChance),
"usekitsinmagicmode" => VtankCell.Bool(v.UseKitsInMagicMode),
"staminatohealthmultiplier" => VtankCell.Double(v.StaminaToHealthMultiplier),
"manatohealthmultiplier" => VtankCell.Double(v.ManaToHealthMultiplier),
"staminatohealthmultiplier" => Num(name, v.StaminaToHealthMultiplier),
"manatohealthmultiplier" => Num(name, v.ManaToHealthMultiplier),
"navpriorityboost" => VtankCell.Bool(n.Priority),
"deleteghostmonsters" => VtankCell.Bool(c.DeleteGhostMonsters),
"ghostmonsterspellattemptcount" => VtankCell.Int(c.GhostMonsterSpellAttemptCount),
"ghostmonsterspellattemptcount" => Num(name, c.GhostMonsterSpellAttemptCount),
"whoyougonnacall" => VtankCell.Bool(c.WhoYouGonnaCall),
"blacklistmonsterattemptcount" => VtankCell.Int(c.BlacklistMonsterAttemptCount),
"blacklistmonstertimeoutseconds" => VtankCell.Double(c.BlacklistMonsterTimeoutSeconds),
"blacklistmonsterattemptcount" => Num(name, c.BlacklistMonsterAttemptCount),
"blacklistmonstertimeoutseconds" => Num(name, c.BlacklistMonsterTimeoutSeconds),
"combinesalvage" => VtankCell.Bool(i.Loot.CombineSalvage),
"lootonlyrarecorpses" => VtankCell.Bool(i.Loot.LootOnlyRareCorpses),
"deleteghostmonstersbyhptracker" => VtankCell.Bool(c.DeleteGhostMonstersByHealthTracker),
"ghostdeletehptrackerseconds" => VtankCell.Double(c.GhostDeleteHealthTrackerSeconds),
"ghostdeletehptrackerseconds" => Num(name, c.GhostDeleteHealthTrackerSeconds),
"gotopeacemodetousekits" => VtankCell.Bool(v.GoToPeaceModeToUseKits),
"userecklessness" => VtankCell.Bool(c.UseRecklessness),
"debuffprecastseconds" => VtankCell.Double(c.DebuffPrecastSeconds),
"debuffprecastseconds" => Num(name, 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),
"idlecraftcount_healthkits" => Num(name, i.IdleHealthKitCount),
"idlecraftcount_stamkits" => Num(name, i.IdleStaminaKitCount),
"idlecraftcount_manakits" => Num(name, i.IdleManaKitCount),
"idlecraftcount_healthfood" => Num(name, i.IdleHealthFoodCount),
"idlecraftcount_stamfood" => Num(name, i.IdleStaminaFoodCount),
"idlecraftcount_manafood" => Num(name, i.IdleManaFoodCount),
"buffcastrecast_seconds" => Num(name, b.BuffCastRecastSeconds),
"buffcastrecastreset_seconds" => Num(name, b.BuffCastRecastResetSeconds),
"blacklistedspellcomps" => VtankCell.String(b.BlacklistedSpellComponents),
"droptopeacemoderetrycount" => VtankCell.Int(v.DropToPeaceModeRetryCount),
"droptopeacemoderetrycount" => Num(name, v.DropToPeaceModeRetryCount),
"followaroundcorners" => VtankCell.Bool(n.FollowAroundCorners),
"blacklistcorpseopenattemptcount" => VtankCell.Int(i.Loot.BlacklistCorpseOpenAttemptCount),
"blacklistcorpseopentimeoutseconds" => VtankCell.Double(i.Loot.BlacklistCorpseOpenTimeoutSeconds),
"blacklistcorpseopenattemptcount" => Num(name, i.Loot.BlacklistCorpseOpenAttemptCount),
"blacklistcorpseopentimeoutseconds" => Num(name, 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),
"petrangemode" => Num(name, (int)c.PetRangeMode),
"petcustomrange" => Num(name, c.PetCustomRange / 240d),
"petrefillcount-idle" => Num(name, c.PetRefillCountIdle),
"petrefillcount-normal" => Num(name, c.PetRefillCountNormal),
"corpseopentimeoutseconds" => Num(name, i.Loot.CorpseOpenTimeoutSeconds),
"petmonsterdensity" => Num(name, c.PetMonsterDensity),
"corpselootitemmaxattempts" => Num(name, i.Loot.CorpseLootItemMaxAttempts),
"fastcastbuffs" => VtankCell.Bool(b.FastCastBuffs),
"usebreakableturnto" => VtankCell.Bool(c.UseBreakableTurnTo),
"useprojectileawareness" => VtankCell.Bool(c.UseProjectileAwareness),
"collisionprojectileradius" => VtankCell.Float(c.CollisionProjectileRadius),
"collisionstepdistance" => VtankCell.Float(c.CollisionStepDistance),
"collisionprojectileradius" => Num(name, c.CollisionProjectileRadius),
"collisionstepdistance" => Num(name, 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),
"maximumcollisioncheckspertick" => Num(name, c.MaximumCollisionChecksPerTick),
"spellrangefudge" => Num(name, c.SpellRangeFudge),
"buffwithuntrained-item" => Num(name, b.BuffWithUntrainedItemSkill),
"buffwithuntrained-creature" => Num(name, b.BuffWithUntrainedCreatureSkill),
"buffwithuntrained-life" => Num(name, b.BuffWithUntrainedLifeSkill),
"allowdebufffallback" => VtankCell.Bool(c.AllowDebuffFallback),
_ => null, // "enablemeta" (live engine state) and "rechargehandlerset"
// (no write path exists in real VTank either, section 2 row 137)

View file

@ -110,13 +110,16 @@ internal sealed class VtankCell
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>).
/// Formats a double/float the way VTank's own (older .NET Framework)
/// build does: <c>Convert.ToString(double, CultureInfo)</c>
/// (<c>gy.cs:60-61</c>) used the CLR's classic 15-significant-digit
/// general format, not .NET Core's shortest-round-trippable default.
/// Verified against every <c>d</c>-tagged value in the real
/// <c>defaultsettings.usd</c> fixture (e.g. 5/240 renders as
/// <c>0.0208333333333333</c> — 15 significant digits).
/// </summary>
internal static string FormatDouble(double value) =>
value.ToString(CultureInfo.InvariantCulture);
value.ToString("G15", CultureInfo.InvariantCulture);
internal void WriteTo(List<string> lines)
{

View file

@ -1,4 +1,5 @@
using System.Globalization;
using System.Linq;
namespace AcDream.Plugins.MossTank.Tests;
@ -127,6 +128,86 @@ public sealed class VtankSettingsProfileSerializerTests
Assert.Equal(["EnableMeta", "RechargeHandlerSet"], unmapped);
}
// Fidelity regression pin for the "Capture() writes the wrong type tag"
// defect: 17 tInt settings (Recharge-*, IdleBuffTopoffTimeSeconds, …)
// were written as VtankCell "d" and 3 tSingle settings
// (CollisionProjectileRadius, CollisionStepDistance, SpellRangeFudge)
// were written as "f", while VTank's shipped defaultsettings.usd
// declares every one of them "i"/"d" respectively via its own
// SettingType column. VTank's gy.e/gy.f unbox helpers
// (refs/vtank/decompiled/gy.cs) throw InvalidCastException on that
// mismatch. This theory drives the expectation straight off the real
// fixture row rather than off a second hand-written table, so it
// cannot silently agree with a wrong assumption baked into the
// production code twice.
public static TheoryData<string> CatalogNamesWithLiveWritePath()
{
var data = new TheoryData<string>();
foreach (string name in VtankOptionCatalog.Names)
{
if (name is "EnableMeta" or "RechargeHandlerSet")
continue; // no live write path (Every137CatalogNameMapsToExactlyOneField).
if (name == "UseArcs")
continue; // pre-existing bool-collapse simplification; see UntouchedRoundTripIsByteIdentical.
data.Add(name);
}
return data;
}
[Theory]
[MemberData(nameof(CatalogNamesWithLiveWritePath))]
public void CaptureMatchesDeclaredSettingTypeAndValue(string name)
{
string text = File.ReadAllText(Path.Combine(FixturesRoot, "defaultsettings.usd"));
VtankSettingsProfileSerializer.AllSettings settings = NewSettings();
VtankDatabase document = VtankSettingsProfileSerializer.Load(text, settings);
VtankTable table = document.Find("Settings")!;
int nameColumn = table.ColumnIndex("Setting");
int valueColumn = table.ColumnIndex("Value");
VtankRow row = table.Rows.Single(
r => r.Cells[nameColumn].AsString().Equals(name, StringComparison.Ordinal));
VtankCell fixtureCell = row.Cells[valueColumn];
VtankCell? captured = VtankSettingsProfileSerializer.Capture(name, settings);
Assert.NotNull(captured);
Assert.Equal(fixtureCell.Tag, captured!.Tag);
switch (fixtureCell.Tag)
{
case "b":
Assert.Equal(fixtureCell.AsBool(), captured.AsBool());
break;
case "s":
Assert.Equal(fixtureCell.AsString(), captured.AsString());
break;
case "i":
Assert.Equal(fixtureCell.AsInt(), captured.AsInt());
break;
case "u":
Assert.Equal(fixtureCell.AsUInt(), captured.AsUInt());
break;
case "d":
case "f":
// A handful of distance settings are declared tDouble but
// still round-trip through a CombatSettings `float` field
// for the physics math that consumes them (Apply's
// `(float)(cell.AsDouble() * 240d)`); that costs precision
// below float's ~7-significant-digit guarantee, not a real
// value change. Bounded exactly like
// VtankSettingsProfileSerializer's own ValuesEqual.
double expected = fixtureCell.AsDouble();
double actual = captured.AsDouble();
double tolerance = Math.Max(Math.Abs(expected), Math.Abs(actual)) * 1.5e-7;
Assert.True(
Math.Abs(expected - actual) <= tolerance,
$"{name}: fixture={expected} captured={actual}");
break;
default:
Assert.Fail($"{name}: unexpected tag '{fixtureCell.Tag}'.");
break;
}
}
[Fact]
public void RechargeHandlerSetRoundTripsFromOwnerFixture()
{