diff --git a/docs/research/vtank-kb/07-meta-and-expressions.md b/docs/research/vtank-kb/07-meta-and-expressions.md index e737f247..e1477c41 100644 --- a/docs/research/vtank-kb/07-meta-and-expressions.md +++ b/docs/research/vtank-kb/07-meta-and-expressions.md @@ -723,7 +723,7 @@ line counts). This is an unusually precise reverse-engineering result — the | 3 | **`MonsterNameCountWithinDistance`'s name pattern is case-insensitive in MossTank, case-sensitive in retail** | `d7.c()` compiles with `RegexOptions.Compiled` only (`d7.cs:26`) — case-sensitive | `Meta.cs`'s `MonsterCount` helper (`Meta.cs:536-552`) compiles with `RegexOptions.IgnoreCase \| RegexOptions.CultureInvariant` | **Medium-high.** A pattern authored against retail's case-sensitive matching (e.g. deliberately excluding a differently-cased variant name) will over-match in MossTank. Note `ChatMessage`/`ChatMessageCapture` do **not** have this divergence — MossTank's `ChatMatch` (`Meta.cs:463-513`) is correctly case-sensitive (`RegexOptions.CultureInvariant` only), matching `hl.cs`/`c5.cs`. | | 4 | **`;` sequence-operator value convention is inverted vs. retail (but matches UtilityBelt, and was chosen deliberately)** | Retail VTank: `a;b` evaluates to **`a`** (the left/first operand), discarding `b`'s value, while still executing `b` for side effects (`ExpressionEvaluator.cs:787-790`); `;` is an ordinary operator that can nest anywhere via normal precedence | `ExpressionProgram.Evaluate` (`ExpressionEngine.cs:20-27`) treats top-level `;`-separated statements as a `Node[]` program and returns the value of the **last** statement — this matches UtilityBelt's own audited grammar ("multiple `;`-separated statements, returning the final result", `2026-08-26-mosstank-vtank-utilitybelt-research.md:237`), which the campaign explicitly chose as MossTank's baseline dialect (§3.1 there) | **Medium, and by design, not an oversight.** Retail VTank and UtilityBelt already disagree with each other on `;`'s return value; MossTank correctly implements UtilityBelt's convention. The compat risk is narrower than a plain bug: only a `.met` authored *against retail's own* semantics (chaining `sideeffect[]; realcheck[]` and relying on the *first* value) evaluates to the opposite result once imported. | | 5 | **Watchdog ring pre-fill differs (far sentinel vs. arm-time position)** | All 10 position-history slots start as a sentinel far from any real coordinate (`(1000,1000,1000)`, `h7.cs:45-49`), guaranteeing expiry cannot even be *reachable* until one full `timeSpan` window has elapsed and every slot has been overwritten with a real sample | `SetWatchdog` (`Meta.cs:585-596`) pre-fills all 10 slots with the **current** position at arm time | **Lower-medium.** Both converge on "earliest possible expiry is ~one `TimeSpanSeconds` after arming" in the common case, but they diverge for a watchdog that is armed, then the player leaves and returns to very near the arm point before the window closes — retail's sentinel-seeded ring cannot spuriously read the arm-time position as one of its 10 samples, MossTank's can. | -| 6 | **`MetaRule.Enabled` (a per-rule disable-without-delete toggle) has no metaf-compatible representation at all** | Real VTank/metaf has no such concept — `metaf_monolithic.py` has zero occurrences of "enabled"/"disabled" anywhere; a rule in a `.met`/`.af` is simply present or absent | `MetafSerializer.SaveMeta` (Campaign VT slice-1 item H) now **refuses to save** a profile containing any disabled rule by default (throws `InvalidOperationException` naming the count) rather than silently dropping it — the caller must opt in via `SaveMeta(profile, dropDisabledRules: true)` to accept the loss explicitly. `MossTankMetaProfileStore.WriteLegacyExport` (the `.af` convenience mirror written alongside MossTank's own fully-fidelity JSON storage) deliberately does NOT opt in: it leaves that mirror stale rather than losing the disabled rule, and logs a warning via the existing try/catch. | **Low** (by design, not a bug): this is a MossTank-only UI extension with no VTank equivalent to diverge from; the representational loss is confined to the legacy-export mirror, never the authoritative profile data, and is now impossible to hit silently. | +| 6 | **`MetaRule.Enabled` (a per-rule disable-without-delete toggle) has no metaf-compatible representation at all** | Real VTank/metaf has no such concept — `metaf_monolithic.py` has zero occurrences of "enabled"/"disabled" anywhere; a rule in a `.met`/`.af` is simply present or absent | `MetafSerializer.SaveMeta` (Campaign VT slice-1 item H) **refuses to save** a profile containing any disabled rule by default (throws `InvalidOperationException` naming the count) rather than silently dropping it — the caller must opt in via `SaveMeta(profile, dropDisabledRules: true)` to accept the loss explicitly. Round 2 (Campaign VT slice 1 Part A) made `.af` the SOLE authoritative Meta store — `MossTankMetaProfileStore` has no separate JSON storage or `.af` "convenience mirror" any more, so this refusal now blocks the save of the profile itself: a disabled rule makes that profile file genuinely **unsaveable** until the user re-enables or deletes the rule (the file on disk keeps its last successfully-saved content in the meantime; round 3 item 12 corrected this row, which previously described the pre-cutover "legacy-export mirror" design). | **Low** (by design, not a bug): this is a MossTank-only UI extension with no VTank equivalent to diverge from, and the refusal is loud (a user-visible notice) rather than a silent data loss. | ### 5.3 Confirmed non-gaps (the format/engine is otherwise unusually faithful) diff --git a/src/AcDream.Plugins.MossTank/MossTankCommands.cs b/src/AcDream.Plugins.MossTank/MossTankCommands.cs index b8e9fc64..178a2797 100644 --- a/src/AcDream.Plugins.MossTank/MossTankCommands.cs +++ b/src/AcDream.Plugins.MossTank/MossTankCommands.cs @@ -271,9 +271,13 @@ internal sealed partial class MossTankPanel // Item J (slice-1 fix round): .af is the only VTank-compatible // storage format now (Campaign VT slice 1 Part A); ".nav" is // stripped for legacy-typed names, ".af" for names copy-pasted - // from the exports/nav/ directory or a real VTank profile folder — - // without both, "/vt nav save Foo.af" would have produced a - // doubled "Foo.af.af" export. + // from a real .af file (the VtankProfiles directory, or an + // imports/ drop-in for TryImportLegacy below) — without both, + // "/vt nav save Foo.af" would have produced a doubled "Foo.af.af" + // file. Round 3 item 12: this comment previously cited an + // "exports/nav/" mirror directory that no longer exists — route + // profiles have written directly to their real .af file since the + // round 2 step 2-3 cutover, with no separate export copy. name = StripExtension(name, ".nav", ".af"); if (operation == "save") { diff --git a/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs index 769b2a8b..9de10c84 100644 --- a/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs +++ b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs @@ -212,7 +212,7 @@ internal sealed class MossTankProfileStore database = VtankDefaultSettingsDatabase.Parse(); ApplyFromDatabase(database, settings); sidecar = SideCarDocument.CreateDefaults(); - sidecar.Apply(settings, noBuffItemNames); + sidecar.Apply(settings, noBuffItemNames, _host.Log); } WriteUsdText(fileName, database.Render()); WriteJson(SideCarKey(fileName), sidecar); @@ -268,7 +268,7 @@ internal sealed class MossTankProfileStore // template/defaults rather than leaving the live settings alone. VtankDatabase fresh = VtankDefaultSettingsDatabase.Parse(); ApplyFromDatabase(fresh, settings); - SideCarDocument.CreateDefaults().Apply(settings, noBuffItemNames); + SideCarDocument.CreateDefaults().Apply(settings, noBuffItemNames, _host.Log); _currentDatabase = fresh; _currentDatabaseFileName = fileName; return; @@ -286,7 +286,7 @@ internal sealed class MossTankProfileStore _host.Log.Warn(RecoveryNotice); VtankDatabase fresh = VtankDefaultSettingsDatabase.Parse(); ApplyFromDatabase(fresh, settings); - SideCarDocument.CreateDefaults().Apply(settings, noBuffItemNames); + SideCarDocument.CreateDefaults().Apply(settings, noBuffItemNames, _host.Log); _currentDatabase = fresh; _currentDatabaseFileName = fileName; return; @@ -294,7 +294,7 @@ internal sealed class MossTankProfileStore _currentDatabase = database; _currentDatabaseFileName = fileName; (ReadJson(SideCarKey(fileName)) ?? SideCarDocument.CreateDefaults()) - .Apply(settings, noBuffItemNames); + .Apply(settings, noBuffItemNames, _host.Log); } public void SaveCurrent( @@ -319,7 +319,7 @@ internal sealed class MossTankProfileStore { VtankDatabase database = VtankDefaultSettingsDatabase.Parse(); ApplyFromDatabase(database, settings); - SideCarDocument.CreateDefaults().Apply(settings, noBuffItemNames); + SideCarDocument.CreateDefaults().Apply(settings, noBuffItemNames, _host.Log); string fileName = CurrentFileName(); WriteUsdText(fileName, database.Render()); WriteJson(SideCarKey(fileName), SideCarDocument.CreateDefaults()); @@ -499,7 +499,8 @@ internal sealed class MossTankProfileStore }; var noBuffItemNames = new HashSet(StringComparer.Ordinal); legacy.Apply( - settings.Combat, settings.Buffs, settings.Vitals, settings.Inventory, noBuffItemNames); + settings.Combat, settings.Buffs, settings.Vitals, settings.Inventory, + noBuffItemNames, _host.Log); VtankDatabase database = VtankSettingsProfileSerializer.CreateNew(settings); WriteUsdText(fileName, database.Render()); WriteJson(SideCarKey(fileName), SideCarDocument.Capture(settings, noBuffItemNames)); @@ -563,7 +564,7 @@ internal sealed class MossTankProfileStore return; } - legacy.Apply(settings.Combat, settings.Buffs, settings.Vitals, settings.Inventory, noBuffItemNames); + legacy.Apply(settings.Combat, settings.Buffs, settings.Vitals, settings.Inventory, noBuffItemNames, _host.Log); VtankDatabase database = VtankSettingsProfileSerializer.CreateNew(settings); WriteUsdText(fileName, database.Render()); WriteJson(SideCarKey(fileName), SideCarDocument.Capture(settings, noBuffItemNames)); @@ -823,7 +824,8 @@ internal sealed class MossTankProfileStore public void Apply( VtankSettingsProfileSerializer.AllSettings settings, - ISet noBuffItemNames) + ISet noBuffItemNames, + IPluginLogger? logger = null) { Replace(settings.Combat.CombatItemNames, ItemNames); settings.Combat.CombatItemObjectIds.Clear(); @@ -851,7 +853,12 @@ internal sealed class MossTankProfileStore foreach (MonsterRuleDocument rule in CombatRules ?? []) { try { settings.Combat.Rules.Add(rule.ToRule()); } - catch (FormatException) { } + catch (FormatException error) + { + // Round 3 item 12: log rather than silently drop a + // corrupt side-car monster rule. + logger?.Warn($"MossTank could not restore a monster rule: {error.Message}"); + } } if (!settings.Combat.Rules.Any(static rule => rule.IsDefault)) settings.Combat.Rules.Add(new MonsterRule("DEFAULT", 0)); @@ -1014,9 +1021,10 @@ internal sealed class MossTankProfileStore BuffSettings buffs, VitalSettings vitals, InventorySettings inventory, - ISet noBuffItemNames) + ISet noBuffItemNames, + IPluginLogger? logger = null) { - (Combat ?? LegacyCombatProfileDocument.Capture(new CombatSettings())).Apply(combat); + (Combat ?? LegacyCombatProfileDocument.Capture(new CombatSettings())).Apply(combat, logger); (Buffs ?? LegacyBuffProfileDocument.Capture(new BuffSettings())).Apply(buffs); (Vitals ?? LegacyVitalProfileDocument.Capture(new VitalSettings())).Apply(vitals); (Inventory ?? LegacyInventoryProfileDocument.Capture(new InventorySettings())).Apply(inventory); @@ -1237,7 +1245,7 @@ internal sealed class MossTankProfileStore Rules = value.Rules.Select(MonsterRuleDocument.From).ToArray(), }; - public void Apply(CombatSettings value) + public void Apply(CombatSettings value, IPluginLogger? logger = null) { value.Enabled = Enabled; value.MaximumRange = Math.Clamp(MaximumRange, 2f, 100f); @@ -1282,7 +1290,12 @@ internal sealed class MossTankProfileStore foreach (MonsterRuleDocument rule in Rules ?? []) { try { value.Rules.Add(rule.ToRule()); } - catch (FormatException) { } + catch (FormatException error) + { + // Round 3 item 12: log rather than silently drop a + // corrupt legacy-JSON monster rule during migration. + logger?.Warn($"MossTank could not restore a legacy monster rule: {error.Message}"); + } } if (!value.Rules.Any(static rule => rule.IsDefault)) value.Rules.Add(new MonsterRule("DEFAULT", 0)); diff --git a/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs index 5d86edf2..4ff0f2b9 100644 --- a/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs +++ b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs @@ -8,11 +8,17 @@ namespace AcDream.Plugins.MossTank; /// import path only. Per the owner's 2026-09-06 "MossTank does not author /// .nav" direction (Campaign VT slice 1 Part A), the writer that /// used to live here was deleted: .af () -/// is the only storage/authoring format for navigation routes now. The -/// small binary-blob writer 's embedded-navigation -/// contract still needs lives in itself -/// (WriteBinaryNavBlob) rather than here, since that shape is a -/// MossTank runtime contract, not a VTank file on disk. +/// is the only storage/authoring format for navigation routes now. +/// 's embedded-navigation contract (a Meta rule's +/// action, +/// .af tag EmbedNav) is a typed +/// +/// now (round 2 step B, replacing an earlier binary-blob shape), saved and +/// loaded through the SAME / +/// grammar this class no longer +/// owns any writer for — round 3 item 12 cleanup: this doc comment +/// previously cited a "WriteBinaryNavBlob" method that does not +/// exist anywhere in the codebase. /// internal static class VtankNavRouteSerializer { diff --git a/src/AcDream.Plugins.MossTank/VtankUsdDocument.cs b/src/AcDream.Plugins.MossTank/VtankUsdDocument.cs index 0f1614ef..018f5e8e 100644 --- a/src/AcDream.Plugins.MossTank/VtankUsdDocument.cs +++ b/src/AcDream.Plugins.MossTank/VtankUsdDocument.cs @@ -268,6 +268,17 @@ internal sealed class VtankDatabase /// accident of whatever order the source data happened to arrive in /// (no observable behavior change for any fixture today; it only /// matters the day a future code path adds or reorders a table). + /// + /// Round 3 item 12: is used here + /// as a stand-in for .NET Framework's SortedDictionary<string, bd> + /// default key order — that equivalence is confirmed only for the + /// plain-ASCII, single-case table names VTank itself ships + /// (AntiExtraBuffSpells, MyMonsters, Settings, …), not as a general + /// claim that ordinal and .NET's culture-aware default string + /// comparer agree for every possible string; a hypothetical future + /// table name using non-ASCII characters or mixed-width comparisons + /// could sort differently under the two. No behavior change is + /// implied or made by this note. /// public string Render() { diff --git a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs index 11cb6a3f..68f30fb9 100644 --- a/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs +++ b/tests/AcDream.Plugins.MossTank.Tests/MossTankPanelTests.cs @@ -26,6 +26,35 @@ public sealed class MossTankPanelTests Assert.Contains("{ this is not json", backup.Value, StringComparison.Ordinal); } + /// + /// Round 3 item 12: a side-car monster rule whose Expression fails to + /// compile used to be silently dropped (a bare + /// catch (FormatException) { }); it must now log a warning + /// instead, while still falling back gracefully (the malformed rule is + /// skipped, everything else loads). + /// + [Fact] + public void CorruptSideCarMonsterRuleIsLoggedNotSilentlySwallowed() + { + var storage = new MemoryStorage(); + string usdKey = VtankProfileDirectory.AutoCharacterFileName("Barris", string.Empty, "usd"); + storage.Text[usdKey] = VtankDefaultSettingsDatabase.Parse().Render(); + storage.Text["profiles/macro/sidecar/--Barris_.usd.json"] = """ + { + "CombatRules": [ + { "Expression": "(((" }, + { "Expression": "DEFAULT" } + ] + } + """; + + var host = new FakeHost(new FakeAutomation { Name = "Barris" }, storage); + _ = new MossTankPanel(host); + + Assert.Contains(host.Logger.Warnings, message => + message.Contains("monster rule", StringComparison.OrdinalIgnoreCase)); + } + /// /// Reproduces the pre-cutover hashed JSON key a "By char" macro profile /// used to be stored under (MossTankProfileStore.LegacyProfileKey, @@ -2158,7 +2187,10 @@ public sealed class MossTankPanelTests IPluginStorage? vtankProfiles = null) : IPluginHost { public bool HasUi => false; - public IPluginLogger Log { get; } = new FakeLogger(); + // Round 3 item 12: exposed as the concrete FakeLogger (not just the + // IPluginHost.Log interface view) so a test can inspect Warnings. + public FakeLogger Logger { get; } = new(); + public IPluginLogger Log => Logger; public IGameState State { get; } = new FakeState(); public IEvents Events { get; } = new FakeEvents(); public ISelectionService Selection { get; } = new FakeSelection(); @@ -2482,8 +2514,13 @@ public sealed class MossTankPanelTests private sealed class FakeLogger : IPluginLogger { + // Round 3 item 12: capture Warn() calls so a test can prove a + // corrupt monster-rule expression is logged rather than silently + // swallowed by the two former bare "catch (FormatException) { }" + // blocks in MossTankProfileStore. + public List Warnings { get; } = []; public void Info(string message) { } - public void Warn(string message) { } + public void Warn(string message) => Warnings.Add(message); public void Error(string message, Exception? exception = null) { } }