From 978bf5b237bf509cd01a26f3dfe3b51e9911f290 Mon Sep 17 00:00:00 2001 From: Chris Knight Date: Fri, 21 Aug 2026 23:36:34 -0400 Subject: [PATCH 1/2] Require a vanilla declaration kept by either side to survive the merge ValidateWholeFileMergeOutput's lost-unit check exempted the one shape that does the most damage. Its condition was required = (inOld && inNew) || (inOld && !inBase) || (inNew && !inBase); so a unit present in vanilla, kept by one input and absent from the other was not required to survive - it could vanish from the merged output with the merge still reporting success. From text alone that shape is indistinguishable from the case that actually breaks games: a mod shipping a whole-file copy taken from an OLDER game build simply has no copy of declarations vanilla added since, and a three-way diff reads that absence as a deletion. Found the hard way on a live next-gen install. A pre-4.0 copy of r4Game.ws erased CR4Game.OnHDRChangedEvent - an engine-called event whose body is GetGuiManager().OnHDRChanged() - from the merged output. The merge reported success, the scripts compiled cleanly, and the game then rendered its menu background with no main menu at all, with nothing anywhere naming the cause. Two other mods in the same load order did the same thing: mapMenu.ws lost OnFiltersChanged / SetInitialFilters / m_fxSetInitialFilters, exploration.ws lost CheckVector / DoHorseKick / OnHorseKick plus five member variables. In every case the declaration was present in vanilla AND in the other contributing mod. The check now requires every unit present in either input to survive; only a name both sides dropped may still disappear (and such a name never reaches the loop, which iterates the two inputs' own keys). This deliberately reclassifies "one mod deletes a vanilla function, the other keeps it" from a silent deletion into a violation, reversing an existing test that asserted the old behavior. It is the same principle the function-level engine has always applied one layer down - "a deletion never silently overrides a surviving edit - losing code silently is unrecoverable if the deletion was wrong, keeping an unwanted edit is not" (Core's CLAUDE.md) - which the whole-file path simply wasn't enforcing. A violation also doesn't skip the file: it routes to the function-level rescue first, exactly like every other violation, and that engine's own edit-survives-competing-deletion policy usually keeps the unit. ValidateWholeFileMergeOutput takes optional oldDescription/newDescription so the message can name the mod that lacks the declaration - the single most useful thing to say, since the fix is almost always "that mod is built for an older game version". Optional, so existing callers and tests are unaffected; DiffPlexMergeEngine passes the descriptions it already has. 5 new tests (179 total), plus the reversed one rewritten with its rationale: the violation and its wording, a name dropped by both sides still allowed through, the stale mod named on whichever side it is, and a graceful message without descriptions. dotnet format clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FP8H6rBLCGPBFRSVsF3Kgw --- WitcherScriptMerger.Core/CLAUDE.md | 25 ++++++ .../Tools/DiffPlexMergeEngine.cs | 3 +- .../Tools/FunctionLevelMergeEngine.cs | 64 ++++++++++++-- WitcherScriptMerger.Tests/CLAUDE.md | 6 +- .../Tools/FunctionLevelMergeEngineTests.cs | 88 ++++++++++++++++++- vortex-extension/src/discoveredTool.test.ts | 9 ++ vortex-extension/src/discoveredTool.ts | 29 ++++-- 7 files changed, 204 insertions(+), 20 deletions(-) diff --git a/WitcherScriptMerger.Core/CLAUDE.md b/WitcherScriptMerger.Core/CLAUDE.md index 619cb2c..fc1b09b 100644 --- a/WitcherScriptMerger.Core/CLAUDE.md +++ b/WitcherScriptMerger.Core/CLAUDE.md @@ -523,6 +523,31 @@ defects the original design produced on a live install): against the real broken output preserved in `docs/bugs/artifacts/` (gate fails it on exactly the orphaned accessor the game rejected) with zero false positives across real vanilla `r4Player.ws`/`player.ws`/`actor.ws`/`baseEffect.ws`. +- **A vanilla declaration kept by either side must survive the whole-file merge** + (`ValidateWholeFileMergeOutput`'s lost-unit check). This extends the function-level + engine's own long-standing "a deletion never silently overrides a surviving edit" + principle (above) to the whole-file path, which did not previously enforce it: a unit + present in vanilla, kept by one input and absent from the other used to be allowed to + vanish, on the reading that it was "a legitimate deletion propagating". From text alone + that shape is **indistinguishable** from the case that actually causes damage — a mod + shipping a whole-file copy taken from an older game build simply has no copy of + declarations vanilla added since, and a three-way diff reads that absence as a deletion. + Observed live on a next-gen install: a pre-4.0 `r4Game.ws` erased + `CR4Game.OnHDRChangedEvent` (engine-called, calls `GetGuiManager().OnHDRChanged()`) from + the merged output; the merge reported success, the scripts compiled, and the game + rendered its menu background with **no main menu at all**. Two other mods in the same + load order did the same to `mapMenu.ws` (`OnFiltersChanged`, `SetInitialFilters`, + `m_fxSetInitialFilters`) and `exploration.ws` (`CheckVector`, `DoHorseKick`, + `OnHorseKick` plus five member variables) — in every case the declaration was present in + vanilla *and* in the other contributing mod. Since the two cases can't be told apart, + this errs toward the survivable one: the deliberate-deletion mod loses its deletion, + rather than an engine-called vanilla event disappearing with nothing saying so. A + violation doesn't skip the file — it routes to the function-level rescue first, like + every other violation, and that engine's own edit-survives-competing-deletion policy + usually keeps the unit. `ValidateWholeFileMergeOutput` takes optional + `oldDescription`/`newDescription` purely so the message can **name the mod that lacks + the declaration**, since the fix is almost always "that mod is built for an older game + version". Only a name *both* sides dropped may still disappear. - A gap that still exists between two intact units is compared as before — whitespace-tolerant, deliberately NOT comment-stripped — producing a `Decisions` note when content differs; vanilla's gap text still wins at non-insertion slots. diff --git a/WitcherScriptMerger.Core/Tools/DiffPlexMergeEngine.cs b/WitcherScriptMerger.Core/Tools/DiffPlexMergeEngine.cs index 861a4e4..3a43b22 100644 --- a/WitcherScriptMerger.Core/Tools/DiffPlexMergeEngine.cs +++ b/WitcherScriptMerger.Core/Tools/DiffPlexMergeEngine.cs @@ -268,7 +268,8 @@ public MergeEngineResult MergeHeadless( // from the ORIGINAL inputs, sidestepping the corrupted output // entirely), then fall through to the conflict-marker sidecar. if (FunctionLevelMergeEngine.ValidateWholeFileMergeOutput( - baseText, oldText, newText, result.MergedText, outputPath, out var invariantViolation)) + baseText, oldText, newText, result.MergedText, outputPath, out var invariantViolation, + oldDescription ?? source1.Name, newDescription ?? source2.Name)) { // A prior attempt at this same conflict may have left a sidecar // marker file behind (see below) - if this attempt now auto-solves diff --git a/WitcherScriptMerger.Core/Tools/FunctionLevelMergeEngine.cs b/WitcherScriptMerger.Core/Tools/FunctionLevelMergeEngine.cs index bc47e44..bc35aef 100644 --- a/WitcherScriptMerger.Core/Tools/FunctionLevelMergeEngine.cs +++ b/WitcherScriptMerger.Core/Tools/FunctionLevelMergeEngine.cs @@ -702,8 +702,25 @@ int At(Dictionary counts, string name) => // Inputs that don't extract cleanly make the judgement impossible - returns // true (trust the merge, as before this guard existed) - EXCEPT when the inputs // extract and the OUTPUT doesn't, which is itself corruption. + // Names the side a violation message is talking about. Falls back to "one mod"/"the + // other mod" when the caller supplied no descriptions (tests, and any future caller + // that has none) - the message stays true, just less specific. + static string DescribeSide(bool isOldSide, string oldDescription, string newDescription) + { + var description = isOldSide ? oldDescription : newDescription; + if (!string.IsNullOrWhiteSpace(description)) + return description; + return isOldSide ? "one mod" : "the other mod"; + } + + // oldDescription/newDescription are optional purely so the violation text can name + // the mod that lacks a vanilla declaration - the single most useful thing to tell a + // user here, since the fix is almost always "that mod is built for an older game + // version". Optional rather than required so existing callers and tests are + // unaffected. public static bool ValidateWholeFileMergeOutput( - string baseText, string oldText, string newText, string mergedText, string outputPath, out string violation) + string baseText, string oldText, string newText, string mergedText, string outputPath, out string violation, + string oldDescription = null, string newDescription = null) { violation = null; if (!FileIndex.ModFile.IsScript(outputPath)) @@ -764,17 +781,50 @@ public static bool ValidateWholeFileMergeOutput( } } + // Every unit present in EITHER input must survive. Only a name both sides + // dropped may legitimately disappear - and such a name never reaches this loop, + // since it iterates the two inputs' own keys. + // + // The vanilla-retention case (in base, kept by one side, absent from the other) + // used to be excluded here, and that exclusion caused real, silent damage. A mod + // shipping a whole-file copy taken from an OLDER game build simply doesn't + // contain declarations the current vanilla added since - which a three-way diff + // cannot tell apart from "this mod deleted it", so the deletion won and the + // merged file shipped without it. Observed live on a next-gen install: a + // pre-4.0 copy of r4Game.ws erased `CR4Game.OnHDRChangedEvent` (an + // engine-called event that calls GetGuiManager().OnHDRChanged()) from the merged + // output; the merge reported success, the scripts compiled, and the game then + // rendered its menu background with no main menu at all. Two more mods did the + // same thing in the same load order - mapMenu.ws lost OnFiltersChanged / + // SetInitialFilters / m_fxSetInitialFilters, exploration.ws lost CheckVector / + // DoHorseKick / OnHorseKick plus five member variables. In every case the + // declaration was present in vanilla AND in the other contributing mod. + // + // Treating this as a violation deliberately reclassifies a genuine + // "one mod deletes a vanilla function, the other keeps it" case as a conflict + // rather than a silent deletion. That is the right trade: whole-function + // deletion by a mod is rare, silently losing engine-called vanilla code is + // catastrophic and near-impossible to diagnose from the symptom, and a violation + // here doesn't skip the file outright - it routes to the function-level rescue + // first, exactly like every other violation. foreach (var name in oldCounts.Keys.Concat(newCounts.Keys).Distinct()) { var inOld = At(oldCounts, name) > 0; var inNew = At(newCounts, name) > 0; var inBase = At(baseCounts, name) > 0; - var required = (inOld && inNew) || (inOld && !inBase) || (inNew && !inBase); - if (required && At(mergedCounts, name) == 0) - { - violation = $"'{name}' is present in {(inOld && inNew ? "both inputs" : "an input that inserted it")} but missing from the merged output (lost)"; - return false; - } + if (At(mergedCounts, name) != 0) + continue; + + if (inOld && inNew) + violation = $"'{name}' is present in both inputs but missing from the merged output (lost)"; + else if (!inBase) + violation = $"'{name}' is present in an input that inserted it but missing from the merged output (lost)"; + else + violation = + $"'{name}' is declared in the vanilla file and kept by {DescribeSide(inOld, oldDescription, newDescription)}, " + + $"but is missing from the merged output (lost) - {DescribeSide(!inOld, oldDescription, newDescription)} has no copy of it, " + + "which usually means that mod ships a whole-file copy taken from an older game build"; + return false; } // Line-level: DiffPlex's silent duplication can strike INSIDE a function diff --git a/WitcherScriptMerger.Tests/CLAUDE.md b/WitcherScriptMerger.Tests/CLAUDE.md index bd1bd0f..703e410 100644 --- a/WitcherScriptMerger.Tests/CLAUDE.md +++ b/WitcherScriptMerger.Tests/CLAUDE.md @@ -32,7 +32,11 @@ does. most-distinct-from-vanilla tiebreak (including its deterministic tie-break and a scaled-down `DiffAlgorithmException` case), edit-survives-competing-deletion, insertion reconciliation (including the same-name-different-body decline case), and gap-comment - detection. + detection. Also `ValidateWholeFileMergeOutput`'s lost-unit check, including the + deliberately reversed expectation for a vanilla declaration kept by one side and + absent from the other (now a violation, not "a legitimate deletion propagating" - + see Core's `CLAUDE.md`), that a name dropped by *both* sides is still allowed to go, + and that the violation message names the stale mod on whichever side it is. - `Inventory/FileMergerTests.cs` — `FileMerger.IsVanillaDlcBundleFolder`: known vanilla DLC-folder names, case-insensitivity, and non-matches (including anchoring) — see Core's `CLAUDE.md`'s "Vortex-fork parity fixes" section. Also covers the two-arg diff --git a/WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs b/WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs index 4a9bc10..1548ac3 100644 --- a/WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs +++ b/WitcherScriptMerger.Tests/Tools/FunctionLevelMergeEngineTests.cs @@ -554,17 +554,101 @@ public void ValidateWholeFileMergeOutput_DetectsSilentLossOfAnInsertion() Assert.Contains("lost", violation); } - [Fact] - public void ValidateWholeFileMergeOutput_AllowsALegitimateDeletionPropagating() + // DELIBERATE REVERSAL of this test's previous expectation, which asserted that a + // vanilla declaration present in one input and absent from the other could legitimately + // vanish from the merged output ("a legitimate deletion propagating"). + // + // That shape is indistinguishable, from text alone, from the failure that motivated this + // change: a mod shipping a whole-file copy taken from an OLDER game build has no copy of + // declarations vanilla added since, and a three-way diff reads that absence as a + // deletion. Observed live on a next-gen install - a pre-4.0 r4Game.ws erased + // CR4Game.OnHDRChangedEvent (engine-called, calls GetGuiManager().OnHDRChanged()) from + // the merged output; the merge reported success, the scripts compiled, and the game + // rendered its menu background with no main menu. Two other mods in the same load order + // did the same to mapMenu.ws and exploration.ws. + // + // Since the two cases cannot be told apart, this errs toward the survivable one. A + // violation does not skip the file - it routes to the function-level rescue first, which + // re-merges per unit and has its own edit-survives-competing-deletion policy. The cost is + // that a deliberate whole-function deletion no longer propagates silently; the benefit is + // that engine-called vanilla code can no longer disappear with nothing saying so. + [Fact] + public void ValidateWholeFileMergeOutput_VanillaDeclarationKeptByOneSideButLost_IsAViolation() { var baseText = Fn("A", "\ta();\r\n") + "\r\n" + Fn("B", "\tb();\r\n"); var oldText = baseText; var newText = Fn("A", "\ta();\r\n"); var mergedText = Fn("A", "\ta();\r\n"); + Assert.False(FunctionLevelMergeEngine.ValidateWholeFileMergeOutput(baseText, oldText, newText, mergedText, "x.ws", out var violation)); + Assert.Contains("lost", violation); + Assert.Contains("older game build", violation); + } + + // The one loss that stays legitimate: neither input has it any more, so nothing is being + // discarded. Such a name never even reaches the check, since it iterates the two inputs' + // own keys - this pins that down. + [Fact] + public void ValidateWholeFileMergeOutput_VanillaDeclarationDroppedByBothSides_IsAllowed() + { + var baseText = Fn("A", "\ta();\r\n") + "\r\n" + Fn("B", "\tb();\r\n"); + var oldText = Fn("A", "\ta();\r\n"); + var newText = Fn("A", "\ta();\r\n"); + var mergedText = Fn("A", "\ta();\r\n"); + Assert.True(FunctionLevelMergeEngine.ValidateWholeFileMergeOutput(baseText, oldText, newText, mergedText, "x.ws", out _)); } + // The most useful thing this message can carry is WHICH mod lacks the declaration, + // because the fix is almost always "that mod is built for an older game version". + [Fact] + public void ValidateWholeFileMergeOutput_NamesTheModThatLacksTheVanillaDeclaration() + { + var baseText = Fn("A", "\ta();\r\n") + "\r\n" + Fn("B", "\tb();\r\n"); + var oldText = baseText; + var newText = Fn("A", "\ta();\r\n"); + var mergedText = Fn("A", "\ta();\r\n"); + + Assert.False(FunctionLevelMergeEngine.ValidateWholeFileMergeOutput( + baseText, oldText, newText, mergedText, "x.ws", out var violation, "modKeepsIt", "modStale")); + + Assert.Contains("kept by modKeepsIt", violation); + Assert.Contains("modStale has no copy of it", violation); + } + + // Same case with the stale side reversed, so the message cannot be passing by hardcoding + // one side. + [Fact] + public void ValidateWholeFileMergeOutput_NamesTheStaleModWhicheverSideItIs() + { + var baseText = Fn("A", "\ta();\r\n") + "\r\n" + Fn("B", "\tb();\r\n"); + var oldText = Fn("A", "\ta();\r\n"); + var newText = baseText; + var mergedText = Fn("A", "\ta();\r\n"); + + Assert.False(FunctionLevelMergeEngine.ValidateWholeFileMergeOutput( + baseText, oldText, newText, mergedText, "x.ws", out var violation, "modStale", "modKeepsIt")); + + Assert.Contains("kept by modKeepsIt", violation); + Assert.Contains("modStale has no copy of it", violation); + } + + // Descriptions are optional - without them the message must still be true, just less + // specific. + [Fact] + public void ValidateWholeFileMergeOutput_WithoutDescriptions_StillDescribesBothSides() + { + var baseText = Fn("A", "\ta();\r\n") + "\r\n" + Fn("B", "\tb();\r\n"); + var oldText = baseText; + var newText = Fn("A", "\ta();\r\n"); + var mergedText = Fn("A", "\ta();\r\n"); + + Assert.False(FunctionLevelMergeEngine.ValidateWholeFileMergeOutput(baseText, oldText, newText, mergedText, "x.ws", out var violation)); + + Assert.Contains("kept by one mod", violation); + Assert.Contains("the other mod has no copy of it", violation); + } + [Fact] public void ValidateWholeFileMergeOutput_NonScriptFiles_AlwaysTrusted() { diff --git a/vortex-extension/src/discoveredTool.test.ts b/vortex-extension/src/discoveredTool.test.ts index ea40004..5cef5b1 100644 --- a/vortex-extension/src/discoveredTool.test.ts +++ b/vortex-extension/src/discoveredTool.test.ts @@ -27,6 +27,15 @@ describe('buildWsmDiscoveredTool', () => { expect(tool.executable()).toBe('WitcherScriptMerger.Headless.exe'); }); + it('defaults to running a real, overwrite-enabled merge when launched directly from the Tools dashboard', () => { + // A bare Tools-dashboard launch has no preview/confirmation dialog to gate on (see + // resolveScriptConflicts.ts) - it must resolve conflicts outright, the same way that + // action's own confirmed (non-dry-run) merge call does, rather than printing usage + // and exiting (the pre-fix behavior with no `parameters` at all). + const tool = buildWsmDiscoveredTool({ exePath: EXE_PATH }); + expect(tool.parameters).toEqual(['merge', '--overwrite']); + }); + it('defaults environment to an empty object when none is given', () => { const tool = buildWsmDiscoveredTool({ exePath: EXE_PATH }); expect(tool.environment).toEqual({}); diff --git a/vortex-extension/src/discoveredTool.ts b/vortex-extension/src/discoveredTool.ts index deba641..c8599f8 100644 --- a/vortex-extension/src/discoveredTool.ts +++ b/vortex-extension/src/discoveredTool.ts @@ -60,15 +60,26 @@ export function buildWsmDiscoveredTool(options: WsmDiscoveredToolOptions): types // requiredFiles (which drives that scan) is deliberately empty. requiredFiles: [], executable: () => path.basename(options.exePath), - // No default `parameters`: WitcherScriptMerger.Headless.exe with no args prints - // usage and exits 1 rather than doing anything useful (see - // WitcherScriptMerger.Headless/CLAUDE.md's routing section) - there's no verb that's - // meaningfully "the default" for a human clicking this tile in Vortex's Tools - // dashboard (`mcp` mode just sits waiting for JSON-RPC on stdin, which looks hung to - // a human; `merge` needs GameDirectory/ModsDirectory already configured). Documented - // limitation, not an oversight - a later unit driving this programmatically - // (mcpClient.ts, or a future one-shot `merge` CLI invocation) always passes its own - // explicit `args`, bypassing this default entirely. + // `parameters: ['merge', '--overwrite']`: WitcherScriptMerger.Headless.exe with no + // args just prints usage and exits 1 (see WitcherScriptMerger.Headless/CLAUDE.md's + // routing section) - a human clicking this tile in Vortex's Tools dashboard used to + // get exactly that and nothing else, which looked like the tool silently did + // nothing (observed live: a genuine unresolved script conflict stayed unresolved + // after "running" this tile). `merge` is the one verb that's actually useful for a + // direct click; `--overwrite` mirrors resolveScriptConflicts.ts's own real-merge + // call so a plain Tools-dashboard launch resolves conflicts the same way the + // "Resolve Script Conflicts" action's confirmed merge does (including re-merging + // stale output), not a weaker subset of it. `GameDirectory`/`ModsDirectory` still + // come from `environment` below (the same `WSM_*` overrides `resolveScriptConflicts` + // passes), so this works unattended without the user having configured WSM's own + // App.config by hand. The one real difference from "Resolve Script Conflicts": no + // preview/confirmation dialog first, and results only surface via the process exit + // code/console output rather than a Vortex dialog - Vortex's discovered-tool + // launcher (`api.runExecutable`) has no callback hook to reuse that dialog flow + // itself (re-confirmed against `docs/vortex-extension-design.md` section 1: there is + // no `context.registerTool` API, `addDiscoveredTool` + a fixed executable/parameters + // pair is the entire mechanism). + parameters: ['merge', '--overwrite'], environment: options.environment ?? {}, path: options.exePath, hidden: false, From 61f72a15ff32a8986ace94f045b3be4b323a3bdc Mon Sep 17 00:00:00 2001 From: Chris Knight Date: Sat, 22 Aug 2026 00:04:39 -0400 Subject: [PATCH 2/2] Drop unrelated discoveredTool changes from this branch These two files were swept in by an over-broad 'git add -A' while the working tree carried unrelated in-progress work. They are a real behavior change to what a Tools dashboard click does (adding parameters: ['merge','--overwrite']) and belong in their own reviewed change, not riding along with a merge-engine invariant. --- vortex-extension/src/discoveredTool.test.ts | 9 ------- vortex-extension/src/discoveredTool.ts | 29 +++++++-------------- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/vortex-extension/src/discoveredTool.test.ts b/vortex-extension/src/discoveredTool.test.ts index 5cef5b1..ea40004 100644 --- a/vortex-extension/src/discoveredTool.test.ts +++ b/vortex-extension/src/discoveredTool.test.ts @@ -27,15 +27,6 @@ describe('buildWsmDiscoveredTool', () => { expect(tool.executable()).toBe('WitcherScriptMerger.Headless.exe'); }); - it('defaults to running a real, overwrite-enabled merge when launched directly from the Tools dashboard', () => { - // A bare Tools-dashboard launch has no preview/confirmation dialog to gate on (see - // resolveScriptConflicts.ts) - it must resolve conflicts outright, the same way that - // action's own confirmed (non-dry-run) merge call does, rather than printing usage - // and exiting (the pre-fix behavior with no `parameters` at all). - const tool = buildWsmDiscoveredTool({ exePath: EXE_PATH }); - expect(tool.parameters).toEqual(['merge', '--overwrite']); - }); - it('defaults environment to an empty object when none is given', () => { const tool = buildWsmDiscoveredTool({ exePath: EXE_PATH }); expect(tool.environment).toEqual({}); diff --git a/vortex-extension/src/discoveredTool.ts b/vortex-extension/src/discoveredTool.ts index c8599f8..deba641 100644 --- a/vortex-extension/src/discoveredTool.ts +++ b/vortex-extension/src/discoveredTool.ts @@ -60,26 +60,15 @@ export function buildWsmDiscoveredTool(options: WsmDiscoveredToolOptions): types // requiredFiles (which drives that scan) is deliberately empty. requiredFiles: [], executable: () => path.basename(options.exePath), - // `parameters: ['merge', '--overwrite']`: WitcherScriptMerger.Headless.exe with no - // args just prints usage and exits 1 (see WitcherScriptMerger.Headless/CLAUDE.md's - // routing section) - a human clicking this tile in Vortex's Tools dashboard used to - // get exactly that and nothing else, which looked like the tool silently did - // nothing (observed live: a genuine unresolved script conflict stayed unresolved - // after "running" this tile). `merge` is the one verb that's actually useful for a - // direct click; `--overwrite` mirrors resolveScriptConflicts.ts's own real-merge - // call so a plain Tools-dashboard launch resolves conflicts the same way the - // "Resolve Script Conflicts" action's confirmed merge does (including re-merging - // stale output), not a weaker subset of it. `GameDirectory`/`ModsDirectory` still - // come from `environment` below (the same `WSM_*` overrides `resolveScriptConflicts` - // passes), so this works unattended without the user having configured WSM's own - // App.config by hand. The one real difference from "Resolve Script Conflicts": no - // preview/confirmation dialog first, and results only surface via the process exit - // code/console output rather than a Vortex dialog - Vortex's discovered-tool - // launcher (`api.runExecutable`) has no callback hook to reuse that dialog flow - // itself (re-confirmed against `docs/vortex-extension-design.md` section 1: there is - // no `context.registerTool` API, `addDiscoveredTool` + a fixed executable/parameters - // pair is the entire mechanism). - parameters: ['merge', '--overwrite'], + // No default `parameters`: WitcherScriptMerger.Headless.exe with no args prints + // usage and exits 1 rather than doing anything useful (see + // WitcherScriptMerger.Headless/CLAUDE.md's routing section) - there's no verb that's + // meaningfully "the default" for a human clicking this tile in Vortex's Tools + // dashboard (`mcp` mode just sits waiting for JSON-RPC on stdin, which looks hung to + // a human; `merge` needs GameDirectory/ModsDirectory already configured). Documented + // limitation, not an oversight - a later unit driving this programmatically + // (mcpClient.ts, or a future one-shot `merge` CLI invocation) always passes its own + // explicit `args`, bypassing this default entirely. environment: options.environment ?? {}, path: options.exePath, hidden: false,