diff --git a/src/AcDream.Plugins.MossTank/MossTankCommands.cs b/src/AcDream.Plugins.MossTank/MossTankCommands.cs index cfda5ca95..b8e9fc643 100644 --- a/src/AcDream.Plugins.MossTank/MossTankCommands.cs +++ b/src/AcDream.Plugins.MossTank/MossTankCommands.cs @@ -425,17 +425,28 @@ internal sealed partial class MossTankPanel WriteVtank("Option set: Invalid option specified."); return; } - if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, out ExpressionValue value)) + canonical = VtankOptionCatalog.Canonical(name); + VtankSettingValueType declaredType = VtankOptionCatalog.DeclaredType(canonical); + if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, declaredType, out ExpressionValue value)) { - WriteVtank("Option set: Invalid value specified."); + // Round 3 item 6: retail's exact failure text + // (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612) + // names the option and the CLR type its Settings row + // actually declares (gy's own m_a, ToString()'d). + WriteVtank( + "Option set: Invalid value specified. Proper type of " + + canonical + " is " + DeclaredClrTypeName(declaredType) + "."); return; } - canonical = VtankOptionCatalog.Canonical(name); SetMetaOption(canonical, value); if (operation.Equals("setinall", StringComparison.OrdinalIgnoreCase)) { + // Round 3 item 6: retail's exact bk.a text + // (refs/vtank/decompiled/bk.cs:32) — no "= value" suffix, + // and the count is every .usd file scanned, not just the + // ones that already had a row for this name. int count = _profiles.SetOptionInAll(canonical, _allSettings); - WriteVtank($"Set option {canonical} in {count} profile(s) = {GetMetaOption(canonical).ToDisplayString()}"); + WriteVtank($"Done saving setting {canonical} to all profiles. (Changed {count} profiles)"); } else { @@ -965,22 +976,107 @@ internal sealed partial class MossTankPanel return result; } - private static bool TryParseOptionValue(string source, out ExpressionValue value) + /// + /// Free-form parse (no catalog type to check against) for callers like + /// the Advanced Options editor, which can target names outside the 137- + /// row catalog: best-effort bool, then number, then string. + /// + private static bool TryParseOptionValue(string source, out ExpressionValue value) => + TryParseOptionValue(source, declaredType: null, out value); + + /// + /// Round 3 item 6: /vt opt set/setinall validate the typed + /// value against the catalog's OWN declared + /// (retail's eSettingValueType) rather than accepting anything — + /// a value that does not parse as that type fails with retail's exact + /// "Proper type of X is Y." text (). + /// is only for + /// the free-form Advanced Options editor, which keeps the original lax + /// best-effort behavior. + /// + private static bool TryParseOptionValue( + string source, + VtankSettingValueType? declaredType, + out ExpressionValue value) { - if (bool.TryParse(source, out bool boolean)) + if (source.Length == 0) { - value = ExpressionValue.Boolean(boolean); - return true; + value = default; + return false; } - if (double.TryParse(source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number)) + switch (declaredType) { - value = ExpressionValue.Number(number); - return true; + case VtankSettingValueType.Bool: + if (bool.TryParse(source, out bool boolean)) + { + value = ExpressionValue.Boolean(boolean); + return true; + } + value = default; + return false; + case VtankSettingValueType.Int: + case VtankSettingValueType.Enum: + if (int.TryParse( + source, NumberStyles.Integer, CultureInfo.InvariantCulture, out int integer)) + { + value = ExpressionValue.Number(integer); + return true; + } + value = default; + return false; + case VtankSettingValueType.Double: + case VtankSettingValueType.Single: + if (double.TryParse( + source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number)) + { + value = ExpressionValue.Number(number); + return true; + } + value = default; + return false; + case VtankSettingValueType.String: + value = ExpressionValue.String(source); + return true; + default: + // No declared type (free-form editor) or VtankSettingValueType.Custom + // (the one non-scalar row, RechargeHandlerSet, never reached + // through /vt opt set): keep the original lax parse. + if (bool.TryParse(source, out bool freeBoolean)) + { + value = ExpressionValue.Boolean(freeBoolean); + return true; + } + if (double.TryParse( + source, NumberStyles.Float, CultureInfo.InvariantCulture, out double freeNumber)) + { + value = ExpressionValue.Number(freeNumber); + return true; + } + value = ExpressionValue.String(source); + return true; } - value = ExpressionValue.String(source); - return source.Length != 0; } + /// + /// The CLR type name retail's exact failure text names + /// (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612: + /// cw2[1].b().ToString() — the Settings row's underlying + /// gy cell type, 'd). Retail stores an + /// Enum-declared row as a plain int cell (confirmed against the + /// UseArcs row in the shipped defaultsettings.usd fixture), so it also + /// reads "System.Int32". + /// + private static string DeclaredClrTypeName(VtankSettingValueType type) => type switch + { + VtankSettingValueType.Bool => "System.Boolean", + VtankSettingValueType.Double => "System.Double", + VtankSettingValueType.Int => "System.Int32", + VtankSettingValueType.Single => "System.Single", + VtankSettingValueType.String => "System.String", + VtankSettingValueType.Enum => "System.Int32", + _ => "System.Object", + }; + private bool TryParseCoordinates(string source, out PluginNavigationPosition position) { position = default; diff --git a/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs index 29fd46fb4..769b2a8b3 100644 --- a/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs @@ -328,13 +328,22 @@ internal sealed class MossTankProfileStore } /// - /// VTank's opt setinall: patch every known .usd file's - /// Settings row for (including the currently - /// bound character's own file even if it has never been saved before), - /// leaving every other row and every other table untouched. The live - /// value has already been applied by the caller (SetMetaOption) - /// before this runs; this only persists that same value into every - /// profile file. + /// VTank's opt setinall (bk.a, refs/vtank/decompiled/bk.cs:6-35): + /// patch every known .usd file's Settings row for + /// (including the currently bound character's + /// own file even if it has never been saved before), leaving every + /// other row and every other table untouched. The live value has + /// already been applied by the caller (SetMetaOption) before + /// this runs; this only persists that same value into every profile + /// file. Round 3 item 6: when a file's Settings table has no row for + /// this name at all, bk.a APPENDS a new one + /// (bk.cs:22-27: cw3[0]=name, cw3[1]=value, every other + /// column left at its default-constructed gy — a "void" cell, + /// gy.cs:98-101) rather than skipping that file, and the caller + /// reports files.Length (every .usd file scanned, + /// regardless of whether that file's own row already existed) — + /// returns that same file count, not a + /// per-row "successfully changed" tally. /// public int SetOptionInAll(string name, VtankSettingsProfileSerializer.AllSettings current) { @@ -370,7 +379,6 @@ internal sealed class MossTankProfileStore ? DynamicSettingDocument.From(liveDynamicValue) : null; - int updated = 0; foreach (string fileName in fileNames) { string? text = ReadUsdText(fileName); @@ -395,7 +403,16 @@ internal sealed class MossTankProfileStore candidate.Cells[nameColumn].AsString().Equals( canonical, StringComparison.OrdinalIgnoreCase)); if (row is null) - continue; + { + // bk.cs:22-27: append a brand-new row rather than skip this + // file. Every column other than [0]=name/[1]=value stays a + // void gy() cell (gy.cs:98-101 writes only "0" for one). + row = new VtankRow(); + for (int column = 0; column < table.ColumnNames.Count; column++) + row.Cells.Add(new VtankCell { Tag = "0" }); + row.Cells[nameColumn] = VtankCell.String(canonical); + table.Rows.Add(row); + } row.Cells[valueColumn] = captured; WriteUsdText(fileName, database.Render()); if (fileName.Equals(_currentDatabaseFileName, StringComparison.Ordinal)) @@ -407,9 +424,11 @@ internal sealed class MossTankProfileStore sidecar.CombatDynamicSettings[canonical] = dynamicDocument; WriteJson(SideCarKey(fileName), sidecar); } - updated++; } - return updated; + // bk.cs:32: PluginCore.a(... + files.Length + " profiles)") reports + // the TOTAL file count scanned, not a per-file "did it actually + // change" tally. + return fileNames.Count; } // ------------------------------------------------------------------ diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs index 64381a363..0d5bddc5d 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs @@ -1517,6 +1517,97 @@ public sealed class MossTankPanelTests "uboptget['AttackDistance']").AsNumber(), precision: 7); } + /// + /// Round 3 item 6: retail's bk.a text (refs/vtank/decompiled/bk.cs:32) + /// is "Done saving setting X to all profiles. (Changed N profiles)" — + /// no "= value" suffix, unlike the plain "set" success message. + /// + [Fact] + public void VtankSetInAllReportsRetailsExactMessageText() + { + var automation = new FakeAutomation(); + var panel = new MossTankPanel(new FakeHost(automation)); + + Command(panel, "opt setinall AttackDistance 0.03"); + + string message = Assert.Single(automation.Messages, static text => + text.StartsWith("Done saving setting", StringComparison.Ordinal)); + Assert.StartsWith( + "Done saving setting AttackDistance to all profiles. (Changed ", + message, + StringComparison.Ordinal); + Assert.EndsWith(" profiles)", message, StringComparison.Ordinal); + Assert.DoesNotContain("=", message, StringComparison.Ordinal); + } + + /// + /// Round 3 item 6: a value that does not parse as the catalog's own + /// declared type (EnableLooting is Bool) fails with retail's exact + /// text (refs/vtank/decompiled/uTank2/PluginCore.cs:5501,5508,5612). + /// + [Theory] + [InlineData("set")] + [InlineData("setinall")] + public void VtankOptSetRejectsAWrongTypedValueWithRetailsExactText(string operation) + { + var automation = new FakeAutomation(); + var panel = new MossTankPanel(new FakeHost(automation)); + + Command(panel, $"opt {operation} EnableLooting notaboolean"); + + Assert.Equal( + "Option set: Invalid value specified. Proper type of EnableLooting is System.Boolean.", + Assert.Single(automation.Messages)); + } + + /// + /// Round 3 item 6: bk.a (refs/vtank/decompiled/bk.cs:22-27) + /// APPENDS a new Settings row — [0]=name, [1]=value, every other column + /// left as a void gy() cell — when a .usd file's Settings + /// table has no row for the option at all, rather than skipping that + /// file outright. + /// + [Fact] + public void SetOptionInAllAppendsARowWhenAFileHasNoneForThatSetting() + { + var storage = new MemoryStorage(); + var automation = new FakeAutomation { Name = "Barris" }; + // A hand-trimmed Settings table (4 real columns: Setting, Value, + // Description, SettingType) with exactly one row, for a DIFFERENT + // setting — "EnableLooting" is deliberately absent. + const string minimalUsd = + "1\r\nSettings\r\n4\r\nSetting\r\nValue\r\nDescription\r\nSettingType\r\n" + + "y\r\nn\r\nn\r\nn\r\n1\r\ns\r\nEnableNav\r\nb\r\nFalse\r\ns\r\n\r\ni\r\n1\r\n"; + storage.Text["Other.usd"] = minimalUsd; + + var store = new MossTankProfileStore(new FakeHost(automation, storage)); + var settings = new VtankSettingsProfileSerializer.AllSettings + { + Combat = new CombatSettings(), + Buffs = new BuffSettings(), + Vitals = new VitalSettings(), + Inventory = new InventorySettings(), + Navigation = new NavigationSettings(), + }; + settings.Inventory.Loot.Enabled = true; + + int count = store.SetOptionInAll("EnableLooting", settings); + + VtankDatabase rewritten = VtankDatabase.Parse(storage.Text["Other.usd"]); + VtankTable table = rewritten.Find("Settings")!; + int nameColumn = table.ColumnIndex("Setting"); + int valueColumn = table.ColumnIndex("Value"); + VtankRow appended = Assert.Single(table.Rows, row => + row.Cells[nameColumn].AsString().Equals( + "EnableLooting", StringComparison.OrdinalIgnoreCase)); + Assert.True(appended.Cells[valueColumn].AsBool()); + // The remaining columns (Description/SettingType) stay void, never + // copied from the existing EnableNav row. + Assert.Equal("0", appended.Cells[2].Tag); + Assert.Equal("0", appended.Cells[3].Tag); + Assert.True(count >= 1); + } + [Fact] public void VtankHelpAndExpressionsUseTheLocalCommandSurface() {