diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..78a118b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +end_of_line = crlf +insert_final_newline = true +charset = utf-8 + +[*.cs] +indent_style = tab +indent_size = 4 +charset = utf-8-bom + +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ff2ad4d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# Only reviews from listed code owners count toward the required approving +# review on protected branches (see branch protection settings on `main`). +# This exists because the repo is public - without it, any GitHub user's +# approval would count toward the review requirement, not just this project's +# maintainer(s). +* @TheValiantOne diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..298146f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,42 @@ +name: Build + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + build: + name: Build & format check + runs-on: windows-latest + + steps: + # .editorconfig requires CRLF line endings, and .cs files are committed + # as LF (no .gitattributes normalizes this). Force checkout to produce + # CRLF so `dotnet format whitespace` sees the same line endings as a + # local Windows dev machine, regardless of the runner image's git default. + - name: Configure git to check out CRLF line endings + run: git config --global core.autocrlf true + + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + cache: true + # No packages.lock.json in this repo (setup-dotnet's cache default), + # so key the NuGet cache off the csproj's pinned package versions instead. + cache-dependency-path: WitcherScriptMerger/WitcherScriptMerger.csproj + + - name: Restore + run: dotnet restore WitcherScriptMerger.sln + + - name: Build + run: dotnet build WitcherScriptMerger.sln --no-restore --configuration Release + + - name: Verify formatting + run: dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes diff --git a/.gitignore b/.gitignore index 6349413..9500e48 100644 --- a/.gitignore +++ b/.gitignore @@ -197,3 +197,31 @@ FakesAssemblies/ # Visual Studio 6 workspace options file *.opt + +# Local-only Claude Code session/handoff notes (machine-specific paths, +# in-progress personal task context) - not meant to be shared in the repo. +HANDOFF*.md + +# Claude Code runtime state (scheduled tasks, worktrees, checkpoints, etc.) +# Mirrors this machine's .git/info/exclude so the exclusion travels with the +# repo instead of depending on local, unshared git config. Deliberately +# scoped to runtime state, not a blanket .claude/ ignore - things like +# .claude/commands/ or .claude/agents/ may be intentionally committed. +.claude/scheduled_tasks.lock +.claude/scheduled_tasks.json +.claude/routines/.state/ +.claude/worktrees/ +.claude/checkpoints/ +.claude/mailbox/ +.claude/agent-registry.json +.claude/agent-memory-local +.claude/first-run +.claude/assistant-daemon-state.json + +# Aider's local session/cache state - same reasoning as the .claude/ block above. +.aider* + +# If you're using another agentic coding tool that writes local-only runtime +# state into this repo (session logs, caches, scratch indexes), add a scoped +# entry here rather than committing it - see the AI-assisted development +# section of CONTRIBUTING.md. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6d88a33 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,156 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project overview + +Script Merger for The Witcher 3 — a Windows desktop tool (WinForms, not WPF) that detects and merges conflicting mod script files. It scans a mod folder, finds `.ws`/`.xml` files (including inside `.bundle` packages) that multiple mods modify, and drives a 3-way merge (vanilla + mod1 + mod2) via the external tool KDiff3. `.bundle` package contents are unpacked with QuickBMS and repacked with wcc_lite. + +This is a fork of the upstream `AnotherSymbiote/WitcherScriptMerger` repo (still the `origin` remote — no separate fork exists yet), currently mid-modernization. See `HANDOFF.md` at the repo root for the full rationale behind the fork and detailed gotchas hit during the .NET modernization — read it before picking up follow-on work in this repo. Of its original list of open goals, whitespace/diff-noise and a CLI mode (see "CLI mode" below) are done; dependency-packaging/licensing decisions are still open. An MCP server mode (see "MCP mode" below) was added afterward, beyond that original list, to let an MCP client (e.g. Claude Code) drive merges directly instead of only through the CLI. + +## Build & run + +- Build: `dotnet build WitcherScriptMerger.sln` from the repo root. +- 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. +- 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 + +`WitcherScriptMerger.Tests` (xunit) covers `WitcherScriptMerger.Core` — run with `dotnet test WitcherScriptMerger.Tests/WitcherScriptMerger.Tests.csproj` (or `dotnet test WitcherScriptMerger.sln`). It does **not** reference the host project (no WinForms, no `Tools/KDiff3.cs`), so `KDiff3MergeEngine`/`KDiff3.cs` still have no automated coverage — only `DiffPlexMergeEngine` and other Core-side logic (`Hasher`, `FileEncoding`) do. +- Tests never construct `FileMerger.MergeSource` via `MergeSource.FromFlatFile`/`FromBundle` (both call `ModFile.GetModNameFromPath` → `Paths.ModsDirectory` → `AppState.Settings`) or otherwise force `AppState.Settings` to construct outside a real GUI/CLI/MCP entry point: `AppSettings`'s constructor calls `Environment.Exit(1)` if it can't find a config file next to `Assembly.GetEntryAssembly().Location`, and in a `dotnet test` host (`testhost.dll`, no matching `.config`) that kills the entire test process, not just one test. `AppState.Settings` is a lazy property specifically so that merely touching `AppState.Notifier` (which Core code — e.g. `DiffPlexMergeEngine`'s headless skip/guard messages — legitimately does on its own) doesn't also force `Settings` to construct; see `AppState.cs` and "Startup flow" below. `Paths.cs`'s own properties (`ScriptsDirectory`/`ModsDirectory`/`IsScriptsDirectoryDerived`/`IsModsDirectoryDerived`) read `AppState.Settings.Get(...)` on every access rather than caching the result via a static field initializer, for the identical reason one layer further out: a field initializer there would've forced `Settings` to construct merely from touching an unrelated static member of `Paths` (e.g. `GetRelativePath`), via C#'s beforefieldinit semantics, which would've silently undermined `AppState.Settings`' own laziness. `Tools/DiffPlexMergeEngine.GetConflictMarkerPath` reads only the compile-time-literal `Paths.DiffPlexConflictsDirectory` const, which never triggers `Paths`' type initializer at all, so it's safe to call from tests unconditionally. Tests that need a `FileMerger.MergeSource` build it directly via object-initializer syntax instead (its fields are all public). +- A few tests optionally cross-check against a real Witcher 3 + WitcherScriptMerger install (a live `MergeInventory.xml`'s recorded hashes, or the real `KDiff3.exe` binary for an auto-solvable-only A/B check against `DiffPlexMergeEngine`) via `WitcherScriptMerger.Tests/LiveInstall.cs`, gated entirely on the `WSM_TEST_GAME_DIR` environment variable (unset by default) — never a hardcoded or scanned path, per this repo's "scrub machine-specific paths" rule (see `CONTRIBUTING.md`). They silently no-op when it's unset. +- For anything not covered by the test project — especially further hash-output, `MergeInventory.xml` schema, or KDiff3-invocation changes — the precedent set in `HANDOFF.md` still applies: a disposable, non-committed `dotnet new console` scratch app, exercising synthetic edge cases plus a cross-check against a real value already recorded in a live `MergeInventory.xml`. Follow that pattern rather than assuming API docs alone are sufficient, particularly for anything hash- or serialization-related (see "Compatibility constraints" below). + +## Architecture + +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. + +Domain code in Core doesn't call into WinForms directly — it goes through `AppState.Notifier` (an `IMergeNotifier`, defined against neutral `NotifyResult`/`NotifyButtons`/`DialogIcon` types so Core never needs `System.Windows.Forms`), which is what makes CLI mode possible. `MainForm` (host) implements `IMergeNotifier` too, translating those neutral types to/from real `MessageBox.Show(...)`/`DialogResult`. The host's `Program.Notifier`/`Settings`/`LoadOrder`/`Inventory` are pass-through properties onto `AppState` (Core) — see "Startup flow" below. See "CLI mode" below for more. + +Folder map — **Core**: +- `FileIndex/` — scans the mods folder and builds the conflict index: `ModFileIndex.cs` (`BuildAsync` → `Conflicts`), `ModFile.cs`, `ModFileCategory.cs`. +- `Inventory/` — core merge domain + persistence: `FileMerger.cs` (headless orchestration plus TreeNode-free interactive orchestration — see "Interactive vs. headless split" below), `Merge.cs`, `MergeInventory.cs`, `FileHash.cs`, `MergeProgressInfo.cs`. +- `LoadOrder/` — mod load-order logic: `CustomLoadOrder.cs`, `LoadOrderComparer.cs`, `LoadOrderValidator.cs`, `ModLoadSetting.cs`. +- `Tools/` — wrappers that shell out to bundled external executables, plus the merge-engine abstraction: `QuickBms.cs`, `WccLite.cs`, `Hasher.cs`, `IMergeEngine.cs` (see "Interactive vs. headless split" below), `FileEncoding.cs` (UTF-16LE+BOM normalization shared by every merge engine — see "KDiff3 input encoding" under Compatibility constraints), `DiffPlexMergeEngine.cs` (the DiffPlex-based `IMergeEngine` implementation — see "Interactive vs. headless split" below). +- `Cli/` — `MergeOperations.cs`: the scan-then-merge sequence shared by the `merge` CLI verb and the MCP tools. +- `Mcp/` — `WsmMcpTools.cs`: the MCP server's tool implementations. See "MCP mode" below. +- Root: `AppState.cs` (shared mutable state — `Notifier`/`Settings`/`LoadOrder`/`Inventory`/`MergeEngine`), `AppSettings.cs`, `Paths.cs`, `StringExtensions.cs`, `IMergeNotifier.cs`, `NotifyTypes.cs` (the neutral `NotifyResult`/`NotifyButtons`/`DialogIcon` enums), `HeadlessMergeNotifier.cs`. + +Folder map — **host**: +- `Forms/` — WinForms screens: `MainForm.cs` (the hub, also implements `IMergeNotifier`, translating to/from real WinForms types), `OptionsForm.cs`, `DependencyForm.cs` (startup blocker if tool paths are invalid), `MergeReportForm.cs`, `PackReportForm.cs`, `PriorityPrompt.cs`, `MessageBoxManager.cs`. +- `Controls/` — custom `TreeView` subclasses: `SMTree.cs` (base, metadata/context-menu logic), `ConflictTree.cs` (detected conflicts), `MergeTree.cs` (existing merges), `SMTreeSorter.cs`, `ToolStripRegion.cs`. +- `Inventory/` — `InteractiveMergeRunner.cs`: the host-side counterpart to Core's `FileMerger` for the interactive path (see "Interactive vs. headless split" below). +- `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). + +Both `FileMerger.MergeText*` methods talk to the active text-merge engine through `IMergeEngine` (`Merge`/`MergeHeadless`) rather than calling a specific tool directly. Two implementations exist: +- **`KDiff3MergeEngine`** (host) wraps `Tools/KDiff3.Run`/`KDiff3.RunHeadless` (Win32 P/Invoke has to stay in the host project) — see the "KDiff3 input encoding"/"Verify KDiff3 process behavior"/"KDiff3's pop-up window"/`RunHeadless`'s poll interval bullets under Compatibility constraints for what it's actually doing. +- **`DiffPlexMergeEngine`** (Core, `Tools/DiffPlexMergeEngine.cs`) is an in-process alternative built on the DiffPlex NuGet package (MIT-licensed), needing no external binary. It builds its own merge loop around `DiffPlex.ThreeWayDiffer.CreateDiffs` rather than calling `ThreeWayDiffer.CreateMerge` directly, so it can intercept `ThreeWayChangeType.Conflict` blocks itself: a conflict whose two sides are equal once whitespace is collapsed (joined-and-collapsed comparison over the classic ASCII whitespace set — space/tab/CR/LF/form-feed/vertical-tab, deliberately narrower than .NET regex's Unicode-aware `\s` so a genuine content difference that happens to be NBSP-vs-space isn't misclassified as whitespace-only — not per-line `Trim()`, and never applied when either side has zero pieces, since a real deletion must never be conflated with "the surviving side happens to collapse to empty too") auto-resolves by taking the first mod's side verbatim, mirroring KDiff3's `--cs "WhiteSpace3FileMergeDefault=2"` (confirmed against the KDiff3 source: value 2 means "always pick input B", and KDiff3's own file order — vanilla, source1, source2 — maps source1 to B). A genuine conflict instead produces git/diff3-style conflict markers (`<<<<<<< ` / `||||||| Vanilla` / `=======` / `>>>>>>> `) written to a **sidecar** file under `Paths.DiffPlexConflictsDirectory` (a dedicated top-level `DiffPlexConflicts` folder — via `GetConflictMarkerPath`, keyed by an `XxHash32` of the full output path plus its filename) rather than to `outputPath` itself — writing markers to `outputPath` would make `FileMerger`'s pre-merge `File.Exists(_outputPath)` overwrite guard treat it as an already-completed merge and permanently skip retrying, since `HeadlessMergeNotifier` always declines the overwrite prompt. The sidecar went through two prior locations before landing here, each ruled out by direct end-to-end testing against the real CLI rather than by inspection alone: it originally lived right beside `outputPath` (`.conflict`), which code review flagged for two real problems (a flat-file conflict's `outputPath` sits inside the live `Paths.ModsDirectory` tree, which nothing ever cleans up; a bundle-content conflict's `outputPath` sits inside `Paths.MergedBundleContent`, which `Tools/WccLite.PackBundle` packs *wholesale* with no filtering, so a leftover `.conflict` file there could get embedded as bogus content into a later-packed `blob0.bundle`); it then moved under `Paths.TempBundleContent`, which fixed both of those but broke immediately in end-to-end testing for a third reason neither review nor unit tests caught - `FileMerger.CleanUpTempFiles()` deletes the entire `TempBundleContent` tree wholesale at the end of every headless merge run (to clear QuickBMS-unpacked bundle scratch content), so the sidecar was gone by the time the CLI process exited, before a user could ever see it. `Paths.DiffPlexConflictsDirectory` is an unrelated top-level name specifically to avoid that collision - see its own comment in `Paths.cs` for the full story. A conflict-marker file that later becomes an auto-solve on retry has its stale sidecar deleted before writing the fresh output. Two loose ends worth stating plainly rather than leaving implicit: nothing automatically deletes the `DiffPlexConflicts` directory itself (the same "accumulates until manually cleared" property `TempBundleContent` has, and the CLI-mode section below already tells users to clear that one between runs - `DiffPlexConflicts` needs the same manual housekeeping, just without an automated sweep); and `Paths.DiffPlexConflictsDirectory` is a relative path, resolved against `Environment.CurrentDirectory` - `Program.RunCli` sets that to `AppContext.BaseDirectory` before anything else runs (see "Startup flow" below), so in CLI mode sidecars land predictably next to the installed exe, but the GUI path never does that reset, so a GUI-mode DiffPlex conflict's sidecar lands wherever the process's CWD happened to be at launch (typically the exe's own directory too, for a normal double-click launch, but not guaranteed). Not a functional problem given the GUI-mode DiffPlex path is already incomplete UI-wise (no way to open the sidecar from the report dialog yet either), but worth knowing before relying on the sidecar's location being deterministic outside CLI mode. There's no UI here at all (unlike KDiff3's own GUI), so `Merge()` (interactive) just runs `MergeHeadless()` and maps `NeedsManualResolution` to `Failed`, matching `IMergeEngine.Merge`'s contract — this also means the "merging an updated mod file into an existing merge chain" outdated-hash guard (mirrored from `KDiff3.RunHeadless`, not hoisted into shared `FileMerger` orchestration) surfaces as a silent `Failed` on the interactive path here, where `KDiff3MergeEngine`'s interactive `Run()` opens KDiff3's GUI for manual review instead — an accepted gap given there's no interactive UI for DiffPlex conflicts at all yet. A 3-way merge with no vanilla version at all (expected mainly on the bundle-content path, when no matching vanilla bundle is found, but the guard applies unconditionally to any conflict missing one) is refused outright (`NeedsManualResolution`, nothing written) rather than attempted with an empty base string — `DiffPlex.ThreeWayDiffer` degrades silently to zero diff blocks and a "successful" empty merge in that case, confirmed empirically, which would otherwise produce a truncated output file; this is a deliberate divergence from `KDiff3MergeEngine`, which has no equivalent guard and always attempts a real (if vanilla-less, degraded 2-way) `--auto` merge instead, since KDiff3 has a coherent notion of a 2-file merge and DiffPlex's `ThreeWayDiffer` as used here does not. See the "DiffPlex's `ThreeWayDiffer` can produce internally inconsistent diff blocks" bullet under Compatibility constraints below for a confirmed upstream DiffPlex bug this engine has to defend against on every merge, regardless of any of the above. Selected via the `MergeEngine` `App.config` key (`kdiff3`, the default, or `diffplex`) — see "Startup flow" below; `KDiff3MergeEngine` remains the default, both because `DiffPlexMergeEngine` hasn't been cross-checked against KDiff3 on enough real conflicting files yet (only synthetic fixtures in `WitcherScriptMerger.Tests`, plus an optional, narrowly-scoped real-KDiff3 A/B check gated on `WSM_TEST_GAME_DIR` — see "Tests" above) and because of that confirmed upstream DiffPlex bug's measured non-trivial failure rate even at realistic edit densities. + +Either way, `AppState.MergeEngine` is supplied once, as (part of) the first line of `Program.Main`, before anything else runs. `IMergeEngine` is explicitly scaffolding for the Core/host split, not a permanent pluggable-engine abstraction — a later unit removing KDiff3 entirely will likely delete this interface and inline its replacement directly into `FileMerger`. + +### Startup flow (`Program.cs`) + +The shared mutable state that used to be static fields directly on `Program` (`Notifier`/`Settings`/`LoadOrder`/`Inventory`) now lives on Core's `AppState` instead — domain code that moved to Core needs to read/write it, and Core can never reference the host assembly. `Program.Notifier`/`Settings`/`LoadOrder`/`Inventory` are pass-through properties onto `AppState` so every pre-existing host call site kept working unchanged. `AppState.Notifier` defaults to `HeadlessMergeNotifier` via field initializer, unconditionally, so any startup error is safe to report even before it's known whether this is a GUI or CLI run. `AppState.Settings` is a **lazy property**, not a field initializer (`_settings ?? (_settings = new AppSettings())`) — deliberately decoupled from `Notifier`'s eager init: `AppSettings`'s constructor calls `Environment.Exit(1)` if it can't find a config file next to the entry assembly, appropriate for the real GUI/CLI/MCP entry points (where that's genuinely fatal) but not for `WitcherScriptMerger.Tests`, whose `dotnet test` host has no matching `.config` — Core code legitimately reads `AppState.Notifier` on its own (e.g. `DiffPlexMergeEngine`'s headless skip/guard messages), and before this change that alone was enough to also force `Settings` to construct (C# runs all of a type's static field initializers together on first touch of *any* static member) and crash the whole test process. First real access to `Settings` still runs the identical `new AppSettings()` and identical crash-on-missing-config behavior for the real app, just deferred to that access instead of bundled with `Notifier`'s. `AppState` has an explicit (empty) static constructor so its own init ordering is deterministic rather than left to `beforefieldinit`'s discretion — `Program` needs the same treatment for its own remaining field initializer (`_consoleAttached = MaybeAttachConsole()`) for the identical reason, now that nothing in `Main()` necessarily touches a `Program`-owned field anymore (its former fields became properties). `MaybeAttachConsole()` runs as a field initializer, ahead of everything, so early failures are visible in the invoking terminal when there are CLI args. + +`[STAThread] Main(string[] args)`: first sets `AppState.MergeEngine` to either `KDiff3MergeEngine` (default) or `DiffPlexMergeEngine`, based on the `MergeEngine` `App.config` setting (`kdiff3`/`diffplex`) — see "Interactive vs. headless split" above; must happen before anything calls `Paths.ValidateDependencyPaths()` or constructs a `FileMerger`, in any of the paths below. Then: if `args` is non-empty, hands off entirely to the CLI path (see below) and returns — the GUI is never touched. Otherwise: `Application.EnableVisualStyles()` → check `Settings.HasConfigFile` → `Paths.ValidateDependencyPaths()` (shows `DependencyForm` if KDiff3/QuickBMS/wcc_lite paths are invalid) → construct `MainForm`, reassign `Program.Notifier = MainForm`, `Application.Run(MainForm)`. + +### Merge flow + +No hand-rolled diff algorithm lives in this codebase — it's an orchestrator around KDiff3 for text merges and QuickBMS/wcc_lite for `.bundle` archives: + +1. `FileIndex/ModFileIndex.BuildAsync` scans `Paths.ModsDirectory`, groups files by relative path, flags conflicts. +2. `MainForm` feeds the results into `ConflictTree`/`MergeTree`; the user checks nodes to merge. +3. `Inventory/FileMerger.MergeByTreeNodesAsync` runs on a `BackgroundWorker`, building/reusing an `Inventory/Merge` record per file and dispatching to `MergeFlatFileNode` (plain `.ws`/`.xml`) or `MergeBundleFileNode` (bundle-packed files, which first go through `Tools/QuickBms.UnpackFile`). +4. `FileMerger.MergeText` calls `Tools/KDiff3.Run(source1, source2, vanillaFile, outputPath)`, which shells out to `KDiff3.exe` (`--auto` for auto-solvable 3-way merges, or opens its GUI for manual resolution). Before building the command line, `KDiff3.Run` normalizes each input file to UTF-16LE with a BOM (matching vanilla's encoding) via `Tools/FileEncoding.EnsureUtf16File` (Core - shared with `DiffPlexMergeEngine`, see "Interactive vs. headless split" above), writing a temp copy under `Paths.TempBundleContent\Encoding\...` when a file isn't already in that encoding — see "KDiff3 input encoding" below for why. +5. On success, `Inventory/MergeInventory.AddModToMerge` hashes the result via `Tools/Hasher` (xxHash32, `System.IO.Hashing`) and persists the merge record to `MergeInventory.xml` (`XmlSerializer`). `MergeInventory.HasResolvedConflict` re-checks these hashes on refresh to detect merges made stale by upstream mod file changes. +6. Bundle content changes additionally go through `FileMerger.PackNewBundle` → `Tools/WccLite.PackBundle` + `GenerateMetadata` to repack `blob0.bundle`. + +The CLI path (`FileMerger.MergeConflictsHeadless`) is a separate, parallel orchestration over the same domain objects — see "CLI mode" below. + +### CLI mode + +`WitcherScriptMerger.exe merge [--order-file ]` merges every auto-solvable conflict without opening any window, then exits. No-args still launches the GUI unchanged; passing `merge` is what selects the CLI path in `Program.cs`. + +- **Orchestration**: `Cli/MergeOperations.ScanConflicts()` runs `ModFileIndex.BuildAsync` synchronously (via a `ManualResetEventSlim`) and returns the built index; `MergeOperations.RunMerge(inventory, conflicts, mergedModName, orderOverrides)` calls `FileMerger.MergeConflictsHeadless`. Both the `merge` CLI verb and the MCP tools (see "MCP mode" below) call these instead of duplicating the scan/wait/merge sequence. `MergeConflictsHeadless` iterates `ModFileIndex.Conflicts` directly — those are plain `ModFile`/`FileHash` objects (relative path, category, per-mod name and hash), so no `TreeNode`/`ConflictTree` is ever constructed for this path. `MergeFlatConflictHeadless` and `MergeBundleConflictHeadless` mirror `MergeFlatFileNode`/`MergeBundleFileNode`'s logic against that plain data instead of `TreeNode.GetMetadata()`; bundle merges reuse `GetUnpackedFiles`/`PackNewBundle` unchanged. Per-file mod order defaults to `LoadOrderComparer` (matching `ConflictTree`'s own default sort, `Controls/SMTreeSorter.cs`); the `--order-file` JSON (`{"relative\\path.ws": ["modA", "modB"]}`) overrides specific files without requiring every conflict to be listed. Within a listed file's mod list, `FileMerger.ResolveMergeOrder` requires: no unknown mod names, no duplicate names, at least two entries, and every one of that file's *real* source mods present at least once — any violation rejects that one file with a clear error (via `AppState.Notifier.ShowError`) rather than silently merging an incomplete, self-paired, or single-entry (no-op) chain. This applies to both the CLI's `--order-file` and the MCP `merge_conflicts` tool's `orderOverrides`, since both funnel through this shared method. "Real source mods" deliberately excludes the configured merged-mod name itself: once a file has already been merged once, its own merged-mod folder re-enters `conflict.Mods` as if it were a source (see `scan_conflicts`'s tool description below), so re-merging after a source mod's file changes only needs to list the actual mods again, not the previous merge output too — the at-least-two-entries rule still applies, though, so listing only that one remaining real mod (with the merged-mod folder excluded from the requirement) is rejected rather than silently merged with nothing to merge against. +- **`IMergeNotifier`** (Core: `IMergeNotifier.cs`, `NotifyTypes.cs`, `HeadlessMergeNotifier.cs`; host: `MainForm.cs`'s implementation): replaces every direct `Program.MainForm.ShowMessage/ShowError/ShowModal` call in domain code with `AppState.Notifier.*` (host code still spells this `Program.Notifier.*`, via the pass-through property). The interface is defined against neutral `NotifyResult`/`NotifyButtons`/`DialogIcon` types, not `DialogResult`/`MessageBoxButtons`/`MessageBoxIcon` — Core can't reference `System.Windows.Forms` at all. `MainForm` translates those neutral types to/from real `MessageBox.Show(...)`/`DialogResult` calls; this is **not** a behavior-identical passthrough — one real prompt (`LoadOrderValidator`'s "Custom Load Order Problem" dialog) lost a `MessageBoxManager`-based custom button caption that used to mark its Cancel option as destructive/permanent (that caption mechanism was likely already silently broken pre-split — `MessageBoxManager.Register()`'s `AppDomain.GetCurrentThreadId()`-based hook doesn't reliably work on modern .NET — but the loss is real either way; the warning is now spelled out in the message body instead). `ShowModal(Form)` isn't part of `IMergeNotifier` at all — every call site is GUI-only, interactive code in the host project, which calls `MainForm.ShowModal` directly instead of going through the notifier abstraction. `HeadlessMergeNotifier` writes to the console and returns a fixed, non-destructive default for every decision: don't overwrite an existing merge output, don't use a merge name that's still conflicting, don't continue past a canceled/failed merge — except where a caller explicitly overrides that generic default via `ShowMessage`'s `defaultResult` parameter (added for `LoadOrderValidator`, whose YesNoCancel prompt has an inverted-from-usual safety shape: Cancel, not Yes/No, is the one destructive/permanent choice there). This is also what fixed a real null-ref hazard in `CustomLoadOrder.Refresh()`, which used to reach `Program.MainForm` at construction time. +- **`KDiff3.RunHeadless`**: see the "Verify KDiff3 process behavior..." and "KDiff3's pop-up window can't be suppressed" compatibility constraints above for the window-persistence detection this relies on and why the window itself is left alone rather than hidden. `-o` always targets a scratch path under `Paths.TempBundleContent\HeadlessOutput\`, copied to the real output only after a confirmed clean exit — a killed process can never leave a partial file at the real path. +- **Verification status**: the flat-file path (`Categories.Script`/`Categories.Xml`) is verified end-to-end against real conflicting files (including the encoding-mismatch scenario) and a synthetic guaranteed-conflict, in a scratch game/mods tree — never against a live install. The bundle path (`Categories.BundleText`) is code-reviewed and mirrors the proven flat-file orchestration, and its two building blocks (`GetUnpackedFiles`, `PackNewBundle`) are unchanged, already-exercised code — but it hasn't been round-tripped through a real bundle-vs-bundle conflict. If `tempbundlecontent` accumulates across many CLI runs during development, clear it between runs — one debugging session saw a single very slow run after a long buildup that didn't reproduce once it was cleared. + +### MCP mode + +`WitcherScriptMerger.exe mcp` runs an MCP (Model Context Protocol) server over stdio, using the official `ModelContextProtocol` NuGet package (`Host.CreateApplicationBuilder().Services.AddMcpServer().WithStdioServerTransport().WithToolsFromAssembly()`, wired up in `Program.cs`'s `RunMcp`). It's a third entry point alongside the GUI and the `merge` CLI verb — same `args.Length > 0` routing in `Main`, dispatched on `args[0] == "mcp"`. Lets an MCP client (e.g. a Claude Code session) inspect conflicts and drive merges directly instead of only through one-shot CLI invocations. + +- **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. +- **TFM must keep the explicit `7.0` OS-version suffix.** The project uses `false` (to keep the hand-written `Properties/AssemblyInfo.cs`), which also suppresses the SDK's auto-generated `[assembly: SupportedOSPlatform("windows")]` attribute — that attribute is added manually in `AssemblyInfo.cs` instead. The TFM must stay `net10.0-windows7.0` (not bare `net10.0-windows`); dropping the `7.0` suffix reintroduces ~800 spurious `CA1416` platform-compatibility warnings. +- **KDiff3 input encoding must stay normalized to UTF-16LE, never down to UTF-8.** Vanilla `.ws` files are UTF-16LE with a BOM; mod authors' files are often plain UTF-8/ASCII with no BOM (confirmed against real files on a live install). KDiff3 has no command-line flag to specify per-input encoding, and a mismatch can make it treat an entire file as unmatchable, falling back to manual (GUI) conflict resolution instead of auto-solving — empirically confirmed real-world false-conflict case: `baseEffect.ws` failed to auto-solve with mismatched encodings and succeeded cleanly once normalized, with correct merged output. `Tools/FileEncoding.cs` (Core, shared by both merge engines) handles this: `EnsureUtf16File` writes a UTF-16LE+BOM temp copy of any non-UTF-16LE input file before invoking KDiff3 (matching vanilla's encoding), while `ReadAnyEncoding`/`WriteUtf16` give `DiffPlexMergeEngine` the same normalization without needing a temp file at all, since it merges in-process text rather than shelling out to a tool that needs a file path — never normalize toward UTF-8, since the game may not load a merged `.ws` file in that encoding. `ReadAnyEncoding` deliberately uses `File.ReadAllText(path)`'s built-in BOM auto-detection rather than decoding raw bytes with a fixed `Encoding` instance - the latter does not strip a detected BOM, leaving a stray U+FEFF glued to the first line, confirmed empirically to reproduce the exact `baseEffect.ws`-style false conflict this whole mechanism exists to avoid (see `WitcherScriptMerger.Tests`' `MergeHeadless_EncodingMismatch_...` fixture). KDiff3 itself has no config option to ignore whitespace generally during diff/merge (only `WhiteSpace3FileMergeDefault` auto-picks a side for *purely* whitespace-only conflicts, confirmed against the KDiff3 source: value `2` means "always pick input B", i.e. the first mod/`source1`, given KDiff3's own file order of vanilla/source1/source2) — confirmed from the bundled `doc/options.html`, not assumed. `DiffPlexMergeEngine` mirrors this for conflicts that are purely whitespace once collapsed (see "Interactive vs. headless split" above), rather than only literal-identical-content merges. +- **DiffPlex's `ThreeWayDiffer` (1.9.0) can produce internally inconsistent diff blocks - `DiffPlexMergeEngine.BuildMerge` must never trust its output without checking.** Confirmed as a genuine upstream library bug, not a defect in this repo's own merge loop: `BuildMerge`'s block-iteration loop is a faithful port of DiffPlex's own `ThreeWayDiffer.CreateMerge` (same index-chasing shape), and a throwaway scratch console app (per this repo's testing convention) calling DiffPlex's own `CreateMerge` directly - with both `LineChunker` (DiffPlex's own default, and the *only* chunker its own `Facts.DiffPlex/ThreeWayDifferFacts.cs` test suite ever exercises for 3-way diffs) and `LineEndingsPreservingChunker` (the one this engine actually uses) - reproduced the identical failure on the identical input either way. When old-side and new-side edits interleave/overlap relative to base in certain ways, `CreateThreeWayDiffBlocks` can emit a block list whose `OldCount`/`NewCount` don't actually correspond to the real `PiecesOld`/`PiecesNew` arrays. This surfaces two ways: an outright `ArgumentOutOfRangeException` from direct indexer access, or - confirmed via a minimal repro (base `"a();/b();/c();"`, one side inserts a line after `a()`, the other independently changes `b()` to `B()`) - no exception at all, but silently wrong output (content lost or duplicated), because the running `oldIndex`/`newIndex` end up not matching `PiecesOld.Count`/`PiecesNew.Count` even though no single block's own bookkeeping looked wrong in isolation. A large randomized stress test (varying edit density and file length, run against the real, fixed `BuildMerge`) measured combined failure rates of **0.35%** at one independent single-line edit per side on 50-200 line files (the closest analogue to a typical two-mod `.ws` conflict), rising to **0.88%** (1-2 edits/side), **2.65%** (2-3 edits/side), **4.99%** (1-6 edits/side on 50-200 line files), and **38.89%** on the original dense adversarial case (1-6 edits/side on 1-19 line files) - zero cases of any exception type other than the one this bug produces, across 100,000 total trials. `BuildMerge` defends against both failure modes: the block-processing loop is wrapped in `try`/`catch (ArgumentOutOfRangeException)`, and a post-loop check verifies `oldIndex`/`newIndex` actually reached `PiecesOld.Count`/`PiecesNew.Count` (accounting for a legitimate trailing-unchanged gap needing the exact same lockstep advance as the per-block gap-catchup above it - an early, incorrect version of this check that skipped that trailing advance produced a ~33% false-positive "inconsistent" rate on the exact same benign inputs). Either failure mode throws a `DiffPlexMergeEngine.DiffAlgorithmException`, which `MergeHeadless` catches and reports as `NeedsManualResolution` **without writing anything, including a conflict-marker sidecar** - the marker content itself would have been built from the same untrustworthy piece indices, so this is the one case where `DiffPlexMergeEngine` can't even offer a conflict-marker starting point the way KDiff3 always can. This is the primary reason `DiffPlexMergeEngine` isn't the default engine (see "Interactive vs. headless split" above) - a measured, non-negligible failure rate even at realistic edit density is a real reliability gap `KDiff3MergeEngine` doesn't share. Regression-tested via `DiffPlexMergeEngineTests`' `BuildMerge_InterleavedIndependentEdits_...`/`MergeHeadless_InterleavedIndependentEdits_...` fixtures (the minimal repro above). Do not "fix" this by switching chunkers - the bug reproduces under DiffPlex's own default/tested `LineChunker` too, just at a somewhat lower rate, so it isn't a `LineEndingsPreservingChunker`-specific problem and switching would trade a real, working byte-for-byte line-ending-preservation property for no actual safety gain. +- **Verify KDiff3 process behavior via `Process.Start(fileName, argsString)` (the two-string overload, `UseShellExecute=false` by default on modern .NET), not via a shell.** A prior verification pass tested KDiff3 invocation through Git Bash/MSYS2 and concluded `damageManagerProcessor.ws` (a second real conflict) still needed manual GUI resolution even after encoding normalization. Re-tested later through .NET's `Process.Start` — the actual code path this app uses — the same file (both raw and normalized) auto-solved cleanly every time; the bash-based test was an invocation-environment artifact, not real KDiff3 behavior. A guaranteed-genuine conflict (two synthetic mods editing the identical line differently) confirmed what actually distinguishes the two outcomes: KDiff3 always briefly shows a window titled exactly `Conflicts` on startup (auto-solves or not — this is not a "needs manual resolution" signal), but only a genuine unresolved conflict leaves a second window open whose title ends in `" - KDiff3"` (the actual comparison/merge editor, e.g. `Vanilla <-> modA <-> modB - KDiff3`) — that one persists indefinitely until closed, while an auto-solve's process exits within a few seconds regardless of file size (3400+ line files exited in under 3s in testing). Any headless/non-interactive invocation path must detect on window persistence past a short grace period (~2-3s, to let the transient `Conflicts` window close), not on elapsed time alone and not by assuming a visible window means failure. +- **KDiff3's pop-up window can't be suppressed without breaking the merge — don't try.** Five techniques were tested empirically (scratch harness in a session's `scratchpad/detector-test/`) against both an auto-solve case and a guaranteed-conflict case: `ProcessStartInfo.WindowStyle = Hidden` and `= Minimized` are both silently ignored by KDiff3/Qt (window shows full-size regardless, confirmed via `IsIconic` for the minimized case) but don't break anything; `ShowWindow(hwnd, SW_HIDE)`, `SetWindowPos` moved off-screen, and launching on a separate non-interactive Windows desktop (`CreateDesktop`) 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: the identical launch mechanism, untouched, auto-solves in 1.6–6.5s every time). The pattern held across three independent suppression mechanisms, which is strong evidence KDiff3's Qt runtime needs the window genuinely composited on the real, interactive desktop to make progress at all — not something fixable from outside the process. The window does steal foreground focus while shown (confirmed via `GetForegroundWindow()`). Given that hard constraint, `KDiff3.RunHeadless` accepts the window appearing and instead attempts to restore focus to whatever had it beforehand (captured before launching KDiff3, restored in a `finally` once KDiff3's own window is confirmed gone — `proc.Kill` is async, so the kill path also waits up to 2s via `WaitForExit` before restoring, or the restore could race a still-alive window). **This restoration is unverified in practice — treat it as attempted, not guaranteed.** Plain `SetForegroundWindow` was empirically denied every time in testing (Windows' foreground-lock policy: WSM's own process never owned the foreground to begin with, since KDiff3's window did, so it isn't a privileged caller by the time it tries to restore). `RestoreForegroundWindow` upgrades to the standard `AttachThreadInput` workaround (temporarily share input state with whatever thread currently owns the foreground, then call `SetForegroundWindow`) — but this was *also* observed to be denied in every test run in this session's sandboxed automation environment. Whether that's a real limit or an artifact of that specific environment (its own automation harness aggressively reclaiming focus) is unresolved; it hasn't been tested from a normal interactive user session. Treat the restore as a good-faith best-effort mitigation, not a proven one, until someone verifies it from an ordinary desktop session. Do not reintroduce any of the three broken suppression techniques without re-verifying they still hang; if a future KDiff3 update changes this behavior, that verification needs to be redone before trusting a different result. +- **`RunHeadless`'s ~250ms detection-loop poll interval is load-bearing — don't tighten it.** Discovered by accident while testing the suppression techniques above: polling every 15ms for the first second — with *zero* window manipulation, just `EnumWindows`/`GetWindowText` read-only queries — reliably hung KDiff3 the same way the suppression techniques did; the identical untouched launch polled at 200ms auto-solved normally every time. `GetWindowText` issues a cross-process `SendMessage(WM_GETTEXT)` to the target window, which is a blocking call the target thread must service — a plausible mechanism is that polling fast enough starves or reorders KDiff3's own message loop during the window it needs to actually compute the merge. "Poll faster to detect the conflict window sooner" is a natural-looking optimization that would silently turn every merge into a hang — don't make this change without re-verifying against both an auto-solve case and a guaranteed-conflict case first. + +### Settings & persistence + +- App settings: `AppSettings.cs` wraps `System.Configuration.ConfigurationManager` over `App.config`'s `` block (`Get`/`Get`/`Set`/`Save`, cached `Configuration` object) — deliberately *not* `Properties.Settings` (that scaffolding was removed during the SDK-style migration). Settings are cached and require an explicit `Save()` call. +- Merge history: `MergeInventory.xml`, via `XmlSerializer` (`Inventory/MergeInventory.cs`). +- Game load order: `LoadOrder/CustomLoadOrder.cs` reads the game's own `mods.settings` file. + +### External tool dependencies + +Three bundled Windows executables are invoked via `Process.Start`, with relative paths configured in `App.config`'s `` (`KDiff3Path`, `QuickBmsPath`, `QuickBmsPluginPath`, `WccLitePath`): +- **KDiff3** (`Tools\KDiff3\KDiff3.exe`) — GPL-licensed, safe to bundle into a release. +- **QuickBMS** (`Tools\QuickBMS\quickbms.exe` + `witcher3.bms` plugin) — no license file found; do not add to source control. +- **wcc_lite** (`Tools\wcc_lite\bin\x64\wcc_lite.exe`) — no license file found; do not add to source control. + +None of these binaries are committed to this repo (matches the original upstream project's precedent) — keep it that way; if packaging is tackled later, it belongs in a separate release artifact, not source control. + +## Coding standards & SOP + +See `CONTRIBUTING.md` for observed code style (bracing, naming, region conventions) and repository process (branching, commit style, AI-assisted-development disclosure). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f8958c9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,40 @@ +# Contributing + +This is a public repository, and contributions — human or AI-agent-assisted — are welcome. See `CLAUDE.md` for build commands, architecture, and compatibility constraints; this file covers style and process. For the project's own fork lineage (this repo vs. the upstream `AnotherSymbiote/WitcherScriptMerger` project), see `CLAUDE.md`'s Project overview. + +## Code style + +Match the existing source (e.g. `Inventory/FileMerger.cs`, `Controls/SMTree.cs`) rather than introducing a different style in new code. Enforced via `.editorconfig` — run `dotnet format whitespace WitcherScriptMerger.sln` before opening a PR if you're not sure your editor is honoring it: + +- **Tabs, not spaces**, for indentation (`.editorconfig`: `indent_style = tab`, `indent_size = 4`). +- Allman brace style (opening brace on its own line). +- Larger classes group members under `#region Types` / `#region Members` blocks. +- Private fields are `_camelCase` with the access modifier omitted (the codebase's own history includes a commit removing unnecessary explicit access modifiers — don't reintroduce them). +- Expression-bodied members for simple, single-expression methods/properties. +- Single-statement `if` bodies are sometimes left unbraced on the following line; this isn't universal, use judgment based on surrounding code. +- `.cs` files are UTF-8 **with a BOM**, CRLF line endings — matches the existing codebase and `.editorconfig`. + +## Repository SOP + +- **`main` is protected.** No direct commits or pushes — all changes land via pull request. Force-pushes and branch deletion are disabled on `main` at the GitHub level. +- **Branch per feature/fix**, off `main`: `feature/` for new functionality, `fix/` for bug fixes, `chore/` for tooling/process/docs changes not tied to a feature or bug. Keep the description short and kebab-case (e.g. `fix/kdiff3-encoding-mismatch`). +- **Pull requests require 2 approving reviews** before merge (GitHub branch protection on `main`). This applies to everyone, including repository admins in normal circumstances — admin bypass exists at the platform level for genuine emergencies, not as a routine shortcut. +- **PR description should cover**: what changed and why, and — given there's no test suite (see Testing below) — specifically *how you verified it*. "Builds successfully" is necessary but not sufficient for anything touching hash output, `MergeInventory.xml` schema, KDiff3/QuickBMS/wcc_lite invocation, or encoding handling; see `CLAUDE.md`'s Compatibility constraints for why those are load-bearing, and its Tests section for the verification pattern this codebase uses in place of a test suite. +- Commit messages are short, descriptive sentences (e.g. `Fixed crash after canceling file-open.`, `Replace hand-ported xxHash32 with System.IO.Hashing`). A `Category:` prefix (`Fixed:`, etc.) shows up occasionally but isn't enforced. No Conventional Commits format required. +- GitHub Actions CI (`.github/workflows/build.yml`) runs `dotnet build --configuration Release` and `dotnet format whitespace --verify-no-changes` on every PR targeting `main`, but don't rely on it to catch problems for you — run both locally first: `dotnet build WitcherScriptMerger.sln --configuration Release` and `dotnet format whitespace WitcherScriptMerger.sln --verify-no-changes` before opening a PR. Catching failures before CI does saves a round trip. +- External binary dependencies (KDiff3, QuickBMS, wcc_lite — see `CLAUDE.md`'s External tool dependencies) aren't in source control, so a fresh clone needs them sourced separately before the app runs end-to-end. PRs that only touch code not exercising those tools don't need them to build and review. + +## Testing + +There's no test project in this repo. For changes that touch hash output, `MergeInventory.xml` schema, or KDiff3 invocation, use a disposable, non-committed scratch console app: exercise synthetic edge cases plus a cross-check against a real value already recorded in a live `MergeInventory.xml`. See `CLAUDE.md`'s Tests section for the specifics of why this matters for this codebase. Describe what you actually ran in your PR description — see Repository SOP above. + +## AI-assisted development + +This repository is developed with AI coding agents (Claude Code, and expect others), openly — that's not hidden, and it's not discouraged. `CLAUDE.md` carries the operational guidance these tools use when working in this repo, kept up to date as the codebase changes; read it before pointing an agent at this repo. If these guidelines are silent on something and you're using an agent, defer to the explicit rules below over whatever the agent proposes on its own. + +- **Disclose it.** If a PR was substantially produced or assisted by an AI coding agent, say so in the PR description. Commits already carry a `Co-Authored-By` trailer when an agent is involved (Claude Code does this automatically) — that's necessary but not sufficient; the PR description is where a reviewer looks first. +- **You own what you submit, regardless of how it was produced.** Be able to explain any part of your own PR if a reviewer asks — "the agent wrote it that way" isn't an answer to "why does this work." If you can't explain a change, that's a signal to understand it better before submitting, not to submit it anyway. +- **The verification bar doesn't move for AI-assisted changes — if anything, hold it higher.** This codebase has no test suite and several genuinely load-bearing, non-obvious compatibility constraints (hash format, KDiff3 encoding normalization, the window-persistence detection in headless mode — all documented in `CLAUDE.md`). Agents are good at producing code that looks plausible and compiles; they have no way to know these constraints exist unless `CLAUDE.md` tells them, and no way to know their fix actually works unless it's actually run against real data. "Should work" is not verification — see Testing above. +- **Scrub machine-specific state before submitting.** Agent-assisted sessions tend to accumulate absolute local paths, scratch config pointing at a personal install, or test artifacts from the working process — check your diff for anything like a `G:\SteamLibrary\...`-style path or a personal game install location before opening a PR. `.gitignore` excludes common agent runtime-state directories (`.claude/`, `.cursor/`, etc.) and session handoff notes (`HANDOFF*.md`) for the same reason — extend it rather than working around it if your tool of choice uses a different local-state convention. +- **You're responsible for license compatibility of anything an agent produces**, same as for hand-written code — this project cares about this already (see `CLAUDE.md`'s External tool dependencies section on why QuickBMS/wcc_lite specifically aren't bundled). Don't accept agent output that reproduces code from a source with an incompatible license. +- **Bulk or automated PRs still go through the same process.** A large refactor being agent-generated isn't a reason to skip branch-per-change, PR review, or the two-approval requirement — if anything, larger diffs benefit more from review, not less. diff --git a/WitcherScriptMerger.Core/AppSettings.cs b/WitcherScriptMerger.Core/AppSettings.cs new file mode 100644 index 0000000..2c6fcb5 --- /dev/null +++ b/WitcherScriptMerger.Core/AppSettings.cs @@ -0,0 +1,96 @@ +using System; +using System.Configuration; +using System.Reflection; + +namespace WitcherScriptMerger +{ + public class AppSettings + { + string _assemblyPath; + + Configuration _cachedConfig; + Configuration CachedConfig + { + get + { + if (_cachedConfig == null) + _cachedConfig = ConfigurationManager.OpenExeConfiguration(_assemblyPath); + return _cachedConfig; + } + } + + public bool HasConfigFile => CachedConfig.HasFile; + + public AppSettings() + { + _assemblyPath = Assembly.GetEntryAssembly().Location; + + if (!CachedConfig.HasFile) + { + AppState.Notifier.ShowError("Config file is missing.", "Script Merger Error"); + Environment.Exit(1); + } + } + + public void Set(string key, object value) + { + try + { + CachedConfig.AppSettings.Settings[key].Value = value.ToString(); + } + catch + { + CachedConfig.AppSettings.Settings.Add(key, value.ToString()); + } + } + + public T Get(string key) + { + try + { + if (CachedConfig.HasFile) + { + var valueString = CachedConfig.AppSettings.Settings[key].Value; + var parseMethod = typeof(T).GetMethod("Parse", new Type[] { typeof(string) }); + var valueObject = parseMethod.Invoke(null, new object[] { valueString }); + return (T)valueObject; + } + + AppState.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}"); + return default(T); + } + catch + { + return default(T); + } + } + + public string Get(string key) + { + try + { + if (CachedConfig.HasFile) + return CachedConfig.AppSettings.Settings[key].Value; + + AppState.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}"); + return string.Empty; + } + catch + { + return string.Empty; + } + } + + public void Save() + { + try + { + CachedConfig.Save(ConfigurationSaveMode.Minimal); + } + catch (Exception ex) + { + AppState.Notifier.ShowError($"Failed to save config due to error:\n\n{ex.Message}"); + } + } + } +} diff --git a/WitcherScriptMerger.Core/AppState.cs b/WitcherScriptMerger.Core/AppState.cs new file mode 100644 index 0000000..e0c673c --- /dev/null +++ b/WitcherScriptMerger.Core/AppState.cs @@ -0,0 +1,80 @@ +using System.Threading; +using WitcherScriptMerger.Inventory; +using WitcherScriptMerger.LoadOrder; +using WitcherScriptMerger.Tools; + +namespace WitcherScriptMerger +{ + // Shared mutable application state, previously held directly by the host + // project's Program class. It moved out to Core during the Core/host project + // split because domain code that now lives in Core (Paths, AppSettings, + // ModFileIndex, FileMerger, CustomLoadOrder, Cli/MergeOperations, + // Mcp/WsmMcpTools, ...) needs to read/write it, and Core can never reference + // the host assembly (that's the whole point of the split - the dependency only + // flows host -> Core). The host project's Program class re-exposes these as + // pass-through Notifier/Settings/LoadOrder/Inventory properties so none of its + // own call sites had to change. + // + // An explicit static constructor suppresses `beforefieldinit`, so this class's + // field initializers run at a precise, well-defined point (first member access) + // rather than at some unspecified point the CLR chooses - load-bearing here + // because Program.MaybeAttachConsole() must run before Settings' constructor + // can report a missing-config error to the invoking terminal (see CLAUDE.md's + // Startup flow). Paths.cs used to have the same beforefieldinit hazard one hop + // further out (its own static field initializers read Settings.Get(...) + // eagerly) - fixed by making those Paths properties compute on every access + // instead of caching via a field initializer, so Settings' laziness isn't + // undermined transitively; see Paths.cs. + public static class AppState + { + // Defaults to the headless implementation so it's safe to use from the very + // first line of Main() - the GUI path swaps it out for MainForm once + // constructed. See CLAUDE.md's IMergeNotifier section. + public static IMergeNotifier Notifier = new HeadlessMergeNotifier(); + + // Lazy rather than a field initializer: AppSettings' constructor calls + // Environment.Exit(1) if it can't find a config file next to the entry + // assembly (see AppSettings.cs) - appropriate for the real GUI/CLI/MCP entry + // points, where that's genuinely fatal, but not for WitcherScriptMerger.Tests, + // whose test host has no matching .config. Since C# runs ALL of a type's + // static field initializers together on first touch of ANY static member, + // Settings being a plain field-with-initializer meant merely reading + // AppState.Notifier (which Core code - e.g. DiffPlexMergeEngine's headless + // skip/guard messages - legitimately does on its own, unprompted by test code) + // silently also ran `new AppSettings()` and crashed the whole test process. + // Making Settings lazy decouples the two: touching Notifier alone no longer + // forces Settings to construct. Confirmed no call site assigns AppState.Settings + // or Program.Settings, so keeping this settable (for symmetry with the other + // fields here, and in case a future test wants to inject a stub) is a safe, + // behavior-preserving change for every existing GUI/CLI/MCP call site: first + // real access still runs the identical `new AppSettings()` and identical + // crash-on-missing-config behavior, just deferred to that first access instead + // of eagerly. + // + // LazyInitializer.EnsureInitialized (rather than the simpler + // `_settings ?? (_settings = new AppSettings())`) makes this thread-safe: the + // simpler form is a classic non-atomic check-then-act race that could, under + // concurrent first access, construct AppSettings() more than once (each with + // its own real side effects, including a possible Environment.Exit(1)). + // Currently unreachable from any shipped entry point or the test suite (all + // single-threaded at this point in startup) - flagged in code review as a + // latent risk anyway, since other Core statics (e.g. QuickBms.cs/WccLite.cs) + // also read AppState.Settings.Get(...) from their own static field + // initializers, and nothing prevents a future concurrent caller. + static AppSettings _settings; + public static AppSettings Settings + { + get => LazyInitializer.EnsureInitialized(ref _settings, () => new AppSettings()); + set => _settings = value; + } + + public static CustomLoadOrder LoadOrder = null; + public static MergeInventory Inventory = null; + + // Set once by the host project at startup (see Program.cs) to a + // KDiff3MergeEngine - see Tools/IMergeEngine.cs for why this exists. + public static IMergeEngine MergeEngine = null; + + static AppState() { } + } +} diff --git a/WitcherScriptMerger.Core/Cli/MergeOperations.cs b/WitcherScriptMerger.Core/Cli/MergeOperations.cs new file mode 100644 index 0000000..88d52bd --- /dev/null +++ b/WitcherScriptMerger.Core/Cli/MergeOperations.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Threading; +using WitcherScriptMerger.FileIndex; +using WitcherScriptMerger.Inventory; + +namespace WitcherScriptMerger.Cli +{ + // Shared scan/merge orchestration behind both the `merge` CLI verb (Program.cs) and the + // MCP tools (Mcp/WsmMcpTools.cs) - see CLAUDE.md's CLI mode / MCP mode sections. + public static class MergeOperations + { + public static ModFileIndex ScanConflicts() + { + var modIndex = new ModFileIndex(); + using (var scanComplete = new ManualResetEventSlim(false)) + { + modIndex.BuildAsync( + AppState.Settings.Get("CheckScripts"), + AppState.Settings.Get("CheckXmlFiles"), + AppState.Settings.Get("CheckBundleContents"), + (s, e) => { }, + (s, e) => scanComplete.Set()); + scanComplete.Wait(); + } + return modIndex; + } + + public static FileMerger.HeadlessMergeSummary RunMerge( + MergeInventory inventory, + IEnumerable conflicts, + string mergedModName, + IReadOnlyDictionary orderOverrides, + bool dryRun = false) + { + // AppState.MergeEngine is supplied once by the host project at startup + // (Program.cs) - see Tools/IMergeEngine.cs for why Core can't construct + // its one real implementation (KDiff3MergeEngine) itself. + var merger = new FileMerger(inventory, AppState.MergeEngine); + return merger.MergeConflictsHeadless(conflicts, mergedModName, orderOverrides, dryRun); + } + } +} diff --git a/WitcherScriptMerger.Core/FileIndex/ModFile.cs b/WitcherScriptMerger.Core/FileIndex/ModFile.cs new file mode 100644 index 0000000..4a52a63 --- /dev/null +++ b/WitcherScriptMerger.Core/FileIndex/ModFile.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Serialization; +using WitcherScriptMerger.Inventory; + +namespace WitcherScriptMerger.FileIndex +{ + public class ModFile + { + #region Members + + [XmlElement] + public string RelativePath { get; set; } + + [XmlElement("IncludedMod")] + public List Mods { get; private set; } + + [XmlElement] + public string BundleName { get; set; } + + [XmlIgnore] + public ModFileCategory Category + { + get + { + if (BundleName != null) + { + if (IsTextFile(RelativePath)) + return Categories.BundleText; + else + return Categories.BundleNotMergeable; + } + else if (IsScript(RelativePath)) + return Categories.Script; + else if (IsXml(RelativePath)) + return Categories.Xml; + else + return Categories.FlatNotMergeable; + } + } + + [XmlIgnore] + public bool IsBundleContent => (BundleName != null); + + [XmlIgnore] + public bool HasConflict => (Mods.Count > 1); + + #endregion + + public ModFile(string relPath, string bundlePath = null) + { + RelativePath = relPath; + Mods = new List(); + if (bundlePath != null) + BundleName = Path.GetFileName(bundlePath); + } + + public ModFile() + { + Mods = new List(); + } + + public bool ContainsMod(string modName) + { + return Mods.Any(mod => mod.Name.EqualsIgnoreCase(modName)); + } + + public string GetVanillaFile() + { + if (Category == Categories.Script) + return Path.Combine(Paths.ScriptsDirectory, RelativePath); + else if (Category == Categories.Xml) + return Path.Combine(Paths.GameDirectory, RelativePath); + else + throw new Exception($"Can't get vanilla file for category '{Category.DisplayName}'."); + } + + public string GetModFile(string modName) + { + if (Category == Categories.Script) + return Path.Combine(Paths.ModsDirectory, modName, Paths.ModScriptBase, RelativePath); + else if (Category == Categories.Xml) + return Path.Combine(Paths.ModsDirectory, modName, RelativePath); + else if (Category.IsBundled) + return Path.Combine(Paths.ModsDirectory, modName, Paths.BundleBase, BundleName); + else + throw new NotImplementedException(); + } + + public static string GetModNameFromPath(string modFilePath) + { + if (!modFilePath.StartsWithIgnoreCase(Paths.ModsDirectory)) // Merged bundle content has internal path, not derived from mod folder + return Paths.MergedBundleContent; + + var nameStart = Paths.ModsDirectory.Length + 1; + var name = modFilePath.Substring(nameStart); + + // 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"); + + public static bool IsXml(string path) => path.EndsWithIgnoreCase(".xml"); + + public static bool IsFlatFile(string path) => (IsScript(path) || IsXml(path)); + + public static bool IsBundle(string path) => path.EndsWithIgnoreCase(".bundle"); + + public static bool IsTextFile(string path) => (path.EndsWithIgnoreCase(".ws") || path.EndsWithIgnoreCase(".xml") || path.EndsWithIgnoreCase(".txt") || path.EndsWithIgnoreCase(".csv")); + + public override string ToString() + { + return $"({Mods.Count} mod{Mods.Count.GetPluralS()}) {RelativePath}"; + } + } +} diff --git a/WitcherScriptMerger.Core/FileIndex/ModFileCategory.cs b/WitcherScriptMerger.Core/FileIndex/ModFileCategory.cs new file mode 100644 index 0000000..33eee97 --- /dev/null +++ b/WitcherScriptMerger.Core/FileIndex/ModFileCategory.cs @@ -0,0 +1,48 @@ +namespace WitcherScriptMerger.FileIndex +{ + public class ModFileCategory + { + public ModFileCategory(int orderIndex, string displayName, string toolTipText, bool isSupported, bool isBundled) + { + OrderIndex = orderIndex; + DisplayName = displayName; + ToolTipText = toolTipText; + IsSupported = isSupported; + IsBundled = isBundled; + } + + public int OrderIndex { get; private set; } + public string DisplayName { get; private set; } + public string ToolTipText { get; private set; } + public bool IsSupported { get; private set; } + public bool IsBundled { get; private set; } + + public override string ToString() + { + return DisplayName; + } + } + + // readonly (not just static): callers throughout the codebase compare against + // these by reference equality (e.g. `category == Categories.Script`), and these + // fields are now `public` (required for cross-assembly access after the Core + // split, where they were merely assembly-internal before) - readonly closes off + // any accidental external reassignment silently breaking every such comparison. + public static class Categories + { + public static readonly ModFileCategory Script = new ModFileCategory( + 1, "Scripts", "These plaintext .ws files can be merged", true, false); + + public static readonly ModFileCategory Xml = new ModFileCategory( + 2, "Non-Bundled XML", "These .xml text files can be merged", true, false); + + public static readonly ModFileCategory BundleText = new ModFileCategory( + 3, "Bundled Text", "These bundled text files can be merged", true, true); + + public static readonly ModFileCategory BundleNotMergeable = new ModFileCategory( + 4, "Bundled Non-text - Not Mergeable", "Right-click mods to define your load order instead of merging", false, true); + + public static readonly ModFileCategory FlatNotMergeable = new ModFileCategory( + 5, "Not Mergeable", "Script Merger doesn't know what these files are", false, false); + } +} diff --git a/WitcherScriptMerger.Core/FileIndex/ModFileIndex.cs b/WitcherScriptMerger.Core/FileIndex/ModFileIndex.cs new file mode 100644 index 0000000..8185a7a --- /dev/null +++ b/WitcherScriptMerger.Core/FileIndex/ModFileIndex.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using WitcherScriptMerger.Inventory; +using WitcherScriptMerger.Tools; + +namespace WitcherScriptMerger.FileIndex +{ + public class ModFileIndex + { + public List Files; + + public IEnumerable Conflicts => Files.Where(f => f.HasConflict); + + public bool HasConflict => Files.Any(f => f.HasConflict); + + public int ModCount { get; private set; } + + public int ScriptCount { get; private set; } + + public int XmlCount { get; private set; } + + public int BundleCount { get; private set; } + + public ModFileIndex() + { + Files = new List(); + } + + public void BuildAsync( + bool checkScripts, bool checkXml, bool checkBundles, + ProgressChangedEventHandler progressHandler, + RunWorkerCompletedEventHandler completedHandler) + { + var ignoredModNames = GetIgnoredModNames(); + var modDirPaths = Directory.GetDirectories(Paths.ModsDirectory, "mod*", SearchOption.TopDirectoryOnly) + .Where(path => !ignoredModNames.Any(name => name.EqualsIgnoreCase(new DirectoryInfo(path).Name))) + .ToList(); + ModCount = modDirPaths.Count; + if (ModCount == 0) + { + 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 + }; + bgWorker.DoWork += (sender, e) => + { + var i = 0; + ScriptCount = XmlCount = BundleCount = 0; + foreach (var modDirPath in modDirPaths) + { + var modName = Path.GetFileName(modDirPath); + var filePaths = Directory.GetFiles(modDirPath, "*", SearchOption.AllDirectories); + var scriptPaths = filePaths.Where(path => ModFile.IsScript(path)); + var xmlPaths = filePaths.Where(path => ModFile.IsXml(path)); + var bundlePaths = filePaths.Where(path => ModFile.IsBundle(path)); + + ScriptCount += scriptPaths.Count(); + XmlCount += xmlPaths.Count(); + BundleCount += bundlePaths.Count(); + + if (checkScripts) + { + Files.AddRange(GetModFilesFromPaths(scriptPaths, Categories.Script, modName)); + } + if (checkXml) + { + Files.AddRange(GetModFilesFromPaths(xmlPaths, Categories.Xml, modName)); + } + if (canCheckBundles) + { + foreach (var bundlePath in bundlePaths) + { + var contentPaths = QuickBms.GetBundleContentPaths(bundlePath); + Files.AddRange(GetModFilesFromPaths(contentPaths, Categories.BundleText, modName, bundlePath)); + } + } + var progressPct = (int)((float)++i / modDirPaths.Count * 100f); + bgWorker.ReportProgress(progressPct, modName as object); + } + if (canCheckBundles) + System.Threading.Thread.Sleep(500); // Wait for progress bar to fill completely + }; + bgWorker.RunWorkerCompleted += completedHandler; + bgWorker.ProgressChanged += progressHandler; + bgWorker.RunWorkerAsync(); + } + + private List GetModFilesFromPaths( + IEnumerable filePaths, + ModFileCategory category, + string modName, string bundlePath = null) + { + var fileList = new List(); + foreach (var filePath in filePaths) + { + string relPath = null; + if (category == Categories.Script) + relPath = Paths.GetRelativePath(filePath, Paths.ModScriptBase); + else if (category == Categories.Xml) + relPath = Paths.GetRelativePath(filePath, modName); + else if (category == Categories.BundleText) + relPath = filePath; + else + throw new NotImplementedException(); + + var existingFile = Files.FirstOrDefault(file => + file.RelativePath.EqualsIgnoreCase(relPath)); + if (existingFile == null) + { + var newFile = (bundlePath != null + ? new ModFile(relPath, bundlePath) + : new ModFile(relPath)); + newFile.Mods.Add(new FileHash { Name = modName }); + fileList.Add(newFile); + } + else + existingFile.Mods.Add(new FileHash { Name = modName }); + } + return fileList; + } + + private IEnumerable GetIgnoredModNames() + { + var ignoredNames = AppState.Settings.Get("IgnoreModNames"); + return ignoredNames.Split(',') + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Select(name => name.Trim()); + } + } +} diff --git a/WitcherScriptMerger.Core/HeadlessMergeNotifier.cs b/WitcherScriptMerger.Core/HeadlessMergeNotifier.cs new file mode 100644 index 0000000..f43deac --- /dev/null +++ b/WitcherScriptMerger.Core/HeadlessMergeNotifier.cs @@ -0,0 +1,61 @@ +using System; + +namespace WitcherScriptMerger +{ + // Console-based IMergeNotifier for CLI mode. Never blocks on user input - + // every decision has a fixed, non-destructive default (don't overwrite, + // don't use a conflicting merge name, don't retry) so a batch run can + // never hang waiting for a prompt nobody is watching. + class HeadlessMergeNotifier : IMergeNotifier + { + public NotifyResult ShowMessage(string text, + string title = "", + NotifyButtons buttons = NotifyButtons.OK, + DialogIcon icon = DialogIcon.None, + NotifyResult defaultResult = NotifyResult.None) + { + Write(text, title, icon); + + // A caller-supplied defaultResult is, by IMergeNotifier's contract, the + // answer that caller considers safe/non-destructive for this specific + // prompt (see IMergeNotifier.ShowMessage's doc comment) - honor it + // directly rather than falling through to the generic per-button-set + // guess below. This matters beyond just respecting the caller's intent: + // for a button set whose generic "safe" answer doesn't actually hold for + // every call site (e.g. YesNoCancel's generic Cancel-is-safest guess is + // wrong for LoadOrderValidator, where Cancel is the one destructive, + // permanent choice), only the caller - not this generic table - actually + // knows which answer is safe. + if (defaultResult != NotifyResult.None) + return defaultResult; + + return buttons switch + { + NotifyButtons.OK => NotifyResult.OK, + NotifyButtons.YesNo => NotifyResult.No, + NotifyButtons.YesNoCancel => NotifyResult.Cancel, + NotifyButtons.AbortRetryIgnore => NotifyResult.Abort, + NotifyButtons.RetryCancel => NotifyResult.Cancel, + NotifyButtons.OKCancel => NotifyResult.Cancel, + _ => NotifyResult.Cancel, + }; + } + + public NotifyResult ShowError(string text, string title = "Error") + { + Write(text, title, DialogIcon.Error); + return NotifyResult.OK; + } + + static void Write(string text, string title, DialogIcon icon) + { + var prefix = string.IsNullOrEmpty(title) ? "WSM" : title; + var line = $"[{prefix}] {text}"; + + if (icon == DialogIcon.Error || icon == DialogIcon.Warning || icon == DialogIcon.Exclamation) + Console.Error.WriteLine(line); + else + Console.WriteLine(line); + } + } +} diff --git a/WitcherScriptMerger.Core/IMergeNotifier.cs b/WitcherScriptMerger.Core/IMergeNotifier.cs new file mode 100644 index 0000000..e7bd602 --- /dev/null +++ b/WitcherScriptMerger.Core/IMergeNotifier.cs @@ -0,0 +1,42 @@ +namespace WitcherScriptMerger +{ + // Implemented by HeadlessMergeNotifier (Core, console output, fixed non-destructive + // defaults) and by MainForm (host project, translates to/from real WinForms + // MessageBox.Show(...)/DialogResult around these neutral types) - see CLAUDE.md's + // IMergeNotifier section. Public: MainForm implements this across the Core/host + // assembly boundary. + // + // ShowModal(Form) was deliberately dropped from this interface during the Core + // split: every call site (report-form popups) is GUI-only, interactive code that + // already lives in the host project, so it calls MainForm's ShowModal directly + // instead of going through the notifier abstraction. See the PR description for + // the full reasoning. + // + // IsInteractive was also dropped here (it had zero read call sites anywhere in + // the codebase, before or after the Core split - confirmed dead code, not + // something this split made unused). + public interface IMergeNotifier + { + // defaultResult is the caller's own answer for "which result is safe/ + // non-destructive for this specific prompt" - added specifically for + // LoadOrderValidator.PromptToPrioritizeMergedMod, whose YesNoCancel prompt + // has an inverted-from-usual safety shape (Cancel is the one destructive, + // permanent choice there, not Yes/No). NotifyResult.None means "no + // preference, use whatever's generically safe/natural for this button set". + // Both implementations honor it, not just the interactive one: + // - MainForm translates it to the real MessageBoxDefaultButton (which + // button is pre-focused), matching what a direct + // MessageBox.Show(..., MessageBoxDefaultButton) call could do before the + // Core split. + // - HeadlessMergeNotifier returns it directly instead of falling through to + // its own generic per-button-set guess, since only the caller actually + // knows which answer is safe for a prompt like this one. + NotifyResult ShowMessage(string text, + string title = "", + NotifyButtons buttons = NotifyButtons.OK, + DialogIcon icon = DialogIcon.None, + NotifyResult defaultResult = NotifyResult.None); + + NotifyResult ShowError(string text, string title = "Error"); + } +} diff --git a/WitcherScriptMerger.Core/Inventory/FileHash.cs b/WitcherScriptMerger.Core/Inventory/FileHash.cs new file mode 100644 index 0000000..7e3c365 --- /dev/null +++ b/WitcherScriptMerger.Core/Inventory/FileHash.cs @@ -0,0 +1,17 @@ +using System.Xml.Serialization; + +namespace WitcherScriptMerger.Inventory +{ + [XmlRoot] + public class FileHash + { + [XmlAttribute] + public string Hash { get; set; } + + [XmlText] + public string Name { get; set; } + + [XmlIgnore] + public bool IsOutdated { get; set; } + } +} diff --git a/WitcherScriptMerger.Core/Inventory/FileMerger.cs b/WitcherScriptMerger.Core/Inventory/FileMerger.cs new file mode 100644 index 0000000..77738f9 --- /dev/null +++ b/WitcherScriptMerger.Core/Inventory/FileMerger.cs @@ -0,0 +1,868 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using WitcherScriptMerger.FileIndex; +using WitcherScriptMerger.LoadOrder; +using WitcherScriptMerger.Tools; + +namespace WitcherScriptMerger.Inventory +{ + // Core/host project split: this class used to mix TreeNode/BackgroundWorker-driven + // interactive methods with headless ones (MergeConflictsHeadless etc.) and + // constructed WinForms report forms (MergeReportForm/PackReportForm) directly. + // Neither TreeNode/BackgroundWorker nor Forms.* can appear here anymore, since this + // class now lives in the WinForms-free Core project: + // - The interactive orchestration (MergeFilesInteractive et al.) takes plain + // InteractiveMergeRequest/MergeSource data instead of TreeNode[]. The host + // project's InteractiveMergeRunner (Inventory/InteractiveMergeRunner.cs) extracts + // that data from TreeNodes, owns the BackgroundWorker, and is the thing MainForm + // actually talks to - its public API shape deliberately mirrors what this class + // used to expose (MergeByTreeNodesAsync/RepackBundleAsync), so MainForm's call + // sites barely changed. + // - Report-form popups and completion sounds (System.Media.SystemSounds, also not + // something Core should depend on) are host-only concerns now: this class calls + // OnMergeReport/OnPackReport after a successful interactive merge/pack, and + // InteractiveMergeRunner supplies callbacks that build the real forms, call + // MainForm.ShowModal, and play the sound - all exactly where the old inline + // `using (var reportForm = ...) { ShowModal }` blocks used to run. + // - KDiff3 invocation goes through IMergeEngine instead of calling Tools/KDiff3.cs + // directly - see Tools/IMergeEngine.cs for why. + public class FileMerger + { + #region Types + + public struct MergeSource + { + public FileInfo TextFile; + public FileInfo Bundle; + public FileHash Hash; + public string Name; + + public static MergeSource FromFlatFile(FileInfo file, FileHash hash) + => Create(file, hash, false); + + public static MergeSource FromBundle(FileInfo file, FileHash hash) + => Create(file, hash, true); + + static MergeSource Create(FileInfo file, FileHash hash, bool isBundle) + => new MergeSource + { + TextFile = isBundle ? null : file, + Bundle = isBundle ? file : null, + Hash = hash, + Name = ModFile.GetModNameFromPath(file.FullName) + }; + } + + public class HeadlessMergeSummary + { + public List Merged { get; } = new List(); + public List Skipped { get; } = new List(); + } + + // One file's interactive merge request, extracted by the host project's + // InteractiveMergeRunner from checked TreeNodes so this class never sees a + // TreeNode. OrderedSources[i].Name is only read (via the ConfirmRemainingConflict + // gate below) before any merging starts for this file, while every element is + // still an original per-mod source - MergeFlatFileInteractive/ + // MergeBundleFileInteractive only ever reassign a local loop variable to an + // intermediate merge result, never an element of this array, so + // ModFile.GetModNameFromPath's Paths.MergedBundleContent fallback (for a source + // that isn't an original per-mod file) never applies at that read site. + public class InteractiveMergeRequest + { + public string RelativePath; + public bool IsBundle; + public string VanillaFilePath; // null for bundle-category files + public MergeSource[] OrderedSources; + } + + // Handed to OnMergeReport after each successful interactive pairwise text + // merge, so the host project can build a MergeReportForm - Core has no Forms.* + // types to build one itself. + public class MergeReportData + { + public int MergeNum; + public int TotalMergeCount; + public string Source1Path; + public string Source2Path; + public string OutputPath; + public string Source1Name; + public string Source2Name; + } + + #endregion + + #region Members + + public MergeProgressInfo ProgressInfo { get; private set; } + + public IMergeEngine MergeEngine { get; set; } + + // Invoked after a successful interactive merge/bundle pack. Only ever set (and + // only ever invoked) on the interactive path - MergeConflictsHeadless never + // touches these. See InteractiveMergeRunner.cs for what the host project's + // callbacks actually do (report forms, completion sounds). + public Action OnMergeReport { get; set; } + public Action OnPackReport { get; set; } + + MergeInventory _inventory; + FileInfo _vanillaFile; + string _mergedModName; + string _outputPath; + + bool _bundleChanged; + List _pendingBundleMerges = new List(); + + #endregion + + public FileMerger(MergeInventory inventory, IMergeEngine mergeEngine) + { + // AppState.MergeEngine (the usual source callers pass here) defaults to + // null and is only ever populated by the one real entry point + // (Program.Main, before anything else runs) - nothing in the type system + // enforces that. Failing fast here with a clear message beats letting + // Merge()/MergeHeadless() throw an unhandled NullReferenceException from + // deep inside a merge the first time any future entry point (a test + // harness, the Linux CLI/MCP-only host planned for a later unit) + // constructs a FileMerger without going through that startup path first. + if (mergeEngine == null) + throw new ArgumentNullException(nameof(mergeEngine), + "FileMerger requires a non-null IMergeEngine. If this was constructed via " + + "AppState.MergeEngine, the host entry point never set it - see Tools/IMergeEngine.cs."); + + _inventory = inventory; + MergeEngine = mergeEngine; + ProgressInfo = new MergeProgressInfo(); + } + + #region Interactive + + public void MergeFilesInteractive(IReadOnlyList filesToMerge, string mergedModName) + { + _mergedModName = mergedModName; + + ProgressInfo.TotalMergeCount = filesToMerge.Sum(f => f.OrderedSources.Length - 1); + ProgressInfo.TotalFileCount = filesToMerge.Count; + + for (int i = 0; i < filesToMerge.Count; ++i) + { + var file = filesToMerge[i]; + + ProgressInfo.CurrentFileName = Path.GetFileName(file.RelativePath); + ProgressInfo.CurrentFileNum = i + 1; + ProgressInfo.CurrentAction = "Starting merge"; + + if (file.OrderedSources.Any(source => (new LoadOrderComparer()).Compare(source.Name, _mergedModName) < 0) && + !ConfirmRemainingConflict(_mergedModName)) + continue; + + var isNew = false; + var merge = _inventory.Merges.FirstOrDefault(m => m.RelativePath.EqualsIgnoreCase(file.RelativePath)); + if (merge == null) + { + isNew = true; + merge = new Merge + { + RelativePath = file.RelativePath, + MergedModName = _mergedModName + }; + } + + if (file.IsBundle) + { + merge.BundleName = Path.GetFileName(Paths.RetrieveMergedBundlePath()); + MergeBundleFileInteractive(file, merge, isNew); + } + else + MergeFlatFileInteractive(file, merge, isNew); + } + if (_bundleChanged) + { + var newBundlePath = PackNewBundle(Paths.RetrieveMergedBundlePath()); + if (newBundlePath != null) + { + ProgressInfo.CurrentAction = "Adding bundle merge to inventory"; + foreach (var bundleMerge in _pendingBundleMerges) + _inventory.Merges.Add(bundleMerge); + + OnPackReport?.Invoke(newBundlePath); + } + } + CleanUpTempFiles(); + CleanUpEmptyDirectories(); + } + + void MergeFlatFileInteractive(InteractiveMergeRequest file, Merge merge, bool isNew) + { + var source1 = file.OrderedSources[0]; + + var relPath = Paths.GetRelativePath( + source1.TextFile.FullName, + Path.Combine(Paths.ModsDirectory, source1.Name)); + + _outputPath = Path.Combine(Paths.ModsDirectory, _mergedModName, relPath); + + if (File.Exists(_outputPath) && !ConfirmOutputOverwrite(_outputPath)) + return; + + _vanillaFile = new FileInfo(file.VanillaFilePath); + + for (int i = 1; i < file.OrderedSources.Length; ++i) + { + ++ProgressInfo.CurrentMergeNum; + + var source2 = file.OrderedSources[i]; + + var mergedFile = MergeTextInteractive(merge, source1, source2); + if (mergedFile != null) + { + source1 = MergeSource.FromFlatFile(mergedFile, null); + } + else if (!ConfirmContinueAfterCanceledMerge(file.OrderedSources.Length - i - 1, merge)) + break; + } + + if (isNew && merge.Mods.Count > 1) + { + ProgressInfo.CurrentAction = "Adding script merge to inventory"; + _inventory.Merges.Add(merge); + } + } + + void MergeBundleFileInteractive(InteractiveMergeRequest file, Merge merge, bool isNew) + { + _outputPath = Path.Combine(Paths.MergedBundleContent, file.RelativePath); + + if (File.Exists(_outputPath) && !ConfirmOutputOverwrite(_outputPath)) + return; + + _vanillaFile = null; + + var source1 = file.OrderedSources[0]; + + for (int i = 1; i < file.OrderedSources.Length; ++i) + { + ++ProgressInfo.CurrentMergeNum; + + var source2 = file.OrderedSources[i]; + + if (!GetUnpackedFiles(file.RelativePath, ref source1, ref source2)) + { + if (ConfirmContinueAfterCanceledMerge(file.OrderedSources.Length - i - 1, merge)) + continue; + break; + } + + var mergedFile = MergeTextInteractive(merge, source1, source2); + if (mergedFile != null) + { + source1 = MergeSource.FromFlatFile(mergedFile, null); + } + else if (!ConfirmContinueAfterCanceledMerge(file.OrderedSources.Length - i - 1, merge)) + break; + } + + if (merge.BundleName != null && isNew && merge.Mods.Count > 1) + { + _bundleChanged = true; + _pendingBundleMerges.Add(merge); + } + } + + FileInfo MergeTextInteractive(Merge merge, MergeSource source1, MergeSource source2) + { + // Deliberately engine-neutral wording: this used to name KDiff3 explicitly + // ("waiting for KDiff3 to close"), which is wrong when MergeEngine is + // DiffPlexMergeEngine instead - no external process or window is involved + // there at all. Flagged in code review, see CLAUDE.md. + ProgressInfo.CurrentAction = $"Merging {source1.Name} && {source2.Name}"; + + var result = MergeEngine.Merge(source1, source2, _vanillaFile, _outputPath); + + if (result != MergeEngineResult.AutoSolved) + return null; + + RecordMergedSources(merge, source1, source2); + + OnMergeReport?.Invoke(new MergeReportData + { + MergeNum = ProgressInfo.CurrentMergeNum, + TotalMergeCount = ProgressInfo.TotalMergeCount, + Source1Path = source1.TextFile.FullName, + Source2Path = source2.TextFile.FullName, + OutputPath = _outputPath, + Source1Name = source1.Name, + Source2Name = source2.Name, + }); + + return new FileInfo(_outputPath); + } + + // Synchronous - the host project's InteractiveMergeRunner runs this on its own + // BackgroundWorker, same as it did when this logic lived directly in + // RepackBundleAsync. + public string RepackBundle(string bundlePath) + { + var newBundlePath = PackNewBundle(bundlePath, isRepack: true); + if (newBundlePath != null) + OnPackReport?.Invoke(newBundlePath); + return newBundlePath; + } + + bool ConfirmRemainingConflict(string mergedModName) + { + return (NotifyResult.Yes == AppState.Notifier.ShowMessage( + "There will still be a conflict if you use the merged mod name " + mergedModName + ".\n\n" + + "The Witcher 3 loads mods in case-insensitive ASCII order, " + + "so this merged mod name will load after one of the original mods, " + + "and the merged file will be ignored.\n\n" + + "Use this name anyway?", + "Merged Mod Name Conflict", + NotifyButtons.YesNo, + DialogIcon.Exclamation)); + } + + // Returns false when the caller should stop trying further merges for this + // file (user declined to continue past a canceled/failed merge). + bool ConfirmContinueAfterCanceledMerge(int remainingMergesForFile, Merge merge) + { + var msg = $"Merge {ProgressInfo.CurrentMergeNum} of {ProgressInfo.TotalMergeCount} was canceled."; + var buttons = NotifyButtons.OK; + if (remainingMergesForFile > 0) + { + var fileName = Path.GetFileName(merge.RelativePath); + msg += $"\n\nContinue with {remainingMergesForFile} remaining merge{remainingMergesForFile.GetPluralS()} for file {fileName}?"; + buttons = NotifyButtons.YesNo; + } + var result = AppState.Notifier.ShowMessage(msg, "Skipped Merge", buttons, DialogIcon.Information); + if (result == NotifyResult.No) + { + ProgressInfo.CurrentMergeNum += remainingMergesForFile; + return false; + } + return true; + } + + #endregion + + #region Headless + + // Headless equivalent of MergeFlatFileInteractive/MergeBundleFileInteractive, + // driven by plain ModFile/FileHash data (FileIndex/ModFileIndex.Conflicts) + // instead of InteractiveMergeRequest - those already carry everything needed + // (relative path, category, per-mod name and hash), so no TreeNode is ever + // involved on this path either. + public HeadlessMergeSummary MergeConflictsHeadless( + IEnumerable conflicts, + string mergedModName, + IReadOnlyDictionary orderOverrides, + bool dryRun = false) + { + var summary = new HeadlessMergeSummary(); + + foreach (var conflict in conflicts.Where(c => + c.Category == Categories.Script || c.Category == Categories.Xml || c.Category == Categories.BundleText)) + { + var orderedNames = ResolveMergeOrder(conflict, mergedModName, orderOverrides); + if (orderedNames == null) + { + summary.Skipped.Add(conflict.RelativePath); + continue; + } + + // A dry run always merges into a throwaway record instead of one pulled + // from _inventory.Merges - that way nothing this pass does to `merge` + // (BundleName, recorded source hashes via RecordMergedSources) can ever + // mutate a live object still referenced by the loaded inventory, even if + // some other code path calls Save() later. isNew is irrelevant for a dry + // run since the block below that would add it to the inventory is itself + // skipped for dry runs. + var isNew = false; + Merge merge = null; + if (!dryRun) + merge = _inventory.Merges.FirstOrDefault(m => m.RelativePath.EqualsIgnoreCase(conflict.RelativePath)); + if (merge == null) + { + isNew = true; + merge = new Merge + { + RelativePath = conflict.RelativePath, + MergedModName = mergedModName + }; + } + + var isBundle = conflict.Category == Categories.BundleText; + var fullyMerged = isBundle + ? MergeBundleConflictHeadless(conflict, merge, orderedNames, dryRun) + : MergeFlatConflictHeadless(conflict, merge, mergedModName, orderedNames, dryRun); + + if (!fullyMerged) + { + summary.Skipped.Add(conflict.RelativePath); + continue; + } + + // Dry run never adds to the inventory or flags the bundle as needing a + // repack - it only reports what *would* happen, so PackNewBundle (which + // overwrites the real blob0.bundle) never runs for one either. + if (!dryRun && isNew && merge.Mods.Count > 1) + { + if (isBundle) + { + _bundleChanged = true; + _pendingBundleMerges.Add(merge); + } + else + _inventory.Merges.Add(merge); + } + summary.Merged.Add(conflict.RelativePath); + } + + if (_bundleChanged) + { + var newBundlePath = PackNewBundle(Paths.RetrieveMergedBundlePath()); + if (newBundlePath != null) + { + foreach (var bundleMerge in _pendingBundleMerges) + _inventory.Merges.Add(bundleMerge); + } + else + { + // Content merged fine, but repacking blob0.bundle failed - those + // conflicts didn't actually make it into a usable merge. + foreach (var bundleMerge in _pendingBundleMerges) + { + summary.Merged.Remove(bundleMerge.RelativePath); + summary.Skipped.Add(bundleMerge.RelativePath); + } + } + } + + CleanUpTempFiles(); + CleanUpEmptyDirectories(); + + return summary; + } + + bool MergeFlatConflictHeadless(ModFile conflict, Merge merge, string mergedModName, string[] orderedNames, bool dryRun) + { + var firstHash = conflict.Mods.First(h => h.Name.EqualsIgnoreCase(orderedNames[0])); + var source1 = MergeSource.FromFlatFile(new FileInfo(conflict.GetModFile(orderedNames[0])), firstHash); + + var relPath = Paths.GetRelativePath( + source1.TextFile.FullName, + Path.Combine(Paths.ModsDirectory, source1.Name)); + var realOutputPath = Path.Combine(Paths.ModsDirectory, mergedModName, relPath); + + // Checked against the real would-be output path regardless of dryRun: a real + // run always declines to overwrite an existing output (HeadlessMergeNotifier's + // fixed default), so a dry run needs to predict that same "already exists, + // would be skipped" outcome rather than only ever reporting whether the text + // itself would auto-solve - otherwise a preview and the real run it's meant to + // predict could disagree on a conflict whose output already exists. + if (File.Exists(realOutputPath) && !ConfirmOutputOverwrite(realOutputPath)) + return false; + + // KDiff3 always physically writes its -o target on a successful solve - there's + // no "check without writing" mode - so a dry run still needs somewhere real to + // land. Routing it under TempBundleContent instead of the real mod output path + // means CleanUpTempFiles() at the end of MergeConflictsHeadless deletes it + // afterward, so nothing from a dry run is meant to survive past this call (best + // -effort, like the rest of this method's cleanup - see CleanUpTempFiles). + _outputPath = dryRun + ? Path.Combine(Paths.TempBundleContent, "DryRun", conflict.RelativePath) + : realOutputPath; + + _vanillaFile = new FileInfo(conflict.GetVanillaFile()); + + for (int i = 1; i < orderedNames.Length; ++i) + { + var hash = conflict.Mods.First(h => h.Name.EqualsIgnoreCase(orderedNames[i])); + var source2 = MergeSource.FromFlatFile(new FileInfo(conflict.GetModFile(orderedNames[i])), hash); + + var mergedFile = MergeTextHeadless(merge, source1, source2, dryRun); + if (mergedFile == null) + return false; + source1 = MergeSource.FromFlatFile(mergedFile, null); + } + return true; + } + + bool MergeBundleConflictHeadless(ModFile conflict, Merge merge, string[] orderedNames, bool dryRun) + { + merge.BundleName = Path.GetFileName(Paths.RetrieveMergedBundlePath()); + + var realOutputPath = Path.Combine(Paths.MergedBundleContent, conflict.RelativePath); + + // See the matching comment in MergeFlatConflictHeadless - same reasoning: check + // against the real would-be output regardless of dryRun, so a preview predicts + // the same "already exists, declined" outcome a real run would hit. + if (File.Exists(realOutputPath) && !ConfirmOutputOverwrite(realOutputPath)) + return false; + + // Rooted under TempBundleContent instead of MergedBundleContent for a dry run so + // its intermediate merge text can never linger there either - see the matching + // comment in MergeFlatConflictHeadless. + _outputPath = dryRun + ? Path.Combine(Paths.TempBundleContent, "DryRun", conflict.RelativePath) + : realOutputPath; + + _vanillaFile = null; + + var firstHash = conflict.Mods.First(h => h.Name.EqualsIgnoreCase(orderedNames[0])); + var source1 = MergeSource.FromBundle(new FileInfo(conflict.GetModFile(orderedNames[0])), firstHash); + + for (int i = 1; i < orderedNames.Length; ++i) + { + var hash = conflict.Mods.First(h => h.Name.EqualsIgnoreCase(orderedNames[i])); + var source2 = MergeSource.FromBundle(new FileInfo(conflict.GetModFile(orderedNames[i])), hash); + + if (!GetUnpackedFiles(conflict.RelativePath, ref source1, ref source2)) + return false; + + var mergedFile = MergeTextHeadless(merge, source1, source2, dryRun); + if (mergedFile == null) + return false; + source1 = MergeSource.FromFlatFile(mergedFile, null); + } + return true; + } + + // Explicit order-file entries win; conflicts not listed fall back to the same + // LoadOrderComparer ordering ConflictTree's default sort already uses + // (Controls/SMTreeSorter.cs), so headless behavior matches the GUI's default + // without needing every conflict spelled out. + string[] ResolveMergeOrder(ModFile conflict, string mergedModName, IReadOnlyDictionary orderOverrides) + { + if (orderOverrides != null && orderOverrides.TryGetValue(conflict.RelativePath, out var explicitOrder)) + { + if (explicitOrder == null) + { + AppState.Notifier.ShowError($"Order file's mod list for {conflict.RelativePath} is null."); + return null; + } + + var unknown = explicitOrder.Where(name => !conflict.ContainsMod(name)).ToArray(); + if (unknown.Any()) + { + AppState.Notifier.ShowError( + $"Order file lists unknown mod(s) for {conflict.RelativePath}: " + + string.Join(", ", unknown.Select(n => n ?? "(null)"))); + return null; + } + + var distinctCount = explicitOrder.Distinct(StringComparer.OrdinalIgnoreCase).Count(); + if (distinctCount != explicitOrder.Length) + { + AppState.Notifier.ShowError( + $"Order file's mod list for {conflict.RelativePath} names the same mod more than once."); + return null; + } + + // A merge needs at least a pair to actually do anything - without this, + // a conflict left with only one *real* source after excluding + // mergedModName (e.g. every other source mod's file was since removed, + // leaving only the previous merge output and one real mod) could pass an + // override naming just that one real mod: nothing "missing", no + // duplicate, but MergeFlatConflictHeadless's chain loop (starting at + // index 1) would never run, silently reporting the file as fully merged + // having merged nothing and written nothing. + if (explicitOrder.Length < 2) + { + AppState.Notifier.ShowError( + $"Order file's mod list for {conflict.RelativePath} must name at least two mods to merge."); + return null; + } + + // Every *real* source mod must be covered - omitting one would otherwise + // merge an incomplete chain and still report the file as fully merged. + // mergedModName itself doesn't count as a required source: once a file has + // already been merged once, its own merged-mod folder re-enters + // conflict.Mods as if it were a source (scan_conflicts's own description + // warns clients about this), and the documented way to re-merge after a + // source mod's file changes is to list just the real mods again - not to + // also re-list the previous merge output. + var requiredNames = conflict.Mods + .Select(m => m.Name) + .Where(name => !name.EqualsIgnoreCase(mergedModName)) + .ToArray(); + var missing = requiredNames.Where(name => !explicitOrder.Any(n => n.EqualsIgnoreCase(name))).ToArray(); + if (missing.Any()) + { + AppState.Notifier.ShowError( + $"Order file's mod list for {conflict.RelativePath} is missing conflicting mod(s): " + + string.Join(", ", missing)); + return null; + } + + return explicitOrder; + } + + return conflict.Mods + .Select(h => h.Name) + .OrderBy(name => name, new LoadOrderComparer()) + .ToArray(); + } + + FileInfo MergeTextHeadless(Merge merge, MergeSource source1, MergeSource source2, bool dryRun) + { + ProgressInfo.CurrentAction = $"Merging {source1.Name} && {source2.Name}"; + + var result = MergeEngine.MergeHeadless(source1, source2, _vanillaFile, _outputPath); + + if (result != MergeEngineResult.AutoSolved) + return null; + + // Dry run only needs the auto-solve verdict above. `merge` is always a + // throwaway object for a dry run (see MergeConflictsHeadless), so skipping + // this is a second, independent guard rather than the only thing preventing a + // recorded-hash mutation from reaching the loaded inventory. + if (!dryRun) + RecordMergedSources(merge, source1, source2); + + return new FileInfo(_outputPath); + } + + #endregion + + #region Shared + + // Shared by MergeTextInteractive/MergeTextHeadless after a successful merge: + // records each source's hash into the merge record, unless that source IS the + // output file itself (the accumulated merge target from a previous pairwise + // merge in the same multi-mod chain, not a distinct mod source) or lives under + // MergedBundleContent (an intermediate bundle-merge byproduct, same reasoning). + // MergeInventory.xml's hashes are load-bearing (see CLAUDE.md's Compatibility + // constraints) - kept in one place so a future fix to this guard has one call + // site to touch, not two that can silently drift apart. + void RecordMergedSources(Merge merge, MergeSource source1, MergeSource source2) + { + if (!source1.TextFile.FullName.EqualsIgnoreCase(_outputPath) + && !source1.TextFile.FullName.StartsWithIgnoreCase(Paths.MergedBundleContentAbsolute)) + { + _inventory.AddModToMerge(source1, merge); + } + + if (!source2.TextFile.FullName.EqualsIgnoreCase(_outputPath) + && !source2.TextFile.FullName.StartsWithIgnoreCase(Paths.MergedBundleContentAbsolute)) + { + _inventory.AddModToMerge(source2, merge); + } + } + + bool ConfirmOutputOverwrite(string outputPath) + { + return (NotifyResult.Yes == AppState.Notifier.ShowMessage( + "The output file below already exists! Overwrite?\n\n" + outputPath, + "Overwrite?", + NotifyButtons.YesNo, + DialogIcon.Exclamation)); + } + + bool GetUnpackedFiles(string contentRelativePath, ref MergeSource source1, ref MergeSource source2) + { + if (_vanillaFile == null) + { + 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.Exists(Paths.BundlesDirectory) + ? Directory.GetDirectories(Paths.BundlesDirectory).Select(path => Path.Combine(path, "bundles")) + : Enumerable.Empty()) + .Concat( + 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()) + .ToArray(); + + for (int i = bundleDirs.Length - 1; i >= 0; --i) // Search vanilla directories in reverse + { // order, as patches & DLC override content. + var bundleFiles = Directory.GetFiles(bundleDirs[i], "*.bundle"); + foreach (var bundle in bundleFiles) + { + var contentPaths = QuickBms.GetBundleContentPaths(bundle); + if (contentPaths.Any(path => path.EqualsIgnoreCase(contentRelativePath))) + { + _vanillaFile = new FileInfo(bundle); + break; + } + } + if (_vanillaFile != null) + break; + } + if (_vanillaFile != null) + { + ProgressInfo.CurrentAction = "Unpacking vanilla bundle content file"; + var vanillaContentPath = UnpackFile(_vanillaFile.FullName, contentRelativePath, "Vanilla"); + _vanillaFile = new FileInfo(vanillaContentPath); + } + } + + if (source1.TextFile == null) + { + ProgressInfo.CurrentAction = $"Unpacking bundle content file for {source1.Name}"; + var modContentFile1 = UnpackFile(source1.Bundle.FullName, contentRelativePath, "Mod 1"); + if (modContentFile1 == null) + return false; + source1.TextFile = new FileInfo(modContentFile1); + } + ProgressInfo.CurrentAction = $"Unpacking bundle content file for {source2.Name}"; + var modContentFile2 = UnpackFile(source2.Bundle.FullName, contentRelativePath, "Mod 2"); + if (modContentFile2 == null) + return false; + source2.TextFile = new FileInfo(modContentFile2); + return true; + } + + string UnpackFile(string bundlePath, string contentRelativePath, string outputDirName) + { + var outputDir = Path.Combine(Paths.TempBundleContent, outputDirName); + + var exitCode = QuickBms.UnpackFile(bundlePath, contentRelativePath, outputDir); + + return exitCode == 0 + ? Path.Combine(outputDir, contentRelativePath) + : null; + } + + string PackNewBundle(string bundlePath, bool isRepack = false) + { + ProgressInfo.CurrentPhase = (!isRepack ? "Packing Bundle" : "Repacking Bundle"); + ProgressInfo.CurrentAction = "Packing merged bundle content into new blob0.bundle"; + + var outputDir = Path.GetDirectoryName(bundlePath); + + var exitCode = WccLite.PackBundle(Paths.MergedBundleContentAbsolute, outputDir); + if (exitCode != 0) + return null; + + ProgressInfo.CurrentAction = "Generating metadata.store for new blob0.bundle"; + + exitCode = WccLite.GenerateMetadata(outputDir); + if (exitCode != 0) + return null; + + return bundlePath; + } + + void CleanUpTempFiles() + { + if (!Directory.Exists(Paths.TempBundleContent)) + return; + + try + { + ProgressInfo.CurrentAction = "Deleting temporary unpacked bundle content"; + DeleteDirectory(Paths.TempBundleContent); + } + catch (Exception ex) + { + AppState.Notifier.ShowMessage( + "Non-critical error: Failed to delete temporary unpacked bundle content.\n\n" + ex.Message, + "Error", + NotifyButtons.OK, + DialogIcon.Warning); + } + } + + void CleanUpEmptyDirectories() + { + if (!Directory.Exists(Paths.MergedBundleContent)) + return; + + try + { + ProgressInfo.CurrentAction = "Deleting empty Merged Bundle Content directories"; + DeleteEmptyDirectories(Paths.MergedBundleContent); + } + catch (Exception ex) + { + AppState.Notifier.ShowMessage( + "Non-critical error: Failed to delete empty Merged Bundle Content directories.\n\n" + ex.Message, + "Error", + NotifyButtons.OK, + DialogIcon.Warning); + } + } + + /// + /// Depth-first recursive delete, with handling for descendant + /// directories open in Windows Explorer. + /// + void DeleteDirectory(string path) + { + foreach (var subdirPath in Directory.GetDirectories(path)) + { + System.Threading.Thread.Sleep(1); + DeleteDirectory(subdirPath); + } + + try + { + System.Threading.Thread.Sleep(1); + Directory.Delete(path, true); + } + catch (IOException) + { + System.Threading.Thread.Sleep(1); + Directory.Delete(path, true); + } + catch (UnauthorizedAccessException) + { + System.Threading.Thread.Sleep(1); + Directory.Delete(path, true); + } + catch (Exception) + { + throw; + } + } + + /// + /// Deletes any subdirectories of the root that are empty, AS WELL AS the root itself, if it's empty. + /// + void DeleteEmptyDirectories(string rootPath) + { + foreach (string directory in Directory.GetDirectories(rootPath)) + { + System.Threading.Thread.Sleep(1); + DeleteEmptyDirectories(directory); + } + + if (Directory.GetFiles(rootPath).Any() || Directory.GetDirectories(rootPath).Any()) + return; + + try + { + System.Threading.Thread.Sleep(1); + DeleteDirectory(rootPath); + } + catch (Exception) + { + throw; + } + } + + #endregion + } +} diff --git a/WitcherScriptMerger.Core/Inventory/Merge.cs b/WitcherScriptMerger.Core/Inventory/Merge.cs new file mode 100644 index 0000000..41957a8 --- /dev/null +++ b/WitcherScriptMerger.Core/Inventory/Merge.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Serialization; +using WitcherScriptMerger.FileIndex; + +namespace WitcherScriptMerger.Inventory +{ + [XmlRoot] + public class Merge : ModFile + { + [XmlElement] + public string MergedModName; + + public string GetMergedFile() + { + if (Category == Categories.Script) + return Path.Combine(Paths.ModsDirectory, MergedModName, Paths.ModScriptBase, RelativePath); + else if (Category == Categories.Xml) + return Path.Combine(Paths.ModsDirectory, MergedModName, RelativePath); + else if (Category == Categories.BundleText) + return Path.Combine(Paths.MergedBundleContent, RelativePath); + else + throw new NotImplementedException(); + } + + public string GetMergedBundle() + { + if (Category != Categories.BundleText) + throw new Exception($"Can't get bundle for file of category '{Category.DisplayName}'."); + + return Path.Combine(Paths.ModsDirectory, MergedModName, Paths.BundleBase, BundleName); + } + + public FileHash GetHashByModName(string modName) + { + return Mods.FirstOrDefault(m => m.Name.EqualsIgnoreCase(modName)); + } + } +} diff --git a/WitcherScriptMerger.Core/Inventory/MergeInventory.cs b/WitcherScriptMerger.Core/Inventory/MergeInventory.cs new file mode 100644 index 0000000..6428254 --- /dev/null +++ b/WitcherScriptMerger.Core/Inventory/MergeInventory.cs @@ -0,0 +1,156 @@ +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Xml.Serialization; +using WitcherScriptMerger.FileIndex; +using WitcherScriptMerger.LoadOrder; + +namespace WitcherScriptMerger.Inventory +{ + [XmlRoot] + public class MergeInventory + { + [XmlElement("Merge")] + public ObservableCollection Merges { get; private set; } + + [XmlIgnore] + public bool ScriptsChanged { get; private set; } + + [XmlIgnore] + public bool XmlChanged { get; private set; } + + [XmlIgnore] + public bool BundleChanged { get; private set; } + + [XmlIgnore] + public bool HasChanged => (ScriptsChanged || XmlChanged || BundleChanged); + + static XmlSerializer _serializer = new XmlSerializer(typeof(MergeInventory)); + + public MergeInventory() + { + Merges = new ObservableCollection(); + Merges.CollectionChanged += Merges_CollectionChanged; + } + + // allowSave gates AddMissingHashes' own auto-heal Save() below - a caller previewing + // via merge_conflicts(dryRun: true) needs a guarantee that Load() itself can never + // write to MergeInventory.xml, even when an older-schema file is missing hashes and + // would otherwise get silently backfilled and saved as a side effect of just loading + // it. Every other caller (the `merge` CLI verb, scan_conflicts, list_merges, the GUI) + // keeps the previous default (true) unchanged. + public static MergeInventory Load(string path, bool allowSave = true) + { + MergeInventory inventory; + try + { + _serializer = new XmlSerializer(typeof(MergeInventory)); + using (var stream = File.OpenRead(path)) + { + inventory = (MergeInventory)_serializer.Deserialize(stream); + } + + AddMissingHashes(inventory, allowSave); + } + catch + { + inventory = new MergeInventory(); + } + inventory.ScriptsChanged = inventory.XmlChanged = inventory.BundleChanged = false; + return inventory; + } + + void Merges_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + if ((e.NewItems != null && e.NewItems.Cast().Any(merge => merge.Category == Categories.Script)) || + (e.OldItems != null && e.OldItems.Cast().Any(merge => merge.Category == Categories.Script))) + ScriptsChanged = true; + if ((e.NewItems != null && e.NewItems.Cast().Any(merge => merge.Category == Categories.Xml)) || + (e.OldItems != null && e.OldItems.Cast().Any(merge => merge.Category == Categories.Xml))) + XmlChanged = true; + if ((e.NewItems != null && e.NewItems.Cast().Any(merge => merge.IsBundleContent)) || + (e.OldItems != null && e.OldItems.Cast().Any(merge => merge.IsBundleContent))) + BundleChanged = true; + } + + public void AddModToMerge(FileMerger.MergeSource source, Merge m) + { + var modFilePath = + m.IsBundleContent + ? source.Bundle.FullName + : source.TextFile.FullName; + + var existingMod = m.Mods.Find(mod => mod.Name.EqualsIgnoreCase(source.Name)); + if (existingMod != null) + existingMod.Hash = Tools.Hasher.ComputeHash(modFilePath); + else + { + m.Mods.Add( + new FileHash + { + Hash = Tools.Hasher.ComputeHash(modFilePath), + Name = source.Name + }); + } + + if (m.Category == Categories.Script) + ScriptsChanged = true; + else if (m.Category == Categories.Xml) + XmlChanged = true; + else if (m.IsBundleContent) + BundleChanged = true; + } + + public void Save() + { + if (_serializer == null) + return; + using (var writer = new StreamWriter(Paths.Inventory)) + { + _serializer.Serialize(writer, this); + } + } + + public bool HasResolvedConflict(ModFile conflict) + { + var merge = Merges.FirstOrDefault(mrg => mrg.RelativePath.EqualsIgnoreCase(conflict.RelativePath)); + if (merge == null) + return false; + + if (conflict.Mods.Any(mod => !mod.Name.EqualsIgnoreCase(merge.MergedModName) && !merge.ContainsMod(mod.Name))) + return false; + + if (merge.Mods.Any(mod => new LoadOrderComparer().Compare(merge.MergedModName, mod.Name) > 0)) + return false; + + return + merge.Mods.All(mod => mod.Hash == Tools.Hasher.ComputeHash(merge.GetModFile(mod.Name))); + } + + public Merge GetMergeByRelativePath(string relativePath) + { + return Merges.FirstOrDefault(m => m.RelativePath.EqualsIgnoreCase(relativePath)); + } + + // Adds file hashes to old inventories that don't have them + static void AddMissingHashes(MergeInventory inventory, bool allowSave) + { + var anyMissing = false; + + foreach (var merge in inventory.Merges) + { + foreach (var mod in merge.Mods) + { + if (mod.Hash == null) + { + anyMissing = true; + mod.Hash = Tools.Hasher.ComputeHash(merge.GetModFile(mod.Name)); + } + } + } + + if (anyMissing && allowSave) + inventory.Save(); + } + } +} diff --git a/WitcherScriptMerger.Core/Inventory/MergeProgressInfo.cs b/WitcherScriptMerger.Core/Inventory/MergeProgressInfo.cs new file mode 100644 index 0000000..f02beff --- /dev/null +++ b/WitcherScriptMerger.Core/Inventory/MergeProgressInfo.cs @@ -0,0 +1,105 @@ +using System.ComponentModel; + +namespace WitcherScriptMerger.Inventory +{ + public class MergeProgressInfo : INotifyPropertyChanged + { + string _currentAction; + public string CurrentAction + { + get { return _currentAction; } + set { Set(ref _currentAction, value); } + } + + string _currentPhase; + public string CurrentPhase + { + get { return _currentPhase; } + set { Set(ref _currentPhase, value); } + } + + int _currentMergeNum; + public int CurrentMergeNum + { + get { return _currentMergeNum; } + set + { + _currentMergeNum = value; + UpdatePhase(); + } + } + + int _totalMergeCount; + public int TotalMergeCount + { + get { return _totalMergeCount; } + set + { + _totalMergeCount = value; + UpdatePhase(); + } + } + + string _currentFileName; + public string CurrentFileName + { + get { return _currentFileName; } + set + { + _currentFileName = value; + UpdatePhase(); + } + } + + int _currentFileNum; + public int CurrentFileNum + { + get { return _currentFileNum; } + set + { + _currentFileNum = value; + UpdatePhase(); + } + } + + int _totalFileCount; + public int TotalFileCount + { + get { return _totalFileCount; } + set + { + _totalFileCount = value; + UpdatePhase(); + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + protected virtual void OnPropertyChanged() + { + PropertyChanged?.Invoke(this, null); + } + + void Set(ref T property, T value) + { + property = value; + OnPropertyChanged(); + } + + void UpdatePhase() + { + CurrentPhase = + "Resolving mod conflict" + + ( + TotalMergeCount > 1 + ? $" {CurrentMergeNum} of {TotalMergeCount}" : "" + ) + + "\nFile" + + ( + TotalFileCount > 1 && TotalFileCount != TotalMergeCount + ? $" {CurrentFileNum} of {TotalFileCount}" : "" + ) + + $": {CurrentFileName}"; + } + } +} diff --git a/WitcherScriptMerger.Core/LoadOrder/CustomLoadOrder.cs b/WitcherScriptMerger.Core/LoadOrder/CustomLoadOrder.cs new file mode 100644 index 0000000..9428032 --- /dev/null +++ b/WitcherScriptMerger.Core/LoadOrder/CustomLoadOrder.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace WitcherScriptMerger.LoadOrder +{ + public class CustomLoadOrder + { + public const int TopPriority = 0; + public const int BottomPriority = 9999; + + public readonly string FilePath = + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + "The Witcher 3", + "mods.settings"); + + public List Mods { get; private set; } + + public bool IsValid { get; private set; } + + public CustomLoadOrder() + { + Refresh(); + } + + #region File Processing + + public void Refresh() + { + Mods = new List(); + IsValid = false; + + if (!File.Exists(FilePath)) + { + IsValid = true; + return; + } + + var lines = File.ReadAllLines(FilePath); + + List mods = new List(); + ModLoadSetting currModSetting = null; + + for (int i = 0; i < lines.Length; ++i) + { + if (!ProcessLine(lines[i], i + 1, ref currModSetting)) + return; + + if (currModSetting != null + && currModSetting.IsEnabled.HasValue + && currModSetting.Priority.HasValue) + { + mods.Add(currModSetting); + currModSetting = null; + } + } + + IsValid = true; + + Mods = mods + .OrderBy(m => m.Priority) + .ThenBy(m => m.ModName) + .ToList(); + } + + bool ProcessLine(string line, int lineNum, ref ModLoadSetting setting) + { + line = line.Replace(" ", "").Replace("\t", ""); + + if (line.StartsWith("[") && line.EndsWith("]")) + { + if (!ProcessModNameLine(line, ref setting)) + return false; + } + else if (line.StartsWith("Enabled=")) + { + if (!ProcessIsEnabledLine(line, lineNum, setting)) + return false; + } + else if (line.StartsWith("Priority=")) + { + if (!ProcessPriorityLine(line, lineNum, setting)) + return false; + } + else if (!string.IsNullOrWhiteSpace(line) && !line.StartsWith(";")) + { + ShowWarningForMalformedFile($"Unrecognized value on line {lineNum}:\n\n{line}"); + return false; + } + return true; + } + + bool ProcessModNameLine(string line, ref ModLoadSetting setting) + { + if (setting != null) + { + ShowWarningForMalformedFile($"{setting.ModName} settings are incomplete. 'Enabled' and 'Priority' are both required."); + return false; + } + + var modName = line.Substring(1, line.Length - 2); // Trim brackets + setting = new ModLoadSetting(modName); + return true; + } + + bool ProcessIsEnabledLine(string line, int lineNum, ModLoadSetting setting) + { + if (setting == null) + { + ShowWarningForMalformedFile($"The 'Enabled' setting on line {lineNum} doesn't have a corresponding mod name."); + return false; + } + if (!new Regex("^Enabled=[0|1]$").IsMatch(line)) + { + ShowWarningForMalformedFile($"The 'Enabled' setting on line {lineNum} isn't within the valid range of 0 or 1:\n\n{line}"); + return false; + } + + setting.IsEnabled = line.EndsWith("1"); + return true; + } + + bool ProcessPriorityLine(string line, int lineNum, ModLoadSetting setting) + { + if (setting == null) + { + ShowWarningForMalformedFile($"The 'Priority' setting on line {lineNum} doesn't have a corresponding mod name."); + return false; + } + + var priorityString = line.Substring(line.IndexOf('=') + 1); + int parsedPriority; + + if (!int.TryParse(priorityString, out parsedPriority)) + { + ShowWarningForMalformedFile($"Can't parse the priority on line {lineNum}:\n\n{line}"); + return false; + } + if (TopPriority > parsedPriority || parsedPriority > BottomPriority) + { + ShowWarningForMalformedFile($"The priority on line {lineNum} isn't within the valid range of {TopPriority} to {BottomPriority}:\n\n{line}"); + return false; + } + + setting.Priority = parsedPriority; + return true; + } + + void ShowWarningForMalformedFile(string reason) + { + AppState.Notifier.ShowMessage( + "Your mods.settings file is invalid.\n\n" + reason, + "Invalid Load Order File", + NotifyButtons.OK, + DialogIcon.Warning); + } + + public void Save() + { + var builder = new StringBuilder(); + + foreach (var modSetting in Mods) + { + builder + .Append("[").Append(modSetting.ModName).AppendLine("]") + .Append("Enabled = ").AppendLine(Convert.ToInt32(modSetting.IsEnabled).ToString()) + .Append("Priority = ").AppendLine(modSetting.Priority.ToString()); + + if (modSetting != Mods.Last()) + builder.AppendLine(); + } + + File.WriteAllText(FilePath, builder.ToString()); + } + + #endregion + + public void AddMergedModIfMissing() + { + var mergedModName = Paths.RetrieveMergedModName(); + + if (!Mods.Any(setting => setting.ModName.EqualsIgnoreCase(mergedModName))) + { + Mods.Insert(0, + new ModLoadSetting + { + ModName = mergedModName, + IsEnabled = true, + Priority = TopPriority + }); + } + } + + public bool HasResolvedConflict(IEnumerable modNames) + { + var loadSettings = modNames + .Select(GetModLoadSettingByName) + .Where(setting => setting != null); + + if (!loadSettings.Any()) + return false; + + if (loadSettings.Any(setting => setting.IsEnabled.Value)) + return true; + + var numSettings = loadSettings.Count(); + var numMods = modNames.Count(); + + return (numSettings >= numMods - 1); + } + + public bool ContainsMod(string modName) + { + return Mods.Any(setting => setting.ModName.EqualsIgnoreCase(modName)); + } + + public ModLoadSetting GetTopPriorityEnabledMod() + { + return Mods + .OrderBy(setting => setting, new LoadOrderComparer()) + .FirstOrDefault(); + } + + public string GetTopPriorityEnabledMod(IEnumerable conflictMods) + { + var conflictModSettings = Mods.Where(setting => conflictMods.Any(modName => modName.EqualsIgnoreCase(setting.ModName))); + var enabledModSettings = conflictModSettings.Where(setting => setting.IsEnabled.Value); + + if (!conflictModSettings.Any()) + return conflictMods + .OrderBy(name => name, new LoadOrderComparer()) + .FirstOrDefault(); + + if (!enabledModSettings.Any()) + return conflictMods + .Except(conflictModSettings.Select(setting => setting.ModName)) + .OrderBy(name => name, new LoadOrderComparer()) + .FirstOrDefault(); + + return enabledModSettings + .OrderBy(setting => setting, new LoadOrderComparer()) + .ThenBy(setting => setting.ModName, new LoadOrderComparer()) + .FirstOrDefault() + ?.ModName; + } + + public ModLoadSetting GetModLoadSettingByName(string modName) + { + return Mods.FirstOrDefault(setting => setting.ModName.EqualsIgnoreCase(modName)); + } + + public bool IsModDisabledByName(string modName) + { + var mod = GetModLoadSettingByName(modName); + + return (mod != null && !mod.IsEnabled.Value); + } + + public void ToggleModByName(string modName) + { + var mod = GetModLoadSettingByName(modName); + + if (mod != null) + mod.IsEnabled = !mod.IsEnabled; + else + { + Mods.Add(new ModLoadSetting + { + ModName = modName, + IsEnabled = false, + Priority = BottomPriority + }); + } + } + + public int GetPriorityByName(string modName) + { + var mod = GetModLoadSettingByName(modName); + + return + mod != null + ? mod.Priority.Value + : -1; + } + + public void SetPriorityByName(string modName, int priority) + { + var mod = GetModLoadSettingByName(modName); + + if (mod != null) + { + mod.Priority = priority; + } + else + { + Mods.Add(new ModLoadSetting + { + ModName = modName, + IsEnabled = true, + Priority = priority + }); + } + } + } +} diff --git a/WitcherScriptMerger.Core/LoadOrder/LoadOrderComparer.cs b/WitcherScriptMerger.Core/LoadOrder/LoadOrderComparer.cs new file mode 100644 index 0000000..7481353 --- /dev/null +++ b/WitcherScriptMerger.Core/LoadOrder/LoadOrderComparer.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; + +namespace WitcherScriptMerger.LoadOrder +{ + public class LoadOrderComparer : IComparer, IComparer + { + public int Compare(string x, string y) + { + // The game loads numbers first, then underscores, then letters (upper or lower). + // ASCII (ordinal) order is numbers, then uppercase letters, then underscores, then lowercase. + // To achieve the game's load order, we can convert uppercase letters to lowercase, then take ASCII order. + return string.Compare( + x.ToLowerInvariant(), + y.ToLowerInvariant(), + StringComparison.Ordinal); + } + + public int Compare(ModLoadSetting x, ModLoadSetting y) + { + if (x.IsEnabled.Value) + { + if (y.IsEnabled.Value) + return x.Priority.Value.CompareTo(y.Priority.Value); + else + return -1; // Only x is enabled + } + else if (y.IsEnabled.Value) + return 1; // Only y is enabled + else + return Compare(x.ModName, y.ModName); // Neither is enabled + } + } +} diff --git a/WitcherScriptMerger.Core/LoadOrder/LoadOrderValidator.cs b/WitcherScriptMerger.Core/LoadOrder/LoadOrderValidator.cs new file mode 100644 index 0000000..b916b42 --- /dev/null +++ b/WitcherScriptMerger.Core/LoadOrder/LoadOrderValidator.cs @@ -0,0 +1,105 @@ +using System.Linq; + +namespace WitcherScriptMerger.LoadOrder +{ + public static class LoadOrderValidator + { + public static void ValidateAndFix(CustomLoadOrder loadOrder) + { + if (!loadOrder.Mods.Any()) + return; + + var mergedModName = Paths.RetrieveMergedModName(); + var mergedMod = loadOrder.Mods.Find(m => m.ModName.EqualsIgnoreCase(mergedModName)); + + if (mergedMod != null && mergedMod == loadOrder.GetTopPriorityEnabledMod()) + return; + + var choice = PromptToPrioritizeMergedMod(loadOrder.FilePath); + if (choice == NotifyResult.Yes) + { + PrioritizeMergedMod(loadOrder, mergedMod); + } + else if (choice == NotifyResult.Cancel) // Never + { + AppState.Settings.Set("ValidateCustomLoadOrder", false); + AppState.Settings.Save(); + } + } + + // Routed through IMergeNotifier rather than a direct MessageBox.Show(...) call + // (as this used before the Core/host project split) since Core can't reference + // System.Windows.Forms at all. + // + // Before the split, the direct MessageBox.Show(...) call used + // MessageBoxManager to relabel the Cancel button "Ne&ver", because Cancel's + // real effect here is destructive and permanent (ValidateAndFix's Cancel + // branch below sets ValidateCustomLoadOrder=false and saves it to App.config - + // this prompt is never shown again after that). IMergeNotifier has no hook + // for relabeling a button (and MessageBoxManager's relabeling was almost + // certainly already silently broken even before this split - it hooks via + // AppDomain.GetCurrentThreadId(), a deprecated API that doesn't reliably + // return the real Win32 thread ID SetWindowsHookEx needs). Either way, a + // plain-captioned "Cancel" button carries none of that "this is permanent" + // signal on its own, so the warning is now spelled out in the message body + // instead, where it doesn't depend on any button-relabeling mechanism working. + static NotifyResult PromptToPrioritizeMergedMod(string modsSettingsPath) + { + return AppState.Notifier.ShowMessage( + $"{modsSettingsPath}\n\n" + + "Detected custom load order in the file above, and merged files aren't configured to load first.\n\n" + + "Would you like Script Merger to modify your custom load order so that your merged files have top priority?\n\n" + + "Yes: fix it now.\n" + + "No: leave it as-is for now; you'll be asked again next time.\n" + + "Cancel: NEVER ask again - permanently disables this check.", + "Custom Load Order Problem", + NotifyButtons.YesNoCancel, + DialogIcon.Exclamation, + NotifyResult.No); + } + + static void PrioritizeMergedMod(CustomLoadOrder loadOrder, ModLoadSetting mergedModSetting) + { + // Priority of min - 1 will be incremented to min + var priority = CustomLoadOrder.TopPriority - 1; + + if (mergedModSetting != null) + { + mergedModSetting.IsEnabled = true; + mergedModSetting.Priority = priority; + } + else + { + loadOrder.Mods.Insert(0, new ModLoadSetting + { + ModName = Paths.RetrieveMergedModName(), + IsEnabled = true, + Priority = priority + }); + } + + IncrementLeadingContiguousPriorities(loadOrder, priority); + + loadOrder.Save(); + } + + static void IncrementLeadingContiguousPriorities(CustomLoadOrder loadOrder, int startingPriority) + { + var nextPriority = startingPriority + 1; + var modsToIncrement = loadOrder.Mods.Where(mod => mod.Priority == startingPriority).ToArray(); + var displacedMods = loadOrder.Mods.Where(mod => mod.Priority == nextPriority).ToArray(); + + if (!modsToIncrement.Any()) + return; + + if (displacedMods.Any() && + nextPriority < CustomLoadOrder.BottomPriority) + { + IncrementLeadingContiguousPriorities(loadOrder, nextPriority); + } + + foreach (var mod in modsToIncrement) + ++mod.Priority; + } + } +} diff --git a/WitcherScriptMerger.Core/LoadOrder/ModLoadSetting.cs b/WitcherScriptMerger.Core/LoadOrder/ModLoadSetting.cs new file mode 100644 index 0000000..f8424c8 --- /dev/null +++ b/WitcherScriptMerger.Core/LoadOrder/ModLoadSetting.cs @@ -0,0 +1,24 @@ +namespace WitcherScriptMerger.LoadOrder +{ + public class ModLoadSetting + { + public string ModName { get; set; } + + public bool? IsEnabled { get; set; } + + public int? Priority { get; set; } + + public ModLoadSetting() + { } + + public ModLoadSetting(string modName) + { + ModName = modName; + } + + public override string ToString() + { + return $"{ModName}, priority {Priority}, {(!IsEnabled.HasValue || IsEnabled.Value ? "enabled" : "disabled")}"; + } + } +} diff --git a/WitcherScriptMerger.Core/Mcp/CLAUDE.md b/WitcherScriptMerger.Core/Mcp/CLAUDE.md new file mode 100644 index 0000000..3df1d7f --- /dev/null +++ b/WitcherScriptMerger.Core/Mcp/CLAUDE.md @@ -0,0 +1,32 @@ +# CLAUDE.md — Mcp/ + +Guidance specific to `WsmMcpTools.cs`. See the root `CLAUDE.md`'s "MCP mode" section for +the tool list, transport rationale, and per-call state model — this file covers only what +that section doesn't: exactly what the process touches, and at what privilege level. + +## Minimal required permissions + +- **Standard user-level file I/O only.** No admin/elevated rights are needed to run any of + the four tools. +- **Three filesystem roots, all ordinary user-writable locations:** + - The configured mods directory (`Paths.ModsDirectory`) and game directory + (`Paths.GameDirectory`) — read for scanning conflicts and vanilla/mod source files, + write for merged output (flat-file merges land inside the mods directory; a bundle + merge additionally repacks `blob0.bundle` there). + - The app's own install directory — `Paths.Inventory` (`MergeInventory.xml`), + `Paths.TempBundleContent` (`tempbundlecontent`), and `Paths.MergedBundleContent` + (`Merged Bundle Content`) are all relative paths, resolved against + `Environment.CurrentDirectory`, not against the mods/game tree. `Program.RunCli` + pins `Environment.CurrentDirectory = AppContext.BaseDirectory` before dispatching to + either the `merge` or `mcp` verb, so in practice this is always the directory the + executable itself lives in, regardless of what directory an MCP client launches it + from — verified empirically (a client-supplied working directory had no effect; + `MergeInventory.xml` always landed next to the executable). +- **`merge_conflicts`'s `relativePaths` and `orderOverrides` keys are validated against + `Paths.ModsDirectory`** before any scan or merge runs (`WsmMcpTools.EnsureInScope` / + `IsWithinModsDirectory`) — an entry that doesn't resolve inside that directory (absolute + path, UNC path, or a `..\` escape) is rejected with a clear error rather than silently + matching nothing or being joined into a path outside the intended scope. +- **No network access beyond the transport itself.** The MCP SDK speaks JSON-RPC over the + process's stdin/stdout pipes to whatever spawned it (`WithStdioServerTransport()`) — that + is the only inbound/outbound channel this process opens on its own. diff --git a/WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs b/WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs new file mode 100644 index 0000000..13a87d2 --- /dev/null +++ b/WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using ModelContextProtocol.Server; +using WitcherScriptMerger.Cli; +using WitcherScriptMerger.Inventory; +using WitcherScriptMerger.LoadOrder; + +namespace WitcherScriptMerger.Mcp +{ + [McpServerToolType] + public static class WsmMcpTools + { + // scan_conflicts and merge_conflicts both load-then-mutate the shared AppState.Inventory + // (MergeInventory.xml is the load-bearing, string-hash-compared merge record - see + // CLAUDE.md's Compatibility constraints). An MCP client can issue tool calls concurrently; + // without serializing these two, one call's Load() can clobber another's in-flight + // instance and Save() can write whatever happened to land in the field. Not a concern for + // get_status/list_merges - neither touches AppState.Inventory. + static readonly object _inventoryLock = new object(); + + [McpServerTool(Name = "scan_conflicts"), Description( + "Scans the configured mods folder and returns every detected file conflict: relative " + + "path, category, each mod's file hash, the default merge order, and whether it's " + + "already resolved by a recorded merge. Note: if a file was already merged, the merged-mod " + + "folder itself can appear in mods/defaultOrder as if it were a source - don't echo " + + "defaultOrder back verbatim as orderOverrides without checking alreadyResolved first.")] + public static object ScanConflicts() + { + RequireDependenciesAndModsDirectory(); + + lock (_inventoryLock) + { + AppState.Inventory = MergeInventory.Load(Paths.Inventory); + var modIndex = MergeOperations.ScanConflicts(); + + return modIndex.Conflicts.Select(c => new + { + relativePath = c.RelativePath, + category = c.Category.DisplayName, + mods = c.Mods.Select(h => new { name = h.Name, hash = h.Hash, isOutdated = h.IsOutdated }).ToArray(), + defaultOrder = c.Mods.Select(h => h.Name).OrderBy(n => n, new LoadOrderComparer()).ToArray(), + alreadyResolved = AppState.Inventory.HasResolvedConflict(c), + }).ToArray(); + } + } + + [McpServerTool(Name = "merge_conflicts"), Description( + "Merges detected conflicts headlessly - never opens KDiff3's GUI; conflicts that can't " + + "be auto-solved are skipped and reported, not merged. Restrict to specific files with " + + "relativePaths (default: every detected conflict); override a file's mod merge order " + + "with orderOverrides (default merge order otherwise matches the game's own load order). " + + "Set dryRun to preview which conflicts would auto-solve without writing any merged " + + "output, repacking any bundle, or modifying MergeInventory.xml.")] + public static object MergeConflicts( + [Description("Relative paths to merge; omit to merge every detected conflict. Each must " + + "resolve inside the configured mods directory - absolute paths, UNC paths, and " + + "\"..\\\" segments that would escape it are rejected.")] string[] relativePaths = null, + [Description("Map of relative path to an explicit, ordered list of mod names for that " + + "file - at least two, no duplicates, and every one of that file's real source " + + "mods must appear (the configured merged-mod name itself doesn't need to be " + + "listed, even if it shows up as a source because the file was already merged " + + "once).")] Dictionary orderOverrides = null, + [Description("If true, evaluates which conflicts would auto-solve without writing any " + + "merged output, repacking any bundle, or modifying MergeInventory.xml.")] bool dryRun = false) + { + // Validated before touching the mods folder or dependency state: this is pure + // input validation the caller controls, so it should fail fast and + // independently of whatever else is or isn't configured. + EnsureInScope(relativePaths, orderOverrides); + + RequireDependenciesAndModsDirectory(); + + var mergedModName = Paths.RetrieveMergedModName(); + if (string.IsNullOrWhiteSpace(mergedModName)) + throw new InvalidOperationException("MergedModName isn't configured in App.config."); + + // 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) + { + // allowSave: !dryRun - MergeInventory.Load() can itself write to + // MergeInventory.xml (AddMissingHashes backfilling an old record's null + // hash) before dryRun is ever otherwise consulted; without this, a dry run + // against a legacy inventory file could still touch disk despite the + // tool's own "without... modifying MergeInventory.xml" description. + AppState.Inventory = MergeInventory.Load(Paths.Inventory, allowSave: !dryRun); + var modIndex = MergeOperations.ScanConflicts(); + + var conflicts = (normalizedRelativePaths == null + ? modIndex.Conflicts + : modIndex.Conflicts.Where(c => normalizedRelativePaths.Any(p => p.EqualsIgnoreCase(c.RelativePath)))) + .ToArray(); + + // In-scope but no longer a detected conflict (e.g. resolved or removed + // between scan and this call) is reported back, not silently dropped - + // only an out-of-scope path (rejected above) is a hard error. Derived from + // `conflicts` (already the relativePaths-matching subset) rather than + // re-scanning the full modIndex.Conflicts a second time. + var unmatched = normalizedRelativePaths == null + ? Array.Empty() + : normalizedRelativePaths.Where(p => !conflicts.Any(c => c.RelativePath.EqualsIgnoreCase(p))).ToArray(); + + // 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 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('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar), + kv => kv.Value, + StringComparer.OrdinalIgnoreCase); + + var summary = MergeOperations.RunMerge(AppState.Inventory, conflicts, mergedModName, normalizedOrderOverrides, dryRun); + + // FileMerger guarantees a dry run never adds or updates records in the + // loaded inventory (see MergeConflictsHeadless), but skipping the disk + // write too is a second, independent guarantee that a preview call can + // never leave MergeInventory.xml touched, even if that were ever weakened. + if (!dryRun) + AppState.Inventory.Save(); + + return new { merged = summary.Merged, skipped = summary.Skipped, unmatched, dryRun }; + } + } + + [McpServerTool(Name = "get_status"), Description( + "Reports WSM's current configuration and dependency status: resolved game/mods " + + "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() + { + // 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 (textMergeDependenciesValid && modsDirectoryExists) + conflictCount = MergeOperations.ScanConflicts().Conflicts.Count(); + + return new + { + gameDirectory = Paths.GameDirectory, + modsDirectory = Paths.ModsDirectory, + dependenciesValid = textMergeDependenciesValid && bundleDependenciesValid, + textMergeDependenciesValid, + bundleDependenciesValid, + modsDirectoryExists, + mergedModName = AppState.Settings.Get("MergedModName"), + conflictCount, + }; + } + + [McpServerTool(Name = "list_merges"), Description( + "Lists every merge already recorded in MergeInventory.xml: relative path, which mod " + + "folder holds the merged result, and each source mod's recorded hash.")] + public static object ListMerges() + { + var inventory = MergeInventory.Load(Paths.Inventory); + + return inventory.Merges.Select(m => new + { + relativePath = m.RelativePath, + mergedModName = m.MergedModName, + mods = m.Mods.Select(h => new { name = h.Name, hash = h.Hash }).ToArray(), + }).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.ValidateTextMergeDependencies()) + throw new InvalidOperationException( + "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."); + } + + // Directory allow-listing for merge_conflicts's two path-shaped inputs. Neither is + // currently joined into a filesystem path by itself - relativePaths is only ever + // compared for equality against already-scanned ModFile.RelativePath values + // (WsmMcpTools.MergeConflicts, above), and orderOverrides keys are only ever + // looked up the same way (FileMerger.ResolveMergeOrder) - so there's no live + // traversal vector through either today. This exists as defense-in-depth against + // that changing later, and so a malicious-looking value is rejected with a clear + // error up front instead of just silently matching nothing. + static void EnsureInScope(string[] relativePaths, Dictionary orderOverrides) + { + var offenders = new List(); + + if (relativePaths != null) + offenders.AddRange(relativePaths.Where(p => !IsWithinModsDirectory(p)).Select(p => p ?? "(null)")); + + if (orderOverrides != null) + offenders.AddRange(orderOverrides.Keys.Where(k => !IsWithinModsDirectory(k))); + + if (offenders.Any()) + throw new ArgumentException( + "The following path(s) are outside the configured mods directory and were rejected: " + + string.Join(", ", offenders.Distinct())); + } + + // A relative path is in scope only if resolving it against the configured mods + // directory still lands inside that directory. Path.IsPathRooted rejects both + // absolute paths and UNC paths outright (Path.Combine silently discards its first + // argument when the second is rooted, which is exactly the bypass that check + // closes). Path.GetFullPath then normalizes any "..\" segments before the + // comparison, which is a proper prefix check against the fully-qualified root + // (with a trailing separator, so "ModsDirectory" can't be spoofed by a sibling + // directory like "ModsDirectoryEvil") rather than a naive string StartsWith. + // This does NOT resolve symlinks - Path.GetFullPath doesn't either - but that's + // fine here: this value is only ever compared against already-scanned + // ModFile.RelativePath strings or used as a dictionary key, never opened + // directly, so a symlink planted inside the mods directory can't be exploited + // through this check. + static bool IsWithinModsDirectory(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath)) + return false; + + var root = Paths.ModsDirectory; + if (string.IsNullOrWhiteSpace(root)) + return false; + + string fullRoot, candidate; + try + { + fullRoot = Path.GetFullPath(root); + candidate = Path.GetFullPath(Path.Combine(fullRoot, relativePath)); + } + catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException) + { + return false; + } + + var rootWithSeparator = fullRoot.EndsWith(Path.DirectorySeparatorChar) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + + return candidate.Equals(fullRoot, StringComparison.OrdinalIgnoreCase) + || candidate.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/WitcherScriptMerger.Core/NotifyTypes.cs b/WitcherScriptMerger.Core/NotifyTypes.cs new file mode 100644 index 0000000..1b2c334 --- /dev/null +++ b/WitcherScriptMerger.Core/NotifyTypes.cs @@ -0,0 +1,49 @@ +namespace WitcherScriptMerger +{ + // Neutral, WinForms-free equivalents of System.Windows.Forms.DialogResult / + // MessageBoxButtons / MessageBoxIcon, so IMergeNotifier can live in Core without + // referencing System.Windows.Forms. MainForm (host project) translates these + // to/from the real WinForms types around actual MessageBox.Show(...) calls. + + // 1:1 with the DialogResult members this codebase actually returns/compares against. + public enum NotifyResult + { + None, + OK, + Cancel, + Abort, + Retry, + Ignore, + Yes, + No, + } + + // Mirrors MessageBoxButtons - the full set HeadlessMergeNotifier already handles + // defensively, even though not every value has a real call site yet. + public enum NotifyButtons + { + OK, + OKCancel, + AbortRetryIgnore, + YesNoCancel, + YesNo, + RetryCancel, + } + + // Mirrors the subset of MessageBoxIcon actually used at real call sites. Deliberately + // NOT named "NotifyIcon": this type lives in the root WitcherScriptMerger namespace, + // which every host-project file under WitcherScriptMerger.Forms/.Controls/etc. can + // already see without a `using` (nested-namespace lookup) - naming it NotifyIcon + // would silently shadow System.Windows.Forms.NotifyIcon (the tray-icon control + // class) for any unqualified reference in host code, since an enclosing-namespace + // type wins over a using-imported one in C#'s simple-name resolution. + public enum DialogIcon + { + None, + Warning, + Error, + Exclamation, + Information, + Question, + } +} diff --git a/WitcherScriptMerger.Core/Paths.cs b/WitcherScriptMerger.Core/Paths.cs new file mode 100644 index 0000000..03f0f5a --- /dev/null +++ b/WitcherScriptMerger.Core/Paths.cs @@ -0,0 +1,206 @@ +using System; +using System.IO; +using WitcherScriptMerger.Tools; + +namespace WitcherScriptMerger +{ + public static class Paths + { + public const string TempBundleContent = "tempbundlecontent"; + public static string MergedBundleContent = "Merged Bundle Content"; + public static string MergedBundleContentAbsolute = Path.Combine(Environment.CurrentDirectory, MergedBundleContent); + + // A dedicated top-level directory for DiffPlexMergeEngine's conflict-marker + // sidecar files (Tools/DiffPlexMergeEngine.cs::GetConflictMarkerPath) - + // deliberately NOT a subdirectory of TempBundleContent, even though both are + // "scratch-ish" locations conceptually: FileMerger.CleanUpTempFiles() deletes + // the entire TempBundleContent tree wholesale at the end of every headless + // merge run (to clear QuickBMS-unpacked bundle scratch content), which would + // otherwise delete every sidecar moments after DiffPlexMergeEngine wrote it - + // confirmed by direct observation running the real CLI end-to-end: the sidecar + // briefly existed during the run (the "conflict markers written to..." message + // printed a real path) but was gone by the time the process exited. A separate, + // unrelated top-level name sidesteps that collision entirely while keeping the + // same original benefits (out of the live Paths.ModsDirectory tree, out of + // Paths.MergedBundleContent's wholesale-packed tree - see DiffPlexMergeEngine's + // own comment on GetConflictMarkerPath for those two reasons). + public const string DiffPlexConflictsDirectory = "DiffPlexConflicts"; + public const string Inventory = "MergeInventory.xml"; + public static string ModScriptBase = Path.Combine("content", "scripts"); + public static string VanillaScriptBase = Path.Combine("content", "content0", "scripts"); + public static string BundleBase = "content"; + + public static string GameDirectory => AppState.Settings.Get("GameDirectory"); + + public static string GameExe => Path.Combine(GameDirectory, "bin", "x64", "witcher3.exe"); + + public static string BundlesDirectory => Path.Combine(GameDirectory, BundleBase); + + public static string DlcDirectory => Path.Combine(GameDirectory, "DLC"); + + // Deliberately not cached in a static field (as these two used to be): a field + // initializer here would run alongside every other static field initializer of + // this type on first touch of ANY of them (C#'s beforefieldinit semantics), + // which would eagerly call AppState.Settings.Get(...) - forcing + // AppState.Settings to construct (see its own lazy-property comment in + // AppState.cs) merely from touching an unrelated static member of Paths, e.g. a + // plain string helper like GetRelativePath with no settings dependency at all. + // That's exactly the crash-in-a-dotnet-test-host scenario AppState.Settings' + // laziness exists to avoid, one hop removed - flagged in code review, see + // CLAUDE.md. AppState.Settings.Get(...) already reads from AppSettings' own + // cached ConfigurationManager state, so re-reading it on every call here (rather + // than caching again at this layer) costs nothing meaningful. + public static string ScriptsDirectory + { + get + { + var setting = AppState.Settings.Get("VanillaScriptsDirectory"); + return (!string.IsNullOrWhiteSpace(setting) + ? setting + : Path.Combine(GameDirectory, VanillaScriptBase)); + } + } + + public static string ModsDirectory + { + get + { + var setting = AppState.Settings.Get("ModsDirectory"); + return (!string.IsNullOrWhiteSpace(setting) + ? setting + : Path.Combine(GameDirectory, "Mods")); + } + } + + public static bool IsScriptsDirectoryDerived => string.IsNullOrWhiteSpace(AppState.Settings.Get("VanillaScriptsDirectory")); + + public static bool IsModsDirectoryDerived => string.IsNullOrWhiteSpace(AppState.Settings.Get("ModsDirectory")); + + public static string GetRelativePath(string fullPath, string basePath) + { + var startIndex = fullPath.IndexOfIgnoreCase(basePath) + basePath.Length + 1; + return fullPath.Substring(startIndex); + } + + // KDiff3's own exe-path check goes through AppState.MergeEngine rather than a + // direct reference to Tools/KDiff3.cs, which stays in the host project for + // its Win32 P/Invoke and so can't be referenced from Core - see + // Tools/IMergeEngine.cs. Like AppState.Notifier/Settings, this relies on the + // host having set AppState.MergeEngine before calling in - true for the one + // real entry point (Program.Main, first line) but not enforced by the type + // 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. + // 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 File.Exists(QuickBms.ExePath) && + File.Exists(QuickBms.PluginPath) && + File.Exists(WccLite.ExePath); + } + + public static bool ValidateDependencyPaths() + { + return ValidateTextMergeDependencies() && ValidateBundleDependencies(); + } + + public static bool ValidateModsDirectory() + { + if (!Directory.Exists(ModsDirectory)) + { + AppState.Notifier.ShowMessage( + (!IsModsDirectoryDerived + ? "Can't find the Mods directory specified in the config file." + : "Can't find Mods directory in the specified game directory.")); + return false; + } + return true; + } + + public static bool ValidateScriptsDirectory() + { + if (!Directory.Exists(ScriptsDirectory)) + { + AppState.Notifier.ShowMessage( + (!IsScriptsDirectoryDerived + ? "Can't find the Scripts directory specified in the config file." + : "Can't find \\content\\content0\\scripts directory in the specified game directory.") + + "\n\nIt was added in patch 1.08.1 and should contain the game's vanilla scripts."); + return false; + } + return true; + } + + public static bool ValidateBundlesDirectory() + { + if (!Directory.Exists(BundlesDirectory)) + { + AppState.Notifier.ShowMessage("Can't find 'content' directory in the specified game directory."); + return false; + } + return true; + } + + public static string RetrieveMergedBundlePath() + { + var mergedModName = RetrieveMergedModName(); + if (mergedModName != null) + return Path.Combine(ModsDirectory, mergedModName, BundleBase, "blob0.bundle"); + else + return null; + } + + public static string RetrieveMergedModName() + { + var mergedModName = AppState.Settings.Get("MergedModName"); + if (string.IsNullOrWhiteSpace(mergedModName)) + { + AppState.Notifier.ShowMessage("The MergedModName setting isn't configured in the .config file."); + return null; + } + if (mergedModName.Length > 64) + mergedModName = mergedModName.Substring(0, 64); + if (!mergedModName.IsAlphaNumeric() || !mergedModName.StartsWith("mod")) + { + if (!ConfirmInvalidModName(mergedModName)) + return null; + } + return mergedModName; + } + + public static string RetrieveMergedModDir() + { + var modName = RetrieveMergedModName(); + return + modName != null + ? Path.Combine(ModsDirectory, modName) + : null; + } + + static bool ConfirmInvalidModName(string mergedModName) + { + return (NotifyResult.Yes == AppState.Notifier.ShowMessage( + "The Witcher 3 won't load the merged file if the mod name isn't \"mod\" followed by numbers, letters, or underscores." + + "\n\nUse this name anyway?\n" + mergedModName + + "\n\nTo change the name: Click No, then edit \"MergedModName\" in the .config file.", + "Warning", + NotifyButtons.YesNo, + DialogIcon.Exclamation)); + } + } +} diff --git a/WitcherScriptMerger.Core/StringExtensions.cs b/WitcherScriptMerger.Core/StringExtensions.cs new file mode 100644 index 0000000..bf9e978 --- /dev/null +++ b/WitcherScriptMerger.Core/StringExtensions.cs @@ -0,0 +1,57 @@ +using System; +using System.Text.RegularExpressions; + +namespace WitcherScriptMerger +{ + // Split out of the host project's Extensions.cs during the Core/host project split - + // these pure string helpers are used pervasively by domain code that now lives in + // Core, while the rest of the original Extensions.cs (TreeNode/TreeView helpers, + // Win32 P/Invoke) stayed in the host project since it's all WinForms-specific. Named + // differently from the host's own `Extensions` class to avoid a duplicate-type + // compile error across the two assemblies - the class name doesn't matter to call + // sites either way, since these are extension methods resolved by namespace. + public static class StringExtensions + { + public static string ReplaceIgnoreCase(this string s, string oldValue, string newValue) + { + return Regex.Replace(s, Regex.Escape(oldValue), newValue.Replace("$", "$$"), RegexOptions.IgnoreCase); + } + + public static bool EqualsIgnoreCase(this string s, string otherString) + { + return s.Equals(otherString, StringComparison.InvariantCultureIgnoreCase); + } + + public static int IndexOfIgnoreCase(this string s, string value, int startIndex = 0) + { + return s.IndexOf(value, startIndex, StringComparison.InvariantCultureIgnoreCase); + } + + public static int LastIndexOfIgnoreCase(this string s, string value, int startIndex = -1) + { + if (startIndex == -1) + startIndex = s.Length - 1; + return s.LastIndexOf(value, startIndex, StringComparison.InvariantCultureIgnoreCase); + } + + public static bool StartsWithIgnoreCase(this string s, string value) + { + return s.StartsWith(value, StringComparison.InvariantCultureIgnoreCase); + } + + public static bool EndsWithIgnoreCase(this string s, string value) + { + return s.EndsWith(value, StringComparison.InvariantCultureIgnoreCase); + } + + public static bool IsAlphaNumeric(this string s) + { + return new Regex("^[_a-zA-Z0-9]*$").IsMatch(s); + } + + public static string GetPluralS(this int num) + { + return num == 1 ? "" : "s"; + } + } +} diff --git a/WitcherScriptMerger.Core/Tools/DiffPlexMergeEngine.cs b/WitcherScriptMerger.Core/Tools/DiffPlexMergeEngine.cs new file mode 100644 index 0000000..00d73da --- /dev/null +++ b/WitcherScriptMerger.Core/Tools/DiffPlexMergeEngine.cs @@ -0,0 +1,510 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Hashing; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using DiffPlex; +using DiffPlex.Chunkers; +using DiffPlex.Model; +using WitcherScriptMerger.Inventory; + +namespace WitcherScriptMerger.Tools +{ + // In-process, external-binary-free alternative to KDiff3MergeEngine (host project), + // built on DiffPlex (MIT-licensed NuGet package)'s ThreeWayDiffer. Not the active + // engine by default - see Program.Main (host project) for the "MergeEngine" App.config + // switch. See Tools/IMergeEngine.cs for why this interface exists at all: it's + // Core/host split scaffolding, not a permanent pluggable-engine abstraction, and a + // later unit that removes KDiff3 entirely may delete the interface and inline this + // engine's logic directly into FileMerger. + // + // There's no UI here at all - unlike KDiff3MergeEngine, which can open KDiff3's own + // GUI for the interactive path - so "interactive" and "headless" collapse to the same + // underlying logic. Merge() just runs MergeHeadless() and maps NeedsManualResolution + // to Failed, since IMergeEngine.Merge's contract explicitly forbids ever returning + // NeedsManualResolution (that's a headless-only concept - see the interface's doc + // comment). One real behavior difference from KDiff3MergeEngine as a result: the + // "ReviewEachMerge" setting (show the merge UI even for auto-solvable merges, so the + // user can double check it) has nothing to open here and is silently not honored - + // there is no in-process equivalent to implement it against. + public class DiffPlexMergeEngine : IMergeEngine + { + #region Types + + public readonly struct MergeTextResult + { + public string MergedText { get; } + public bool HasConflicts { get; } + + public MergeTextResult(string mergedText, bool hasConflicts) + { + MergedText = mergedText; + HasConflicts = hasConflicts; + } + } + + // Thrown by BuildMerge when DiffPlex's ThreeWayDiffer itself produces + // internally inconsistent diff-block metadata for a given base/old/new triple + // - a genuine, confirmed upstream bug (DiffPlex 1.9.0), not a defect in this + // class's own loop. See BuildMerge's comment for the full empirical writeup + // and CLAUDE.md's Compatibility constraints for measured failure rates. Kept + // separate from ArgumentNullException (a caller-error guard) so + // MergeHeadless can catch specifically this and only this as "the algorithm + // itself can't be trusted here" rather than accidentally swallowing an + // unrelated bug. + public sealed class DiffAlgorithmException : Exception + { + public DiffAlgorithmException(string message) : base(message) { } + } + + #endregion + + #region Members + + // Deliberately the classic ASCII whitespace set (space, tab, CR, LF, form feed, + // vertical tab), not \s+: .NET's \s matches the full Unicode whitespace + // category too (NBSP, U+2028/2029, ideographic space, etc.), and collapsing + // those away could misclassify a genuine content difference as "purely + // whitespace" - e.g. two mods' string-literal dialogue text differing only by + // NBSP vs. a regular space (plausible in localized text) would otherwise be + // silently auto-resolved instead of flagged as a conflict. CR/LF stay included + // so a side that merely adds/removes a blank line (see + // BuildMerge_WhitespaceOnlyConflict_ToleratesDifferingLineCounts) still + // collapses the same as before - only the extra-exotic Unicode members of \s + // are excluded. Flagged in code review, see CLAUDE.md. + static readonly Regex WhitespaceRun = new Regex(@"[ \t\r\n\f\v]+", RegexOptions.Compiled); + + #endregion + + public MergeEngineResult Merge( + FileMerger.MergeSource source1, + FileMerger.MergeSource source2, + FileInfo vanillaFile, + string outputPath) + { + var result = MergeHeadless(source1, source2, vanillaFile, outputPath); + return result == MergeEngineResult.NeedsManualResolution ? MergeEngineResult.Failed : result; + } + + public MergeEngineResult MergeHeadless( + FileMerger.MergeSource source1, + FileMerger.MergeSource source2, + FileInfo vanillaFile, + string outputPath) + { + var hasVanillaVersion = vanillaFile != null && vanillaFile.Exists; + + // A 3-way merge is meaningless without a base to diff against - confirmed + // empirically in this change's verification scratch app that feeding + // ThreeWayDiffer an empty base string doesn't degrade gracefully to some + // reasonable 2-way behavior: CreateThreeWayDiffBlocks' main loop is + // `while (baseIndex < basePieces.Count)`, which never executes when + // basePieces.Count is 0, so it silently returns zero diff blocks and a + // merge result with IsSuccessful=true but a completely empty MergedPieces - + // i.e. it would happily "auto-solve" straight to an empty output file. In + // practice this is expected mainly on the bundle-content path, when no + // vanilla bundle containing this file could be found (FileMerger. + // GetUnpackedFiles leaves _vanillaFile null), but this guard applies + // unconditionally to any conflict with no vanilla file, flat or bundled - + // safest, and consistent with HeadlessMergeNotifier's non-destructive + // defaults, is to refuse rather than guess. Note this is a real, deliberate + // behavior difference from KDiff3MergeEngine: Tools/KDiff3.cs's BuildArgs has + // no equivalent guard and always attempts a real (if degraded, vanilla-less) + // 2-way --auto merge in this situation instead of refusing outright, because + // KDiff3 itself has a coherent notion of a 2-file diff/merge - DiffPlex's + // ThreeWayDiffer, as used here, does not, so there's no equally meaningful + // fallback to attempt. Which conflicts even get attempted can therefore differ + // depending on which engine is configured; flagged in code review, not fixed + // by building a parallel 2-way DiffPlex merge path since that's new scope + // beyond what this engine set out to replicate - see CLAUDE.md. + if (!hasVanillaVersion) + { + AppState.Notifier.ShowMessage( + $"Skipped {source1.Name} + {source2.Name}: no vanilla version of this file could be found, " + + "so a 3-way merge isn't possible.", + "Skipped", NotifyButtons.OK, DialogIcon.Warning); + return MergeEngineResult.NeedsManualResolution; + } + + // Same "merging an updated mod file into an existing merge chain" guard + // KDiff3.RunHeadless applies (see its comment for the full reasoning) - kept + // duplicated here rather than hoisted into FileMerger since that's shared + // orchestration code outside this unit's scope; a later unit collapsing the + // merge engines should consider moving it there instead of keeping two copies. + // One real consequence of the duplication (vs. hoisting into FileMerger, + // which both Merge and MergeHeadless funnel through) worth calling out: since + // Merge() (the interactive path) just delegates straight to MergeHeadless() + // here (see this class's header comment - there's no UI to fall back to), + // this outdated-hash case comes back as Failed on the interactive path too, + // where KDiff3MergeEngine's own interactive Run() instead opens KDiff3's GUI + // for manual review. That gap already exists for every other kind of conflict + // on the DiffPlex interactive path (no UI here at all yet), so it isn't a new + // asymmetry this guard introduces - flagged in code review, see CLAUDE.md. + if (source1.TextFile.FullName.EqualsIgnoreCase(outputPath) + && source2.Hash != null && source2.Hash.IsOutdated) + { + AppState.Notifier.ShowMessage( + $"Skipped {source1.Name} + {source2.Name}: merging an updated mod file into a merge " + + "created with a previous version needs manual review (auto-solving could keep changes " + + "from the previous version that have been removed in the new one).", + "Skipped", NotifyButtons.OK, DialogIcon.Warning); + return MergeEngineResult.NeedsManualResolution; + } + + var baseText = FileEncoding.ReadAnyEncoding(vanillaFile.FullName); + var oldText = FileEncoding.ReadAnyEncoding(source1.TextFile.FullName); + var newText = FileEncoding.ReadAnyEncoding(source2.TextFile.FullName); + + MergeTextResult result; + try + { + result = BuildMerge(baseText, oldText, newText, source1.Name, source2.Name); + } + catch (DiffAlgorithmException ex) + { + // DiffPlex's own diff algorithm produced output it isn't safe to trust + // (see BuildMerge's comment) - don't write anything, including a sidecar: + // the "conflict marker" content itself would have been built from the + // same inconsistent piece indices, so it can't be trusted either. This is + // the one case where DiffPlexMergeEngine can't even offer a conflict-marker + // starting point the way KDiff3 always can - genuinely needs the source + // files opened side by side. + AppState.Notifier.ShowMessage( + $"Skipped {source1.Name} + {source2.Name}: the automatic 3-way merge algorithm hit " + + $"an internal inconsistency it couldn't safely recover from ({ex.Message}) - a known " + + "limitation of the underlying DiffPlex library for certain multi-edit conflicts, see " + + "CLAUDE.md. Needs manual resolution (e.g. via KDiff3).", + "Skipped", NotifyButtons.OK, DialogIcon.Warning); + return MergeEngineResult.NeedsManualResolution; + } + + if (!result.HasConflicts) + { + // A prior attempt at this same conflict may have left a sidecar marker + // file behind (see below) - if this attempt now auto-solves (e.g. the + // mod files were updated to no longer conflict), remove it so it doesn't + // sit next to the fresh output indefinitely, stale and misleading. + DeleteIfExists(GetConflictMarkerPath(outputPath)); + + FileEncoding.WriteUtf16(outputPath, result.MergedText); + return MergeEngineResult.AutoSolved; + } + + // Never write conflict markers to outputPath itself: FileMerger's headless + // callers (MergeFlatConflictHeadless/MergeBundleConflictHeadless) check + // `File.Exists(_outputPath)` BEFORE attempting a merge and, if it exists, + // prompt to overwrite via ConfirmOutputOverwrite - which HeadlessMergeNotifier + // always answers "no". A marker file left at outputPath would therefore + // permanently block every future retry of this same conflict without ever + // attempting the merge again. Writing to a separate sidecar location instead + // (see GetConflictMarkerPath) keeps outputPath itself untouched (so retries + // behave exactly as if this merge had never been attempted) while still + // producing well-formed conflict-marker output at a predictable, computable + // location for a later unit to open in the user's default text editor. + FileEncoding.WriteUtf16(GetConflictMarkerPath(outputPath), result.MergedText); + + AppState.Notifier.ShowMessage( + $"Skipped {source1.Name} + {source2.Name}: genuine conflict, needs manual resolution. " + + $"Conflict markers were written to {GetConflictMarkerPath(outputPath)} for review.", + "Skipped", NotifyButtons.OK, DialogIcon.Warning); + return MergeEngineResult.NeedsManualResolution; + } + + // No external executable - DiffPlex is an in-process managed library, so there's + // nothing to validate a path for. Note this doesn't remove QuickBMS/wcc_lite from + // Paths.ValidateDependencyPaths()'s checks - those are still required for bundle + // content regardless of which text-merge engine is active. + public bool ValidateExePath() => true; + + // Where a conflict-marker file is written when a merge can't be auto-solved - + // never at outputPath itself (see MergeHeadless's comment above). Originally + // this wrote to ".conflict", right beside the real output - but code + // review (see CLAUDE.md) caught two real problems with that: (1) for a flat-file + // (.ws/.xml) conflict, outputPath sits inside the live, user-facing + // Paths.ModsDirectory tree, and nothing ever cleans up a sidecar left there, + // unlike Paths.TempBundleContent, which is documented as safe to clear between + // runs; (2) for a bundle-content conflict, outputPath sits inside + // Paths.MergedBundleContent, which Tools/WccLite.PackBundle packs *wholesale* + // (no filtering) - a leftover ".conflict" text file there would get embedded as + // bogus content into the shipped blob0.bundle on any later successful pack of + // that same bundle. Relocating under Paths.DiffPlexConflictsDirectory avoids + // both - and deliberately does NOT nest under Paths.TempBundleContent either, + // despite both being "scratch-ish" locations conceptually: an earlier version + // of this fix did nest there, and end-to-end testing against the real CLI + // caught a real regression - FileMerger.CleanUpTempFiles() deletes the entire + // TempBundleContent tree wholesale at the end of every headless merge run (to + // clear QuickBMS-unpacked bundle scratch content), which silently deleted every + // sidecar moments after this method wrote it, before a user could ever see it. + // See Paths.DiffPlexConflictsDirectory's own comment for the full story. The + // XxHash32 of the full absolute outputPath (Core already depends on + // System.IO.Hashing for Tools/Hasher.cs) keeps the result collision-free without + // needing to know which of those two root trees outputPath came from, and + // without the unbounded path length a naive "flatten the whole absolute path + // into one filename" scheme would risk for a deeply-nested bundle-content path. + // string.GetHashCode() was deliberately not used here - .NET randomizes string + // hash codes per process by default, so it isn't stable across runs, unlike + // XxHash32. This is still a computable, not merely a discoverable-by-browsing, + // location: a later unit wiring up "open in editor" can call this same method. + public static string GetConflictMarkerPath(string outputPath) + { + var pathHash = XxHash32.HashToUInt32(Encoding.UTF8.GetBytes(outputPath), 0); + var fileName = Path.GetFileName(outputPath) + "." + pathHash.ToString("X8") + ".conflict"; + return Path.Combine(Paths.DiffPlexConflictsDirectory, fileName); + } + + // Swallows every exception (locked file, permission denial, etc.) rather than + // surfacing a failed delete - deliberate, not an oversight: this only ever + // removes a stale sidecar right before writing a fresh, correct output to + // outputPath, which happens regardless of whether this cleanup succeeds. The + // only consequence of a failed delete is a stale ".conflict" file left sitting + // next to a now-correct output - mildly confusing if someone stumbles on it, but + // never incorrect or data-lossy, so it isn't worth a user-facing notification for + // what's already a low-probability failure on a best-effort cleanup step. + // Flagged in code review, see CLAUDE.md. + static void DeleteIfExists(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } + + #region Merge algorithm + + // The actual 3-way merge, factored out as a public static method (independent of + // any FileMerger/MergeSource/disk I/O) specifically so it's directly unit + // testable. Mirrors DiffPlex's own ThreeWayDiffer.CreateMerge loop (see its + // source for the shape this follows) but adds two things CreateMerge doesn't do: + // - Purely-whitespace-only conflicts auto-resolve instead of producing markers, + // mirroring KDiff3's --cs "WhiteSpace3FileMergeDefault=2" (verified against the + // KDiff3 source: value 2 means "always pick input B", which is oldText/oldLabel + // here, matching KDiff3.BuildArgs' own file order of vanilla/source1/source2 - + // see CLAUDE.md's KDiff3 compatibility notes). + // - Genuine conflicts are rendered as git/diff3-style conflict markers labeled + // with the actual mod names, not DiffPlex's generic "old"/"base"/"new". + // Uses LineEndingsPreservingChunker (not DiffPlex's default LineChunker) so + // unchanged/single-side-changed content round-trips through unmodified, keeping + // each such line's original line-ending byte-for-byte - only synthetic content + // this method itself adds (conflict marker lines) uses an explicit "\r\n" to + // match vanilla .ws files' own DOS line endings (KDiff3.BuildArgs' own + // --cs "LineEndStyle=1" - confirmed against the KDiff3 source, value 1 is DOS). + public static MergeTextResult BuildMerge(string baseText, string oldText, string newText, string oldLabel, string newLabel) + { + // ThreeWayDiffer.CreateDiffs throws its own ArgumentNullException for a null + // baseText/oldText/newText, but from inside DiffPlex rather than at this + // method's own boundary - guard here instead so a caller gets a clear + // exception pointing at this public entry point. + if (baseText == null) throw new ArgumentNullException(nameof(baseText)); + if (oldText == null) throw new ArgumentNullException(nameof(oldText)); + if (newText == null) throw new ArgumentNullException(nameof(newText)); + + var chunker = LineEndingsPreservingChunker.Instance; + var diffResult = ThreeWayDiffer.Instance.CreateDiffs( + baseText, oldText, newText, ignoreWhiteSpace: false, ignoreCase: false, chunker); + + var merged = new StringBuilder(); + var hasConflicts = false; + + var baseIndex = 0; + var oldIndex = 0; + var newIndex = 0; + + // CONFIRMED UPSTREAM BUG (DiffPlex 1.9.0), not a defect in this loop's own + // bookkeeping: this loop is a faithful port of DiffPlex's own + // ThreeWayDiffer.CreateMerge (same index-chasing shape), and DiffPlex's own + // CreateMerge was verified - via a throwaway scratch console app per this + // repo's testing convention, calling DiffPlex's ThreeWayDiffer.CreateMerge + // directly - to exhibit the exact same two failure modes on the exact same + // inputs, with both LineChunker (DiffPlex's own default/only-tested chunker + // for 3-way diffs - its own Facts.DiffPlex/ThreeWayDifferFacts.cs never + // exercises any other chunker) and LineEndingsPreservingChunker: when old-side + // and new-side edits interleave/overlap relative to base in certain ways, + // CreateThreeWayDiffBlocks can produce a block list whose OldCount/NewCount + // don't actually correspond to the real PiecesOld/PiecesNew arrays. This + // surfaces two ways: (1) an outright ArgumentOutOfRangeException from the + // direct indexer accesses below, or (2) - confirmed via a minimal repro + // (base "a();/b();/c();", one side inserts a line, the other independently + // changes "b()" to "B()") - no exception at all, but content is silently + // lost or duplicated, because the running oldIndex/newIndex end up not + // matching PiecesOld.Count/PiecesNew.Count even though no single block's own + // bookkeeping ever looked wrong in isolation. A large randomized stress test + // (varying edit density and file length) measured combined failure rates from + // ~0.35% (one independent single-line edit per side, 50-200 line files - the + // closest analogue to a typical two-mod .ws conflict) up to double digits for + // denser multi-edit-per-side cases - see CLAUDE.md's Compatibility + // constraints for the full numbers. Given real, measured, non-negligible + // rates of both failure modes, this is caught here (an exception) and + // verified for (the silent case, via the post-loop count check below) rather + // than trusted - MergeHeadless treats either as "needs manual resolution" + // rather than ever risking corrupted merge output. This is also a primary + // reason DiffPlexMergeEngine isn't the default engine yet (see Program.cs). + try + { + foreach (var block in diffResult.DiffBlocks) + { + while (baseIndex < block.BaseStart) + { + merged.Append(diffResult.PiecesBase[baseIndex]); + ++baseIndex; + ++oldIndex; + ++newIndex; + } + + switch (block.ChangeType) + { + case ThreeWayChangeType.Unchanged: + for (var i = 0; i < block.BaseCount; ++i) + merged.Append(diffResult.PiecesBase[baseIndex + i]); + break; + + case ThreeWayChangeType.OldOnly: + for (var i = 0; i < block.OldCount; ++i) + merged.Append(diffResult.PiecesOld[oldIndex + i]); + break; + + case ThreeWayChangeType.NewOnly: + for (var i = 0; i < block.NewCount; ++i) + merged.Append(diffResult.PiecesNew[newIndex + i]); + break; + + case ThreeWayChangeType.BothSame: + // Both sides made the same change - take either (old, matching + // DiffPlex's own CreateMerge convention). + for (var i = 0; i < block.OldCount; ++i) + merged.Append(diffResult.PiecesOld[oldIndex + i]); + break; + + case ThreeWayChangeType.Conflict: + var oldPieces = diffResult.PiecesOld.Skip(oldIndex).Take(block.OldCount).ToList(); + var newPieces = diffResult.PiecesNew.Skip(newIndex).Take(block.NewCount).ToList(); + + if (IsWhitespaceOnlyDifference(oldPieces, newPieces)) + { + foreach (var piece in oldPieces) + merged.Append(piece); + } + else + { + hasConflicts = true; + var basePieces = diffResult.PiecesBase.Skip(baseIndex).Take(block.BaseCount).ToList(); + AppendConflictMarkers(merged, oldLabel, oldPieces, basePieces, newLabel, newPieces); + } + break; + } + + baseIndex += block.BaseCount; + oldIndex += block.OldCount; + newIndex += block.NewCount; + } + + while (baseIndex < diffResult.PiecesBase.Count) + { + merged.Append(diffResult.PiecesBase[baseIndex]); + ++baseIndex; + ++oldIndex; + ++newIndex; + } + } + catch (ArgumentOutOfRangeException ex) + { + throw new DiffAlgorithmException( + "DiffPlex's ThreeWayDiffer produced diff-block metadata that doesn't match " + + "its own piece arrays for this file (" + ex.Message + ")."); + } + + // Even when nothing threw, the same underlying inconsistency can silently + // produce WRONG merged content instead - confirmed via the minimal repro + // described above, where oldIndex/newIndex end up one past + // PiecesOld.Count/PiecesNew.Count with no exception anywhere. Verifying the + // running counters actually landed on the true totals (rather than trusting + // that "no exception" means "correct") is what catches that case. + if (oldIndex != diffResult.PiecesOld.Count || newIndex != diffResult.PiecesNew.Count) + { + throw new DiffAlgorithmException( + "DiffPlex's ThreeWayDiffer produced diff-block metadata that doesn't fully " + + "(or doubly) account for this file's content, without throwing an exception."); + } + + return new MergeTextResult(merged.ToString(), hasConflicts); + } + + static void AppendConflictMarkers( + StringBuilder merged, + string oldLabel, + List oldPieces, + List basePieces, + string newLabel, + List newPieces) + { + EnsureLineBreakBeforeMarker(merged); + + merged.Append("<<<<<<< ").Append(oldLabel).Append("\r\n"); + foreach (var piece in oldPieces) + merged.Append(piece); + EnsureLineBreakBeforeMarker(merged); + + merged.Append("||||||| Vanilla\r\n"); + foreach (var piece in basePieces) + merged.Append(piece); + EnsureLineBreakBeforeMarker(merged); + + merged.Append("=======\r\n"); + foreach (var piece in newPieces) + merged.Append(piece); + EnsureLineBreakBeforeMarker(merged); + + merged.Append(">>>>>>> ").Append(newLabel).Append("\r\n"); + } + + // Pieces from LineEndingsPreservingChunker only carry a line ending when the + // original text had one at that point - a file (or a conflicting region right at + // EOF) that doesn't end in a newline would otherwise glue a marker line onto the + // preceding content instead of starting a new line. Checks for a trailing '\r' + // as well as '\n': the original check only excluded '\n', so a region ending in + // a lone '\r' (old Mac-style line ending, or a genuinely incomplete line) would + // still get "\r\n" appended, producing a stray "\r\r\n" right before the marker + // - flagged in code review, see CLAUDE.md. + static void EnsureLineBreakBeforeMarker(StringBuilder sb) + { + if (sb.Length > 0 && sb[sb.Length - 1] != '\n' && sb[sb.Length - 1] != '\r') + sb.Append("\r\n"); + } + + // KDiff3's WhiteSpace3FileMergeDefault only auto-resolves a conflict that's + // "purely whitespace" - i.e. once whitespace differences are ignored entirely, + // both sides agree. Comparing the whole joined-and-collapsed region (rather than + // piece-by-piece) is deliberate: two sides can disagree on how many lines a + // change spans (e.g. one side also adds a blank line) while still being + // whitespace-equivalent overall - confirmed against DiffPlex's actual block + // output in this change's verification scratch app, where such a case produces a + // single Conflict block with different OldCount/NewCount. A stricter + // element-wise comparison would misclassify that as a genuine conflict. + static bool IsWhitespaceOnlyDifference(IReadOnlyList oldPieces, IReadOnlyList newPieces) + { + // A genuine deletion (one side has zero pieces in this region) must never be + // treated as "whitespace-only", even if the surviving side's content happens + // to collapse to "" once whitespace runs are stripped - confirmed via a + // synthetic case: base has a whitespace-only separator line, one mod merely + // trims its trailing spaces (still present, still blank), the other mod + // deletes the line outright as part of a real edit. Both sides normalize to + // "", which would otherwise misclassify a genuine content-vs-deletion + // conflict as auto-resolvable and silently discard the deletion. If both + // sides happen to have zero pieces (e.g. both independently deleted the same + // region), this correctly falls through to producing empty conflict markers + // rather than assuming anything about whether DiffPlex would even classify + // that case as Conflict in the first place - see BuildMerge's comment on why + // this library's block metadata isn't assumed trustworthy without checking. + // Flagged in code review, see CLAUDE.md. + if (oldPieces.Count == 0 || newPieces.Count == 0) + return false; + + return NormalizeWhitespace(oldPieces) == NormalizeWhitespace(newPieces); + } + + static string NormalizeWhitespace(IEnumerable pieces) + { + return WhitespaceRun.Replace(string.Concat(pieces), " ").Trim(); + } + + #endregion + } +} diff --git a/WitcherScriptMerger.Core/Tools/FileEncoding.cs b/WitcherScriptMerger.Core/Tools/FileEncoding.cs new file mode 100644 index 0000000..4b62f93 --- /dev/null +++ b/WitcherScriptMerger.Core/Tools/FileEncoding.cs @@ -0,0 +1,93 @@ +using System.IO; +using System.Text; + +namespace WitcherScriptMerger.Tools +{ + // Shared UTF-16LE+BOM normalization, used by every merge engine. KDiff3MergeEngine + // (host project) needs an on-disk temp copy since it shells out to an external exe + // that only accepts file paths; DiffPlexMergeEngine (Core) merges in-process and only + // needs the text itself, via ReadAnyEncoding/WriteUtf16 below - EnsureUtf16File exists + // for the former, kept here too so any future file-based tool can reuse it instead of + // duplicating this logic (this method used to be a private copy inside + // WitcherScriptMerger/Tools/KDiff3.cs::EnsureUtf16Encoding). + // + // Vanilla .ws files are UTF-16LE with a BOM; mod authors' files are often plain + // UTF-8/ASCII with no BOM (confirmed against real files on a live install) - see + // CLAUDE.md's "KDiff3 input encoding" compatibility constraint for why normalizing UP + // to UTF-16LE (never down to UTF-8) matters: the game may not load a merged .ws file + // that isn't UTF-16LE. + public static class FileEncoding + { + // UTF-16LE with BOM - matches vanilla .ws file encoding. Never normalize merge + // output toward UTF-8; the game may not load it. + public static readonly Encoding Utf16LEWithBom = new UnicodeEncoding(bigEndian: false, byteOrderMark: true); + + // A UTF-16LE BOM (FF FE) is also a byte-for-byte prefix of UTF-32LE's own BOM + // (FF FE 00 00) - reading only 2 bytes would misidentify a UTF-32LE file as + // already UTF-16LE, skipping normalization and producing garbled comparison/ + // merge output (flagged in code review, see CLAUDE.md; pre-existing limitation + // carried over unchanged from the original KDiff3.cs::EnsureUtf16Encoding this + // was ported from, now fixed here since it's shared by both merge engines). + // UTF-32 isn't a realistic encoding for real .ws/.xml mod files, but reading 2 + // extra bytes to rule it out is cheap and removes the ambiguity outright. + public static bool HasUtf16LeBom(string path) + { + using (var stream = File.OpenRead(path)) + { + var bom = new byte[4]; + var bytesRead = stream.Read(bom, 0, 4); + if (bytesRead < 2 || bom[0] != 0xFF || bom[1] != 0xFE) + return false; + + var looksLikeUtf32Le = bytesRead >= 4 && bom[2] == 0x00 && bom[3] == 0x00; + return !looksLikeUtf32Le; + } + } + + // Reads a file's text regardless of whether it's UTF-16LE+BOM (vanilla's usual + // encoding) or plain UTF-8/ASCII with no BOM (common for mod authors' files). + // File.ReadAllText(path) without an explicit encoding auto-detects a BOM (UTF-16LE + // included) and falls back to UTF-8 when none is present, which is exactly the two + // cases this codebase needs - and, importantly, StreamReader strips a detected BOM + // from the returned text. Decoding the raw bytes manually with a fixed Encoding + // instead (e.g. Encoding.Unicode.GetString(File.ReadAllBytes(path))) does NOT strip + // it, leaving a stray U+FEFF glued to the first line - confirmed empirically in this + // change's verification scratch app. That stray character would make a UTF-16LE + // vanilla file's first line never equal a UTF-8 mod file's first line, silently + // reproducing the exact class of false conflict this method exists to avoid (see + // CLAUDE.md's baseEffect.ws case). + public static string ReadAnyEncoding(string path) => File.ReadAllText(path); + + // Writes text as UTF-16LE with BOM - the encoding every merge engine's output must + // use, matching vanilla's own encoding (see class remarks above). + public static void WriteUtf16(string path, string text) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + File.WriteAllText(path, text, Utf16LEWithBom); + } + + // Ensures an on-disk copy of `file` is UTF-16LE+BOM, writing a temp copy under + // Paths.TempBundleContent\Encoding\\ only when a copy is actually needed. + // For tools that must be handed a file path (KDiff3.exe's command line) rather than + // raw text - an in-process engine that reads/writes strings directly doesn't need + // this at all, just ReadAnyEncoding/WriteUtf16 above. + public static string EnsureUtf16File(FileInfo file, string role) + { + if (HasUtf16LeBom(file.FullName)) + return file.FullName; + + var text = File.ReadAllText(file.FullName, Encoding.UTF8); + + var tempDir = Path.Combine(Paths.TempBundleContent, "Encoding", role); + Directory.CreateDirectory(tempDir); + + var tempPath = Path.Combine(tempDir, file.Name); + File.WriteAllText(tempPath, text, Utf16LEWithBom); + + return tempPath; + } + } +} diff --git a/WitcherScriptMerger.Core/Tools/Hasher.cs b/WitcherScriptMerger.Core/Tools/Hasher.cs new file mode 100644 index 0000000..b510dcb --- /dev/null +++ b/WitcherScriptMerger.Core/Tools/Hasher.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; +using System.IO.Hashing; + +namespace WitcherScriptMerger.Tools +{ + public static class Hasher + { + // xxHash32, seed 0 - matches the hand-ported implementation this replaced, + // so hashes already recorded in existing MergeInventory.xml files stay valid. + public static string ComputeHash(string filePath) + { + if (!File.Exists(filePath)) + throw new FileNotFoundException("Can't find file to hash: " + filePath); + + var hasher = new XxHash32(); + + using (var stream = File.OpenRead(filePath)) + { + var buffer = new byte[81920]; + int bytesRead; + while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0) + { + hasher.Append(buffer.AsSpan(0, bytesRead)); + } + } + + return string.Format("{0:X}", hasher.GetCurrentHashAsUInt32()); + } + } +} diff --git a/WitcherScriptMerger.Core/Tools/IMergeEngine.cs b/WitcherScriptMerger.Core/Tools/IMergeEngine.cs new file mode 100644 index 0000000..b2eda71 --- /dev/null +++ b/WitcherScriptMerger.Core/Tools/IMergeEngine.cs @@ -0,0 +1,49 @@ +using System.IO; +using WitcherScriptMerger.Inventory; + +namespace WitcherScriptMerger.Tools +{ + public enum MergeEngineResult + { + AutoSolved, + NeedsManualResolution, + Failed, + } + + // Scaffolding introduced by the Core/host project split - NOT meant as a + // permanent pluggable-engine abstraction. It exists only so FileMerger (now in + // Core) can call a 3-way text merge without Core referencing Tools/KDiff3.cs's + // Win32 P/Invoke, which has to stay in the host project for now. The host + // project supplies the one real implementation (KDiff3MergeEngine) at startup + // via AppState.MergeEngine. A later unit that removes KDiff3 entirely will + // likely delete this interface and inline its replacement directly into + // FileMerger, unless a test project ends up depending on it as a seam. + public interface IMergeEngine + { + // Interactive: may open the merge tool's own UI and block until the user + // finishes or cancels. Returns AutoSolved on any successful save (whether + // auto-solved or manually resolved by the user), Failed on cancel/error. + // Never returns NeedsManualResolution - that's a headless-only concept. + MergeEngineResult Merge( + FileMerger.MergeSource source1, + FileMerger.MergeSource source2, + FileInfo vanillaFile, + string outputPath); + + // Headless: never blocks on user interaction. Detects an unresolved + // conflict itself and reports NeedsManualResolution instead of leaving a + // process hanging or a window open with nobody watching it. + MergeEngineResult MergeHeadless( + FileMerger.MergeSource source1, + FileMerger.MergeSource source2, + FileInfo vanillaFile, + string outputPath); + + // Whether the underlying merge tool's executable can actually be found. + // Exists so Paths.ValidateDependencyPaths() (Core) can validate the merge + // engine's dependency alongside QuickBMS/wcc_lite without Core referencing + // Tools/KDiff3.cs directly - that class stays in the host project for its + // Win32 P/Invoke, so Core can only reach it through this interface. + bool ValidateExePath(); + } +} diff --git a/WitcherScriptMerger.Core/Tools/QuickBms.cs b/WitcherScriptMerger.Core/Tools/QuickBms.cs new file mode 100644 index 0000000..31960ee --- /dev/null +++ b/WitcherScriptMerger.Core/Tools/QuickBms.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace WitcherScriptMerger.Tools +{ + 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)) + return 1; + + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + + var startInfo = BuildStartInfo($"-Y -f \"{contentRelativePath}\" \"{PluginPath}\" \"{bundlePath}\" \"{outputDir}\""); + + using (var bmsProc = new Process { StartInfo = startInfo }) + { + bmsProc.Start(); + var output = bmsProc.StandardError.ReadToEnd(); // QuickBMS prints results to std error, even if successful + + if (output.Contains("- 0 files found")) + { + var errorMsg = "Error unpacking bundle content file using QuickBMS.\nIts output is below."; + var outputStart = output.IndexOf("- filter string"); + if (outputStart != -1) + { + output = output.Substring(outputStart); + errorMsg += "\n\n" + output; + } + AppState.Notifier.ShowError(errorMsg); + return 1; + } + + return 0; + } + } + + // 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 Array.Empty(); + + var contentPaths = new List(); + + var startInfo = BuildStartInfo($"-l \"{PluginPath}\" \"{bundlePath}\""); + + using (var bmsProc = new Process { StartInfo = startInfo }) + { + bmsProc.Start(); + var output = bmsProc.StandardOutput.ReadToEnd() + "\n\n" + bmsProc.StandardError.ReadToEnd(); + var footerPos = output.LastIndexOf("QuickBMS generic"); + var outputLines = output.Substring(0, footerPos).Split('\n'); + var paths = outputLines + .Where(line => line.Length > 5) + .Select(line => line.Substring(line.LastIndexOf(' ')).Trim()); + contentPaths.AddRange(paths); + } + return contentPaths.ToArray(); + } + + static bool ValidateResources(string bundlePath) + { + if (!File.Exists(bundlePath)) + { + AppState.Notifier.ShowError("Can't find bundle file:\n\n" + bundlePath, "Missing Bundle"); + return false; + } + if (!File.Exists(ExePath)) + { + AppState.Notifier.ShowError("Can't find QuickBMS at this location:\n\n" + ExePath, "Missing QuickBMS"); + return false; + } + if (!File.Exists(PluginPath)) + { + AppState.Notifier.ShowError("Can't find QuickBMS plugin at this location:\n\n" + PluginPath, "Missing QuickBMS Plugin"); + return false; + } + return true; + } + + static ProcessStartInfo BuildStartInfo(string arguments) + { + return new ProcessStartInfo + { + FileName = ExePath, + Arguments = arguments, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + } + } +} diff --git a/WitcherScriptMerger.Core/Tools/WccLite.cs b/WitcherScriptMerger.Core/Tools/WccLite.cs new file mode 100644 index 0000000..0dcc3c3 --- /dev/null +++ b/WitcherScriptMerger.Core/Tools/WccLite.cs @@ -0,0 +1,71 @@ +using System.Diagnostics; +using System.IO; + +namespace WitcherScriptMerger.Tools +{ + public static class WccLite + { + public static string ExePath = AppState.Settings.Get("WccLitePath"); + + public static int PackBundle(string sourceDir, string outputDir) + { + if (!Directory.Exists(sourceDir)) + { + AppState.Notifier.ShowError("Can't find content directory to pack into bundle:\n\n" + sourceDir, "Missing Directory"); + return 1; + } + + return Run( + $"pack -dir=\"{sourceDir}\" -outdir=\"{outputDir}\"", + "Error packing merged content into a new bundle using wcc_lite.\nIts error output is below." + ); + } + + public static int GenerateMetadata(string bundleDir) + { + return Run( + $"metadatastore -path=\"{bundleDir}\"", + "Error generating metadata.store for new merged bundle using wcc_lite.\nIts error output is below." + ); + } + + public static int Run(string arguments, string failureMsg) + { + if (!File.Exists(ExePath)) + { + AppState.Notifier.ShowError("Can't find wcc_lite at this location:\n\n" + ExePath, "Missing wcc_lite"); + return 1; + } + + var procInfo = new ProcessStartInfo + { + FileName = ExePath, + Arguments = arguments, + WorkingDirectory = Path.GetDirectoryName(ExePath), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using (var wccLiteProc = new Process { StartInfo = procInfo }) + { + wccLiteProc.Start(); + var stdOutput = wccLiteProc.StandardOutput.ReadToEnd().Trim(); + var stdError = wccLiteProc.StandardError.ReadToEnd().Trim(); + + string errorMsg = null; + if (!string.IsNullOrWhiteSpace(stdError)) + errorMsg = stdError; + else if (stdOutput.EndsWith("Wcc operation failed")) + errorMsg = stdOutput; + if (errorMsg != null) + { + AppState.Notifier.ShowError(failureMsg + "\n\n" + errorMsg); + return 1; + } + } + return 0; + } + } +} diff --git a/WitcherScriptMerger.Core/WitcherScriptMerger.Core.csproj b/WitcherScriptMerger.Core/WitcherScriptMerger.Core.csproj new file mode 100644 index 0000000..ed68f3c --- /dev/null +++ b/WitcherScriptMerger.Core/WitcherScriptMerger.Core.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + WitcherScriptMerger + WitcherScriptMerger.Core + disable + disable + + ..\WitcherScriptMerger\DeadCodeDetection.ruleset + + + + + + + + + + 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.Tests/LiveInstall.cs b/WitcherScriptMerger.Tests/LiveInstall.cs new file mode 100644 index 0000000..2633c70 --- /dev/null +++ b/WitcherScriptMerger.Tests/LiveInstall.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; + +namespace WitcherScriptMerger.Tests +{ + // Opt-in discovery of a real Witcher 3 + WitcherScriptMerger install, for tests that + // cross-check against real recorded data (a live MergeInventory.xml's hashes) or a + // real KDiff3.exe binary. Deliberately NOT a hardcoded path, and deliberately NOT a + // drive-letter scan either: CONTRIBUTING.md requires scrubbing machine-specific + // absolute paths from committed diffs/tests, so discovery here is opt-in only, via an + // environment variable a developer sets locally before running `dotnet test` - never + // a default that would silently vary test behavior across machines or in CI. + public static class LiveInstall + { + // Point this at a Witcher 3 game install directory (the one containing Mods\ and + // WitcherScriptMerger\) to opt in to the tests gated on this class. + public static string GameDirectory + { + get + { + var dir = Environment.GetEnvironmentVariable("WSM_TEST_GAME_DIR"); + return string.IsNullOrWhiteSpace(dir) ? null : dir; + } + } + + public static string MergeInventoryPath + { + get + { + var gameDir = GameDirectory; + if (gameDir == null) + return null; + var path = Path.Combine(gameDir, "WitcherScriptMerger", "MergeInventory.xml"); + return File.Exists(path) ? path : null; + } + } + + public static string ModsDirectory + { + get + { + var gameDir = GameDirectory; + return gameDir == null ? null : Path.Combine(gameDir, "Mods"); + } + } + + public static string Kdiff3ExePath + { + get + { + var gameDir = GameDirectory; + if (gameDir == null) + return null; + var path = Path.Combine(gameDir, "WitcherScriptMerger", "Tools", "KDiff3", "KDiff3.exe"); + return File.Exists(path) ? path : null; + } + } + } +} diff --git a/WitcherScriptMerger.Tests/Tools/DiffPlexMergeEngineTests.cs b/WitcherScriptMerger.Tests/Tools/DiffPlexMergeEngineTests.cs new file mode 100644 index 0000000..252767f --- /dev/null +++ b/WitcherScriptMerger.Tests/Tools/DiffPlexMergeEngineTests.cs @@ -0,0 +1,395 @@ +using System; +using System.IO; +using System.Text; +using WitcherScriptMerger.Inventory; +using WitcherScriptMerger.Tools; +using Xunit; + +namespace WitcherScriptMerger.Tests.Tools +{ + // Regression coverage for DiffPlexMergeEngine - the fixtures CLAUDE.md's history + // calls out specifically: a purely-whitespace-only conflict auto-resolving (mirroring + // KDiff3's --cs "WhiteSpace3FileMergeDefault=2"), a genuine conflict producing + // well-formed conflict markers, and an encoding-mismatch case (UTF-8/no-BOM mod file + // against a UTF-16LE+BOM vanilla file) normalizing correctly - the same class of + // false conflict CLAUDE.md documents as the real baseEffect.ws case. + // + // Deliberately never constructs FileMerger.MergeSource via + // MergeSource.FromFlatFile/FromBundle: those call ModFile.GetModNameFromPath, which + // reads Paths.ModsDirectory, which reads AppState.Settings - and AppState.Settings's + // constructor calls Environment.Exit(1) if it can't find a config file next to the + // entry assembly (see AppSettings.cs), which in a test-host process would abort the + // entire test run, not just fail one test. MergeSource's fields are all public, so + // tests build it directly instead - this exercises DiffPlexMergeEngine exactly the + // same way, since it only ever reads TextFile/Hash/Name off the struct. + public class DiffPlexMergeEngineTests + { + [Fact] + public void BuildMerge_WhitespaceOnlyConflict_AutoResolvesToOldSideVerbatim() + { + var baseText = "function f() {\r\n\tx = 1;\r\n}\r\n"; + var oldText = "function f() {\r\n x = 1;\r\n}\r\n"; // source1: 4-space indent + var newText = "function f() {\r\n x = 1;\r\n}\r\n"; // source2: 2-space indent + + var result = DiffPlexMergeEngine.BuildMerge(baseText, oldText, newText, "modA", "modB"); + + Assert.False(result.HasConflicts); + // WhiteSpace3FileMergeDefault=2 means "always pick input B" in KDiff3 terms, + // which is oldText/source1 here (see KDiff3.BuildArgs' file order: vanilla, + // source1, source2 map to A, B, C) - so the merge should take oldText's exact + // whitespace, not some averaged/normalized form. + Assert.Equal(oldText, result.MergedText); + Assert.DoesNotContain("<<<<<<<", result.MergedText); + } + + [Fact] + public void BuildMerge_WhitespaceOnlyConflict_ToleratesDifferingLineCounts() + { + // One side's whitespace-only edit also happens to add a blank line - still + // purely whitespace once collapsed, so this should still auto-resolve rather + // than being misclassified as a genuine conflict just because the two sides' + // piece counts differ (confirmed against DiffPlex's actual block output in + // this change's verification scratch app before writing this fixture). + var baseText = "a();\r\nx=1;\r\nb();\r\n"; + var oldText = "a();\r\n x=1;\r\n\r\nb();\r\n"; + var newText = "a();\r\n\tx=1;\r\nb();\r\n"; + + var result = DiffPlexMergeEngine.BuildMerge(baseText, oldText, newText, "modA", "modB"); + + Assert.False(result.HasConflicts); + Assert.DoesNotContain("<<<<<<<", result.MergedText); + } + + [Fact] + public void BuildMerge_GenuineConflict_ProducesGitStyleMarkersLabeledWithModNames() + { + var baseText = "x = 1;\r\n"; + var oldText = "x = 2;\r\n"; + var newText = "x = 3;\r\n"; + + var result = DiffPlexMergeEngine.BuildMerge(baseText, oldText, newText, "modA", "modB"); + + Assert.True(result.HasConflicts); + Assert.Equal( + "<<<<<<< modA\r\n" + + "x = 2;\r\n" + + "||||||| Vanilla\r\n" + + "x = 1;\r\n" + + "=======\r\n" + + "x = 3;\r\n" + + ">>>>>>> modB\r\n", + result.MergedText); + } + + [Fact] + public void BuildMerge_NonOverlappingEdits_MergeBothCleanlyWithoutConflict() + { + var baseText = "a();\r\nb();\r\nc();\r\n"; + var oldText = "a();\r\nMOD1();\r\nb();\r\nc();\r\n"; + var newText = "a();\r\nb();\r\nc();\r\nMOD2();\r\n"; + + var result = DiffPlexMergeEngine.BuildMerge(baseText, oldText, newText, "modA", "modB"); + + Assert.False(result.HasConflicts); + Assert.Equal("a();\r\nMOD1();\r\nb();\r\nc();\r\nMOD2();\r\n", result.MergedText); + } + + [Fact] + public void BuildMerge_InterleavedIndependentEdits_ThrowsDiffAlgorithmExceptionRatherThanCorruptingOutput() + { + // Regression test for a confirmed upstream DiffPlex 1.9.0 bug in + // ThreeWayDiffer.CreateThreeWayDiffBlocks (see BuildMerge's own comment for + // the full writeup and CLAUDE.md for measured failure rates): one mod + // inserts a line right after "a();", the other independently changes "b()" + // to "B()". Before the try/catch + post-loop consistency check this fixture + // guards, this exact input silently produced WRONG merged output (base's + // "b();" escaped both the conflict markers and its correct position, while + // "new"'s edit was captured against an empty base region) with no exception + // at all - confirmed via a throwaway scratch console app directly against + // both this engine's BuildMerge and DiffPlex's own official + // ThreeWayDiffer.CreateMerge. This must now come back as a clearly-typed + // failure instead of ever risking a corrupted merge. + var baseText = "a();\r\nb();\r\nc();\r\n"; + var oldText = "a();\r\nnewline();\r\nb();\r\nc();\r\n"; + var newText = "a();\r\nB();\r\nc();\r\n"; + + var ex = Assert.Throws( + () => DiffPlexMergeEngine.BuildMerge(baseText, oldText, newText, "modA", "modB")); + Assert.NotNull(ex.Message); + } + + [Fact] + public void MergeHeadless_InterleavedIndependentEdits_SkipsWithoutWritingAnythingIncludingSidecar() + { + // Same scenario as the BuildMerge-level fixture above, exercised through the + // full MergeHeadless path: since the "conflict marker" content itself would + // have been built from the same untrustworthy piece indices, MergeHeadless + // must not write a sidecar here either - this is the one case where + // DiffPlexMergeEngine can't even offer a conflict-marker starting point. + var dir = CreateTempDir(); + try + { + var vanillaPath = Path.Combine(dir, "vanilla.ws"); + FileEncoding.WriteUtf16(vanillaPath, "a();\r\nb();\r\nc();\r\n"); + var mod1Path = Path.Combine(dir, "mod1.ws"); + FileEncoding.WriteUtf16(mod1Path, "a();\r\nnewline();\r\nb();\r\nc();\r\n"); + var mod2Path = Path.Combine(dir, "mod2.ws"); + FileEncoding.WriteUtf16(mod2Path, "a();\r\nB();\r\nc();\r\n"); + + var outputPath = Path.Combine(dir, "merged.ws"); + + var source1 = new FileMerger.MergeSource { TextFile = new FileInfo(mod1Path), Name = "modA" }; + var source2 = new FileMerger.MergeSource { TextFile = new FileInfo(mod2Path), Name = "modB" }; + + var result = new DiffPlexMergeEngine().MergeHeadless(source1, source2, new FileInfo(vanillaPath), outputPath); + + Assert.Equal(MergeEngineResult.NeedsManualResolution, result); + Assert.False(File.Exists(outputPath)); + Assert.False(File.Exists(DiffPlexMergeEngine.GetConflictMarkerPath(outputPath))); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void BuildMerge_DeletionVersusWhitespaceReformat_IsNotMisclassifiedAsWhitespaceOnly() + { + // A genuine content-vs-deletion conflict must never be silently auto-resolved + // just because the surviving side's content happens to collapse to "" once + // whitespace is stripped. Base has a whitespace-only separator line; mod1 + // merely trims its trailing spaces (still blank); mod2 deletes the line + // outright as part of a real edit. Before the fix, both normalized to "" and + // were treated as equal, silently discarding mod2's deletion. + var baseText = "a();\r\n \r\nb();\r\n"; + var oldText = "a();\r\n\r\nb();\r\n"; // mod1: trims trailing spaces, line stays blank + var newText = "a();\r\nb();\r\n"; // mod2: deletes the blank line entirely + + var result = DiffPlexMergeEngine.BuildMerge(baseText, oldText, newText, "modA", "modB"); + + Assert.True(result.HasConflicts); + Assert.Contains("<<<<<<< modA", result.MergedText); + Assert.Contains(">>>>>>> modB", result.MergedText); + } + + [Fact] + public void MergeHeadless_EncodingMismatch_NormalizesAndProducesUtf16LEWithBomOutput() + { + // Mirrors the real baseEffect.ws false-conflict case CLAUDE.md documents: + // vanilla is UTF-16LE+BOM, one mod file is plain UTF-8 with no BOM. Reading + // raw bytes with a fixed Encoding (rather than the auto-detecting + // File.ReadAllText this engine actually uses) would leave a stray U+FEFF + // glued to the vanilla file's first line, making it never equal the mod + // file's first line and turning this into a spurious conflict - which is + // exactly the failure mode this fixture guards against. + var dir = CreateTempDir(); + try + { + var vanillaPath = Path.Combine(dir, "vanilla.ws"); + FileEncoding.WriteUtf16(vanillaPath, "function f() {\r\n\tx = 1;\r\n}\r\n"); + + var mod1Path = Path.Combine(dir, "mod1.ws"); + File.WriteAllText(mod1Path, "function f() {\r\n\tx = 1;\r\n\ty = 2;\r\n}\r\n", new UTF8Encoding(false)); + + var mod2Path = Path.Combine(dir, "mod2.ws"); + File.WriteAllText(mod2Path, "function f() {\r\n\tx = 1;\r\n}\r\n", new UTF8Encoding(false)); + + var outputPath = Path.Combine(dir, "merged.ws"); + + var source1 = new FileMerger.MergeSource { TextFile = new FileInfo(mod1Path), Name = "modA" }; + var source2 = new FileMerger.MergeSource { TextFile = new FileInfo(mod2Path), Name = "modB" }; + + var result = new DiffPlexMergeEngine().MergeHeadless(source1, source2, new FileInfo(vanillaPath), outputPath); + + Assert.Equal(MergeEngineResult.AutoSolved, result); + Assert.True(File.Exists(outputPath)); + Assert.True(FileEncoding.HasUtf16LeBom(outputPath)); + + var outputBytes = File.ReadAllBytes(outputPath); + Assert.Equal(0xFF, outputBytes[0]); + Assert.Equal(0xFE, outputBytes[1]); + + var mergedText = File.ReadAllText(outputPath); + // Assert.DoesNotContain(string, string) does a culture-aware substring + // search (CompareInfo, not ordinal) - under which U+FEFF, a zero-width + // Unicode format character, is collation-ignorable and reports a "match" + // in any string, even one that doesn't contain it at all (confirmed + // empirically: mergedText.Contains("\uFEFF") - ordinal - is false, while + // Assert.DoesNotContain("\uFEFF", mergedText) still fails). The + // char/IEnumerable overload below does an exact ordinal element + // comparison instead, which is what this assertion actually means. + Assert.DoesNotContain('\uFEFF', mergedText); + Assert.Equal("function f() {\r\n\tx = 1;\r\n\ty = 2;\r\n}\r\n", mergedText); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void MergeHeadless_GenuineConflict_WritesSidecarMarkerFileNotOutputPath() + { + var dir = CreateTempDir(); + try + { + var vanillaPath = Path.Combine(dir, "vanilla.ws"); + FileEncoding.WriteUtf16(vanillaPath, "x = 1;\r\n"); + var mod1Path = Path.Combine(dir, "mod1.ws"); + FileEncoding.WriteUtf16(mod1Path, "x = 2;\r\n"); + var mod2Path = Path.Combine(dir, "mod2.ws"); + FileEncoding.WriteUtf16(mod2Path, "x = 3;\r\n"); + + var outputPath = Path.Combine(dir, "merged.ws"); + + var source1 = new FileMerger.MergeSource { TextFile = new FileInfo(mod1Path), Name = "modA" }; + var source2 = new FileMerger.MergeSource { TextFile = new FileInfo(mod2Path), Name = "modB" }; + + var result = new DiffPlexMergeEngine().MergeHeadless(source1, source2, new FileInfo(vanillaPath), outputPath); + + Assert.Equal(MergeEngineResult.NeedsManualResolution, result); + + // Never poison the real output path with conflict markers - see + // DiffPlexMergeEngine.MergeHeadless's comment for why (it would + // permanently block every future retry of this same conflict, since + // FileMerger's headless callers treat any existing file at outputPath as + // "already merged, don't overwrite"). + Assert.False(File.Exists(outputPath)); + + var sidecarPath = DiffPlexMergeEngine.GetConflictMarkerPath(outputPath); + Assert.True(File.Exists(sidecarPath)); + Assert.True(FileEncoding.HasUtf16LeBom(sidecarPath)); + + var sidecarText = File.ReadAllText(sidecarPath); + Assert.Contains("<<<<<<< modA", sidecarText); + Assert.Contains(">>>>>>> modB", sidecarText); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void MergeHeadless_RetryAfterConflictThatNowAutoSolves_RemovesStaleSidecar() + { + // A conflicting retry leaves a sidecar marker file behind (see the fixture + // above). If a later retry against updated inputs auto-solves cleanly, the + // stale sidecar from the earlier failed attempt must not be left sitting next + // to the fresh output indefinitely - MergeHeadless deletes it on the + // AutoSolved path specifically to avoid that. + var dir = CreateTempDir(); + try + { + var vanillaPath = Path.Combine(dir, "vanilla.ws"); + FileEncoding.WriteUtf16(vanillaPath, "x = 1;\r\n"); + var mod1Path = Path.Combine(dir, "mod1.ws"); + FileEncoding.WriteUtf16(mod1Path, "x = 2;\r\n"); + var mod2Path = Path.Combine(dir, "mod2.ws"); + FileEncoding.WriteUtf16(mod2Path, "x = 3;\r\n"); + + var outputPath = Path.Combine(dir, "merged.ws"); + var sidecarPath = DiffPlexMergeEngine.GetConflictMarkerPath(outputPath); + + var source1 = new FileMerger.MergeSource { TextFile = new FileInfo(mod1Path), Name = "modA" }; + var source2 = new FileMerger.MergeSource { TextFile = new FileInfo(mod2Path), Name = "modB" }; + + var engine = new DiffPlexMergeEngine(); + Assert.Equal(MergeEngineResult.NeedsManualResolution, engine.MergeHeadless(source1, source2, new FileInfo(vanillaPath), outputPath)); + Assert.True(File.Exists(sidecarPath)); + + // Now "fix" mod2 so this pairing no longer conflicts, and retry. + FileEncoding.WriteUtf16(mod2Path, "x = 2;\r\n"); + var retrySource2 = new FileMerger.MergeSource { TextFile = new FileInfo(mod2Path), Name = "modB" }; + + Assert.Equal(MergeEngineResult.AutoSolved, engine.MergeHeadless(source1, retrySource2, new FileInfo(vanillaPath), outputPath)); + Assert.True(File.Exists(outputPath)); + Assert.False(File.Exists(sidecarPath)); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void MergeHeadless_NoVanillaFile_SkipsWithoutWritingAnything() + { + // A 3-way merge is meaningless without a base - see MergeHeadless's comment + // for the empty-base bug this guard exists to avoid (confirmed empirically: + // feeding ThreeWayDiffer an empty base string produces zero diff blocks and a + // "successful" empty merge, i.e. it would silently produce an empty output + // file instead of refusing). + var dir = CreateTempDir(); + try + { + var mod1Path = Path.Combine(dir, "mod1.ws"); + FileEncoding.WriteUtf16(mod1Path, "x = 2;\r\n"); + var mod2Path = Path.Combine(dir, "mod2.ws"); + FileEncoding.WriteUtf16(mod2Path, "x = 3;\r\n"); + + var outputPath = Path.Combine(dir, "merged.ws"); + var missingVanilla = new FileInfo(Path.Combine(dir, "does-not-exist.ws")); + + var source1 = new FileMerger.MergeSource { TextFile = new FileInfo(mod1Path), Name = "modA" }; + var source2 = new FileMerger.MergeSource { TextFile = new FileInfo(mod2Path), Name = "modB" }; + + var result = new DiffPlexMergeEngine().MergeHeadless(source1, source2, missingVanilla, outputPath); + + Assert.Equal(MergeEngineResult.NeedsManualResolution, result); + Assert.False(File.Exists(outputPath)); + Assert.False(File.Exists(DiffPlexMergeEngine.GetConflictMarkerPath(outputPath))); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Merge_Interactive_NeverReturnsNeedsManualResolution() + { + // IMergeEngine.Merge's contract explicitly forbids ever returning + // NeedsManualResolution (that's a headless-only concept) - DiffPlexMergeEngine + // has no UI to resolve a conflict interactively, so a genuine conflict must + // come back as Failed instead. + var dir = CreateTempDir(); + try + { + var vanillaPath = Path.Combine(dir, "vanilla.ws"); + FileEncoding.WriteUtf16(vanillaPath, "x = 1;\r\n"); + var mod1Path = Path.Combine(dir, "mod1.ws"); + FileEncoding.WriteUtf16(mod1Path, "x = 2;\r\n"); + var mod2Path = Path.Combine(dir, "mod2.ws"); + FileEncoding.WriteUtf16(mod2Path, "x = 3;\r\n"); + + var outputPath = Path.Combine(dir, "merged.ws"); + + var source1 = new FileMerger.MergeSource { TextFile = new FileInfo(mod1Path), Name = "modA" }; + var source2 = new FileMerger.MergeSource { TextFile = new FileInfo(mod2Path), Name = "modB" }; + + var result = new DiffPlexMergeEngine().Merge(source1, source2, new FileInfo(vanillaPath), outputPath); + + Assert.Equal(MergeEngineResult.Failed, result); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void ValidateExePath_AlwaysTrue_NoExternalBinaryToValidate() + { + Assert.True(new DiffPlexMergeEngine().ValidateExePath()); + } + + static string CreateTempDir() + { + var dir = Path.Combine(Path.GetTempPath(), "wsm-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + } +} diff --git a/WitcherScriptMerger.Tests/Tools/FileEncodingTests.cs b/WitcherScriptMerger.Tests/Tools/FileEncodingTests.cs new file mode 100644 index 0000000..c2f7db8 --- /dev/null +++ b/WitcherScriptMerger.Tests/Tools/FileEncodingTests.cs @@ -0,0 +1,180 @@ +using System; +using System.IO; +using System.Text; +using WitcherScriptMerger.Tools; +using Xunit; + +namespace WitcherScriptMerger.Tests.Tools +{ + // Direct coverage of the shared encoding helper both merge engines rely on - see + // FileEncoding.cs's remarks for why File.ReadAllText(path) (auto-detecting) is used + // for reads instead of decoding raw bytes with a fixed Encoding. + // + // Deliberately never references WitcherScriptMerger.Paths beyond its TempBundleContent + // const (a compile-time literal, so referencing it can't trigger Paths' static field + // initializers) - see DiffPlexMergeEngineTests' class remarks for why touching + // Paths/AppState.Settings from a test host is unsafe. + public class FileEncodingTests + { + [Fact] + public void HasUtf16LeBom_DetectsBomCorrectly() + { + var dir = CreateTempDir(); + try + { + var utf16Path = Path.Combine(dir, "utf16.ws"); + File.WriteAllText(utf16Path, "hello", new UnicodeEncoding(false, true)); + var utf8Path = Path.Combine(dir, "utf8.ws"); + File.WriteAllText(utf8Path, "hello", new UTF8Encoding(false)); + + Assert.True(FileEncoding.HasUtf16LeBom(utf16Path)); + Assert.False(FileEncoding.HasUtf16LeBom(utf8Path)); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void HasUtf16LeBom_DoesNotMisidentifyUtf32LeAsUtf16LE() + { + // UTF-16LE's BOM (FF FE) is a byte-for-byte prefix of UTF-32LE's own BOM + // (FF FE 00 00) - a 2-byte-only check would misidentify this file and skip + // normalization, producing garbled output. Flagged in code review; see + // FileEncoding.HasUtf16LeBom's remarks. + var dir = CreateTempDir(); + try + { + var utf32Path = Path.Combine(dir, "utf32.ws"); + File.WriteAllText(utf32Path, "hello", new UTF32Encoding(bigEndian: false, byteOrderMark: true)); + + Assert.False(FileEncoding.HasUtf16LeBom(utf32Path)); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void HasUtf16LeBom_TinyBomOnlyFileStillDetectedAsUtf16LE() + { + // A file containing only the 2-byte UTF-16LE BOM and no content at all - the + // 4-byte read this method now does for the UTF-32LE disambiguation above must + // not require 4 bytes to actually exist on disk. + var dir = CreateTempDir(); + try + { + var path = Path.Combine(dir, "bomonly.ws"); + File.WriteAllBytes(path, new byte[] { 0xFF, 0xFE }); + + Assert.True(FileEncoding.HasUtf16LeBom(path)); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void ReadAnyEncoding_AgreesRegardlessOfSourceEncodingAndStripsBom() + { + var dir = CreateTempDir(); + try + { + var text = "line1\r\nline2\r\n"; + var utf16Path = Path.Combine(dir, "utf16.ws"); + File.WriteAllText(utf16Path, text, new UnicodeEncoding(false, true)); + var utf8Path = Path.Combine(dir, "utf8.ws"); + File.WriteAllText(utf8Path, text, new UTF8Encoding(false)); + + var fromUtf16 = FileEncoding.ReadAnyEncoding(utf16Path); + var fromUtf8 = FileEncoding.ReadAnyEncoding(utf8Path); + + Assert.Equal(text, fromUtf16); + Assert.Equal(text, fromUtf8); + Assert.Equal(fromUtf16, fromUtf8); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void WriteUtf16_ProducesExactBomBytesAndCreatesMissingDirectory() + { + var dir = CreateTempDir(); + try + { + var path = Path.Combine(dir, "nested", "out.ws"); + FileEncoding.WriteUtf16(path, "content\r\n"); + + var bytes = File.ReadAllBytes(path); + Assert.Equal(0xFF, bytes[0]); + Assert.Equal(0xFE, bytes[1]); + Assert.Equal("content\r\n", File.ReadAllText(path)); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void EnsureUtf16File_ReturnsOriginalPathWhenAlreadyUtf16LE() + { + var dir = CreateTempDir(); + try + { + var path = Path.Combine(dir, "vanilla.ws"); + File.WriteAllText(path, "content", new UnicodeEncoding(false, true)); + + var result = FileEncoding.EnsureUtf16File(new FileInfo(path), "TestRole"); + + Assert.Equal(path, result); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void EnsureUtf16File_WritesNormalizedTempCopyWhenNotUtf16LE() + { + var dir = CreateTempDir(); + string tempCopyDir = null; + try + { + var path = Path.Combine(dir, "mod.ws"); + File.WriteAllText(path, "content", new UTF8Encoding(false)); + + var result = FileEncoding.EnsureUtf16File(new FileInfo(path), "TestRole"); + tempCopyDir = Path.GetDirectoryName(result); + + Assert.NotEqual(path, result); + Assert.True(FileEncoding.HasUtf16LeBom(result)); + Assert.Equal("content", File.ReadAllText(result)); + } + finally + { + Directory.Delete(dir, true); + // EnsureUtf16File's temp copy goes under the relative "tempbundlecontent" + // directory (Paths.TempBundleContent's literal value), not under `dir` - + // clean it up too so repeated test runs don't accumulate copies, matching + // CLAUDE.md's own noted precedent for clearing this directory between runs. + if (tempCopyDir != null && Directory.Exists(tempCopyDir)) + Directory.Delete(tempCopyDir, true); + } + } + + static string CreateTempDir() + { + var dir = Path.Combine(Path.GetTempPath(), "wsm-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + } +} diff --git a/WitcherScriptMerger.Tests/Tools/HasherTests.cs b/WitcherScriptMerger.Tests/Tools/HasherTests.cs new file mode 100644 index 0000000..1d95915 --- /dev/null +++ b/WitcherScriptMerger.Tests/Tools/HasherTests.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using System.IO.Hashing; +using System.Text; +using System.Xml.Linq; +using WitcherScriptMerger.Tools; +using Xunit; + +namespace WitcherScriptMerger.Tests.Tools +{ + // Regression coverage for Tools/Hasher.cs. CLAUDE.md's Compatibility constraints call + // this format load-bearing: MergeInventory.xml compares these hashes by plain string + // equality to detect when a mod file has changed since it was last merged, so any + // change to Hasher.ComputeHash - not just its numeric result, but its exact output + // format - would silently make every already-recorded merge "go stale". Expected + // values below were computed by actually running Hasher's exact algorithm against + // synthetic inputs in a disposable scratch console app (this repo's own established + // verification pattern - see CLAUDE.md's Tests section), not hand-derived, to avoid + // transcription error. + public class HasherTests + { + [Fact] + public void ComputeHash_EmptyFile_MatchesKnownXxHash32Vector() + { + // xxHash32 of a zero-length input with seed 0 is a well-known published test + // vector (0x02CC5D05) - this confirms Hasher's seed/algorithm choice hasn't + // silently drifted, independent of the scratch-app cross-check this class + // otherwise relies on. + var path = WriteTempFile(Array.Empty()); + try + { + Assert.Equal("2CC5D05", Hasher.ComputeHash(path)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ComputeHash_SmallAsciiContent_MatchesRecordedValue() + { + var path = WriteTempFile(Encoding.ASCII.GetBytes("abc")); + try + { + Assert.Equal("32D153FF", Hasher.ComputeHash(path)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ComputeHash_DoesNotZeroPadLeadingNibble() + { + // ComputeHash formats with "{0:X}", which never zero-pads - confirmed against + // a real recorded hash in a live install's MergeInventory.xml, which contains + // Hash="D830FD" (6 hex digits, i.e. unpadded from the usual 8). Reformatting + // to a fixed-width "X8" would be exactly the kind of silent output-format + // change CLAUDE.md warns would make every already-recorded merge hash + // comparison fail. + var path = WriteTempFile(Encoding.ASCII.GetBytes("candidate-41")); + try + { + var hash = Hasher.ComputeHash(path); + Assert.Equal("19AD22", hash); + Assert.True(hash.Length < 8); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ComputeHash_InputLargerThanReadBuffer_MatchesOneShotHash() + { + // ComputeHash streams through an 81920-byte buffer in a loop instead of + // hashing the whole file in a single call - this independently verifies that + // chunking via repeated XxHash32.Append calls produces the same result as + // hashing the same bytes in one call, i.e. the loop's chunk-boundary handling + // is correct. 100000 bytes deliberately crosses the 81920-byte boundary. + var bytes = new byte[100000]; + for (var i = 0; i < bytes.Length; ++i) + bytes[i] = (byte)(i % 251); + + var path = WriteTempFile(bytes); + try + { + var expected = string.Format("{0:X}", XxHash32.HashToUInt32(bytes)); + Assert.Equal(expected, Hasher.ComputeHash(path)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ComputeHash_MissingFile_ThrowsFileNotFoundException() + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".ws"); + Assert.Throws(() => Hasher.ComputeHash(path)); + } + + // Cross-checks a freshly computed hash against a real value recorded in a live + // install's MergeInventory.xml, per this repo's Tests precedent (CLAUDE.md / + // CONTRIBUTING.md). Gated entirely on WSM_TEST_GAME_DIR (see LiveInstall.cs) - + // silently does nothing when unset, so it never fails a machine or CI run that + // doesn't have a live install configured. Writes a one-line Console message + // either way (visible via `dotnet test --logger "console;verbosity=detailed"`) + // stating whether it actually cross-checked anything - a silent no-assertion + // pass here would look identical to a real cross-check in every other way, + // which is exactly the ambiguity CLAUDE.md's Tests section warns about. + [Fact] + public void ComputeHash_LiveInstallCrossCheck() + { + var inventoryPath = LiveInstall.MergeInventoryPath; + var modsDir = LiveInstall.ModsDirectory; + if (inventoryPath == null || modsDir == null) + { + Console.WriteLine("ComputeHash_LiveInstallCrossCheck: WSM_TEST_GAME_DIR not set or no MergeInventory.xml found - skipped."); + return; + } + + var doc = XDocument.Load(inventoryPath); + foreach (var mergeEl in doc.Root.Elements("Merge")) + { + var relativePath = (string)mergeEl.Element("RelativePath"); + if (relativePath == null) + continue; + + foreach (var modEl in mergeEl.Elements("IncludedMod")) + { + var recordedHash = (string)modEl.Attribute("Hash"); + var modName = modEl.Value; + if (recordedHash == null || string.IsNullOrEmpty(modName)) + continue; + + var modFilePath = Path.Combine(modsDir, modName, "content", "scripts", relativePath); + if (!File.Exists(modFilePath)) + continue; + + // One real cross-check is enough to catch a format regression - + // return as soon as we find (and assert against) one. + Console.WriteLine($"ComputeHash_LiveInstallCrossCheck: cross-checked {modName}'s {relativePath} against recorded hash {recordedHash}."); + Assert.Equal(recordedHash, Hasher.ComputeHash(modFilePath)); + return; + } + } + + // Reached only when a live inventory exists but none of its recorded mod + // source files are present on disk anymore - nothing to cross-check against, + // so this intentionally asserts nothing rather than failing. + Console.WriteLine("ComputeHash_LiveInstallCrossCheck: found a live MergeInventory.xml, but none of its recorded mod source files are still on disk - nothing cross-checked."); + } + + static string WriteTempFile(byte[] bytes) + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".bin"); + File.WriteAllBytes(path, bytes); + return path; + } + } +} diff --git a/WitcherScriptMerger.Tests/Tools/KDiff3CrossCheckTests.cs b/WitcherScriptMerger.Tests/Tools/KDiff3CrossCheckTests.cs new file mode 100644 index 0000000..1d8eac8 --- /dev/null +++ b/WitcherScriptMerger.Tests/Tools/KDiff3CrossCheckTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Diagnostics; +using System.IO; +using WitcherScriptMerger.Tools; +using Xunit; + +namespace WitcherScriptMerger.Tests.Tools +{ + // Optional A/B check: does DiffPlexMergeEngine agree with the real KDiff3.exe binary + // on auto-solvable merges? Gated entirely on WSM_TEST_GAME_DIR (see LiveInstall.cs) - + // never runs by default, and never fails a run where it's unset; per this repo's + // CONTRIBUTING.md, a committed test must not require or hardcode a machine-specific + // path. + // + // Deliberately narrow in scope: only auto-solvable scenarios are compared here + // (whitespace-only, and non-overlapping edits). A genuine two-sided conflict is NOT + // cross-checked against the real binary from this test project, because safely + // automating KDiff3 headlessly for that case needs the window-persistence detection + // documented in CLAUDE.md's compatibility constraints (a ~250ms poll interval that's + // itself load-bearing, and a window that can't be hidden without hanging the merge + // entirely) - that logic (Win32 P/Invoke) lives in the host project's Tools/KDiff3.cs, + // which this Core-only test project intentionally doesn't reference. Below uses a + // single bounded Process.WaitForExit with a kill-on-timeout fallback instead, safe + // only because both scenarios here are designed to be cleanly auto-solvable - per + // CLAUDE.md, an untouched, auto-solvable KDiff3 launch reliably exits in a few + // seconds regardless of file size. + // + // Running this locally (WSM_TEST_GAME_DIR set) will briefly show KDiff3's window and + // steal foreground focus, twice - the same documented behavior CLAUDE.md describes + // for the real headless CLI path. That's expected, not a bug in this test. + public class KDiff3CrossCheckTests + { + [Fact] + public void WhitespaceOnlyConflict_RealKDiff3AgreesWithDiffPlexEngine() + { + var kdiff3Path = LiveInstall.Kdiff3ExePath; + if (kdiff3Path == null) + return; + + var baseText = "function f() {\r\n\tx = 1;\r\n}\r\n"; + var oldText = "function f() {\r\n x = 1;\r\n}\r\n"; + var newText = "function f() {\r\n x = 1;\r\n}\r\n"; + + RunComparison(kdiff3Path, baseText, oldText, newText); + } + + [Fact] + public void NonOverlappingEdits_RealKDiff3AgreesWithDiffPlexEngine() + { + var kdiff3Path = LiveInstall.Kdiff3ExePath; + if (kdiff3Path == null) + return; + + var baseText = "a();\r\nb();\r\nc();\r\n"; + var oldText = "a();\r\nMOD1();\r\nb();\r\nc();\r\n"; + var newText = "a();\r\nb();\r\nc();\r\nMOD2();\r\n"; + + RunComparison(kdiff3Path, baseText, oldText, newText); + } + + static void RunComparison(string kdiff3Path, string baseText, string oldText, string newText) + { + var dir = Path.Combine(Path.GetTempPath(), "wsm-tests-kdiff3-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var vanillaPath = Path.Combine(dir, "vanilla.ws"); + var oldPath = Path.Combine(dir, "old.ws"); + var newPath = Path.Combine(dir, "new.ws"); + var kdiff3OutPath = Path.Combine(dir, "kdiff3-out.ws"); + + FileEncoding.WriteUtf16(vanillaPath, baseText); + FileEncoding.WriteUtf16(oldPath, oldText); + FileEncoding.WriteUtf16(newPath, newText); + + var kdiff3Text = RunRealKDiff3(kdiff3Path, vanillaPath, oldPath, newPath, kdiff3OutPath); + if (kdiff3Text == null) + return; // didn't exit cleanly within the bounded wait - see RunRealKDiff3 + + var diffPlexResult = DiffPlexMergeEngine.BuildMerge(baseText, oldText, newText, "old", "new"); + + Assert.False(diffPlexResult.HasConflicts); + Assert.Equal(kdiff3Text, diffPlexResult.MergedText); + } + finally + { + Directory.Delete(dir, true); + } + } + + // Invokes the real kdiff3.exe with the same --cs settings KDiff3.BuildArgs uses + // (WhiteSpace3FileMergeDefault=2, LineEndStyle=1) plus --auto, via the two-string + // Process.Start(fileName, argsString) overload - CLAUDE.md's compatibility notes + // call out that this specific overload (not a shell) is the one that matches this + // app's real invocation path. Returns null (never throws/fails) if the process + // doesn't exit cleanly within the bounded wait, so a flaky or unexpectedly slow + // KDiff3 run degrades to "comparison skipped", not a build-breaking test failure. + static string RunRealKDiff3(string kdiff3Path, string vanillaPath, string oldPath, string newPath, string outputPath) + { + var args = + $"\"{vanillaPath}\" \"{oldPath}\" \"{newPath}\" " + + $"-o \"{outputPath}\" " + + "--cs \"WhiteSpace3FileMergeDefault=2\" " + + "--cs \"CreateBakFiles=0\" " + + "--cs \"LineEndStyle=1\" " + + "--auto"; + + var proc = Process.Start(kdiff3Path, args); + try + { + if (!proc.WaitForExit(15000)) + { + try { proc.Kill(entireProcessTree: true); } catch { } + return null; + } + + return (proc.ExitCode == 0 && File.Exists(outputPath)) + ? File.ReadAllText(outputPath) + : null; + } + finally + { + proc.Dispose(); + } + } + } +} diff --git a/WitcherScriptMerger.Tests/WitcherScriptMerger.Tests.csproj b/WitcherScriptMerger.Tests/WitcherScriptMerger.Tests.csproj new file mode 100644 index 0000000..25aa5c4 --- /dev/null +++ b/WitcherScriptMerger.Tests/WitcherScriptMerger.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + + disable + disable + false + + + + + + + + + + + + + + diff --git a/WitcherScriptMerger.sln b/WitcherScriptMerger.sln index f080f87..7411938 100644 --- a/WitcherScriptMerger.sln +++ b/WitcherScriptMerger.sln @@ -5,16 +5,70 @@ VisualStudioVersion = 14.0.25420.1 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WitcherScriptMerger", "WitcherScriptMerger\WitcherScriptMerger.csproj", "{B0417CBE-445D-47A0-8502-717BCFE63013}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WitcherScriptMerger.Core", "WitcherScriptMerger.Core\WitcherScriptMerger.Core.csproj", "{339EF28F-A6D3-4878-A03E-0EE691B74FDE}" +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 + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {B0417CBE-445D-47A0-8502-717BCFE63013}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B0417CBE-445D-47A0-8502-717BCFE63013}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0417CBE-445D-47A0-8502-717BCFE63013}.Debug|x64.ActiveCfg = Debug|Any CPU + {B0417CBE-445D-47A0-8502-717BCFE63013}.Debug|x64.Build.0 = Debug|Any CPU + {B0417CBE-445D-47A0-8502-717BCFE63013}.Debug|x86.ActiveCfg = Debug|Any CPU + {B0417CBE-445D-47A0-8502-717BCFE63013}.Debug|x86.Build.0 = Debug|Any CPU {B0417CBE-445D-47A0-8502-717BCFE63013}.Release|Any CPU.ActiveCfg = Release|Any CPU {B0417CBE-445D-47A0-8502-717BCFE63013}.Release|Any CPU.Build.0 = Release|Any CPU + {B0417CBE-445D-47A0-8502-717BCFE63013}.Release|x64.ActiveCfg = Release|Any CPU + {B0417CBE-445D-47A0-8502-717BCFE63013}.Release|x64.Build.0 = Release|Any CPU + {B0417CBE-445D-47A0-8502-717BCFE63013}.Release|x86.ActiveCfg = Release|Any CPU + {B0417CBE-445D-47A0-8502-717BCFE63013}.Release|x86.Build.0 = Release|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Debug|x64.ActiveCfg = Debug|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Debug|x64.Build.0 = Debug|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Debug|x86.ActiveCfg = Debug|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Debug|x86.Build.0 = Debug|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Release|Any CPU.Build.0 = Release|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Release|x64.ActiveCfg = Release|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Release|x64.Build.0 = Release|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Release|x86.ActiveCfg = Release|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Release|x86.Build.0 = Release|Any CPU + {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Debug|Any CPU.Build.0 = Debug|Any CPU + {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Debug|x64.ActiveCfg = Debug|Any CPU + {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Debug|x64.Build.0 = Debug|Any CPU + {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Debug|x86.ActiveCfg = Debug|Any CPU + {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Debug|x86.Build.0 = Debug|Any CPU + {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Release|Any CPU.ActiveCfg = Release|Any CPU + {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Release|Any CPU.Build.0 = Release|Any CPU + {401B0543-E5DB-4AAA-86BF-A7B84E6C6175}.Release|x64.ActiveCfg = Release|Any CPU + {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 diff --git a/WitcherScriptMerger/App.config b/WitcherScriptMerger/App.config index 72fc1c6..35a78ea 100644 --- a/WitcherScriptMerger/App.config +++ b/WitcherScriptMerger/App.config @@ -27,6 +27,12 @@ KDiff3Path Where KDiff3.exe is located QuickBmsPath Where quickbms.exe is located QuickBmsPluginPath Where the witcher3.bms plugin for QuickBMS is located WccLitePath Where wcc_lite.exe is located + +MergeEngine Which text-merge engine to use: "kdiff3" (default) or "diffplex". + DiffPlex is an in-process alternative to KDiff3 that needs no + external binary - see CLAUDE.md's Interactive vs. headless split + section. Not the default yet; still being verified against KDiff3 + on real conflicts. --> @@ -52,6 +58,7 @@ WccLitePath Where wcc_lite.exe is located + diff --git a/WitcherScriptMerger/AppSettings.cs b/WitcherScriptMerger/AppSettings.cs deleted file mode 100644 index 97931ae..0000000 --- a/WitcherScriptMerger/AppSettings.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using System.Configuration; -using System.Reflection; -using System.Windows.Forms; - -namespace WitcherScriptMerger -{ - class AppSettings - { - string _assemblyPath; - - Configuration _cachedConfig; - Configuration CachedConfig - { - get - { - if (_cachedConfig == null) - _cachedConfig = ConfigurationManager.OpenExeConfiguration(_assemblyPath); - return _cachedConfig; - } - } - - public bool HasConfigFile => CachedConfig.HasFile; - - public AppSettings() - { - _assemblyPath = Assembly.GetEntryAssembly().Location; - - if (!CachedConfig.HasFile) - { - MessageBox.Show( - "Config file is missing.", - "Script Merger Error", - MessageBoxButtons.OK, - MessageBoxIcon.Error); - Environment.Exit(1); - } - } - - public void Set(string key, object value) - { - try - { - CachedConfig.AppSettings.Settings[key].Value = value.ToString(); - } - catch - { - CachedConfig.AppSettings.Settings.Add(key, value.ToString()); - } - } - - public T Get(string key) - { - try - { - if (CachedConfig.HasFile) - { - var valueString = CachedConfig.AppSettings.Settings[key].Value; - var parseMethod = typeof(T).GetMethod("Parse", new Type[] { typeof(string) }); - var valueObject = parseMethod.Invoke(null, new object[] { valueString }); - return (T)valueObject; - } - - Program.MainForm.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}"); - return default(T); - } - catch - { - return default(T); - } - } - - public string Get(string key) - { - try - { - if (CachedConfig.HasFile) - return CachedConfig.AppSettings.Settings[key].Value; - - Program.MainForm.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}"); - return string.Empty; - } - catch - { - return string.Empty; - } - } - - public void Save() - { - try - { - CachedConfig.Save(ConfigurationSaveMode.Minimal); - } - catch (Exception ex) - { - Program.MainForm.ShowError($"Failed to save config due to error:\n\n{ex.Message}"); - } - } - } -} diff --git a/WitcherScriptMerger/Controls/ConflictTree.cs b/WitcherScriptMerger/Controls/ConflictTree.cs index 48c7c41..6b9017a 100644 --- a/WitcherScriptMerger/Controls/ConflictTree.cs +++ b/WitcherScriptMerger/Controls/ConflictTree.cs @@ -9,295 +9,295 @@ namespace WitcherScriptMerger.Controls { - class ConflictTree : SMTree - { - #region Members - - public static readonly Color UnresolvedForeColor = Color.Red; - public static readonly Color ResolvedForeColor = Color.Purple; - - public static readonly new Color FileNodeForeColor = UnresolvedForeColor; - - ToolStripSeparator _contextCustomLoadOrderSeparator = new ToolStripSeparator(); - ToolStripMenuItem _contextPrioritizeMod = new ToolStripMenuItem(); - ToolStripMenuItem _contextToggleMod = new ToolStripMenuItem(); - ToolStripMenuItem _contextRemoveFromCustomLoadOrder = new ToolStripMenuItem(); - - #endregion - - public ConflictTree() - { - ContextNodeRegion.Items.AddRange(new ToolStripItem[] - { - _contextCustomLoadOrderSeparator, - _contextPrioritizeMod, - _contextToggleMod, - _contextRemoveFromCustomLoadOrder - }); - BuildContextMenu(); - - // contextCustomLoadOrderSeparator - _contextCustomLoadOrderSeparator.Name = "contextCustomLoadOrderSeparator"; - _contextCustomLoadOrderSeparator.Size = new Size(235, 6); - - // contextPrioritizeMod - _contextPrioritizeMod.Name = "contextPrioritizeMod"; - _contextPrioritizeMod.Size = new Size(225, 22); - _contextPrioritizeMod.Text = "Set Overall Mod Priority..."; - _contextPrioritizeMod.ToolTipText = "Lets you define the load order of your mods"; - _contextPrioritizeMod.Click += ContextPrioritizeMod; - - // contextToggleMod - _contextToggleMod.Name = "contextToggleMod"; - _contextToggleMod.Size = new Size(225, 22); - _contextToggleMod.ToolTipText = "Tells the game whether to load any of this mod's files"; - _contextToggleMod.Click += ContextToggleMod; - - // contextRemoveFromCustomLoadOrder - _contextRemoveFromCustomLoadOrder.Name = "contextRemoveFromCustomLoadOrder"; - _contextRemoveFromCustomLoadOrder.Size = new Size(225, 22); - _contextRemoveFromCustomLoadOrder.ToolTipText = "Removes this mod's custom load order settings"; - _contextRemoveFromCustomLoadOrder.Click += ContextRemoveFromCustomLoadOrder; - } - - protected override void HandleCheckedChange() - { - if (IsCategoryNode(ClickedNode)) - { - foreach (var fileNode in ClickedNode.GetTreeNodes()) - { - fileNode.SetCheckedIfVisible(ClickedNode.Checked); - foreach (var modNode in fileNode.GetTreeNodes()) - modNode.SetCheckedIfVisible(ClickedNode.Checked); - } - } - else if (IsFileNode(ClickedNode)) - { - foreach (var modNode in ClickedNode.GetTreeNodes()) - modNode.SetCheckedIfVisible(ClickedNode.Checked); - - var catNode = ClickedNode.Parent; - catNode.Checked = catNode.AreAllVisibleCheckboxesChecked(); - } - else if (IsModNode(ClickedNode)) - { - var fileNode = ClickedNode.Parent; - fileNode.Checked = fileNode.AreAllVisibleCheckboxesChecked(); - - var catNode = fileNode.Parent; - catNode.Checked = catNode.AreAllVisibleCheckboxesChecked(); - } - Program.MainForm.EnableMergeIfValidSelection(); - } - - protected override void OnLeftMouseUp(MouseEventArgs e) - { - if (ClickedNode == null) - return; - - TreeNode catNode; - if (IsCategoryNode(ClickedNode)) - catNode = ClickedNode; - else if (IsFileNode(ClickedNode)) - catNode = ClickedNode.Parent; - else - catNode = ClickedNode.Parent.Parent; - - var category = catNode.Tag as ModFileCategory; - if (!category.IsSupported) - { - EndUpdate(); - IsUpdating = false; - } - else - base.OnLeftMouseUp(e); - } - - protected override void SetAllChecked(bool isChecked) - { - foreach (var catNode in CategoryNodes) - { - var category = catNode.Tag as ModFileCategory; - if (!category.IsSupported) - continue; - catNode.Checked = isChecked; - foreach (var fileNode in catNode.GetTreeNodes()) - { - fileNode.Checked = isChecked; - foreach (var modNode in fileNode.GetTreeNodes()) - modNode.SetCheckedIfVisible(isChecked); - } - } - Program.MainForm.EnableMergeIfValidSelection(); - } - - protected override void SetContextItemAvailability() - { - base.SetContextItemAvailability(); - - if (ClickedNode != null) - { - if (IsModNode(ClickedNode)) - { - _contextCustomLoadOrderSeparator.Available = true; - _contextPrioritizeMod.Available = true; - - foreach (var item in ContextNodeRegion.Items.Cast()) - { - item.Enabled = Program.LoadOrder.IsValid; - } - - var isDisabled = Program.LoadOrder.IsModDisabledByName(ClickedNode.Text); - - _contextToggleMod.Available = true; - _contextToggleMod.Text = - isDisabled - ? "Enable Mod" - : "Disable Mod"; - - _contextRemoveFromCustomLoadOrder.Available = Program.LoadOrder.ContainsMod(ClickedNode.Text); - _contextRemoveFromCustomLoadOrder.Text = - isDisabled - ? "Clear Priority && Disabled State" - : "Clear Priority"; - } - } - else if (!this.IsEmpty()) - { - ContextSelectAll.Available = CategoryNodes.Any(catNode => !catNode.Checked && (catNode.Tag as ModFileCategory).IsSupported); - - ContextDeselectAll.Available = ModNodes.Any(modNode => modNode.Checked); - } - } - - void ContextPrioritizeMod(object sender, EventArgs e) - { - RightClickedNode.BackColor = Color.Gainsboro; - - var modName = RightClickedNode.Text; - int? inputVal; - - using (var prompt = new PriorityPrompt()) - { - inputVal = prompt.ShowDialog(Program.LoadOrder.GetPriorityByName(modName)); - } - - RightClickedNode.BackColor = Color.Transparent; - - if (!inputVal.HasValue) - return; - - Program.LoadOrder.Refresh(); - Program.LoadOrder.SetPriorityByName(modName, inputVal.Value); - Program.LoadOrder.AddMergedModIfMissing(); - Program.LoadOrder.Save(); - - SetStylesForCustomLoadOrder(); - } - - void ContextToggleMod(object sender, EventArgs e) - { - var modName = RightClickedNode.Text; - - Program.LoadOrder.Refresh(); - Program.LoadOrder.ToggleModByName(modName); - Program.LoadOrder.AddMergedModIfMissing(); - Program.LoadOrder.Save(); - - SetStylesForCustomLoadOrder(); - - var fileNode = RightClickedNode.Parent; - - if ((fileNode.Parent.Tag as ModFileCategory).IsSupported) - { - fileNode.Checked = fileNode.GetTreeNodes() - .Where(modNode => modNode.IsCheckBoxVisible()) - .All(modNode => modNode.Checked); - } - - Program.MainForm.EnableMergeIfValidSelection(); - } - - void ContextRemoveFromCustomLoadOrder(object sender, EventArgs e) - { - Program.LoadOrder.Refresh(); - - var modName = RightClickedNode.Text; - - var index = Program.LoadOrder.Mods.FindIndex(setting => setting.ModName.EqualsIgnoreCase(modName)); - - if (index > -1) - { - Program.LoadOrder.Mods.RemoveAt(index); - Program.LoadOrder.Save(); - } - - SetStylesForCustomLoadOrder(); - } - - internal void SetStylesForCustomLoadOrder() - { - foreach (var fileNode in FileNodes) - { - var modNames = fileNode.GetTreeNodes().Select(modNode => modNode.Text); - - var isResolved = Program.LoadOrder.HasResolvedConflict(modNames); - - var topPriorityMod = - isResolved - ? Program.LoadOrder.GetTopPriorityEnabledMod(modNames) - : null; - - fileNode.ForeColor = - isResolved - ? ResolvedForeColor - : UnresolvedForeColor; - - foreach (var modNode in fileNode.GetTreeNodes()) - { - modNode.NodeFont = DefaultFont; - modNode.ForeColor = DefaultForeColor; - modNode.ToolTipText = ""; - - var priority = Program.LoadOrder.GetPriorityByName(modNode.Text); - - modNode.ToolTipText = - priority > -1 - ? $"Priority {priority}" - : "No Priority"; - - if (modNode.Text.EqualsIgnoreCase(topPriorityMod)) - { - if (priority > -1) - modNode.ToolTipText += " - Top priority in this conflict"; - } - else if (isResolved) - { - modNode.ToolTipText += " - Overridden by a higher-priority mod"; - modNode.ForeColor = Color.Gray; - } - - if (Program.LoadOrder.IsModDisabledByName(modNode.Text)) - { - modNode.ToolTipText = "This mod is disabled in your custom load order"; - modNode.ForeColor = Color.Gray; - modNode.SetFontStyle(FontStyle.Strikeout); - modNode.Checked = false; - modNode.SetIsCheckBoxVisible(false); - } - else if ((fileNode.Parent.Tag as ModFileCategory).IsSupported) - modNode.SetIsCheckBoxVisible(true); - - var mergeFile = Program.Inventory - ?.GetMergeByRelativePath(fileNode.Text) - ?.GetHashByModName(modNode.Text); - if (mergeFile != null && mergeFile.IsOutdated) - { - modNode.ToolTipText += " - CHANGED SINCE MERGE"; - modNode.SetFontStyle(FontStyle.Italic); - } - } - } - } - } + class ConflictTree : SMTree + { + #region Members + + public static readonly Color UnresolvedForeColor = Color.Red; + public static readonly Color ResolvedForeColor = Color.Purple; + + public static readonly new Color FileNodeForeColor = UnresolvedForeColor; + + ToolStripSeparator _contextCustomLoadOrderSeparator = new ToolStripSeparator(); + ToolStripMenuItem _contextPrioritizeMod = new ToolStripMenuItem(); + ToolStripMenuItem _contextToggleMod = new ToolStripMenuItem(); + ToolStripMenuItem _contextRemoveFromCustomLoadOrder = new ToolStripMenuItem(); + + #endregion + + public ConflictTree() + { + ContextNodeRegion.Items.AddRange(new ToolStripItem[] + { + _contextCustomLoadOrderSeparator, + _contextPrioritizeMod, + _contextToggleMod, + _contextRemoveFromCustomLoadOrder + }); + BuildContextMenu(); + + // contextCustomLoadOrderSeparator + _contextCustomLoadOrderSeparator.Name = "contextCustomLoadOrderSeparator"; + _contextCustomLoadOrderSeparator.Size = new Size(235, 6); + + // contextPrioritizeMod + _contextPrioritizeMod.Name = "contextPrioritizeMod"; + _contextPrioritizeMod.Size = new Size(225, 22); + _contextPrioritizeMod.Text = "Set Overall Mod Priority..."; + _contextPrioritizeMod.ToolTipText = "Lets you define the load order of your mods"; + _contextPrioritizeMod.Click += ContextPrioritizeMod; + + // contextToggleMod + _contextToggleMod.Name = "contextToggleMod"; + _contextToggleMod.Size = new Size(225, 22); + _contextToggleMod.ToolTipText = "Tells the game whether to load any of this mod's files"; + _contextToggleMod.Click += ContextToggleMod; + + // contextRemoveFromCustomLoadOrder + _contextRemoveFromCustomLoadOrder.Name = "contextRemoveFromCustomLoadOrder"; + _contextRemoveFromCustomLoadOrder.Size = new Size(225, 22); + _contextRemoveFromCustomLoadOrder.ToolTipText = "Removes this mod's custom load order settings"; + _contextRemoveFromCustomLoadOrder.Click += ContextRemoveFromCustomLoadOrder; + } + + protected override void HandleCheckedChange() + { + if (IsCategoryNode(ClickedNode)) + { + foreach (var fileNode in ClickedNode.GetTreeNodes()) + { + fileNode.SetCheckedIfVisible(ClickedNode.Checked); + foreach (var modNode in fileNode.GetTreeNodes()) + modNode.SetCheckedIfVisible(ClickedNode.Checked); + } + } + else if (IsFileNode(ClickedNode)) + { + foreach (var modNode in ClickedNode.GetTreeNodes()) + modNode.SetCheckedIfVisible(ClickedNode.Checked); + + var catNode = ClickedNode.Parent; + catNode.Checked = catNode.AreAllVisibleCheckboxesChecked(); + } + else if (IsModNode(ClickedNode)) + { + var fileNode = ClickedNode.Parent; + fileNode.Checked = fileNode.AreAllVisibleCheckboxesChecked(); + + var catNode = fileNode.Parent; + catNode.Checked = catNode.AreAllVisibleCheckboxesChecked(); + } + Program.MainForm.EnableMergeIfValidSelection(); + } + + protected override void OnLeftMouseUp(MouseEventArgs e) + { + if (ClickedNode == null) + return; + + TreeNode catNode; + if (IsCategoryNode(ClickedNode)) + catNode = ClickedNode; + else if (IsFileNode(ClickedNode)) + catNode = ClickedNode.Parent; + else + catNode = ClickedNode.Parent.Parent; + + var category = catNode.Tag as ModFileCategory; + if (!category.IsSupported) + { + EndUpdate(); + IsUpdating = false; + } + else + base.OnLeftMouseUp(e); + } + + protected override void SetAllChecked(bool isChecked) + { + foreach (var catNode in CategoryNodes) + { + var category = catNode.Tag as ModFileCategory; + if (!category.IsSupported) + continue; + catNode.Checked = isChecked; + foreach (var fileNode in catNode.GetTreeNodes()) + { + fileNode.Checked = isChecked; + foreach (var modNode in fileNode.GetTreeNodes()) + modNode.SetCheckedIfVisible(isChecked); + } + } + Program.MainForm.EnableMergeIfValidSelection(); + } + + protected override void SetContextItemAvailability() + { + base.SetContextItemAvailability(); + + if (ClickedNode != null) + { + if (IsModNode(ClickedNode)) + { + _contextCustomLoadOrderSeparator.Available = true; + _contextPrioritizeMod.Available = true; + + foreach (var item in ContextNodeRegion.Items.Cast()) + { + item.Enabled = Program.LoadOrder.IsValid; + } + + var isDisabled = Program.LoadOrder.IsModDisabledByName(ClickedNode.Text); + + _contextToggleMod.Available = true; + _contextToggleMod.Text = + isDisabled + ? "Enable Mod" + : "Disable Mod"; + + _contextRemoveFromCustomLoadOrder.Available = Program.LoadOrder.ContainsMod(ClickedNode.Text); + _contextRemoveFromCustomLoadOrder.Text = + isDisabled + ? "Clear Priority && Disabled State" + : "Clear Priority"; + } + } + else if (!this.IsEmpty()) + { + ContextSelectAll.Available = CategoryNodes.Any(catNode => !catNode.Checked && (catNode.Tag as ModFileCategory).IsSupported); + + ContextDeselectAll.Available = ModNodes.Any(modNode => modNode.Checked); + } + } + + void ContextPrioritizeMod(object sender, EventArgs e) + { + RightClickedNode.BackColor = Color.Gainsboro; + + var modName = RightClickedNode.Text; + int? inputVal; + + using (var prompt = new PriorityPrompt()) + { + inputVal = prompt.ShowDialog(Program.LoadOrder.GetPriorityByName(modName)); + } + + RightClickedNode.BackColor = Color.Transparent; + + if (!inputVal.HasValue) + return; + + Program.LoadOrder.Refresh(); + Program.LoadOrder.SetPriorityByName(modName, inputVal.Value); + Program.LoadOrder.AddMergedModIfMissing(); + Program.LoadOrder.Save(); + + SetStylesForCustomLoadOrder(); + } + + void ContextToggleMod(object sender, EventArgs e) + { + var modName = RightClickedNode.Text; + + Program.LoadOrder.Refresh(); + Program.LoadOrder.ToggleModByName(modName); + Program.LoadOrder.AddMergedModIfMissing(); + Program.LoadOrder.Save(); + + SetStylesForCustomLoadOrder(); + + var fileNode = RightClickedNode.Parent; + + if ((fileNode.Parent.Tag as ModFileCategory).IsSupported) + { + fileNode.Checked = fileNode.GetTreeNodes() + .Where(modNode => modNode.IsCheckBoxVisible()) + .All(modNode => modNode.Checked); + } + + Program.MainForm.EnableMergeIfValidSelection(); + } + + void ContextRemoveFromCustomLoadOrder(object sender, EventArgs e) + { + Program.LoadOrder.Refresh(); + + var modName = RightClickedNode.Text; + + var index = Program.LoadOrder.Mods.FindIndex(setting => setting.ModName.EqualsIgnoreCase(modName)); + + if (index > -1) + { + Program.LoadOrder.Mods.RemoveAt(index); + Program.LoadOrder.Save(); + } + + SetStylesForCustomLoadOrder(); + } + + internal void SetStylesForCustomLoadOrder() + { + foreach (var fileNode in FileNodes) + { + var modNames = fileNode.GetTreeNodes().Select(modNode => modNode.Text); + + var isResolved = Program.LoadOrder.HasResolvedConflict(modNames); + + var topPriorityMod = + isResolved + ? Program.LoadOrder.GetTopPriorityEnabledMod(modNames) + : null; + + fileNode.ForeColor = + isResolved + ? ResolvedForeColor + : UnresolvedForeColor; + + foreach (var modNode in fileNode.GetTreeNodes()) + { + modNode.NodeFont = DefaultFont; + modNode.ForeColor = DefaultForeColor; + modNode.ToolTipText = ""; + + var priority = Program.LoadOrder.GetPriorityByName(modNode.Text); + + modNode.ToolTipText = + priority > -1 + ? $"Priority {priority}" + : "No Priority"; + + if (modNode.Text.EqualsIgnoreCase(topPriorityMod)) + { + if (priority > -1) + modNode.ToolTipText += " - Top priority in this conflict"; + } + else if (isResolved) + { + modNode.ToolTipText += " - Overridden by a higher-priority mod"; + modNode.ForeColor = Color.Gray; + } + + if (Program.LoadOrder.IsModDisabledByName(modNode.Text)) + { + modNode.ToolTipText = "This mod is disabled in your custom load order"; + modNode.ForeColor = Color.Gray; + modNode.SetFontStyle(FontStyle.Strikeout); + modNode.Checked = false; + modNode.SetIsCheckBoxVisible(false); + } + else if ((fileNode.Parent.Tag as ModFileCategory).IsSupported) + modNode.SetIsCheckBoxVisible(true); + + var mergeFile = Program.Inventory + ?.GetMergeByRelativePath(fileNode.Text) + ?.GetHashByModName(modNode.Text); + if (mergeFile != null && mergeFile.IsOutdated) + { + modNode.ToolTipText += " - CHANGED SINCE MERGE"; + modNode.SetFontStyle(FontStyle.Italic); + } + } + } + } + } } diff --git a/WitcherScriptMerger/Controls/MergeTree.cs b/WitcherScriptMerger/Controls/MergeTree.cs index 5f76482..fb328f2 100644 --- a/WitcherScriptMerger/Controls/MergeTree.cs +++ b/WitcherScriptMerger/Controls/MergeTree.cs @@ -5,154 +5,154 @@ namespace WitcherScriptMerger.Controls { - class MergeTree : SMTree - { - #region Members - - public static readonly new Color FileNodeForeColor = Color.Blue; - - ToolStripMenuItem _contextOpenMergedFile = new ToolStripMenuItem(); - ToolStripMenuItem _contextOpenMergedFileDir = new ToolStripMenuItem(); - ToolStripMenuItem _contextDeleteAssociatedMerges = new ToolStripMenuItem(); - ToolStripMenuItem _contextDeleteMerge = new ToolStripMenuItem(); - ToolStripSeparator _contextDeleteSeparator = new ToolStripSeparator(); - - #endregion - - public MergeTree() - { - - ContextOpenRegion.Items.AddRange(new ToolStripItem[] - { - _contextOpenMergedFile, - _contextOpenMergedFileDir - }); - ContextNodeRegion.Items.AddRange(new ToolStripItem[] - { - _contextDeleteSeparator, - _contextDeleteMerge, - _contextDeleteAssociatedMerges - }); - BuildContextMenu(); - - // contextOpenMergedFile - _contextOpenMergedFile.Name = "contextOpenMergedFile"; - _contextOpenMergedFile.Size = new Size(225, 22); - _contextOpenMergedFile.Text = "Open Merged File"; - _contextOpenMergedFile.ToolTipText = "Opens the merged version of the file"; - _contextOpenMergedFile.Click += ContextOpenFile_Click; - - // contextOpenMergedFileDir - _contextOpenMergedFileDir.Name = "contextOpenMergedFileDir"; - _contextOpenMergedFileDir.Size = new Size(225, 22); - _contextOpenMergedFileDir.Text = "Open Merged File Directory"; - _contextOpenMergedFileDir.ToolTipText = "Opens the location of the merged version of the file"; - _contextOpenMergedFileDir.Click += ContextOpenDirectory_Click; - - // contextDeleteSeparator - _contextDeleteSeparator.Name = "contextDeleteSeparator"; - _contextDeleteSeparator.Size = new Size(235, 6); - - // contextDeleteMerge - _contextDeleteMerge.Name = "contextDeleteMerge"; - _contextDeleteMerge.Size = new Size(225, 22); - _contextDeleteMerge.Text = "Delete This Merge"; - _contextDeleteMerge.ToolTipText = "Deletes the merged version of the file"; - _contextDeleteMerge.Click += ContextDeleteMerge_Click; - - // contextDeleteAssociatedMerges - _contextDeleteAssociatedMerges.Name = "contextDeleteAssociatedMerges"; - _contextDeleteAssociatedMerges.Size = new Size(225, 22); - _contextDeleteAssociatedMerges.Text = "Delete All {0} Merges"; - _contextDeleteAssociatedMerges.ToolTipText = "Deletes all merges that contain this mod's files"; - _contextDeleteAssociatedMerges.Click += ContextDeleteAssociatedMerges_Click; - } - - protected override void HandleCheckedChange() - { - if (IsCategoryNode(ClickedNode)) - { - foreach (var fileNode in ClickedNode.GetTreeNodes()) - fileNode.Checked = ClickedNode.Checked; - } - else if (IsFileNode(ClickedNode)) - { - var catNode = ClickedNode.Parent; - catNode.Checked = catNode.GetTreeNodes().All(node => node.Checked); - } - Program.MainForm.EnableUnmergeIfValidSelection(); - } - - protected override void OnLeftMouseUp(MouseEventArgs e) - { - if (ClickedNode != null && IsModNode(ClickedNode)) - ClickedNode = ClickedNode.Parent; - - base.OnLeftMouseUp(e); - } - - protected override void SetAllChecked(bool isChecked) - { - foreach (var catNode in CategoryNodes) - { - catNode.Checked = isChecked; - foreach (var fileNode in catNode.GetTreeNodes()) - fileNode.Checked = isChecked; - } - Program.MainForm.EnableUnmergeIfValidSelection(); - } - - void ContextDeleteMerge_Click(object sender, EventArgs e) - { - if (RightClickedNode == null) - return; - - if (IsModNode(RightClickedNode)) - Program.MainForm.DeleteMerges(new TreeNode[] { RightClickedNode.Parent }); - else - Program.MainForm.DeleteMerges(new TreeNode[] { RightClickedNode }); - } - - void ContextDeleteAssociatedMerges_Click(object sender, EventArgs e) - { - if (RightClickedNode == null || !IsModNode(RightClickedNode)) - return; - - // Find all file nodes that contain a node matching the clicked node - var fileNodes = FileNodes.Where(node => - node.GetTreeNodes().Any(modNode => - modNode.Text == RightClickedNode.Text)); - - Program.MainForm.DeleteMerges(fileNodes); - } - - protected override void SetContextItemAvailability() - { - base.SetContextItemAvailability(); - - if (ClickedNode != null) - { - if (ClickedNode.Tag != null && IsFileNode(ClickedNode)) - { - _contextOpenMergedFile.Available = _contextOpenMergedFileDir.Available = true; - } - - if (!IsCategoryNode(ClickedNode)) - { - _contextDeleteMerge.Available = _contextDeleteSeparator.Available = true; - if (IsModNode(ClickedNode)) - { - _contextDeleteAssociatedMerges.Available = true; - _contextDeleteAssociatedMerges.Text = $"Delete All {ClickedNode.Text} Merges"; - } - } - } - else if (!this.IsEmpty()) - { - ContextSelectAll.Available = CategoryNodes.Any(catNode => !catNode.Checked); - - ContextDeselectAll.Available = FileNodes.Any(fileNode => fileNode.Checked); - } - } - } + class MergeTree : SMTree + { + #region Members + + public static readonly new Color FileNodeForeColor = Color.Blue; + + ToolStripMenuItem _contextOpenMergedFile = new ToolStripMenuItem(); + ToolStripMenuItem _contextOpenMergedFileDir = new ToolStripMenuItem(); + ToolStripMenuItem _contextDeleteAssociatedMerges = new ToolStripMenuItem(); + ToolStripMenuItem _contextDeleteMerge = new ToolStripMenuItem(); + ToolStripSeparator _contextDeleteSeparator = new ToolStripSeparator(); + + #endregion + + public MergeTree() + { + + ContextOpenRegion.Items.AddRange(new ToolStripItem[] + { + _contextOpenMergedFile, + _contextOpenMergedFileDir + }); + ContextNodeRegion.Items.AddRange(new ToolStripItem[] + { + _contextDeleteSeparator, + _contextDeleteMerge, + _contextDeleteAssociatedMerges + }); + BuildContextMenu(); + + // contextOpenMergedFile + _contextOpenMergedFile.Name = "contextOpenMergedFile"; + _contextOpenMergedFile.Size = new Size(225, 22); + _contextOpenMergedFile.Text = "Open Merged File"; + _contextOpenMergedFile.ToolTipText = "Opens the merged version of the file"; + _contextOpenMergedFile.Click += ContextOpenFile_Click; + + // contextOpenMergedFileDir + _contextOpenMergedFileDir.Name = "contextOpenMergedFileDir"; + _contextOpenMergedFileDir.Size = new Size(225, 22); + _contextOpenMergedFileDir.Text = "Open Merged File Directory"; + _contextOpenMergedFileDir.ToolTipText = "Opens the location of the merged version of the file"; + _contextOpenMergedFileDir.Click += ContextOpenDirectory_Click; + + // contextDeleteSeparator + _contextDeleteSeparator.Name = "contextDeleteSeparator"; + _contextDeleteSeparator.Size = new Size(235, 6); + + // contextDeleteMerge + _contextDeleteMerge.Name = "contextDeleteMerge"; + _contextDeleteMerge.Size = new Size(225, 22); + _contextDeleteMerge.Text = "Delete This Merge"; + _contextDeleteMerge.ToolTipText = "Deletes the merged version of the file"; + _contextDeleteMerge.Click += ContextDeleteMerge_Click; + + // contextDeleteAssociatedMerges + _contextDeleteAssociatedMerges.Name = "contextDeleteAssociatedMerges"; + _contextDeleteAssociatedMerges.Size = new Size(225, 22); + _contextDeleteAssociatedMerges.Text = "Delete All {0} Merges"; + _contextDeleteAssociatedMerges.ToolTipText = "Deletes all merges that contain this mod's files"; + _contextDeleteAssociatedMerges.Click += ContextDeleteAssociatedMerges_Click; + } + + protected override void HandleCheckedChange() + { + if (IsCategoryNode(ClickedNode)) + { + foreach (var fileNode in ClickedNode.GetTreeNodes()) + fileNode.Checked = ClickedNode.Checked; + } + else if (IsFileNode(ClickedNode)) + { + var catNode = ClickedNode.Parent; + catNode.Checked = catNode.GetTreeNodes().All(node => node.Checked); + } + Program.MainForm.EnableUnmergeIfValidSelection(); + } + + protected override void OnLeftMouseUp(MouseEventArgs e) + { + if (ClickedNode != null && IsModNode(ClickedNode)) + ClickedNode = ClickedNode.Parent; + + base.OnLeftMouseUp(e); + } + + protected override void SetAllChecked(bool isChecked) + { + foreach (var catNode in CategoryNodes) + { + catNode.Checked = isChecked; + foreach (var fileNode in catNode.GetTreeNodes()) + fileNode.Checked = isChecked; + } + Program.MainForm.EnableUnmergeIfValidSelection(); + } + + void ContextDeleteMerge_Click(object sender, EventArgs e) + { + if (RightClickedNode == null) + return; + + if (IsModNode(RightClickedNode)) + Program.MainForm.DeleteMerges(new TreeNode[] { RightClickedNode.Parent }); + else + Program.MainForm.DeleteMerges(new TreeNode[] { RightClickedNode }); + } + + void ContextDeleteAssociatedMerges_Click(object sender, EventArgs e) + { + if (RightClickedNode == null || !IsModNode(RightClickedNode)) + return; + + // Find all file nodes that contain a node matching the clicked node + var fileNodes = FileNodes.Where(node => + node.GetTreeNodes().Any(modNode => + modNode.Text == RightClickedNode.Text)); + + Program.MainForm.DeleteMerges(fileNodes); + } + + protected override void SetContextItemAvailability() + { + base.SetContextItemAvailability(); + + if (ClickedNode != null) + { + if (ClickedNode.Tag != null && IsFileNode(ClickedNode)) + { + _contextOpenMergedFile.Available = _contextOpenMergedFileDir.Available = true; + } + + if (!IsCategoryNode(ClickedNode)) + { + _contextDeleteMerge.Available = _contextDeleteSeparator.Available = true; + if (IsModNode(ClickedNode)) + { + _contextDeleteAssociatedMerges.Available = true; + _contextDeleteAssociatedMerges.Text = $"Delete All {ClickedNode.Text} Merges"; + } + } + } + else if (!this.IsEmpty()) + { + ContextSelectAll.Available = CategoryNodes.Any(catNode => !catNode.Checked); + + ContextDeselectAll.Available = FileNodes.Any(fileNode => fileNode.Checked); + } + } + } } diff --git a/WitcherScriptMerger/Controls/SMTree.cs b/WitcherScriptMerger/Controls/SMTree.cs index 47b6f1e..58881d5 100644 --- a/WitcherScriptMerger/Controls/SMTree.cs +++ b/WitcherScriptMerger/Controls/SMTree.cs @@ -10,497 +10,497 @@ namespace WitcherScriptMerger.Controls { - abstract class SMTree : TreeView - { - #region Types + abstract class SMTree : TreeView + { + #region Types - public enum LevelType : int - { - Categories, Files, Mods - } + public enum LevelType : int + { + Categories, Files, Mods + } - public class NodeMetadata - { - public string FilePath; - public FileHash FileHash; - public ModFile ModFile; - } - - #endregion - - #region Members - - public static readonly Color FileNodeForeColor = Color.Black; - - public List CategoryNodes => GetNodesAtLevel(LevelType.Categories); - - public List FileNodes => GetNodesAtLevel(LevelType.Files); - - public List ModNodes => GetNodesAtLevel(LevelType.Mods); - - protected TreeNode ClickedNode = null; - protected bool IsUpdating = false; - - Color _clickedNodeForeColor; - - #endregion - - #region Double-buffering - - // From http://stackoverflow.com/a/10364283/1641069 - // Pinvoke: - private const int TVM_SETEXTENDEDSTYLE = 0x1100 + 44; - private const int TVM_GETEXTENDEDSTYLE = 0x1100 + 45; - private const int TVS_EX_DOUBLEBUFFER = 0x0004; - [DllImport("user32.dll")] - private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wp, IntPtr lp); - - protected override void OnHandleCreated(EventArgs e) - { - SendMessage(this.Handle, TVM_SETEXTENDEDSTYLE, (IntPtr)TVS_EX_DOUBLEBUFFER, (IntPtr)TVS_EX_DOUBLEBUFFER); - base.OnHandleCreated(e); - } - - #endregion - - #region Context Menu Members - - protected TreeNode RightClickedNode; - - ContextMenuStrip _contextMenu; - - protected ToolStripRegion ContextOpenRegion; - ToolStripMenuItem _contextOpenModFile = new ToolStripMenuItem(); - ToolStripMenuItem _contextOpenModFileDir = new ToolStripMenuItem(); - ToolStripMenuItem _contextOpenModBundleDir = new ToolStripMenuItem(); - ToolStripMenuItem _contextOpenVanillaFile = new ToolStripMenuItem(); - ToolStripMenuItem _contextOpenVanillaFileDir = new ToolStripMenuItem(); - ToolStripMenuItem _contextCopyPath = new ToolStripMenuItem(); - - protected ToolStripRegion ContextNodeRegion; - - protected ToolStripRegion ContextAllRegion; - ToolStripSeparator _contextAllSeparator = new ToolStripSeparator(); - ToolStripMenuItem _contextExpandAll = new ToolStripMenuItem(); - ToolStripMenuItem _contextCollapseAll = new ToolStripMenuItem(); - protected ToolStripMenuItem ContextSelectAll = new ToolStripMenuItem(); - protected ToolStripMenuItem ContextDeselectAll = new ToolStripMenuItem(); - - #endregion - - public SMTree() - { - InitializeContextMenu(); - TreeViewNodeSorter = new SMTreeSorter(); - } - - protected List GetNodesAtLevel(LevelType level) - { - var nodes = Nodes.Cast(); - for (int i = 0; i < (int)level; ++i) - nodes = nodes.SelectMany(node => node.GetTreeNodes()); - return nodes.ToList(); - } - - public TreeNode GetCategoryNode(ModFileCategory category) - { - return CategoryNodes.FirstOrDefault(node => - category == (ModFileCategory)node.Tag); - } - - public void SetFontBold(LevelType level) - { - BeginUpdate(); - foreach (var node in GetNodesAtLevel(level)) - node.SetFontStyle(FontStyle.Bold); - EndUpdate(); - } - - protected override void OnKeyDown(KeyEventArgs e) - { - base.OnKeyDown(e); - if (e.Control) - { - if (e.KeyCode == Keys.A) - ContextSelectAll_Click(null, null); - else if (e.KeyCode == Keys.D) - ContextDeselectAll_Click(null, null); - } - } - - protected override void OnAfterSelect(TreeViewEventArgs e) - { - base.OnAfterSelect(e); - SelectedNode = null; - } - - protected override void OnMouseDown(MouseEventArgs e) - { - base.OnMouseDown(e); - ClickedNode = GetNodeAt(e.Location); - if (ClickedNode != null) - { - if (!ClickedNode.Bounds.Contains(e.Location)) - ClickedNode = null; - else if (e.Button == MouseButtons.Left) - { - _clickedNodeForeColor = ClickedNode.ForeColor; - ClickedNode.ForeColor = Color.White; - ClickedNode.BackColor = Color.CornflowerBlue; - } - } - - if (e.Button == MouseButtons.Right) - { - BeginUpdate(); - IsUpdating = true; - } - } - - protected override void OnMouseMove(MouseEventArgs e) - { - base.OnMouseMove(e); - if (ClickedNode == null || RightClickedNode != null || e.Button == MouseButtons.Right) - return; - if (ClickedNode.Bounds.Contains(e.Location)) - { - ClickedNode.BackColor = Color.CornflowerBlue; - ClickedNode.ForeColor = Color.White; - } - else - { - ClickedNode.ForeColor = _clickedNodeForeColor; - ClickedNode.BackColor = Color.Transparent; - } - } - - protected override void OnMouseUp(MouseEventArgs e) - { - base.OnMouseUp(e); - - var lastClicked = ClickedNode; - ClickedNode = GetNodeAt(e.Location); - if (ClickedNode != null && - (lastClicked != ClickedNode || !ClickedNode.Bounds.Contains(e.Location))) - ClickedNode = null; - - if (e.Button == MouseButtons.Left) - { - OnLeftMouseUp(e); - if (lastClicked != null && ClickedNode != null) - { - lastClicked.ForeColor = _clickedNodeForeColor; - lastClicked.BackColor = Color.Transparent; - } - ClickedNode = null; - } - else if (e.Button == MouseButtons.Right) - OnRightMouseUp(e); - EndUpdate(); - IsUpdating = false; - } - - protected virtual void OnLeftMouseUp(MouseEventArgs e) - { - if (ClickedNode == null) - return; - if (ClickedNode.SetCheckedIfVisible(!ClickedNode.Checked)) - HandleCheckedChange(); - } - - protected virtual void OnRightMouseUp(MouseEventArgs e) - { - ResetContextItemAvailability(); - SetContextItemAvailability(); - - if (_contextMenu.Items.OfType().Any(item => item.Available)) - { - if (ClickedNode != null) - ClickedNode.BackColor = Color.Gainsboro; - - SetContextMenuSize(); - - _contextMenu.Show(this, e.X, e.Y); - } - } - - protected override void OnAfterCheck(TreeViewEventArgs e) - { - base.OnAfterCheck(e); - if (e.Action != TreeViewAction.Unknown) // Event was triggered programmatically - { - ClickedNode = e.Node; - HandleCheckedChange(); - } - } - - protected abstract void HandleCheckedChange(); - - protected override void OnMouseLeave(EventArgs e) - { - base.OnMouseLeave(e); - - if (IsUpdating) - EndUpdate(); - } - - protected bool IsCategoryNode(TreeNode node) => ((LevelType)node.Level == LevelType.Categories); - - protected bool IsFileNode(TreeNode node) => ((LevelType)node.Level == LevelType.Files); - - protected bool IsModNode(TreeNode node) => ((LevelType)node.Level == LevelType.Mods); - - #region Context Menu - - void InitializeContextMenu() - { - _contextMenu = new ContextMenuStrip(); - - ContextOpenRegion = new ToolStripRegion(_contextMenu as ToolStrip, new ToolStripItem[] - { - _contextCopyPath, - _contextOpenModFile, - _contextOpenModFileDir, - _contextOpenModBundleDir, - _contextOpenVanillaFile, - _contextOpenVanillaFileDir - }); - - ContextNodeRegion = new ToolStripRegion(_contextMenu as ToolStrip, new ToolStripItem[0]); - - ContextAllRegion = new ToolStripRegion(_contextMenu as ToolStrip, new ToolStripItem[] - { - _contextAllSeparator, - ContextSelectAll, - ContextDeselectAll, - _contextExpandAll, - _contextCollapseAll - }); - - // treeContextMenu - _contextMenu.AutoSize = false; - _contextMenu.Name = "treeContextMenu"; - _contextMenu.Size = new Size(239, 390); - _contextMenu.Closing += ContextMenu_Closing; - - // contextOpenModFile - _contextOpenModFile.Name = "contextOpenModFile"; - _contextOpenModFile.Size = new Size(225, 22); - _contextOpenModFile.Text = "Open Mod File"; - _contextOpenModFile.ToolTipText = "Opens this mod's version of the file"; - _contextOpenModFile.Click += ContextOpenFile_Click; - - // contextOpenModFileDir - _contextOpenModFileDir.Name = "contextOpenModFileDir"; - _contextOpenModFileDir.Size = new Size(225, 22); - _contextOpenModFileDir.Text = "Open Mod File Directory"; - _contextOpenModFileDir.ToolTipText = "Opens the location of this mod's version of the file"; - _contextOpenModFileDir.Click += ContextOpenDirectory_Click; - - // contextOpenModBundleDir - _contextOpenModBundleDir.Name = "contextOpenModBundleDir"; - _contextOpenModBundleDir.Size = new Size(225, 22); - _contextOpenModBundleDir.Text = "Open Mod Bundle Directory"; - _contextOpenModBundleDir.ToolTipText = "Opens the location of this mod's bundle file"; - _contextOpenModBundleDir.Click += ContextOpenDirectory_Click; - - // contextOpenVanillaFile - _contextOpenVanillaFile.Name = "contextOpenVanillaFile"; - _contextOpenVanillaFile.Size = new Size(225, 22); - _contextOpenVanillaFile.Text = "Open Vanilla File"; - _contextOpenVanillaFile.ToolTipText = "Opens the unmodded version of the file"; - _contextOpenVanillaFile.Click += ContextOpenVanillaFile_Click; - - // contextOpenVanillaFileDir - _contextOpenVanillaFileDir.Name = "contextOpenVanillaFileDir"; - _contextOpenVanillaFileDir.Size = new Size(225, 22); - _contextOpenVanillaFileDir.Text = "Open Vanilla File Directory"; - _contextOpenVanillaFileDir.ToolTipText = "Opens the location of the unmodded version of the file"; - _contextOpenVanillaFileDir.Click += ContextOpenVanillaDirectory_Click; - - // contextCopyPath - _contextCopyPath.Name = "contextCopyPath"; - _contextCopyPath.Size = new Size(225, 22); - _contextCopyPath.Text = "Copy Path"; - _contextCopyPath.Click += ContextCopyPath_Click; - - // contextAllSeparator - _contextAllSeparator.Name = "contextAllSeparator"; - _contextAllSeparator.Size = new Size(235, 6); - - // contextSelectAll - ContextSelectAll.Name = "contextSelectAll"; - ContextSelectAll.Size = new Size(225, 22); - ContextSelectAll.Text = "Select All"; - ContextSelectAll.Click += ContextSelectAll_Click; - - // contextDeselectAll - ContextDeselectAll.Name = "contextDeselectAll"; - ContextDeselectAll.Size = new Size(225, 22); - ContextDeselectAll.Text = "Deselect All"; - ContextDeselectAll.Click += ContextDeselectAll_Click; - - // contextExpandAll - _contextExpandAll.Name = "contextExpandAll"; - _contextExpandAll.Size = new Size(225, 22); - _contextExpandAll.Text = "Expand All"; - _contextExpandAll.Click += ContextExpandAll_Click; - - // contextCollapseAll - _contextCollapseAll.Name = "contextCollapseAll"; - _contextCollapseAll.Size = new Size(225, 22); - _contextCollapseAll.Text = "Collapse All"; - _contextCollapseAll.Click += ContextCollapseAll_Click; - } - - protected void BuildContextMenu() - { - _contextMenu.Items.Clear(); - _contextMenu.Items.AddRange(ContextOpenRegion.Items); - _contextMenu.Items.AddRange(ContextNodeRegion.Items); - _contextMenu.Items.AddRange(ContextAllRegion.Items); - } - - void ResetContextItemAvailability() - { - foreach (var menuItem in _contextMenu.Items.OfType()) - menuItem.Available = false; - } - - protected virtual void SetContextItemAvailability() - { - foreach (var menuItem in _contextMenu.Items.OfType()) - menuItem.Available = false; - - if (ClickedNode != null && ClickedNode.Tag is NodeMetadata) - { - _contextCopyPath.Available = true; - if (IsFileNode(ClickedNode) - && !((ModFileCategory)ClickedNode.Parent.Tag).IsBundled - && File.Exists((ClickedNode.Tag as NodeMetadata).ModFile.GetVanillaFile())) - { - _contextOpenVanillaFile.Available = true; - _contextOpenVanillaFileDir.Available = true; - } - else if (IsModNode(ClickedNode)) - { - if (ClickedNode.GetMetadata().ModFile.IsBundleContent) - _contextOpenModBundleDir.Available = true; - else - _contextOpenModFile.Available = _contextOpenModFileDir.Available = true; - } - } - - if (ClickedNode == null && !this.IsEmpty()) - { - _contextExpandAll.Available = - CategoryNodes.Any(catNode => !catNode.IsExpanded) - || FileNodes.Any(fileNode => !fileNode.IsExpanded); - - _contextCollapseAll.Available = CategoryNodes.Any(node => node.IsExpanded); - - _contextAllSeparator.Visible = - (_contextExpandAll.Available || _contextCollapseAll.Available) - && (ContextOpenRegion.Available || ContextNodeRegion.Available); - } - } - - void SetContextMenuSize() - { - if (_contextMenu.Items.OfType().Any(item => item.Available)) - { - var width = _contextMenu.Items.OfType().Where(item => item.Available) - .Max(item => TextRenderer.MeasureText(item.Text, item.Font).Width); - var height = _contextMenu.GetAvailableItems() - .Sum(item => item.Height); - _contextMenu.Width = width + 45; - _contextMenu.Height = height + 5; - } - } - - protected void ContextOpenFile_Click(object sender, EventArgs e) - { - if (RightClickedNode == null) - return; - - Program.TryOpenFile(RightClickedNode.GetMetadata().FilePath); - - RightClickedNode = null; - } - - protected void ContextOpenDirectory_Click(object sender, EventArgs e) - { - if (RightClickedNode == null) - return; - - Program.TryOpenFileLocation(RightClickedNode.GetMetadata().FilePath); - - RightClickedNode = null; - } - - protected void ContextOpenVanillaFile_Click(object sender, EventArgs e) - { - if (RightClickedNode == null) - return; - - Program.TryOpenFile(RightClickedNode.GetMetadata().ModFile.GetVanillaFile()); - - RightClickedNode = null; - } - - protected void ContextOpenVanillaDirectory_Click(object sender, EventArgs e) - { - if (RightClickedNode == null) - return; - - Program.TryOpenFileLocation(RightClickedNode.GetMetadata().ModFile.GetVanillaFile()); - - RightClickedNode = null; - } - - void ContextCopyPath_Click(object sender, EventArgs e) - { - if (RightClickedNode == null) - return; - - Clipboard.SetText(RightClickedNode.GetMetadata().FilePath); - - RightClickedNode = null; - } - - protected void ContextSelectAll_Click(object sender, EventArgs e) - { - SetAllChecked(true); - } - - protected void ContextDeselectAll_Click(object sender, EventArgs e) - { - SetAllChecked(false); - } - - protected abstract void SetAllChecked(bool isChecked); - - void ContextExpandAll_Click(object sender, EventArgs e) - { - ExpandAll(); - } - - void ContextCollapseAll_Click(object sender, EventArgs e) - { - CollapseAll(); - } - - void ContextMenu_Closing(object sender, ToolStripDropDownClosingEventArgs e) - { - if (ClickedNode == null) - return; - ClickedNode.BackColor = Color.Transparent; - ClickedNode.TreeView.Update(); - - RightClickedNode = ClickedNode; // Preserve reference to clicked node so context item handlers can access, - ClickedNode = null; // but clear ClickedNode so mouseover doesn't change back color. - } - - #endregion - } -} \ No newline at end of file + public class NodeMetadata + { + public string FilePath; + public FileHash FileHash; + public ModFile ModFile; + } + + #endregion + + #region Members + + public static readonly Color FileNodeForeColor = Color.Black; + + public List CategoryNodes => GetNodesAtLevel(LevelType.Categories); + + public List FileNodes => GetNodesAtLevel(LevelType.Files); + + public List ModNodes => GetNodesAtLevel(LevelType.Mods); + + protected TreeNode ClickedNode = null; + protected bool IsUpdating = false; + + Color _clickedNodeForeColor; + + #endregion + + #region Double-buffering + + // From http://stackoverflow.com/a/10364283/1641069 + // Pinvoke: + private const int TVM_SETEXTENDEDSTYLE = 0x1100 + 44; + private const int TVM_GETEXTENDEDSTYLE = 0x1100 + 45; + private const int TVS_EX_DOUBLEBUFFER = 0x0004; + [DllImport("user32.dll")] + private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wp, IntPtr lp); + + protected override void OnHandleCreated(EventArgs e) + { + SendMessage(this.Handle, TVM_SETEXTENDEDSTYLE, (IntPtr)TVS_EX_DOUBLEBUFFER, (IntPtr)TVS_EX_DOUBLEBUFFER); + base.OnHandleCreated(e); + } + + #endregion + + #region Context Menu Members + + protected TreeNode RightClickedNode; + + ContextMenuStrip _contextMenu; + + protected ToolStripRegion ContextOpenRegion; + ToolStripMenuItem _contextOpenModFile = new ToolStripMenuItem(); + ToolStripMenuItem _contextOpenModFileDir = new ToolStripMenuItem(); + ToolStripMenuItem _contextOpenModBundleDir = new ToolStripMenuItem(); + ToolStripMenuItem _contextOpenVanillaFile = new ToolStripMenuItem(); + ToolStripMenuItem _contextOpenVanillaFileDir = new ToolStripMenuItem(); + ToolStripMenuItem _contextCopyPath = new ToolStripMenuItem(); + + protected ToolStripRegion ContextNodeRegion; + + protected ToolStripRegion ContextAllRegion; + ToolStripSeparator _contextAllSeparator = new ToolStripSeparator(); + ToolStripMenuItem _contextExpandAll = new ToolStripMenuItem(); + ToolStripMenuItem _contextCollapseAll = new ToolStripMenuItem(); + protected ToolStripMenuItem ContextSelectAll = new ToolStripMenuItem(); + protected ToolStripMenuItem ContextDeselectAll = new ToolStripMenuItem(); + + #endregion + + public SMTree() + { + InitializeContextMenu(); + TreeViewNodeSorter = new SMTreeSorter(); + } + + protected List GetNodesAtLevel(LevelType level) + { + var nodes = Nodes.Cast(); + for (int i = 0; i < (int)level; ++i) + nodes = nodes.SelectMany(node => node.GetTreeNodes()); + return nodes.ToList(); + } + + public TreeNode GetCategoryNode(ModFileCategory category) + { + return CategoryNodes.FirstOrDefault(node => + category == (ModFileCategory)node.Tag); + } + + public void SetFontBold(LevelType level) + { + BeginUpdate(); + foreach (var node in GetNodesAtLevel(level)) + node.SetFontStyle(FontStyle.Bold); + EndUpdate(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Control) + { + if (e.KeyCode == Keys.A) + ContextSelectAll_Click(null, null); + else if (e.KeyCode == Keys.D) + ContextDeselectAll_Click(null, null); + } + } + + protected override void OnAfterSelect(TreeViewEventArgs e) + { + base.OnAfterSelect(e); + SelectedNode = null; + } + + protected override void OnMouseDown(MouseEventArgs e) + { + base.OnMouseDown(e); + ClickedNode = GetNodeAt(e.Location); + if (ClickedNode != null) + { + if (!ClickedNode.Bounds.Contains(e.Location)) + ClickedNode = null; + else if (e.Button == MouseButtons.Left) + { + _clickedNodeForeColor = ClickedNode.ForeColor; + ClickedNode.ForeColor = Color.White; + ClickedNode.BackColor = Color.CornflowerBlue; + } + } + + if (e.Button == MouseButtons.Right) + { + BeginUpdate(); + IsUpdating = true; + } + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + if (ClickedNode == null || RightClickedNode != null || e.Button == MouseButtons.Right) + return; + if (ClickedNode.Bounds.Contains(e.Location)) + { + ClickedNode.BackColor = Color.CornflowerBlue; + ClickedNode.ForeColor = Color.White; + } + else + { + ClickedNode.ForeColor = _clickedNodeForeColor; + ClickedNode.BackColor = Color.Transparent; + } + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + + var lastClicked = ClickedNode; + ClickedNode = GetNodeAt(e.Location); + if (ClickedNode != null && + (lastClicked != ClickedNode || !ClickedNode.Bounds.Contains(e.Location))) + ClickedNode = null; + + if (e.Button == MouseButtons.Left) + { + OnLeftMouseUp(e); + if (lastClicked != null && ClickedNode != null) + { + lastClicked.ForeColor = _clickedNodeForeColor; + lastClicked.BackColor = Color.Transparent; + } + ClickedNode = null; + } + else if (e.Button == MouseButtons.Right) + OnRightMouseUp(e); + EndUpdate(); + IsUpdating = false; + } + + protected virtual void OnLeftMouseUp(MouseEventArgs e) + { + if (ClickedNode == null) + return; + if (ClickedNode.SetCheckedIfVisible(!ClickedNode.Checked)) + HandleCheckedChange(); + } + + protected virtual void OnRightMouseUp(MouseEventArgs e) + { + ResetContextItemAvailability(); + SetContextItemAvailability(); + + if (_contextMenu.Items.OfType().Any(item => item.Available)) + { + if (ClickedNode != null) + ClickedNode.BackColor = Color.Gainsboro; + + SetContextMenuSize(); + + _contextMenu.Show(this, e.X, e.Y); + } + } + + protected override void OnAfterCheck(TreeViewEventArgs e) + { + base.OnAfterCheck(e); + if (e.Action != TreeViewAction.Unknown) // Event was triggered programmatically + { + ClickedNode = e.Node; + HandleCheckedChange(); + } + } + + protected abstract void HandleCheckedChange(); + + protected override void OnMouseLeave(EventArgs e) + { + base.OnMouseLeave(e); + + if (IsUpdating) + EndUpdate(); + } + + protected bool IsCategoryNode(TreeNode node) => ((LevelType)node.Level == LevelType.Categories); + + protected bool IsFileNode(TreeNode node) => ((LevelType)node.Level == LevelType.Files); + + protected bool IsModNode(TreeNode node) => ((LevelType)node.Level == LevelType.Mods); + + #region Context Menu + + void InitializeContextMenu() + { + _contextMenu = new ContextMenuStrip(); + + ContextOpenRegion = new ToolStripRegion(_contextMenu as ToolStrip, new ToolStripItem[] + { + _contextCopyPath, + _contextOpenModFile, + _contextOpenModFileDir, + _contextOpenModBundleDir, + _contextOpenVanillaFile, + _contextOpenVanillaFileDir + }); + + ContextNodeRegion = new ToolStripRegion(_contextMenu as ToolStrip, new ToolStripItem[0]); + + ContextAllRegion = new ToolStripRegion(_contextMenu as ToolStrip, new ToolStripItem[] + { + _contextAllSeparator, + ContextSelectAll, + ContextDeselectAll, + _contextExpandAll, + _contextCollapseAll + }); + + // treeContextMenu + _contextMenu.AutoSize = false; + _contextMenu.Name = "treeContextMenu"; + _contextMenu.Size = new Size(239, 390); + _contextMenu.Closing += ContextMenu_Closing; + + // contextOpenModFile + _contextOpenModFile.Name = "contextOpenModFile"; + _contextOpenModFile.Size = new Size(225, 22); + _contextOpenModFile.Text = "Open Mod File"; + _contextOpenModFile.ToolTipText = "Opens this mod's version of the file"; + _contextOpenModFile.Click += ContextOpenFile_Click; + + // contextOpenModFileDir + _contextOpenModFileDir.Name = "contextOpenModFileDir"; + _contextOpenModFileDir.Size = new Size(225, 22); + _contextOpenModFileDir.Text = "Open Mod File Directory"; + _contextOpenModFileDir.ToolTipText = "Opens the location of this mod's version of the file"; + _contextOpenModFileDir.Click += ContextOpenDirectory_Click; + + // contextOpenModBundleDir + _contextOpenModBundleDir.Name = "contextOpenModBundleDir"; + _contextOpenModBundleDir.Size = new Size(225, 22); + _contextOpenModBundleDir.Text = "Open Mod Bundle Directory"; + _contextOpenModBundleDir.ToolTipText = "Opens the location of this mod's bundle file"; + _contextOpenModBundleDir.Click += ContextOpenDirectory_Click; + + // contextOpenVanillaFile + _contextOpenVanillaFile.Name = "contextOpenVanillaFile"; + _contextOpenVanillaFile.Size = new Size(225, 22); + _contextOpenVanillaFile.Text = "Open Vanilla File"; + _contextOpenVanillaFile.ToolTipText = "Opens the unmodded version of the file"; + _contextOpenVanillaFile.Click += ContextOpenVanillaFile_Click; + + // contextOpenVanillaFileDir + _contextOpenVanillaFileDir.Name = "contextOpenVanillaFileDir"; + _contextOpenVanillaFileDir.Size = new Size(225, 22); + _contextOpenVanillaFileDir.Text = "Open Vanilla File Directory"; + _contextOpenVanillaFileDir.ToolTipText = "Opens the location of the unmodded version of the file"; + _contextOpenVanillaFileDir.Click += ContextOpenVanillaDirectory_Click; + + // contextCopyPath + _contextCopyPath.Name = "contextCopyPath"; + _contextCopyPath.Size = new Size(225, 22); + _contextCopyPath.Text = "Copy Path"; + _contextCopyPath.Click += ContextCopyPath_Click; + + // contextAllSeparator + _contextAllSeparator.Name = "contextAllSeparator"; + _contextAllSeparator.Size = new Size(235, 6); + + // contextSelectAll + ContextSelectAll.Name = "contextSelectAll"; + ContextSelectAll.Size = new Size(225, 22); + ContextSelectAll.Text = "Select All"; + ContextSelectAll.Click += ContextSelectAll_Click; + + // contextDeselectAll + ContextDeselectAll.Name = "contextDeselectAll"; + ContextDeselectAll.Size = new Size(225, 22); + ContextDeselectAll.Text = "Deselect All"; + ContextDeselectAll.Click += ContextDeselectAll_Click; + + // contextExpandAll + _contextExpandAll.Name = "contextExpandAll"; + _contextExpandAll.Size = new Size(225, 22); + _contextExpandAll.Text = "Expand All"; + _contextExpandAll.Click += ContextExpandAll_Click; + + // contextCollapseAll + _contextCollapseAll.Name = "contextCollapseAll"; + _contextCollapseAll.Size = new Size(225, 22); + _contextCollapseAll.Text = "Collapse All"; + _contextCollapseAll.Click += ContextCollapseAll_Click; + } + + protected void BuildContextMenu() + { + _contextMenu.Items.Clear(); + _contextMenu.Items.AddRange(ContextOpenRegion.Items); + _contextMenu.Items.AddRange(ContextNodeRegion.Items); + _contextMenu.Items.AddRange(ContextAllRegion.Items); + } + + void ResetContextItemAvailability() + { + foreach (var menuItem in _contextMenu.Items.OfType()) + menuItem.Available = false; + } + + protected virtual void SetContextItemAvailability() + { + foreach (var menuItem in _contextMenu.Items.OfType()) + menuItem.Available = false; + + if (ClickedNode != null && ClickedNode.Tag is NodeMetadata) + { + _contextCopyPath.Available = true; + if (IsFileNode(ClickedNode) + && !((ModFileCategory)ClickedNode.Parent.Tag).IsBundled + && File.Exists((ClickedNode.Tag as NodeMetadata).ModFile.GetVanillaFile())) + { + _contextOpenVanillaFile.Available = true; + _contextOpenVanillaFileDir.Available = true; + } + else if (IsModNode(ClickedNode)) + { + if (ClickedNode.GetMetadata().ModFile.IsBundleContent) + _contextOpenModBundleDir.Available = true; + else + _contextOpenModFile.Available = _contextOpenModFileDir.Available = true; + } + } + + if (ClickedNode == null && !this.IsEmpty()) + { + _contextExpandAll.Available = + CategoryNodes.Any(catNode => !catNode.IsExpanded) + || FileNodes.Any(fileNode => !fileNode.IsExpanded); + + _contextCollapseAll.Available = CategoryNodes.Any(node => node.IsExpanded); + + _contextAllSeparator.Visible = + (_contextExpandAll.Available || _contextCollapseAll.Available) + && (ContextOpenRegion.Available || ContextNodeRegion.Available); + } + } + + void SetContextMenuSize() + { + if (_contextMenu.Items.OfType().Any(item => item.Available)) + { + var width = _contextMenu.Items.OfType().Where(item => item.Available) + .Max(item => TextRenderer.MeasureText(item.Text, item.Font).Width); + var height = _contextMenu.GetAvailableItems() + .Sum(item => item.Height); + _contextMenu.Width = width + 45; + _contextMenu.Height = height + 5; + } + } + + protected void ContextOpenFile_Click(object sender, EventArgs e) + { + if (RightClickedNode == null) + return; + + Program.TryOpenFile(RightClickedNode.GetMetadata().FilePath); + + RightClickedNode = null; + } + + protected void ContextOpenDirectory_Click(object sender, EventArgs e) + { + if (RightClickedNode == null) + return; + + Program.TryOpenFileLocation(RightClickedNode.GetMetadata().FilePath); + + RightClickedNode = null; + } + + protected void ContextOpenVanillaFile_Click(object sender, EventArgs e) + { + if (RightClickedNode == null) + return; + + Program.TryOpenFile(RightClickedNode.GetMetadata().ModFile.GetVanillaFile()); + + RightClickedNode = null; + } + + protected void ContextOpenVanillaDirectory_Click(object sender, EventArgs e) + { + if (RightClickedNode == null) + return; + + Program.TryOpenFileLocation(RightClickedNode.GetMetadata().ModFile.GetVanillaFile()); + + RightClickedNode = null; + } + + void ContextCopyPath_Click(object sender, EventArgs e) + { + if (RightClickedNode == null) + return; + + Clipboard.SetText(RightClickedNode.GetMetadata().FilePath); + + RightClickedNode = null; + } + + protected void ContextSelectAll_Click(object sender, EventArgs e) + { + SetAllChecked(true); + } + + protected void ContextDeselectAll_Click(object sender, EventArgs e) + { + SetAllChecked(false); + } + + protected abstract void SetAllChecked(bool isChecked); + + void ContextExpandAll_Click(object sender, EventArgs e) + { + ExpandAll(); + } + + void ContextCollapseAll_Click(object sender, EventArgs e) + { + CollapseAll(); + } + + void ContextMenu_Closing(object sender, ToolStripDropDownClosingEventArgs e) + { + if (ClickedNode == null) + return; + ClickedNode.BackColor = Color.Transparent; + ClickedNode.TreeView.Update(); + + RightClickedNode = ClickedNode; // Preserve reference to clicked node so context item handlers can access, + ClickedNode = null; // but clear ClickedNode so mouseover doesn't change back color. + } + + #endregion + } +} diff --git a/WitcherScriptMerger/Controls/SMTreeSorter.cs b/WitcherScriptMerger/Controls/SMTreeSorter.cs index 7a93725..5f422cf 100644 --- a/WitcherScriptMerger/Controls/SMTreeSorter.cs +++ b/WitcherScriptMerger/Controls/SMTreeSorter.cs @@ -5,24 +5,24 @@ namespace WitcherScriptMerger.Controls { - class SMTreeSorter : IComparer - { - public int Compare(object x, object y) - { - var xNode = x as TreeNode; - var yNode = y as TreeNode; + class SMTreeSorter : IComparer + { + public int Compare(object x, object y) + { + var xNode = x as TreeNode; + var yNode = y as TreeNode; - switch (xNode.Level) - { - case (int)SMTree.LevelType.Categories: - var xCat = (ModFileCategory)xNode.Tag; - var yCat = (ModFileCategory)yNode.Tag; - return xCat.OrderIndex.CompareTo(yCat.OrderIndex); - case (int)SMTree.LevelType.Mods: - return (new LoadOrderComparer()).Compare(xNode.Text, yNode.Text); - default: - return xNode.Text.CompareTo(yNode.Text); - } - } - } + switch (xNode.Level) + { + case (int)SMTree.LevelType.Categories: + var xCat = (ModFileCategory)xNode.Tag; + var yCat = (ModFileCategory)yNode.Tag; + return xCat.OrderIndex.CompareTo(yCat.OrderIndex); + case (int)SMTree.LevelType.Mods: + return (new LoadOrderComparer()).Compare(xNode.Text, yNode.Text); + default: + return xNode.Text.CompareTo(yNode.Text); + } + } + } } diff --git a/WitcherScriptMerger/Controls/ToolStripRegion.cs b/WitcherScriptMerger/Controls/ToolStripRegion.cs index 6f5147b..060be74 100644 --- a/WitcherScriptMerger/Controls/ToolStripRegion.cs +++ b/WitcherScriptMerger/Controls/ToolStripRegion.cs @@ -3,15 +3,15 @@ namespace WitcherScriptMerger.Controls { - class ToolStripRegion - { - public ToolStripItemCollection Items; + class ToolStripRegion + { + public ToolStripItemCollection Items; - public bool Available => Items.Cast().Any(item => item.Available); + public bool Available => Items.Cast().Any(item => item.Available); - public ToolStripRegion(ToolStrip owner, ToolStripItem[] value) - { - Items = new ToolStripItemCollection(owner, value); - } - } + public ToolStripRegion(ToolStrip owner, ToolStripItem[] value) + { + Items = new ToolStripItemCollection(owner, value); + } + } } diff --git a/WitcherScriptMerger/Extensions.cs b/WitcherScriptMerger/Extensions.cs index 8d3585d..1fe7dc7 100644 --- a/WitcherScriptMerger/Extensions.cs +++ b/WitcherScriptMerger/Extensions.cs @@ -3,206 +3,163 @@ using System.Drawing; using System.Linq; using System.Runtime.InteropServices; -using System.Text.RegularExpressions; using System.Windows.Forms; namespace WitcherScriptMerger { - static class Extensions - { - #region Strings - - public static string ReplaceIgnoreCase(this string s, string oldValue, string newValue) - { - return Regex.Replace(s, Regex.Escape(oldValue), newValue.Replace("$", "$$"), RegexOptions.IgnoreCase); - } - - public static bool EqualsIgnoreCase(this string s, string otherString) - { - return s.Equals(otherString, StringComparison.InvariantCultureIgnoreCase); - } - - public static int IndexOfIgnoreCase(this string s, string value, int startIndex = 0) - { - return s.IndexOf(value, startIndex, StringComparison.InvariantCultureIgnoreCase); - } - - public static int LastIndexOfIgnoreCase(this string s, string value, int startIndex = -1) - { - if (startIndex == -1) - startIndex = s.Length - 1; - return s.LastIndexOf(value, startIndex, StringComparison.InvariantCultureIgnoreCase); - } - - public static bool StartsWithIgnoreCase(this string s, string value) - { - return s.StartsWith(value, StringComparison.InvariantCultureIgnoreCase); - } - - public static bool EndsWithIgnoreCase(this string s, string value) - { - return s.EndsWith(value, StringComparison.InvariantCultureIgnoreCase); - } - - public static bool IsAlphaNumeric(this string s) - { - return new Regex("^[_a-zA-Z0-9]*$").IsMatch(s); - } - - public static string GetPluralS(this int num) - { - return num == 1 ? "" : "s"; - } - - #endregion - - #region Tree & Context Menu - - public static IEnumerable GetAvailableItems(this ContextMenuStrip menu) - { - return menu.Items.Cast().Where(item => item.Available); - } - - public static void SetFontStyle(this TreeNode node, FontStyle style) - { - var currFont = node.NodeFont ?? Control.DefaultFont; - node.NodeFont = new Font(currFont, style); - } - - public static IEnumerable GetTreeNodes(this TreeNode node) - { - return node.Nodes.Cast(); - } - - public static Controls.SMTree.NodeMetadata GetMetadata(this TreeNode node) - { - return node.Tag as Controls.SMTree.NodeMetadata; - } - - public static bool IsEmpty(this TreeView tree) - { - return (tree.Nodes.Count == 0); - } - - #endregion - - #region Scrolling TreeView to Top - - const int WM_VSCROLL = 0x0115; - const int SB_THUMBPOSITION = 0x0004; - - [DllImport("user32.dll", CharSet = CharSet.Auto)] - static extern int SetScrollPos(IntPtr hWnd, int nBar, int nPos, bool bRedraw); - - [DllImport("User32.Dll", EntryPoint = "PostMessageA")] - static extern bool PostMessage(IntPtr hWnd, uint msg, int wParam, int lParam); - - public static void ScrollToTop(this TreeView treeView) - { - if (SetScrollPos(treeView.Handle, WM_VSCROLL, 0, true) != -1) - PostMessage(treeView.Handle, WM_VSCROLL, SB_THUMBPOSITION, 0); - } - - #endregion - - #region TreeView Checkbox Visibility - - // From http://stackoverflow.com/a/22488652/1641069 - - const int TVIF_STATE = 0x8; - const int TVIS_STATEIMAGEMASK = 0xF000; - const int TV_FIRST = 0x1100; - const int TVM_GETITEM = TV_FIRST + 62; - const int TVM_SETITEM = TV_FIRST + 63; - - [StructLayout(LayoutKind.Sequential, Pack = 8, CharSet = CharSet.Auto)] - struct TVITEM - { - public int mask; - public IntPtr hItem; - public int state; - public int stateMask; - [MarshalAs(UnmanagedType.LPTStr)] - public string lpszText; - public int cchTextMax; - public int iImage; - public int iSelectedImage; - public int cChildren; - public IntPtr lParam; - } - - [DllImport("user32.dll", CharSet = CharSet.Auto)] - static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, ref TVITEM lParam); - - /// - /// Gets a value indicating if the checkbox is visible on the tree node. - /// - /// The tree node. - /// true if the checkbox is visible on the tree node; otherwise false. - public static bool IsCheckBoxVisible(this TreeNode node) - { - if (node == null) - throw new ArgumentNullException("node"); - if (node.TreeView == null) - throw new InvalidOperationException("The node does not belong to a tree."); - var tvi = new TVITEM - { - hItem = node.Handle, - mask = TVIF_STATE - }; - var result = SendMessage(node.TreeView.Handle, TVM_GETITEM, node.Handle, ref tvi); - if (result == IntPtr.Zero) - throw new ApplicationException("Error getting TreeNode state."); - var imageIndex = (tvi.state & TVIS_STATEIMAGEMASK) >> 12; - return (imageIndex != 0); - } - - /// - /// Sets a value indicating if the checkbox is visible on the tree node. - /// - /// The tree node. - /// true to make the checkbox visible on the tree node; otherwise false. - public static void SetIsCheckBoxVisible(this TreeNode node, bool isVisible, bool applyToSubtree = false) - { - if (node.TreeView == null) - throw new InvalidOperationException("The node does not belong to a tree."); - var tvi = new TVITEM - { - hItem = node.Handle, - mask = TVIF_STATE, - stateMask = TVIS_STATEIMAGEMASK, - state = (isVisible ? node.Checked ? 2 : 1 : 0) << 12 - }; - var result = SendMessage(node.TreeView.Handle, TVM_SETITEM, IntPtr.Zero, ref tvi); - if (result == IntPtr.Zero) - throw new ApplicationException("Error setting TreeNode state."); - - if (applyToSubtree) - { - foreach (var childNode in node.GetTreeNodes()) - { - childNode.SetIsCheckBoxVisible(isVisible, applyToSubtree); - } - } - } - - public static bool SetCheckedIfVisible(this TreeNode node, bool isChecked) - { - if (node.IsCheckBoxVisible()) - { - node.Checked = isChecked; - return true; - } - return false; - } - - public static bool AreAllVisibleCheckboxesChecked(this TreeNode node) - { - return node.GetTreeNodes() - .Where(child => child.IsCheckBoxVisible()) - .All(child => child.Checked); - } - - #endregion - } + // Pure string helpers (EqualsIgnoreCase, GetPluralS, etc.) moved to Core's + // StringExtensions.cs during the Core/host project split, since domain code that + // now lives in Core needs them too and Core can't reference this host assembly. + // What's left here is WinForms-specific (TreeNode/TreeView helpers, Win32 P/Invoke). + static class Extensions + { + #region Tree & Context Menu + + public static IEnumerable GetAvailableItems(this ContextMenuStrip menu) + { + return menu.Items.Cast().Where(item => item.Available); + } + + public static void SetFontStyle(this TreeNode node, FontStyle style) + { + var currFont = node.NodeFont ?? Control.DefaultFont; + node.NodeFont = new Font(currFont, style); + } + + public static IEnumerable GetTreeNodes(this TreeNode node) + { + return node.Nodes.Cast(); + } + + public static Controls.SMTree.NodeMetadata GetMetadata(this TreeNode node) + { + return node.Tag as Controls.SMTree.NodeMetadata; + } + + public static bool IsEmpty(this TreeView tree) + { + return (tree.Nodes.Count == 0); + } + + #endregion + + #region Scrolling TreeView to Top + + const int WM_VSCROLL = 0x0115; + const int SB_THUMBPOSITION = 0x0004; + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + static extern int SetScrollPos(IntPtr hWnd, int nBar, int nPos, bool bRedraw); + + [DllImport("User32.Dll", EntryPoint = "PostMessageA")] + static extern bool PostMessage(IntPtr hWnd, uint msg, int wParam, int lParam); + + public static void ScrollToTop(this TreeView treeView) + { + if (SetScrollPos(treeView.Handle, WM_VSCROLL, 0, true) != -1) + PostMessage(treeView.Handle, WM_VSCROLL, SB_THUMBPOSITION, 0); + } + + #endregion + + #region TreeView Checkbox Visibility + + // From http://stackoverflow.com/a/22488652/1641069 + + const int TVIF_STATE = 0x8; + const int TVIS_STATEIMAGEMASK = 0xF000; + const int TV_FIRST = 0x1100; + const int TVM_GETITEM = TV_FIRST + 62; + const int TVM_SETITEM = TV_FIRST + 63; + + [StructLayout(LayoutKind.Sequential, Pack = 8, CharSet = CharSet.Auto)] + struct TVITEM + { + public int mask; + public IntPtr hItem; + public int state; + public int stateMask; + [MarshalAs(UnmanagedType.LPTStr)] + public string lpszText; + public int cchTextMax; + public int iImage; + public int iSelectedImage; + public int cChildren; + public IntPtr lParam; + } + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, ref TVITEM lParam); + + /// + /// Gets a value indicating if the checkbox is visible on the tree node. + /// + /// The tree node. + /// true if the checkbox is visible on the tree node; otherwise false. + public static bool IsCheckBoxVisible(this TreeNode node) + { + if (node == null) + throw new ArgumentNullException("node"); + if (node.TreeView == null) + throw new InvalidOperationException("The node does not belong to a tree."); + var tvi = new TVITEM + { + hItem = node.Handle, + mask = TVIF_STATE + }; + var result = SendMessage(node.TreeView.Handle, TVM_GETITEM, node.Handle, ref tvi); + if (result == IntPtr.Zero) + throw new ApplicationException("Error getting TreeNode state."); + var imageIndex = (tvi.state & TVIS_STATEIMAGEMASK) >> 12; + return (imageIndex != 0); + } + + /// + /// Sets a value indicating if the checkbox is visible on the tree node. + /// + /// The tree node. + /// true to make the checkbox visible on the tree node; otherwise false. + public static void SetIsCheckBoxVisible(this TreeNode node, bool isVisible, bool applyToSubtree = false) + { + if (node.TreeView == null) + throw new InvalidOperationException("The node does not belong to a tree."); + var tvi = new TVITEM + { + hItem = node.Handle, + mask = TVIF_STATE, + stateMask = TVIS_STATEIMAGEMASK, + state = (isVisible ? node.Checked ? 2 : 1 : 0) << 12 + }; + var result = SendMessage(node.TreeView.Handle, TVM_SETITEM, IntPtr.Zero, ref tvi); + if (result == IntPtr.Zero) + throw new ApplicationException("Error setting TreeNode state."); + + if (applyToSubtree) + { + foreach (var childNode in node.GetTreeNodes()) + { + childNode.SetIsCheckBoxVisible(isVisible, applyToSubtree); + } + } + } + + public static bool SetCheckedIfVisible(this TreeNode node, bool isChecked) + { + if (node.IsCheckBoxVisible()) + { + node.Checked = isChecked; + return true; + } + return false; + } + + public static bool AreAllVisibleCheckboxesChecked(this TreeNode node) + { + return node.GetTreeNodes() + .Where(child => child.IsCheckBoxVisible()) + .All(child => child.Checked); + } + + #endregion + } } diff --git a/WitcherScriptMerger/FileIndex/ModFile.cs b/WitcherScriptMerger/FileIndex/ModFile.cs deleted file mode 100644 index 07dc09e..0000000 --- a/WitcherScriptMerger/FileIndex/ModFile.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Xml.Serialization; -using WitcherScriptMerger.Inventory; - -namespace WitcherScriptMerger.FileIndex -{ - public class ModFile - { - #region Members - - [XmlElement] - public string RelativePath { get; set; } - - [XmlElement("IncludedMod")] - public List Mods { get; private set; } - - [XmlElement] - public string BundleName { get; set; } - - [XmlIgnore] - public ModFileCategory Category - { - get - { - if (BundleName != null) - { - if (IsTextFile(RelativePath)) - return Categories.BundleText; - else - return Categories.BundleNotMergeable; - } - else if (IsScript(RelativePath)) - return Categories.Script; - else if (IsXml(RelativePath)) - return Categories.Xml; - else - return Categories.FlatNotMergeable; - } - } - - [XmlIgnore] - public bool IsBundleContent => (BundleName != null); - - [XmlIgnore] - public bool HasConflict => (Mods.Count > 1); - - #endregion - - public ModFile(string relPath, string bundlePath = null) - { - RelativePath = relPath; - Mods = new List(); - if (bundlePath != null) - BundleName = Path.GetFileName(bundlePath); - } - - public ModFile() - { - Mods = new List(); - } - - public bool ContainsMod(string modName) - { - return Mods.Any(mod => mod.Name.EqualsIgnoreCase(modName)); - } - - public string GetVanillaFile() - { - if (Category == Categories.Script) - return Path.Combine(Paths.ScriptsDirectory, RelativePath); - else if (Category == Categories.Xml) - return Path.Combine(Paths.GameDirectory, RelativePath); - else - throw new Exception($"Can't get vanilla file for category '{Category.DisplayName}'."); - } - - public string GetModFile(string modName) - { - if (Category == Categories.Script) - return Path.Combine(Paths.ModsDirectory, modName, Paths.ModScriptBase, RelativePath); - else if (Category == Categories.Xml) - return Path.Combine(Paths.ModsDirectory, modName, RelativePath); - else if (Category.IsBundled) - return Path.Combine(Paths.ModsDirectory, modName, Paths.BundleBase, BundleName); - else - throw new NotImplementedException(); - } - - public static string GetModNameFromPath(string modFilePath) - { - if (!modFilePath.StartsWithIgnoreCase(Paths.ModsDirectory)) // Merged bundle content has internal path, not derived from mod folder - return Paths.MergedBundleContent; - - var nameStart = Paths.ModsDirectory.Length + 1; - var name = modFilePath.Substring(nameStart); - return name.Substring(0, name.IndexOf('\\')); - } - - public static bool IsScript(string path) => path.EndsWithIgnoreCase(".ws"); - - public static bool IsXml(string path) => path.EndsWithIgnoreCase(".xml"); - - public static bool IsFlatFile(string path) => (IsScript(path) || IsXml(path)); - - public static bool IsBundle(string path) => path.EndsWithIgnoreCase(".bundle"); - - public static bool IsTextFile(string path) => (path.EndsWithIgnoreCase(".ws") || path.EndsWithIgnoreCase(".xml") || path.EndsWithIgnoreCase(".txt") || path.EndsWithIgnoreCase(".csv")); - - public override string ToString() - { - return $"({Mods.Count} mod{Mods.Count.GetPluralS()}) {RelativePath}"; - } - } -} \ No newline at end of file diff --git a/WitcherScriptMerger/FileIndex/ModFileCategory.cs b/WitcherScriptMerger/FileIndex/ModFileCategory.cs deleted file mode 100644 index 72f4299..0000000 --- a/WitcherScriptMerger/FileIndex/ModFileCategory.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace WitcherScriptMerger.FileIndex -{ - public class ModFileCategory - { - public ModFileCategory(int orderIndex, string displayName, string toolTipText, bool isSupported, bool isBundled) - { - OrderIndex = orderIndex; - DisplayName = displayName; - ToolTipText = toolTipText; - IsSupported = isSupported; - IsBundled = isBundled; - } - - public int OrderIndex { get; private set; } - public string DisplayName { get; private set; } - public string ToolTipText { get; private set; } - public bool IsSupported { get; private set; } - public bool IsBundled { get; private set; } - - public override string ToString() - { - return DisplayName; - } - } - - static class Categories - { - public static ModFileCategory Script = new ModFileCategory( - 1, "Scripts", "These plaintext .ws files can be merged", true, false); - - public static ModFileCategory Xml = new ModFileCategory( - 2, "Non-Bundled XML", "These .xml text files can be merged", true, false); - - public static ModFileCategory BundleText = new ModFileCategory( - 3, "Bundled Text", "These bundled text files can be merged", true, true); - - public static ModFileCategory BundleNotMergeable = new ModFileCategory( - 4, "Bundled Non-text - Not Mergeable", "Right-click mods to define your load order instead of merging", false, true); - - public static ModFileCategory FlatNotMergeable = new ModFileCategory( - 5, "Not Mergeable", "Script Merger doesn't know what these files are", false, false); - } -} diff --git a/WitcherScriptMerger/FileIndex/ModFileIndex.cs b/WitcherScriptMerger/FileIndex/ModFileIndex.cs deleted file mode 100644 index f54babc..0000000 --- a/WitcherScriptMerger/FileIndex/ModFileIndex.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.IO; -using System.Linq; -using WitcherScriptMerger.Inventory; -using WitcherScriptMerger.Tools; - -namespace WitcherScriptMerger.FileIndex -{ - class ModFileIndex - { - public List Files; - - public IEnumerable Conflicts => Files.Where(f => f.HasConflict); - - public bool HasConflict => Files.Any(f => f.HasConflict); - - public int ModCount { get; private set; } - - public int ScriptCount { get; private set; } - - public int XmlCount { get; private set; } - - public int BundleCount { get; private set; } - - public ModFileIndex() - { - Files = new List(); - } - - public void BuildAsync( - bool checkScripts, bool checkXml, bool checkBundles, - ProgressChangedEventHandler progressHandler, - RunWorkerCompletedEventHandler completedHandler) - { - var ignoredModNames = GetIgnoredModNames(); - var modDirPaths = Directory.GetDirectories(Paths.ModsDirectory, "mod*", SearchOption.TopDirectoryOnly) - .Where(path => !ignoredModNames.Any(name => name.EqualsIgnoreCase(new DirectoryInfo(path).Name))) - .ToList(); - ModCount = modDirPaths.Count; - if (ModCount == 0) - { - Program.MainForm.ShowMessage("Can't find any mods in the Mods directory."); - } - - var bgWorker = new BackgroundWorker - { - WorkerReportsProgress = true - }; - bgWorker.DoWork += (sender, e) => - { - var i = 0; - ScriptCount = XmlCount = BundleCount = 0; - foreach (var modDirPath in modDirPaths) - { - var modName = Path.GetFileName(modDirPath); - var filePaths = Directory.GetFiles(modDirPath, "*", SearchOption.AllDirectories); - var scriptPaths = filePaths.Where(path => ModFile.IsScript(path)); - var xmlPaths = filePaths.Where(path => ModFile.IsXml(path)); - var bundlePaths = filePaths.Where(path => ModFile.IsBundle(path)); - - ScriptCount += scriptPaths.Count(); - XmlCount += xmlPaths.Count(); - BundleCount += bundlePaths.Count(); - - if (checkScripts) - { - Files.AddRange(GetModFilesFromPaths(scriptPaths, Categories.Script, modName)); - } - if (checkXml) - { - Files.AddRange(GetModFilesFromPaths(xmlPaths, Categories.Xml, modName)); - } - if (checkBundles) - { - foreach (var bundlePath in bundlePaths) - { - var contentPaths = QuickBms.GetBundleContentPaths(bundlePath); - Files.AddRange(GetModFilesFromPaths(contentPaths, Categories.BundleText, modName, bundlePath)); - } - } - var progressPct = (int)((float)++i / modDirPaths.Count * 100f); - bgWorker.ReportProgress(progressPct, modName as object); - } - if (checkBundles) - System.Threading.Thread.Sleep(500); // Wait for progress bar to fill completely - }; - bgWorker.RunWorkerCompleted += completedHandler; - bgWorker.ProgressChanged += progressHandler; - bgWorker.RunWorkerAsync(); - } - - private List GetModFilesFromPaths( - IEnumerable filePaths, - ModFileCategory category, - string modName, string bundlePath = null) - { - var fileList = new List(); - foreach (var filePath in filePaths) - { - string relPath = null; - if (category == Categories.Script) - relPath = Paths.GetRelativePath(filePath, Paths.ModScriptBase); - else if (category == Categories.Xml) - relPath = Paths.GetRelativePath(filePath, modName); - else if (category == Categories.BundleText) - relPath = filePath; - else - throw new NotImplementedException(); - - var existingFile = Files.FirstOrDefault(file => - file.RelativePath.EqualsIgnoreCase(relPath)); - if (existingFile == null) - { - var newFile = (bundlePath != null - ? new ModFile(relPath, bundlePath) - : new ModFile(relPath)); - newFile.Mods.Add(new FileHash { Name = modName }); - fileList.Add(newFile); - } - else - existingFile.Mods.Add(new FileHash { Name = modName }); - } - return fileList; - } - - private IEnumerable GetIgnoredModNames() - { - var ignoredNames = Program.Settings.Get("IgnoreModNames"); - return ignoredNames.Split(',') - .Where(name => !string.IsNullOrWhiteSpace(name)) - .Select(name => name.Trim()); - } - } -} diff --git a/WitcherScriptMerger/Forms/DependencyForm.cs b/WitcherScriptMerger/Forms/DependencyForm.cs index efc1ed5..ebef874 100644 --- a/WitcherScriptMerger/Forms/DependencyForm.cs +++ b/WitcherScriptMerger/Forms/DependencyForm.cs @@ -7,152 +7,152 @@ namespace WitcherScriptMerger.Forms { - partial class DependencyForm : Form - { - bool AreAnyPathsChanged - { - get - { - return (!txtKDiff3Path.Text.EqualsIgnoreCase(KDiff3.ExePath) || - !txtBmsPath.Text.EqualsIgnoreCase(QuickBms.ExePath) || - !txtBmsPluginPath.Text.EqualsIgnoreCase(QuickBms.PluginPath) || - !txtWccLitePath.Text.EqualsIgnoreCase(WccLite.ExePath)); - } - } - - public DependencyForm() - { - InitializeComponent(); - } - - void DependencyForm_Load(object sender, EventArgs e) - { - txtKDiff3Path.Text = KDiff3.ExePath; - txtBmsPath.Text = QuickBms.ExePath; - txtBmsPluginPath.Text = QuickBms.PluginPath; - txtWccLitePath.Text = WccLite.ExePath; - btnOK.Select(); - } - - void btnOK_Click(object sender, EventArgs e) - { - var allValid = - Color.LightGreen == txtKDiff3Path.BackColor && - Color.LightGreen == txtBmsPath.BackColor && - Color.LightGreen == txtBmsPluginPath.BackColor && - Color.LightGreen == txtWccLitePath.BackColor; - - if (!allValid && - DialogResult.No == MessageBox.Show( - "Not all the files are located & valid. Save settings anyway?", - "Missing Dependency", - MessageBoxButtons.YesNo, - MessageBoxIcon.Warning)) - { - DialogResult = DialogResult.None; - return; - } - - if (AreAnyPathsChanged) - { - KDiff3.ExePath = UpdatePathSetting(KDiff3.ExePath, txtKDiff3Path.Text, "Kdiff3Path"); - QuickBms.ExePath = UpdatePathSetting(QuickBms.ExePath, txtBmsPath.Text, "QuickBmsPath"); - QuickBms.PluginPath = UpdatePathSetting(QuickBms.PluginPath, txtBmsPluginPath.Text, "QuickBmsPluginPath"); - WccLite.ExePath = UpdatePathSetting(WccLite.ExePath, txtWccLitePath.Text, "WccLitePath"); - Program.Settings.Save(); - } - - DialogResult = (allValid - ? DialogResult.OK - : DialogResult.Cancel); - } - - string UpdatePathSetting(string oldPath, string newPath, string settingName) - { - if (oldPath.EqualsIgnoreCase(newPath)) - return oldPath; - Program.Settings.Set(settingName, newPath); - return newPath; - } - - void btnCancel_Click(object sender, EventArgs e) - { - DialogResult = DialogResult.Cancel; - } - - #region Selecting Files - - void btnKDiff3Path_Click(object sender, EventArgs e) - { - GetUserFileChoice(txtKDiff3Path, "Executables|*.exe"); - } - - void btnBmsPath_Click(object sender, EventArgs e) - { - GetUserFileChoice(txtBmsPath, "Executables|*.exe"); - } - - void btnBmsPluginPath_Click(object sender, EventArgs e) - { - GetUserFileChoice(txtBmsPluginPath, "QuickBMS Plugins|*.bms"); - } - - void btnWccLitePath_Click(object sender, EventArgs e) - { - GetUserFileChoice(txtWccLitePath, "Executables|*.exe"); - } - - void GetUserFileChoice(TextBox txt, string filter) - { - var dlgSelectFile = new OpenFileDialog(); - dlgSelectFile.Filter = filter; - if (!string.IsNullOrWhiteSpace(txt.Text) && File.Exists(txt.Text)) - dlgSelectFile.FileName = txt.Text; - if (DialogResult.OK == dlgSelectFile.ShowDialog()) - txt.Text = dlgSelectFile.FileName.Replace(Environment.CurrentDirectory + "\\", ""); - } - - #endregion - - #region Clicking Links - - void lnkKDiff3_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - { - Process.Start("http://kdiff3.sourceforge.net/"); - } - - void lnkBms_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - { - Process.Start("http://aluigi.altervista.org/quickbms.htm"); - } - - void lnkWccLite_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) - { - Process.Start("http://www.nexusmods.com/witcher3/news/12625/?"); - } - - #endregion - - #region Validation - - void exe_TextChanged(object sender, EventArgs e) - { - ValidateTextBox(sender as TextBox, ".exe"); - } - - void bms_TextChanged(object sender, EventArgs e) - { - ValidateTextBox(sender as TextBox, ".bms"); - } - - void ValidateTextBox(TextBox txt, string validExtension) - { - var path = txt.Text; - txt.BackColor = (path.EndsWithIgnoreCase(validExtension) && File.Exists(path) - ? Color.LightGreen - : Color.LightPink); - } - - #endregion - } -} \ No newline at end of file + partial class DependencyForm : Form + { + bool AreAnyPathsChanged + { + get + { + return (!txtKDiff3Path.Text.EqualsIgnoreCase(KDiff3.ExePath) || + !txtBmsPath.Text.EqualsIgnoreCase(QuickBms.ExePath) || + !txtBmsPluginPath.Text.EqualsIgnoreCase(QuickBms.PluginPath) || + !txtWccLitePath.Text.EqualsIgnoreCase(WccLite.ExePath)); + } + } + + public DependencyForm() + { + InitializeComponent(); + } + + void DependencyForm_Load(object sender, EventArgs e) + { + txtKDiff3Path.Text = KDiff3.ExePath; + txtBmsPath.Text = QuickBms.ExePath; + txtBmsPluginPath.Text = QuickBms.PluginPath; + txtWccLitePath.Text = WccLite.ExePath; + btnOK.Select(); + } + + void btnOK_Click(object sender, EventArgs e) + { + var allValid = + Color.LightGreen == txtKDiff3Path.BackColor && + Color.LightGreen == txtBmsPath.BackColor && + Color.LightGreen == txtBmsPluginPath.BackColor && + Color.LightGreen == txtWccLitePath.BackColor; + + if (!allValid && + DialogResult.No == MessageBox.Show( + "Not all the files are located & valid. Save settings anyway?", + "Missing Dependency", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning)) + { + DialogResult = DialogResult.None; + return; + } + + if (AreAnyPathsChanged) + { + KDiff3.ExePath = UpdatePathSetting(KDiff3.ExePath, txtKDiff3Path.Text, "Kdiff3Path"); + QuickBms.ExePath = UpdatePathSetting(QuickBms.ExePath, txtBmsPath.Text, "QuickBmsPath"); + QuickBms.PluginPath = UpdatePathSetting(QuickBms.PluginPath, txtBmsPluginPath.Text, "QuickBmsPluginPath"); + WccLite.ExePath = UpdatePathSetting(WccLite.ExePath, txtWccLitePath.Text, "WccLitePath"); + Program.Settings.Save(); + } + + DialogResult = (allValid + ? DialogResult.OK + : DialogResult.Cancel); + } + + string UpdatePathSetting(string oldPath, string newPath, string settingName) + { + if (oldPath.EqualsIgnoreCase(newPath)) + return oldPath; + Program.Settings.Set(settingName, newPath); + return newPath; + } + + void btnCancel_Click(object sender, EventArgs e) + { + DialogResult = DialogResult.Cancel; + } + + #region Selecting Files + + void btnKDiff3Path_Click(object sender, EventArgs e) + { + GetUserFileChoice(txtKDiff3Path, "Executables|*.exe"); + } + + void btnBmsPath_Click(object sender, EventArgs e) + { + GetUserFileChoice(txtBmsPath, "Executables|*.exe"); + } + + void btnBmsPluginPath_Click(object sender, EventArgs e) + { + GetUserFileChoice(txtBmsPluginPath, "QuickBMS Plugins|*.bms"); + } + + void btnWccLitePath_Click(object sender, EventArgs e) + { + GetUserFileChoice(txtWccLitePath, "Executables|*.exe"); + } + + void GetUserFileChoice(TextBox txt, string filter) + { + var dlgSelectFile = new OpenFileDialog(); + dlgSelectFile.Filter = filter; + if (!string.IsNullOrWhiteSpace(txt.Text) && File.Exists(txt.Text)) + dlgSelectFile.FileName = txt.Text; + if (DialogResult.OK == dlgSelectFile.ShowDialog()) + txt.Text = dlgSelectFile.FileName.Replace(Environment.CurrentDirectory + "\\", ""); + } + + #endregion + + #region Clicking Links + + void lnkKDiff3_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + { + Process.Start("http://kdiff3.sourceforge.net/"); + } + + void lnkBms_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + { + Process.Start("http://aluigi.altervista.org/quickbms.htm"); + } + + void lnkWccLite_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) + { + Process.Start("http://www.nexusmods.com/witcher3/news/12625/?"); + } + + #endregion + + #region Validation + + void exe_TextChanged(object sender, EventArgs e) + { + ValidateTextBox(sender as TextBox, ".exe"); + } + + void bms_TextChanged(object sender, EventArgs e) + { + ValidateTextBox(sender as TextBox, ".bms"); + } + + void ValidateTextBox(TextBox txt, string validExtension) + { + var path = txt.Text; + txt.BackColor = (path.EndsWithIgnoreCase(validExtension) && File.Exists(path) + ? Color.LightGreen + : Color.LightPink); + } + + #endregion + } +} diff --git a/WitcherScriptMerger/Forms/MainForm.cs b/WitcherScriptMerger/Forms/MainForm.cs index 25dd7cf..d0f86b6 100644 --- a/WitcherScriptMerger/Forms/MainForm.cs +++ b/WitcherScriptMerger/Forms/MainForm.cs @@ -12,996 +12,1089 @@ namespace WitcherScriptMerger.Forms { - partial class MainForm : Form - { - #region Members - - public string GameDirectorySetting => txtGameDir.Text; - - ModFileIndex _modIndex = null; - - #endregion - - #region Form Operations - - public MainForm() - { - InitializeComponent(); - this.Text += " v" + Application.ProductVersion; - } - - void MainForm_Load(object sender, EventArgs e) - { - txtGameDir.Text = Program.Settings.Get("GameDirectory"); - LoadLastWindowConfiguration(); - } - - async void MainForm_Shown(object sender, EventArgs e) - { - Update(); - - var repackingBundle = false; - if (!string.IsNullOrWhiteSpace(txtGameDir.Text) || !Paths.IsModsDirectoryDerived) - repackingBundle = await RefreshMergeInventory(); - if (repackingBundle) - return; - - if (!string.IsNullOrWhiteSpace(txtGameDir.Text) || - (!Paths.IsScriptsDirectoryDerived && !Paths.IsModsDirectoryDerived)) - RefreshConflictsTree(); - else - lblStatusLeft1.Text = "Please locate your 'The Witcher 3 Wild Hunt' game directory."; - } - - void MainForm_FormClosing(object sender, FormClosingEventArgs e) - { - if (pnlProgress.Visible) - { - e.Cancel = true; - return; - } - - Program.Settings.Set("GameDirectory", txtGameDir.Text); - - if (WindowState == FormWindowState.Maximized) - Program.Settings.Set("StartMaximized", true); - else - { - Program.Settings.Set("StartMaximized", false); - Program.Settings.Set("StartWidth", Width); - Program.Settings.Set("StartHeight", Height); - Program.Settings.Set("StartPosTop", Top); - Program.Settings.Set("StartPosLeft", Left); - } - Program.Settings.Set("StartSplitterPosPct", (int)((float)splitContainer.SplitterDistance / splitContainer.Width * 100f)); - Program.Settings.Save(); - } - - void LoadLastWindowConfiguration() - { - var top = Program.Settings.Get("StartPosTop"); - var left = Program.Settings.Get("StartPosLeft"); - if (top > 0) - Top = top; - if (left > 0) - Left = left; - if (Top > 0 || Left > 0) - StartPosition = FormStartPosition.Manual; - - var startWidth = Program.Settings.Get("StartWidth"); - var startHeight = Program.Settings.Get("StartHeight"); - if (startWidth > 0) - Width = startWidth; - if (startHeight > 0) - Height = startHeight; - - if (Program.Settings.Get("StartMaximized")) - WindowState = FormWindowState.Maximized; - - var splitterPosPct = Program.Settings.Get("StartSplitterPosPct"); - if (splitterPosPct > 0) - splitContainer.SplitterDistance = (int)(splitterPosPct / 100f * splitContainer.Width); - } - - void txtGameDir_TextChanged(object sender, EventArgs e) - { - Program.Settings.Set("GameDirectory", txtGameDir.Text); - } - - void UpdateStatusText() - { - var solvableCount = treConflicts.FileNodes.Count(node => ModFile.IsTextFile(node.Text)); - - if (treConflicts.IsEmpty()) - lblStatusLeft1.Text = "0 conflicts"; - else - { - lblStatusLeft1.Text = $"{solvableCount} mergeable"; - if (solvableCount < treConflicts.FileNodes.Count) - { - lblStatusLeft2.Text = $"{treConflicts.FileNodes.Count - solvableCount} not mergeable"; - lblStatusLeft2.Visible = true; - } - } - - lblStatusLeft3.Text = string.Format( - "{0} merge{1}", - treMerges.FileNodes.Count, - treMerges.FileNodes.Count.GetPluralS() - ); - lblStatusLeft3.Visible = true; - - if (_modIndex != null) - { - lblStatusRight.Text = string.Format( - "Found {0} mod{1}, {2} script{3}, {4} XML{5}, {6} bundle{7}", - _modIndex.ModCount, - _modIndex.ModCount.GetPluralS(), - _modIndex.ScriptCount, - _modIndex.ScriptCount.GetPluralS(), - _modIndex.XmlCount, - _modIndex.XmlCount.GetPluralS(), - _modIndex.BundleCount, - _modIndex.BundleCount.GetPluralS()); - } - } - - public void EnableMergeIfValidSelection() - { - var validFileNodeCount = treConflicts.FileNodes.Count(node => node.GetTreeNodes().Count(modNode => modNode.Checked) > 1); - btnCreateMerges.Enabled = (validFileNodeCount > 0); - btnCreateMerges.Text = (validFileNodeCount > 1 - ? "&Create " + validFileNodeCount + " Selected Merges" - : "&Create Selected Merge"); - } - - public void EnableUnmergeIfValidSelection() - { - var selectedCount = treMerges.FileNodes.Count(node => node.Checked); - btnDeleteMerges.Enabled = (selectedCount > 0); - btnDeleteMerges.Text = (selectedCount > 1 - ? "&Delete " + selectedCount + " Selected Merges" - : "&Delete Selected Merge"); - } - - #endregion - - #region Refreshing Trees - - async Task RefreshMergeInventory() - { - InitializeProgressScreen("Loading Merges", ProgressBarStyle.Continuous); - - lblProgressCurrentAction.Text = "Loading MergeInventory.xml file"; - Program.Inventory = await Task.Run(() => - MergeInventory.Load(Paths.Inventory) - ); - progressBar.Value = 25; - - lblProgressCurrentAction.Text = "Loading mods.settings file"; - Program.LoadOrder = await Task.Run(() => - new CustomLoadOrder() - ); - progressBar.Value = 50; - - if (Program.Settings.Get("ValidateCustomLoadOrder") && Program.Inventory.Merges.Any()) - { - lblProgressCurrentAction.Text = "Validating load order"; - await Task.Run(() => - LoadOrderValidator.ValidateAndFix(Program.LoadOrder) - ); - } - progressBar.Value = 75; - - lblProgressCurrentAction.Text = "Refreshing merge tree"; - return await Task.Run(() => - RefreshMergeTree() - ); - } - - bool RefreshMergeTree() - { - this.Invoke((MethodInvoker)delegate - { - treMerges.Nodes.Clear(); - }); - var changed = false; - var bundleMergesPruned = new List(); - var mergesToDelete = new List(); - for (int i = Program.Inventory.Merges.Count - 1; i >= 0; --i) - { - var merge = Program.Inventory.Merges[i]; - if (!File.Exists(merge.GetMergedFile()) && ConfirmPruneMissingMergeFile(merge)) - { - Program.Inventory.Merges.RemoveAt(i); - changed = true; - - if (merge.IsBundleContent) - bundleMergesPruned.Add(merge); - continue; - } - else - { - var willDelete = false; - foreach (var mod in merge.Mods) - { - var modFilePath = merge.GetModFile(mod.Name); - if (!File.Exists(modFilePath) && ConfirmDeleteMergeForMissingMod(merge, mod.Name)) - { - willDelete = true; - break; - } - var modLoadSetting = Program.LoadOrder.GetModLoadSettingByName(mod.Name); - if (modLoadSetting != null && !modLoadSetting.IsEnabled.Value && ConfirmDeleteMergeForDisabledMod(merge, mod.Name)) - { - willDelete = true; - break; - } - var latestHash = Tools.Hasher.ComputeHash(modFilePath); - if (latestHash != null && mod.Hash != latestHash) - { - mod.IsOutdated = true; - if (Program.Settings.Get("ValidateMergeSources")) - { - var choice = PromptToDeleteForChangedHash(merge, modFilePath, mod.Name); - if (choice == DialogResult.Yes) - { - willDelete = true; - break; - } - else if (choice == DialogResult.Cancel) // Never - { - Program.Settings.Set("ValidateMergeSources", false); - Program.Settings.Save(); - } - } - } - } - if (willDelete) - { - mergesToDelete.Add(merge); - continue; - } - } - - this.Invoke((MethodInvoker)delegate - { - var fileNode = new TreeNode - { - Text = merge.RelativePath, - ForeColor = MergeTree.FileNodeForeColor, - Tag = new MergeTree.NodeMetadata - { - FilePath = merge.GetMergedFile(), - ModFile = merge - } - }; - - var categoryNode = treMerges.GetCategoryNode(merge.Category); - if (categoryNode == null) - { - categoryNode = new TreeNode - { - Text = merge.Category.DisplayName, - ToolTipText = merge.Category.ToolTipText, - Tag = merge.Category - }; - treMerges.Nodes.Add(categoryNode); - } - categoryNode.Nodes.Add(fileNode); - - foreach (var mod in merge.Mods) - { - fileNode.Nodes.Add( - new TreeNode - { - Text = mod.Name, - Tag = new MergeTree.NodeMetadata - { - FilePath = merge.GetModFile(mod.Name), - FileHash = mod, - ModFile = merge - } - } - ); - } - }); - } - if (mergesToDelete.Any()) - { - if (DeleteMerges(mergesToDelete)) - return true; - } - if (changed) - { - Program.Inventory.Save(); - if (bundleMergesPruned.Any()) - return DeleteMerges(bundleMergesPruned); - } - this.Invoke((MethodInvoker)delegate - { - treMerges.Sort(); - treMerges.ExpandAll(); - treMerges.ScrollToTop(); - treMerges.SetFontBold(SMTree.LevelType.Categories); - foreach (var modNode in treMerges.ModNodes) - modNode.SetIsCheckBoxVisible(false); - - UpdateStatusText(); - EnableUnmergeIfValidSelection(); - - progressBar.Value = 100; - }); - return false; - } - - bool ConfirmPruneMissingMergeFile(Merge merge) - { - var msg = - "Can't find the merged version of the following file.\n\n" + - merge.RelativePath + "\n " + - string.Join("\n ", merge.Mods.Select(mod => mod.Name)) + "\n\n" + - "Expected path:\n" + - merge.GetMergedFile() + "\n\n"; - - msg += merge.IsBundleContent - ? "Remove from Merges list & repack merged bundle?" - : "Remove from Merges list?"; - - return (DialogResult.Yes == ShowMessage( - msg, - "Missing Merge Inventory File", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question)); - } - - bool ConfirmDeleteMergeForMissingMod(Merge merge, string modName) - { - var msg = - $"Can't find the '{modName}' version of the following file, " + - "perhaps because the mod was uninstalled or updated.\n\n" + - merge.RelativePath + "\n " + - string.Join("\n ", merge.Mods.Select(mod => mod.Name)) + "\n\n" + - "Expected path:\n" + - merge.GetModFile(modName) + "\n\n"; - - msg += merge.IsBundleContent - ? "Delete this affected merge & repack the merged bundle?" - : "Delete this affected merge?"; - - return (DialogResult.Yes == ShowMessage( - msg, - "Missing Merge Inventory File", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question)); - } - - bool ConfirmDeleteMergeForDisabledMod(Merge merge, string modName) - { - var msg = - $"In your custom load order, {modName} is disabled.\n" + - "Delete the following merge that includes the disabled mod?\n\n" + - merge.RelativePath + "\n " + - string.Join("\n ", merge.Mods.Select(mod => mod.Name)); - - return (DialogResult.Yes == ShowMessage( - msg, - "Disabled Mod in Merge", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question)); - } - - private DialogResult PromptToDeleteForChangedHash(Merge merge, string modFilePath, string modName) - { - var msg = - $"The '{modName}' {(merge.IsBundleContent ? "bundle" : "version of the following file")} " + - "is different from when it was used in a merge, perhaps because the mod has been updated.\n\n" + - $"This file has changed:\n\n{modFilePath}\n\n" + - $"This merge is affected:\n\n{merge.RelativePath}\n " + - string.Join("\n ", merge.Mods.Select(mod => mod.Name)) + "\n\n"; - - msg += merge.IsBundleContent - ? "Delete this affected merge & repack the merged bundle?" - : "Delete this affected merge?"; - - MessageBoxManager.Cancel = "Ne&ver"; - MessageBoxManager.Register(); - - var choice = MessageBox.Show( - msg, - "Merged Mod File Changed", - MessageBoxButtons.YesNoCancel, - MessageBoxIcon.Exclamation); - - MessageBoxManager.Unregister(); - return choice; - } - - void RefreshConflictsTree(bool checkBundles = true) - { - checkBundles = checkBundles && Program.Settings.Get("CheckBundleContents"); - - InitializeProgressScreen("Detecting Conflicts", ProgressBarStyle.Continuous); - lblStatusLeft1.Text = "Refreshing..."; - lblStatusLeft2.Visible = lblStatusLeft3.Visible = false; - - if (Program.Inventory.ScriptsChanged && Program.Inventory.BundleChanged) - treConflicts.Nodes.Clear(); - else - { - var nodesToUpdate = new List(); - - var scriptCatNode = treConflicts.GetCategoryNode(Categories.Script); - if (scriptCatNode != null) - nodesToUpdate.Add(scriptCatNode); - - var xmlCatNode = treConflicts.GetCategoryNode(Categories.Xml); - if (xmlCatNode != null) - nodesToUpdate.Add(xmlCatNode); - - if (Program.Inventory.BundleChanged || checkBundles || !Program.Settings.Get("CheckBundleContents")) - { - var bundleTextCatNode = treConflicts.GetCategoryNode(Categories.BundleText); - if (bundleTextCatNode != null) - nodesToUpdate.Add(bundleTextCatNode); - var bundleNotMergeableCatNode = treConflicts.GetCategoryNode(Categories.BundleNotMergeable); - if (bundleNotMergeableCatNode != null) - nodesToUpdate.Add(bundleNotMergeableCatNode); - } - - var missingFileNodes = treConflicts.FileNodes.Where(node => - node.GetTreeNodes().Any(modNode => - !File.Exists(modNode.GetMetadata().FilePath) - ) - ); - nodesToUpdate.AddRange(missingFileNodes); - - foreach (var node in nodesToUpdate) - treConflicts.Nodes.Remove(node); - foreach (var catNode in treConflicts.CategoryNodes) // Hack-fix for bug: Empty category remained on refresh after resolving conflicts outside of SM - { - if (catNode.Nodes.Count == 0) - treConflicts.Nodes.Remove(catNode); - } - } - - _modIndex = new ModFileIndex(); - _modIndex.BuildAsync( - Program.Settings.Get("CheckScripts"), - Program.Settings.Get("CheckXmlFiles"), - Program.Settings.Get("CheckBundleContents"), - OnRefreshConflictsProgressChanged, - OnRefreshConflictsComplete); - } - - void OnRefreshConflictsProgressChanged(object sender, ProgressChangedEventArgs e) - { - progressBar.Value = e.ProgressPercentage; - lblProgressCurrentAction.Text = e.UserState as string; - - TaskbarProgress.SetState(this.Handle, TaskbarProgress.TaskbarStates.Normal); - TaskbarProgress.SetValue(this.Handle, e.ProgressPercentage, 100); - } - - void OnRefreshConflictsComplete(object sender, RunWorkerCompletedEventArgs e) - { - if (_modIndex.HasConflict) - { - foreach (var conflict in _modIndex.Conflicts) - { - if (Program.Inventory.HasResolvedConflict(conflict)) - continue; - - var fileNode = treConflicts.FileNodes.FirstOrDefault(node => - node.Text.EqualsIgnoreCase(conflict.RelativePath)); - - if (fileNode == null) - { - fileNode = new TreeNode - { - Text = conflict.RelativePath, - Tag = new SMTree.NodeMetadata - { - FilePath = (conflict.Category == Categories.Script || conflict.Category == Categories.Xml - ? conflict.GetVanillaFile() - : conflict.RelativePath), - ModFile = conflict - } - }; - - var categoryNode = treConflicts.GetCategoryNode(conflict.Category); - if (categoryNode == null) - { - categoryNode = new TreeNode - { - Text = conflict.Category.DisplayName, - ToolTipText = conflict.Category.ToolTipText, - Tag = conflict.Category - }; - treConflicts.Nodes.Add(categoryNode); - } - categoryNode.Nodes.Add(fileNode); - } - - var merge = Program.Inventory.Merges.FirstOrDefault(mrg => mrg.RelativePath.EqualsIgnoreCase(conflict.RelativePath)); - foreach (var mod in conflict.Mods) - { - var mergeModHash = merge?.Mods.FirstOrDefault(m => m.Name.EqualsIgnoreCase(mod.Name)); - if (mergeModHash != null && !mergeModHash.IsOutdated) - continue; - - var modNode = fileNode.GetTreeNodes().FirstOrDefault(node => - node.Text.EqualsIgnoreCase(mod.Name)); - - if (modNode == null) - { - modNode = new TreeNode - { - Text = mod.Name, - Tag = new SMTree.NodeMetadata - { - FilePath = conflict.GetModFile(mod.Name), - FileHash = mergeModHash, - ModFile = conflict - } - }; - fileNode.Nodes.Add(modNode); - } - } - } - - treConflicts.Sort(); - treConflicts.ExpandAll(); - treConflicts.Select(); - foreach (var catNode in treConflicts.CategoryNodes) - { - if (!(catNode.Tag as ModFileCategory).IsSupported) - { - catNode.SetIsCheckBoxVisible(false, true); - if (Program.Settings.Get("CollapseNotMergeable")) - catNode.Collapse(); - } - } - - treConflicts.SetStylesForCustomLoadOrder(); - - foreach (var fileNode in treConflicts.FileNodes) - { - if (Program.Settings.Get("CollapseCustomLoadOrder") && fileNode.ForeColor == ConflictTree.ResolvedForeColor) - fileNode.Collapse(); - } - } - - treConflicts.ScrollToTop(); - treConflicts.SetFontBold(SMTree.LevelType.Categories); - UpdateStatusText(); - HideProgressScreen(); - EnableMergeIfValidSelection(); - } - - #endregion - - #region Button Clicks - - void btnSelectGameDirectory_Click(object sender, EventArgs e) - { - var dirChoice = GetUserDirectoryChoice(); - if (!string.IsNullOrWhiteSpace(dirChoice)) - { - if (dirChoice.EndsWithIgnoreCase("The Witcher 3 Wild Hunt\\Mods")) // Auto-truncate "Mods" - dirChoice = Path.GetDirectoryName(dirChoice); - - txtGameDir.Text = dirChoice; - RefreshTrees(); - } - } - - string GetUserDirectoryChoice() - { - var dlgSelectRoot = new FolderBrowserDialog(); - if (Directory.Exists(txtGameDir.Text)) - dlgSelectRoot.SelectedPath = txtGameDir.Text; - if (DialogResult.OK == dlgSelectRoot.ShowDialog()) - return dlgSelectRoot.SelectedPath; - else - return null; - } - - async void btnRefreshMerged_Click(object sender, EventArgs e) - { - if (string.IsNullOrWhiteSpace(txtGameDir.Text)) - { - Program.MainForm.ShowMessage( - "Please locate your 'The Witcher 3 Wild Hunt' game directory."); - return; - } - - if (txtGameDir.Text.EndsWithIgnoreCase("The Witcher 3 Wild Hunt\\Mods")) // Auto-truncate "Mods" - txtGameDir.Text = Path.GetDirectoryName(txtGameDir.Text); - - if (Paths.ValidateModsDirectory()) - await RefreshMergeInventory(); - - HideProgressScreen(); - } - - void btnRefreshConflicts_Click(object sender, EventArgs e) - { - if (string.IsNullOrWhiteSpace(txtGameDir.Text)) - { - Program.MainForm.ShowMessage( - "Please locate your 'The Witcher 3 Wild Hunt' game directory."); - return; - } - - if (txtGameDir.Text.EndsWithIgnoreCase("The Witcher 3 Wild Hunt\\Mods")) // Auto-truncate "Mods" - txtGameDir.Text = Path.GetDirectoryName(txtGameDir.Text); - - RefreshTrees(); - } - - void btnMergeFiles_Click(object sender, EventArgs e) - { - if (!Paths.ValidateModsDirectory() || - (treConflicts.FileNodes.Any(node => ModFile.IsScript(node.Text)) && !Paths.ValidateScriptsDirectory()) || - (treConflicts.FileNodes.Any(node => ModFile.IsBundle(node.Text)) && !Paths.ValidateBundlesDirectory())) - return; - - var mergedModName = Paths.RetrieveMergedModName(); - if (mergedModName == null) - return; - - InitializeProgressScreen("Merging"); - - Program.Inventory = MergeInventory.Load(Paths.Inventory); - - var merger = new FileMerger(Program.Inventory, OnMergeProgressChanged, OnMergeComplete); - - var fileNodes = treConflicts.FileNodes.Where(node => node.GetTreeNodes().Count(modNode => modNode.Checked) > 1); - - merger.MergeByTreeNodesAsync(fileNodes, mergedModName); - } - - void OnMergeProgressChanged(object sender, ProgressChangedEventArgs e) - { - var mergeProgress = (MergeProgressInfo)e.UserState; - lblProgressCurrentPhase.Text = mergeProgress.CurrentPhase; - lblProgressCurrentAction.Text = mergeProgress.CurrentAction; - } - - void OnMergeComplete(object sender, RunWorkerCompletedEventArgs e) - { - if (Program.Inventory.HasChanged) - { - Program.Inventory.Save(); - RefreshTrees(Program.Inventory.BundleChanged); - } - else - { - HideProgressScreen(); - EnableMergeIfValidSelection(); - } - } - - void btnDeleteMerges_Click(object sender, EventArgs e) - { - var fileNodes = treMerges.FileNodes.Where(node => node.Checked); - DeleteMerges(fileNodes); - } - - async void RefreshTrees(bool checkBundles = true) - { - if (!Paths.ValidateModsDirectory() || - (Program.Settings.Get("CheckScripts") && !Paths.ValidateScriptsDirectory()) || - (Program.Settings.Get("CheckBundleContents") && !Paths.ValidateBundlesDirectory())) - return; - - if (Program.Inventory == null) - await RefreshMergeInventory(); - else - { - InitializeProgressScreen("Loading Merges"); - Program.LoadOrder.Refresh(); - RefreshMergeTree(); - } - RefreshConflictsTree(checkBundles); - } - - #endregion - - #region Key Input - - private void MainForm_KeyDown(object sender, KeyEventArgs e) - { - if (e.KeyCode == Keys.F5 && btnRefreshConflicts.Enabled) - btnRefreshConflicts_Click(null, null); - } - - void txt_KeyDown(object sender, KeyEventArgs e) - { - if (e.Control && e.KeyCode == Keys.A) - (sender as TextBox).SelectAll(); - } - - void splitContainer_Panel1_PreviewKeyDown(object sender, PreviewKeyDownEventArgs e) - { - if (e.KeyCode == Keys.Enter && btnCreateMerges.Enabled) - btnMergeFiles_Click(null, null); - } - - void splitContainer_Panel2_PreviewKeyDown(object sender, PreviewKeyDownEventArgs e) - { - if (e.KeyCode == Keys.Delete && btnDeleteMerges.Enabled) - btnDeleteMerges_Click(null, null); - } - - #endregion - - #region Deleting Merges - - public void DeleteMerges(IEnumerable fileNodes) - { - var merges = fileNodes.Select(node => - Program.Inventory.Merges.First(merge => - merge.RelativePath.EqualsIgnoreCase(node.Text))) - .ToList(); - DeleteMerges(merges); - } - - bool DeleteMerges(List merges) - { - var bundleMerges = new List(); - foreach (var merge in merges) - { - var mergePath = merge.GetMergedFile(); - if (File.Exists(mergePath)) - { - File.Delete(mergePath); - DeleteEmptyDirs(Path.GetDirectoryName(mergePath), Paths.ScriptsDirectory); - } - if (merge.IsBundleContent) - { - var mergesForBundle = Program.Inventory.Merges.Where(m => - m.IsBundleContent && - m.MergedModName.EqualsIgnoreCase(merge.MergedModName) && - m.BundleName.EqualsIgnoreCase(merge.BundleName)); - if (mergesForBundle.All(m => merges.Contains(m))) - { - var bundlePath = merge.GetMergedBundle(); - if (File.Exists(bundlePath)) - File.Delete(bundlePath); - - var metadataPath = Path.Combine(Path.GetDirectoryName(bundlePath), "metadata.store"); - if (File.Exists(metadataPath)) - File.Delete(metadataPath); - - DeleteEmptyDirs(Path.GetDirectoryName(bundlePath), Paths.ScriptsDirectory); - } - else if (merge.IsBundleContent) - bundleMerges.Add(merge); - } - - Program.Inventory.Merges.Remove(merge); - } - if (Program.Inventory.HasChanged) - { - Program.Inventory.Save(); - if (bundleMerges.Count > 0) - { - HandleDeletedBundleMerges(bundleMerges); - return true; - } - // If mod index is null, we haven't refreshed it for the 1st time yet. Don't do it here. - if (_modIndex != null) - RefreshTrees(Program.Inventory.BundleChanged); - } - return false; - } - - void HandleDeletedBundleMerges(List bundleMerges) - { - var affectedBundles = bundleMerges.Select(merge => merge.GetMergedBundle()).Distinct(); - foreach (var bundlePath in affectedBundles) - { - InitializeProgressScreen("Merge Deleted"); - - new FileMerger(Program.Inventory, OnMergeProgressChanged, OnMergeComplete) - .RepackBundleAsync(bundlePath); - } - } - - #endregion - - #region File/Dir Operations - - void DeleteEmptyDirs(string dirPath, string stopPath) - { - if (dirPath.EqualsIgnoreCase(stopPath)) - return; - var dirInfo = new DirectoryInfo(dirPath); - if (!dirInfo.Exists || dirInfo.GetFiles().Length > 0 || dirInfo.GetDirectories().Length > 0) - return; - Directory.Delete(dirPath); - DeleteEmptyDirs(dirInfo.Parent.FullName, stopPath); - } - - #endregion - - #region Progress Screen - - void InitializeProgressScreen(string progressOf, ProgressBarStyle style = ProgressBarStyle.Marquee) - { - menuStrip.Enabled - = lblGameDir.Enabled - = txtGameDir.Enabled - = btnSelectGameDir.Enabled - = splitContainer.Panel1.Enabled - = splitContainer.Panel2.Enabled - = false; - progressBar.Value = 0; - lblProgressCurrentPhase.Text = progressOf; - lblProgressCurrentAction.Text = string.Empty; - progressBar.Style = style; - - switch (style) - { - case ProgressBarStyle.Continuous: - TaskbarProgress.SetValue(this.Handle, 0, 100); - TaskbarProgress.SetState(this.Handle, TaskbarProgress.TaskbarStates.Normal); - break; - case ProgressBarStyle.Marquee: - TaskbarProgress.SetState(this.Handle, TaskbarProgress.TaskbarStates.Indeterminate); - break; - } - - pnlProgress.Visible = true; - Update(); - } - - void HideProgressScreen() - { - pnlProgress.Visible = false; - menuStrip.Enabled - = lblGameDir.Enabled - = txtGameDir.Enabled - = btnSelectGameDir.Enabled - = splitContainer.Panel1.Enabled - = splitContainer.Panel2.Enabled - = true; - treMerges.Select(); - - TaskbarProgress.SetState(this.Handle, TaskbarProgress.TaskbarStates.NoProgress); - } - - #endregion - - #region Cross-thread Operations - - public DialogResult ShowMessage(string text, - string title = "", - MessageBoxButtons buttons = MessageBoxButtons.OK, - MessageBoxIcon icon = MessageBoxIcon.None) - { - this.ActivateSafely(); - - if (this.InvokeRequired) - { - return (DialogResult)this.Invoke(new Func( - () => { return MessageBox.Show(this, text, title, buttons, icon); })); - } - else - { - return MessageBox.Show(this, text, title, buttons, icon); - } - } - - public DialogResult ShowError(string text, string title = "Error") - { - return ShowMessage(text, title, MessageBoxButtons.OK, MessageBoxIcon.Error); - } - - public DialogResult ShowModal(Form form) - { - this.ActivateSafely(); - - if (this.InvokeRequired) - { - return (DialogResult)this.Invoke( - new Func( - () => { return form.ShowDialog(this); } - ) - ); - } - else - { - return form.ShowDialog(this); - } - } - - public void ActivateSafely() - { - if (this.InvokeRequired) - { - this.Invoke((MethodInvoker)delegate () - { - this.Activate(); - }); - } - else - this.Activate(); - } - - #endregion - - #region Menus - - void menuDependencies_Click(object sender, EventArgs e) - { - using (var dependencyForm = new DependencyForm()) - { - ShowModal(dependencyForm); - } - } - - private void menuOpenLoadOrderFile_Click(object sender, EventArgs e) - { - Program.TryOpenFile(Program.LoadOrder.FilePath); - } - - private void menuOpenMergedModDir_Click(object sender, EventArgs e) - { - Program.TryOpenDirectory(Paths.RetrieveMergedModDir()); - } - - private void menuOpenBundleContentDir_Click(object sender, EventArgs e) - { - Program.TryOpenDirectory(Paths.MergedBundleContent); - } - - private void menuOptions_Click(object sender, EventArgs e) - { - using (var optionsForm = new OptionsForm()) - { - ShowModal(optionsForm); - } - } - - private void menuRepackBundle_Click(object sender, EventArgs e) - { - var mergedBundles = Program.Inventory.Merges.Where(merge => merge.IsBundleContent).Select(merge => merge.GetMergedBundle()).Distinct(); - var mergedBundleCount = mergedBundles.Count(); - foreach (var bundlePath in mergedBundles) - { - InitializeProgressScreen($"Repacking Bundle{mergedBundleCount.GetPluralS()}"); - - new FileMerger(Program.Inventory, OnMergeProgressChanged, OnMergeComplete) - .RepackBundleAsync(bundlePath); - } - } - - private void menuExitAndPlay_Click(object sender, EventArgs e) - { - if (Program.TryOpenFile(Paths.GameExe)) - Environment.Exit(0); - } - - private void menuFile_DropDownOpening(object sender, EventArgs e) - { - menuRepackBundle.Enabled = Directory.Exists(Paths.MergedBundleContent); - - menuExitAndPlay.Enabled = File.Exists(Paths.GameExe); - } - - private void menuOpen_DropDownOpening(object sender, EventArgs e) - { - menuOpenLoadOrderFile.Enabled = File.Exists(Program.LoadOrder.FilePath); - - var mergedModDir = Paths.RetrieveMergedModDir(); - menuOpenMergedModDir.Enabled = (mergedModDir != null && Directory.Exists(mergedModDir)); - - menuOpenBundleContentDir.Enabled = Directory.Exists(Paths.MergedBundleContent); - } - - #endregion - } -} \ No newline at end of file + partial class MainForm : Form, IMergeNotifier + { + #region Members + + public string GameDirectorySetting => txtGameDir.Text; + + ModFileIndex _modIndex = null; + + #endregion + + #region Form Operations + + public MainForm() + { + InitializeComponent(); + this.Text += " v" + Application.ProductVersion; + } + + void MainForm_Load(object sender, EventArgs e) + { + txtGameDir.Text = Program.Settings.Get("GameDirectory"); + LoadLastWindowConfiguration(); + } + + async void MainForm_Shown(object sender, EventArgs e) + { + Update(); + + var repackingBundle = false; + if (!string.IsNullOrWhiteSpace(txtGameDir.Text) || !Paths.IsModsDirectoryDerived) + repackingBundle = await RefreshMergeInventory(); + if (repackingBundle) + return; + + if (!string.IsNullOrWhiteSpace(txtGameDir.Text) || + (!Paths.IsScriptsDirectoryDerived && !Paths.IsModsDirectoryDerived)) + RefreshConflictsTree(); + else + lblStatusLeft1.Text = "Please locate your 'The Witcher 3 Wild Hunt' game directory."; + } + + void MainForm_FormClosing(object sender, FormClosingEventArgs e) + { + if (pnlProgress.Visible) + { + e.Cancel = true; + return; + } + + Program.Settings.Set("GameDirectory", txtGameDir.Text); + + if (WindowState == FormWindowState.Maximized) + Program.Settings.Set("StartMaximized", true); + else + { + Program.Settings.Set("StartMaximized", false); + Program.Settings.Set("StartWidth", Width); + Program.Settings.Set("StartHeight", Height); + Program.Settings.Set("StartPosTop", Top); + Program.Settings.Set("StartPosLeft", Left); + } + Program.Settings.Set("StartSplitterPosPct", (int)((float)splitContainer.SplitterDistance / splitContainer.Width * 100f)); + Program.Settings.Save(); + } + + void LoadLastWindowConfiguration() + { + var top = Program.Settings.Get("StartPosTop"); + var left = Program.Settings.Get("StartPosLeft"); + if (top > 0) + Top = top; + if (left > 0) + Left = left; + if (Top > 0 || Left > 0) + StartPosition = FormStartPosition.Manual; + + var startWidth = Program.Settings.Get("StartWidth"); + var startHeight = Program.Settings.Get("StartHeight"); + if (startWidth > 0) + Width = startWidth; + if (startHeight > 0) + Height = startHeight; + + if (Program.Settings.Get("StartMaximized")) + WindowState = FormWindowState.Maximized; + + var splitterPosPct = Program.Settings.Get("StartSplitterPosPct"); + if (splitterPosPct > 0) + splitContainer.SplitterDistance = (int)(splitterPosPct / 100f * splitContainer.Width); + } + + void txtGameDir_TextChanged(object sender, EventArgs e) + { + Program.Settings.Set("GameDirectory", txtGameDir.Text); + } + + void UpdateStatusText() + { + var solvableCount = treConflicts.FileNodes.Count(node => ModFile.IsTextFile(node.Text)); + + if (treConflicts.IsEmpty()) + lblStatusLeft1.Text = "0 conflicts"; + else + { + lblStatusLeft1.Text = $"{solvableCount} mergeable"; + if (solvableCount < treConflicts.FileNodes.Count) + { + lblStatusLeft2.Text = $"{treConflicts.FileNodes.Count - solvableCount} not mergeable"; + lblStatusLeft2.Visible = true; + } + } + + lblStatusLeft3.Text = string.Format( + "{0} merge{1}", + treMerges.FileNodes.Count, + treMerges.FileNodes.Count.GetPluralS() + ); + lblStatusLeft3.Visible = true; + + if (_modIndex != null) + { + lblStatusRight.Text = string.Format( + "Found {0} mod{1}, {2} script{3}, {4} XML{5}, {6} bundle{7}", + _modIndex.ModCount, + _modIndex.ModCount.GetPluralS(), + _modIndex.ScriptCount, + _modIndex.ScriptCount.GetPluralS(), + _modIndex.XmlCount, + _modIndex.XmlCount.GetPluralS(), + _modIndex.BundleCount, + _modIndex.BundleCount.GetPluralS()); + } + } + + public void EnableMergeIfValidSelection() + { + var validFileNodeCount = treConflicts.FileNodes.Count(node => node.GetTreeNodes().Count(modNode => modNode.Checked) > 1); + btnCreateMerges.Enabled = (validFileNodeCount > 0); + btnCreateMerges.Text = (validFileNodeCount > 1 + ? "&Create " + validFileNodeCount + " Selected Merges" + : "&Create Selected Merge"); + } + + public void EnableUnmergeIfValidSelection() + { + var selectedCount = treMerges.FileNodes.Count(node => node.Checked); + btnDeleteMerges.Enabled = (selectedCount > 0); + btnDeleteMerges.Text = (selectedCount > 1 + ? "&Delete " + selectedCount + " Selected Merges" + : "&Delete Selected Merge"); + } + + #endregion + + #region Refreshing Trees + + async Task RefreshMergeInventory() + { + InitializeProgressScreen("Loading Merges", ProgressBarStyle.Continuous); + + lblProgressCurrentAction.Text = "Loading MergeInventory.xml file"; + Program.Inventory = await Task.Run(() => + MergeInventory.Load(Paths.Inventory) + ); + progressBar.Value = 25; + + lblProgressCurrentAction.Text = "Loading mods.settings file"; + Program.LoadOrder = await Task.Run(() => + new CustomLoadOrder() + ); + progressBar.Value = 50; + + if (Program.Settings.Get("ValidateCustomLoadOrder") && Program.Inventory.Merges.Any()) + { + lblProgressCurrentAction.Text = "Validating load order"; + await Task.Run(() => + LoadOrderValidator.ValidateAndFix(Program.LoadOrder) + ); + } + progressBar.Value = 75; + + lblProgressCurrentAction.Text = "Refreshing merge tree"; + return await Task.Run(() => + RefreshMergeTree() + ); + } + + bool RefreshMergeTree() + { + this.Invoke((MethodInvoker)delegate + { + treMerges.Nodes.Clear(); + }); + var changed = false; + var bundleMergesPruned = new List(); + var mergesToDelete = new List(); + for (int i = Program.Inventory.Merges.Count - 1; i >= 0; --i) + { + var merge = Program.Inventory.Merges[i]; + if (!File.Exists(merge.GetMergedFile()) && ConfirmPruneMissingMergeFile(merge)) + { + Program.Inventory.Merges.RemoveAt(i); + changed = true; + + if (merge.IsBundleContent) + bundleMergesPruned.Add(merge); + continue; + } + else + { + var willDelete = false; + foreach (var mod in merge.Mods) + { + var modFilePath = merge.GetModFile(mod.Name); + if (!File.Exists(modFilePath) && ConfirmDeleteMergeForMissingMod(merge, mod.Name)) + { + willDelete = true; + break; + } + var modLoadSetting = Program.LoadOrder.GetModLoadSettingByName(mod.Name); + if (modLoadSetting != null && !modLoadSetting.IsEnabled.Value && ConfirmDeleteMergeForDisabledMod(merge, mod.Name)) + { + willDelete = true; + break; + } + var latestHash = Tools.Hasher.ComputeHash(modFilePath); + if (latestHash != null && mod.Hash != latestHash) + { + mod.IsOutdated = true; + if (Program.Settings.Get("ValidateMergeSources")) + { + var choice = PromptToDeleteForChangedHash(merge, modFilePath, mod.Name); + if (choice == DialogResult.Yes) + { + willDelete = true; + break; + } + else if (choice == DialogResult.Cancel) // Never + { + Program.Settings.Set("ValidateMergeSources", false); + Program.Settings.Save(); + } + } + } + } + if (willDelete) + { + mergesToDelete.Add(merge); + continue; + } + } + + this.Invoke((MethodInvoker)delegate + { + var fileNode = new TreeNode + { + Text = merge.RelativePath, + ForeColor = MergeTree.FileNodeForeColor, + Tag = new MergeTree.NodeMetadata + { + FilePath = merge.GetMergedFile(), + ModFile = merge + } + }; + + var categoryNode = treMerges.GetCategoryNode(merge.Category); + if (categoryNode == null) + { + categoryNode = new TreeNode + { + Text = merge.Category.DisplayName, + ToolTipText = merge.Category.ToolTipText, + Tag = merge.Category + }; + treMerges.Nodes.Add(categoryNode); + } + categoryNode.Nodes.Add(fileNode); + + foreach (var mod in merge.Mods) + { + fileNode.Nodes.Add( + new TreeNode + { + Text = mod.Name, + Tag = new MergeTree.NodeMetadata + { + FilePath = merge.GetModFile(mod.Name), + FileHash = mod, + ModFile = merge + } + } + ); + } + }); + } + if (mergesToDelete.Any()) + { + if (DeleteMerges(mergesToDelete)) + return true; + } + if (changed) + { + Program.Inventory.Save(); + if (bundleMergesPruned.Any()) + return DeleteMerges(bundleMergesPruned); + } + this.Invoke((MethodInvoker)delegate + { + treMerges.Sort(); + treMerges.ExpandAll(); + treMerges.ScrollToTop(); + treMerges.SetFontBold(SMTree.LevelType.Categories); + foreach (var modNode in treMerges.ModNodes) + modNode.SetIsCheckBoxVisible(false); + + UpdateStatusText(); + EnableUnmergeIfValidSelection(); + + progressBar.Value = 100; + }); + return false; + } + + bool ConfirmPruneMissingMergeFile(Merge merge) + { + var msg = + "Can't find the merged version of the following file.\n\n" + + merge.RelativePath + "\n " + + string.Join("\n ", merge.Mods.Select(mod => mod.Name)) + "\n\n" + + "Expected path:\n" + + merge.GetMergedFile() + "\n\n"; + + msg += merge.IsBundleContent + ? "Remove from Merges list & repack merged bundle?" + : "Remove from Merges list?"; + + return (NotifyResult.Yes == ShowMessage( + msg, + "Missing Merge Inventory File", + NotifyButtons.YesNo, + DialogIcon.Question)); + } + + bool ConfirmDeleteMergeForMissingMod(Merge merge, string modName) + { + var msg = + $"Can't find the '{modName}' version of the following file, " + + "perhaps because the mod was uninstalled or updated.\n\n" + + merge.RelativePath + "\n " + + string.Join("\n ", merge.Mods.Select(mod => mod.Name)) + "\n\n" + + "Expected path:\n" + + merge.GetModFile(modName) + "\n\n"; + + msg += merge.IsBundleContent + ? "Delete this affected merge & repack the merged bundle?" + : "Delete this affected merge?"; + + return (NotifyResult.Yes == ShowMessage( + msg, + "Missing Merge Inventory File", + NotifyButtons.YesNo, + DialogIcon.Question)); + } + + bool ConfirmDeleteMergeForDisabledMod(Merge merge, string modName) + { + var msg = + $"In your custom load order, {modName} is disabled.\n" + + "Delete the following merge that includes the disabled mod?\n\n" + + merge.RelativePath + "\n " + + string.Join("\n ", merge.Mods.Select(mod => mod.Name)); + + return (NotifyResult.Yes == ShowMessage( + msg, + "Disabled Mod in Merge", + NotifyButtons.YesNo, + DialogIcon.Question)); + } + + private DialogResult PromptToDeleteForChangedHash(Merge merge, string modFilePath, string modName) + { + var msg = + $"The '{modName}' {(merge.IsBundleContent ? "bundle" : "version of the following file")} " + + "is different from when it was used in a merge, perhaps because the mod has been updated.\n\n" + + $"This file has changed:\n\n{modFilePath}\n\n" + + $"This merge is affected:\n\n{merge.RelativePath}\n " + + string.Join("\n ", merge.Mods.Select(mod => mod.Name)) + "\n\n"; + + msg += merge.IsBundleContent + ? "Delete this affected merge & repack the merged bundle?" + : "Delete this affected merge?"; + + MessageBoxManager.Cancel = "Ne&ver"; + MessageBoxManager.Register(); + + var choice = MessageBox.Show( + msg, + "Merged Mod File Changed", + MessageBoxButtons.YesNoCancel, + MessageBoxIcon.Exclamation); + + MessageBoxManager.Unregister(); + return choice; + } + + void RefreshConflictsTree(bool checkBundles = true) + { + checkBundles = checkBundles && Program.Settings.Get("CheckBundleContents"); + + InitializeProgressScreen("Detecting Conflicts", ProgressBarStyle.Continuous); + lblStatusLeft1.Text = "Refreshing..."; + lblStatusLeft2.Visible = lblStatusLeft3.Visible = false; + + if (Program.Inventory.ScriptsChanged && Program.Inventory.BundleChanged) + treConflicts.Nodes.Clear(); + else + { + var nodesToUpdate = new List(); + + var scriptCatNode = treConflicts.GetCategoryNode(Categories.Script); + if (scriptCatNode != null) + nodesToUpdate.Add(scriptCatNode); + + var xmlCatNode = treConflicts.GetCategoryNode(Categories.Xml); + if (xmlCatNode != null) + nodesToUpdate.Add(xmlCatNode); + + if (Program.Inventory.BundleChanged || checkBundles || !Program.Settings.Get("CheckBundleContents")) + { + var bundleTextCatNode = treConflicts.GetCategoryNode(Categories.BundleText); + if (bundleTextCatNode != null) + nodesToUpdate.Add(bundleTextCatNode); + var bundleNotMergeableCatNode = treConflicts.GetCategoryNode(Categories.BundleNotMergeable); + if (bundleNotMergeableCatNode != null) + nodesToUpdate.Add(bundleNotMergeableCatNode); + } + + var missingFileNodes = treConflicts.FileNodes.Where(node => + node.GetTreeNodes().Any(modNode => + !File.Exists(modNode.GetMetadata().FilePath) + ) + ); + nodesToUpdate.AddRange(missingFileNodes); + + foreach (var node in nodesToUpdate) + treConflicts.Nodes.Remove(node); + foreach (var catNode in treConflicts.CategoryNodes) // Hack-fix for bug: Empty category remained on refresh after resolving conflicts outside of SM + { + if (catNode.Nodes.Count == 0) + treConflicts.Nodes.Remove(catNode); + } + } + + _modIndex = new ModFileIndex(); + _modIndex.BuildAsync( + Program.Settings.Get("CheckScripts"), + Program.Settings.Get("CheckXmlFiles"), + Program.Settings.Get("CheckBundleContents"), + OnRefreshConflictsProgressChanged, + OnRefreshConflictsComplete); + } + + void OnRefreshConflictsProgressChanged(object sender, ProgressChangedEventArgs e) + { + progressBar.Value = e.ProgressPercentage; + lblProgressCurrentAction.Text = e.UserState as string; + + TaskbarProgress.SetState(this.Handle, TaskbarProgress.TaskbarStates.Normal); + TaskbarProgress.SetValue(this.Handle, e.ProgressPercentage, 100); + } + + void OnRefreshConflictsComplete(object sender, RunWorkerCompletedEventArgs e) + { + if (_modIndex.HasConflict) + { + foreach (var conflict in _modIndex.Conflicts) + { + if (Program.Inventory.HasResolvedConflict(conflict)) + continue; + + var fileNode = treConflicts.FileNodes.FirstOrDefault(node => + node.Text.EqualsIgnoreCase(conflict.RelativePath)); + + if (fileNode == null) + { + fileNode = new TreeNode + { + Text = conflict.RelativePath, + Tag = new SMTree.NodeMetadata + { + FilePath = (conflict.Category == Categories.Script || conflict.Category == Categories.Xml + ? conflict.GetVanillaFile() + : conflict.RelativePath), + ModFile = conflict + } + }; + + var categoryNode = treConflicts.GetCategoryNode(conflict.Category); + if (categoryNode == null) + { + categoryNode = new TreeNode + { + Text = conflict.Category.DisplayName, + ToolTipText = conflict.Category.ToolTipText, + Tag = conflict.Category + }; + treConflicts.Nodes.Add(categoryNode); + } + categoryNode.Nodes.Add(fileNode); + } + + var merge = Program.Inventory.Merges.FirstOrDefault(mrg => mrg.RelativePath.EqualsIgnoreCase(conflict.RelativePath)); + foreach (var mod in conflict.Mods) + { + var mergeModHash = merge?.Mods.FirstOrDefault(m => m.Name.EqualsIgnoreCase(mod.Name)); + if (mergeModHash != null && !mergeModHash.IsOutdated) + continue; + + var modNode = fileNode.GetTreeNodes().FirstOrDefault(node => + node.Text.EqualsIgnoreCase(mod.Name)); + + if (modNode == null) + { + modNode = new TreeNode + { + Text = mod.Name, + Tag = new SMTree.NodeMetadata + { + FilePath = conflict.GetModFile(mod.Name), + FileHash = mergeModHash, + ModFile = conflict + } + }; + fileNode.Nodes.Add(modNode); + } + } + } + + treConflicts.Sort(); + treConflicts.ExpandAll(); + treConflicts.Select(); + foreach (var catNode in treConflicts.CategoryNodes) + { + if (!(catNode.Tag as ModFileCategory).IsSupported) + { + catNode.SetIsCheckBoxVisible(false, true); + if (Program.Settings.Get("CollapseNotMergeable")) + catNode.Collapse(); + } + } + + treConflicts.SetStylesForCustomLoadOrder(); + + foreach (var fileNode in treConflicts.FileNodes) + { + if (Program.Settings.Get("CollapseCustomLoadOrder") && fileNode.ForeColor == ConflictTree.ResolvedForeColor) + fileNode.Collapse(); + } + } + + treConflicts.ScrollToTop(); + treConflicts.SetFontBold(SMTree.LevelType.Categories); + UpdateStatusText(); + HideProgressScreen(); + EnableMergeIfValidSelection(); + } + + #endregion + + #region Button Clicks + + void btnSelectGameDirectory_Click(object sender, EventArgs e) + { + var dirChoice = GetUserDirectoryChoice(); + if (!string.IsNullOrWhiteSpace(dirChoice)) + { + if (dirChoice.EndsWithIgnoreCase("The Witcher 3 Wild Hunt\\Mods")) // Auto-truncate "Mods" + dirChoice = Path.GetDirectoryName(dirChoice); + + txtGameDir.Text = dirChoice; + RefreshTrees(); + } + } + + string GetUserDirectoryChoice() + { + var dlgSelectRoot = new FolderBrowserDialog(); + if (Directory.Exists(txtGameDir.Text)) + dlgSelectRoot.SelectedPath = txtGameDir.Text; + if (DialogResult.OK == dlgSelectRoot.ShowDialog()) + return dlgSelectRoot.SelectedPath; + else + return null; + } + + async void btnRefreshMerged_Click(object sender, EventArgs e) + { + if (string.IsNullOrWhiteSpace(txtGameDir.Text)) + { + Program.MainForm.ShowMessage( + "Please locate your 'The Witcher 3 Wild Hunt' game directory."); + return; + } + + if (txtGameDir.Text.EndsWithIgnoreCase("The Witcher 3 Wild Hunt\\Mods")) // Auto-truncate "Mods" + txtGameDir.Text = Path.GetDirectoryName(txtGameDir.Text); + + if (Paths.ValidateModsDirectory()) + await RefreshMergeInventory(); + + HideProgressScreen(); + } + + void btnRefreshConflicts_Click(object sender, EventArgs e) + { + if (string.IsNullOrWhiteSpace(txtGameDir.Text)) + { + Program.MainForm.ShowMessage( + "Please locate your 'The Witcher 3 Wild Hunt' game directory."); + return; + } + + if (txtGameDir.Text.EndsWithIgnoreCase("The Witcher 3 Wild Hunt\\Mods")) // Auto-truncate "Mods" + txtGameDir.Text = Path.GetDirectoryName(txtGameDir.Text); + + RefreshTrees(); + } + + void btnMergeFiles_Click(object sender, EventArgs e) + { + if (!Paths.ValidateModsDirectory() || + (treConflicts.FileNodes.Any(node => ModFile.IsScript(node.Text)) && !Paths.ValidateScriptsDirectory()) || + (treConflicts.FileNodes.Any(node => ModFile.IsBundle(node.Text)) && !Paths.ValidateBundlesDirectory())) + return; + + var mergedModName = Paths.RetrieveMergedModName(); + if (mergedModName == null) + return; + + InitializeProgressScreen("Merging"); + + Program.Inventory = MergeInventory.Load(Paths.Inventory); + + var merger = new InteractiveMergeRunner(Program.Inventory, OnMergeProgressChanged, OnMergeComplete); + + var fileNodes = treConflicts.FileNodes.Where(node => node.GetTreeNodes().Count(modNode => modNode.Checked) > 1); + + merger.MergeByTreeNodesAsync(fileNodes, mergedModName); + } + + void OnMergeProgressChanged(object sender, ProgressChangedEventArgs e) + { + var mergeProgress = (MergeProgressInfo)e.UserState; + lblProgressCurrentPhase.Text = mergeProgress.CurrentPhase; + lblProgressCurrentAction.Text = mergeProgress.CurrentAction; + } + + void OnMergeComplete(object sender, RunWorkerCompletedEventArgs e) + { + if (Program.Inventory.HasChanged) + { + Program.Inventory.Save(); + RefreshTrees(Program.Inventory.BundleChanged); + } + else + { + HideProgressScreen(); + EnableMergeIfValidSelection(); + } + } + + void btnDeleteMerges_Click(object sender, EventArgs e) + { + var fileNodes = treMerges.FileNodes.Where(node => node.Checked); + DeleteMerges(fileNodes); + } + + async void RefreshTrees(bool checkBundles = true) + { + if (!Paths.ValidateModsDirectory() || + (Program.Settings.Get("CheckScripts") && !Paths.ValidateScriptsDirectory()) || + (Program.Settings.Get("CheckBundleContents") && !Paths.ValidateBundlesDirectory())) + return; + + if (Program.Inventory == null) + await RefreshMergeInventory(); + else + { + InitializeProgressScreen("Loading Merges"); + Program.LoadOrder.Refresh(); + RefreshMergeTree(); + } + RefreshConflictsTree(checkBundles); + } + + #endregion + + #region Key Input + + private void MainForm_KeyDown(object sender, KeyEventArgs e) + { + if (e.KeyCode == Keys.F5 && btnRefreshConflicts.Enabled) + btnRefreshConflicts_Click(null, null); + } + + void txt_KeyDown(object sender, KeyEventArgs e) + { + if (e.Control && e.KeyCode == Keys.A) + (sender as TextBox).SelectAll(); + } + + void splitContainer_Panel1_PreviewKeyDown(object sender, PreviewKeyDownEventArgs e) + { + if (e.KeyCode == Keys.Enter && btnCreateMerges.Enabled) + btnMergeFiles_Click(null, null); + } + + void splitContainer_Panel2_PreviewKeyDown(object sender, PreviewKeyDownEventArgs e) + { + if (e.KeyCode == Keys.Delete && btnDeleteMerges.Enabled) + btnDeleteMerges_Click(null, null); + } + + #endregion + + #region Deleting Merges + + public void DeleteMerges(IEnumerable fileNodes) + { + var merges = fileNodes.Select(node => + Program.Inventory.Merges.First(merge => + merge.RelativePath.EqualsIgnoreCase(node.Text))) + .ToList(); + DeleteMerges(merges); + } + + bool DeleteMerges(List merges) + { + var bundleMerges = new List(); + foreach (var merge in merges) + { + var mergePath = merge.GetMergedFile(); + if (File.Exists(mergePath)) + { + File.Delete(mergePath); + DeleteEmptyDirs(Path.GetDirectoryName(mergePath), Paths.ScriptsDirectory); + } + if (merge.IsBundleContent) + { + var mergesForBundle = Program.Inventory.Merges.Where(m => + m.IsBundleContent && + m.MergedModName.EqualsIgnoreCase(merge.MergedModName) && + m.BundleName.EqualsIgnoreCase(merge.BundleName)); + if (mergesForBundle.All(m => merges.Contains(m))) + { + var bundlePath = merge.GetMergedBundle(); + if (File.Exists(bundlePath)) + File.Delete(bundlePath); + + var metadataPath = Path.Combine(Path.GetDirectoryName(bundlePath), "metadata.store"); + if (File.Exists(metadataPath)) + File.Delete(metadataPath); + + DeleteEmptyDirs(Path.GetDirectoryName(bundlePath), Paths.ScriptsDirectory); + } + else if (merge.IsBundleContent) + bundleMerges.Add(merge); + } + + Program.Inventory.Merges.Remove(merge); + } + if (Program.Inventory.HasChanged) + { + Program.Inventory.Save(); + if (bundleMerges.Count > 0) + { + HandleDeletedBundleMerges(bundleMerges); + return true; + } + // If mod index is null, we haven't refreshed it for the 1st time yet. Don't do it here. + if (_modIndex != null) + RefreshTrees(Program.Inventory.BundleChanged); + } + return false; + } + + void HandleDeletedBundleMerges(List bundleMerges) + { + var affectedBundles = bundleMerges.Select(merge => merge.GetMergedBundle()).Distinct(); + foreach (var bundlePath in affectedBundles) + { + InitializeProgressScreen("Merge Deleted"); + + new InteractiveMergeRunner(Program.Inventory, OnMergeProgressChanged, OnMergeComplete) + .RepackBundleAsync(bundlePath); + } + } + + #endregion + + #region File/Dir Operations + + void DeleteEmptyDirs(string dirPath, string stopPath) + { + if (dirPath.EqualsIgnoreCase(stopPath)) + return; + var dirInfo = new DirectoryInfo(dirPath); + if (!dirInfo.Exists || dirInfo.GetFiles().Length > 0 || dirInfo.GetDirectories().Length > 0) + return; + Directory.Delete(dirPath); + DeleteEmptyDirs(dirInfo.Parent.FullName, stopPath); + } + + #endregion + + #region Progress Screen + + void InitializeProgressScreen(string progressOf, ProgressBarStyle style = ProgressBarStyle.Marquee) + { + menuStrip.Enabled + = lblGameDir.Enabled + = txtGameDir.Enabled + = btnSelectGameDir.Enabled + = splitContainer.Panel1.Enabled + = splitContainer.Panel2.Enabled + = false; + progressBar.Value = 0; + lblProgressCurrentPhase.Text = progressOf; + lblProgressCurrentAction.Text = string.Empty; + progressBar.Style = style; + + switch (style) + { + case ProgressBarStyle.Continuous: + TaskbarProgress.SetValue(this.Handle, 0, 100); + TaskbarProgress.SetState(this.Handle, TaskbarProgress.TaskbarStates.Normal); + break; + case ProgressBarStyle.Marquee: + TaskbarProgress.SetState(this.Handle, TaskbarProgress.TaskbarStates.Indeterminate); + break; + } + + pnlProgress.Visible = true; + Update(); + } + + void HideProgressScreen() + { + pnlProgress.Visible = false; + menuStrip.Enabled + = lblGameDir.Enabled + = txtGameDir.Enabled + = btnSelectGameDir.Enabled + = splitContainer.Panel1.Enabled + = splitContainer.Panel2.Enabled + = true; + treMerges.Select(); + + TaskbarProgress.SetState(this.Handle, TaskbarProgress.TaskbarStates.NoProgress); + } + + #endregion + + #region Cross-thread Operations + + // IMergeNotifier is defined in Core against neutral NotifyResult/NotifyButtons/ + // DialogIcon types (Core can't reference System.Windows.Forms at all - see + // IMergeNotifier.cs) - this translates to/from the real WinForms MessageBox + // around the actual MessageBox.Show(...) call. + public NotifyResult ShowMessage(string text, + string title = "", + NotifyButtons buttons = NotifyButtons.OK, + DialogIcon icon = DialogIcon.None, + NotifyResult defaultResult = NotifyResult.None) + { + this.ActivateSafely(); + + var nativeButtons = ToNative(buttons); + var nativeIcon = ToNative(icon); + var nativeDefault = ToNativeDefaultButton(buttons, defaultResult); + + if (this.InvokeRequired) + { + return ToNeutral((DialogResult)this.Invoke(new Func( + () => { return MessageBox.Show(this, text, title, nativeButtons, nativeIcon, nativeDefault); }))); + } + else + { + return ToNeutral(MessageBox.Show(this, text, title, nativeButtons, nativeIcon, nativeDefault)); + } + } + + public NotifyResult ShowError(string text, string title = "Error") + { + return ShowMessage(text, title, NotifyButtons.OK, DialogIcon.Error); + } + + // Not part of IMergeNotifier - see IMergeNotifier.cs for why ShowModal was + // dropped from that interface during the Core split. Every caller (report-form + // popups, the Dependencies/Options menu commands below) is GUI-only code that + // already lives in this project, so it calls this directly instead. + public DialogResult ShowModal(Form form) + { + this.ActivateSafely(); + + if (this.InvokeRequired) + { + return (DialogResult)this.Invoke( + new Func( + () => { return form.ShowDialog(this); } + ) + ); + } + else + { + return form.ShowDialog(this); + } + } + + public void ActivateSafely() + { + if (this.InvokeRequired) + { + this.Invoke((MethodInvoker)delegate () + { + this.Activate(); + }); + } + else + this.Activate(); + } + + static MessageBoxButtons ToNative(NotifyButtons buttons) + { + return buttons switch + { + NotifyButtons.OK => MessageBoxButtons.OK, + NotifyButtons.OKCancel => MessageBoxButtons.OKCancel, + NotifyButtons.AbortRetryIgnore => MessageBoxButtons.AbortRetryIgnore, + NotifyButtons.YesNoCancel => MessageBoxButtons.YesNoCancel, + NotifyButtons.YesNo => MessageBoxButtons.YesNo, + NotifyButtons.RetryCancel => MessageBoxButtons.RetryCancel, + _ => MessageBoxButtons.OK, + }; + } + + static MessageBoxIcon ToNative(DialogIcon icon) + { + return icon switch + { + DialogIcon.None => MessageBoxIcon.None, + DialogIcon.Warning => MessageBoxIcon.Warning, + DialogIcon.Error => MessageBoxIcon.Error, + DialogIcon.Exclamation => MessageBoxIcon.Exclamation, + DialogIcon.Information => MessageBoxIcon.Information, + DialogIcon.Question => MessageBoxIcon.Question, + _ => MessageBoxIcon.None, + }; + } + + // MessageBoxDefaultButton is positional (Button1/2/3), but IMergeNotifier's + // callers think in terms of which *result* they want pre-focused (e.g. "No"), + // not which position it happens to occupy in a given button set - so this + // finds preferred's position within the actual button set being shown. + // NotifyResult.None means "no preference", which keeps WinForms' own default + // (Button1) exactly as every pre-existing ShowMessage call site already got. + static MessageBoxDefaultButton ToNativeDefaultButton(NotifyButtons buttons, NotifyResult preferred) + { + if (preferred == NotifyResult.None) + return MessageBoxDefaultButton.Button1; + + var order = ButtonOrder(buttons); + var index = Array.IndexOf(order, preferred); + return index switch + { + 1 => MessageBoxDefaultButton.Button2, + 2 => MessageBoxDefaultButton.Button3, + _ => MessageBoxDefaultButton.Button1, // index 0, or not found in this button set + }; + } + + // The order WinForms actually lays these buttons out left-to-right. + static NotifyResult[] ButtonOrder(NotifyButtons buttons) + { + return buttons switch + { + NotifyButtons.OK => new[] { NotifyResult.OK }, + NotifyButtons.OKCancel => new[] { NotifyResult.OK, NotifyResult.Cancel }, + NotifyButtons.AbortRetryIgnore => new[] { NotifyResult.Abort, NotifyResult.Retry, NotifyResult.Ignore }, + NotifyButtons.YesNoCancel => new[] { NotifyResult.Yes, NotifyResult.No, NotifyResult.Cancel }, + NotifyButtons.YesNo => new[] { NotifyResult.Yes, NotifyResult.No }, + NotifyButtons.RetryCancel => new[] { NotifyResult.Retry, NotifyResult.Cancel }, + _ => new[] { NotifyResult.OK }, + }; + } + + static NotifyResult ToNeutral(DialogResult result) + { + return result switch + { + DialogResult.None => NotifyResult.None, + DialogResult.OK => NotifyResult.OK, + DialogResult.Cancel => NotifyResult.Cancel, + DialogResult.Abort => NotifyResult.Abort, + DialogResult.Retry => NotifyResult.Retry, + DialogResult.Ignore => NotifyResult.Ignore, + DialogResult.Yes => NotifyResult.Yes, + DialogResult.No => NotifyResult.No, + _ => NotifyResult.None, + }; + } + + #endregion + + #region Menus + + void menuDependencies_Click(object sender, EventArgs e) + { + using (var dependencyForm = new DependencyForm()) + { + ShowModal(dependencyForm); + } + } + + private void menuOpenLoadOrderFile_Click(object sender, EventArgs e) + { + Program.TryOpenFile(Program.LoadOrder.FilePath); + } + + private void menuOpenMergedModDir_Click(object sender, EventArgs e) + { + Program.TryOpenDirectory(Paths.RetrieveMergedModDir()); + } + + private void menuOpenBundleContentDir_Click(object sender, EventArgs e) + { + Program.TryOpenDirectory(Paths.MergedBundleContent); + } + + private void menuOptions_Click(object sender, EventArgs e) + { + using (var optionsForm = new OptionsForm()) + { + ShowModal(optionsForm); + } + } + + private void menuRepackBundle_Click(object sender, EventArgs e) + { + var mergedBundles = Program.Inventory.Merges.Where(merge => merge.IsBundleContent).Select(merge => merge.GetMergedBundle()).Distinct(); + var mergedBundleCount = mergedBundles.Count(); + foreach (var bundlePath in mergedBundles) + { + InitializeProgressScreen($"Repacking Bundle{mergedBundleCount.GetPluralS()}"); + + new InteractiveMergeRunner(Program.Inventory, OnMergeProgressChanged, OnMergeComplete) + .RepackBundleAsync(bundlePath); + } + } + + private void menuExitAndPlay_Click(object sender, EventArgs e) + { + if (Program.TryOpenFile(Paths.GameExe)) + Environment.Exit(0); + } + + private void menuFile_DropDownOpening(object sender, EventArgs e) + { + menuRepackBundle.Enabled = Directory.Exists(Paths.MergedBundleContent); + + menuExitAndPlay.Enabled = File.Exists(Paths.GameExe); + } + + private void menuOpen_DropDownOpening(object sender, EventArgs e) + { + menuOpenLoadOrderFile.Enabled = File.Exists(Program.LoadOrder.FilePath); + + var mergedModDir = Paths.RetrieveMergedModDir(); + menuOpenMergedModDir.Enabled = (mergedModDir != null && Directory.Exists(mergedModDir)); + + menuOpenBundleContentDir.Enabled = Directory.Exists(Paths.MergedBundleContent); + } + + #endregion + } +} diff --git a/WitcherScriptMerger/Forms/MergeReportForm.cs b/WitcherScriptMerger/Forms/MergeReportForm.cs index 2ea980f..eb7c31f 100644 --- a/WitcherScriptMerger/Forms/MergeReportForm.cs +++ b/WitcherScriptMerger/Forms/MergeReportForm.cs @@ -3,90 +3,90 @@ namespace WitcherScriptMerger.Forms { - partial class MergeReportForm : Form - { - #region Initialization - - public MergeReportForm( - int mergeNum, int mergeTotal, - string file1, string file2, string outputFile, - string modName1, string modName2) - { - InitializeComponent(); - - if (mergeTotal > 1) - { - Text += $" ({mergeNum} of {mergeTotal})"; // Window title - if (mergeNum < mergeTotal) - btnOK.Text = "Continue"; - } - - lblTempContentFiles.Visible = outputFile.StartsWithIgnoreCase(Paths.MergedBundleContent); - - grpFile1.Text = modName1; - grpFile2.Text = modName2; - - txtFilePath1.Text = file1; - txtFilePath2.Text = file2; - txtMergedPath.Text = outputFile; - - chkShowAfterMerge.Checked = Program.Settings.Get("ReportAfterMerge"); - - btnOK.Select(); - - lblPlusAndArrow.Left = (ClientSize.Width / 2) - (lblPlusAndArrow.Width / 2); - } - - void MergeReportForm_FormClosing(object sender, FormClosingEventArgs e) - { - Program.Settings.Set("ReportAfterMerge", chkShowAfterMerge.Checked); - } - - #endregion - - #region Button Clicks - - void btnOpenFile1_Click(object sender, EventArgs e) - { - Program.TryOpenFile(txtFilePath1.Text); - } - - void btnOpenFile2_Click(object sender, EventArgs e) - { - Program.TryOpenFile(txtFilePath2.Text); - } - - void btnOpenOutputFile_Click(object sender, EventArgs e) - { - Program.TryOpenFile(txtMergedPath.Text); - } - - void btnOpenDir1_Click(object sender, EventArgs e) - { - Program.TryOpenFileLocation(txtFilePath1.Text); - } - - void btnOpenDir2_Click(object sender, EventArgs e) - { - Program.TryOpenFileLocation(txtFilePath2.Text); - } - - void btnOpenOutputDir_Click(object sender, EventArgs e) - { - Program.TryOpenFileLocation(txtMergedPath.Text); - } - - void btnOK_Click(object sender, EventArgs e) - { - DialogResult = DialogResult.OK; - } - - #endregion - - void txt_KeyDown(object sender, KeyEventArgs e) - { - if (e.Control && e.KeyCode == Keys.A) - (sender as TextBox).SelectAll(); - } - } + partial class MergeReportForm : Form + { + #region Initialization + + public MergeReportForm( + int mergeNum, int mergeTotal, + string file1, string file2, string outputFile, + string modName1, string modName2) + { + InitializeComponent(); + + if (mergeTotal > 1) + { + Text += $" ({mergeNum} of {mergeTotal})"; // Window title + if (mergeNum < mergeTotal) + btnOK.Text = "Continue"; + } + + lblTempContentFiles.Visible = outputFile.StartsWithIgnoreCase(Paths.MergedBundleContent); + + grpFile1.Text = modName1; + grpFile2.Text = modName2; + + txtFilePath1.Text = file1; + txtFilePath2.Text = file2; + txtMergedPath.Text = outputFile; + + chkShowAfterMerge.Checked = Program.Settings.Get("ReportAfterMerge"); + + btnOK.Select(); + + lblPlusAndArrow.Left = (ClientSize.Width / 2) - (lblPlusAndArrow.Width / 2); + } + + void MergeReportForm_FormClosing(object sender, FormClosingEventArgs e) + { + Program.Settings.Set("ReportAfterMerge", chkShowAfterMerge.Checked); + } + + #endregion + + #region Button Clicks + + void btnOpenFile1_Click(object sender, EventArgs e) + { + Program.TryOpenFile(txtFilePath1.Text); + } + + void btnOpenFile2_Click(object sender, EventArgs e) + { + Program.TryOpenFile(txtFilePath2.Text); + } + + void btnOpenOutputFile_Click(object sender, EventArgs e) + { + Program.TryOpenFile(txtMergedPath.Text); + } + + void btnOpenDir1_Click(object sender, EventArgs e) + { + Program.TryOpenFileLocation(txtFilePath1.Text); + } + + void btnOpenDir2_Click(object sender, EventArgs e) + { + Program.TryOpenFileLocation(txtFilePath2.Text); + } + + void btnOpenOutputDir_Click(object sender, EventArgs e) + { + Program.TryOpenFileLocation(txtMergedPath.Text); + } + + void btnOK_Click(object sender, EventArgs e) + { + DialogResult = DialogResult.OK; + } + + #endregion + + void txt_KeyDown(object sender, KeyEventArgs e) + { + if (e.Control && e.KeyCode == Keys.A) + (sender as TextBox).SelectAll(); + } + } } diff --git a/WitcherScriptMerger/Forms/MessageBoxManager.cs b/WitcherScriptMerger/Forms/MessageBoxManager.cs index ee3ce9b..2ec8d5f 100644 --- a/WitcherScriptMerger/Forms/MessageBoxManager.cs +++ b/WitcherScriptMerger/Forms/MessageBoxManager.cs @@ -3,216 +3,214 @@ #pragma warning disable 0618 using System.Text; using System.Runtime.InteropServices; -using System.Security.Permissions; -[assembly: SecurityPermission(SecurityAction.RequestMinimum, UnmanagedCode = true)] namespace System.Windows.Forms { - class MessageBoxManager - { - private delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam); - private delegate bool EnumChildProc(IntPtr hWnd, IntPtr lParam); - - private const int WH_CALLWNDPROCRET = 12; - private const int WM_DESTROY = 0x0002; - private const int WM_INITDIALOG = 0x0110; - private const int WM_TIMER = 0x0113; - private const int WM_USER = 0x400; - private const int DM_GETDEFID = WM_USER + 0; - - private const int MBOK = 1; - private const int MBCancel = 2; - private const int MBAbort = 3; - private const int MBRetry = 4; - private const int MBIgnore = 5; - private const int MBYes = 6; - private const int MBNo = 7; - - - [DllImport("user32.dll")] - private static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam); - - [DllImport("user32.dll")] - private static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hInstance, int threadId); - - [DllImport("user32.dll")] - private static extern int UnhookWindowsHookEx(IntPtr idHook); - - [DllImport("user32.dll")] - private static extern IntPtr CallNextHookEx(IntPtr idHook, int nCode, IntPtr wParam, IntPtr lParam); - - [DllImport("user32.dll", EntryPoint = "GetWindowTextLengthW", CharSet = CharSet.Unicode)] - private static extern int GetWindowTextLength(IntPtr hWnd); - - [DllImport("user32.dll", EntryPoint = "GetWindowTextW", CharSet = CharSet.Unicode)] - private static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int maxLength); - - [DllImport("user32.dll")] - private static extern int EndDialog(IntPtr hDlg, IntPtr nResult); - - [DllImport("user32.dll")] - private static extern bool EnumChildWindows(IntPtr hWndParent, EnumChildProc lpEnumFunc, IntPtr lParam); - - [DllImport("user32.dll", EntryPoint = "GetClassNameW", CharSet = CharSet.Unicode)] - private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); - - [DllImport("user32.dll")] - private static extern int GetDlgCtrlID(IntPtr hwndCtl); - - [DllImport("user32.dll")] - private static extern IntPtr GetDlgItem(IntPtr hDlg, int nIDDlgItem); - - [DllImport("user32.dll", EntryPoint = "SetWindowTextW", CharSet = CharSet.Unicode)] - private static extern bool SetWindowText(IntPtr hWnd, string lpString); - - - [StructLayout(LayoutKind.Sequential)] - public struct CWPRETSTRUCT - { - public IntPtr lResult; - public IntPtr lParam; - public IntPtr wParam; - public uint message; - public IntPtr hwnd; - }; - - private static HookProc hookProc; - private static EnumChildProc enumProc; - [ThreadStatic] - private static IntPtr hHook; - [ThreadStatic] - private static int nButton; - - /// - /// OK text - /// - public static string OK = "&OK"; - /// - /// Cancel text - /// - public static string Cancel = "&Cancel"; - /// - /// Abort text - /// - public static string Abort = "&Abort"; - /// - /// Retry text - /// - public static string Retry = "&Retry"; - /// - /// Ignore text - /// - public static string Ignore = "&Ignore"; - /// - /// Yes text - /// - public static string Yes = "&Yes"; - /// - /// No text - /// - public static string No = "&No"; - - static MessageBoxManager() - { - hookProc = new HookProc(MessageBoxHookProc); - enumProc = new EnumChildProc(MessageBoxEnumProc); - hHook = IntPtr.Zero; - } - - /// - /// Enables MessageBoxManager functionality - /// - /// - /// MessageBoxManager functionality is enabled on current thread only. - /// Each thread that needs MessageBoxManager functionality has to call this method. - /// - public static void Register() - { - if (hHook != IntPtr.Zero) - throw new NotSupportedException("One hook per thread allowed."); - hHook = SetWindowsHookEx(WH_CALLWNDPROCRET, hookProc, IntPtr.Zero, AppDomain.GetCurrentThreadId()); - } - - /// - /// Disables MessageBoxManager functionality - /// - /// - /// Disables MessageBoxManager functionality on current thread only. - /// - public static void Unregister() - { - if (hHook != IntPtr.Zero) - { - UnhookWindowsHookEx(hHook); - hHook = IntPtr.Zero; - } - } - - private static IntPtr MessageBoxHookProc(int nCode, IntPtr wParam, IntPtr lParam) - { - if (nCode < 0) - return CallNextHookEx(hHook, nCode, wParam, lParam); - - CWPRETSTRUCT msg = (CWPRETSTRUCT)Marshal.PtrToStructure(lParam, typeof(CWPRETSTRUCT)); - IntPtr hook = hHook; - - if (msg.message == WM_INITDIALOG) - { - int nLength = GetWindowTextLength(msg.hwnd); - StringBuilder className = new StringBuilder(10); - GetClassName(msg.hwnd, className, className.Capacity); - if (className.ToString() == "#32770") - { - nButton = 0; - EnumChildWindows(msg.hwnd, enumProc, IntPtr.Zero); - if (nButton == 1) - { - IntPtr hButton = GetDlgItem(msg.hwnd, MBCancel); - if (hButton != IntPtr.Zero) - SetWindowText(hButton, OK); - } - } - } - - return CallNextHookEx(hook, nCode, wParam, lParam); - } - - private static bool MessageBoxEnumProc(IntPtr hWnd, IntPtr lParam) - { - StringBuilder className = new StringBuilder(10); - GetClassName(hWnd, className, className.Capacity); - if (className.ToString() == "Button") - { - int ctlId = GetDlgCtrlID(hWnd); - switch (ctlId) - { - case MBOK: - SetWindowText(hWnd, OK); - break; - case MBCancel: - SetWindowText(hWnd, Cancel); - break; - case MBAbort: - SetWindowText(hWnd, Abort); - break; - case MBRetry: - SetWindowText(hWnd, Retry); - break; - case MBIgnore: - SetWindowText(hWnd, Ignore); - break; - case MBYes: - SetWindowText(hWnd, Yes); - break; - case MBNo: - SetWindowText(hWnd, No); - break; - - } - nButton++; - } - - return true; - } - } + class MessageBoxManager + { + private delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam); + private delegate bool EnumChildProc(IntPtr hWnd, IntPtr lParam); + + private const int WH_CALLWNDPROCRET = 12; + private const int WM_DESTROY = 0x0002; + private const int WM_INITDIALOG = 0x0110; + private const int WM_TIMER = 0x0113; + private const int WM_USER = 0x400; + private const int DM_GETDEFID = WM_USER + 0; + + private const int MBOK = 1; + private const int MBCancel = 2; + private const int MBAbort = 3; + private const int MBRetry = 4; + private const int MBIgnore = 5; + private const int MBYes = 6; + private const int MBNo = 7; + + + [DllImport("user32.dll")] + private static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hInstance, int threadId); + + [DllImport("user32.dll")] + private static extern int UnhookWindowsHookEx(IntPtr idHook); + + [DllImport("user32.dll")] + private static extern IntPtr CallNextHookEx(IntPtr idHook, int nCode, IntPtr wParam, IntPtr lParam); + + [DllImport("user32.dll", EntryPoint = "GetWindowTextLengthW", CharSet = CharSet.Unicode)] + private static extern int GetWindowTextLength(IntPtr hWnd); + + [DllImport("user32.dll", EntryPoint = "GetWindowTextW", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int maxLength); + + [DllImport("user32.dll")] + private static extern int EndDialog(IntPtr hDlg, IntPtr nResult); + + [DllImport("user32.dll")] + private static extern bool EnumChildWindows(IntPtr hWndParent, EnumChildProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll", EntryPoint = "GetClassNameW", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount); + + [DllImport("user32.dll")] + private static extern int GetDlgCtrlID(IntPtr hwndCtl); + + [DllImport("user32.dll")] + private static extern IntPtr GetDlgItem(IntPtr hDlg, int nIDDlgItem); + + [DllImport("user32.dll", EntryPoint = "SetWindowTextW", CharSet = CharSet.Unicode)] + private static extern bool SetWindowText(IntPtr hWnd, string lpString); + + + [StructLayout(LayoutKind.Sequential)] + public struct CWPRETSTRUCT + { + public IntPtr lResult; + public IntPtr lParam; + public IntPtr wParam; + public uint message; + public IntPtr hwnd; + }; + + private static HookProc hookProc; + private static EnumChildProc enumProc; + [ThreadStatic] + private static IntPtr hHook; + [ThreadStatic] + private static int nButton; + + /// + /// OK text + /// + public static string OK = "&OK"; + /// + /// Cancel text + /// + public static string Cancel = "&Cancel"; + /// + /// Abort text + /// + public static string Abort = "&Abort"; + /// + /// Retry text + /// + public static string Retry = "&Retry"; + /// + /// Ignore text + /// + public static string Ignore = "&Ignore"; + /// + /// Yes text + /// + public static string Yes = "&Yes"; + /// + /// No text + /// + public static string No = "&No"; + + static MessageBoxManager() + { + hookProc = new HookProc(MessageBoxHookProc); + enumProc = new EnumChildProc(MessageBoxEnumProc); + hHook = IntPtr.Zero; + } + + /// + /// Enables MessageBoxManager functionality + /// + /// + /// MessageBoxManager functionality is enabled on current thread only. + /// Each thread that needs MessageBoxManager functionality has to call this method. + /// + public static void Register() + { + if (hHook != IntPtr.Zero) + throw new NotSupportedException("One hook per thread allowed."); + hHook = SetWindowsHookEx(WH_CALLWNDPROCRET, hookProc, IntPtr.Zero, AppDomain.GetCurrentThreadId()); + } + + /// + /// Disables MessageBoxManager functionality + /// + /// + /// Disables MessageBoxManager functionality on current thread only. + /// + public static void Unregister() + { + if (hHook != IntPtr.Zero) + { + UnhookWindowsHookEx(hHook); + hHook = IntPtr.Zero; + } + } + + private static IntPtr MessageBoxHookProc(int nCode, IntPtr wParam, IntPtr lParam) + { + if (nCode < 0) + return CallNextHookEx(hHook, nCode, wParam, lParam); + + CWPRETSTRUCT msg = (CWPRETSTRUCT)Marshal.PtrToStructure(lParam, typeof(CWPRETSTRUCT)); + IntPtr hook = hHook; + + if (msg.message == WM_INITDIALOG) + { + int nLength = GetWindowTextLength(msg.hwnd); + StringBuilder className = new StringBuilder(10); + GetClassName(msg.hwnd, className, className.Capacity); + if (className.ToString() == "#32770") + { + nButton = 0; + EnumChildWindows(msg.hwnd, enumProc, IntPtr.Zero); + if (nButton == 1) + { + IntPtr hButton = GetDlgItem(msg.hwnd, MBCancel); + if (hButton != IntPtr.Zero) + SetWindowText(hButton, OK); + } + } + } + + return CallNextHookEx(hook, nCode, wParam, lParam); + } + + private static bool MessageBoxEnumProc(IntPtr hWnd, IntPtr lParam) + { + StringBuilder className = new StringBuilder(10); + GetClassName(hWnd, className, className.Capacity); + if (className.ToString() == "Button") + { + int ctlId = GetDlgCtrlID(hWnd); + switch (ctlId) + { + case MBOK: + SetWindowText(hWnd, OK); + break; + case MBCancel: + SetWindowText(hWnd, Cancel); + break; + case MBAbort: + SetWindowText(hWnd, Abort); + break; + case MBRetry: + SetWindowText(hWnd, Retry); + break; + case MBIgnore: + SetWindowText(hWnd, Ignore); + break; + case MBYes: + SetWindowText(hWnd, Yes); + break; + case MBNo: + SetWindowText(hWnd, No); + break; + + } + nButton++; + } + + return true; + } + } } diff --git a/WitcherScriptMerger/Forms/OptionsForm.cs b/WitcherScriptMerger/Forms/OptionsForm.cs index b33196d..2e8583c 100644 --- a/WitcherScriptMerger/Forms/OptionsForm.cs +++ b/WitcherScriptMerger/Forms/OptionsForm.cs @@ -3,72 +3,72 @@ namespace WitcherScriptMerger.Forms { - public partial class OptionsForm : Form - { - public OptionsForm() - { - InitializeComponent(); - } - - void Options_Load(object sender, EventArgs e) - { - chkCheckScripts.Checked = Program.Settings.Get("CheckScripts"); - chkCheckXmlFiles.Checked = Program.Settings.Get("CheckScripts"); - chkCheckBundleContents.Checked = Program.Settings.Get("CheckBundleContents"); - - chkCollapseNotMergeable.Checked = Program.Settings.Get("CollapseNotMergeable"); - chkCollapseCustomLoadOrder.Checked = Program.Settings.Get("CollapseCustomLoadOrder"); - - chkPromptOutdatedMerge.Checked = Program.Settings.Get("ValidateMergeSources"); - chkPromptPrioritize.Checked = Program.Settings.Get("ValidateCustomLoadOrder"); - - chkReviewEachMerge.Checked = Program.Settings.Get("ReviewEachMerge"); - chkShowPathsInKDiff3.Checked = Program.Settings.Get("ShowPathsInKDiff3"); - chkCompletionSounds.Checked = Program.Settings.Get("PlayCompletionSounds"); - chkMergeReport.Checked = Program.Settings.Get("ReportAfterMerge"); - chkPackReport.Checked = Program.Settings.Get("ReportAfterPack"); - - btnOK.Select(); - } - - void btnOK_Click(object sender, EventArgs e) - { - Save(); - - DialogResult = DialogResult.OK; - } - - void btnCancel_Click(object sender, EventArgs e) - { - DialogResult = DialogResult.Cancel; - } - - void btnApply_Click(object sender, EventArgs e) - { - Save(); - - DialogResult = DialogResult.None; - } - - void Save() - { - Program.Settings.Set("CheckScripts", chkCheckScripts.Checked); - Program.Settings.Set("CheckXmlFiles", chkCheckXmlFiles.Checked); - Program.Settings.Set("CheckBundleContents", chkCheckBundleContents.Checked); - - Program.Settings.Set("CollapseNotMergeable", chkCollapseNotMergeable.Checked); - Program.Settings.Set("CollapseCustomLoadOrder", chkCollapseCustomLoadOrder.Checked); - - Program.Settings.Set("ValidateMergeSources", chkPromptOutdatedMerge.Checked); - Program.Settings.Set("ValidateCustomLoadOrder", chkPromptPrioritize.Checked); - - Program.Settings.Set("ReviewEachMerge", chkReviewEachMerge.Checked); - Program.Settings.Set("ShowPathsInKDiff3", chkShowPathsInKDiff3.Checked); - Program.Settings.Set("PlayCompletionSounds", chkCompletionSounds.Checked); - Program.Settings.Set("ReportAfterMerge", chkMergeReport.Checked); - Program.Settings.Set("ReportAfterPack", chkPackReport.Checked); - - Program.Settings.Save(); - } - } + public partial class OptionsForm : Form + { + public OptionsForm() + { + InitializeComponent(); + } + + void Options_Load(object sender, EventArgs e) + { + chkCheckScripts.Checked = Program.Settings.Get("CheckScripts"); + chkCheckXmlFiles.Checked = Program.Settings.Get("CheckScripts"); + chkCheckBundleContents.Checked = Program.Settings.Get("CheckBundleContents"); + + chkCollapseNotMergeable.Checked = Program.Settings.Get("CollapseNotMergeable"); + chkCollapseCustomLoadOrder.Checked = Program.Settings.Get("CollapseCustomLoadOrder"); + + chkPromptOutdatedMerge.Checked = Program.Settings.Get("ValidateMergeSources"); + chkPromptPrioritize.Checked = Program.Settings.Get("ValidateCustomLoadOrder"); + + chkReviewEachMerge.Checked = Program.Settings.Get("ReviewEachMerge"); + chkShowPathsInKDiff3.Checked = Program.Settings.Get("ShowPathsInKDiff3"); + chkCompletionSounds.Checked = Program.Settings.Get("PlayCompletionSounds"); + chkMergeReport.Checked = Program.Settings.Get("ReportAfterMerge"); + chkPackReport.Checked = Program.Settings.Get("ReportAfterPack"); + + btnOK.Select(); + } + + void btnOK_Click(object sender, EventArgs e) + { + Save(); + + DialogResult = DialogResult.OK; + } + + void btnCancel_Click(object sender, EventArgs e) + { + DialogResult = DialogResult.Cancel; + } + + void btnApply_Click(object sender, EventArgs e) + { + Save(); + + DialogResult = DialogResult.None; + } + + void Save() + { + Program.Settings.Set("CheckScripts", chkCheckScripts.Checked); + Program.Settings.Set("CheckXmlFiles", chkCheckXmlFiles.Checked); + Program.Settings.Set("CheckBundleContents", chkCheckBundleContents.Checked); + + Program.Settings.Set("CollapseNotMergeable", chkCollapseNotMergeable.Checked); + Program.Settings.Set("CollapseCustomLoadOrder", chkCollapseCustomLoadOrder.Checked); + + Program.Settings.Set("ValidateMergeSources", chkPromptOutdatedMerge.Checked); + Program.Settings.Set("ValidateCustomLoadOrder", chkPromptPrioritize.Checked); + + Program.Settings.Set("ReviewEachMerge", chkReviewEachMerge.Checked); + Program.Settings.Set("ShowPathsInKDiff3", chkShowPathsInKDiff3.Checked); + Program.Settings.Set("PlayCompletionSounds", chkCompletionSounds.Checked); + Program.Settings.Set("ReportAfterMerge", chkMergeReport.Checked); + Program.Settings.Set("ReportAfterPack", chkPackReport.Checked); + + Program.Settings.Save(); + } + } } diff --git a/WitcherScriptMerger/Forms/PackReportForm.cs b/WitcherScriptMerger/Forms/PackReportForm.cs index 3bf6280..b65061f 100644 --- a/WitcherScriptMerger/Forms/PackReportForm.cs +++ b/WitcherScriptMerger/Forms/PackReportForm.cs @@ -4,54 +4,54 @@ namespace WitcherScriptMerger.Forms { - partial class PackReportForm : Form - { - #region Initialization + partial class PackReportForm : Form + { + #region Initialization - public PackReportForm(string bundlePath) - { - InitializeComponent(); - - txtBundlePath.Text = bundlePath; + public PackReportForm(string bundlePath) + { + InitializeComponent(); - var contentPaths = Directory.GetFiles(Paths.MergedBundleContent, "*", SearchOption.AllDirectories); - txtContent.Text = string.Join(Environment.NewLine, contentPaths); + txtBundlePath.Text = bundlePath; - chkShowAfterPack.Checked = Program.Settings.Get("ReportAfterPack"); + var contentPaths = Directory.GetFiles(Paths.MergedBundleContent, "*", SearchOption.AllDirectories); + txtContent.Text = string.Join(Environment.NewLine, contentPaths); - btnOK.Select(); - } + chkShowAfterPack.Checked = Program.Settings.Get("ReportAfterPack"); - void PackReportForm_FormClosing(object sender, FormClosingEventArgs e) - { - Program.Settings.Set("ReportAfterPack", chkShowAfterPack.Checked); - } + btnOK.Select(); + } - #endregion + void PackReportForm_FormClosing(object sender, FormClosingEventArgs e) + { + Program.Settings.Set("ReportAfterPack", chkShowAfterPack.Checked); + } - #region Button Clicks + #endregion - void btnOpenBundleDir_Click(object sender, EventArgs e) - { - Program.TryOpenFileLocation(txtBundlePath.Text); - } + #region Button Clicks - void btnOpenContentDir_Click(object sender, EventArgs e) - { - Program.TryOpenDirectory(Paths.MergedBundleContent); - } + void btnOpenBundleDir_Click(object sender, EventArgs e) + { + Program.TryOpenFileLocation(txtBundlePath.Text); + } - void btnOK_Click(object sender, EventArgs e) - { - DialogResult = DialogResult.OK; - } + void btnOpenContentDir_Click(object sender, EventArgs e) + { + Program.TryOpenDirectory(Paths.MergedBundleContent); + } - #endregion + void btnOK_Click(object sender, EventArgs e) + { + DialogResult = DialogResult.OK; + } - void txt_KeyDown(object sender, KeyEventArgs e) - { - if (e.Control && e.KeyCode == Keys.A) - (sender as TextBox).SelectAll(); - } - } + #endregion + + void txt_KeyDown(object sender, KeyEventArgs e) + { + if (e.Control && e.KeyCode == Keys.A) + (sender as TextBox).SelectAll(); + } + } } diff --git a/WitcherScriptMerger/Forms/PriorityPrompt.cs b/WitcherScriptMerger/Forms/PriorityPrompt.cs index 5e99e28..e5edaf1 100644 --- a/WitcherScriptMerger/Forms/PriorityPrompt.cs +++ b/WitcherScriptMerger/Forms/PriorityPrompt.cs @@ -4,102 +4,102 @@ namespace WitcherScriptMerger.Forms { - class PriorityPrompt : Form - { - const int Spacing = 5; + class PriorityPrompt : Form + { + const int Spacing = 5; - NumericUpDown _inputField; - TextBox _innerTextBox; - Button _okButton; + NumericUpDown _inputField; + TextBox _innerTextBox; + Button _okButton; - public int? ShowDialog(int value = 0) - { - _inputField = new NumericUpDown - { - Left = Spacing, - Top = Spacing, - Width = 50, - Increment = 1, - DecimalPlaces = 0, - Minimum = CustomLoadOrder.TopPriority + 1, - Maximum = CustomLoadOrder.BottomPriority, - }; - _innerTextBox = (TextBox)_inputField.Controls[1]; - _innerTextBox.KeyDown += InputField_KeyDown; - _innerTextBox.TextChanged += InputField_TextChanged; + public int? ShowDialog(int value = 0) + { + _inputField = new NumericUpDown + { + Left = Spacing, + Top = Spacing, + Width = 50, + Increment = 1, + DecimalPlaces = 0, + Minimum = CustomLoadOrder.TopPriority + 1, + Maximum = CustomLoadOrder.BottomPriority, + }; + _innerTextBox = (TextBox)_inputField.Controls[1]; + _innerTextBox.KeyDown += InputField_KeyDown; + _innerTextBox.TextChanged += InputField_TextChanged; - _okButton = new Button - { - Text = "&OK", - Left = _inputField.Left + _inputField.Width + Spacing, - Width = 50, - DialogResult = DialogResult.OK - }; - _okButton.Top = _inputField.Top - (System.Math.Abs(_okButton.Height - _inputField.Height) / 2); + _okButton = new Button + { + Text = "&OK", + Left = _inputField.Left + _inputField.Width + Spacing, + Width = 50, + DialogResult = DialogResult.OK + }; + _okButton.Top = _inputField.Top - (System.Math.Abs(_okButton.Height - _inputField.Height) / 2); - FormBorderStyle = FormBorderStyle.FixedToolWindow; - Text = "Set Priority"; - ClientSize = new Size - { - Width = Spacing + _inputField.Width + Spacing + _okButton.Width + Spacing, - Height = Spacing + _inputField.Height + Spacing - }; - StartPosition = FormStartPosition.CenterParent; - MinimizeBox = MaximizeBox = false; - AcceptButton = _okButton; - Icon = Program.MainForm.Icon; - Controls.AddRange( - new Control[] - { - _inputField, - _okButton - }); - KeyPreview = true; - KeyDown += OnKeyDown; + FormBorderStyle = FormBorderStyle.FixedToolWindow; + Text = "Set Priority"; + ClientSize = new Size + { + Width = Spacing + _inputField.Width + Spacing + _okButton.Width + Spacing, + Height = Spacing + _inputField.Height + Spacing + }; + StartPosition = FormStartPosition.CenterParent; + MinimizeBox = MaximizeBox = false; + AcceptButton = _okButton; + Icon = Program.MainForm.Icon; + Controls.AddRange( + new Control[] + { + _inputField, + _okButton + }); + KeyPreview = true; + KeyDown += OnKeyDown; - if (value >= _inputField.Minimum && value <= _inputField.Maximum) - _inputField.Value = value; + if (value >= _inputField.Minimum && value <= _inputField.Maximum) + _inputField.Value = value; - return - base.ShowDialog() == DialogResult.OK - ? (int?)System.Convert.ToInt32(_inputField.Value) - : null; - } + return + base.ShowDialog() == DialogResult.OK + ? (int?)System.Convert.ToInt32(_inputField.Value) + : null; + } - private void InputField_TextChanged(object sender, System.EventArgs e) - { - _okButton.Enabled = !string.IsNullOrWhiteSpace((sender as TextBox).Text); - } + private void InputField_TextChanged(object sender, System.EventArgs e) + { + _okButton.Enabled = !string.IsNullOrWhiteSpace((sender as TextBox).Text); + } - void InputField_KeyDown(object sender, KeyEventArgs e) - { - e.SuppressKeyPress = - (e.KeyCode == Keys.Subtract) || - (IsCharacterCountMaxed() && !HasSelection() && IsNumeric(e.KeyCode)) || - (_innerTextBox.SelectionStart == 0 && (e.KeyCode == Keys.D0 || e.KeyCode == Keys.NumPad0)); - } + void InputField_KeyDown(object sender, KeyEventArgs e) + { + e.SuppressKeyPress = + (e.KeyCode == Keys.Subtract) || + (IsCharacterCountMaxed() && !HasSelection() && IsNumeric(e.KeyCode)) || + (_innerTextBox.SelectionStart == 0 && (e.KeyCode == Keys.D0 || e.KeyCode == Keys.NumPad0)); + } - bool IsCharacterCountMaxed() - { - return _innerTextBox.Text.Length == _inputField.Maximum.ToString().Length; - } + bool IsCharacterCountMaxed() + { + return _innerTextBox.Text.Length == _inputField.Maximum.ToString().Length; + } - bool HasSelection() - { - return _innerTextBox.SelectionLength > 0; - } + bool HasSelection() + { + return _innerTextBox.SelectionLength > 0; + } - bool IsNumeric(Keys keyCode) - { - return - (keyCode >= Keys.D0 && keyCode <= Keys.D9) || - (keyCode >= Keys.NumPad0 && keyCode <= Keys.NumPad9); - } + bool IsNumeric(Keys keyCode) + { + return + (keyCode >= Keys.D0 && keyCode <= Keys.D9) || + (keyCode >= Keys.NumPad0 && keyCode <= Keys.NumPad9); + } - void OnKeyDown(object sender, KeyEventArgs e) - { - if (e.KeyCode == Keys.Escape) - Close(); - } - } + void OnKeyDown(object sender, KeyEventArgs e) + { + if (e.KeyCode == Keys.Escape) + Close(); + } + } } diff --git a/WitcherScriptMerger/Inventory/FileHash.cs b/WitcherScriptMerger/Inventory/FileHash.cs deleted file mode 100644 index 36aa18e..0000000 --- a/WitcherScriptMerger/Inventory/FileHash.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Xml.Serialization; - -namespace WitcherScriptMerger.Inventory -{ - [XmlRoot] - public class FileHash - { - [XmlAttribute] - public string Hash { get; set; } - - [XmlText] - public string Name { get; set; } - - [XmlIgnore] - public bool IsOutdated { get; set; } - } -} diff --git a/WitcherScriptMerger/Inventory/FileMerger.cs b/WitcherScriptMerger/Inventory/FileMerger.cs deleted file mode 100644 index 4148a18..0000000 --- a/WitcherScriptMerger/Inventory/FileMerger.cs +++ /dev/null @@ -1,546 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using System.Windows.Forms; -using WitcherScriptMerger.FileIndex; -using WitcherScriptMerger.Forms; -using WitcherScriptMerger.LoadOrder; -using WitcherScriptMerger.Tools; - -namespace WitcherScriptMerger.Inventory -{ - public class FileMerger - { - #region Types - - public struct MergeSource - { - public FileInfo TextFile; - public FileInfo Bundle; - public FileHash Hash; - public string Name; - - public static MergeSource FromFlatFile(FileInfo file, FileHash hash) - => Create(file, hash, false); - - public static MergeSource FromBundle(FileInfo file, FileHash hash) - => Create(file, hash, true); - - static MergeSource Create(FileInfo file, FileHash hash, bool isBundle) - => new MergeSource - { - TextFile = isBundle ? null : file, - Bundle = isBundle ? file : null, - Hash = hash, - Name = ModFile.GetModNameFromPath(file.FullName) - }; - } - - #endregion - - #region Members - - public MergeProgressInfo ProgressInfo { get; private set; } - - MergeInventory _inventory; - TreeNode[] _checkedFileNodes; - FileInfo _vanillaFile; - string _mergedModName; - string _outputPath; - - bool _bundleChanged; - List _pendingBundleMerges = new List(); - - BackgroundWorker _bgWorker; - - #endregion - - public FileMerger( - MergeInventory inventory, - ProgressChangedEventHandler progressHandler, - RunWorkerCompletedEventHandler completedHandler) - { - _inventory = inventory; - - _bgWorker = new BackgroundWorker - { - WorkerReportsProgress = true - }; - _bgWorker.ProgressChanged += progressHandler; - ProgressInfo = new MergeProgressInfo(); - ProgressInfo.PropertyChanged += (sender, e) => - { - _bgWorker.ReportProgress(0, ProgressInfo); - }; - _bgWorker.RunWorkerCompleted += completedHandler; - } - - ~FileMerger() - { - if (_bgWorker != null) - _bgWorker.Dispose(); - } - - public void MergeByTreeNodesAsync( - IEnumerable fileNodesToMerge, - string mergedModName) - { - _bgWorker.DoWork += (sender, e) => - { - _checkedFileNodes = fileNodesToMerge.ToArray(); - _mergedModName = mergedModName; - - var checkedModNodesForFile = - _checkedFileNodes.Select( - fileNode => - fileNode.GetTreeNodes().Where( - modNode => - modNode.Checked - ).ToArray() - ).ToArray(); - - ProgressInfo.TotalMergeCount = checkedModNodesForFile.Sum(modNodes => modNodes.Length - 1); - ProgressInfo.TotalFileCount = _checkedFileNodes.Length; - - for (int i = 0; i < _checkedFileNodes.Length; ++i) - { - var fileNode = _checkedFileNodes[i]; - - ProgressInfo.CurrentFileName = Path.GetFileName(fileNode.Text); - ProgressInfo.CurrentFileNum = i + 1; - - var checkedModNodes = checkedModNodesForFile[i]; - - ProgressInfo.CurrentAction = "Starting merge"; - - if (checkedModNodes.Any(node => (new LoadOrderComparer()).Compare(node.Text, _mergedModName) < 0) && - !ConfirmRemainingConflict(_mergedModName)) - continue; - - var isNew = false; - var merge = _inventory.Merges.FirstOrDefault(m => m.RelativePath.EqualsIgnoreCase(fileNode.Text)); - if (merge == null) - { - isNew = true; - merge = new Merge - { - RelativePath = fileNode.Text, - MergedModName = _mergedModName - }; - } - - if ((ModFileCategory)fileNode.Parent.Tag == Categories.BundleText) - { - merge.BundleName = Path.GetFileName(Paths.RetrieveMergedBundlePath()); - MergeBundleFileNode(fileNode, checkedModNodes, merge, isNew); - } - else - MergeFlatFileNode(fileNode, checkedModNodes, merge, isNew); - } - if (_bundleChanged) - { - var newBundlePath = PackNewBundle(Paths.RetrieveMergedBundlePath()); - if (newBundlePath != null) - { - ProgressInfo.CurrentAction = "Adding bundle merge to inventory"; - foreach (var bundleMerge in _pendingBundleMerges) - _inventory.Merges.Add(bundleMerge); - - if (Program.Settings.Get("PlayCompletionSounds")) - { - System.Media.SystemSounds.Asterisk.Play(); - } - if (Program.Settings.Get("ReportAfterPack")) - { - using (var reportForm = new PackReportForm(newBundlePath)) - { - ProgressInfo.CurrentAction = "Showing pack report"; - Program.MainForm.ShowModal(reportForm); - } - } - } - } - CleanUpTempFiles(); - CleanUpEmptyDirectories(); - }; - _bgWorker.RunWorkerAsync(); - } - - void MergeFlatFileNode(TreeNode fileNode, TreeNode[] checkedModNodes, Merge merge, bool isNew) - { - var metadata1 = checkedModNodes[0].GetMetadata(); - var source1 = MergeSource.FromFlatFile(new FileInfo(metadata1.FilePath), metadata1.FileHash); - - var relPath = Paths.GetRelativePath( - source1.TextFile.FullName, - Path.Combine(Paths.ModsDirectory, source1.Name)); - - _outputPath = Path.Combine(Paths.ModsDirectory, _mergedModName, relPath); - - if (File.Exists(_outputPath) && !ConfirmOutputOverwrite(_outputPath)) - return; - - _vanillaFile = new FileInfo(fileNode.GetMetadata().FilePath); - - for (int i = 1; i < checkedModNodes.Length; ++i) - { - ++ProgressInfo.CurrentMergeNum; - - var metadata2 = checkedModNodes[i].GetMetadata(); - var source2 = MergeSource.FromFlatFile(new FileInfo(metadata2.FilePath), metadata2.FileHash); - - var mergedFile = MergeText(merge, source1, source2); - if (mergedFile != null) - { - source1 = MergeSource.FromFlatFile(mergedFile, null); - } - else if (DialogResult.Abort == HandleCanceledMerge(checkedModNodes.Length - i - 1, merge)) - break; - } - - if (isNew && merge.Mods.Count > 1) - { - ProgressInfo.CurrentAction = "Adding script merge to inventory"; - _inventory.Merges.Add(merge); - } - } - - void MergeBundleFileNode(TreeNode fileNode, TreeNode[] checkedModNodes, Merge merge, bool isNew) - { - _outputPath = Path.Combine(Paths.MergedBundleContent, fileNode.Text); - - if (File.Exists(_outputPath) && !ConfirmOutputOverwrite(_outputPath)) - return; - - _vanillaFile = null; - - var metadata1 = checkedModNodes[0].GetMetadata(); - var source1 = MergeSource.FromBundle(new FileInfo(metadata1.FilePath), metadata1.FileHash); - - for (int i = 1; i < checkedModNodes.Length; ++i) - { - ++ProgressInfo.CurrentMergeNum; - - var metadata2 = checkedModNodes[i].GetMetadata(); - var source2 = MergeSource.FromBundle(new FileInfo(metadata2.FilePath), metadata2.FileHash); - - if (!GetUnpackedFiles(fileNode.Text, ref source1, ref source2)) - { - if (DialogResult.Abort != HandleCanceledMerge(checkedModNodes.Length - i - 1, merge)) - continue; - break; - } - - var mergedFile = MergeText(merge, source1, source2); - if (mergedFile != null) - { - source1 = MergeSource.FromFlatFile(mergedFile, null); - } - else if (DialogResult.Abort == HandleCanceledMerge(checkedModNodes.Length - i - 1, merge)) - break; - } - - if (merge.BundleName != null && isNew && merge.Mods.Count > 1) - { - _bundleChanged = true; - _pendingBundleMerges.Add(merge); - } - } - - FileInfo MergeText(Merge merge, MergeSource source1, MergeSource source2) - { - ProgressInfo.CurrentAction = $"Merging {source1.Name} && {source2.Name} — waiting for KDiff3 to close"; - - var exitCode = KDiff3.Run(source1, source2, _vanillaFile, _outputPath); - - if (exitCode == 0) - { - if (!source1.TextFile.FullName.EqualsIgnoreCase(_outputPath) - && !source1.TextFile.FullName.StartsWithIgnoreCase(Paths.MergedBundleContentAbsolute)) - { - _inventory.AddModToMerge(source1, merge); - } - - if (!source2.TextFile.FullName.EqualsIgnoreCase(_outputPath) - && !source2.TextFile.FullName.StartsWithIgnoreCase(Paths.MergedBundleContentAbsolute)) - { - _inventory.AddModToMerge(source2, merge); - } - - if (Program.Settings.Get("PlayCompletionSounds")) - { - System.Media.SystemSounds.Asterisk.Play(); - } - if (Program.Settings.Get("ReportAfterMerge")) - { - using (var reportForm = new MergeReportForm( - ProgressInfo.CurrentMergeNum, ProgressInfo.TotalMergeCount, - source1.TextFile.FullName, source2.TextFile.FullName, _outputPath, - source1.Name, source2.Name)) - { - ProgressInfo.CurrentAction = "Showing merge report"; - Program.MainForm.ShowModal(reportForm); - } - } - return new FileInfo(_outputPath); - } - else - return null; - } - - bool ConfirmRemainingConflict(string mergedModName) - { - return (DialogResult.Yes == Program.MainForm.ShowMessage( - "There will still be a conflict if you use the merged mod name " + mergedModName + ".\n\n" + - "The Witcher 3 loads mods in case-insensitive ASCII order, " + - "so this merged mod name will load after one of the original mods, " + - "and the merged file will be ignored.\n\n" + - "Use this name anyway?", - "Merged Mod Name Conflict", - MessageBoxButtons.YesNo, - MessageBoxIcon.Exclamation)); - } - - bool ConfirmOutputOverwrite(string outputPath) - { - return (DialogResult.Yes == Program.MainForm.ShowMessage( - "The output file below already exists! Overwrite?\n\n" + outputPath, - "Overwrite?", - MessageBoxButtons.YesNo, - MessageBoxIcon.Exclamation)); - } - - DialogResult HandleCanceledMerge(int remainingMergesForFile, Merge merge) - { - var msg = $"Merge {ProgressInfo.CurrentMergeNum} of {ProgressInfo.TotalMergeCount} was canceled."; - var buttons = MessageBoxButtons.OK; - if (remainingMergesForFile > 0) - { - var fileName = Path.GetFileName(merge.RelativePath); - msg += $"\n\nContinue with {remainingMergesForFile} remaining merge{remainingMergesForFile.GetPluralS()} for file {fileName}?"; - buttons = MessageBoxButtons.YesNo; - } - var result = Program.MainForm.ShowMessage(msg, "Skipped Merge", buttons, MessageBoxIcon.Information); - if (result == DialogResult.No) - { - ProgressInfo.CurrentMergeNum += remainingMergesForFile; - return DialogResult.Abort; - } - return DialogResult.OK; - } - - bool GetUnpackedFiles(string contentRelativePath, ref MergeSource source1, ref MergeSource source2) - { - if (_vanillaFile == null) - { - ProgressInfo.CurrentAction = "Searching for corresponding vanilla bundle"; - - var bundleDirs = - Directory.GetDirectories(Paths.BundlesDirectory) - .Select(path => Path.Combine(path, "bundles")) - .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")) - ) - .Where(path => Directory.Exists(path)) - .OrderBy(path => path, new LoadOrderComparer()) - .ToArray(); - - for (int i = bundleDirs.Length - 1; i >= 0; --i) // Search vanilla directories in reverse - { // order, as patches & DLC override content. - var bundleFiles = Directory.GetFiles(bundleDirs[i], "*.bundle"); - foreach (var bundle in bundleFiles) - { - var contentPaths = QuickBms.GetBundleContentPaths(bundle); - if (contentPaths.Any(path => path.EqualsIgnoreCase(contentRelativePath))) - { - _vanillaFile = new FileInfo(bundle); - break; - } - } - if (_vanillaFile != null) - break; - } - if (_vanillaFile != null) - { - ProgressInfo.CurrentAction = "Unpacking vanilla bundle content file"; - var vanillaContentPath = UnpackFile(_vanillaFile.FullName, contentRelativePath, "Vanilla"); - _vanillaFile = new FileInfo(vanillaContentPath); - } - } - - if (source1.TextFile == null) - { - ProgressInfo.CurrentAction = $"Unpacking bundle content file for {source1.Name}"; - var modContentFile1 = UnpackFile(source1.Bundle.FullName, contentRelativePath, "Mod 1"); - if (modContentFile1 == null) - return false; - source1.TextFile = new FileInfo(modContentFile1); - } - ProgressInfo.CurrentAction = $"Unpacking bundle content file for {source2.Name}"; - var modContentFile2 = UnpackFile(source2.Bundle.FullName, contentRelativePath, "Mod 2"); - if (modContentFile2 == null) - return false; - source2.TextFile = new FileInfo(modContentFile2); - return true; - } - - string UnpackFile(string bundlePath, string contentRelativePath, string outputDirName) - { - var outputDir = Path.Combine(Paths.TempBundleContent, outputDirName); - - var exitCode = QuickBms.UnpackFile(bundlePath, contentRelativePath, outputDir); - - return exitCode == 0 - ? Path.Combine(outputDir, contentRelativePath) - : null; - } - - public void RepackBundleAsync(string bundlePath) - { - if (_bgWorker.IsBusy) - throw new Exception("BackgroundWorker can't run 2 tasks concurrently."); - _bgWorker.DoWork += (sender, e) => - { - var newBundlePath = PackNewBundle(bundlePath, true); - if (newBundlePath == null) - return; - - if (Program.Settings.Get("PlayCompletionSounds")) - { - System.Media.SystemSounds.Asterisk.Play(); - } - if (Program.Settings.Get("ReportAfterPack")) - { - using (var reportForm = new PackReportForm(bundlePath)) - { - Program.MainForm.ShowModal(reportForm); - } - } - }; - _bgWorker.RunWorkerAsync(); - } - - string PackNewBundle(string bundlePath, bool isRepack = false) - { - ProgressInfo.CurrentPhase = (!isRepack ? "Packing Bundle" : "Repacking Bundle"); - ProgressInfo.CurrentAction = "Packing merged bundle content into new blob0.bundle"; - - var outputDir = Path.GetDirectoryName(bundlePath); - - var exitCode = WccLite.PackBundle(Paths.MergedBundleContentAbsolute, outputDir); - if (exitCode != 0) - return null; - - ProgressInfo.CurrentAction = "Generating metadata.store for new blob0.bundle"; - - exitCode = WccLite.GenerateMetadata(outputDir); - if (exitCode != 0) - return null; - - return bundlePath; - } - - void CleanUpTempFiles() - { - if (!Directory.Exists(Paths.TempBundleContent)) - return; - - try - { - ProgressInfo.CurrentAction = "Deleting temporary unpacked bundle content"; - DeleteDirectory(Paths.TempBundleContent); - } - catch (Exception ex) - { - Program.MainForm.ShowMessage( - "Non-critical error: Failed to delete temporary unpacked bundle content.\n\n" + ex.Message, - "Error", - MessageBoxButtons.OK, - MessageBoxIcon.Warning); - } - } - - void CleanUpEmptyDirectories() - { - if (!Directory.Exists(Paths.MergedBundleContent)) - return; - - try - { - ProgressInfo.CurrentAction = "Deleting empty Merged Bundle Content directories"; - DeleteEmptyDirectories(Paths.MergedBundleContent); - } - catch (Exception ex) - { - Program.MainForm.ShowMessage( - "Non-critical error: Failed to delete empty Merged Bundle Content directories.\n\n" + ex.Message, - "Error", - MessageBoxButtons.OK, - MessageBoxIcon.Warning); - } - } - - /// - /// Depth-first recursive delete, with handling for descendant - /// directories open in Windows Explorer. - /// - void DeleteDirectory(string path) - { - foreach (var subdirPath in Directory.GetDirectories(path)) - { - System.Threading.Thread.Sleep(1); - DeleteDirectory(subdirPath); - } - - try - { - System.Threading.Thread.Sleep(1); - Directory.Delete(path, true); - } - catch (IOException) - { - System.Threading.Thread.Sleep(1); - Directory.Delete(path, true); - } - catch (UnauthorizedAccessException) - { - System.Threading.Thread.Sleep(1); - Directory.Delete(path, true); - } - catch (Exception) - { - throw; - } - } - - /// - /// Deletes any subdirectories of the root that are empty, AS WELL AS the root itself, if it's empty. - /// - void DeleteEmptyDirectories(string rootPath) - { - foreach (string directory in Directory.GetDirectories(rootPath)) - { - System.Threading.Thread.Sleep(1); - DeleteEmptyDirectories(directory); - } - - if (Directory.GetFiles(rootPath).Any() || Directory.GetDirectories(rootPath).Any()) - return; - - try - { - System.Threading.Thread.Sleep(1); - DeleteDirectory(rootPath); - } - catch (Exception) - { - throw; - } - } - } -} \ No newline at end of file diff --git a/WitcherScriptMerger/Inventory/InteractiveMergeRunner.cs b/WitcherScriptMerger/Inventory/InteractiveMergeRunner.cs new file mode 100644 index 0000000..6c7c8b2 --- /dev/null +++ b/WitcherScriptMerger/Inventory/InteractiveMergeRunner.cs @@ -0,0 +1,152 @@ +using System; +using System.ComponentModel; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows.Forms; +using WitcherScriptMerger.FileIndex; +using WitcherScriptMerger.Forms; + +namespace WitcherScriptMerger.Inventory +{ + // Host-side counterpart to Core's Inventory/FileMerger.cs, restoring exactly what + // that class used to do before the Core/host project split: drive an interactive, + // TreeNode-driven merge on a BackgroundWorker and pop up MergeReportForm/ + // PackReportForm afterward. Its public API (constructor shape, + // MergeByTreeNodesAsync, RepackBundleAsync) deliberately mirrors the old FileMerger + // so MainForm's call sites needed only a type-name change - see CLAUDE.md/the PR + // description for the full split rationale. + // + // This class does the TreeNode-specific extraction (pulling relative paths/mod + // names/hashes out of TreeNode.GetMetadata()) and owns the BackgroundWorker; Core's + // FileMerger does the actual merge orchestration and never sees a TreeNode. + class InteractiveMergeRunner + { + public MergeProgressInfo ProgressInfo => _fileMerger.ProgressInfo; + + FileMerger _fileMerger; + BackgroundWorker _bgWorker; + + public InteractiveMergeRunner( + MergeInventory inventory, + ProgressChangedEventHandler progressHandler, + RunWorkerCompletedEventHandler completedHandler) + { + _fileMerger = new FileMerger(inventory, AppState.MergeEngine) + { + OnMergeReport = ShowMergeReport, + OnPackReport = ShowPackReport, + }; + + _bgWorker = new BackgroundWorker + { + WorkerReportsProgress = true + }; + _bgWorker.ProgressChanged += progressHandler; + _fileMerger.ProgressInfo.PropertyChanged += (sender, e) => + { + _bgWorker.ReportProgress(0, _fileMerger.ProgressInfo); + }; + _bgWorker.RunWorkerCompleted += completedHandler; + } + + ~InteractiveMergeRunner() + { + if (_bgWorker != null) + _bgWorker.Dispose(); + } + + public void MergeByTreeNodesAsync(IEnumerable fileNodesToMerge, string mergedModName) + { + // TreeNode extraction (ExtractRequest) happens inside DoWork, on the + // BackgroundWorker's thread - not out here on the UI thread - matching + // where the pre-split FileMerger.MergeByTreeNodesAsync did its own + // equivalent TreeNode traversal. This isn't just fidelity for its own + // sake: ExtractRequest dereferences node metadata and casts Tag with no + // null/type check, and BackgroundWorker captures any exception from + // DoWork into RunWorkerCompletedEventArgs.Error instead of letting it + // propagate. Extracting out here instead would let that same exception + // throw synchronously from btnMergeFiles_Click on the UI thread, which + // modern .NET WinForms terminates the process for by default (unlike + // .NET Framework's more forgiving behavior) - turning what was at worst a + // silently-swallowed no-op (OnMergeComplete never inspects e.Error + // either, both before and after this split) into a hard crash. + _bgWorker.DoWork += (sender, e) => + { + var requests = fileNodesToMerge.Select(ExtractRequest).ToArray(); + _fileMerger.MergeFilesInteractive(requests, mergedModName); + }; + _bgWorker.RunWorkerAsync(); + } + + static FileMerger.InteractiveMergeRequest ExtractRequest(TreeNode fileNode) + { + var isBundle = (ModFileCategory)fileNode.Parent.Tag == Categories.BundleText; + + var checkedModNodes = fileNode.GetTreeNodes().Where(modNode => modNode.Checked).ToArray(); + + var orderedSources = checkedModNodes.Select(modNode => + { + var metadata = modNode.GetMetadata(); + var file = new FileInfo(metadata.FilePath); + return isBundle + ? FileMerger.MergeSource.FromBundle(file, metadata.FileHash) + : FileMerger.MergeSource.FromFlatFile(file, metadata.FileHash); + }).ToArray(); + + return new FileMerger.InteractiveMergeRequest + { + RelativePath = fileNode.Text, + IsBundle = isBundle, + VanillaFilePath = isBundle ? null : fileNode.GetMetadata().FilePath, + OrderedSources = orderedSources, + }; + } + + public void RepackBundleAsync(string bundlePath) + { + if (_bgWorker.IsBusy) + throw new Exception("BackgroundWorker can't run 2 tasks concurrently."); + _bgWorker.DoWork += (sender, e) => + { + _fileMerger.RepackBundle(bundlePath); + }; + _bgWorker.RunWorkerAsync(); + } + + void ShowMergeReport(FileMerger.MergeReportData data) + { + if (Program.Settings.Get("PlayCompletionSounds")) + { + System.Media.SystemSounds.Asterisk.Play(); + } + if (Program.Settings.Get("ReportAfterMerge")) + { + using (var reportForm = new MergeReportForm( + data.MergeNum, data.TotalMergeCount, + data.Source1Path, data.Source2Path, data.OutputPath, + data.Source1Name, data.Source2Name)) + { + _fileMerger.ProgressInfo.CurrentAction = "Showing merge report"; + Program.MainForm.ShowModal(reportForm); + } + } + } + + void ShowPackReport(string bundlePath) + { + if (Program.Settings.Get("PlayCompletionSounds")) + { + System.Media.SystemSounds.Asterisk.Play(); + } + if (Program.Settings.Get("ReportAfterPack")) + { + using (var reportForm = new PackReportForm(bundlePath)) + { + _fileMerger.ProgressInfo.CurrentAction = "Showing pack report"; + Program.MainForm.ShowModal(reportForm); + } + } + } + } +} diff --git a/WitcherScriptMerger/Inventory/Merge.cs b/WitcherScriptMerger/Inventory/Merge.cs deleted file mode 100644 index 4365cd8..0000000 --- a/WitcherScriptMerger/Inventory/Merge.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Xml.Serialization; -using WitcherScriptMerger.FileIndex; - -namespace WitcherScriptMerger.Inventory -{ - [XmlRoot] - public class Merge : ModFile - { - [XmlElement] - public string MergedModName; - - public string GetMergedFile() - { - if (Category == Categories.Script) - return Path.Combine(Paths.ModsDirectory, MergedModName, Paths.ModScriptBase, RelativePath); - else if (Category == Categories.Xml) - return Path.Combine(Paths.ModsDirectory, MergedModName, RelativePath); - else if (Category == Categories.BundleText) - return Path.Combine(Paths.MergedBundleContent, RelativePath); - else - throw new NotImplementedException(); - } - - public string GetMergedBundle() - { - if (Category != Categories.BundleText) - throw new Exception($"Can't get bundle for file of category '{Category.DisplayName}'."); - - return Path.Combine(Paths.ModsDirectory, MergedModName, Paths.BundleBase, BundleName); - } - - public FileHash GetHashByModName(string modName) - { - return Mods.FirstOrDefault(m => m.Name.EqualsIgnoreCase(modName)); - } - } -} \ No newline at end of file diff --git a/WitcherScriptMerger/Inventory/MergeInventory.cs b/WitcherScriptMerger/Inventory/MergeInventory.cs deleted file mode 100644 index 83889e5..0000000 --- a/WitcherScriptMerger/Inventory/MergeInventory.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Xml.Serialization; -using WitcherScriptMerger.FileIndex; -using WitcherScriptMerger.LoadOrder; - -namespace WitcherScriptMerger.Inventory -{ - [XmlRoot] - public class MergeInventory - { - [XmlElement("Merge")] - public ObservableCollection Merges { get; private set; } - - [XmlIgnore] - public bool ScriptsChanged { get; private set; } - - [XmlIgnore] - public bool XmlChanged { get; private set; } - - [XmlIgnore] - public bool BundleChanged { get; private set; } - - [XmlIgnore] - public bool HasChanged => (ScriptsChanged || XmlChanged || BundleChanged); - - static XmlSerializer _serializer = new XmlSerializer(typeof(MergeInventory)); - - public MergeInventory() - { - Merges = new ObservableCollection(); - Merges.CollectionChanged += Merges_CollectionChanged; - } - - public static MergeInventory Load(string path) - { - MergeInventory inventory; - try - { - _serializer = new XmlSerializer(typeof(MergeInventory)); - using (var stream = File.OpenRead(path)) - { - inventory = (MergeInventory)_serializer.Deserialize(stream); - } - - AddMissingHashes(inventory); - } - catch - { - inventory = new MergeInventory(); - } - inventory.ScriptsChanged = inventory.XmlChanged = inventory.BundleChanged = false; - return inventory; - } - - void Merges_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) - { - if ((e.NewItems != null && e.NewItems.Cast().Any(merge => merge.Category == Categories.Script)) || - (e.OldItems != null && e.OldItems.Cast().Any(merge => merge.Category == Categories.Script))) - ScriptsChanged = true; - if ((e.NewItems != null && e.NewItems.Cast().Any(merge => merge.Category == Categories.Xml)) || - (e.OldItems != null && e.OldItems.Cast().Any(merge => merge.Category == Categories.Xml))) - XmlChanged = true; - if ((e.NewItems != null && e.NewItems.Cast().Any(merge => merge.IsBundleContent)) || - (e.OldItems != null && e.OldItems.Cast().Any(merge => merge.IsBundleContent))) - BundleChanged = true; - } - - public void AddModToMerge(FileMerger.MergeSource source, Merge m) - { - var modFilePath = - m.IsBundleContent - ? source.Bundle.FullName - : source.TextFile.FullName; - - var existingMod = m.Mods.Find(mod => mod.Name.EqualsIgnoreCase(source.Name)); - if (existingMod != null) - existingMod.Hash = Tools.Hasher.ComputeHash(modFilePath); - else - { - m.Mods.Add( - new FileHash - { - Hash = Tools.Hasher.ComputeHash(modFilePath), - Name = source.Name - }); - } - - if (m.Category == Categories.Script) - ScriptsChanged = true; - else if (m.Category == Categories.Xml) - XmlChanged = true; - else if (m.IsBundleContent) - BundleChanged = true; - } - - public void Save() - { - if (_serializer == null) - return; - using (var writer = new StreamWriter(Paths.Inventory)) - { - _serializer.Serialize(writer, this); - } - } - - public bool HasResolvedConflict(ModFile conflict) - { - var merge = Merges.FirstOrDefault(mrg => mrg.RelativePath.EqualsIgnoreCase(conflict.RelativePath)); - if (merge == null) - return false; - - if (conflict.Mods.Any(mod => !mod.Name.EqualsIgnoreCase(merge.MergedModName) && !merge.ContainsMod(mod.Name))) - return false; - - if (merge.Mods.Any(mod => new LoadOrderComparer().Compare(merge.MergedModName, mod.Name) > 0)) - return false; - - return - merge.Mods.All(mod => mod.Hash == Tools.Hasher.ComputeHash(merge.GetModFile(mod.Name))); - } - - public Merge GetMergeByRelativePath(string relativePath) - { - return Merges.FirstOrDefault(m => m.RelativePath.EqualsIgnoreCase(relativePath)); - } - - // Adds file hashes to old inventories that don't have them - static void AddMissingHashes(MergeInventory inventory) - { - var anyMissing = false; - - foreach (var merge in inventory.Merges) - { - foreach (var mod in merge.Mods) - { - if (mod.Hash == null) - { - anyMissing = true; - mod.Hash = Tools.Hasher.ComputeHash(merge.GetModFile(mod.Name)); - } - } - } - - if (anyMissing) - inventory.Save(); - } - } -} diff --git a/WitcherScriptMerger/Inventory/MergeProgressInfo.cs b/WitcherScriptMerger/Inventory/MergeProgressInfo.cs deleted file mode 100644 index 8609745..0000000 --- a/WitcherScriptMerger/Inventory/MergeProgressInfo.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.ComponentModel; - -namespace WitcherScriptMerger.Inventory -{ - public class MergeProgressInfo : INotifyPropertyChanged - { - string _currentAction; - public string CurrentAction - { - get { return _currentAction; } - set { Set(ref _currentAction, value); } - } - - string _currentPhase; - public string CurrentPhase - { - get { return _currentPhase; } - set { Set(ref _currentPhase, value); } - } - - int _currentMergeNum; - public int CurrentMergeNum - { - get { return _currentMergeNum; } - set - { - _currentMergeNum = value; - UpdatePhase(); - } - } - - int _totalMergeCount; - public int TotalMergeCount - { - get { return _totalMergeCount; } - set - { - _totalMergeCount = value; - UpdatePhase(); - } - } - - string _currentFileName; - public string CurrentFileName - { - get { return _currentFileName; } - set - { - _currentFileName = value; - UpdatePhase(); - } - } - - int _currentFileNum; - public int CurrentFileNum - { - get { return _currentFileNum; } - set - { - _currentFileNum = value; - UpdatePhase(); - } - } - - int _totalFileCount; - public int TotalFileCount - { - get { return _totalFileCount; } - set - { - _totalFileCount = value; - UpdatePhase(); - } - } - - public event PropertyChangedEventHandler PropertyChanged; - - protected virtual void OnPropertyChanged() - { - PropertyChanged?.Invoke(this, null); - } - - void Set(ref T property, T value) - { - property = value; - OnPropertyChanged(); - } - - void UpdatePhase() - { - CurrentPhase = - "Resolving mod conflict" + - ( - TotalMergeCount > 1 - ? $" {CurrentMergeNum} of {TotalMergeCount}" : "" - ) + - "\nFile" + - ( - TotalFileCount > 1 && TotalFileCount != TotalMergeCount - ? $" {CurrentFileNum} of {TotalFileCount}" : "" - ) + - $": {CurrentFileName}"; - } - } -} diff --git a/WitcherScriptMerger/LoadOrder/CustomLoadOrder.cs b/WitcherScriptMerger/LoadOrder/CustomLoadOrder.cs deleted file mode 100644 index f6326e4..0000000 --- a/WitcherScriptMerger/LoadOrder/CustomLoadOrder.cs +++ /dev/null @@ -1,309 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; - -namespace WitcherScriptMerger.LoadOrder -{ - class CustomLoadOrder - { - public const int TopPriority = 0; - public const int BottomPriority = 9999; - - public readonly string FilePath = - Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), - "The Witcher 3", - "mods.settings"); - - public List Mods { get; private set; } - - public bool IsValid { get; private set; } - - public CustomLoadOrder() - { - Refresh(); - } - - #region File Processing - - public void Refresh() - { - Mods = new List(); - IsValid = false; - - if (!File.Exists(FilePath)) - { - IsValid = true; - return; - } - - var lines = File.ReadAllLines(FilePath); - - List mods = new List(); - ModLoadSetting currModSetting = null; - - for (int i = 0; i < lines.Length; ++i) - { - if (!ProcessLine(lines[i], i + 1, ref currModSetting)) - return; - - if (currModSetting != null - && currModSetting.IsEnabled.HasValue - && currModSetting.Priority.HasValue) - { - mods.Add(currModSetting); - currModSetting = null; - } - } - - IsValid = true; - - Mods = mods - .OrderBy(m => m.Priority) - .ThenBy(m => m.ModName) - .ToList(); - } - - bool ProcessLine(string line, int lineNum, ref ModLoadSetting setting) - { - line = line.Replace(" ", "").Replace("\t", ""); - - if (line.StartsWith("[") && line.EndsWith("]")) - { - if (!ProcessModNameLine(line, ref setting)) - return false; - } - else if (line.StartsWith("Enabled=")) - { - if (!ProcessIsEnabledLine(line, lineNum, setting)) - return false; - } - else if (line.StartsWith("Priority=")) - { - if (!ProcessPriorityLine(line, lineNum, setting)) - return false; - } - else if (!string.IsNullOrWhiteSpace(line) && !line.StartsWith(";")) - { - ShowWarningForMalformedFile($"Unrecognized value on line {lineNum}:\n\n{line}"); - return false; - } - return true; - } - - bool ProcessModNameLine(string line, ref ModLoadSetting setting) - { - if (setting != null) - { - ShowWarningForMalformedFile($"{setting.ModName} settings are incomplete. 'Enabled' and 'Priority' are both required."); - return false; - } - - var modName = line.Substring(1, line.Length - 2); // Trim brackets - setting = new ModLoadSetting(modName); - return true; - } - - bool ProcessIsEnabledLine(string line, int lineNum, ModLoadSetting setting) - { - if (setting == null) - { - ShowWarningForMalformedFile($"The 'Enabled' setting on line {lineNum} doesn't have a corresponding mod name."); - return false; - } - if (!new Regex("^Enabled=[0|1]$").IsMatch(line)) - { - ShowWarningForMalformedFile($"The 'Enabled' setting on line {lineNum} isn't within the valid range of 0 or 1:\n\n{line}"); - return false; - } - - setting.IsEnabled = line.EndsWith("1"); - return true; - } - - bool ProcessPriorityLine(string line, int lineNum, ModLoadSetting setting) - { - if (setting == null) - { - ShowWarningForMalformedFile($"The 'Priority' setting on line {lineNum} doesn't have a corresponding mod name."); - return false; - } - - var priorityString = line.Substring(line.IndexOf('=') + 1); - int parsedPriority; - - if (!int.TryParse(priorityString, out parsedPriority)) - { - ShowWarningForMalformedFile($"Can't parse the priority on line {lineNum}:\n\n{line}"); - return false; - } - if (TopPriority > parsedPriority || parsedPriority > BottomPriority) - { - ShowWarningForMalformedFile($"The priority on line {lineNum} isn't within the valid range of {TopPriority} to {BottomPriority}:\n\n{line}"); - return false; - } - - setting.Priority = parsedPriority; - return true; - } - - void ShowWarningForMalformedFile(string reason) - { - Program.MainForm.ShowMessage( - "Your mods.settings file is invalid.\n\n" + reason, - "Invalid Load Order File", - System.Windows.Forms.MessageBoxButtons.OK, - System.Windows.Forms.MessageBoxIcon.Warning); - } - - public void Save() - { - var builder = new StringBuilder(); - - foreach (var modSetting in Mods) - { - builder - .Append("[").Append(modSetting.ModName).AppendLine("]") - .Append("Enabled = ").AppendLine(Convert.ToInt32(modSetting.IsEnabled).ToString()) - .Append("Priority = ").AppendLine(modSetting.Priority.ToString()); - - if (modSetting != Mods.Last()) - builder.AppendLine(); - } - - File.WriteAllText(FilePath, builder.ToString()); - } - - #endregion - - public void AddMergedModIfMissing() - { - var mergedModName = Paths.RetrieveMergedModName(); - - if (!Mods.Any(setting => setting.ModName.EqualsIgnoreCase(mergedModName))) - { - Mods.Insert(0, - new ModLoadSetting - { - ModName = mergedModName, - IsEnabled = true, - Priority = TopPriority - }); - } - } - - public bool HasResolvedConflict(IEnumerable modNames) - { - var loadSettings = modNames - .Select(GetModLoadSettingByName) - .Where(setting => setting != null); - - if (!loadSettings.Any()) - return false; - - if (loadSettings.Any(setting => setting.IsEnabled.Value)) - return true; - - var numSettings = loadSettings.Count(); - var numMods = modNames.Count(); - - return (numSettings >= numMods - 1); - } - - public bool ContainsMod(string modName) - { - return Mods.Any(setting => setting.ModName.EqualsIgnoreCase(modName)); - } - - public ModLoadSetting GetTopPriorityEnabledMod() - { - return Mods - .OrderBy(setting => setting, new LoadOrderComparer()) - .FirstOrDefault(); - } - - public string GetTopPriorityEnabledMod(IEnumerable conflictMods) - { - var conflictModSettings = Mods.Where(setting => conflictMods.Any(modName => modName.EqualsIgnoreCase(setting.ModName))); - var enabledModSettings = conflictModSettings.Where(setting => setting.IsEnabled.Value); - - if (!conflictModSettings.Any()) - return conflictMods - .OrderBy(name => name, new LoadOrderComparer()) - .FirstOrDefault(); - - if (!enabledModSettings.Any()) - return conflictMods - .Except(conflictModSettings.Select(setting => setting.ModName)) - .OrderBy(name => name, new LoadOrderComparer()) - .FirstOrDefault(); - - return enabledModSettings - .OrderBy(setting => setting, new LoadOrderComparer()) - .ThenBy(setting => setting.ModName, new LoadOrderComparer()) - .FirstOrDefault() - ?.ModName; - } - - public ModLoadSetting GetModLoadSettingByName(string modName) - { - return Mods.FirstOrDefault(setting => setting.ModName.EqualsIgnoreCase(modName)); - } - - public bool IsModDisabledByName(string modName) - { - var mod = GetModLoadSettingByName(modName); - - return (mod != null && !mod.IsEnabled.Value); - } - - public void ToggleModByName(string modName) - { - var mod = GetModLoadSettingByName(modName); - - if (mod != null) - mod.IsEnabled = !mod.IsEnabled; - else - { - Mods.Add(new ModLoadSetting - { - ModName = modName, - IsEnabled = false, - Priority = BottomPriority - }); - } - } - - public int GetPriorityByName(string modName) - { - var mod = GetModLoadSettingByName(modName); - - return - mod != null - ? mod.Priority.Value - : -1; - } - - public void SetPriorityByName(string modName, int priority) - { - var mod = GetModLoadSettingByName(modName); - - if (mod != null) - { - mod.Priority = priority; - } - else - { - Mods.Add(new ModLoadSetting - { - ModName = modName, - IsEnabled = true, - Priority = priority - }); - } - } - } -} diff --git a/WitcherScriptMerger/LoadOrder/LoadOrderComparer.cs b/WitcherScriptMerger/LoadOrder/LoadOrderComparer.cs deleted file mode 100644 index 0c3cf0f..0000000 --- a/WitcherScriptMerger/LoadOrder/LoadOrderComparer.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace WitcherScriptMerger.LoadOrder -{ - class LoadOrderComparer : IComparer, IComparer - { - public int Compare(string x, string y) - { - // The game loads numbers first, then underscores, then letters (upper or lower). - // ASCII (ordinal) order is numbers, then uppercase letters, then underscores, then lowercase. - // To achieve the game's load order, we can convert uppercase letters to lowercase, then take ASCII order. - return string.Compare( - x.ToLowerInvariant(), - y.ToLowerInvariant(), - StringComparison.Ordinal); - } - - public int Compare(ModLoadSetting x, ModLoadSetting y) - { - if (x.IsEnabled.Value) - { - if (y.IsEnabled.Value) - return x.Priority.Value.CompareTo(y.Priority.Value); - else - return -1; // Only x is enabled - } - else if (y.IsEnabled.Value) - return 1; // Only y is enabled - else - return Compare(x.ModName, y.ModName); // Neither is enabled - } - } -} diff --git a/WitcherScriptMerger/LoadOrder/LoadOrderValidator.cs b/WitcherScriptMerger/LoadOrder/LoadOrderValidator.cs deleted file mode 100644 index 90fac84..0000000 --- a/WitcherScriptMerger/LoadOrder/LoadOrderValidator.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Linq; -using System.Windows.Forms; - -namespace WitcherScriptMerger.LoadOrder -{ - static class LoadOrderValidator - { - public static void ValidateAndFix(CustomLoadOrder loadOrder) - { - if (!loadOrder.Mods.Any()) - return; - - var mergedModName = Paths.RetrieveMergedModName(); - var mergedMod = loadOrder.Mods.Find(m => m.ModName.EqualsIgnoreCase(mergedModName)); - - if (mergedMod != null && mergedMod == loadOrder.GetTopPriorityEnabledMod()) - return; - - var choice = PromptToPrioritizeMergedMod(loadOrder.FilePath); - if (choice == DialogResult.Yes) - { - PrioritizeMergedMod(loadOrder, mergedMod); - } - else if (choice == DialogResult.Cancel) // Never - { - Program.Settings.Set("ValidateCustomLoadOrder", false); - Program.Settings.Save(); - } - } - - static DialogResult PromptToPrioritizeMergedMod(string modsSettingsPath) - { - MessageBoxManager.Cancel = "Ne&ver"; - MessageBoxManager.Register(); - - var choice = MessageBox.Show( - $"{modsSettingsPath}\n\n" + - "Detected custom load order in the file above, and merged files aren't configured to load first.\n\n" + - "Would you like Script Merger to modify your custom load order so that your merged files have top priority?", - "Custom Load Order Problem", - MessageBoxButtons.YesNoCancel, - MessageBoxIcon.Exclamation, - MessageBoxDefaultButton.Button2); - - MessageBoxManager.Unregister(); - return choice; - } - - static void PrioritizeMergedMod(CustomLoadOrder loadOrder, ModLoadSetting mergedModSetting) - { - // Priority of min - 1 will be incremented to min - var priority = CustomLoadOrder.TopPriority - 1; - - if (mergedModSetting != null) - { - mergedModSetting.IsEnabled = true; - mergedModSetting.Priority = priority; - } - else - { - loadOrder.Mods.Insert(0, new ModLoadSetting - { - ModName = Paths.RetrieveMergedModName(), - IsEnabled = true, - Priority = priority - }); - } - - IncrementLeadingContiguousPriorities(loadOrder, priority); - - loadOrder.Save(); - } - - static void IncrementLeadingContiguousPriorities(CustomLoadOrder loadOrder, int startingPriority) - { - var nextPriority = startingPriority + 1; - var modsToIncrement = loadOrder.Mods.Where(mod => mod.Priority == startingPriority).ToArray(); - var displacedMods = loadOrder.Mods.Where(mod => mod.Priority == nextPriority).ToArray(); - - if (!modsToIncrement.Any()) - return; - - if (displacedMods.Any() && - nextPriority < CustomLoadOrder.BottomPriority) - { - IncrementLeadingContiguousPriorities(loadOrder, nextPriority); - } - - foreach (var mod in modsToIncrement) - ++mod.Priority; - } - } -} diff --git a/WitcherScriptMerger/LoadOrder/ModLoadSetting.cs b/WitcherScriptMerger/LoadOrder/ModLoadSetting.cs deleted file mode 100644 index 579218a..0000000 --- a/WitcherScriptMerger/LoadOrder/ModLoadSetting.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace WitcherScriptMerger.LoadOrder -{ - class ModLoadSetting - { - public string ModName { get; set; } - - public bool? IsEnabled { get; set; } - - public int? Priority { get; set; } - - public ModLoadSetting() - { } - - public ModLoadSetting(string modName) - { - ModName = modName; - } - - public override string ToString() - { - return $"{ModName}, priority {Priority}, {(!IsEnabled.HasValue || IsEnabled.Value ? "enabled" : "disabled")}"; - } - } -} diff --git a/WitcherScriptMerger/Paths.cs b/WitcherScriptMerger/Paths.cs deleted file mode 100644 index df43954..0000000 --- a/WitcherScriptMerger/Paths.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.IO; -using System.Windows.Forms; -using WitcherScriptMerger.Tools; - -namespace WitcherScriptMerger -{ - static class Paths - { - public const string TempBundleContent = "tempbundlecontent"; - public static string MergedBundleContent = "Merged Bundle Content"; - public static string MergedBundleContentAbsolute = Path.Combine(Environment.CurrentDirectory, MergedBundleContent); - public const string Inventory = "MergeInventory.xml"; - public static string ModScriptBase = Path.Combine("content", "scripts"); - public static string VanillaScriptBase = Path.Combine("content", "content0", "scripts"); - public static string BundleBase = "content"; - - public static string GameDirectory => Program.MainForm.GameDirectorySetting; - - public static string GameExe => Path.Combine(GameDirectory, "bin", "x64", "witcher3.exe"); - - public static string BundlesDirectory => Path.Combine(GameDirectory, BundleBase); - - public static string DlcDirectory => Path.Combine(GameDirectory, "DLC"); - - static string _scriptsDirSetting = Program.Settings.Get("VanillaScriptsDirectory"); - public static string ScriptsDirectory - { - get - { - return (!string.IsNullOrWhiteSpace(_scriptsDirSetting) - ? _scriptsDirSetting - : Path.Combine(GameDirectory, VanillaScriptBase)); - } - } - - static string _modsDirSetting = Program.Settings.Get("ModsDirectory"); - public static string ModsDirectory - { - get - { - return (!string.IsNullOrWhiteSpace(_modsDirSetting) - ? _modsDirSetting - : Path.Combine(GameDirectory, "Mods")); - } - } - - public static bool IsScriptsDirectoryDerived => string.IsNullOrWhiteSpace(_scriptsDirSetting); - - public static bool IsModsDirectoryDerived => string.IsNullOrWhiteSpace(_modsDirSetting); - - public static string GetRelativePath(string fullPath, string basePath) - { - var startIndex = fullPath.IndexOfIgnoreCase(basePath) + basePath.Length + 1; - return fullPath.Substring(startIndex); - } - - public static bool ValidateDependencyPaths() - { - return (File.Exists(KDiff3.ExePath) && - File.Exists(QuickBms.ExePath) && - File.Exists(QuickBms.PluginPath) && - File.Exists(WccLite.ExePath)); - } - - public static bool ValidateModsDirectory() - { - if (!Directory.Exists(ModsDirectory)) - { - Program.MainForm.ShowMessage( - (!IsModsDirectoryDerived - ? "Can't find the Mods directory specified in the config file." - : "Can't find Mods directory in the specified game directory.")); - return false; - } - return true; - } - - public static bool ValidateScriptsDirectory() - { - if (!Directory.Exists(ScriptsDirectory)) - { - Program.MainForm.ShowMessage( - (!IsScriptsDirectoryDerived - ? "Can't find the Scripts directory specified in the config file." - : "Can't find \\content\\content0\\scripts directory in the specified game directory.") + - "\n\nIt was added in patch 1.08.1 and should contain the game's vanilla scripts."); - return false; - } - return true; - } - - public static bool ValidateBundlesDirectory() - { - if (!Directory.Exists(BundlesDirectory)) - { - Program.MainForm.ShowMessage("Can't find 'content' directory in the specified game directory."); - return false; - } - return true; - } - - public static string RetrieveMergedBundlePath() - { - var mergedModName = RetrieveMergedModName(); - if (mergedModName != null) - return Path.Combine(ModsDirectory, mergedModName, BundleBase, "blob0.bundle"); - else - return null; - } - - public static string RetrieveMergedModName() - { - var mergedModName = Program.Settings.Get("MergedModName"); - if (string.IsNullOrWhiteSpace(mergedModName)) - { - Program.MainForm.ShowMessage("The MergedModName setting isn't configured in the .config file."); - return null; - } - if (mergedModName.Length > 64) - mergedModName = mergedModName.Substring(0, 64); - if (!mergedModName.IsAlphaNumeric() || !mergedModName.StartsWith("mod")) - { - if (!ConfirmInvalidModName(mergedModName)) - return null; - } - return mergedModName; - } - - public static string RetrieveMergedModDir() - { - var modName = RetrieveMergedModName(); - return - modName != null - ? Path.Combine(ModsDirectory, modName) - : null; - } - - static bool ConfirmInvalidModName(string mergedModName) - { - return (DialogResult.Yes == Program.MainForm.ShowMessage( - "The Witcher 3 won't load the merged file if the mod name isn't \"mod\" followed by numbers, letters, or underscores." - + "\n\nUse this name anyway?\n" + mergedModName - + "\n\nTo change the name: Click No, then edit \"MergedModName\" in the .config file.", - "Warning", - MessageBoxButtons.YesNo, - MessageBoxIcon.Exclamation)); - } - } -} \ No newline at end of file diff --git a/WitcherScriptMerger/Program.cs b/WitcherScriptMerger/Program.cs index 32ed6c2..47f2957 100644 --- a/WitcherScriptMerger/Program.cs +++ b/WitcherScriptMerger/Program.cs @@ -1,97 +1,299 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Windows.Forms; -using WitcherScriptMerger.Forms; -using WitcherScriptMerger.Inventory; -using WitcherScriptMerger.LoadOrder; - -namespace WitcherScriptMerger -{ - static class Program - { - public static AppSettings Settings = new AppSettings(); - public static CustomLoadOrder LoadOrder = null; - public static MergeInventory Inventory = null; - public static MainForm MainForm; - - /// - /// The main entry point for the application. - /// - [STAThread] - static void Main() - { - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - - if (!Settings.HasConfigFile) - { - ShowLaunchFailure("Config file is missing."); - return; - } - if (!Paths.ValidateDependencyPaths()) - { - using (var dependencyForm = new DependencyForm()) - { - if (dependencyForm.ShowDialog() != DialogResult.OK) - { - ShowLaunchFailure("A dependency is missing."); - return; - } - } - } - - MainForm = new MainForm(); - Application.Run(MainForm); - } - - static void ShowLaunchFailure(string message) - { - MessageBox.Show( - $"Launch failure: {message}", - "Script Merger Error", - MessageBoxButtons.OK, - MessageBoxIcon.Error); - } - - public static bool TryOpenFile(string path) - { - if (!File.Exists(path)) - { - MainForm.ShowMessage("Can't find file: " + path); - return false; - } - - if (path.EndsWithIgnoreCase(".exe")) // EXEs need working dir to be specified - { - var startInfo = new ProcessStartInfo - { - FileName = path, - WorkingDirectory = Path.GetDirectoryName(path) - }; - Process.Start(startInfo); - } - else - try { Process.Start(path); } - catch (Exception) { } - - return true; - } - - public static bool TryOpenFileLocation(string filePath) - { - return TryOpenDirectory(Path.GetDirectoryName(filePath)); - } - - public static bool TryOpenDirectory(string dirPath) - { - if (!Directory.Exists(dirPath)) - { - MainForm.ShowMessage("Can't find directory: " + dirPath); - return false; - } - Process.Start(dirPath); - return true; - } - } -} \ No newline at end of file +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Windows.Forms; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; +using WitcherScriptMerger.Cli; +using WitcherScriptMerger.Forms; +using WitcherScriptMerger.Inventory; +using WitcherScriptMerger.LoadOrder; +using WitcherScriptMerger.Mcp; +using WitcherScriptMerger.Tools; + +namespace WitcherScriptMerger +{ + static class Program + { + // Must run before anything else in this class's static init, so any + // early startup error (e.g. missing App.config) is visible in the + // invoking terminal instead of written to an unattached console. + static readonly bool _consoleAttached = MaybeAttachConsole(); + + // Explicit (empty) static constructor: without one, the C# compiler marks + // this class `beforefieldinit`, under which the CLR is free to defer running + // _consoleAttached's initializer until Program's own static fields are first + // touched - Main() itself no longer counts, since Notifier/Settings/LoadOrder/ + // Inventory became pass-through properties to AppState (see below) rather than + // fields, so nothing in Main() necessarily touches a field of this class at + // all. Confirmed empirically with a minimal repro mirroring this exact shape: + // without this constructor, the field initializer's side effect (here, + // MaybeAttachConsole()) never ran at all during a normal Main() invocation. + // Do not remove this without re-verifying that repro. + static Program() { } + + // Notifier/Settings/LoadOrder/Inventory live in Core's AppState now, not here - + // domain code that moved to Core (Paths, FileMerger, Cli/MergeOperations, + // Mcp/WsmMcpTools, ...) needs them, and Core can never reference this host + // assembly (see AppState.cs). These pass-through properties keep every + // existing Program.X call site in this project unchanged. + public static IMergeNotifier Notifier + { + get => AppState.Notifier; + set => AppState.Notifier = value; + } + public static AppSettings Settings => AppState.Settings; + public static CustomLoadOrder LoadOrder + { + get => AppState.LoadOrder; + set => AppState.LoadOrder = value; + } + public static MergeInventory Inventory + { + get => AppState.Inventory; + set => AppState.Inventory = value; + } + public static MainForm MainForm; + + /// + /// The main entry point for the application. + /// + [STAThread] + static void Main(string[] args) + { + // The default IMergeEngine implementation, supplied here since KDiff3MergeEngine + // needs Tools/KDiff3.cs's Win32 P/Invoke (host-only) - see Tools/IMergeEngine.cs. + // Must be set before anything calls Paths.ValidateDependencyPaths() or + // constructs a FileMerger, in any of the GUI/CLI/MCP paths below. The + // "MergeEngine" App.config setting can switch to DiffPlexMergeEngine (Core, no + // external binary) instead - not the default yet, since it hasn't been + // cross-checked against KDiff3 on enough real conflicting files (see CLAUDE.md + // and the PR that introduced it); this switch exists so it can be tried without + // recompiling, not as a signal that it's considered production-ready. + AppState.MergeEngine = + Settings.Get("MergeEngine").EqualsIgnoreCase("diffplex") + ? (IMergeEngine)new DiffPlexMergeEngine() + : new KDiff3MergeEngine(); + + if (args.Length > 0) + { + Environment.ExitCode = RunCli(args); + return; + } + + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + if (!Settings.HasConfigFile) + { + ShowLaunchFailure("Config file is missing."); + return; + } + if (!Paths.ValidateDependencyPaths()) + { + using (var dependencyForm = new DependencyForm()) + { + if (dependencyForm.ShowDialog() != DialogResult.OK) + { + ShowLaunchFailure("A dependency is missing."); + return; + } + } + } + + MainForm = new MainForm(); + Notifier = MainForm; + Application.Run(MainForm); + } + + static void ShowLaunchFailure(string message) + { + Notifier.ShowError($"Launch failure: {message}", "Script Merger Error"); + } + + public static bool TryOpenFile(string path) + { + if (!File.Exists(path)) + { + MainForm.ShowMessage("Can't find file: " + path); + return false; + } + + if (path.EndsWithIgnoreCase(".exe")) // EXEs need working dir to be specified + { + var startInfo = new ProcessStartInfo + { + FileName = path, + WorkingDirectory = Path.GetDirectoryName(path) + }; + Process.Start(startInfo); + } + else + try { Process.Start(path); } + catch (Exception) { } + + return true; + } + + public static bool TryOpenFileLocation(string filePath) + { + return TryOpenDirectory(Path.GetDirectoryName(filePath)); + } + + public static bool TryOpenDirectory(string dirPath) + { + if (!Directory.Exists(dirPath)) + { + MainForm.ShowMessage("Can't find directory: " + dirPath); + return false; + } + Process.Start(dirPath); + return true; + } + + #region CLI + + // "merge" and "mcp" are the only commands for now. Exit codes (merge): 0 = every + // conflict merged, 1 = couldn't even start (bad args/config/deps), 2 = ran, but + // one or more conflicts were skipped. + static int RunCli(string[] args) + { + Environment.CurrentDirectory = AppContext.BaseDirectory; + + if (!Settings.HasConfigFile) + { + ShowLaunchFailure("Config file is missing."); + return 1; + } + + if (args[0] == "mcp") + return RunMcp(); + + if (args[0] != "merge") + { + Console.Error.WriteLine($"Unknown command '{args[0]}'. Supported commands: merge, mcp"); + return 1; + } + + if (!Paths.ValidateDependencyPaths()) + { + Notifier.ShowError( + "A required dependency (KDiff3, QuickBMS, or wcc_lite) is missing. Configure its path " + + "in App.config, or run without arguments once to use the GUI's dependency setup."); + 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; + + LoadOrder = new CustomLoadOrder(); + Inventory = MergeInventory.Load(Paths.Inventory); + + var modIndex = MergeOperations.ScanConflicts(); + + if (!modIndex.HasConflict) + { + Console.WriteLine("No conflicts found."); + return 0; + } + + var summary = MergeOperations.RunMerge(Inventory, modIndex.Conflicts, mergedModName, orderOverrides); + + 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 (see Mcp/WsmMcpTools.cs and CLAUDE.md's MCP mode + // section). Never returns until the client disconnects/stdin closes. + static int RunMcp() + { + if (!Paths.ValidateDependencyPaths()) + { + Console.Error.WriteLine( + "A required dependency (KDiff3, QuickBMS, or wcc_lite) is missing. Configure its path in App.config."); + 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 now 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. + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(typeof(WsmMcpTools).Assembly); + + builder.Build().RunAsync().GetAwaiter().GetResult(); + return 0; + } + + static bool MaybeAttachConsole() + { + // GetCommandLineArgs()[0] is the exe path itself; more than that means CLI + // arguments were passed. Skip for "mcp": stdin/stdout are reserved for the MCP + // protocol there, and an MCP client spawns WSM with its own redirected pipes + // rather than a console to attach to anyway. + var cliArgs = Environment.GetCommandLineArgs(); + return cliArgs.Length > 1 && cliArgs[1] != "mcp" && AttachConsole(AttachParentProcess); + } + + const int AttachParentProcess = -1; + + [DllImport("kernel32.dll")] + static extern bool AttachConsole(int dwProcessId); + + #endregion + } +} diff --git a/WitcherScriptMerger/Properties/AssemblyInfo.cs b/WitcherScriptMerger/Properties/AssemblyInfo.cs index bf075ac..d9ff88f 100644 --- a/WitcherScriptMerger/Properties/AssemblyInfo.cs +++ b/WitcherScriptMerger/Properties/AssemblyInfo.cs @@ -1,6 +1,9 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +[assembly: SupportedOSPlatform("windows")] // General Information about an assembly is controlled through the following // set of attributes. Change these attribute values to modify the information diff --git a/WitcherScriptMerger/Properties/Settings.Designer.cs b/WitcherScriptMerger/Properties/Settings.Designer.cs deleted file mode 100644 index d522226..0000000 --- a/WitcherScriptMerger/Properties/Settings.Designer.cs +++ /dev/null @@ -1,26 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.34209 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace WitcherScriptMerger.Properties { - - - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "12.0.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { - - private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default { - get { - return defaultInstance; - } - } - } -} diff --git a/WitcherScriptMerger/Properties/Settings.settings b/WitcherScriptMerger/Properties/Settings.settings deleted file mode 100644 index abf36c5..0000000 --- a/WitcherScriptMerger/Properties/Settings.settings +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/WitcherScriptMerger/TaskbarProgress.cs b/WitcherScriptMerger/TaskbarProgress.cs index c36c5a5..50254c2 100644 --- a/WitcherScriptMerger/TaskbarProgress.cs +++ b/WitcherScriptMerger/TaskbarProgress.cs @@ -5,62 +5,62 @@ static class TaskbarProgress { - public enum TaskbarStates - { - NoProgress = 0, - Indeterminate = 0x1, - Normal = 0x2, - Error = 0x4, - Paused = 0x8 - } + public enum TaskbarStates + { + NoProgress = 0, + Indeterminate = 0x1, + Normal = 0x2, + Error = 0x4, + Paused = 0x8 + } - [ComImportAttribute()] - [GuidAttribute("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf")] - [InterfaceTypeAttribute(ComInterfaceType.InterfaceIsIUnknown)] - private interface ITaskbarList3 - { - // ITaskbarList - [PreserveSig] - void HrInit(); - [PreserveSig] - void AddTab(IntPtr hwnd); - [PreserveSig] - void DeleteTab(IntPtr hwnd); - [PreserveSig] - void ActivateTab(IntPtr hwnd); - [PreserveSig] - void SetActiveAlt(IntPtr hwnd); + [ComImportAttribute()] + [GuidAttribute("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf")] + [InterfaceTypeAttribute(ComInterfaceType.InterfaceIsIUnknown)] + private interface ITaskbarList3 + { + // ITaskbarList + [PreserveSig] + void HrInit(); + [PreserveSig] + void AddTab(IntPtr hwnd); + [PreserveSig] + void DeleteTab(IntPtr hwnd); + [PreserveSig] + void ActivateTab(IntPtr hwnd); + [PreserveSig] + void SetActiveAlt(IntPtr hwnd); - // ITaskbarList2 - [PreserveSig] - void MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fFullscreen); + // ITaskbarList2 + [PreserveSig] + void MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fFullscreen); - // ITaskbarList3 - [PreserveSig] - void SetProgressValue(IntPtr hwnd, UInt64 ullCompleted, UInt64 ullTotal); - [PreserveSig] - void SetProgressState(IntPtr hwnd, TaskbarStates state); - } + // ITaskbarList3 + [PreserveSig] + void SetProgressValue(IntPtr hwnd, UInt64 ullCompleted, UInt64 ullTotal); + [PreserveSig] + void SetProgressState(IntPtr hwnd, TaskbarStates state); + } - [GuidAttribute("56FDF344-FD6D-11d0-958A-006097C9A090")] - [ClassInterfaceAttribute(ClassInterfaceType.None)] - [ComImportAttribute()] - private class TaskbarInstance - { - } + [GuidAttribute("56FDF344-FD6D-11d0-958A-006097C9A090")] + [ClassInterfaceAttribute(ClassInterfaceType.None)] + [ComImportAttribute()] + private class TaskbarInstance + { + } - private static ITaskbarList3 taskbarInstance = (ITaskbarList3)new TaskbarInstance(); - private static bool taskbarSupported = Environment.OSVersion.Version >= new Version(6, 1); + private static ITaskbarList3 taskbarInstance = (ITaskbarList3)new TaskbarInstance(); + private static bool taskbarSupported = Environment.OSVersion.Version >= new Version(6, 1); - public static void SetState(IntPtr windowHandle, TaskbarStates taskbarState) - { - if (taskbarSupported) - taskbarInstance.SetProgressState(windowHandle, taskbarState); - } + public static void SetState(IntPtr windowHandle, TaskbarStates taskbarState) + { + if (taskbarSupported) + taskbarInstance.SetProgressState(windowHandle, taskbarState); + } - public static void SetValue(IntPtr windowHandle, double progressValue, double progressMax) - { - if (taskbarSupported) - taskbarInstance.SetProgressValue(windowHandle, (ulong)progressValue, (ulong)progressMax); - } + public static void SetValue(IntPtr windowHandle, double progressValue, double progressMax) + { + if (taskbarSupported) + taskbarInstance.SetProgressValue(windowHandle, (ulong)progressValue, (ulong)progressMax); + } } diff --git a/WitcherScriptMerger/Tools/KDiff3.cs b/WitcherScriptMerger/Tools/KDiff3.cs index 35fbfcf..2a76ad5 100644 --- a/WitcherScriptMerger/Tools/KDiff3.cs +++ b/WitcherScriptMerger/Tools/KDiff3.cs @@ -1,78 +1,302 @@ using System; using System.Diagnostics; using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; using WitcherScriptMerger.Inventory; namespace WitcherScriptMerger.Tools { - static class KDiff3 - { - public static string ExePath = Program.Settings.Get("KDiff3Path"); - - public static int Run( - FileMerger.MergeSource source1, - FileMerger.MergeSource source2, - FileInfo vanillaFile, - string outputPath) - { - if (!File.Exists(ExePath)) - { - Program.MainForm.ShowError("Can't find KDiff3 at this location:\n\n" + ExePath, "Missing KDiff3"); - return 1; - } - - var outputDir = Path.GetDirectoryName(outputPath); - - if (!Directory.Exists(outputDir)) - Directory.CreateDirectory(outputDir); - - var hasVanillaVersion = (vanillaFile != null && vanillaFile.Exists); - - var args = (hasVanillaVersion - ? "\"" + vanillaFile.FullName + "\" " - : ""); - - args += - $"\"{source1.TextFile.FullName}\" \"{source2.TextFile.FullName}\" " + - $"-o \"{outputPath}\" " + - "--cs \"WhiteSpace3FileMergeDefault=2\" " + - "--cs \"CreateBakFiles=0\" " + - "--cs \"LineEndStyle=1\" " + - "--cs \"FollowFileLinks=1\" " + - "--cs \"FollowDirLinks=1\""; - - if (!Program.Settings.Get("ShowPathsInKDiff3")) - { - if (hasVanillaVersion) - args += $" --L1 Vanilla --L2 \"{source1.Name}\" --L3 \"{source2.Name}\""; - else - args += $" --L1 \"{source1.Name}\" --L2 \"{source2.Name}\""; - } - - if (!Program.Settings.Get("ReviewEachMerge") && hasVanillaVersion) - { - if (source1.TextFile.FullName.EqualsIgnoreCase(outputPath) - && source2.Hash != null && source2.Hash.IsOutdated) - { - Program.MainForm.ShowMessage( - "You are merging an updated mod file into a merge created with a previous version of the file.\n\n" + - "You should carefully inspect this merge, because KDiff3's auto-solving behavior KEEPS changes from the previous version of the mod file that have been REMOVED in the new version.", - "Warning", - System.Windows.Forms.MessageBoxButtons.OK, - System.Windows.Forms.MessageBoxIcon.Warning); - } - else - args += " --auto"; - } - - var kdiff3Path = (Path.IsPathRooted(ExePath) - ? ExePath - : Path.Combine(Environment.CurrentDirectory, ExePath)); - - var kdiff3Proc = Process.Start(kdiff3Path, args); - kdiff3Proc.WaitForExit(); - - return kdiff3Proc.ExitCode; - } - } + static class KDiff3 + { + public static string ExePath = Program.Settings.Get("KDiff3Path"); + + public static int Run( + FileMerger.MergeSource source1, + FileMerger.MergeSource source2, + FileInfo vanillaFile, + string outputPath) + { + if (!File.Exists(ExePath)) + { + Program.Notifier.ShowError("Can't find KDiff3 at this location:\n\n" + ExePath, "Missing KDiff3"); + return 1; + } + + var outputDir = Path.GetDirectoryName(outputPath); + + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + + var args = BuildArgs(source1, source2, vanillaFile, outputPath, out var hasVanillaVersion); + + if (!Program.Settings.Get("ReviewEachMerge") && hasVanillaVersion) + { + if (source1.TextFile.FullName.EqualsIgnoreCase(outputPath) + && source2.Hash != null && source2.Hash.IsOutdated) + { + Program.Notifier.ShowMessage( + "You are merging an updated mod file into a merge created with a previous version of the file.\n\n" + + "You should carefully inspect this merge, because KDiff3's auto-solving behavior KEEPS changes from the previous version of the mod file that have been REMOVED in the new version.", + "Warning", + NotifyButtons.OK, + DialogIcon.Warning); + } + else + args += " --auto"; + } + + var kdiff3Path = ResolveExePath(); + + var kdiff3Proc = Process.Start(kdiff3Path, args); + kdiff3Proc.WaitForExit(); + + return kdiff3Proc.ExitCode; + } + + public enum HeadlessResult { AutoSolved, NeedsManualResolution, Failed } + + // KDiff3 has no fail-fast mode - its own docs (doc/dothemerge.html) say plainly + // that when manual interaction is needed, a merge window opens, even in its own + // batch/automation mode. So this doesn't ask KDiff3 to behave headlessly; it + // launches it normally and detects a stuck merge itself: KDiff3 always briefly + // shows a plain "Conflicts" window on startup regardless of outcome (not a + // signal), but only a genuine unresolved conflict leaves open a second window + // titled " <-> [ <-> ] - KDiff3" - the actual comparison/merge + // editor. If that window is still open past a short grace period, this treats + // the merge as needing manual resolution, kills the process, and reports it as + // skipped rather than waiting on it (verified empirically against real and + // synthetic conflicts - see CLAUDE.md). Never writes to the real outputPath + // directly: KDiff3's -o target is a scratch path, only copied into place after + // a confirmed clean exit, so a killed process can never leave a partial file + // where the game would load it. + public static HeadlessResult RunHeadless( + FileMerger.MergeSource source1, + FileMerger.MergeSource source2, + FileInfo vanillaFile, + string outputPath) + { + if (!File.Exists(ExePath)) + { + Program.Notifier.ShowError("Can't find KDiff3 at this location:\n\n" + ExePath, "Missing KDiff3"); + return HeadlessResult.Failed; + } + + var scratchDir = Path.Combine(Paths.TempBundleContent, "HeadlessOutput"); + Directory.CreateDirectory(scratchDir); + var scratchOutputPath = Path.Combine(scratchDir, Guid.NewGuid().ToString("N") + Path.GetExtension(outputPath)); + + var args = BuildArgs(source1, source2, vanillaFile, scratchOutputPath, out var hasVanillaVersion); + + if (hasVanillaVersion + && source1.TextFile.FullName.EqualsIgnoreCase(outputPath) + && source2.Hash != null && source2.Hash.IsOutdated) + { + // The interactive path skips --auto here and relies on the user reviewing + // manually - there's nothing safe to do headlessly but skip it too. + Program.Notifier.ShowMessage( + $"Skipped {source1.Name} + {source2.Name}: merging an updated mod file into a merge " + + "created with a previous version needs manual review (KDiff3's auto-solving would keep " + + "changes from the previous version that have been removed in the new one).", + "Skipped", NotifyButtons.OK, DialogIcon.Warning); + return HeadlessResult.NeedsManualResolution; + } + args += " --auto"; + + // KDiff3's window can't be hidden or moved off-screen without KDiff3 hanging + // indefinitely instead of auto-solving - confirmed empirically against a hidden + // desktop, ShowWindow(SW_HIDE), and SetWindowPos off-screen, all three of which + // reliably broke it while an untouched window auto-solves normally. It also + // steals foreground focus while shown. Since it can't be suppressed, the best + // available mitigation is restoring focus to whatever had it beforehand once + // KDiff3's window is gone (auto-solved, failed, or killed) - see CLAUDE.md. + var previousForeground = NativeMethods.GetForegroundWindow(); + + var proc = Process.Start(ResolveExePath(), args); + var pid = proc.Id; + var sw = Stopwatch.StartNew(); + + try + { + const int gracePeriodMs = 3000; + const int backstopTimeoutMs = 60000; + long? mergeWindowFirstSeenMs = null; + + while (!proc.HasExited && sw.ElapsedMilliseconds < backstopTimeoutMs) + { + if (HasVisibleMergeWindow(pid)) + { + mergeWindowFirstSeenMs ??= sw.ElapsedMilliseconds; + if (sw.ElapsedMilliseconds - mergeWindowFirstSeenMs.Value > gracePeriodMs) + break; + } + else + { + mergeWindowFirstSeenMs = null; + } + proc.Refresh(); + Thread.Sleep(250); + } + + if (!proc.HasExited) + { + // Kill() only requests termination - wait for it to actually take effect + // before returning, so the finally block's focus restore isn't racing a + // window that's still technically alive (and might still own focus). + try { proc.Kill(entireProcessTree: true); proc.WaitForExit(2000); } catch { } + DeleteIfExists(scratchOutputPath); + Program.Notifier.ShowMessage( + $"Skipped {source1.Name} + {source2.Name}: needs manual conflict resolution.", + "Skipped", NotifyButtons.OK, DialogIcon.Warning); + return HeadlessResult.NeedsManualResolution; + } + + if (proc.ExitCode == 0 && File.Exists(scratchOutputPath)) + { + var outputDir = Path.GetDirectoryName(outputPath); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + File.Copy(scratchOutputPath, outputPath, overwrite: true); + DeleteIfExists(scratchOutputPath); + return HeadlessResult.AutoSolved; + } + + DeleteIfExists(scratchOutputPath); + return HeadlessResult.Failed; + } + finally + { + RestoreForegroundWindow(previousForeground); + } + } + + // Plain SetForegroundWindow is denied by Windows' foreground-lock rules here: this + // process didn't own the foreground when KDiff3's window took over (KDiff3 did), so + // by the time this runs, this process isn't a privileged caller. Confirmed empirically - + // plain SetForegroundWindow was silently denied every time, even after waiting for + // KDiff3's process to fully exit. AttachThreadInput temporarily shares input state with + // whatever thread currently owns the foreground, which grants this thread the same + // privilege for the duration of the call - the standard workaround for this restriction. + // Still best-effort: if it fails, there's nothing destructive about not refocusing. + static void RestoreForegroundWindow(IntPtr previousForeground) + { + try + { + var currentForeground = NativeMethods.GetForegroundWindow(); + var foregroundThreadId = NativeMethods.GetWindowThreadProcessId(currentForeground, out _); + var currentThreadId = NativeMethods.GetCurrentThreadId(); + + var attached = foregroundThreadId != currentThreadId + && NativeMethods.AttachThreadInput(currentThreadId, foregroundThreadId, true); + try + { + NativeMethods.SetForegroundWindow(previousForeground); + } + finally + { + if (attached) + NativeMethods.AttachThreadInput(currentThreadId, foregroundThreadId, false); + } + } + catch { } + } + + static string BuildArgs( + FileMerger.MergeSource source1, + FileMerger.MergeSource source2, + FileInfo vanillaFile, + string outputPath, + out bool hasVanillaVersion) + { + hasVanillaVersion = (vanillaFile != null && vanillaFile.Exists); + + var vanillaPath = hasVanillaVersion ? FileEncoding.EnsureUtf16File(vanillaFile, "Vanilla") : null; + var source1Path = FileEncoding.EnsureUtf16File(source1.TextFile, "Source1"); + var source2Path = FileEncoding.EnsureUtf16File(source2.TextFile, "Source2"); + + var args = (hasVanillaVersion + ? "\"" + vanillaPath + "\" " + : ""); + + args += + $"\"{source1Path}\" \"{source2Path}\" " + + $"-o \"{outputPath}\" " + + "--cs \"WhiteSpace3FileMergeDefault=2\" " + + "--cs \"CreateBakFiles=0\" " + + "--cs \"LineEndStyle=1\" " + + "--cs \"FollowFileLinks=1\" " + + "--cs \"FollowDirLinks=1\""; + + if (!Program.Settings.Get("ShowPathsInKDiff3")) + { + if (hasVanillaVersion) + args += $" --L1 Vanilla --L2 \"{source1.Name}\" --L3 \"{source2.Name}\""; + else + args += $" --L1 \"{source1.Name}\" --L2 \"{source2.Name}\""; + } + + return args; + } + + static string ResolveExePath() + { + return Path.IsPathRooted(ExePath) + ? ExePath + : Path.Combine(Environment.CurrentDirectory, ExePath); + } + + static void DeleteIfExists(string path) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } + + static bool HasVisibleMergeWindow(int pid) + { + var found = false; + NativeMethods.EnumWindows((hWnd, _) => + { + NativeMethods.GetWindowThreadProcessId(hWnd, out uint windowPid); + if (windowPid == (uint)pid && NativeMethods.IsWindowVisible(hWnd)) + { + var sb = new StringBuilder(256); + NativeMethods.GetWindowText(hWnd, sb, sb.Capacity); + if (sb.ToString().EndsWith(" - KDiff3", StringComparison.Ordinal)) + found = true; + } + return true; + }, IntPtr.Zero); + return found; + } + + static class NativeMethods + { + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern bool EnumWindows(EnumWindowsProc enumProc, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + [DllImport("user32.dll")] + public static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("kernel32.dll")] + public static extern uint GetCurrentThreadId(); + + [DllImport("user32.dll")] + public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); + } + + } } diff --git a/WitcherScriptMerger/Tools/KDiff3MergeEngine.cs b/WitcherScriptMerger/Tools/KDiff3MergeEngine.cs new file mode 100644 index 0000000..0d85783 --- /dev/null +++ b/WitcherScriptMerger/Tools/KDiff3MergeEngine.cs @@ -0,0 +1,39 @@ +using System.IO; +using WitcherScriptMerger.Inventory; + +namespace WitcherScriptMerger.Tools +{ + // The one real IMergeEngine implementation - see Core's Tools/IMergeEngine.cs for + // why this scaffolding exists. Just wraps the existing KDiff3.Run/RunHeadless + // calls FileMerger (now in Core) used to make directly; all the real logic + // (encoding normalization, window-persistence detection, focus restoration) stays + // in KDiff3.cs unchanged. + class KDiff3MergeEngine : IMergeEngine + { + public MergeEngineResult Merge( + FileMerger.MergeSource source1, + FileMerger.MergeSource source2, + FileInfo vanillaFile, + string outputPath) + { + var exitCode = KDiff3.Run(source1, source2, vanillaFile, outputPath); + return exitCode == 0 ? MergeEngineResult.AutoSolved : MergeEngineResult.Failed; + } + + public MergeEngineResult MergeHeadless( + FileMerger.MergeSource source1, + FileMerger.MergeSource source2, + FileInfo vanillaFile, + string outputPath) + { + return KDiff3.RunHeadless(source1, source2, vanillaFile, outputPath) switch + { + KDiff3.HeadlessResult.AutoSolved => MergeEngineResult.AutoSolved, + KDiff3.HeadlessResult.NeedsManualResolution => MergeEngineResult.NeedsManualResolution, + _ => MergeEngineResult.Failed, + }; + } + + public bool ValidateExePath() => File.Exists(KDiff3.ExePath); + } +} diff --git a/WitcherScriptMerger/Tools/QuickBms.cs b/WitcherScriptMerger/Tools/QuickBms.cs deleted file mode 100644 index 9f0b083..0000000 --- a/WitcherScriptMerger/Tools/QuickBms.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; - -namespace WitcherScriptMerger.Tools -{ - static class QuickBms - { - public static string ExePath = Program.Settings.Get("QuickBmsPath"); - public static string PluginPath = Program.Settings.Get("QuickBmsPluginPath"); - - public static int UnpackFile(string bundlePath, string contentRelativePath, string outputDir) - { - if (!ValidateResources(bundlePath)) - return 1; - - if (!Directory.Exists(outputDir)) - Directory.CreateDirectory(outputDir); - - var startInfo = BuildStartInfo($"-Y -f \"{contentRelativePath}\" \"{PluginPath}\" \"{bundlePath}\" \"{outputDir}\""); - - using (var bmsProc = new Process { StartInfo = startInfo }) - { - bmsProc.Start(); - var output = bmsProc.StandardError.ReadToEnd(); // QuickBMS prints results to std error, even if successful - - if (output.Contains("- 0 files found")) - { - var errorMsg = "Error unpacking bundle content file using QuickBMS.\nIts output is below."; - var outputStart = output.IndexOf("- filter string"); - if (outputStart != -1) - { - output = output.Substring(outputStart); - errorMsg += "\n\n" + output; - } - Program.MainForm.ShowError(errorMsg); - return 1; - } - - return 0; - } - } - - public static string[] GetBundleContentPaths(string bundlePath) - { - if (!ValidateResources(bundlePath)) - return null; - - var contentPaths = new List(); - - var startInfo = BuildStartInfo($"-l \"{PluginPath}\" \"{bundlePath}\""); - - using (var bmsProc = new Process { StartInfo = startInfo }) - { - bmsProc.Start(); - var output = bmsProc.StandardOutput.ReadToEnd() + "\n\n" + bmsProc.StandardError.ReadToEnd(); - var footerPos = output.LastIndexOf("QuickBMS generic"); - var outputLines = output.Substring(0, footerPos).Split('\n'); - var paths = outputLines - .Where(line => line.Length > 5) - .Select(line => line.Substring(line.LastIndexOf(' ')).Trim()); - contentPaths.AddRange(paths); - } - return contentPaths.ToArray(); - } - - static bool ValidateResources(string bundlePath) - { - if (!File.Exists(bundlePath)) - { - Program.MainForm.ShowError("Can't find bundle file:\n\n" + bundlePath, "Missing Bundle"); - return false; - } - if (!File.Exists(ExePath)) - { - Program.MainForm.ShowError("Can't find QuickBMS at this location:\n\n" + ExePath, "Missing QuickBMS"); - return false; - } - if (!File.Exists(PluginPath)) - { - Program.MainForm.ShowError("Can't find QuickBMS plugin at this location:\n\n" + PluginPath, "Missing QuickBMS Plugin"); - return false; - } - return true; - } - - static ProcessStartInfo BuildStartInfo(string arguments) - { - return new ProcessStartInfo - { - FileName = ExePath, - Arguments = arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - } - } -} diff --git a/WitcherScriptMerger/Tools/WccLite.cs b/WitcherScriptMerger/Tools/WccLite.cs deleted file mode 100644 index b3c87a9..0000000 --- a/WitcherScriptMerger/Tools/WccLite.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Diagnostics; -using System.IO; - -namespace WitcherScriptMerger.Tools -{ - static class WccLite - { - public static string ExePath = Program.Settings.Get("WccLitePath"); - - public static int PackBundle(string sourceDir, string outputDir) - { - if (!Directory.Exists(sourceDir)) - { - Program.MainForm.ShowError("Can't find content directory to pack into bundle:\n\n" + sourceDir, "Missing Directory"); - return 1; - } - - return Run( - $"pack -dir=\"{sourceDir}\" -outdir=\"{outputDir}\"", - "Error packing merged content into a new bundle using wcc_lite.\nIts error output is below." - ); - } - - public static int GenerateMetadata(string bundleDir) - { - return Run( - $"metadatastore -path=\"{bundleDir}\"", - "Error generating metadata.store for new merged bundle using wcc_lite.\nIts error output is below." - ); - } - - public static int Run(string arguments, string failureMsg) - { - if (!File.Exists(ExePath)) - { - Program.MainForm.ShowError("Can't find wcc_lite at this location:\n\n" + ExePath, "Missing wcc_lite"); - return 1; - } - - var procInfo = new ProcessStartInfo - { - FileName = ExePath, - Arguments = arguments, - WorkingDirectory = Path.GetDirectoryName(ExePath), - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using (var wccLiteProc = new Process { StartInfo = procInfo }) - { - wccLiteProc.Start(); - var stdOutput = wccLiteProc.StandardOutput.ReadToEnd().Trim(); - var stdError = wccLiteProc.StandardError.ReadToEnd().Trim(); - - string errorMsg = null; - if (!string.IsNullOrWhiteSpace(stdError)) - errorMsg = stdError; - else if (stdOutput.EndsWith("Wcc operation failed")) - errorMsg = stdOutput; - if (errorMsg != null) - { - Program.MainForm.ShowError(failureMsg + "\n\n" + errorMsg); - return 1; - } - } - return 0; - } - } -} diff --git a/WitcherScriptMerger/Tools/xxHash.cs b/WitcherScriptMerger/Tools/xxHash.cs deleted file mode 100644 index a184233..0000000 --- a/WitcherScriptMerger/Tools/xxHash.cs +++ /dev/null @@ -1,173 +0,0 @@ -/* -Created by Wilhelm Liao on 2015-12-25. -Copyright (c) 2015, Wilhelm Liao -All rights reserved. -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -- Original xxHash's License follows: -""" - Copyright (C) 2012-2015, Yann Collet. (https://github.com/Cyan4973/xxHash) - BSD 2-Clause License (http://www.opensource.org/licenses/bsd-license.php) - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT - LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR - A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, - DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY - THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - You can contact the author at : - - xxHash source repository : https://github.com/Cyan4973/xxHash -""" -*/ - -using System.IO; - -namespace WitcherScriptMerger.Tools -{ - static class Hasher - { - public static string ComputeHash(string filePath) - { - if (!File.Exists(filePath)) - throw new FileNotFoundException("Can't find file to hash: " + filePath); - - return new xxHash(filePath).ToString(); - } - } - - class xxHash - { - const uint Seed = 0U, - Prime1 = 2654435761U, - Prime2 = 2246822519U, - Prime3 = 3266489917U, - Prime4 = 668265263U, - Prime5 = 374761393U; - - const int TransformSize = sizeof(uint) * 4; - uint _v1, - _v2, - _v3, - _v4; - - string _hex; - - public override string ToString() => _hex; - - public xxHash(string filePath) - { - unchecked // Allow integer overflow - { - _v1 = Seed + Prime1 + Prime2; - _v2 = Seed + Prime2; - _v3 = Seed; - _v4 = Seed - Prime1; - } - - using (var reader = new BinaryReader(File.OpenRead(filePath))) - { - // Limit prevents trying to transform by - // last chunk of input when it's too short - long streamLength = reader.BaseStream.Length; // Cache this because it's IO-expensive - long limit = streamLength - TransformSize; - while (reader.BaseStream.Position <= limit) - { - TransformBy(reader); - } - - _hex = string.Format("{0:X}", Finalize(reader, streamLength)); - } - } - - void TransformBy(BinaryReader reader) - { - _v1 += reader.ReadUInt32() * Prime2; - _v1 = XXH_rotl(_v1, 13); - _v1 *= Prime1; - - _v2 += reader.ReadUInt32() * Prime2; - _v2 = XXH_rotl(_v2, 13); - _v2 *= Prime1; - - _v3 += reader.ReadUInt32() * Prime2; - _v3 = XXH_rotl(_v3, 13); - _v3 *= Prime1; - - _v4 += reader.ReadUInt32() * Prime2; - _v4 = XXH_rotl(_v4, 13); - _v4 *= Prime1; - } - - uint Finalize(BinaryReader reader, long streamLength) - { - var stream = reader.BaseStream; - - uint hash = - (streamLength >= 16) - ? XXH_rotl(_v1, 1) + XXH_rotl(_v2, 7) + XXH_rotl(_v3, 12) + XXH_rotl(_v4, 18) - : Seed + Prime5; - - hash += (uint)streamLength; - - // Transform hash by any leftover bytes at end of input - if (stream.Position < streamLength) - { - while (stream.Position + sizeof(uint) <= streamLength) - { - hash += reader.ReadUInt32() * Prime3; - hash = XXH_rotl(hash, 17) * Prime4; - } - - while (stream.Position < streamLength) - { - hash += reader.ReadByte() * Prime5; - hash = XXH_rotl(hash, 11) * Prime1; - } - } - - hash ^= hash >> 15; - hash *= Prime2; - hash ^= hash >> 13; - hash *= Prime3; - hash ^= hash >> 16; - - return hash; - } - - // Rotates unsigned 32-bit integer "x" to the left by the number of bits "r" - static uint XXH_rotl(uint x, int r) - { - return (x << r) | (x >> (32 - r)); - } - } -} diff --git a/WitcherScriptMerger/WitcherScriptMerger.csproj b/WitcherScriptMerger/WitcherScriptMerger.csproj index d124350..ca170db 100644 --- a/WitcherScriptMerger/WitcherScriptMerger.csproj +++ b/WitcherScriptMerger/WitcherScriptMerger.csproj @@ -1,179 +1,31 @@ - - - - - Debug - AnyCPU - {B0417CBE-445D-47A0-8502-717BCFE63013} - WinExe - Properties - WitcherScriptMerger - WitcherScriptMerger - v4.5 - 512 - - - AnyCPU - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - DeadCodeDetection.ruleset - - - AnyCPU - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - DeadCodeDetection.ruleset - - - WolfMedallion.ico - - - - - - - - - - - - - - - - - - - Component - - - Component - - - Component - - - - - Form - - - OptionsForm.cs - - - - - - - - Form - - - DependencyForm.cs - - - Form - - - PackReportForm.cs - - - Form - - - MergeReportForm.cs - - - - Form - - - MainForm.cs - - - Form - - - - - - - - - - - - - - - - - - - DependencyForm.cs - - - OptionsForm.cs - - - PackReportForm.cs - - - MergeReportForm.cs - - - MainForm.cs - Designer - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - True - - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - - - - Designer - - - - - - - - - if exist "$(TargetPath).locked" del "$(TargetPath).locked" -if exist "$(TargetPath)" if not exist "$(TargetPath).locked" move "$(TargetPath)" "$(TargetPath).locked" - - - \ No newline at end of file + + + + WinExe + net10.0-windows7.0 + true + WitcherScriptMerger + WitcherScriptMerger + WolfMedallion.ico + false + disable + disable + DeadCodeDetection.ruleset + + + + + + + + + + + + + + + + + + diff --git a/docs/decisions/bundle-format-replacement-spike.md b/docs/decisions/bundle-format-replacement-spike.md new file mode 100644 index 0000000..b5b5fef --- /dev/null +++ b/docs/decisions/bundle-format-replacement-spike.md @@ -0,0 +1,317 @@ +# Spike: Can WolvenKit replace QuickBMS + wcc_lite for `.bundle` handling? + +**Status:** Researched, no follow-on implementation recommended at this time. +**Type:** Research spike (Wave 0, Unit 3) — no code changes. + +## Question + +WSM currently shells out to two Windows binaries with no license file in their +distribution — QuickBMS (`quickbms.exe` + `witcher3.bms`) to unpack `.bundle` +contents, and wcc_lite (`wcc_lite.exe`) to repack them and regenerate +`metadata.store` — neither of which is committed to source control (see +`CLAUDE.md`'s "External tool dependencies"). Could `WolvenKit.Modkit`, an +open-source, clearly-licensed (GPL-3.0) NuGet package from the WolvenKit +project, replace both, in-process, as a managed C# dependency? + +## Current behavior (what has to be replaced) + +- `WitcherScriptMerger/Tools/QuickBms.cs`: unpacks a single file with + `quickbms.exe -Y -f "" "" "" ""` + and lists a bundle's contents with `quickbms.exe -l "" ""`, + scraping the list-mode stdout as text (`GetBundleContentPaths`). +- `WitcherScriptMerger/Tools/WccLite.cs`: repacks with + `wcc_lite.exe pack -dir="" -outdir=""` and regenerates + the archive's integrity/index file with + `wcc_lite.exe metadatastore -path=""`. +- The QuickBMS plugin script bundled with WSM, `witcher3.bms`, documents the + `.bundle` container format in its own comments: a `POTATO70` magic header, + a bundle size / dummy size / data-offset triple, then a table of + 0x100-byte-padded name + 16-byte hash + size/zsize/offset/timestamp + + zip-type (0=none, 1=zlib, 2=snappy, 3=doboz, 4/5=lz4) records. Notably, + `witcher3.bms` only covers `.bundle`/`.cache` content — it says nothing + about `metadata.store`, which is a separate file wcc_lite alone generates. +- No `doc/`-style folder ships next to QuickBMS or wcc_lite in the local + toolset the way KDiff3 ships `doc/options.html` — confirmed by listing the + tool directories in a local Witcher 3 installation that had WSM's + `Tools/` folder populated. (This environment did have that installation + reachable; if a future reader's doesn't, the absence is otherwise + consistent with upstream's silence on the topic.) The only proof of format + ever having been written down at all is inside `witcher3.bms`'s comments + and, as this spike found, independently inside WolvenKit's own source. + +## Finding 1: `WolvenKit.Modkit` targets the wrong game + +The task's starting premise was that `WolvenKit.Modkit` (NuGet) could be +evaluated against WSM's `.bundle` needs. It cannot — it's the wrong package +for the wrong engine generation: + +- `WolvenKit.Modkit`'s own NuGet Gallery listing describes it simply as + "Modding tools for Cyberpunk 2077," tagged `wolvenkit`, `cyberpunk2077` + (https://www.nuget.org/packages/WolvenKit.Modkit). It belongs to the main + `WolvenKit/WolvenKit` repository (https://github.com/WolvenKit/WolvenKit), + which targets **REDengine 4 / Cyberpunk 2077's `.archive` format**, not + REDengine 3 / Witcher 3's `.bundle` format. +- The tool that actually targets Witcher 3 is a *separate* repository, + `WolvenKit/WolvenKit-7` ("WolvenKit for Witcher 3" per its own GitHub + description, https://github.com/WolvenKit/WolvenKit-7), **created + 2021-10-04** per `gh api repos/WolvenKit/WolvenKit-7`. Confirmed via that + same call: GPL-3.0 licensed, last pushed 2025-12-28 — roughly seven + months before this spike (today is 2026-08-07) — 102 stars, 19 open + issues. Seven months without a push is not "actively maintained" in any + strong sense; treat it as "not abandoned, but not fast-moving either." +- `WolvenKit-7` does **not** publish a `Modkit`-named NuGet package (its + README doesn't reference one, and no such package turned up in NuGet + search). There is a Witcher-3-scoped package published under the + WolvenKit name — `WolvenKit.RED3.CR2W` (MIT-licensed, owner "WolvenKit", + project URL `github.com/WolvenKit/Wolven-kit`, per its NuGet Gallery page) + — but its own description is "File formats (The Witcher 3) for the + WolvenKit Mod Editor," it depends only on `WolvenKit.Core`/`FastMember`/ + `Newtonsoft.Json`, and neither its description nor dependency list + mentions bundles or `metadata.store`. RED3 CR2W handles the CR2W + *resource* file format (individual asset files) — a different format from + the bundle *archive* container WSM needs. No `WolvenKit.RED3.Bundle`-style + sibling package turned up in repeated NuGet search. `WolvenKit.CLI`/ + `WolvenKit.Modkit` remain the Cyberpunk-only packages. + +So the honest reframe of the research question is: **does `WolvenKit-7` +(not `WolvenKit.Modkit`) expose anything usable for `.bundle` unpack/repack?** + +## Finding 2: read path exists in source, but isn't a consumable library + +`WolvenKit-7` does contain a pure-C# `.bundle`/`metadata.store` reader, +in a project named `WolvenKit.Bundles` +(https://github.com/WolvenKit/WolvenKit-7/tree/main/WolvenKit.Bundles): + +- `Bundle.cs` has a constructor that reads `.bundle` contents directly via + `BinaryReader`, independent of any external process. +- `Metadata_Store.cs`'s constructor (`public Metadata_Store(string filepath)`) + fully parses `metadata.store` — header, file string table, file info list, + file entry info list, bundle info list, buffers, dir/file init info, and + hashes — again with a plain `BinaryReader`, no external tool. Its doc + comment describes the file candidly: *"This game file at the root of the + witcher3 content/ folder is used extensively by wcc_lite. It is used to + keep track of archived files and to control their integrity."* + +This read capability is real and matches the task's expectation ("very +likely" a read API exists — confirmed). But it is **not something WSM could +just add as a NuGet dependency**: + +- `WolvenKit.Bundles.csproj` targets `net481` (.NET Framework 4.8.1), not + a modern TFM compatible with WSM's `net10.0-windows7.0`. +- It references `System.Windows.Forms` and `System.ServiceModel` directly + (legacy WinForms/WCF coupling baked into what's nominally a parsing + library), plus a third-party `VVVV.FreeImage` reference of unclear license. +- It is not packaged/published to NuGet under any name — it's an internal + project of the `WolvenKit-7` solution, not a reusable artifact. Consuming + it would mean vendoring and substantially reworking that source, not + `dotnet add package`. + +## Finding 3: the write path — the actual discriminator — is unimplemented + +This is the load-bearing finding for the whole spike, and it was directly +verifiable in source rather than inferred: + +- GitHub issue **"Metadata.store parser" (WolvenKit/WolvenKit #33)** + (https://github.com/WolvenKit/WolvenKit/issues/33), opened March 2017, + states the goal plainly in its own body: *"We need a way to parse&write + metadata.store files so we don't have to use wcc_lite for it which is + slow"* — with an explicit two-item checklist: `- [x] Parser` and + `- [ ] Writer`. This issue lives in the `WolvenKit/WolvenKit` repo, which + today is the Cyberpunk-2077-only tool — but it was opened in 2017, four + years *before* `WolvenKit-7` existed as a separate repository (created + 2021-10-04, above). In 2017 there was only one WolvenKit codebase, and it + targeted Witcher 3; the issue is a direct historical record of that + shared codebase's `.bundle`/`metadata.store` work, not a citation from an + unrelated project. The issue was closed in February 2022 (`state_reason: + "completed"`, confirmed via `gh api repos/WolvenKit/WolvenKit/issues/33`) + — i.e. **closed, not open** — but the **Writer checkbox was never + checked**, even at closing time. Comments on the issue (fetched via + `gh issue view 33 --comments`) describe reverse-engineering the + header/paths/file-record layout and confirm only the parser being + implemented and demoed — no comment claims a working writer. +- Reading `WolvenKit-7`'s current `Metadata_Store.cs` directly confirms the + gap still exists in the Witcher-3-specific codebase today: its `Write` + method is a literal stub — + ```csharp + public void Write(string OutPutPath, params Bundle[] Bundles) + { + //TODO: Code this when everything is figured out. + } + ``` + and every constituent record type's serializer (`UBundleInfo.Serialize`, + `UFileInfo.Serialize`, `UFileEntryInfo.Serialize`, `UDirInitInfo.Serialize`, + `UFileInitInfo.Serialize`, `UHash.Serialize`) throws + `NotImplementedException()`. There is a `DeserializeFromCsv` stub too, + also `throw new NotImplementedException();` — the class can read the + binary format and dump it to CSV for inspection, but cannot write + `metadata.store` back out in any form. +- `Bundle.cs`'s raw `.bundle`-writing method (`public static void + Write(string Outputpath, string rootfolder)`) does have real writing + logic (unlike the metadata.store writer), but is marked with substantive + open questions in its own comments: `//TODO Calculate the resulting + bundle's size`, `//TODO: Figure out what the hell is this.` (for a + 12-byte header constant), and `//TODO: Check if the game actually cares` + (for whether a CRC32 field is even validated by the game). This reads as + an experimental prototype, not a proven repacker. +- Consistent with the writer never landing: `WolvenKit-7`'s own GUI pack/cook + workflow (`WolvenKit.App/Model/WccHelper.cs`'s `Cook()` method) does not + use `WolvenKit.Bundles` to write anything — it shells out to the real + external tool. The wrapper class it calls, + `WolvenKit.Common.Wcc.WccLite`, documents exactly what it is in its own + doc comment: *"Closed-source program published by CDPR in the official + Witcher 3 modkit. Provides a wide range of utilities, mainly + cooking/uncooking..."* — invoked via `Process.Start` with logged + `WCC_TASK: ` lines, the same architecture as WSM's own + `Tools/WccLite.cs` today. (A real user bug report, + https://github.com/WolvenKit/WolvenKit-7/issues/21, shows this in the + wild: a failing `WCC_TASK: analyze r4dlc ...` invocation.) +- The only CLI surface in the repo, `WolvenKit.Console` + (`WolvenKit.Console/Options.cs`), has no working pack/repack verb: its + `bundle` verb is an empty stub with zero options, and the only + metadata.store-related verb is `dumpMetadataStore` — read-only, for + inspection. + +**Conclusion on the discriminator**: neither the NuGet-published +`WolvenKit.Modkit` (wrong game/format entirely) nor `WolvenKit-7`'s +in-repo `WolvenKit.Bundles` code (right format, but an admittedly +unimplemented writer, `net481`/WinForms-coupled, and not published as a +library) can write a `.bundle` + `metadata.store` pair today. `WolvenKit-7` +itself — the actual maintained Witcher 3 tool — still depends on shelling +out to the same closed-source `wcc.exe`/`wcc_lite.exe` WSM already uses for +that half of the job. + +## License analysis + +WSM's own `LICENSE` is bare GNU GPL v2, **no** "or later version" clause +(confirmed by reading the file — the standard GPLv2 boilerplate at the +bottom offers the "or (at your option) any later version" language, but the +committed `LICENSE` doesn't fill that clause in as "or later"). The license +*text* lives only in `LICENSE`; checked for a separate license *grant* that +might upgrade it elsewhere, with two greps: `README.md` for the GPL-specific +terms `General Public License`/`GNU GPL`/`GPL-2`/`GPL-3`/`or later` +(case-insensitive) — zero matches — and every `.cs` file under +`WitcherScriptMerger/` for the broader terms `copyright`/`license` +(case-insensitive), which turned up exactly one file, +`Properties/AssemblyInfo.cs` — but its only relevant content is a bare +`Copyright © 2015` string with no license grant language (it doesn't +contain any of the GPL-specific terms either). Between the two greps, no +"or later" grant exists anywhere in the repo outside `LICENSE` itself, and +`LICENSE` doesn't contain one. `WolvenKit-7` is +GPL-3.0 (confirmed via `gh api repos/WolvenKit/WolvenKit-7` → +`"license":{"key":"gpl-3.0", ...}`). GPLv3 code generally cannot be linked +into a strictly-GPLv2-only binary — the two licenses are not compatible in +that direction. Three options, as framed by the task: + +**(a) Relicense WSM to GPLv2-or-later or GPLv3.** This would legally permit +linking GPL-3.0 code as a compiled-in dependency — *if* it's actually within +this project's power to do. It may not be a simple maintainer decision: +WSM is a fork of `AnotherSymbiote/WitcherScriptMerger` (per this repo's own +`CLAUDE.md`), and `Properties/AssemblyInfo.cs` carries a bare +`Copyright © 2015`, predating this fork. Relicensing a GPLv2 codebase +generally requires consent from every copyright-holding contributor, not +just current maintainers — this spike did not attempt to identify or +contact upstream/original copyright holders, so whether relicensing is even +achievable is itself an open question, separate from whether it's worth +doing. And per Finding 3, there is currently no working `.bundle`-write / +`metadata.store`-write managed library to link in even if the license +problem were fully clear and solved — `WolvenKit-7`'s own writer is +unimplemented, and it doesn't publish `WolvenKit.Bundles` as a package +anyway. Relicensing now would mean taking on a non-trivial, possibly +infeasible legal effort (identifying and clearing consent from all +pre-fork copyright holders) that would remove a linking blocker unlocking +no practical capability today. + +**(b) Shell out to a WolvenKit-provided CLI/console tool as a separate +process**, avoiding the linking question by keeping it a separate process +(same shape as today's QuickBMS/wcc_lite calls, but a clearly-licensed +dependency). This doesn't hold up either: `WolvenKit.Console` has no +pack/repack verb, and `WolvenKit-7`'s own GUI pack pipeline is itself just a +wrapper around the closed-source `wcc.exe`. Shelling out to WolvenKit-7 for +packing would mean depending on a GPL-3.0 GUI-oriented application that +*itself* still requires the user to separately provide the same +ambiguously-licensed CDPR binary WSM already needs — net new dependency +surface for zero reduction in the actual QuickBMS/wcc_lite exposure. + +**(c) Keep QuickBMS/wcc_lite as a Windows-only fallback path indefinitely +and don't pursue a replacement further right now.** This is what the +evidence supports. Nothing found in this spike gives WSM a path to drop +wcc_lite for the write/repack side without either (i) still needing a +closed-source CDPR binary somewhere in the chain (whether called directly, +as today, or indirectly through a WolvenKit-7 GUI wrapper), or (ii) writing +a from-scratch `metadata.store`/`.bundle` writer that nobody has actually +finished — including WolvenKit, the most visible open-source Witcher 3 +modding project, whose own tracking issue on exactly this was opened in +2017 and closed in 2022 with the writer checklist item still unchecked. + +## Recommendation + +**Adopt option (c).** Do not pursue WolvenKit (neither `WolvenKit.Modkit` +nor `WolvenKit-7`) as a QuickBMS/wcc_lite replacement right now, and do not +scope a follow-on implementation unit for this. The premise didn't survive +contact with the source: `WolvenKit.Modkit` targets the wrong game engine +entirely, and `WolvenKit-7` — the tool that actually targets Witcher 3 — has +an explicitly unfinished `metadata.store` writer. That's not an inference; +it's a direct read of two things: the originating GitHub issue's +`- [ ] Writer` checklist item, opened in 2017 and never checked even when +the issue was closed in 2022, and the literal `//TODO: Code this when +everything is figured out.` stub still present in `WolvenKit-7`'s current +`Metadata_Store.Write` (source pinned at commit +`c3c1c2028177de37c97a2706412b499a5c04cbf4` — see Sources). For packing, +`WolvenKit-7` depends on the very same closed-source `wcc.exe` WSM already +shells out to. Replacing WSM's ambiguous-license QuickBMS+wcc_lite pairing +with a GPL-3.0-licensed tool that still can't write the format doesn't +reduce risk or unblock anything; it adds a licensing obligation (a full WSM +relicense, for option (a)) or a new heavyweight dependency (for option (b)) +in exchange for nothing beyond a marginally-better-licensed *read* path +that isn't even packaged for reuse. + +One narrower, genuinely open thread worth flagging separately (**not** as +this unit's follow-on, and explicitly out of scope for a recommendation +here): two independent reverse-engineering efforts — WSM's own bundled +`witcher3.bms` and `WolvenKit-7`'s `WolvenKit.Bundles` reader — agree +closely enough on the `.bundle` container's layout that a from-scratch, +WSM-native managed reader (replacing QuickBMS's *read* path only, with no +WolvenKit dependency at all) looks plausible as a much later, separate +research question. That's a different question from "can WolvenKit replace +these tools" (this spike's actual scope), it still leaves the harder +`metadata.store`-write / `.bundle`-write problem completely unsolved, and it +shouldn't be scheduled ahead of the dependency-ordered waves already +planned. Park it; revisit only if a future spike is explicitly chartered to +ask "should WSM reimplement the bundle format itself," not "does an +existing library already do it." + +## Sources consulted + +- `WitcherScriptMerger/Tools/QuickBms.cs`, `WitcherScriptMerger/Tools/WccLite.cs`, + `WitcherScriptMerger/CLAUDE.md` ("External tool dependencies"), root + `LICENSE` — this repository, read directly. +- `witcher3.bms` (QuickBMS plugin shipped with WSM's configured tooling) — + read directly for `.bundle` format comments. +- https://www.nuget.org/packages/WolvenKit.Modkit — package description. +- https://github.com/WolvenKit/WolvenKit — main (Cyberpunk 2077) repo. +- https://github.com/WolvenKit/WolvenKit-7 — Witcher 3 repo; fetched repo + metadata via `gh api repos/WolvenKit/WolvenKit-7` for license/activity. +- https://github.com/WolvenKit/WolvenKit/issues/33 — "Metadata.store parser," + full issue body, checklist, and comments fetched via `gh issue view 33 + --repo WolvenKit/WolvenKit --comments` and `gh api + repos/WolvenKit/WolvenKit/issues/33`. +- `WolvenKit.Bundles/Metadata_Store.cs`, `WolvenKit.Bundles/Bundle.cs`, + `WolvenKit.Bundles/WolvenKit.Bundles.csproj`, + `WolvenKit.Common/Model/Wcc/wcc_task.cs` (`WccLite` class), + `WolvenKit.App/Model/WccHelper.cs`, `WolvenKit.Console/Options.cs` — all + read directly from `WolvenKit/WolvenKit-7` via `gh api + repos/WolvenKit/WolvenKit-7/contents/`, pinned at commit + `c3c1c2028177de37c97a2706412b499a5c04cbf4` (the ref returned by the + `gh api search/code` calls used to locate these files) — re-verify + against current `main` if reading this doc much later, in case the + writer has since been implemented. +- https://github.com/WolvenKit/WolvenKit-7/issues/21 — real-world + `WCC_TASK` shell-out failure, corroborating the external-process + architecture. +- https://www.nuget.org/packages/WolvenKit.RED3.CR2W/3.32.3 — checked + description, dependency list, owner, and license (MIT) to confirm it's + scoped to the CR2W resource format only, not bundle archives, and that no + separate `WolvenKit.Bundles`-equivalent package exists for Witcher 3. +- https://wiki.redmodding.org/wolvenkit — checked for a Witcher-3-specific + bundle/pack documentation page; none found (its packing docs are + Cyberpunk-2077-oriented import/export and texture-CLI pages). diff --git a/docs/vortex-extension-design.md b/docs/vortex-extension-design.md new file mode 100644 index 0000000..c95674d --- /dev/null +++ b/docs/vortex-extension-design.md @@ -0,0 +1,477 @@ +# Vortex Extension Design (Unit 4) + +**Status: design document only.** Nothing in this file is implemented. There is no +TypeScript/Node scaffolding anywhere in this repository, and this unit does not add +any — that work is explicitly deferred to a later, separate implementation batch. This +document exists so that future work has a starting point instead of a blank page. + +**Scope**: how a Vortex extension could drive WitcherScriptMerger's (WSM's) existing +CLI and MCP interfaces. It does not propose any new WSM-side functionality beyond what +`CLAUDE.md` already documents as done today. §2.2 below also depends on two *sibling* +units of this same re-architecture batch (a self-contained single-file publish, and a +headless-only build) — those are **not** yet reflected in `CLAUDE.md` as of this +writing (confirmed by reading it in full; `HANDOFF.md` is gitignored and wasn't present +in this checkout to check), because they haven't landed yet. They come from this unit's +own task brief, not from repo documentation, and are flagged as depended-upon-but-unbuilt +everywhere they're used below, not treated as already-true facts. + +--- + +## 0. Context: Vortex already has a Script Merger integration today + +Before designing anything new, it's worth being precise about what already exists, +because a new extension has to coexist with it, not pretend it doesn't exist. + +Vortex's official Witcher 3 game extension +([`Nexus-Mods/vortex-games`, `game-witcher3/index.js`](https://github.com/Nexus-Mods/vortex-games/blob/master/game-witcher3/index.js)) +already integrates with a Script Merger build today. The following is verified +directly against that extension's actual source +(`gh api repos/Nexus-Mods/vortex-games/contents/game-witcher3/index.js`), not inferred +from a summary: + +- It registers Script Merger as a discovered **tool** (`registerTool`/`addDiscoveredTool`, + ID `W3ScriptMerger`), with `requiredFiles: ['WitcherScriptMerger.exe']`, and can + **auto-download** a build from GitHub releases at + `https://api.github.com/repos/IDCs/WitcherScriptMerger` — a *different* fork from the + one this repo forked from (`AnotherSymbiote/WitcherScriptMerger`; see this repo's + `CLAUDE.md` "Project overview"). It prompts the user to run it, with consent, when + script conflicts are detected. +- **Running it launches the GUI, not a headless merge.** `runScriptMerger()` calls + `api.runExecutable(tool.path, [], { suggestDeploy: true })` — an *empty* argument + list. Per this repo's own `Program.cs` (`args.Length > 0` is what selects the + CLI/MCP path at all; no args means the GUI), that's a GUI launch, not a headless + `merge` invocation. The `IDCs/WitcherScriptMerger` fork Vortex actually downloads is + also a different codebase from this repo, and predates this repo's CLI/MCP additions + (see this repo's own commit history) — it likely has no headless mode to invoke even + if Vortex wanted one. So Vortex's existing flow today is "launch the GUI, let the + user drive KDiff3 and merge conflicts by hand, then read the result back + afterward" — **not** a precedent for unattended/headless invocation. That distinction + matters directly for §3 below. +- It reads and rewrites WSM's own config file at the OS level: `setMergerConfig()` + parses `WitcherScriptMerger.exe.config` as XML and overwrites the `GameDirectory`, + `VanillaScriptsDirectory`, and `ModsDirectory` `` entries + in its `` block with paths derived from Vortex's own knowledge of the + game install, then writes the file back — called both at initial tool setup and + before running the merger. **This is exactly the "hand-edit the deployed + `.exe.config`" mechanism §4.1 below proposes** — it isn't a novel idea invented for + this design, it's an already-shipping pattern in Vortex's own codebase, which is + reassuring precedent rather than untested ground. +- `getMergeInventory()` parses `MergeInventory.xml` directly (``, + `` elements) — the same file this repo's `Inventory/MergeInventory.cs` + owns via `XmlSerializer`. +- It expects the merged-output mod folder to be named with a `mod0000_`-style locked + prefix (`LOCKED_PREFIX = "mod0000_"` in the source) so Vortex pins it to load-order + slot 1 (ahead of everything it merges). WSM's own default `MergedModName` in + `App.config` is already `mod0000_MergedFiles` — the two conventions already agree by + default, with no translation needed, as long as the setting isn't changed to + something that no longer starts with the locked prefix Vortex expects. +- It has both `exportScriptMerges()` (Vortex Collections: validates merged files + reference only mods present in the collection before letting a collection upload + proceed) and `importScriptMerges()` (installing a collection that bundles script + merges) paths. Installing such a collection shows a warning dialog — "importing + these will overwrite any existing script merges you may have effectuated" — with a + Cancel option, then proceeds to overwrite on confirmation. That's a real coexistence + hazard worth carrying into §4.3 and §6 below: it's not just "two integrations might + both prompt the user," it's "installing a Collection through the existing + integration can overwrite this extension's own prior merge work if the user clicks + through the warning without realizing what it means for a WSM-based workflow." + +This means a brand-new Vortex extension isn't filling a total void; it's a second, +more capable integration point that has to decide its relationship to the built-in one +(see the open questions in §6). It also means the config-file-editing and +load-order-locking "hard problems" already have a proven answer in Vortex's own +codebase (leaned on directly in §4 below) — but headless/unattended invocation of WSM +specifically does **not** have an existing precedent in Vortex's codebase; that part +is genuinely new ground for §3's recommendation to reckon with honestly. + +*(Sources: [`game-witcher3/index.js`](https://github.com/Nexus-Mods/vortex-games/blob/master/game-witcher3/index.js), +fetched and read directly via `gh api`; +[Nexus Mods wiki, "Modding The Witcher 3 with Vortex"](https://wiki.nexusmods.com/index.php/Modding_The_Witcher_3_with_Vortex); +[Vortex Wiki, "Tool Setup: Witcher 3 Script Merger"](https://wiki.nexusmods.com/index.php/Tool_Setup:_Witcher_3_Script_Merger).)* + +--- + +## 1. Tech stack + +Vortex extensions are **TypeScript/Node**, built against the +[`vortex-api`](https://github.com/Nexus-Mods/vortex-api) package and Vortex's own +extension conventions (an `info.json` manifest, an entry point exporting a single +`activate(context)` function, `context.registerAction`/`registerTool`/etc.). That is a +completely different toolchain from this repo's .NET/WinForms solution — there is no +sensible way to fold it into `WitcherScriptMerger.sln`. + +Consequence for repo layout: **this must live in a separate package/repo**, not a +folder inside `WitcherScriptMerger/`. Candidate options (to be decided when this unit +is actually implemented, not now): + +- A new sibling repo, e.g. `witcherscriptmerger-vortex`, with its own `package.json`, + its own CI, its own release cadence tied to (but independent of) WSM's own releases. +- A `vortex-extension/` top-level folder in *this* repo, kept fully outside the `.sln` + and `dotnet build`'s reach, if the owner prefers single-repo convenience over clean + separation. + +Either way: no Node tooling, `package.json`, or `node_modules` should ever need to +appear anywhere `dotnet build WitcherScriptMerger.sln` looks. + +--- + +## 2. Install / setup flow + +### 2.1 Installing the extension itself + +Vortex has a built-in Extensions page that installs from a community-maintained +registry with one click, and also accepts a manually-dropped extension folder under +`%APPDATA%\Vortex\plugins\`. Either distribution path is viable; getting +listed in the in-app registry is a separate, later decision (see §6) distinct from the +extension existing at all. + +### 2.2 Locating the WSM CLI binary + +WSM is not bundled with Vortex, and the extension needs a WSM executable capable of +running `merge` and/or `mcp` mode (see §3). Today, this means the full Windows build — +`WitcherScriptMerger.exe`, which still requires KDiff3/QuickBMS/wcc_lite on disk per +`CLAUDE.md`'s "External tool dependencies" — since no unit in this batch has shipped a +KDiff3/QuickBMS/wcc_lite-free build yet. + +Two other units in this same re-architecture batch are directly relevant here, and +this design explicitly depends on them without assuming either is done: + +- A **self-contained single-file publish profile** for WSM (full GUI+CLI+MCP build). + This is what the extension would most plausibly bundle or download — a single `.exe` + with the .NET runtime baked in, no separate .NET install required on the user's + machine. +- A **lighter-weight headless-only build** (CLI+MCP, no WinForms/GUI), explicitly + called out as a candidate for eventual Linux support. Vortex itself is Windows-only + today, and Nexus Mods has publicly committed to native SteamOS support for Vortex, + expected to land later in 2026 — so a Linux-capable WSM CLI host is *plausibly* + relevant to this extension eventually, not purely speculative. That said, don't + over-read this as an established requirement: KDiff3/QuickBMS/wcc_lite would still + need Linux-native builds or a compatibility layer for a truly native Linux merge + pipeline to work at all, and a simpler alternative might make a dedicated Linux WSM + build unnecessary in the near term — a Linux/SteamOS Vortex could plausibly just keep + shelling out to the existing Windows WSM build the same way SteamOS already runs + unmodified Windows games via Proton, the same compatibility layer Witcher 3 itself + would already be running under on that platform. Which path is actually right isn't + something this document can resolve — flagged as an open question in §6 rather than + assumed here. + +Setup flow, once those artifacts exist: + +1. On first activation (or on first use of a script-merge action), the extension + checks for a cached WSM binary in its own extension-private storage. +2. If absent, it either (a) unpacks a bundled copy shipped inside the extension + package itself, or (b) downloads the self-contained publish artifact from a WSM + GitHub release, similar to how `game-witcher3/index.js` already downloads the + `IDCs/WitcherScriptMerger` fork today (see §0) — verify via checksum before trusting + it. +3. **KDiff3/QuickBMS/wcc_lite are a separate problem the extension cannot solve by + bundling WSM alone.** Per `CLAUDE.md`, none of the three are in WSM's own source + control — QuickBMS and wcc_lite specifically because their licensing is unresolved, + and that constraint doesn't go away just because a different project is doing the + downloading. The self-contained publish profile does not change this: it packages + WSM's own managed code, not these three external binaries. The extension has to + either point at an existing local install of these tools (e.g., detect the + `IDCs/WitcherScriptMerger` fork Vortex may have already downloaded per §0, and + reuse its `Tools\` subfolder) or prompt the user to source them the same way WSM's + own README does. This should not be silently glossed over — see §6. +4. The extension writes the resolved WSM binary path into its own settings, and + surfaces it (read-only or editable) in Vortex's per-game settings panel so the user + can override it if they already have a WSM install they prefer. + +--- + +## 3. Invocation model + +WSM exposes two non-GUI surfaces today (`CLAUDE.md` "CLI mode" / "MCP mode"): + +| | CLI (`merge [--order-file ]`) | MCP (`mcp`, stdio JSON-RPC) | +|---|---|---| +| Lifecycle | One-shot process, exits when done | Long-lived process, one client session per launch | +| Per-file conflict preview (paths, hashes, default order, already-resolved) | **Not exposed** — no `scan`/`status` CLI verb exists | `scan_conflicts` | +| Aggregate status (dependency validation, resolved directories, conflict *count*) | **Not exposed** | `get_status` — note this is aggregate-only (a count), not per-file detail; it doesn't substitute for `scan_conflicts` | +| Merge, restricted to specific files | No — `merge` always acts on every detected conflict (`--order-file` only overrides *ordering*, not *which files*) | `merge_conflicts(relativePaths, orderOverrides)` | +| Structured result | Coarse only: `Program.cs`'s `RunCli` sets a real exit code (`0` = every conflict merged, or none found; `1` = couldn't even start — bad args/config/missing dependency; `2` = ran, but one or more conflicts were skipped), but *which* files merged vs. skipped is only in free-text `Console.WriteLine` output, not machine-parseable JSON | Yes — `{merged: [...], skipped: [...]}` as structured JSON-RPC, naming the actual files | +| History (`MergeInventory.xml` records) | Not exposed by WSM itself (but the file is plain XML — see §4) | `list_merges` | + +### Recommendation: CLI `merge` as the default, MCP as a richer follow-on enhancement + +Reasoning: + +- **Correction against an easy mistake to make here**: it would be tempting to say the + CLI path "mirrors what Vortex's existing integration already does" and call that + proven. It doesn't, quite — §0 found that `runScriptMerger()` launches the *GUI* + (empty argument list to `api.runExecutable`), and the fork it launches likely + predates this repo's CLI/MCP additions entirely. So headless/unattended WSM + invocation from Vortex is genuinely new ground, not something already exercised in + production. What *does* carry over from the existing integration is the shallower, + still-useful shape: spawn a WSM process, wait for it to finish, then re-read + `MergeInventory.xml` to see what changed — that part of the pattern is proven, just + not the "and it was headless" part. +- Given that, the CLI verb still comes out ahead on complexity for a first cut: no new + client-side protocol work (no JSON-RPC/MCP client to write or import), and no + persistent child-process lifecycle to manage (no crash/restart handling, no + orphaned-process cleanup on Vortex exit) — a one-shot process that runs and exits is + about as simple as a first, unverified headless-invocation path can be, which matters + precisely *because* it's new ground rather than something to lean on prior art for. +- Critically, a v1 built only on the CLI is **not** as limited on the history/UX front + as the table above makes it look, because Vortex's own extension already parses + `MergeInventory.xml` directly for its own purposes (§0) — a new extension can do the + same read-only parsing itself for a "merge history" view, without needing WSM's + `list_merges` MCP tool at all. That closes most of the gap between "CLI-only" and + "has history UX." +- What CLI-only *cannot* do is a genuine **pre-merge conflict preview** — "here's what + would change, review it, then confirm" — because there is no CLI verb that only + scans without merging, and nothing in `CLAUDE.md` suggests one is planned. Building + that preview by having the extension re-implement WSM's own conflict-scanning logic + (walking mods, comparing hashes) would duplicate `FileIndex/ModFileIndex.cs` outside + this repo — exactly the kind of "invent new capability" this design is supposed to + avoid. The only way to get a real preview without duplicating that logic is to call + into WSM itself, which means MCP's `scan_conflicts`. +- MCP also gives per-file targeting (`relativePaths`) and per-file order overrides + (`orderOverrides`) as first-class, structured input/output, versus the CLI's + all-conflicts-every-time behavior and free-text console output. A "merge just this + one file, in this order" UX action needs MCP. + +So: ship the CLI-driven "spawn `merge`, wait, refresh the mod list from +`MergeInventory.xml`" flow first, as the low-risk default for the core "resolve script +conflicts" action. Treat MCP as a v2 enhancement that unlocks conflict preview, +per-file merge actions, and a live dependency/status check (`get_status`) surfaced in +Vortex's UI — gated on someone actually writing (or importing) a TypeScript MCP client +and deciding on the child-process lifecycle model (spawn per action-and-tear-down vs. +spawn-once-per-session; `CLAUDE.md` notes every MCP tool call already re-scans and +re-loads from scratch server-side, so a long-lived process mainly saves the stdio +handshake, not server-side work — a "spawn per user-initiated workflow, tear down when +the panel closes" middle ground is probably the sweet spot, not a permanent +session-long daemon). + +--- + +## 4. Data model mapping + +WSM's directory configuration (`GameDirectory`, `ModsDirectory`, `MergedModName`) comes +from `App.config`'s `` block only (`AppSettings.cs` / +`Program.Settings.Get(...)`, read by `Paths.cs`) — **neither the CLI `merge` verb nor +any MCP tool accepts a directory override as an argument.** This is the single most +important constraint for this section, and it shapes everything below. + +### 4.1 Mods directory + +Vortex already manages Witcher 3 mod installation directly into +`\Mods\\...` (the same layout WSM expects; `Paths.ModsDirectory` +defaults to `\Mods` when the `ModsDirectory` setting is blank). Since +Vortex "will only pick up on mods that you have installed via Vortex" (i.e. it deploys +into the real game mods folder, not some Vortex-private staging area, for a +non-symlink-deployment game like this), **no translation of Vortex's internal mod +state into a separate WSM-readable format should be necessary** — WSM can scan the +same physical folder Vortex deploys into, exactly as it does today when a human +installs mods by hand. What the extension *does* need to do is make sure the deployed +WSM instance's `GameDirectory`/`ModsDirectory` settings actually point at the game +install Vortex is managing. Since WSM has no CLI/MCP flag for this, the only way to do +that today is for the extension to write those two keys directly into the deployed +`WitcherScriptMerger.exe.config` XML file before invoking WSM (Vortex already knows the +exact game install path — that's central to what a game extension does). **This isn't +a novel proposal** — it's exactly what Vortex's existing `setMergerConfig()` already +does in production (§0), down to the same file name and the same `GameDirectory`/ +`VanillaScriptsDirectory`/`ModsDirectory` keys, which is good evidence the approach +works in practice, not just in theory. + +That precedent doesn't retire the concurrency question, though: `AppSettings.cs` +caches its `Configuration` object and only persists on an explicit `Save()`, and +`CLAUDE.md`'s MCP section already flags a documented risk of a concurrently-running +GUI WSM instance clobbering `MergeInventory.xml`. The same class of race applies here — +if the extension hand-edits `WitcherScriptMerger.exe.config` while a WSM process (GUI +or CLI/MCP) is already running against the old config, results are undefined. Vortex's +own `setMergerConfig()` doesn't appear to guard against this either (it's a +best-effort file write with a bare try/catch), so this is an inherited risk, not a +solved one — the extension should still only edit the config file when it's about to +spawn a fresh WSM process, never while one is already running. + +### 4.2 Load order / `mods.settings` + +WSM's `LoadOrder/CustomLoadOrder.cs` reads the game's own `mods.settings` file +directly — it doesn't care what wrote it. Independently confirmed from Vortex's own +documentation: "Vortex automatically generates the `mods.settings` file to reflect your +[Vortex-managed] load order," and it's the same physical file. So **no translation +layer is needed here either** — a Vortex-managed load order is, by construction, +already sitting in the exact file WSM already knows how to read. The one thing to keep +consistent is `MergedModName`, per the `LOCKED_PREFIX` convention already covered in +§0: if a user (or this extension) ever changes WSM's `MergedModName` setting away from +a `mod0000_`-prefixed value, the merged mod could stop loading first under Vortex's +load-order locking. + +### 4.3 Write direction: WSM's merge output vs. Vortex's deployment bookkeeping + +§4.1–4.2 only cover Vortex → WSM (WSM reading a mods folder and a `mods.settings` file +Vortex produced). The other direction is not symmetric and is easy to miss. + +When WSM merges conflicts, it writes the result into a new mod folder — +`mod0000_MergedFiles\` by default — *inside the same physical mods directory Vortex +deploys into* (`Inventory/Merge.cs`'s output paths are all rooted at +`Paths.ModsDirectory`). Vortex did not create that folder and has no deployment record +of it; from Vortex's own bookkeeping, it's an unmanaged, foreign addition to a +directory Vortex otherwise considers fully under its control. This is very likely +*why* Vortex's built-in integration doesn't just "refresh a file list" after running +Script Merger — it specifically parses `MergeInventory.xml` and special-cases the +locked `mod0000_` slot (§0) rather than treating the merge output as an ordinary +externally-added file. A new extension inherits the same problem and should follow the +same pattern: after a merge, register/import the merged-mod folder as a +Vortex-tracked mod (or otherwise reconcile it with Vortex's deployment state) rather +than assuming Vortex will notice it on its own. Also worth remembering here: Vortex's +built-in integration can independently overwrite that same merged-mod folder via +`importScriptMerges()` when a Collection bundling script merges is installed (§0) — a +new extension's reconciliation logic needs to survive that happening underneath it, +not just the "Vortex never touches this folder" case. + +### 4.4 Sequencing: WSM only sees what's already deployed + +A mod that's installed in Vortex but not yet **deployed** exists only in Vortex's own +staging area, not in `Paths.ModsDirectory` — WSM has no visibility into Vortex's +internal state and can only scan the real mods folder on disk. So "detect/merge +conflicts" has to run *after* deployment, not at install time or based on Vortex's +in-memory mod list. This is a real ordering constraint on when the extension's actions +are meaningful (matches §5's choice of "after a deployment" as the natural hook point), +not just an implementation nicety. + +### 4.5 Summary + +Reading Vortex's load order and mod layout needs no translation layer — both already +converge on the same on-disk files (`mods.settings`, the mods directory) regardless of +which tool produced them. What *does* need explicit handling is the write side: keep +WSM's `App.config` pointed at the right `GameDirectory`/`ModsDirectory` before each +invocation, don't let `MergedModName` drift from whatever prefix Vortex's load-order +locking expects, only scan/merge after deployment (§4.4), and reconcile WSM's merge +output back into Vortex's own deployment/mod tracking afterward (§4.3) rather than +assuming Vortex will pick it up automatically. + +--- + +## 5. UX + +Proposed surface, roughly in order of how load-bearing each piece is: + +- **Notification / badge when conflicts exist.** This one is genuinely gated on which + invocation model is wired up (§3), and the two versions aren't the same feature: + - v1 (CLI only) cannot know in advance whether conflicts exist without merging them + — there's no scan-only CLI verb. So a v1 notification can only mirror what Vortex's + built-in integration already does today (§0): prompt unconditionally after every + deployment ("check for script conflicts?"), not a badge that's conditional on + conflicts actually being present. + - v2 (MCP) can do the real thing: call `scan_conflicts` after deployment and only + surface a dashboard notification when it actually returns unresolved conflicts. + Don't build the v1 flow as if it were doing v2's job by quietly calling `merge` in + the background to "check" — that both surprises the user (files get merged before + they asked) and doesn't even get a preview out of it, since `merge`'s output is the + free-text console log described in §3, not a structured conflict list. +- **A "Resolve Script Conflicts" action**, presented in Vortex's UI the same place its + own built-in tool-launch action is today (§0), but driving this extension's headless + flow instead of (or in addition to) the plain GUI-tool spawn. v1: click → spawn + `merge` headlessly → use the exit code only for its coarse success/failure/partial + category (§3's table: `0`/`1`/`2`, not a count) → get the actual per-file + merged/skipped detail from a `MergeInventory.xml` diff taken before and after the + run, since the CLI's own console output isn't structured enough to parse reliably. + v2 (MCP): click → open a panel listing `scan_conflicts` results (per-file mod + hashes, default order, already-resolved flag) → let the user pick specific files + and/or override merge order → call `merge_conflicts` with `relativePaths`/ + `orderOverrides` → show the returned `{merged, skipped}` directly. +- **A merge history view**, backed by parsing `MergeInventory.xml` directly (as + Vortex's own extension already does, §0) or, once available, `list_merges` over MCP + for parity/simplicity. Show relative path, which mod folder holds the merge, and + per-source-mod hashes — enough for a user to tell "this merge is stale" the same way + WSM's own `MergeInventory.HasResolvedConflict` does internally. +- **A dependency/status tile**, once MCP's `get_status` is wired up: whether + KDiff3/QuickBMS/wcc_lite are all found, resolved game/mods directories, configured + merged-mod name, live conflict count. Useful as a single place to tell the user "your + script-merge tooling isn't set up" before they hit a confusing failure mid-deploy. +- **Skipped/manual-resolution reporting.** Both CLI and MCP `merge_conflicts` can leave + conflicts unresolved (KDiff3 couldn't auto-solve). The extension should surface these + distinctly from "nothing to do" — WSM's headless paths never open KDiff3's GUI for + these (`CLAUDE.md`'s CLI/MCP sections), so from the extension's point of view a + skipped file needs the user to run WSM's actual GUI to resolve it manually. The + extension should probably offer a "launch WSM GUI" fallback action here, rather than + trying to reproduce manual conflict resolution itself. + +--- + +## 6. Open questions + +For the repo owner to answer before any real implementation starts: + +1. **Relationship to Vortex's existing built-in Script Merger integration (§0).** + Should this new extension replace it, coexist alongside it, or should the long-term + plan instead be to get Vortex's *own* `game-witcher3` extension pointed at builds + from *this* repo instead of the `IDCs/WitcherScriptMerger` fork it uses today (a + Vortex-core PR, not something this extension can do unilaterally)? "Coexist" isn't + just a UX annoyance (the user prompted twice, or two different WSM + forks/binaries downloaded onto the same machine) — §0 and §4.3 found a concrete + correctness hazard too: Vortex's existing `importScriptMerges()` path can overwrite + this extension's own merge output when a Collection bundling script merges is + installed. Any coexistence answer needs to account for that, not just the + double-prompt annoyance. +2. **Packaging/distribution strategy for KDiff3/QuickBMS/wcc_lite.** WSM's own + `CLAUDE.md` is explicit that QuickBMS and wcc_lite have unresolved licensing and + must never enter source control. Does that same caution block this extension from + ever auto-downloading them on the user's behalf, even from a third-party mirror? + Or is "detect and reuse whatever Vortex's existing integration already fetched" + (§2.2 step 3) the sanctioned answer, permanently, regardless of how good WSM's own + self-contained-publish story gets? +3. **Does this become a public, Nexus-Mods-registry-listed Vortex extension**, or stay + a manually-installed/internal tool? This affects branding, support burden, and + whether Nexus Mods' own extension review process applies. +4. **Minimum supported WSM CLI/MCP version.** Once the extension exists, it needs a + compatibility contract with WSM releases — does it pin to a specific tag, accept + any build advertising the `merge`/`mcp` verbs, or version-negotiate somehow? Nothing + in WSM today exposes a `--version` flag or an MCP server-info version string beyond + whatever the `ModelContextProtocol` SDK provides by default — worth checking before + committing to a specific compatibility mechanism. +5. **Should WSM itself grow a config-override mechanism** (CLI flags, environment + variables, or a `--config` path) for `GameDirectory`/`ModsDirectory`/ + `MergedModName`, instead of requiring an external caller to hand-edit + `WitcherScriptMerger.exe.config` XML (§4.1)? Note this isn't blocked on unproven + ground — Vortex's own `setMergerConfig()` already does the hand-edit today (§0), so + "it works" isn't really in question. The actual question is whether WSM should offer + a first-class, supported alternative so every caller (this extension, Vortex's + existing integration, anyone else) isn't independently reimplementing XML surgery + against an internal config format that could change. That's WSM-side follow-on + work, not something this extension can substitute for. +6. **Process lifecycle for MCP mode** (§3): spawn-per-action-and-tear-down vs. + spawn-once-and-keep-alive for the extension's lifetime vs. something in between. + Needs a decision once someone is actually writing the TypeScript client, informed by + real measurements of WSM's own startup/dependency-validation cost, not guessed here. +7. **Concurrent-access safety with a running WSM GUI.** `CLAUDE.md` already flags this + risk for MCP-vs-GUI concurrency; this extension adds a third potential concurrent + writer (Vortex-triggered CLI/MCP invocations) to the same `MergeInventory.xml` and + `App.config`. Does this need an explicit lock/mutex convention across all three, or + is "don't run WSM's GUI and this extension against the same install at the same + time" an acceptable documented limitation for now? +8. **Linux/SteamOS timing and approach.** Nexus Mods has publicly committed to native + SteamOS support for Vortex, expected later in 2026, but that build doesn't exist yet + and Vortex today is Windows-only. Two different questions bundle together here: (a) + should this extension's design assume Windows-only for its first real + implementation and revisit Linux once Vortex-on-SteamOS actually exists, rather than + designing for both simultaneously now; and (b), when that time comes, should a + Linux/SteamOS Vortex drive a native headless-Linux WSM build at all, versus simply + continuing to shell out to the existing Windows build under the same Proton + compatibility layer Witcher 3 itself would already be running under (which sidesteps + needing Linux-native KDiff3/QuickBMS/wcc_lite entirely, a much bigger unknown than + WSM's own managed-code portability)? (b) is really a question for whoever owns the + Linux-support unit of this batch, not this document, but it directly determines + whether the headless-only build this section depends on ever needs to target Linux + specifically or can stay Windows-only forever and still serve a future SteamOS + Vortex via Proton. + +--- + +## Sources consulted + +- This repo's `CLAUDE.md` (CLI mode, MCP mode, Settings & persistence, External tool + dependencies sections) — authoritative for everything WSM-side in this document. +- [`Nexus-Mods/vortex-games`, `game-witcher3/index.js`](https://github.com/Nexus-Mods/vortex-games/blob/master/game-witcher3/index.js) — + Vortex's existing Script Merger tool registration, auto-download, `MergeInventory.xml` + parsing, and load-order locking logic. +- [Nexus Mods Wiki — "Modding The Witcher 3 with Vortex"](https://wiki.nexusmods.com/index.php/Modding_The_Witcher_3_with_Vortex) +- [Nexus Mods Wiki — "Tool Setup: Witcher 3 Script Merger"](https://wiki.nexusmods.com/index.php/Tool_Setup:_Witcher_3_Script_Merger) +- [`Nexus-Mods/vortex-api`](https://github.com/Nexus-Mods/vortex-api) — Vortex extension + API/typings. +- [`Nexus-Mods/Vortex` wiki — "General Introduction to Vortex extensions"](https://github.com/Nexus-Mods/Vortex/wiki/MODDINGWIKI-Developers-General-Introduction-to-Vortex-extensions) +- Reporting on Nexus Mods' 2026 SteamOS/Steam Deck commitment for Vortex (PC Gamer, + Steam Deck HQ, OpenCritic coverage of the Nexus Mods roadmap announcement).