Skip to content

Add WitcherScriptMerger.Headless: a Linux-capable CLI/MCP-only host - #8

Closed
TheValiantOne wants to merge 34 commits into
AnotherSymbiote:masterfrom
TheValiantOne:feature/linux-headless-host
Closed

Add WitcherScriptMerger.Headless: a Linux-capable CLI/MCP-only host#8
TheValiantOne wants to merge 34 commits into
AnotherSymbiote:masterfrom
TheValiantOne:feature/linux-headless-host

Conversation

@TheValiantOne

Copy link
Copy Markdown

Summary

Adds WitcherScriptMerger.Headless, a fourth project in the solution: a slim executable that only wires up the merge CLI verb and mcp server mode — no WinForms reference at all, net10.0 (no -windows suffix), so it's architecturally buildable and runnable on Linux. This is the first concrete step toward "true headless operation for CLI/Agent interaction, focused on modded-gaming + Vortex workflows" from this batch's driving goal.

  • New project, references WitcherScriptMerger.Core only. Always uses DiffPlexMergeEngine (Core, no external binary) — there's no KDiff3MergeEngine available here (it needs Tools/KDiff3.cs's Win32 P/Invoke, which stays host-only) and no MergeEngine config switch, since this host has exactly one engine.
  • Program.cs mirrors the WinForms host's merge/mcp routing but with no GUI branch — no args or an unrecognized command prints usage and exits 1. No [STAThread], no AttachConsole P/Invoke (Windows-only, correctly left out).
  • Bundle-content conflicts (.bundle files, needing QuickBMS/wcc_lite — see docs/decisions/bundle-format-replacement-spike.md, no cross-platform replacement exists) are unsupported by design: CheckBundleContents defaults to false in this host's own App.config, and if turned on anyway, bundle conflicts fail gracefully with a clear message instead of crashing.
  • Added win-x64/linux-x64 self-contained single-file dotnet publish commands to CLAUDE.md (no existing .pubxml convention in this repo to follow).

Bugs found and fixed along the way

Getting this to actually run on Linux (not just cross-compile) surfaced real, previously-unreachable bugs in shared WitcherScriptMerger.Core code — the WinForms host is Windows-only, so nothing had exercised these paths before:

  1. Crash on every flat-file merge on Linux: ModFile.GetModNameFromPath hardcoded '\' to find a path segment. On Linux, Path.Combine-built paths use /, so IndexOf('\') always returned -1, and Substring(0, -1) threw ArgumentOutOfRangeException. Fixed to use Path.DirectorySeparatorChar.
  2. Silently-broken MCP path matching on Linux: WsmMcpTools.MergeConflicts normalized client-supplied relativePaths/orderOverrides toward a hardcoded '\' too — correct on Windows, silently wrong on Linux (where ModFile.RelativePath itself uses /). Fixed to normalize both possible separators to Path.DirectorySeparatorChar.
  3. Latent NullReferenceException on missing QuickBMS: QuickBms.GetBundleContentPaths returned null when QuickBMS couldn't be found; both ModFileIndex.BuildAsync and FileMerger.GetUnpackedFiles enumerated that directly. Unreachable on the WinForms host (always gated behind the combined Paths.ValidateDependencyPaths(), so QuickBMS is guaranteed present before this code runs) but a real crash once this new host legitimately scans without QuickBMS/wcc_lite configured. Now returns Array.Empty<string>().
  4. Latent DirectoryNotFoundException in FileMerger.GetUnpackedFiles's vanilla-bundle search on a missing content/DLC folder (a scratch/incomplete game tree) — now degrades to "no vanilla bundle found" instead.
  5. Split Paths.ValidateDependencyPaths() into ValidateTextMergeDependencies()/ValidateBundleDependencies() so a host without QuickBMS/wcc_lite can still scan/merge flat-file conflicts instead of failing outright. This also relaxes WsmMcpTools's MCP-tool dependency gating for the WinForms host's own mcp verb, not just the new host — previously a missing QuickBMS/wcc_lite path made every scan_conflicts/merge_conflicts MCP call fail outright even with zero bundle-category conflicts in the mods folder. Documented in CLAUDE.md.

All changes and rationale are documented in a new "Headless host" section in CLAUDE.md.

Test plan

  • dotnet build WitcherScriptMerger.sln succeeds (verified against both the working tree and a git archive-exported clean checkout, to confirm no untracked files are needed).
  • dotnet test (existing WitcherScriptMerger.Tests suite, 28 tests) passes unchanged.
  • dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes passes.
  • Self-contained single-file publish succeeds for win-x64 (WinForms host + new host) and linux-x64 (new host).
  • Windows: ran the published WitcherScriptMerger.Headless.exe directly against a synthetic scratch game/mods tree — merge verb correctly auto-solved one conflict (UTF-16LE+BOM output matching vanilla) and skipped a genuine conflict (git/diff3-style markers written under DiffPlexConflicts/); mcp verb's full stdio round-trip (initializetools/listtools/call) verified for all 4 tools via a hand-rolled Python client.
  • Real Linux runtime, not just cross-compilation: WSL2 (Ubuntu 20.04) happened to be available in this environment, so the published linux-x64 binary was actually run there (not just cross-compiled) — this is how bugs Fix KDiff3 & Nexus Mod Manager Compaitibility Issues Regarding Symbolic Links #1 and Latest version of the source code? #2 above were found. Confirmed on genuine Linux: merge verb against a native-filesystem scratch tree (correct merge output, correct conflict markers), mcp verb's full 4-tool round-trip including a merge_conflicts call using forward-slash paths specifically to exercise the separator fix, and the bundle-graceful-degradation path (junk .bundle file + CheckBundleContents=true + no QuickBMS → clean warning, exit code 2, no crash).
  • Bundle-category graceful-failure path verified end-to-end on both Windows and Linux with a junk (non-POTATO70-format) .bundle file, since QuickBMS isn't available in this environment to construct/unpack a real one — a genuine bundle-vs-bundle conflict relies on code inspection of the fixes above rather than an end-to-end run (matches the existing WinForms host's own "bundle path is code-reviewed but not round-tripped" status).
  • Not verified: an actual bare-metal/native Linux distribution outside WSL2 (WSL2 is a real Linux kernel and this environment's closest available approximation, but it isn't literally bare metal).

AI-assisted development: this PR was substantially produced by Claude Code, per CONTRIBUTING.md's disclosure policy. All behavior described above was independently verified by actually running the built/published binaries (including on a real Linux runtime via WSL2) rather than assumed from reading the code.

https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah

Chris Knight and others added 30 commits August 5, 2026 23:21
Migrates from .NET Framework 4.5 (2015-era csproj) to a modern SDK-style
project with implicit file globbing, System.Configuration.ConfigurationManager
for App.config compatibility, and System.IO.Hashing pulled in ahead of
replacing the hand-ported xxHash implementation. Removes unused empty
Settings.settings scaffolding and an obsolete CAS attribute the runtime
no longer honors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139CvhVjVPBuSNHFcq9TF27
Swaps the 2015 hand-ported xxHash implementation (Tools/xxHash.cs) for
the officially maintained System.IO.Hashing.XxHash32, streamed the same
way the original was. Verified byte-for-byte hash compatibility across
empty/boundary/large inputs and against a real mod file already tracked
in a live MergeInventory.xml (modBetterIcons2025_NextGen's baseEffect.ws,
recorded hash 6A33E604, matches exactly) - existing merge inventories
stay valid, no forced re-merges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139CvhVjVPBuSNHFcq9TF27
Previous commit only captured the file rename (xxHash.cs -> Hasher.cs);
the git add for the content change silently no-opped due to a bad
pathspec on the already-moved old filename, so the real replacement
never got staged. This is that content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139CvhVjVPBuSNHFcq9TF27
…otes

Ships operational guidance and coding standards openly with the repo, while
keeping session-scoped handoff notes (HANDOFF*.md) and Claude Code's own
runtime-state files out of source control for machine/session-privacy
reasons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Vanilla .ws files are UTF-16LE; mod authors' files are often plain
UTF-8/ASCII with no BOM. KDiff3 has no per-input encoding flag, and the
mismatch made it treat an entire file as unmatchable and fall back to
manual GUI resolution instead of auto-solving - confirmed against a real
conflict (baseEffect.ws). Normalize non-UTF-16LE inputs up to UTF-16LE+BOM
(matching vanilla, never down to UTF-8) before invoking KDiff3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tifact

Re-verified via .NET's Process.Start (the actual code path this app uses,
not a shell) while researching CLI mode: damageManagerProcessor.ws
auto-solves cleanly either way. The prior claim that it still needed
manual resolution came from testing through Git Bash/MSYS2, not the
real invocation method. Also documents the actual window-title signal
that distinguishes a genuine unresolved conflict from KDiff3's normal
transient startup window, found while building a synthetic guaranteed
conflict to verify this properly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Preparation for a CLI mode: Paths.GameDirectory read MainForm's textbox
directly (Program.MainForm.GameDirectorySetting), which would null-ref
with no MainForm constructed; it now reads the same underlying
Program.Settings value the textbox was only ever mirroring.

Every domain-code call into Program.MainForm.ShowMessage/ShowError/
ShowModal (FileMerger, ModFileIndex, CustomLoadOrder, KDiff3, WccLite,
QuickBms, AppSettings, Program's own launch-failure path) now goes
through a new Program.Notifier (IMergeNotifier) instead. MainForm
implements the interface directly - its existing methods already match
the shape exactly - so the GUI path is unaffected. A new
HeadlessMergeNotifier backs it for non-interactive runs: writes to the
console and returns a fixed, non-destructive default for every prompt
(don't overwrite, don't use a conflicting merge name, don't retry) so a
batch run can never hang waiting on a dialog nobody's watching.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
KDiff3 has no fail-fast mode - its own docs (doc/dothemerge.html) say a
merge window opens whenever manual interaction is needed, even in its
own batch/automation mode. RunHeadless launches it normally and detects
a stuck merge itself instead: KDiff3 always briefly shows a plain
"Conflicts" window on startup regardless of outcome (not a signal, this
was verified empirically and had been mistaken for one), but only a
genuine unresolved conflict leaves open a second window titled
"... - KDiff3", the actual comparison editor. If that persists past a
short grace period the process is killed and the merge reported as
skipped, with a generous backstop timeout as a second line of defense.

-o always targets a scratch path, copied to the real output only after
a confirmed clean exit, so a killed process can never leave a partial
file where the game would load it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`WitcherScriptMerger.exe merge [--order-file <path>]` scans for conflicts
and merges every auto-solvable one without opening any window, using the
Program.Notifier/KDiff3.RunHeadless plumbing from the last few commits.
No arguments still launches the GUI exactly as before.

FileMerger.MergeConflictsHeadless is a new, separate orchestration path
driven by FileIndex/ModFileIndex.Conflicts' plain ModFile/FileHash data -
that already carries everything needed (relative path, category, each
mod's file and hash), so no TreeNode is built for this path at all. Per-
file mod order defaults to the same LoadOrderComparer ordering
ConflictTree already sorts by, overridable per relative path via a JSON
--order-file for cases that need it. Bundle-packed conflicts aren't
included yet (checkBundles: false) - a separate pass once this flat-file
path is proven out.

Console output needs AttachConsole (P/Invoke) since the app is
OutputType=WinExe; it's called as early as possible in Program's static
init, before AppSettings even runs, so a startup failure is visible in
the invoking terminal instead of lost to an unattached console.

Verified end-to-end against real conflicting files (encoding mismatch,
auto-solves correctly, matches HANDOFF.md's pending modToxicityLevelFix
scenario) and a synthetic guaranteed conflict (correctly detected,
process killed cleanly, reported as skipped) in a scratch game/mods
tree - never against the live install.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MergeConflictsHeadless now handles Categories.BundleText alongside flat
files, reusing FileMerger's existing GetUnpackedFiles (QuickBMS unpack)
and PackNewBundle (wcc_lite repack) exactly as the interactive path
does - both were already TreeNode-free, so nothing there needed to
change. Bundle merges batch into one repack at the end, matching
MergeByTreeNodesAsync's existing behavior, and roll back to "skipped"
if the repack itself fails after content merged fine.

CLI now passes CheckBundleContents through to the conflict scan instead
of hardcoding it off.

Verified the scan + orchestration run cleanly with bundle checking
enabled (no regression against the flat-file path, confirmed over
multiple runs); didn't have a real bundle-vs-bundle conflict on hand to
round-trip through actual QuickBMS/wcc_lite content, so that specific
path is code-reviewed and mirrors the proven flat-file orchestration
but isn't end-to-end verified the way the flat-file path is - noted in
CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Covers the merge command and --order-file, the IMergeNotifier split and
why it exists, RunHeadless's window-persistence detection, and an
honest verification status split: flat-file path is end-to-end
verified, bundle path is code-reviewed and reuses proven building
blocks but hasn't been round-tripped through a real bundle conflict.
Also updates the Architecture section's now-outdated claim that domain
code reaches Program.MainForm directly, and marks CLI mode/whitespace
done in the open-goals summary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
KDiff3's pop-up window can't be hidden without breaking its ability to
auto-solve. Tested five techniques against a real auto-solve case and
a guaranteed-conflict case: WindowStyle=Hidden and =Minimized are both
silently ignored by KDiff3/Qt (window shows regardless, but nothing
breaks); ShowWindow(SW_HIDE), SetWindowPos moved off-screen, and
launching on a separate non-interactive Windows desktop all three
genuinely succeed at making the window invisible - and all three
reliably make KDiff3 hang forever at its "Conflicts" splash instead of
ever auto-solving, confirmed against a clean control that auto-solves
normally every time when left untouched. RunHeadless now accepts the
window appearing and instead attempts to restore focus to whatever had
it beforehand once KDiff3's window is confirmed gone (waits on the kill
path since Kill() is async). Plain SetForegroundWindow was empirically
denied every time due to Windows' foreground-lock rules, so this
upgrades to the standard AttachThreadInput workaround - but that was
*also* denied in every test run in this session's sandboxed automation
environment, so CLAUDE.md documents the restore as an unverified
best-effort mitigation, not a proven fix. Also documents an accidental
finding: polling faster than the current ~250ms interval hangs KDiff3
even with zero window manipulation, likely GetWindowText's cross-
process SendMessage starving its message loop - load-bearing, noted so
nobody "optimizes" it into a hang later.

Also adds `WitcherScriptMerger.exe mcp`, a stdio MCP server (official
ModelContextProtocol NuGet package) exposing four tools - scan_
conflicts, merge_conflicts, get_status, list_merges - so an MCP client
(e.g. Claude Code) can inspect conflicts and drive merges directly
instead of only through one-shot CLI invocations. Extracts the shared
scan-then-merge sequence into Cli/MergeOperations.cs, used by both the
`merge` CLI verb and the new MCP tools. Logging is routed to stderr
(stdout is reserved for MCP protocol frames), and AttachConsole is
skipped for the mcp verb since an MCP client redirects stdio itself.
The two MCP tools that touch the shared Program.Inventory are wrapped
in a lock, since an MCP client can issue concurrent tool calls and an
interleaved Load/Save would corrupt MergeInventory.xml.

Verified: the focus-restore and polling-rate findings against real
KDiff3 runs in a scratch game/mods tree; the MCP server via a hand-
rolled stdio JSON-RPC client exercising initialize, tools/list, and
all four tools (including a merge_conflicts call that hit the real
RunHeadless kill path) against the same scratch tree. Never run
against the live install.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Establishes indent_style=tab (4-wide) for .cs files going forward,
applied here via `dotnet format whitespace` across the whole solution.
Purely mechanical - confirmed with git diff --ignore-all-space that
nothing but whitespace changed, plus 6 files (added this session,
before this convention existed) picking up the UTF-8 BOM the rest of
the codebase already uses. Build verified green after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Now that this fork is a real public GitHub repo (TheValiantOne's fork
of AnotherSymbiote/WitcherScriptMerger, replacing the earlier local-
only setup), the old "direct commits to master, no PR workflow" SOP no
longer fits. main is now branch-protected on GitHub: no direct pushes,
PRs require 2 approving reviews (admin bypass left enabled so a single
maintainer isn't locked out before other reviewers are active).
CONTRIBUTING.md documents feature/fix/chore branch-per-change, what a
PR description needs to cover given there's still no test suite, and
updates the code-style section for the tabs switch in the prior commit.

Expands the AI-assisted development section into explicit rules for
outside contributors, not just the maintainer's own disclosed use:
disclosure in the PR description, the contributor owning and being
able to explain whatever an agent produced, the verification bar not
softening for agent-assisted changes (this codebase's hash-format/
encoding/KDiff3-invocation constraints are exactly the kind of thing
an agent has no way to know unless CLAUDE.md tells it, and no way to
confirm without actually running it), scrubbing machine-specific paths
picked up during an agent session before submitting, and license
awareness for agent-suggested code.

Extends .gitignore with a scoped entry for Aider's local session state,
matching the existing .claude/ runtime-state block's reasoning, plus a
note inviting the same pattern for other tools' local state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PromptToPrioritizeMergedMod called MessageBox.Show directly instead of
Program.Notifier.ShowMessage, unlike every other domain call site. Its
sole caller today is invoked only from MainForm.cs, so it was harmless
in practice, but it was a landmine for any future headless (CLI/MCP)
load-order validation path, which would otherwise hit an unmediated
WinForms MessageBox.Show with no message pump watching it.

IMergeNotifier.ShowMessage gained a trailing optional
MessageBoxDefaultButton parameter (default Button1, matching
MessageBox.Show's own default) so the prompt's Button2 (No) default
survives the move - dropping it silently would have flipped the
default action from "leave load order alone" to "rewrite
mods.settings". MainForm.ShowMessage forwards it to the 6-arg
MessageBox.Show overload; HeadlessMergeNotifier ignores it.

The MessageBoxManager relabeling of the Cancel button to "Ne&ver" is
removed rather than preserved: it depends on a SetWindowsHookEx hook
registered on the calling thread, but Program.Notifier.ShowMessage
(MainForm.ShowMessage) marshals the actual MessageBox.Show call onto
the UI thread via Invoke when called off-thread - as this call always
is, via MainForm's Task.Run - so the hook would never see the dialog's
window messages once routed through the notifier. Kept as dead code it
would look functional without being so. The Cancel button now reads
"Cancel" instead of "Never"; the DialogResult value and its handling
in ValidateAndFix are unchanged.

Also guarded ValidateAndFix's Cancel branch on
Program.Notifier.IsInteractive: HeadlessMergeNotifier's fixed
non-destructive default for YesNoCancel is Cancel, which previously
mapped to "Never" here and would have silently persisted
ValidateCustomLoadOrder=false to App.config on any future headless run
that reaches this code path - exactly the landmine this change exists
to defuse.

Verified: Program.Notifier is reassigned to MainForm in Program.cs
before Application.Run, and the only path that reaches
PromptToPrioritizeMergedMod (MainForm_Shown -> RefreshMergeInventory ->
LoadOrderValidator.ValidateAndFix) runs after Shown, never from
MainForm's constructor - so this change doesn't introduce a window
where the prompt silently goes to a HeadlessMergeNotifier instead of
the GUI.

No GUI automation harness is available in this environment; verified
by dotnet build (no new warnings), dotnet format whitespace
--verify-no-changes, and code inspection confirming button set, icon,
message text, and DialogResult handling are unchanged from the
original MessageBox.Show call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
Runs dotnet build and dotnet format whitespace --verify-no-changes on
every PR targeting main, per the recommendation in HANDOFF.md. Also
normalizes checkout line endings to CRLF (git config --global
core.autocrlf true) so the format check sees the same line endings on
the windows-latest runner as a local Windows dev machine, since .cs
files are committed as LF with no .gitattributes to normalize them.

Lightly updates CONTRIBUTING.md's pre-PR verification note to mention
this now also runs automatically in CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
Researches whether WolvenKit.Modkit/WolvenKit-7 could replace
QuickBMS + wcc_lite for .bundle unpack/repack. Finding: WolvenKit.Modkit
targets Cyberpunk 2077 (wrong engine/format entirely); WolvenKit-7 (the
actual Witcher 3 tool) has an unimplemented metadata.store writer
(confirmed via its own GitHub issue #33 and a literal TODO stub in
current source) and its own pack pipeline still shells out to the same
closed-source wcc.exe WSM already depends on. Recommends staying parked
on QuickBMS/wcc_lite (option c) rather than relicensing WSM or adding a
new external dependency for no net capability gain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
Design document for a future Vortex (Nexus Mods) extension that would
drive WSM's existing CLI (`merge`) and MCP (`scan_conflicts`,
`merge_conflicts`, `get_status`, `list_merges`) interfaces from Vortex's
TypeScript/Node extension runtime, instead of only through direct
invocation. Covers tech stack, install/setup, invocation model
(CLI-first recommended, MCP as a v2 enhancement), data model mapping
between Vortex's mod/load-order state and WSM's mods directory/
mods.settings, proposed UX, and open questions for the repo owner.

No code changes - this unit is explicitly design-only per this batch's
plan; TypeScript/Vortex implementation is deferred to a later batch.
Grounded directly against this repo's CLAUDE.md and against Vortex's
actual, current game-witcher3 extension source (fetched via `gh api`)
to document real prior art - including its GUI-only tool invocation,
its existing WitcherScriptMerger.exe.config rewriting, and its
MergeInventory.xml/Collection-import behavior - rather than assuming
or inventing capabilities on either side.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
- LoadOrderValidator.cs: simplify back to unqualified MessageBoxButtons/
  MessageBoxIcon/MessageBoxDefaultButton now that the fully-qualified
  form (matched literally to the task's example) reads as inconsistent
  next to the same file's own unqualified DialogResult usage and the
  rest of the codebase's convention wherever `using System.Windows.Forms;`
  is already present.
- LoadOrderValidator.cs: expand the comment on the dropped "Ne&ver"
  relabel. Review correctly pointed out the old MessageBoxManager hook
  genuinely worked before this change (MessageBox.Show ran directly on
  the same background thread Register() hooked, no Invoke involved) -
  this is a real, disclosed regression in how the Cancel button reads,
  not a no-op cleanup, and the comment now says so plainly along with
  why it can't be preserved through Program.Notifier without extending
  IMergeNotifier with custom button-text support (out of scope here).
- HeadlessMergeNotifier.cs: comment on why defaultButton is accepted
  but not consulted when choosing the headless DialogResult, so it
  doesn't read as an oversight to a future caller relying on it.

Not addressed here, flagged for other units instead: HeadlessMergeNotifier
.Write already routes MessageBoxIcon.None messages to stdout, which is a
pre-existing MCP stdout-hygiene risk unrelated to this change (belongs
to the MCP-hardening unit); MainForm.cs's PromptToDeleteForChangedHash
has an analogous still-"Ne&ver"-labeled prompt that now reads
inconsistently with this one, but that method is GUI-layer code outside
LoadOrderValidator.cs's scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
Move all WinForms-free domain code (FileIndex, Inventory, LoadOrder,
Tools/Hasher+QuickBms+WccLite, Paths, AppSettings, Cli, Mcp) into a new
net10.0 WitcherScriptMerger.Core class library, so it can eventually run
on Linux. The host project keeps Forms/Controls, KDiff3's Win32 P/Invoke,
and Program.cs, referencing Core via ProjectReference.

Key design changes required to make the split compile, beyond plain file
moves - full rationale in the PR description:

- AppState (Core): Notifier/Settings/LoadOrder/Inventory/MergeEngine used
  to be static fields on the host's Program class; moved to a new Core
  class since Core code needs them and can never reference the host
  assembly. Program.cs re-exposes them as pass-through properties so
  host call sites didn't need to change.
- IMergeNotifier neutralized: MessageBoxButtons/MessageBoxIcon/DialogResult
  replaced with NotifyButtons/NotifyIcon/NotifyResult; MainForm translates
  to/from the real WinForms types. ShowModal(Form) dropped from the
  interface entirely - every call site was GUI-only, so the host calls
  MainForm.ShowModal directly instead.
- IMergeEngine (Core) + KDiff3MergeEngine (host): scaffolding so
  FileMerger can move to Core while KDiff3.cs's Win32 P/Invoke stays
  host-only this wave.
- FileMerger split: Core keeps headless + interactive merge orchestration
  (now driven by plain InteractiveMergeRequest data, not TreeNode); the
  host's new InteractiveMergeRunner extracts that data from TreeNodes,
  owns the BackgroundWorker, and shows report forms/plays sounds via
  OnMergeReport/OnPackReport callbacks.
- Extensions.cs split: pure string helpers moved to Core's
  StringExtensions.cs; TreeNode/TreeView WinForms helpers stayed host-side.
- LoadOrderValidator.PromptToPrioritizeMergedMod routed through
  IMergeNotifier instead of a direct MessageBox.Show call (was flagged as
  needing this fix to move into Core at all).

dotnet build WitcherScriptMerger.sln succeeds with the same 5 pre-existing
warnings (all host-side CA1823, unrelated to this change) and zero
warnings/errors from WitcherScriptMerger.Core. dotnet format whitespace
--verify-no-changes passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
- Fix a real regression: Program no longer has an explicit static
  constructor after Notifier/Settings/LoadOrder/Inventory became
  pass-through properties to AppState, so it picked up beforefieldinit -
  under which the CLR is free to defer _consoleAttached's field
  initializer (MaybeAttachConsole()) past Main() entirely, since nothing
  in Main() touches a field of Program anymore. Confirmed empirically
  with an isolated repro (mirrors the exact shape: side-effecting field
  initializer + pass-through property to another type + no static ctor)
  - without an explicit static Program() {}, the initializer's side
    effect never ran before Main() read the forwarded property; with it,
    ordering was correct every time. This likely explains an unexplained
    empty-output CLI invocation earlier in this same session.
- Rename NotifyIcon -> DialogIcon: the original name, declared in the
  root WitcherScriptMerger namespace that every host file already sees
  via nested-namespace lookup, would have silently shadowed
  System.Windows.Forms.NotifyIcon (the tray-icon class) for any
  unqualified reference in host code.
- Add IMergeNotifier.ShowMessage(..., defaultResult) so
  LoadOrderValidator's "Custom Load Order Problem" prompt can still
  request "No" as the pre-focused button, matching the direct
  MessageBox.Show(..., MessageBoxDefaultButton.Button2) call it replaced.
  This was previously mischaracterized as a purely cosmetic loss; it
  wasn't - an accidental Enter/Space keypress could have silently
  rewritten the user's real mods.settings file.
- Remove FileMerger.InteractiveMergeRequest.OrderedModNames: redundant
  with OrderedSources[i].Name at its only read site (the pre-loop
  ConfirmRemainingConflict gate, which always runs before any element of
  OrderedSources is touched).
- Remove IMergeNotifier.IsInteractive: confirmed dead code (zero read
  call sites) both before and after the Core split.
- Make Categories.* fields readonly: closes off silent breakage of the
  reference-equality checks used throughout the codebase
  (`category == Categories.Script`) now that they're public (required
  for cross-assembly access) rather than merely assembly-internal.
- Add code comments acknowledging two accepted, lower-severity
  tradeoffs raised in review: QuickBms/WccLite's ExePath fields staying
  publicly mutable (matches the pre-existing, legitimate
  KDiff3.ExePath-via-DependencyForm mutation pattern) and
  Paths.ValidateDependencyPaths() reading AppState.MergeEngine as a
  global rather than via DI (matches the pre-existing
  AppState.Notifier/Settings pattern this PR extends, not a new
  anti-pattern).

Re-verified after these fixes: dotnet build clean (same 5 pre-existing
warnings), dotnet format --verify-no-changes passes, CLI merge and MCP
tools/list + merge_conflicts round-trip both still succeed against the
scratch tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
1. LoadOrderValidator's YesNoCancel "Custom Load Order Problem" prompt:
   its Cancel button permanently disables ValidateCustomLoadOrder, but
   after the Core split it just reads "Cancel" with nothing marking it
   destructive (the old MessageBoxManager "Ne&ver" relabeling has no
   IMergeNotifier equivalent, and was likely already silently broken
   pre-split regardless - AppDomain.GetCurrentThreadId() doesn't
   reliably return the real Win32 thread ID its SetWindowsHookEx call
   needs). Spelled the Yes/No/Cancel semantics out explicitly in the
   message body instead, where it doesn't depend on any button-caption
   mechanism working.
2. HeadlessMergeNotifier now honors ShowMessage's defaultResult instead
   of always falling through to its generic per-button-set guess -
   that generic guess (Cancel is safest for YesNoCancel) is actually
   the *destructive* choice for LoadOrderValidator's prompt specifically,
   contradicting IMergeNotifier's own non-destructive-default contract.
   This PR is what made LoadOrderValidator reachable from Core (and so,
   in principle, from a headless entry point) in the first place, so
   fixed at the root rather than left contingent on no headless caller
   ever reaching it.
3. InteractiveMergeRunner.MergeByTreeNodesAsync: moved TreeNode
   extraction back inside BackgroundWorker.DoWork instead of running it
   synchronously on the UI thread beforehand, matching the pre-split
   threading model. ExtractRequest dereferences node metadata with no
   null/type check; outside DoWork, an exception there now throws
   synchronously from btnMergeFiles_Click, which modern .NET WinForms
   terminates the process for by default - DoWork instead captures it
   into RunWorkerCompletedEventArgs.Error (still unread by
   OnMergeComplete, same as before this PR), so this restores the
   original "silently does nothing" failure mode rather than a crash.
4. CLAUDE.md: updated the Architecture section (opening description,
   folder map split into Core/host, a new "Interactive vs. headless
   split" subsection) and corrected the IMergeNotifier section's now-
   false claim that MainForm's translation is behavior-identical to the
   old direct calls (see fix 1). Full CLAUDE.md federation is a later
   unit's job - this just stops the doc from actively lying to it and
   to the other Wave-0 units reading it as ground truth in the meantime.
5. WitcherScriptMerger.Core.csproj now references the same
   DeadCodeDetection.ruleset the host project uses, so the ~20 files
   that moved to Core don't silently drop out of dead-code analysis.
6. Factored MergeTextInteractive/MergeTextHeadless's identical
   AddModToMerge bookkeeping (which sources to record, and which to
   skip because they're the output file itself or an intermediate
   bundle-merge byproduct) into one shared RecordMergedSources helper -
   this bookkeeping feeds MergeInventory.xml's hashes, which are
   load-bearing, so a future fix now has one call site, not two that
   can silently drift apart.
7. FileMerger's constructor now throws a clear ArgumentNullException if
   given a null IMergeEngine, instead of leaving AppState.MergeEngine's
   null default (unlike its siblings Notifier/Settings, which both get
   real defaults) to eventually surface as an unhandled
   NullReferenceException deep inside Merge()/MergeHeadless() the first
   time some future entry point (a test harness, the Linux CLI/MCP-only
   host planned for a later unit) constructs a FileMerger without going
   through Program.Main's startup sequence first.

Re-verified after all seven: dotnet build clean (same 5 pre-existing
warnings), dotnet format --verify-no-changes passes, CLI merge and the
full MCP tool round-trip both still succeed against the scratch tree,
GUI still launches and stays responsive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
…gebox-notifier

Route LoadOrderValidator's prompt through Program.Notifier
Add GitHub Actions CI (build + format check on PRs)
Research spike: WolvenKit as a QuickBMS/wcc_lite replacement
…n-doc

Add Vortex extension design doc (Unit 4, design only)
Paired with a branch-protection change (required approving reviews: 2 -> 1,
require_code_owner_reviews: true) so a single approval from a listed owner
satisfies the review requirement, while approvals from other GitHub users
(this is a public repo) no longer count on their own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
…oject

# Conflicts:
#	WitcherScriptMerger.Core/LoadOrder/LoadOrderValidator.cs
#	WitcherScriptMerger/Forms/MainForm.cs
#	WitcherScriptMerger/HeadlessMergeNotifier.cs
#	WitcherScriptMerger/IMergeNotifier.cs
Split into WitcherScriptMerger.Core + host GUI/CLI/MCP project
…g, dry-run, order-file audit

merge_conflicts now validates relativePaths and orderOverrides keys against
Paths.ModsDirectory (proper Path.GetFullPath-based prefix check, not naive
StartsWith) before any scan or merge runs, rejecting absolute/UNC/`..`-escaping
entries with a clear error. Neither value was actually joined into a filesystem
path anywhere in this codebase - this closes no live traversal, it's
defense-in-depth against that changing later, and it fixes the "silently
matches nothing" gap for a malicious-looking relativePaths entry.

Audited orderOverrides for other misuse: values were already whitelisted via
ModFile.ContainsMod before reaching Path.Combine, but the validation had two
real gaps - a partial/duplicate mod list would silently merge an incomplete or
self-paired chain and still report success, and a single-remaining-real-source
override (reachable once a file's already-merged output re-enters
conflict.Mods as a pseudo-source) would report "merged" having done nothing.
FileMerger.ResolveMergeOrder now requires at least two entries, no duplicates,
and every real source mod covered (excluding the configured merged-mod name
itself, matching scan_conflicts's own documented guidance for re-merging).
This validation is shared with the `merge` CLI verb's --order-file, so a
pre-existing partial order-file that used to be silently accepted now gets
rejected per-file instead.

Added merge_conflicts's dryRun mode: previews which conflicts would auto-solve
without writing merged output, repacking a bundle, or modifying
MergeInventory.xml. Distinct from scan_conflicts's alreadyResolved (which only
re-checks existing merge records) since dryRun actually exercises the merge
engine for currently-unresolved conflicts. Output is redirected under
TempBundleContent instead of the real destination; MergeInventory.Load gained
an allowSave flag since it can otherwise write to disk on its own (backfilling
an old record's missing hash) before dryRun is ever consulted; a dry run now
also predicts the same "output already exists, declined" outcome a real run
would hit, so the two don't disagree on a conflict whose output is already on
disk.

Documented minimal required permissions in a new Mcp/CLAUDE.md and updated the
root CLAUDE.md's MCP mode section.

AI-assisted: implemented by Claude Code per repo convention (CONTRIBUTING.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
TheValiantOne and others added 4 commits August 7, 2026 17:14
Harden MCP tools for minimal-rights operation
Adds DiffPlexMergeEngine, an in-process alternative to KDiff3MergeEngine
built on the MIT-licensed DiffPlex package, plus WitcherScriptMerger.Tests
(xunit), the repo's first automated test project. Not the default engine:
DiffPlex's ThreeWayDiffer has a confirmed upstream bug that can produce
internally inconsistent diff-block metadata on multi-edit conflicts
(measured 0.35%-38.89% failure rate depending on edit density) -
DiffPlexMergeEngine detects and safely refuses rather than trusting
corrupted output, but this is a real reliability gap KDiff3 doesn't share.

Also fixes two things code review and end-to-end testing surfaced that
undermined this PR's own AppState.Settings laziness fix: Paths.cs's
eager static field initializers, and a non-atomic Settings lazy-init race.

AI-assisted development per CONTRIBUTING.md.
Add DiffPlex-based merge engine and a test project
New project (net10.0, no -windows suffix, references Core only) that wires
up just the merge CLI verb and mcp server mode - no WinForms, no GUI
fallback. Always uses DiffPlexMergeEngine (Core, no external binary), since
KDiff3MergeEngine needs Win32 P/Invoke that stays host-only.

Getting this actually working on Linux (verified via WSL2, not just a
cross-compile check) surfaced and fixed real bugs in shared Core code:
- ModFile.GetModNameFromPath hardcoded '\' to find a path segment, crashing
  every flat-file merge on Linux (Path.Combine uses '/' there). Fixed to use
  Path.DirectorySeparatorChar.
- WsmMcpTools.MergeConflicts normalized client-supplied paths toward a
  hardcoded '\' too, silently breaking relativePaths/orderOverrides
  matching on Linux. Same fix.
- QuickBms.GetBundleContentPaths returned null when QuickBMS wasn't found,
  which ModFileIndex.BuildAsync and FileMerger.GetUnpackedFiles both
  enumerated directly - unreachable on the WinForms host (always gated
  behind the combined Paths.ValidateDependencyPaths()) but a real
  NullReferenceException once WitcherScriptMerger.Headless legitimately
  scans without QuickBMS/wcc_lite configured. Returns Array.Empty<string>()
  instead now.
- FileMerger.GetUnpackedFiles's vanilla-bundle search also threw
  DirectoryNotFoundException on a missing content/DLC folder (a scratch/
  incomplete game tree) - now degrades to "no vanilla bundle found" instead.

Split Paths.ValidateDependencyPaths() into ValidateTextMergeDependencies()
and ValidateBundleDependencies() so a host without QuickBMS/wcc_lite can
still scan/merge flat-file conflicts; bundle-category conflicts fail
gracefully per-conflict instead (ModFileIndex.BuildAsync prints one clear
message per scan and skips bundle checking rather than crashing or being
noisy per-bundle). This also relaxes WsmMcpTools's MCP-tool gating for the
WinForms host's own mcp verb, not just the new host - documented in
CLAUDE.md.

Verified: full build, existing test suite, and dotnet format whitespace all
pass. Self-contained single-file win-x64 and linux-x64 publishes both
succeed. Actually ran the published linux-x64 binary under WSL2 (real Linux
kernel) for both the merge and mcp verbs against synthetic scratch mods,
including the bundle-graceful-degradation path - not just a cross-compile
check. Added matching win-x64/linux-x64 publish commands to CLAUDE.md (no
existing .pubxml convention to follow).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
@TheValiantOne

Copy link
Copy Markdown
Author

Opened against the wrong repo by mistake (this branch is part of ongoing work on a downstream fork, TheValiantOne/WitcherScriptMerger, and was not intended for upstream). Apologies for the noise — closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant