diff --git a/CLAUDE.md b/CLAUDE.md index 2ff8a21..6d88a33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# CLAUDE.md +# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. @@ -14,7 +14,16 @@ This is a fork of the upstream `AnotherSymbiote/WitcherScriptMerger` repo (still - Run (GUI, no args): launch the built `WitcherScriptMerger.exe`, or `dotnet run --project WitcherScriptMerger/WitcherScriptMerger.csproj`. At startup the app validates `KDiff3Path`/`QuickBmsPath`/`QuickBmsPluginPath`/`WccLitePath` from `App.config` (`Paths.ValidateDependencyPaths` in `WitcherScriptMerger/Paths.cs`) and shows a blocking `DependencyForm` if any are missing — the external binaries (KDiff3, QuickBMS, wcc_lite) are **not** in source control (see "External tool dependencies" below), so a fresh checkout won't run end-to-end without sourcing them separately. - Run (CLI, headless): `WitcherScriptMerger.exe merge [--order-file ]` — see "CLI mode" below. Any arguments at all route to the CLI path instead of the GUI. - Run (MCP server): `WitcherScriptMerger.exe mcp` — see "MCP mode" below. Speaks MCP over stdio; not meant to be run interactively from a terminal. -- Three projects, one `.sln`: `WitcherScriptMerger.Core` (WinForms-free class library, `net10.0`), `WitcherScriptMerger` (the WinForms host — GUI + CLI + MCP entry points, `net10.0-windows7.0`, references Core), and `WitcherScriptMerger.Tests` (xunit, `net10.0`, references Core only). See "Architecture" below for what lives where. +- Run (Linux-capable CLI/MCP-only host): `WitcherScriptMerger.Headless.exe merge [--order-file ]` or `WitcherScriptMerger.Headless.exe mcp` — see "Headless host (WitcherScriptMerger.Headless)" below. Same verbs, no GUI, flat-file conflicts only. +- Four projects, one `.sln`: `WitcherScriptMerger.Core` (WinForms-free class library, `net10.0`), `WitcherScriptMerger` (the WinForms host — GUI + CLI + MCP entry points, `net10.0-windows7.0`, references Core), `WitcherScriptMerger.Headless` (the Linux-capable CLI/MCP-only host, `net10.0`, references Core only), and `WitcherScriptMerger.Tests` (xunit, `net10.0`, references Core only). See "Architecture" below for what lives where. +- **Publishing self-contained single-file binaries** (no existing `.pubxml` profiles in this repo — these commands are the documented convention instead): + - WinForms host, `win-x64` only (it's a WinForms app — never makes sense on Linux): + `dotnet publish WitcherScriptMerger/WitcherScriptMerger.csproj -r win-x64 --self-contained -p:PublishSingleFile=true -c Release` + - Headless host, `win-x64`: + `dotnet publish WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj -r win-x64 --self-contained -p:PublishSingleFile=true -c Release` + - Headless host, `linux-x64` (cross-compiles fine from Windows — producing the binary doesn't require a Linux machine, only *running* it does): + `dotnet publish WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj -r linux-x64 --self-contained -p:PublishSingleFile=true -c Release` + - Each publish's `.dll.config` (the `App.config` copy `System.Configuration.ConfigurationManager` actually reads) lands next to the executable — copy it there if deploying the exe on its own. Confirmed empirically that this resolves correctly even in a single-file publish, despite `Assembly.GetEntryAssembly().Location` being documented (and confirmed here too, via a real build's `IL3000` warning) to always return `""` for a single-file-bundled assembly: `ConfigurationManager.OpenExeConfiguration("")` still finds and reads the real `.dll.config` sitting beside the actual running executable, both with and without that file present (missing-config still correctly triggers `AppSettings`'s existing `Environment.Exit(1)` path) — no `AppSettings.cs` change was needed for single-file publishing to work. ### Tests @@ -25,10 +34,11 @@ This is a fork of the upstream `AnotherSymbiote/WitcherScriptMerger` repo (still ## Architecture -Three projects as of the Core/host split plus the later addition of a test project (see git history around "Split into WitcherScriptMerger.Core + host GUI/CLI/MCP project" for the Core/host rationale): +Four projects as of the Core/host split, the later addition of a test project, and the still-later addition of the Linux-capable Headless host (see git history around "Split into WitcherScriptMerger.Core + host GUI/CLI/MCP project" for the Core/host rationale): - **`WitcherScriptMerger.Core`** (`WitcherScriptMerger.Core/WitcherScriptMerger.Core.csproj`, `net10.0`, no WinForms reference — deliberately cross-platform-capable, toward eventual Linux support) holds all domain logic: file scanning, merge orchestration, load-order handling, settings/paths, and the CLI/MCP entry-point logic. Nothing in Core references `System.Windows.Forms`. - **`WitcherScriptMerger`** (`WitcherScriptMerger/WitcherScriptMerger.csproj`, `net10.0-windows7.0`, `WinExe`, `UseWindowsForms=true`) is the host: WinForms GUI (`Forms/`, `Controls/`), the three entry points (`Program.cs`), and the one remaining external-tool wrapper with Win32 P/Invoke (`Tools/KDiff3.cs`). References Core via `ProjectReference`. +- **`WitcherScriptMerger.Headless`** (`WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj`, `net10.0` — no `-windows` suffix, `Exe`) is the Linux-capable CLI/MCP-only host — no GUI, no reference to the WinForms host project at all. References Core only. See "Headless host (WitcherScriptMerger.Headless)" below for full detail. - **`WitcherScriptMerger.Tests`** (`WitcherScriptMerger.Tests/WitcherScriptMerger.Tests.csproj`, xunit, `net10.0`) references Core only — see "Tests" above for what it covers and its `AppState.Settings`-safety constraints. There is no MVC/MVP split within the host — `Forms/MainForm.cs` (~1000 lines) is a monolithic orchestrator that directly owns the tree controls, constructs `ModFileIndex`/drives merges, and wires up async callbacks. @@ -51,6 +61,8 @@ Folder map — **host**: - `Tools/` — `KDiff3.cs` (Win32 P/Invoke for window-title polling — stays host-only for now; a later unit removes it entirely), `KDiff3MergeEngine.cs` (the one real `IMergeEngine` implementation using KDiff3 — `DiffPlexMergeEngine`, Core, is the other one; see "Interactive vs. headless split" below for both). - Root: `Program.cs` (entry point: GUI, CLI, and MCP), `Extensions.cs` (WinForms-specific `TreeNode`/`TreeView` helpers and Win32 P/Invoke — pure string helpers live in Core's `StringExtensions.cs` instead), `TaskbarProgress.cs`, `App.config`. +Folder map — **Headless**: just `Program.cs` (entry point: CLI and MCP, no GUI) and its own `App.config` — see "Headless host (WitcherScriptMerger.Headless)" below. + ### Interactive vs. headless split (`FileMerger` / `IMergeEngine`) Core's `FileMerger` never sees a `TreeNode`, `BackgroundWorker`, or `Forms.*` type. Its headless methods (`MergeConflictsHeadless` et al.) are unchanged in shape from before the split. Its interactive methods (`MergeFilesInteractive`, `MergeFlatFileInteractive`, `MergeBundleFileInteractive`, `MergeTextInteractive`) take a plain `InteractiveMergeRequest` (relative path, bundle flag, vanilla file path, ordered `MergeSource[]`) instead of `TreeNode[]`, and report back through `OnMergeReport`/`OnPackReport` callbacks instead of constructing `MergeReportForm`/`PackReportForm` directly. The host's `Inventory/InteractiveMergeRunner.cs` is the thing `MainForm` actually talks to: it extracts `InteractiveMergeRequest`s from checked `TreeNode`s (inside its `BackgroundWorker`'s `DoWork`, matching the pre-split threading model — extracting outside `DoWork` let a bad node throw synchronously on the UI thread instead of being captured by `BackgroundWorker`), owns the `BackgroundWorker`, and supplies the `OnMergeReport`/`OnPackReport` callbacks (report forms, completion sounds). @@ -95,10 +107,25 @@ The CLI path (`FileMerger.MergeConflictsHeadless`) is a separate, parallel orche - **Why stdio, and why logging goes to stderr**: an MCP client spawns WSM as a child process and communicates over its redirected stdin/stdout pipes, so stdout must stay reserved for protocol frames — `RunMcp` configures `builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace)` to keep the SDK's own request-handler logging off stdout. `Program.MaybeAttachConsole()` also skips `AttachConsole` for the `mcp` verb specifically (checks `args[1]` before calling it) — there's no parent console to attach to in this scenario, and it's pointless at best. Verified: a hand-rolled stdio client (`initialize` → `tools/list` → `tools/call` for each tool) round-tripped clean JSON-RPC on stdout with all SDK logging correctly landing on stderr. - **Tools** (`Mcp/WsmMcpTools.cs`, `[McpServerToolType]` static class): `scan_conflicts` (no args — scans and returns every conflict's relative path, category, per-mod hashes, default merge order, and whether it's already resolved), `merge_conflicts` (optional `relativePaths` to restrict to specific files, optional `orderOverrides` — same shape as the CLI's `--order-file`, minus the file, and validated per-file the same way (see "CLI mode" above — no duplicates, every real source mod covered), optional `dryRun` to preview which conflicts would auto-solve without writing merged output, repacking a bundle, or touching `MergeInventory.xml`; returns `{merged: [...], skipped: [...], unmatched: [...], dryRun}` — `unmatched` lists any requested `relativePaths` entry that's in-scope but no longer a detected conflict), `get_status` (dependency validation, resolved game/mods directories, configured merged-mod name, current conflict count), `list_merges` (enumerates `MergeInventory.xml`'s existing `Merge` records). All four reuse `Cli/MergeOperations` and the same `IMergeNotifier`/`HeadlessMergeNotifier` machinery as the `merge` CLI verb — merge decisions get the same safe non-destructive defaults either way. +- **Dependency gating only requires the text-merge engine, not QuickBMS/wcc_lite too**: `scan_conflicts`/`merge_conflicts` (via `WsmMcpTools.RequireDependenciesAndModsDirectory`) and `get_status`'s `conflictCount` computation all gate on `Paths.ValidateTextMergeDependencies()` rather than the combined `Paths.ValidateDependencyPaths()` — added for `WitcherScriptMerger.Headless` (see "Headless host" below), which has no QuickBMS/wcc_lite bundled at all, but this is a real behavior change for the WinForms host's MCP mode too, not just the new host: previously, a missing QuickBMS/wcc_lite path made every `scan_conflicts`/`merge_conflicts` call fail outright, even for a mods folder with zero bundle-category conflicts. `get_status` now reports `textMergeDependenciesValid`/`bundleDependenciesValid` separately alongside the original combined `dependenciesValid` (kept for existing callers). Bundle-category conflicts still fail per-conflict, gracefully, when QuickBMS/wcc_lite aren't available — see `Paths.ValidateBundleDependencies`, `Tools/QuickBms.IsAvailable`, and `FileIndex/ModFileIndex.BuildAsync`'s single up-front warning (not per-bundle) when bundle checking can't proceed. - **Directory allow-listing**: `merge_conflicts`'s `relativePaths` and `orderOverrides` keys are validated (`WsmMcpTools.EnsureInScope`/`IsWithinModsDirectory`) to resolve inside `Paths.ModsDirectory` before any scan or merge runs — an absolute path, UNC path, or `..\`-escaping entry is rejected with a clear error instead of silently matching nothing. Neither value is actually joined into a filesystem path anywhere in this codebase today (`relativePaths` is only ever compared for equality against already-scanned `ModFile.RelativePath` values; `orderOverrides` values reach `Path.Combine` in `FileMerger.GetModFile` but only after being validated against `ModFile.ContainsMod`, a whitelist of real scanned mod folder names) — this check is defense-in-depth, not a fix for a live traversal. This check applies mods-directory-relative semantics uniformly to every category; for `Categories.BundleText` specifically, `conflict.RelativePath` is actually a path *internal to a bundle archive* (from `QuickBms.GetBundleContentPaths`), not one rooted at `Paths.ModsDirectory` — an ordinary internal path (e.g. `engine\foo.ws`) still validates fine, but a bundle whose internal listing itself contained a rooted or `..`-bearing entry would make that specific conflict unreachable via `relativePaths` (rejected as out-of-scope even though it's a legitimate conflict). Unexercised: every scratch config used to verify this unit has `CheckBundleContents=false`, consistent with the bundle path's existing "code-reviewed but not round-tripped" verification status below. See `Mcp/CLAUDE.md` for the minimal-permissions summary this unit added. - **State per call, not cached across calls**: every tool call re-scans (`ModFileIndex.BuildAsync`) and re-loads `MergeInventory.Load(Paths.Inventory)` fresh rather than keeping a long-lived server-side cache, since the mods folder or `MergeInventory.xml` can change between calls (including from a concurrently-running GUI instance) and there's no test suite to catch a staleness bug. - **Verification status**: smoke-tested end-to-end against the same scratch game/mods tree used for CLI mode verification — `initialize`, `tools/list`, and all four tools called via a hand-rolled stdio client, including a `merge_conflicts` call that exercised the real `KDiff3.RunHeadless` path (detected a genuine conflict, killed the stuck process, returned it in `skipped`). Never run against a live install. The directory-allow-listing and `dryRun` additions were verified against a real KDiff3 stand-in (dependency paths present but not a real KDiff3.exe, so merges reliably fail/skip) via the same stdio-client approach, plus an in-process harness against `WitcherScriptMerger.Core` directly with a fake `IMergeEngine` (real KDiff3/QuickBMS/wcc_lite weren't available in that verification environment) — see the PR that introduced them for exactly what each covered. +### Headless host (`WitcherScriptMerger.Headless`) + +`WitcherScriptMerger.Headless` (`WitcherScriptMerger.Headless/`, `net10.0`, `Exe`, no `-windows` TFM suffix) is a second, much smaller executable: only the `merge` CLI verb and `mcp` server mode exist here, with no WinForms reference anywhere in the project and no GUI code path to fall back to — a first concrete step toward "true headless operation for CLI/Agent interaction, focused on modded-gaming + Vortex workflows." References `WitcherScriptMerger.Core` only. Build/run/publish commands are under "Build & run" above. + +- **Routing** (`WitcherScriptMerger.Headless/Program.cs`): mirrors `WitcherScriptMerger/Program.cs`'s `args[0] == "merge"` / `args[0] == "mcp"` dispatch, but with no third (no-args-launches-GUI) branch — no args, or an unrecognized first argument, prints usage to stderr and exits 1. `AppState.MergeEngine` is set unconditionally to `new DiffPlexMergeEngine()` — there's no `KDiff3MergeEngine` available here (it needs `Tools/KDiff3.cs`'s Win32 P/Invoke, which stays host-only) and no `MergeEngine` App.config switch either, since this host has exactly one engine, always. `Environment.CurrentDirectory = AppContext.BaseDirectory` is set as the very first statement (before touching `AppState.Settings`/`Paths` at all) for the same reason `Program.RunCli` does it on the WinForms host — several Core paths (`Paths.Inventory`, `Paths.TempBundleContent`, `Paths.DiffPlexConflictsDirectory`, `Paths.MergedBundleContentAbsolute`'s field initializer) are relative to it. No `[STAThread]`, no `AttachConsole`/`MaybeAttachConsole` P/Invoke (`kernel32.dll`, Windows-only) — this project has nothing console-attach-shaped to do (it's always launched as a plain console app, and stdout is reserved for MCP protocol frames in `mcp` mode regardless), so that Windows-specific mechanism was left out entirely rather than ported. The actual scan/merge/MCP-tool orchestration is unchanged, shared Core code (`Cli/MergeOperations.cs`, `Mcp/WsmMcpTools.cs`) — this project only replicates the thin CLI argument-parsing/dispatch glue around it, which was small enough not to warrant extracting into Core too. +- **Flat-file conflicts only — bundle-content conflicts are unsupported, by design, not by oversight.** This host has no QuickBMS/wcc_lite bundled at all (see "External tool dependencies" below and `docs/decisions/bundle-format-replacement-spike.md` — no cross-platform replacement was found to exist). `App.config`'s `CheckBundleContents` defaults to `false` here (unlike the WinForms host's `true`) specifically so a normal run never touches bundle scanning at all. If a user turns it on anyway (or points `QuickBmsPath`/`WccLitePath` at real, sourced-separately Windows binaries — those settings still exist here and work if this host happens to be run on Windows), bundle-category conflicts fail gracefully rather than crashing: `FileIndex/ModFileIndex.BuildAsync` checks `Tools/QuickBms.IsAvailable` (exe + plugin both present) once per scan, not once per bundle, and if unavailable, prints one clear message ("Bundle-content conflicts aren't supported without QuickBMS and wcc_lite configured…") and skips bundle scanning entirely for that run instead of attempting it. This replaced a real crash this unit found by code inspection: `Tools/QuickBms.GetBundleContentPaths` used to return `null` when QuickBMS couldn't be found, and both `ModFileIndex.BuildAsync` and `Inventory/FileMerger.GetUnpackedFiles` enumerated that return value directly — reachable for the first time by this host, since the WinForms host always gates bundle-category scanning behind the combined `Paths.ValidateDependencyPaths()` (real QuickBMS guaranteed present) before it's ever reached. Fixed at the source: `GetBundleContentPaths` now returns `Array.Empty()` instead of `null`. `FileMerger.GetUnpackedFiles`'s vanilla-bundle search (`Directory.GetDirectories(Paths.BundlesDirectory)`/`Paths.DlcDirectory`) is also guarded against a missing `content`/`DLC` directory now (`DirectoryNotFoundException` otherwise) — for the same reason, a scratch/incomplete game tree can now reach this code without a full real Witcher 3 install backing it. Verified end-to-end in a scratch tree: a mod folder containing a junk `.bundle` file, with `CheckBundleContents=true` and no QuickBMS configured, scans and merges cleanly (flat-file conflicts still merge/skip correctly; the bundle file is never opened at all) with the one clear warning message and no exception, on both Windows and (see below) real Linux. +- **Cross-platform path-separator bugs found and fixed via real Linux testing, not just cross-compilation.** Two genuine bugs surfaced only by actually running the `linux-x64` publish under WSL2 (a real Linux kernel, not just a cross-compile target check) — building/publishing for `linux-x64` alone would not have caught either: + - `FileIndex/ModFile.GetModNameFromPath` used a hardcoded `'\\'` to find the mod-folder-name segment of a full path. On Linux, `Path.Combine`-built paths use `/`, so `IndexOf('\\')` always returned `-1`, and the subsequent `Substring(0, -1)` threw `ArgumentOutOfRangeException` on literally the first flat-file merge attempted — a hard crash on every `merge` invocation. Fixed to use `Path.DirectorySeparatorChar`. + - `Mcp/WsmMcpTools.cs`'s `merge_conflicts` normalized a client-supplied `relativePaths`/`orderOverrides` key by replacing `/` with a hardcoded `'\\'` to match `ModFile.RelativePath`'s separator convention — correct on the WinForms host (always Windows), silently wrong on Linux, where `ModFile.RelativePath` itself uses `/`: a client sending a `/`-separated path (the natural style on any OS) would get "normalized" to `\`-separated, never match, and land in `unmatched` looking like it wasn't a real conflict at all. Fixed to normalize both possible separators to `Path.DirectorySeparatorChar` instead of assuming `\`. + + Neither bug is specific to this new project — both live in shared `WitcherScriptMerger.Core` code — but neither was reachable before this unit, since the WinForms host is Windows-only. Grepped the rest of Core for the same hardcoded-`'\\'`/`"\\"` pattern after finding these two; no other occurrences remained. +- **Publish-time config loading verified safe for single-file publishing** — see "Build & run" above for the empirical finding (`ConfigurationManager.OpenExeConfiguration("")` still resolves the real `.dll.config` next to a single-file-bundled exe, despite `Assembly.GetEntryAssembly().Location` returning `""` there) — no `AppSettings.cs` change was needed. +- **Verification status**: `dotnet build WitcherScriptMerger.sln` and `dotnet test` (existing `WitcherScriptMerger.Tests` suite) both pass with these changes. Self-contained single-file `win-x64` and `linux-x64` publishes both succeed. Beyond that — unusually for a change in this repo, given no Linux machine is normally available in this environment — this unit was verified against a **real Linux runtime**, not just a successful cross-compile: WSL2 (Ubuntu 20.04, genuine Linux kernel, both `/mnt/c`-mounted and native `ext4`-backed scratch trees) was available in the development environment this time and used to actually run the published `linux-x64` binary. Confirmed there: the `merge` verb against synthetic scratch mods (one auto-solvable conflict, correctly merged with UTF-16LE+BOM output matching vanilla's encoding; one genuine conflict, correctly skipped with git/diff3-style conflict markers written under `DiffPlexConflicts/` and surviving process exit); the `mcp` verb's full stdio round-trip (`initialize` → `tools/list` → `tools/call` for all four tools, including a `merge_conflicts` call using a forward-slash `relativePaths` entry specifically to exercise the separator-normalization fix above); and the bundle-graceful-degradation path (junk `.bundle` file, `CheckBundleContents=true`, no QuickBMS — clean warning, no crash, exit code 2). The equivalent Windows-side checks (both verbs, both self-contained single-file publishes) were also run and matched in shape. Not verified: an actual bare-metal/native Linux distribution outside WSL2, and the bundle path was only exercised with a junk (non-POTATO70-format) `.bundle` file — a real bundle-vs-bundle conflict was judged impractical to construct without QuickBMS/wcc_lite (matching the WinForms host's own "bundle path is code-reviewed but not round-tripped" status — see "CLI mode" above), so that specific scenario relies on code inspection of the fixes described above, not an end-to-end run. + ### Compatibility constraints - **Hash format is load-bearing.** `MergeInventory.xml` (including real, already-populated files on developer machines) stores per-file hashes compared by string equality to detect when a mod source file has changed since it was last merged. Any change to `Tools/Hasher.cs` must produce byte-for-byte identical output to the current implementation, or every existing recorded merge silently "goes stale." Verify with the synthetic-edge-cases + real-recorded-hash cross-check pattern described under Tests. diff --git a/WitcherScriptMerger.Core/FileIndex/ModFile.cs b/WitcherScriptMerger.Core/FileIndex/ModFile.cs index a7d9c58..4a52a63 100644 --- a/WitcherScriptMerger.Core/FileIndex/ModFile.cs +++ b/WitcherScriptMerger.Core/FileIndex/ModFile.cs @@ -96,7 +96,16 @@ public static string GetModNameFromPath(string modFilePath) var nameStart = Paths.ModsDirectory.Length + 1; var name = modFilePath.Substring(nameStart); - return name.Substring(0, name.IndexOf('\\')); + + // Path.DirectorySeparatorChar, not a hardcoded '\\': modFilePath is built via + // Path.Combine (directly or through Paths.GetRelativePath's substring logic + // over an OS-walked path), which uses '/' on Linux - confirmed by direct + // crash repro under WSL2 (WitcherScriptMerger.Headless, the Linux-capable + // host, running a real merge): the old hardcoded '\\' made IndexOf return -1 + // on every Linux path, throwing ArgumentOutOfRangeException from the + // Substring call below on literally every flat-file merge attempt. Flagged in + // code review, see CLAUDE.md. + return name.Substring(0, name.IndexOf(Path.DirectorySeparatorChar)); } public static bool IsScript(string path) => path.EndsWithIgnoreCase(".ws"); diff --git a/WitcherScriptMerger.Core/FileIndex/ModFileIndex.cs b/WitcherScriptMerger.Core/FileIndex/ModFileIndex.cs index 5fbfac1..8185a7a 100644 --- a/WitcherScriptMerger.Core/FileIndex/ModFileIndex.cs +++ b/WitcherScriptMerger.Core/FileIndex/ModFileIndex.cs @@ -44,6 +44,28 @@ public void BuildAsync( AppState.Notifier.ShowMessage("Can't find any mods in the Mods directory."); } + // Checked once up front, not per bundle: QuickBms.GetBundleContentPaths already + // reports (and now tolerates - see its own comment) a missing QuickBMS/wcc_lite + // per bundle it's asked about, but that's needlessly noisy across a whole scan, + // and WitcherScriptMerger.Headless (the Linux-capable CLI/MCP-only host, no + // QuickBMS/wcc_lite bundled at all - see its CLAUDE.md section) deliberately + // doesn't gate scanning on Paths.ValidateDependencyPaths() first, so this is the + // first point in a scan where that host's missing bundle tooling surfaces. One + // clear message beats one per bundle. BundleCount (below) still counts every + // *.bundle file found regardless of whether checking could proceed - unchanged + // from before this gate, and consistent with ScriptCount/XmlCount, which also + // count regardless of checkScripts/checkXml - only the actual per-file + // conflict-scanning loop is skipped here. + var canCheckBundles = checkBundles && QuickBms.IsAvailable; + if (checkBundles && !canCheckBundles) + { + AppState.Notifier.ShowMessage( + "Bundle-content conflicts aren't supported without QuickBMS and wcc_lite configured - skipping bundle-content checking for this scan.", + "Bundle Checking Unavailable", + NotifyButtons.OK, + DialogIcon.Warning); + } + var bgWorker = new BackgroundWorker { WorkerReportsProgress = true @@ -72,7 +94,7 @@ public void BuildAsync( { Files.AddRange(GetModFilesFromPaths(xmlPaths, Categories.Xml, modName)); } - if (checkBundles) + if (canCheckBundles) { foreach (var bundlePath in bundlePaths) { @@ -83,7 +105,7 @@ public void BuildAsync( var progressPct = (int)((float)++i / modDirPaths.Count * 100f); bgWorker.ReportProgress(progressPct, modName as object); } - if (checkBundles) + if (canCheckBundles) System.Threading.Thread.Sleep(500); // Wait for progress bar to fill completely }; bgWorker.RunWorkerCompleted += completedHandler; diff --git a/WitcherScriptMerger.Core/Inventory/FileMerger.cs b/WitcherScriptMerger.Core/Inventory/FileMerger.cs index 20ae1ae..77738f9 100644 --- a/WitcherScriptMerger.Core/Inventory/FileMerger.cs +++ b/WitcherScriptMerger.Core/Inventory/FileMerger.cs @@ -668,13 +668,28 @@ bool GetUnpackedFiles(string contentRelativePath, ref MergeSource source1, ref M { ProgressInfo.CurrentAction = "Searching for corresponding vanilla bundle"; + // Directory.GetDirectories throws DirectoryNotFoundException on a missing + // root - guarded here (rather than assuming GameDirectory always has real + // "content"/"DLC" subfolders) so a scratch/incomplete game tree degrades to + // "no vanilla bundle found" (handled below, and ultimately by each + // IMergeEngine as a graceful "needs manual resolution" skip - see + // DiffPlexMergeEngine.MergeHeadless's hasVanillaVersion guard) instead of an + // unhandled exception. Previously unreachable on the WinForms host, which + // always gates bundle-category scanning behind Paths.ValidateDependencyPaths() + // (and therefore a real game install) first - but WitcherScriptMerger.Headless + // deliberately doesn't require QuickBMS/wcc_lite to attempt flat-file merges, so + // a bundle conflict can now reach this code without one. Flagged in code review, + // see CLAUDE.md. var bundleDirs = - Directory.GetDirectories(Paths.BundlesDirectory) - .Select(path => Path.Combine(path, "bundles")) + (Directory.Exists(Paths.BundlesDirectory) + ? Directory.GetDirectories(Paths.BundlesDirectory).Select(path => Path.Combine(path, "bundles")) + : Enumerable.Empty()) .Concat( - Directory.GetDirectories(Paths.DlcDirectory) - .Where(path => new Regex("DLC[0-9]*$").IsMatch(path) || new Regex("ep[0-9]$").IsMatch(path)) - .Select(path => Path.Combine(path, Paths.BundleBase, "bundles")) + Directory.Exists(Paths.DlcDirectory) + ? Directory.GetDirectories(Paths.DlcDirectory) + .Where(path => new Regex("DLC[0-9]*$").IsMatch(path) || new Regex("ep[0-9]$").IsMatch(path)) + .Select(path => Path.Combine(path, Paths.BundleBase, "bundles")) + : Enumerable.Empty() ) .Where(path => Directory.Exists(path)) .OrderBy(path => path, new LoadOrderComparer()) diff --git a/WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs b/WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs index 16a3bf0..13a87d2 100644 --- a/WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs +++ b/WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs @@ -77,14 +77,21 @@ public static object MergeConflicts( if (string.IsNullOrWhiteSpace(mergedModName)) throw new InvalidOperationException("MergedModName isn't configured in App.config."); - // ModFile.RelativePath always uses '\' (built via Path.Combine/GetRelativePath - // on Windows). A client-supplied relativePaths entry using '/' already passes - // IsWithinModsDirectory's scope check (Path.GetFullPath normalizes separators), - // but a raw EqualsIgnoreCase against RelativePath below would not - normalize - // here so an in-scope path in a different, still-valid separator style doesn't - // silently fail to match its own conflict and land in `unmatched` looking like - // it was never a conflict at all. - var normalizedRelativePaths = relativePaths?.Select(p => p.Replace('/', '\\')).ToArray(); + // ModFile.RelativePath always uses the host OS's native separator (built via + // Path.Combine/GetRelativePath over an OS-walked path - '\' on the WinForms + // host, '/' on WitcherScriptMerger.Headless when it's actually running on + // Linux). A client-supplied relativePaths entry using the other separator + // already passes IsWithinModsDirectory's scope check (Path.GetFullPath + // normalizes separators), but a raw EqualsIgnoreCase against RelativePath + // below would not - normalize both possible separators to + // Path.DirectorySeparatorChar here so an in-scope path in a different, still- + // valid separator style doesn't silently fail to match its own conflict and + // land in `unmatched` looking like it was never a conflict at all. Hardcoded + // to '\\' until this repo's Linux host existed - see ModFile.GetModNameFromPath + // for a related, worse bug (an outright crash) from the same wrong assumption. + var normalizedRelativePaths = relativePaths? + .Select(p => p.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar)) + .ToArray(); lock (_inventoryLock) { @@ -113,15 +120,16 @@ public static object MergeConflicts( // orderOverrides keys are matched against conflict.RelativePath elsewhere // (FileMerger.ResolveMergeOrder) via a plain Dictionary lookup, which - built // from JSON with no comparer specified - is ordinal case-sensitive by - // default and wouldn't tolerate a '/'-separated key either. Rebuilding it - // here (case-insensitive comparer, '\' separators) keeps that lookup - // consistent with every other path/name comparison in this codebase, so a - // differently-cased or differently-separated but otherwise-correct key - // isn't silently ignored. + // default and wouldn't tolerate a differently-separated key either. + // Rebuilding it here (case-insensitive comparer, normalized to + // Path.DirectorySeparatorChar - see normalizedRelativePaths above for why + // it's not hardcoded to '\\') keeps that lookup consistent with every other + // path/name comparison in this codebase, so a differently-cased or + // differently-separated but otherwise-correct key isn't silently ignored. var normalizedOrderOverrides = orderOverrides == null ? null : orderOverrides.ToDictionary( - kv => kv.Key.Replace('/', '\\'), + kv => kv.Key.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar), kv => kv.Value, StringComparer.OrdinalIgnoreCase); @@ -140,22 +148,35 @@ public static object MergeConflicts( [McpServerTool(Name = "get_status"), Description( "Reports WSM's current configuration and dependency status: resolved game/mods " + - "directories, whether KDiff3/QuickBMS/wcc_lite are all found, the configured " + - "merged-mod name, and the current conflict count.")] + "directories, whether the text-merge engine (KDiff3 or DiffPlex) and QuickBMS/" + + "wcc_lite are found, the configured merged-mod name, and the current conflict " + + "count. textMergeDependenciesValid alone is enough for flat-file (.ws/.xml) " + + "conflicts; bundleDependenciesValid additionally gates bundle-content conflicts " + + "- a host with no QuickBMS/wcc_lite configured can still scan/merge flat-file " + + "conflicts with only the former true.")] public static object GetStatus() { - var dependenciesValid = Paths.ValidateDependencyPaths(); + // Split rather than the combined Paths.ValidateDependencyPaths() so a host + // without QuickBMS/wcc_lite (e.g. WitcherScriptMerger.Headless) doesn't report a + // conflictCount of 0 just because bundle tooling is missing - see + // RequireDependenciesAndModsDirectory below for the same split applied to + // scan_conflicts/merge_conflicts. dependenciesValid is kept for existing callers + // that only checked the combined flag. + var textMergeDependenciesValid = Paths.ValidateTextMergeDependencies(); + var bundleDependenciesValid = Paths.ValidateBundleDependencies(); var modsDirectoryExists = Directory.Exists(Paths.ModsDirectory); var conflictCount = 0; - if (dependenciesValid && modsDirectoryExists) + if (textMergeDependenciesValid && modsDirectoryExists) conflictCount = MergeOperations.ScanConflicts().Conflicts.Count(); return new { gameDirectory = Paths.GameDirectory, modsDirectory = Paths.ModsDirectory, - dependenciesValid, + dependenciesValid = textMergeDependenciesValid && bundleDependenciesValid, + textMergeDependenciesValid, + bundleDependenciesValid, modsDirectoryExists, mergedModName = AppState.Settings.Get("MergedModName"), conflictCount, @@ -177,11 +198,22 @@ public static object ListMerges() }).ToArray(); } + // Only the text-merge engine is required to let scan_conflicts/merge_conflicts run + // at all - not QuickBMS/wcc_lite too. That used to be one combined + // Paths.ValidateDependencyPaths() check, which meant a host with no QuickBMS/ + // wcc_lite configured (WitcherScriptMerger.Headless) could never scan or merge + // even its supported flat-file (.ws/.xml) conflicts. This is a behavior relaxation + // for the WinForms host's MCP mode too, not just the new host - see CLAUDE.md and + // the PR that introduced this split. Bundle-category conflicts still fail + // gracefully per-conflict when QuickBMS/wcc_lite aren't available (see + // QuickBms.IsAvailable's callers, ModFileIndex.BuildAsync, and + // FileMerger.GetUnpackedFiles) rather than being silently attempted and left + // looking like a hard requirement was still being enforced here. static void RequireDependenciesAndModsDirectory() { - if (!Paths.ValidateDependencyPaths()) + if (!Paths.ValidateTextMergeDependencies()) throw new InvalidOperationException( - "A required dependency (KDiff3, QuickBMS, or wcc_lite) is missing. Configure its path in App.config."); + "The configured text-merge engine (KDiff3 or DiffPlex) is missing or misconfigured."); if (!Directory.Exists(Paths.ModsDirectory)) throw new InvalidOperationException("Mods directory not found - check GameDirectory/ModsDirectory in App.config."); diff --git a/WitcherScriptMerger.Core/Paths.cs b/WitcherScriptMerger.Core/Paths.cs index 1f38c36..03f0f5a 100644 --- a/WitcherScriptMerger.Core/Paths.cs +++ b/WitcherScriptMerger.Core/Paths.cs @@ -91,12 +91,32 @@ public static string GetRelativePath(string fullPath, string basePath) // system; a null MergeEngine here reads as "dependency missing" rather than // "not initialized yet", which could be a confusing message if that // invariant is ever broken by a future entry point. - public static bool ValidateDependencyPaths() + // Split out from ValidateDependencyPaths (below) so a host that only supports + // flat-file (.ws/.xml) conflicts - WitcherScriptMerger.Headless, the Linux-capable + // CLI/MCP-only host, which has no QuickBMS/wcc_lite bundled at all (see its + // CLAUDE.md section and docs/decisions/bundle-format-replacement-spike.md) - can + // gate merging on just the text-merge engine, without also requiring bundle + // tooling it deliberately doesn't ship. Bundle-category conflicts still fail + // gracefully per-conflict when attempted without QuickBMS/wcc_lite (see + // QuickBms.IsAvailable's callers and FileMerger.GetUnpackedFiles) - this split + // doesn't change that, it only changes what gates a *scan/merge run starting at + // all*. + public static bool ValidateTextMergeDependencies() + { + return AppState.MergeEngine != null && AppState.MergeEngine.ValidateExePath(); + } + + // See ValidateTextMergeDependencies above for why this is separate. + public static bool ValidateBundleDependencies() { - return (AppState.MergeEngine != null && AppState.MergeEngine.ValidateExePath() && - File.Exists(QuickBms.ExePath) && + return File.Exists(QuickBms.ExePath) && File.Exists(QuickBms.PluginPath) && - File.Exists(WccLite.ExePath)); + File.Exists(WccLite.ExePath); + } + + public static bool ValidateDependencyPaths() + { + return ValidateTextMergeDependencies() && ValidateBundleDependencies(); } public static bool ValidateModsDirectory() diff --git a/WitcherScriptMerger.Core/Tools/QuickBms.cs b/WitcherScriptMerger.Core/Tools/QuickBms.cs index 0de50c8..31960ee 100644 --- a/WitcherScriptMerger.Core/Tools/QuickBms.cs +++ b/WitcherScriptMerger.Core/Tools/QuickBms.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -10,6 +11,15 @@ public static class QuickBms public static string ExePath = AppState.Settings.Get("QuickBmsPath"); public static string PluginPath = AppState.Settings.Get("QuickBmsPluginPath"); + // Whether QuickBMS itself (exe + plugin) can be found at all, independent of any + // specific bundle file - lets a caller that's about to scan many bundles (e.g. + // ModFileIndex.BuildAsync) check once up front instead of hitting + // ValidateResources' per-bundle "Can't find QuickBMS..." message once per bundle. + // Added for WitcherScriptMerger.Headless, the Linux-capable CLI/MCP-only host, + // which has no bundled QuickBMS/wcc_lite at all - see its CLAUDE.md section and + // docs/decisions/bundle-format-replacement-spike.md. + public static bool IsAvailable => File.Exists(ExePath) && File.Exists(PluginPath); + public static int UnpackFile(string bundlePath, string contentRelativePath, string outputDir) { if (!ValidateResources(bundlePath)) @@ -42,10 +52,18 @@ public static int UnpackFile(string bundlePath, string contentRelativePath, stri } } + // Returns Array.Empty (never null) when the bundle or QuickBMS itself + // can't be found: callers (ModFileIndex.BuildAsync, FileMerger.GetUnpackedFiles) + // enumerate the result directly, and a null here used to be a real NullReferenceException + // hazard reachable as soon as a caller stopped gating scans behind + // Paths.ValidateDependencyPaths() first - which WitcherScriptMerger.Headless does + // deliberately, so flat-file-only merging still works without QuickBMS/wcc_lite + // configured. ValidateResources already reports a clear error for why. Flagged in + // code review, see CLAUDE.md. public static string[] GetBundleContentPaths(string bundlePath) { if (!ValidateResources(bundlePath)) - return null; + return Array.Empty(); var contentPaths = new List(); diff --git a/WitcherScriptMerger.Headless/App.config b/WitcherScriptMerger.Headless/App.config new file mode 100644 index 0000000..40bf16f --- /dev/null +++ b/WitcherScriptMerger.Headless/App.config @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/WitcherScriptMerger.Headless/Program.cs b/WitcherScriptMerger.Headless/Program.cs new file mode 100644 index 0000000..eb2e1fc --- /dev/null +++ b/WitcherScriptMerger.Headless/Program.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using WitcherScriptMerger.Cli; +using WitcherScriptMerger.Inventory; +using WitcherScriptMerger.LoadOrder; +using WitcherScriptMerger.Mcp; +using WitcherScriptMerger.Tools; + +namespace WitcherScriptMerger.Headless +{ + // Entry point for the Linux-capable, CLI/MCP-only host - see CLAUDE.md's "Headless + // host (WitcherScriptMerger.Headless)" section. Deliberately a much smaller mirror of + // WitcherScriptMerger/Program.cs: only the "merge" and "mcp" verbs exist here, there's + // no GUI branch at all (no System.Windows.Forms reference in this project, so there's + // nothing that *could* launch one), and nothing Windows-specific (no [STAThread], no + // AttachConsole P/Invoke - see MaybeAttachConsole's comment on the WinForms host for + // why that one is Windows-only) appears here. RunCli/RunMcp's actual orchestration + // (scan/merge sequencing, the MCP tool implementations) already lives in + // WitcherScriptMerger.Core's Cli/MergeOperations.cs and Mcp/WsmMcpTools.cs, shared with + // the WinForms host - this class only replicates the thin routing/argument-parsing + // glue around those, which was small enough not to warrant extracting into Core too. + static class Program + { + static int Main(string[] args) + { + // Several Core paths are relative to Environment.CurrentDirectory + // (Paths.MergedBundleContentAbsolute's field initializer, Paths.Inventory, + // Paths.DiffPlexConflictsDirectory, Paths.TempBundleContent) - must be set + // before anything touches Paths or AppState.Settings. Mirrors + // WitcherScriptMerger/Program.cs's RunCli doing the same as its first + // statement; this host has no no-args-launches-GUI branch to worry about + // leaving unreset, so it's safe to do this unconditionally as the very first + // thing, before even inspecting args. + Environment.CurrentDirectory = AppContext.BaseDirectory; + + // The only IMergeEngine implementation available here - KDiff3MergeEngine + // needs Tools/KDiff3.cs's Win32 P/Invoke, which stays in the WinForms host + // project and can't be referenced from a project meant to build and run on + // Linux. Unlike WitcherScriptMerger/Program.cs, there's no "MergeEngine" + // App.config switch here at all - this host has exactly one engine, always. + AppState.MergeEngine = new DiffPlexMergeEngine(); + + if (args.Length == 0) + { + PrintUsage(); + return 1; + } + + if (args[0] == "mcp") + return RunMcp(); + + if (args[0] == "merge") + return RunMerge(args); + + Console.Error.WriteLine($"Unknown command '{args[0]}'. Supported commands: merge, mcp"); + PrintUsage(); + return 1; + } + + static void PrintUsage() + { + Console.Error.WriteLine("WitcherScriptMerger.Headless - CLI/MCP-only host (no GUI)."); + Console.Error.WriteLine(); + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine(" WitcherScriptMerger.Headless merge [--order-file ]"); + Console.Error.WriteLine(" WitcherScriptMerger.Headless mcp"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Supports flat-file (.ws/.xml) conflicts only - bundle-content conflicts"); + Console.Error.WriteLine("require QuickBMS/wcc_lite, which this host doesn't bundle. See CLAUDE.md."); + } + + // Mirrors WitcherScriptMerger/Program.cs's RunCli's "merge" branch. Exit codes + // match that host's: 0 = every conflict merged, 1 = couldn't even start (bad + // args/config/deps), 2 = ran, but one or more conflicts were skipped. + static int RunMerge(string[] args) + { + if (!AppState.Settings.HasConfigFile) + { + Console.Error.WriteLine("Config file is missing."); + return 1; + } + + // Only the text-merge engine (DiffPlexMergeEngine, always) is required to + // start a merge run here - not QuickBMS/wcc_lite too. This host has no + // QuickBMS/wcc_lite bundled at all (see CLAUDE.md and + // docs/decisions/bundle-format-replacement-spike.md), so requiring the full + // Paths.ValidateDependencyPaths() check (as the WinForms host's CLI verb + // does) would mean this host could never merge even its supported flat-file + // (.ws/.xml) conflicts. Bundle-category conflicts still fail gracefully, + // per-conflict, when actually attempted without QuickBMS/wcc_lite configured + // - see ModFileIndex.BuildAsync and FileMerger.GetUnpackedFiles (Core). + if (!Paths.ValidateTextMergeDependencies()) + { + AppState.Notifier.ShowError( + "The configured text-merge engine is missing or misconfigured. This shouldn't " + + "happen with the built-in DiffPlex engine - check for a corrupted install."); + return 1; + } + + string orderFilePath = null; + for (int i = 1; i < args.Length; ++i) + { + if (args[i] == "--order-file" && i + 1 < args.Length) + orderFilePath = args[++i]; + else + { + Console.Error.WriteLine($"Unknown argument: {args[i]}"); + return 1; + } + } + + IReadOnlyDictionary orderOverrides = null; + if (orderFilePath != null && !TryLoadOrderFile(orderFilePath, out orderOverrides)) + return 1; + + if (!Paths.ValidateModsDirectory()) + return 1; + + var mergedModName = Paths.RetrieveMergedModName(); + if (string.IsNullOrWhiteSpace(mergedModName)) + return 1; + + AppState.LoadOrder = new CustomLoadOrder(); + AppState.Inventory = MergeInventory.Load(Paths.Inventory); + + var modIndex = MergeOperations.ScanConflicts(); + + if (!modIndex.HasConflict) + { + Console.WriteLine("No conflicts found."); + return 0; + } + + var summary = MergeOperations.RunMerge(AppState.Inventory, modIndex.Conflicts, mergedModName, orderOverrides); + + AppState.Inventory.Save(); + + Console.WriteLine($"Merged {summary.Merged.Count} file(s), skipped {summary.Skipped.Count}."); + foreach (var path in summary.Skipped) + Console.WriteLine($" skipped: {path}"); + + return summary.Skipped.Count == 0 ? 0 : 2; + } + + static bool TryLoadOrderFile(string path, out IReadOnlyDictionary orderOverrides) + { + orderOverrides = null; + try + { + var json = File.ReadAllText(path); + orderOverrides = JsonSerializer.Deserialize>(json); + return true; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to read order file '{path}': {ex.Message}"); + return false; + } + } + + // Runs an MCP server over stdio - mirrors WitcherScriptMerger/Program.cs's RunMcp + // exactly (same tool assembly, same stdout/stderr split). See CLAUDE.md's MCP + // mode section. Only requires the text-merge engine, not QuickBMS/wcc_lite - see + // RunMerge's comment above and WsmMcpTools.RequireDependenciesAndModsDirectory + // (Core), which applies the identical relaxation to scan_conflicts/ + // merge_conflicts. + static int RunMcp() + { + if (!AppState.Settings.HasConfigFile) + { + Console.Error.WriteLine("Config file is missing."); + return 1; + } + + if (!Paths.ValidateTextMergeDependencies()) + { + Console.Error.WriteLine( + "The configured text-merge engine is missing or misconfigured. This shouldn't " + + "happen with the built-in DiffPlex engine - check for a corrupted install."); + return 1; + } + + var builder = Host.CreateApplicationBuilder(); + + // stdout is reserved for MCP protocol frames - all logging must go to stderr. + builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + + // WsmMcpTools lives in WitcherScriptMerger.Core, not this (entry/calling) + // assembly - the parameterless WithToolsFromAssembly() overload only scans the + // calling assembly, which would silently register zero tools (server starts, + // `initialize` succeeds, `tools/list` returns an empty array) if left as-is. + // Pass the Core assembly explicitly - same fix WitcherScriptMerger/Program.cs + // needed for the identical reason. + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(typeof(WsmMcpTools).Assembly); + + builder.Build().RunAsync().GetAwaiter().GetResult(); + return 0; + } + } +} diff --git a/WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj b/WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj new file mode 100644 index 0000000..111ddfd --- /dev/null +++ b/WitcherScriptMerger.Headless/WitcherScriptMerger.Headless.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + WitcherScriptMerger.Headless + WitcherScriptMerger.Headless + disable + disable + ..\WitcherScriptMerger\DeadCodeDetection.ruleset + + + + + + + + + + + + + + + + diff --git a/WitcherScriptMerger.sln b/WitcherScriptMerger.sln index 30174bb..7411938 100644 --- a/WitcherScriptMerger.sln +++ b/WitcherScriptMerger.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WitcherScriptMerger.Core", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WitcherScriptMerger.Tests", "WitcherScriptMerger.Tests\WitcherScriptMerger.Tests.csproj", "{401B0543-E5DB-4AAA-86BF-A7B84E6C6175}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WitcherScriptMerger.Headless", "WitcherScriptMerger.Headless\WitcherScriptMerger.Headless.csproj", "{62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,18 @@ Global {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Release|x64.Build.0 = Release|Any CPU {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Release|x86.ActiveCfg = Release|Any CPU {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Release|x86.Build.0 = Release|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Debug|Any CPU.Build.0 = Debug|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Debug|x64.ActiveCfg = Debug|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Debug|x64.Build.0 = Debug|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Debug|x86.ActiveCfg = Debug|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Debug|x86.Build.0 = Debug|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Release|Any CPU.ActiveCfg = Release|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Release|Any CPU.Build.0 = Release|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Release|x64.ActiveCfg = Release|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Release|x64.Build.0 = Release|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Release|x86.ActiveCfg = Release|Any CPU + {62BFD4E9-870E-4B51-BDB9-6E63CBFE5665}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE