From 9c34b6074d26d546c1d5f2a5f325cfdc2777a805 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 6 Sep 2026 21:45:53 +0200 Subject: [PATCH] fix(vt): Capture() emits the declared eSettingValueType tag, not the CLR field type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../VtankOptionCatalog.cs | 171 +++++++++++++ .../VtankSettingsProfileSerializer.cs | 241 ++++++++++-------- .../VtankUsdDocument.cs | 13 +- .../VtankSettingsProfileSerializerTests.cs | 81 ++++++ 4 files changed, 392 insertions(+), 114 deletions(-) diff --git a/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs b/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs index 8947b9e59..8dcb1ecfe 100644 --- a/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs +++ b/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs @@ -5,6 +5,26 @@ namespace AcDream.Plugins.MossTank; /// uTank2.Resources.defaultsettings.usd. Order is retained because /// /vt opt list presents the database order four entries per line. /// +/// +/// VTank's uTank2.eSettingValueType enum +/// (refs/vtank/decompiled/uTank2/eSettingValueType.cs) — the value +/// each Settings row's own SettingType cell declares. This is the +/// authority for which 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 +/// tInt row even though the live field is a double fraction). +/// +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 DeclaredTypes = + new Dictionary(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); + + /// The eSettingValueType VTank's own Settings row declares for this name. + internal static VtankSettingValueType DeclaredType(string name) => + DeclaredTypes.TryGetValue(name, out VtankSettingValueType type) + ? type + : VtankSettingValueType.Double; } diff --git a/src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs b/src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs index 93314d30f..aff951e49 100644 --- a/src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs +++ b/src/AcDream.Plugins.MossTank/VtankSettingsProfileSerializer.cs @@ -134,45 +134,68 @@ internal static class VtankSettingsProfileSerializer }; } - private static readonly HashSet NumericTags = ["d", "i", "u", "f"]; + /// + /// Converts a live numeric setting value to the exact + /// shape its declared SettingType + /// requires — the fix for the fidelity defect where 17 tInt + /// settings were written as d and 3 tSingle settings were + /// written as f (VTank's gy.e/gy.f unbox helpers + /// throw on a tag/CLR-type mismatch). + /// The tag is driven by , + /// never by the CLR type of the C# field the caller happens to store + /// the value in. + /// + 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), + }; /// - /// Compares by parsed VALUE, not raw tag: a setting's real VTank type - /// (e.g. the Recharge-* percentages are stored as plain i ints - /// in defaultsettings.usd, not d doubles) does not need to - /// be re-derived exactly by for an untouched - /// value to round-trip byte-for-byte — any numeric tag comparing equal - /// means keeps the ORIGINAL cell object untouched. + /// Exact compare on the cell's own type — a 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 tDouble + /// but this port still stores their live value in a float 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 + /// , not an arbitrary fudge factor. + /// Every other tag (bool/int/enum/string/uint) compares byte-exact. /// 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) diff --git a/src/AcDream.Plugins.MossTank/VtankUsdDocument.cs b/src/AcDream.Plugins.MossTank/VtankUsdDocument.cs index f69b04e01..5c6531c48 100644 --- a/src/AcDream.Plugins.MossTank/VtankUsdDocument.cs +++ b/src/AcDream.Plugins.MossTank/VtankUsdDocument.cs @@ -110,13 +110,16 @@ internal sealed class VtankCell public bool AsBool() => string.Equals(ScalarText, "True", StringComparison.Ordinal); /// - /// Formats a double/float the way VTank's own build does: .NET's - /// invariant-culture round-trippable ToString(). Verified against - /// real fixture values (e.g. 5/240 renders as - /// 0.0208333333333333, matching defaultsettings.usd). + /// Formats a double/float the way VTank's own (older .NET Framework) + /// build does: Convert.ToString(double, CultureInfo) + /// (gy.cs:60-61) used the CLR's classic 15-significant-digit + /// general format, not .NET Core's shortest-round-trippable default. + /// Verified against every d-tagged value in the real + /// defaultsettings.usd fixture (e.g. 5/240 renders as + /// 0.0208333333333333 — 15 significant digits). /// internal static string FormatDouble(double value) => - value.ToString(CultureInfo.InvariantCulture); + value.ToString("G15", CultureInfo.InvariantCulture); internal void WriteTo(List lines) { diff --git a/tests/AcDream.Plugins.MossTank.Tests/VtankSettingsProfileSerializerTests.cs b/tests/AcDream.Plugins.MossTank.Tests/VtankSettingsProfileSerializerTests.cs index a254513b9..d3d588229 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/VtankSettingsProfileSerializerTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/VtankSettingsProfileSerializerTests.cs @@ -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 CatalogNamesWithLiveWritePath() + { + var data = new TheoryData(); + 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() {