diff --git a/CLAUDE.md b/CLAUDE.md index 53c6241..8b59c35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This is a fork of the upstream `AnotherSymbiote/WitcherScriptMerger` repo (still - Run (GUI, no args): launch the built `WitcherScriptMerger.exe`, or `dotnet run --project WitcherScriptMerger/WitcherScriptMerger.csproj`. At startup the app validates `KDiff3Path`/`QuickBmsPath`/`QuickBmsPluginPath`/`WccLitePath` from `App.config` (`Paths.ValidateDependencyPaths` in `WitcherScriptMerger/Paths.cs`) and shows a blocking `DependencyForm` if any are missing — the external binaries (KDiff3, QuickBMS, wcc_lite) are **not** in source control (see "External tool dependencies" below), so a fresh checkout won't run end-to-end without sourcing them separately. - Run (CLI, headless): `WitcherScriptMerger.exe merge [--order-file ]` — see "CLI mode" below. Any arguments at all route to the CLI path instead of the GUI. - Run (MCP server): `WitcherScriptMerger.exe mcp` — see "MCP mode" below. Speaks MCP over stdio; not meant to be run interactively from a terminal. -- Single project, single `.sln` — there is no separate class library to build independently. +- Two projects, one `.sln`: `WitcherScriptMerger.Core` (WinForms-free class library, `net10.0`) and `WitcherScriptMerger` (the WinForms host — GUI + CLI + MCP entry points, `net10.0-windows7.0`, references Core). See "Architecture" below for what lives where. ### Tests @@ -22,26 +22,42 @@ There is no test project in this repo (`dotnet test` has nothing to run). The pr ## Architecture -Single WinForms project (`WitcherScriptMerger/WitcherScriptMerger.csproj`, SDK-style, targets `net10.0-windows7.0`). There is no MVC/MVP split — `Forms/MainForm.cs` (~1000 lines) is a monolithic orchestrator that directly owns the tree controls, constructs `ModFileIndex`/`FileMerger`, and wires up their async callbacks. +Two projects as of the Core/host split (see git history around "Split into WitcherScriptMerger.Core + host GUI/CLI/MCP project" for the full rationale): -Domain code (`FileMerger`, `ModFileIndex`, `CustomLoadOrder`, `Paths`, `AppSettings`, the `Tools/` wrappers) doesn't call into WinForms directly — it goes through `Program.Notifier` (an `IMergeNotifier`), which is what makes CLI mode possible. See "CLI mode" below. +- **`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`. -Folder map: -- `Forms/` — WinForms screens: `MainForm.cs` (the hub, also implements `IMergeNotifier`), `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`. +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`, `Merge.cs`, `MergeInventory.cs`, `FileHash.cs`, `MergeProgressInfo.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: `KDiff3.cs`, `QuickBms.cs`, `WccLite.cs`, `Hasher.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). - `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: `Program.cs` (entry point: GUI, CLI, and MCP), `AppSettings.cs`, `Paths.cs`, `Extensions.cs`, `TaskbarProgress.cs`, `IMergeNotifier.cs`, `HeadlessMergeNotifier.cs`, `App.config`. +- 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). +- 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`. + +### 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 KDiff3 through `IMergeEngine` (`Merge`/`MergeHeadless`, mirroring `KDiff3.Run`/`KDiff3.RunHeadless`) rather than calling `Tools/KDiff3.cs` directly, since that file's Win32 P/Invoke has to stay in the host project. `KDiff3MergeEngine` (host) is the one real implementation, supplied via `AppState.MergeEngine` — set once, as the first line of `Program.Main`, before anything else runs. This 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`) -Both entry points share `Program`'s static init: `Program.Notifier` defaults to `HeadlessMergeNotifier` via field initializer (before anything else, including `Settings = new AppSettings()`, can run) so any startup error is safe to report even before it's known whether this is a GUI or CLI run. `MaybeAttachConsole()` also runs as a field initializer, ahead of everything, so early failures are visible in the invoking terminal when there are CLI args. +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 (before anything else, including `Settings = new AppSettings()`, can run) so any startup error is safe to report even before it's known whether this is a GUI or CLI run. `AppState` has an explicit (empty) static constructor so this 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)`: 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)`. +`[STAThread] Main(string[] args)`: first sets `AppState.MergeEngine = new KDiff3MergeEngine()` (the one real `IMergeEngine` implementation — 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 @@ -61,7 +77,7 @@ The CLI path (`FileMerger.MergeConflictsHeadless`) is a separate, parallel orche `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. -- **`IMergeNotifier`** (`IMergeNotifier.cs`, `HeadlessMergeNotifier.cs`): replaces every direct `Program.MainForm.ShowMessage/ShowError/ShowModal` call in domain code with `Program.Notifier.*`. `MainForm` implements the interface directly (its methods already matched the shape exactly, so the GUI path is behavior-identical). `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. This is also what fixed a real null-ref hazard in `CustomLoadOrder.Refresh()`, which used to reach `Program.MainForm` at construction time. +- **`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. diff --git a/WitcherScriptMerger/AppSettings.cs b/WitcherScriptMerger.Core/AppSettings.cs similarity index 76% rename from WitcherScriptMerger/AppSettings.cs rename to WitcherScriptMerger.Core/AppSettings.cs index 178deb9..2c6fcb5 100644 --- a/WitcherScriptMerger/AppSettings.cs +++ b/WitcherScriptMerger.Core/AppSettings.cs @@ -4,7 +4,7 @@ namespace WitcherScriptMerger { - class AppSettings + public class AppSettings { string _assemblyPath; @@ -27,7 +27,7 @@ public AppSettings() if (!CachedConfig.HasFile) { - Program.Notifier.ShowError("Config file is missing.", "Script Merger Error"); + AppState.Notifier.ShowError("Config file is missing.", "Script Merger Error"); Environment.Exit(1); } } @@ -56,7 +56,7 @@ public T Get(string key) return (T)valueObject; } - Program.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}"); + AppState.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}"); return default(T); } catch @@ -72,7 +72,7 @@ public string Get(string key) if (CachedConfig.HasFile) return CachedConfig.AppSettings.Settings[key].Value; - Program.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}"); + AppState.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}"); return string.Empty; } catch @@ -89,7 +89,7 @@ public void Save() } catch (Exception ex) { - Program.Notifier.ShowError($"Failed to save config due to error:\n\n{ex.Message}"); + 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..241a263 --- /dev/null +++ b/WitcherScriptMerger.Core/AppState.cs @@ -0,0 +1,41 @@ +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), and because Paths' own static field initializers read + // Settings.Get(...), transitively depending on this class being fully + // initialized first. + 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(); + public static AppSettings Settings = new AppSettings(); + 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/Cli/MergeOperations.cs b/WitcherScriptMerger.Core/Cli/MergeOperations.cs similarity index 66% rename from WitcherScriptMerger/Cli/MergeOperations.cs rename to WitcherScriptMerger.Core/Cli/MergeOperations.cs index bafd70d..da76c96 100644 --- a/WitcherScriptMerger/Cli/MergeOperations.cs +++ b/WitcherScriptMerger.Core/Cli/MergeOperations.cs @@ -7,7 +7,7 @@ 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. - static class MergeOperations + public static class MergeOperations { public static ModFileIndex ScanConflicts() { @@ -15,9 +15,9 @@ public static ModFileIndex ScanConflicts() using (var scanComplete = new ManualResetEventSlim(false)) { modIndex.BuildAsync( - Program.Settings.Get("CheckScripts"), - Program.Settings.Get("CheckXmlFiles"), - Program.Settings.Get("CheckBundleContents"), + AppState.Settings.Get("CheckScripts"), + AppState.Settings.Get("CheckXmlFiles"), + AppState.Settings.Get("CheckBundleContents"), (s, e) => { }, (s, e) => scanComplete.Set()); scanComplete.Wait(); @@ -31,7 +31,10 @@ public static FileMerger.HeadlessMergeSummary RunMerge( string mergedModName, IReadOnlyDictionary orderOverrides) { - var merger = new FileMerger(inventory, (s, e) => { }, (s, e) => { }); + // 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); } } diff --git a/WitcherScriptMerger/FileIndex/ModFile.cs b/WitcherScriptMerger.Core/FileIndex/ModFile.cs similarity index 100% rename from WitcherScriptMerger/FileIndex/ModFile.cs rename to WitcherScriptMerger.Core/FileIndex/ModFile.cs diff --git a/WitcherScriptMerger/FileIndex/ModFileCategory.cs b/WitcherScriptMerger.Core/FileIndex/ModFileCategory.cs similarity index 57% rename from WitcherScriptMerger/FileIndex/ModFileCategory.cs rename to WitcherScriptMerger.Core/FileIndex/ModFileCategory.cs index d287ef8..33eee97 100644 --- a/WitcherScriptMerger/FileIndex/ModFileCategory.cs +++ b/WitcherScriptMerger.Core/FileIndex/ModFileCategory.cs @@ -23,21 +23,26 @@ public override string ToString() } } - static class Categories + // 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 ModFileCategory Script = new ModFileCategory( + public static readonly ModFileCategory Script = new ModFileCategory( 1, "Scripts", "These plaintext .ws files can be merged", true, false); - public static ModFileCategory Xml = new ModFileCategory( + public static readonly ModFileCategory Xml = new ModFileCategory( 2, "Non-Bundled XML", "These .xml text files can be merged", true, false); - public static ModFileCategory BundleText = new ModFileCategory( + public static readonly ModFileCategory BundleText = new ModFileCategory( 3, "Bundled Text", "These bundled text files can be merged", true, true); - public static ModFileCategory BundleNotMergeable = new ModFileCategory( + 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 ModFileCategory FlatNotMergeable = new ModFileCategory( + 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/FileIndex/ModFileIndex.cs b/WitcherScriptMerger.Core/FileIndex/ModFileIndex.cs similarity index 92% rename from WitcherScriptMerger/FileIndex/ModFileIndex.cs rename to WitcherScriptMerger.Core/FileIndex/ModFileIndex.cs index bd9ef1f..5fbfac1 100644 --- a/WitcherScriptMerger/FileIndex/ModFileIndex.cs +++ b/WitcherScriptMerger.Core/FileIndex/ModFileIndex.cs @@ -8,7 +8,7 @@ namespace WitcherScriptMerger.FileIndex { - class ModFileIndex + public class ModFileIndex { public List Files; @@ -41,7 +41,7 @@ public void BuildAsync( ModCount = modDirPaths.Count; if (ModCount == 0) { - Program.Notifier.ShowMessage("Can't find any mods in the Mods directory."); + AppState.Notifier.ShowMessage("Can't find any mods in the Mods directory."); } var bgWorker = new BackgroundWorker @@ -127,7 +127,7 @@ private List GetModFilesFromPaths( private IEnumerable GetIgnoredModNames() { - var ignoredNames = Program.Settings.Get("IgnoreModNames"); + 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/Inventory/FileHash.cs b/WitcherScriptMerger.Core/Inventory/FileHash.cs similarity index 100% rename from WitcherScriptMerger/Inventory/FileHash.cs rename to WitcherScriptMerger.Core/Inventory/FileHash.cs diff --git a/WitcherScriptMerger/Inventory/FileMerger.cs b/WitcherScriptMerger.Core/Inventory/FileMerger.cs similarity index 59% rename from WitcherScriptMerger/Inventory/FileMerger.cs rename to WitcherScriptMerger.Core/Inventory/FileMerger.cs index 31ae2d1..5fa3633 100644 --- a/WitcherScriptMerger/Inventory/FileMerger.cs +++ b/WitcherScriptMerger.Core/Inventory/FileMerger.cs @@ -1,17 +1,34 @@ 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 { + // 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 @@ -45,14 +62,53 @@ public class HeadlessMergeSummary 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; - TreeNode[] _checkedFileNodes; FileInfo _vanillaFile; string _mergedModName; string _outputPath; @@ -60,125 +116,88 @@ public class HeadlessMergeSummary bool _bundleChanged; List _pendingBundleMerges = new List(); - BackgroundWorker _bgWorker; - #endregion - public FileMerger( - MergeInventory inventory, - ProgressChangedEventHandler progressHandler, - RunWorkerCompletedEventHandler completedHandler) + public FileMerger(MergeInventory inventory, IMergeEngine mergeEngine) { - _inventory = inventory; + // 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."); - _bgWorker = new BackgroundWorker - { - WorkerReportsProgress = true - }; - _bgWorker.ProgressChanged += progressHandler; + _inventory = inventory; + MergeEngine = mergeEngine; ProgressInfo = new MergeProgressInfo(); - ProgressInfo.PropertyChanged += (sender, e) => - { - _bgWorker.ReportProgress(0, ProgressInfo); - }; - _bgWorker.RunWorkerCompleted += completedHandler; } - ~FileMerger() - { - if (_bgWorker != null) - _bgWorker.Dispose(); - } + #region Interactive - public void MergeByTreeNodesAsync( - IEnumerable fileNodesToMerge, - string mergedModName) + public void MergeFilesInteractive(IReadOnlyList filesToMerge, 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]; + _mergedModName = mergedModName; - ProgressInfo.CurrentFileName = Path.GetFileName(fileNode.Text); - ProgressInfo.CurrentFileNum = i + 1; + ProgressInfo.TotalMergeCount = filesToMerge.Sum(f => f.OrderedSources.Length - 1); + ProgressInfo.TotalFileCount = filesToMerge.Count; - var checkedModNodes = checkedModNodesForFile[i]; + for (int i = 0; i < filesToMerge.Count; ++i) + { + var file = filesToMerge[i]; - ProgressInfo.CurrentAction = "Starting merge"; + ProgressInfo.CurrentFileName = Path.GetFileName(file.RelativePath); + ProgressInfo.CurrentFileNum = i + 1; + ProgressInfo.CurrentAction = "Starting merge"; - if (checkedModNodes.Any(node => (new LoadOrderComparer()).Compare(node.Text, _mergedModName) < 0) && - !ConfirmRemainingConflict(_mergedModName)) - continue; + 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(fileNode.Text)); - if (merge == null) + var isNew = false; + var merge = _inventory.Merges.FirstOrDefault(m => m.RelativePath.EqualsIgnoreCase(file.RelativePath)); + if (merge == null) + { + isNew = true; + merge = new Merge { - isNew = true; - merge = new Merge - { - RelativePath = fileNode.Text, - MergedModName = _mergedModName - }; - } + RelativePath = file.RelativePath, + 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 (file.IsBundle) + { + merge.BundleName = Path.GetFileName(Paths.RetrieveMergedBundlePath()); + MergeBundleFileInteractive(file, merge, isNew); } - if (_bundleChanged) + else + MergeFlatFileInteractive(file, merge, isNew); + } + if (_bundleChanged) + { + var newBundlePath = PackNewBundle(Paths.RetrieveMergedBundlePath()); + if (newBundlePath != null) { - var newBundlePath = PackNewBundle(Paths.RetrieveMergedBundlePath()); - if (newBundlePath != null) - { - ProgressInfo.CurrentAction = "Adding bundle merge to inventory"; - foreach (var bundleMerge in _pendingBundleMerges) - _inventory.Merges.Add(bundleMerge); + 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.Notifier.ShowModal(reportForm); - } - } - } + OnPackReport?.Invoke(newBundlePath); } - CleanUpTempFiles(); - CleanUpEmptyDirectories(); - }; - _bgWorker.RunWorkerAsync(); + } + CleanUpTempFiles(); + CleanUpEmptyDirectories(); } - void MergeFlatFileNode(TreeNode fileNode, TreeNode[] checkedModNodes, Merge merge, bool isNew) + void MergeFlatFileInteractive(InteractiveMergeRequest file, Merge merge, bool isNew) { - var metadata1 = checkedModNodes[0].GetMetadata(); - var source1 = MergeSource.FromFlatFile(new FileInfo(metadata1.FilePath), metadata1.FileHash); + var source1 = file.OrderedSources[0]; var relPath = Paths.GetRelativePath( source1.TextFile.FullName, @@ -189,21 +208,20 @@ void MergeFlatFileNode(TreeNode fileNode, TreeNode[] checkedModNodes, Merge merg if (File.Exists(_outputPath) && !ConfirmOutputOverwrite(_outputPath)) return; - _vanillaFile = new FileInfo(fileNode.GetMetadata().FilePath); + _vanillaFile = new FileInfo(file.VanillaFilePath); - for (int i = 1; i < checkedModNodes.Length; ++i) + for (int i = 1; i < file.OrderedSources.Length; ++i) { ++ProgressInfo.CurrentMergeNum; - var metadata2 = checkedModNodes[i].GetMetadata(); - var source2 = MergeSource.FromFlatFile(new FileInfo(metadata2.FilePath), metadata2.FileHash); + var source2 = file.OrderedSources[i]; - var mergedFile = MergeText(merge, source1, source2); + var mergedFile = MergeTextInteractive(merge, source1, source2); if (mergedFile != null) { source1 = MergeSource.FromFlatFile(mergedFile, null); } - else if (DialogResult.Abort == HandleCanceledMerge(checkedModNodes.Length - i - 1, merge)) + else if (!ConfirmContinueAfterCanceledMerge(file.OrderedSources.Length - i - 1, merge)) break; } @@ -214,11 +232,125 @@ void MergeFlatFileNode(TreeNode fileNode, TreeNode[] checkedModNodes, Merge merg } } - // Headless equivalent of MergeFlatFileNode/MergeBundleFileNode, driven by - // plain ModFile/FileHash data (FileIndex/ModFileIndex.Conflicts) instead of - // ConflictTree's TreeNodes - those already carry everything needed (relative - // path, category, per-mod name and hash), so no TreeNode is ever constructed - // for this path. + 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) + { + ProgressInfo.CurrentAction = $"Merging {source1.Name} && {source2.Name} — waiting for KDiff3 to close"; + + 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, @@ -368,7 +500,7 @@ string[] ResolveMergeOrder(ModFile conflict, IReadOnlyDictionary !conflict.ContainsMod(name)).ToArray(); if (unknown.Any()) { - Program.Notifier.ShowError( + AppState.Notifier.ShowError( $"Order file lists unknown mod(s) for {conflict.RelativePath}: {string.Join(", ", unknown)}"); return null; } @@ -385,11 +517,30 @@ FileInfo MergeTextHeadless(Merge merge, MergeSource source1, MergeSource source2 { ProgressInfo.CurrentAction = $"Merging {source1.Name} && {source2.Name}"; - var result = KDiff3.RunHeadless(source1, source2, _vanillaFile, _outputPath); + var result = MergeEngine.MergeHeadless(source1, source2, _vanillaFile, _outputPath); - if (result != KDiff3.HeadlessResult.AutoSolved) + if (result != MergeEngineResult.AutoSolved) return null; + 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)) { @@ -401,132 +552,15 @@ FileInfo MergeTextHeadless(Merge merge, MergeSource source1, MergeSource source2 { _inventory.AddModToMerge(source2, merge); } - - return new FileInfo(_outputPath); - } - - 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.Notifier.ShowModal(reportForm); - } - } - return new FileInfo(_outputPath); - } - else - return null; - } - - bool ConfirmRemainingConflict(string mergedModName) - { - return (DialogResult.Yes == Program.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", - MessageBoxButtons.YesNo, - MessageBoxIcon.Exclamation)); } bool ConfirmOutputOverwrite(string outputPath) { - return (DialogResult.Yes == Program.Notifier.ShowMessage( + return (NotifyResult.Yes == AppState.Notifier.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.Notifier.ShowMessage(msg, "Skipped Merge", buttons, MessageBoxIcon.Information); - if (result == DialogResult.No) - { - ProgressInfo.CurrentMergeNum += remainingMergesForFile; - return DialogResult.Abort; - } - return DialogResult.OK; + NotifyButtons.YesNo, + DialogIcon.Exclamation)); } bool GetUnpackedFiles(string contentRelativePath, ref MergeSource source1, ref MergeSource source2) @@ -597,31 +631,6 @@ string UnpackFile(string bundlePath, string contentRelativePath, string outputDi : 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.Notifier.ShowModal(reportForm); - } - } - }; - _bgWorker.RunWorkerAsync(); - } - string PackNewBundle(string bundlePath, bool isRepack = false) { ProgressInfo.CurrentPhase = (!isRepack ? "Packing Bundle" : "Repacking Bundle"); @@ -654,11 +663,11 @@ void CleanUpTempFiles() } catch (Exception ex) { - Program.Notifier.ShowMessage( + AppState.Notifier.ShowMessage( "Non-critical error: Failed to delete temporary unpacked bundle content.\n\n" + ex.Message, "Error", - MessageBoxButtons.OK, - MessageBoxIcon.Warning); + NotifyButtons.OK, + DialogIcon.Warning); } } @@ -674,11 +683,11 @@ void CleanUpEmptyDirectories() } catch (Exception ex) { - Program.Notifier.ShowMessage( + AppState.Notifier.ShowMessage( "Non-critical error: Failed to delete empty Merged Bundle Content directories.\n\n" + ex.Message, "Error", - MessageBoxButtons.OK, - MessageBoxIcon.Warning); + NotifyButtons.OK, + DialogIcon.Warning); } } @@ -739,5 +748,7 @@ void DeleteEmptyDirectories(string rootPath) throw; } } + + #endregion } } diff --git a/WitcherScriptMerger/Inventory/Merge.cs b/WitcherScriptMerger.Core/Inventory/Merge.cs similarity index 100% rename from WitcherScriptMerger/Inventory/Merge.cs rename to WitcherScriptMerger.Core/Inventory/Merge.cs diff --git a/WitcherScriptMerger/Inventory/MergeInventory.cs b/WitcherScriptMerger.Core/Inventory/MergeInventory.cs similarity index 100% rename from WitcherScriptMerger/Inventory/MergeInventory.cs rename to WitcherScriptMerger.Core/Inventory/MergeInventory.cs diff --git a/WitcherScriptMerger/Inventory/MergeProgressInfo.cs b/WitcherScriptMerger.Core/Inventory/MergeProgressInfo.cs similarity index 100% rename from WitcherScriptMerger/Inventory/MergeProgressInfo.cs rename to WitcherScriptMerger.Core/Inventory/MergeProgressInfo.cs diff --git a/WitcherScriptMerger/LoadOrder/CustomLoadOrder.cs b/WitcherScriptMerger.Core/LoadOrder/CustomLoadOrder.cs similarity index 97% rename from WitcherScriptMerger/LoadOrder/CustomLoadOrder.cs rename to WitcherScriptMerger.Core/LoadOrder/CustomLoadOrder.cs index e4f13dd..9428032 100644 --- a/WitcherScriptMerger/LoadOrder/CustomLoadOrder.cs +++ b/WitcherScriptMerger.Core/LoadOrder/CustomLoadOrder.cs @@ -7,7 +7,7 @@ namespace WitcherScriptMerger.LoadOrder { - class CustomLoadOrder + public class CustomLoadOrder { public const int TopPriority = 0; public const int BottomPriority = 9999; @@ -152,11 +152,11 @@ bool ProcessPriorityLine(string line, int lineNum, ModLoadSetting setting) void ShowWarningForMalformedFile(string reason) { - Program.Notifier.ShowMessage( + AppState.Notifier.ShowMessage( "Your mods.settings file is invalid.\n\n" + reason, "Invalid Load Order File", - System.Windows.Forms.MessageBoxButtons.OK, - System.Windows.Forms.MessageBoxIcon.Warning); + NotifyButtons.OK, + DialogIcon.Warning); } public void Save() diff --git a/WitcherScriptMerger/LoadOrder/LoadOrderComparer.cs b/WitcherScriptMerger.Core/LoadOrder/LoadOrderComparer.cs similarity index 92% rename from WitcherScriptMerger/LoadOrder/LoadOrderComparer.cs rename to WitcherScriptMerger.Core/LoadOrder/LoadOrderComparer.cs index 0cdee36..7481353 100644 --- a/WitcherScriptMerger/LoadOrder/LoadOrderComparer.cs +++ b/WitcherScriptMerger.Core/LoadOrder/LoadOrderComparer.cs @@ -3,7 +3,7 @@ namespace WitcherScriptMerger.LoadOrder { - class LoadOrderComparer : IComparer, IComparer + public class LoadOrderComparer : IComparer, IComparer { public int Compare(string x, string y) { diff --git a/WitcherScriptMerger/LoadOrder/LoadOrderValidator.cs b/WitcherScriptMerger.Core/LoadOrder/LoadOrderValidator.cs similarity index 53% rename from WitcherScriptMerger/LoadOrder/LoadOrderValidator.cs rename to WitcherScriptMerger.Core/LoadOrder/LoadOrderValidator.cs index d99f184..b916b42 100644 --- a/WitcherScriptMerger/LoadOrder/LoadOrderValidator.cs +++ b/WitcherScriptMerger.Core/LoadOrder/LoadOrderValidator.cs @@ -1,9 +1,8 @@ using System.Linq; -using System.Windows.Forms; namespace WitcherScriptMerger.LoadOrder { - static class LoadOrderValidator + public static class LoadOrderValidator { public static void ValidateAndFix(CustomLoadOrder loadOrder) { @@ -17,43 +16,46 @@ public static void ValidateAndFix(CustomLoadOrder loadOrder) return; var choice = PromptToPrioritizeMergedMod(loadOrder.FilePath); - if (choice == DialogResult.Yes) + if (choice == NotifyResult.Yes) { PrioritizeMergedMod(loadOrder, mergedMod); } - else if (choice == DialogResult.Cancel && Program.Notifier.IsInteractive) // Never + else if (choice == NotifyResult.Cancel) // Never { - // IsInteractive guard: HeadlessMergeNotifier's fixed default for - // YesNoCancel is Cancel, which would otherwise persist this setting - // on every headless run that reaches here. - Program.Settings.Set("ValidateCustomLoadOrder", false); - Program.Settings.Save(); + AppState.Settings.Set("ValidateCustomLoadOrder", false); + AppState.Settings.Save(); } } - static DialogResult PromptToPrioritizeMergedMod(string modsSettingsPath) + // 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) { - // Known, accepted regression: the Cancel button used to be relabeled - // "Ne&ver" via MessageBoxManager.Register()/Unregister(), which worked - // only because the old MessageBox.Show call ran on the same background - // thread (Register()'s SetWindowsHookEx is thread-affine) as this - // method. Program.Notifier.ShowMessage (MainForm.ShowMessage) Invokes - // the actual MessageBox.Show onto the UI thread, so that hook can no - // longer see the dialog's window messages - relabeling can't be - // preserved without adding custom button-text support to - // IMergeNotifier, which is out of scope here. The Cancel button now - // reads "Cancel"; clicking it still permanently disables this check - // (see the IsInteractive-guarded branch above), just without a label - // saying so. DialogResult semantics and this method's return value are - // otherwise unchanged. - return Program.Notifier.ShowMessage( + 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?", + "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", - MessageBoxButtons.YesNoCancel, - MessageBoxIcon.Exclamation, - MessageBoxDefaultButton.Button2); + NotifyButtons.YesNoCancel, + DialogIcon.Exclamation, + NotifyResult.No); } static void PrioritizeMergedMod(CustomLoadOrder loadOrder, ModLoadSetting mergedModSetting) diff --git a/WitcherScriptMerger/LoadOrder/ModLoadSetting.cs b/WitcherScriptMerger.Core/LoadOrder/ModLoadSetting.cs similarity index 93% rename from WitcherScriptMerger/LoadOrder/ModLoadSetting.cs rename to WitcherScriptMerger.Core/LoadOrder/ModLoadSetting.cs index 894973d..f8424c8 100644 --- a/WitcherScriptMerger/LoadOrder/ModLoadSetting.cs +++ b/WitcherScriptMerger.Core/LoadOrder/ModLoadSetting.cs @@ -1,6 +1,6 @@ namespace WitcherScriptMerger.LoadOrder { - class ModLoadSetting + public class ModLoadSetting { public string ModName { get; set; } diff --git a/WitcherScriptMerger/Mcp/WsmMcpTools.cs b/WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs similarity index 90% rename from WitcherScriptMerger/Mcp/WsmMcpTools.cs rename to WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs index 89e685f..23f20a3 100644 --- a/WitcherScriptMerger/Mcp/WsmMcpTools.cs +++ b/WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs @@ -11,14 +11,14 @@ namespace WitcherScriptMerger.Mcp { [McpServerToolType] - static class WsmMcpTools + public static class WsmMcpTools { - // scan_conflicts and merge_conflicts both load-then-mutate the shared Program.Inventory + // 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 Program.Inventory. + // get_status/list_merges - neither touches AppState.Inventory. static readonly object _inventoryLock = new object(); [McpServerTool(Name = "scan_conflicts"), Description( @@ -33,7 +33,7 @@ public static object ScanConflicts() lock (_inventoryLock) { - Program.Inventory = MergeInventory.Load(Paths.Inventory); + AppState.Inventory = MergeInventory.Load(Paths.Inventory); var modIndex = MergeOperations.ScanConflicts(); return modIndex.Conflicts.Select(c => new @@ -42,7 +42,7 @@ public static object ScanConflicts() 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 = Program.Inventory.HasResolvedConflict(c), + alreadyResolved = AppState.Inventory.HasResolvedConflict(c), }).ToArray(); } } @@ -64,15 +64,15 @@ public static object MergeConflicts( lock (_inventoryLock) { - Program.Inventory = MergeInventory.Load(Paths.Inventory); + AppState.Inventory = MergeInventory.Load(Paths.Inventory); var modIndex = MergeOperations.ScanConflicts(); var conflicts = relativePaths == null ? modIndex.Conflicts : modIndex.Conflicts.Where(c => relativePaths.Any(p => p.EqualsIgnoreCase(c.RelativePath))); - var summary = MergeOperations.RunMerge(Program.Inventory, conflicts, mergedModName, orderOverrides); - Program.Inventory.Save(); + var summary = MergeOperations.RunMerge(AppState.Inventory, conflicts, mergedModName, orderOverrides); + AppState.Inventory.Save(); return new { merged = summary.Merged, skipped = summary.Skipped }; } @@ -97,7 +97,7 @@ public static object GetStatus() modsDirectory = Paths.ModsDirectory, dependenciesValid, modsDirectoryExists, - mergedModName = Program.Settings.Get("MergedModName"), + mergedModName = AppState.Settings.Get("MergedModName"), conflictCount, }; } 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/Paths.cs b/WitcherScriptMerger.Core/Paths.cs similarity index 72% rename from WitcherScriptMerger/Paths.cs rename to WitcherScriptMerger.Core/Paths.cs index 1e9c0c8..91b9016 100644 --- a/WitcherScriptMerger/Paths.cs +++ b/WitcherScriptMerger.Core/Paths.cs @@ -1,11 +1,10 @@ using System; using System.IO; -using System.Windows.Forms; using WitcherScriptMerger.Tools; namespace WitcherScriptMerger { - static class Paths + public static class Paths { public const string TempBundleContent = "tempbundlecontent"; public static string MergedBundleContent = "Merged Bundle Content"; @@ -15,7 +14,7 @@ static class Paths public static string VanillaScriptBase = Path.Combine("content", "content0", "scripts"); public static string BundleBase = "content"; - public static string GameDirectory => Program.Settings.Get("GameDirectory"); + public static string GameDirectory => AppState.Settings.Get("GameDirectory"); public static string GameExe => Path.Combine(GameDirectory, "bin", "x64", "witcher3.exe"); @@ -23,7 +22,7 @@ static class Paths public static string DlcDirectory => Path.Combine(GameDirectory, "DLC"); - static string _scriptsDirSetting = Program.Settings.Get("VanillaScriptsDirectory"); + static string _scriptsDirSetting = AppState.Settings.Get("VanillaScriptsDirectory"); public static string ScriptsDirectory { get @@ -34,7 +33,7 @@ public static string ScriptsDirectory } } - static string _modsDirSetting = Program.Settings.Get("ModsDirectory"); + static string _modsDirSetting = AppState.Settings.Get("ModsDirectory"); public static string ModsDirectory { get @@ -55,9 +54,18 @@ public static string GetRelativePath(string fullPath, string basePath) 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. public static bool ValidateDependencyPaths() { - return (File.Exists(KDiff3.ExePath) && + return (AppState.MergeEngine != null && AppState.MergeEngine.ValidateExePath() && File.Exists(QuickBms.ExePath) && File.Exists(QuickBms.PluginPath) && File.Exists(WccLite.ExePath)); @@ -67,7 +75,7 @@ public static bool ValidateModsDirectory() { if (!Directory.Exists(ModsDirectory)) { - Program.Notifier.ShowMessage( + 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.")); @@ -80,7 +88,7 @@ public static bool ValidateScriptsDirectory() { if (!Directory.Exists(ScriptsDirectory)) { - Program.Notifier.ShowMessage( + 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.") + @@ -94,7 +102,7 @@ public static bool ValidateBundlesDirectory() { if (!Directory.Exists(BundlesDirectory)) { - Program.Notifier.ShowMessage("Can't find 'content' directory in the specified game directory."); + AppState.Notifier.ShowMessage("Can't find 'content' directory in the specified game directory."); return false; } return true; @@ -111,10 +119,10 @@ public static string RetrieveMergedBundlePath() public static string RetrieveMergedModName() { - var mergedModName = Program.Settings.Get("MergedModName"); + var mergedModName = AppState.Settings.Get("MergedModName"); if (string.IsNullOrWhiteSpace(mergedModName)) { - Program.Notifier.ShowMessage("The MergedModName setting isn't configured in the .config file."); + AppState.Notifier.ShowMessage("The MergedModName setting isn't configured in the .config file."); return null; } if (mergedModName.Length > 64) @@ -138,13 +146,13 @@ public static string RetrieveMergedModDir() static bool ConfirmInvalidModName(string mergedModName) { - return (DialogResult.Yes == Program.Notifier.ShowMessage( + 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", - MessageBoxButtons.YesNo, - MessageBoxIcon.Exclamation)); + 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/Tools/Hasher.cs b/WitcherScriptMerger.Core/Tools/Hasher.cs similarity index 96% rename from WitcherScriptMerger/Tools/Hasher.cs rename to WitcherScriptMerger.Core/Tools/Hasher.cs index 4cec06e..b510dcb 100644 --- a/WitcherScriptMerger/Tools/Hasher.cs +++ b/WitcherScriptMerger.Core/Tools/Hasher.cs @@ -4,7 +4,7 @@ namespace WitcherScriptMerger.Tools { - static class Hasher + 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. 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/Tools/QuickBms.cs b/WitcherScriptMerger.Core/Tools/QuickBms.cs similarity index 81% rename from WitcherScriptMerger/Tools/QuickBms.cs rename to WitcherScriptMerger.Core/Tools/QuickBms.cs index 6a9d6d6..0de50c8 100644 --- a/WitcherScriptMerger/Tools/QuickBms.cs +++ b/WitcherScriptMerger.Core/Tools/QuickBms.cs @@ -5,10 +5,10 @@ namespace WitcherScriptMerger.Tools { - static class QuickBms + public static class QuickBms { - public static string ExePath = Program.Settings.Get("QuickBmsPath"); - public static string PluginPath = Program.Settings.Get("QuickBmsPluginPath"); + public static string ExePath = AppState.Settings.Get("QuickBmsPath"); + public static string PluginPath = AppState.Settings.Get("QuickBmsPluginPath"); public static int UnpackFile(string bundlePath, string contentRelativePath, string outputDir) { @@ -34,7 +34,7 @@ public static int UnpackFile(string bundlePath, string contentRelativePath, stri output = output.Substring(outputStart); errorMsg += "\n\n" + output; } - Program.Notifier.ShowError(errorMsg); + AppState.Notifier.ShowError(errorMsg); return 1; } @@ -69,17 +69,17 @@ static bool ValidateResources(string bundlePath) { if (!File.Exists(bundlePath)) { - Program.Notifier.ShowError("Can't find bundle file:\n\n" + bundlePath, "Missing Bundle"); + AppState.Notifier.ShowError("Can't find bundle file:\n\n" + bundlePath, "Missing Bundle"); return false; } if (!File.Exists(ExePath)) { - Program.Notifier.ShowError("Can't find QuickBMS at this location:\n\n" + ExePath, "Missing QuickBMS"); + AppState.Notifier.ShowError("Can't find QuickBMS at this location:\n\n" + ExePath, "Missing QuickBMS"); return false; } if (!File.Exists(PluginPath)) { - Program.Notifier.ShowError("Can't find QuickBMS plugin at this location:\n\n" + PluginPath, "Missing QuickBMS Plugin"); + AppState.Notifier.ShowError("Can't find QuickBMS plugin at this location:\n\n" + PluginPath, "Missing QuickBMS Plugin"); return false; } return true; diff --git a/WitcherScriptMerger/Tools/WccLite.cs b/WitcherScriptMerger.Core/Tools/WccLite.cs similarity index 79% rename from WitcherScriptMerger/Tools/WccLite.cs rename to WitcherScriptMerger.Core/Tools/WccLite.cs index d9860f2..0dcc3c3 100644 --- a/WitcherScriptMerger/Tools/WccLite.cs +++ b/WitcherScriptMerger.Core/Tools/WccLite.cs @@ -3,15 +3,15 @@ namespace WitcherScriptMerger.Tools { - static class WccLite + public static class WccLite { - public static string ExePath = Program.Settings.Get("WccLitePath"); + public static string ExePath = AppState.Settings.Get("WccLitePath"); public static int PackBundle(string sourceDir, string outputDir) { if (!Directory.Exists(sourceDir)) { - Program.Notifier.ShowError("Can't find content directory to pack into bundle:\n\n" + sourceDir, "Missing Directory"); + AppState.Notifier.ShowError("Can't find content directory to pack into bundle:\n\n" + sourceDir, "Missing Directory"); return 1; } @@ -33,7 +33,7 @@ public static int Run(string arguments, string failureMsg) { if (!File.Exists(ExePath)) { - Program.Notifier.ShowError("Can't find wcc_lite at this location:\n\n" + ExePath, "Missing wcc_lite"); + AppState.Notifier.ShowError("Can't find wcc_lite at this location:\n\n" + ExePath, "Missing wcc_lite"); return 1; } @@ -61,7 +61,7 @@ public static int Run(string arguments, string failureMsg) errorMsg = stdOutput; if (errorMsg != null) { - Program.Notifier.ShowError(failureMsg + "\n\n" + errorMsg); + AppState.Notifier.ShowError(failureMsg + "\n\n" + errorMsg); return 1; } } diff --git a/WitcherScriptMerger.Core/WitcherScriptMerger.Core.csproj b/WitcherScriptMerger.Core/WitcherScriptMerger.Core.csproj new file mode 100644 index 0000000..d1e1e8d --- /dev/null +++ b/WitcherScriptMerger.Core/WitcherScriptMerger.Core.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + WitcherScriptMerger + WitcherScriptMerger.Core + disable + disable + + ..\WitcherScriptMerger\DeadCodeDetection.ruleset + + + + + + + + + diff --git a/WitcherScriptMerger.sln b/WitcherScriptMerger.sln index f080f87..a2f5726 100644 --- a/WitcherScriptMerger.sln +++ b/WitcherScriptMerger.sln @@ -5,6 +5,8 @@ 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {B0417CBE-445D-47A0-8502-717BCFE63013}.Debug|Any CPU.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 + {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}.Release|Any CPU.ActiveCfg = Release|Any CPU + {339EF28F-A6D3-4878-A03E-0EE691B74FDE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/WitcherScriptMerger/Extensions.cs b/WitcherScriptMerger/Extensions.cs index d083369..1fe7dc7 100644 --- a/WitcherScriptMerger/Extensions.cs +++ b/WitcherScriptMerger/Extensions.cs @@ -3,59 +3,16 @@ using System.Drawing; using System.Linq; using System.Runtime.InteropServices; -using System.Text.RegularExpressions; using System.Windows.Forms; namespace WitcherScriptMerger { + // 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 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) diff --git a/WitcherScriptMerger/Forms/MainForm.cs b/WitcherScriptMerger/Forms/MainForm.cs index 06a9cb9..d0f86b6 100644 --- a/WitcherScriptMerger/Forms/MainForm.cs +++ b/WitcherScriptMerger/Forms/MainForm.cs @@ -18,8 +18,6 @@ partial class MainForm : Form, IMergeNotifier public string GameDirectorySetting => txtGameDir.Text; - public bool IsInteractive => true; - ModFileIndex _modIndex = null; #endregion @@ -350,11 +348,11 @@ bool ConfirmPruneMissingMergeFile(Merge merge) ? "Remove from Merges list & repack merged bundle?" : "Remove from Merges list?"; - return (DialogResult.Yes == ShowMessage( + return (NotifyResult.Yes == ShowMessage( msg, "Missing Merge Inventory File", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question)); + NotifyButtons.YesNo, + DialogIcon.Question)); } bool ConfirmDeleteMergeForMissingMod(Merge merge, string modName) @@ -371,11 +369,11 @@ bool ConfirmDeleteMergeForMissingMod(Merge merge, string modName) ? "Delete this affected merge & repack the merged bundle?" : "Delete this affected merge?"; - return (DialogResult.Yes == ShowMessage( + return (NotifyResult.Yes == ShowMessage( msg, "Missing Merge Inventory File", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question)); + NotifyButtons.YesNo, + DialogIcon.Question)); } bool ConfirmDeleteMergeForDisabledMod(Merge merge, string modName) @@ -386,11 +384,11 @@ bool ConfirmDeleteMergeForDisabledMod(Merge merge, string modName) merge.RelativePath + "\n " + string.Join("\n ", merge.Mods.Select(mod => mod.Name)); - return (DialogResult.Yes == ShowMessage( + return (NotifyResult.Yes == ShowMessage( msg, "Disabled Mod in Merge", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question)); + NotifyButtons.YesNo, + DialogIcon.Question)); } private DialogResult PromptToDeleteForChangedHash(Merge merge, string modFilePath, string modName) @@ -657,7 +655,7 @@ void btnMergeFiles_Click(object sender, EventArgs e) Program.Inventory = MergeInventory.Load(Paths.Inventory); - var merger = new FileMerger(Program.Inventory, OnMergeProgressChanged, OnMergeComplete); + var merger = new InteractiveMergeRunner(Program.Inventory, OnMergeProgressChanged, OnMergeComplete); var fileNodes = treConflicts.FileNodes.Where(node => node.GetTreeNodes().Count(modNode => modNode.Checked) > 1); @@ -807,7 +805,7 @@ void HandleDeletedBundleMerges(List bundleMerges) { InitializeProgressScreen("Merge Deleted"); - new FileMerger(Program.Inventory, OnMergeProgressChanged, OnMergeComplete) + new InteractiveMergeRunner(Program.Inventory, OnMergeProgressChanged, OnMergeComplete) .RepackBundleAsync(bundlePath); } } @@ -879,30 +877,42 @@ void HideProgressScreen() #region Cross-thread Operations - public DialogResult ShowMessage(string text, + // 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 = "", - MessageBoxButtons buttons = MessageBoxButtons.OK, - MessageBoxIcon icon = MessageBoxIcon.None, - MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1) + 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 (DialogResult)this.Invoke(new Func( - () => { return MessageBox.Show(this, text, title, buttons, icon, defaultButton); })); + return ToNeutral((DialogResult)this.Invoke(new Func( + () => { return MessageBox.Show(this, text, title, nativeButtons, nativeIcon, nativeDefault); }))); } else { - return MessageBox.Show(this, text, title, buttons, icon, defaultButton); + return ToNeutral(MessageBox.Show(this, text, title, nativeButtons, nativeIcon, nativeDefault)); } } - public DialogResult ShowError(string text, string title = "Error") + public NotifyResult ShowError(string text, string title = "Error") { - return ShowMessage(text, title, MessageBoxButtons.OK, MessageBoxIcon.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(); @@ -934,6 +944,86 @@ public void ActivateSafely() 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 @@ -977,7 +1067,7 @@ private void menuRepackBundle_Click(object sender, EventArgs e) { InitializeProgressScreen($"Repacking Bundle{mergedBundleCount.GetPluralS()}"); - new FileMerger(Program.Inventory, OnMergeProgressChanged, OnMergeComplete) + new InteractiveMergeRunner(Program.Inventory, OnMergeProgressChanged, OnMergeComplete) .RepackBundleAsync(bundlePath); } } diff --git a/WitcherScriptMerger/HeadlessMergeNotifier.cs b/WitcherScriptMerger/HeadlessMergeNotifier.cs deleted file mode 100644 index a76ce0b..0000000 --- a/WitcherScriptMerger/HeadlessMergeNotifier.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Windows.Forms; - -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 bool IsInteractive => false; - - public DialogResult ShowMessage(string text, - string title = "", - MessageBoxButtons buttons = MessageBoxButtons.OK, - MessageBoxIcon icon = MessageBoxIcon.None, - MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1) - { - Write(text, title, icon); - - // defaultButton is intentionally unused here: it only affects which - // button has interactive UI focus (Enter-key behavior), and headless - // mode has no UI to focus. The DialogResult below is chosen per - // buttons set instead, per this class's own fixed, non-destructive - // default for each verb - not by the caller's requested default - // button. A call site relying on defaultButton to signal "this is the - // safe answer" for a MessageBoxButtons combination not covered below - // will get whatever the catch-all case returns instead. - return buttons switch - { - MessageBoxButtons.OK => DialogResult.OK, - MessageBoxButtons.YesNo => DialogResult.No, - MessageBoxButtons.YesNoCancel => DialogResult.Cancel, - MessageBoxButtons.AbortRetryIgnore => DialogResult.Abort, - MessageBoxButtons.RetryCancel => DialogResult.Cancel, - MessageBoxButtons.OKCancel => DialogResult.Cancel, - _ => DialogResult.Cancel, - }; - } - - public DialogResult ShowError(string text, string title = "Error") - { - Write(text, title, MessageBoxIcon.Error); - return DialogResult.OK; - } - - public DialogResult ShowModal(Form form) - { - // Report dialogs (MergeReportForm/PackReportForm) are only ever - // constructed behind an IsInteractive check in headless mode; this - // is a defensive fallback in case a call site is missed. Never - // call form.ShowDialog() - there's no message pump running. - return DialogResult.OK; - } - - static void Write(string text, string title, MessageBoxIcon icon) - { - var prefix = string.IsNullOrEmpty(title) ? "WSM" : title; - var line = $"[{prefix}] {text}"; - - if (icon == MessageBoxIcon.Error || icon == MessageBoxIcon.Warning || icon == MessageBoxIcon.Exclamation) - Console.Error.WriteLine(line); - else - Console.WriteLine(line); - } - } -} diff --git a/WitcherScriptMerger/IMergeNotifier.cs b/WitcherScriptMerger/IMergeNotifier.cs deleted file mode 100644 index 0eb3034..0000000 --- a/WitcherScriptMerger/IMergeNotifier.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Windows.Forms; - -namespace WitcherScriptMerger -{ - interface IMergeNotifier - { - bool IsInteractive { get; } - - DialogResult ShowMessage(string text, - string title = "", - MessageBoxButtons buttons = MessageBoxButtons.OK, - MessageBoxIcon icon = MessageBoxIcon.None, - MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1); - - DialogResult ShowError(string text, string title = "Error"); - - // Only ever called when IsInteractive is true. - DialogResult ShowModal(Form form); - } -} 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/Program.cs b/WitcherScriptMerger/Program.cs index bc6e750..71f8e56 100644 --- a/WitcherScriptMerger/Program.cs +++ b/WitcherScriptMerger/Program.cs @@ -13,6 +13,8 @@ using WitcherScriptMerger.Forms; using WitcherScriptMerger.Inventory; using WitcherScriptMerger.LoadOrder; +using WitcherScriptMerger.Mcp; +using WitcherScriptMerger.Tools; namespace WitcherScriptMerger { @@ -23,13 +25,39 @@ static class Program // invoking terminal instead of written to an unattached console. static readonly bool _consoleAttached = MaybeAttachConsole(); - // 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(); - public static AppSettings Settings = new AppSettings(); - public static CustomLoadOrder LoadOrder = null; - public static MergeInventory Inventory = null; + // 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; /// @@ -38,6 +66,12 @@ static class Program [STAThread] static void Main(string[] args) { + // The one real IMergeEngine implementation, supplied here since it 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. + AppState.MergeEngine = new KDiff3MergeEngine(); + if (args.Length > 0) { Environment.ExitCode = RunCli(args); @@ -223,10 +257,15 @@ static int RunMcp() // 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(); + .WithToolsFromAssembly(typeof(WsmMcpTools).Assembly); builder.Build().RunAsync().GetAwaiter().GetResult(); return 0; diff --git a/WitcherScriptMerger/Tools/KDiff3.cs b/WitcherScriptMerger/Tools/KDiff3.cs index 256ef39..d1cc945 100644 --- a/WitcherScriptMerger/Tools/KDiff3.cs +++ b/WitcherScriptMerger/Tools/KDiff3.cs @@ -4,7 +4,6 @@ using System.Runtime.InteropServices; using System.Text; using System.Threading; -using System.Windows.Forms; using WitcherScriptMerger.Inventory; namespace WitcherScriptMerger.Tools @@ -41,8 +40,8 @@ public static int Run( "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", - MessageBoxButtons.OK, - MessageBoxIcon.Warning); + NotifyButtons.OK, + DialogIcon.Warning); } else args += " --auto"; @@ -100,7 +99,7 @@ public static HeadlessResult RunHeadless( $"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", MessageBoxButtons.OK, MessageBoxIcon.Warning); + "Skipped", NotifyButtons.OK, DialogIcon.Warning); return HeadlessResult.NeedsManualResolution; } args += " --auto"; @@ -149,7 +148,7 @@ public static HeadlessResult RunHeadless( DeleteIfExists(scratchOutputPath); Program.Notifier.ShowMessage( $"Skipped {source1.Name} + {source2.Name}: needs manual conflict resolution.", - "Skipped", MessageBoxButtons.OK, MessageBoxIcon.Warning); + "Skipped", NotifyButtons.OK, DialogIcon.Warning); return HeadlessResult.NeedsManualResolution; } 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/WitcherScriptMerger.csproj b/WitcherScriptMerger/WitcherScriptMerger.csproj index b31c25d..ca170db 100644 --- a/WitcherScriptMerger/WitcherScriptMerger.csproj +++ b/WitcherScriptMerger/WitcherScriptMerger.csproj @@ -16,8 +16,6 @@ - - @@ -26,4 +24,8 @@ + + + +