Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions CLAUDE.md

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion WitcherScriptMerger.Core/FileIndex/ModFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,16 @@ public static string GetModNameFromPath(string modFilePath)

var nameStart = Paths.ModsDirectory.Length + 1;
var name = modFilePath.Substring(nameStart);
return name.Substring(0, name.IndexOf('\\'));

// Path.DirectorySeparatorChar, not a hardcoded '\\': modFilePath is built via
// Path.Combine (directly or through Paths.GetRelativePath's substring logic
// over an OS-walked path), which uses '/' on Linux - confirmed by direct
// crash repro under WSL2 (WitcherScriptMerger.Headless, the Linux-capable
// host, running a real merge): the old hardcoded '\\' made IndexOf return -1
// on every Linux path, throwing ArgumentOutOfRangeException from the
// Substring call below on literally every flat-file merge attempt. Flagged in
// code review, see CLAUDE.md.
return name.Substring(0, name.IndexOf(Path.DirectorySeparatorChar));
}

public static bool IsScript(string path) => path.EndsWithIgnoreCase(".ws");
Expand Down
26 changes: 24 additions & 2 deletions WitcherScriptMerger.Core/FileIndex/ModFileIndex.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,28 @@ public void BuildAsync(
AppState.Notifier.ShowMessage("Can't find any mods in the Mods directory.");
}

// Checked once up front, not per bundle: QuickBms.GetBundleContentPaths already
// reports (and now tolerates - see its own comment) a missing QuickBMS/wcc_lite
// per bundle it's asked about, but that's needlessly noisy across a whole scan,
// and WitcherScriptMerger.Headless (the Linux-capable CLI/MCP-only host, no
// QuickBMS/wcc_lite bundled at all - see its CLAUDE.md section) deliberately
// doesn't gate scanning on Paths.ValidateDependencyPaths() first, so this is the
// first point in a scan where that host's missing bundle tooling surfaces. One
// clear message beats one per bundle. BundleCount (below) still counts every
// *.bundle file found regardless of whether checking could proceed - unchanged
// from before this gate, and consistent with ScriptCount/XmlCount, which also
// count regardless of checkScripts/checkXml - only the actual per-file
// conflict-scanning loop is skipped here.
var canCheckBundles = checkBundles && QuickBms.IsAvailable;
if (checkBundles && !canCheckBundles)
{
AppState.Notifier.ShowMessage(
"Bundle-content conflicts aren't supported without QuickBMS and wcc_lite configured - skipping bundle-content checking for this scan.",
"Bundle Checking Unavailable",
NotifyButtons.OK,
DialogIcon.Warning);
}

var bgWorker = new BackgroundWorker
{
WorkerReportsProgress = true
Expand Down Expand Up @@ -72,7 +94,7 @@ public void BuildAsync(
{
Files.AddRange(GetModFilesFromPaths(xmlPaths, Categories.Xml, modName));
}
if (checkBundles)
if (canCheckBundles)
{
foreach (var bundlePath in bundlePaths)
{
Expand All @@ -83,7 +105,7 @@ public void BuildAsync(
var progressPct = (int)((float)++i / modDirPaths.Count * 100f);
bgWorker.ReportProgress(progressPct, modName as object);
}
if (checkBundles)
if (canCheckBundles)
System.Threading.Thread.Sleep(500); // Wait for progress bar to fill completely
};
bgWorker.RunWorkerCompleted += completedHandler;
Expand Down
25 changes: 20 additions & 5 deletions WitcherScriptMerger.Core/Inventory/FileMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -668,13 +668,28 @@ bool GetUnpackedFiles(string contentRelativePath, ref MergeSource source1, ref M
{
ProgressInfo.CurrentAction = "Searching for corresponding vanilla bundle";

// Directory.GetDirectories throws DirectoryNotFoundException on a missing
// root - guarded here (rather than assuming GameDirectory always has real
// "content"/"DLC" subfolders) so a scratch/incomplete game tree degrades to
// "no vanilla bundle found" (handled below, and ultimately by each
// IMergeEngine as a graceful "needs manual resolution" skip - see
// DiffPlexMergeEngine.MergeHeadless's hasVanillaVersion guard) instead of an
// unhandled exception. Previously unreachable on the WinForms host, which
// always gates bundle-category scanning behind Paths.ValidateDependencyPaths()
// (and therefore a real game install) first - but WitcherScriptMerger.Headless
// deliberately doesn't require QuickBMS/wcc_lite to attempt flat-file merges, so
// a bundle conflict can now reach this code without one. Flagged in code review,
// see CLAUDE.md.
var bundleDirs =
Directory.GetDirectories(Paths.BundlesDirectory)
.Select(path => Path.Combine(path, "bundles"))
(Directory.Exists(Paths.BundlesDirectory)
? Directory.GetDirectories(Paths.BundlesDirectory).Select(path => Path.Combine(path, "bundles"))
: Enumerable.Empty<string>())
.Concat(
Directory.GetDirectories(Paths.DlcDirectory)
.Where(path => new Regex("DLC[0-9]*$").IsMatch(path) || new Regex("ep[0-9]$").IsMatch(path))
.Select(path => Path.Combine(path, Paths.BundleBase, "bundles"))
Directory.Exists(Paths.DlcDirectory)
? Directory.GetDirectories(Paths.DlcDirectory)
.Where(path => new Regex("DLC[0-9]*$").IsMatch(path) || new Regex("ep[0-9]$").IsMatch(path))
.Select(path => Path.Combine(path, Paths.BundleBase, "bundles"))
: Enumerable.Empty<string>()
)
.Where(path => Directory.Exists(path))
.OrderBy(path => path, new LoadOrderComparer())
Expand Down
74 changes: 53 additions & 21 deletions WitcherScriptMerger.Core/Mcp/WsmMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,21 @@ public static object MergeConflicts(
if (string.IsNullOrWhiteSpace(mergedModName))
throw new InvalidOperationException("MergedModName isn't configured in App.config.");

// ModFile.RelativePath always uses '\' (built via Path.Combine/GetRelativePath
// on Windows). A client-supplied relativePaths entry using '/' already passes
// IsWithinModsDirectory's scope check (Path.GetFullPath normalizes separators),
// but a raw EqualsIgnoreCase against RelativePath below would not - normalize
// here so an in-scope path in a different, still-valid separator style doesn't
// silently fail to match its own conflict and land in `unmatched` looking like
// it was never a conflict at all.
var normalizedRelativePaths = relativePaths?.Select(p => p.Replace('/', '\\')).ToArray();
// ModFile.RelativePath always uses the host OS's native separator (built via
// Path.Combine/GetRelativePath over an OS-walked path - '\' on the WinForms
// host, '/' on WitcherScriptMerger.Headless when it's actually running on
// Linux). A client-supplied relativePaths entry using the other separator
// already passes IsWithinModsDirectory's scope check (Path.GetFullPath
// normalizes separators), but a raw EqualsIgnoreCase against RelativePath
// below would not - normalize both possible separators to
// Path.DirectorySeparatorChar here so an in-scope path in a different, still-
// valid separator style doesn't silently fail to match its own conflict and
// land in `unmatched` looking like it was never a conflict at all. Hardcoded
// to '\\' until this repo's Linux host existed - see ModFile.GetModNameFromPath
// for a related, worse bug (an outright crash) from the same wrong assumption.
var normalizedRelativePaths = relativePaths?
.Select(p => p.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar))
.ToArray();

lock (_inventoryLock)
{
Expand Down Expand Up @@ -113,15 +120,16 @@ public static object MergeConflicts(
// orderOverrides keys are matched against conflict.RelativePath elsewhere
// (FileMerger.ResolveMergeOrder) via a plain Dictionary lookup, which - built
// from JSON with no comparer specified - is ordinal case-sensitive by
// default and wouldn't tolerate a '/'-separated key either. Rebuilding it
// here (case-insensitive comparer, '\' separators) keeps that lookup
// consistent with every other path/name comparison in this codebase, so a
// differently-cased or differently-separated but otherwise-correct key
// isn't silently ignored.
// default and wouldn't tolerate a differently-separated key either.
// Rebuilding it here (case-insensitive comparer, normalized to
// Path.DirectorySeparatorChar - see normalizedRelativePaths above for why
// it's not hardcoded to '\\') keeps that lookup consistent with every other
// path/name comparison in this codebase, so a differently-cased or
// differently-separated but otherwise-correct key isn't silently ignored.
var normalizedOrderOverrides = orderOverrides == null
? null
: orderOverrides.ToDictionary(
kv => kv.Key.Replace('/', '\\'),
kv => kv.Key.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar),
kv => kv.Value,
StringComparer.OrdinalIgnoreCase);

Expand All @@ -140,22 +148,35 @@ public static object MergeConflicts(

[McpServerTool(Name = "get_status"), Description(
"Reports WSM's current configuration and dependency status: resolved game/mods " +
"directories, whether KDiff3/QuickBMS/wcc_lite are all found, the configured " +
"merged-mod name, and the current conflict count.")]
"directories, whether the text-merge engine (KDiff3 or DiffPlex) and QuickBMS/" +
"wcc_lite are found, the configured merged-mod name, and the current conflict " +
"count. textMergeDependenciesValid alone is enough for flat-file (.ws/.xml) " +
"conflicts; bundleDependenciesValid additionally gates bundle-content conflicts " +
"- a host with no QuickBMS/wcc_lite configured can still scan/merge flat-file " +
"conflicts with only the former true.")]
public static object GetStatus()
{
var dependenciesValid = Paths.ValidateDependencyPaths();
// Split rather than the combined Paths.ValidateDependencyPaths() so a host
// without QuickBMS/wcc_lite (e.g. WitcherScriptMerger.Headless) doesn't report a
// conflictCount of 0 just because bundle tooling is missing - see
// RequireDependenciesAndModsDirectory below for the same split applied to
// scan_conflicts/merge_conflicts. dependenciesValid is kept for existing callers
// that only checked the combined flag.
var textMergeDependenciesValid = Paths.ValidateTextMergeDependencies();
var bundleDependenciesValid = Paths.ValidateBundleDependencies();
var modsDirectoryExists = Directory.Exists(Paths.ModsDirectory);

var conflictCount = 0;
if (dependenciesValid && modsDirectoryExists)
if (textMergeDependenciesValid && modsDirectoryExists)
conflictCount = MergeOperations.ScanConflicts().Conflicts.Count();

return new
{
gameDirectory = Paths.GameDirectory,
modsDirectory = Paths.ModsDirectory,
dependenciesValid,
dependenciesValid = textMergeDependenciesValid && bundleDependenciesValid,
textMergeDependenciesValid,
bundleDependenciesValid,
modsDirectoryExists,
mergedModName = AppState.Settings.Get("MergedModName"),
conflictCount,
Expand All @@ -177,11 +198,22 @@ public static object ListMerges()
}).ToArray();
}

// Only the text-merge engine is required to let scan_conflicts/merge_conflicts run
// at all - not QuickBMS/wcc_lite too. That used to be one combined
// Paths.ValidateDependencyPaths() check, which meant a host with no QuickBMS/
// wcc_lite configured (WitcherScriptMerger.Headless) could never scan or merge
// even its supported flat-file (.ws/.xml) conflicts. This is a behavior relaxation
// for the WinForms host's MCP mode too, not just the new host - see CLAUDE.md and
// the PR that introduced this split. Bundle-category conflicts still fail
// gracefully per-conflict when QuickBMS/wcc_lite aren't available (see
// QuickBms.IsAvailable's callers, ModFileIndex.BuildAsync, and
// FileMerger.GetUnpackedFiles) rather than being silently attempted and left
// looking like a hard requirement was still being enforced here.
static void RequireDependenciesAndModsDirectory()
{
if (!Paths.ValidateDependencyPaths())
if (!Paths.ValidateTextMergeDependencies())
throw new InvalidOperationException(
"A required dependency (KDiff3, QuickBMS, or wcc_lite) is missing. Configure its path in App.config.");
"The configured text-merge engine (KDiff3 or DiffPlex) is missing or misconfigured.");

if (!Directory.Exists(Paths.ModsDirectory))
throw new InvalidOperationException("Mods directory not found - check GameDirectory/ModsDirectory in App.config.");
Expand Down
28 changes: 24 additions & 4 deletions WitcherScriptMerger.Core/Paths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,32 @@ public static string GetRelativePath(string fullPath, string basePath)
// system; a null MergeEngine here reads as "dependency missing" rather than
// "not initialized yet", which could be a confusing message if that
// invariant is ever broken by a future entry point.
public static bool ValidateDependencyPaths()
// Split out from ValidateDependencyPaths (below) so a host that only supports
// flat-file (.ws/.xml) conflicts - WitcherScriptMerger.Headless, the Linux-capable
// CLI/MCP-only host, which has no QuickBMS/wcc_lite bundled at all (see its
// CLAUDE.md section and docs/decisions/bundle-format-replacement-spike.md) - can
// gate merging on just the text-merge engine, without also requiring bundle
// tooling it deliberately doesn't ship. Bundle-category conflicts still fail
// gracefully per-conflict when attempted without QuickBMS/wcc_lite (see
// QuickBms.IsAvailable's callers and FileMerger.GetUnpackedFiles) - this split
// doesn't change that, it only changes what gates a *scan/merge run starting at
// all*.
public static bool ValidateTextMergeDependencies()
{
return AppState.MergeEngine != null && AppState.MergeEngine.ValidateExePath();
}

// See ValidateTextMergeDependencies above for why this is separate.
public static bool ValidateBundleDependencies()
{
return (AppState.MergeEngine != null && AppState.MergeEngine.ValidateExePath() &&
File.Exists(QuickBms.ExePath) &&
return File.Exists(QuickBms.ExePath) &&
File.Exists(QuickBms.PluginPath) &&
File.Exists(WccLite.ExePath));
File.Exists(WccLite.ExePath);
}

public static bool ValidateDependencyPaths()
{
return ValidateTextMergeDependencies() && ValidateBundleDependencies();
}

public static bool ValidateModsDirectory()
Expand Down
22 changes: 20 additions & 2 deletions WitcherScriptMerger.Core/Tools/QuickBms.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
Expand All @@ -10,6 +11,15 @@ public static class QuickBms
public static string ExePath = AppState.Settings.Get("QuickBmsPath");
public static string PluginPath = AppState.Settings.Get("QuickBmsPluginPath");

// Whether QuickBMS itself (exe + plugin) can be found at all, independent of any
// specific bundle file - lets a caller that's about to scan many bundles (e.g.
// ModFileIndex.BuildAsync) check once up front instead of hitting
// ValidateResources' per-bundle "Can't find QuickBMS..." message once per bundle.
// Added for WitcherScriptMerger.Headless, the Linux-capable CLI/MCP-only host,
// which has no bundled QuickBMS/wcc_lite at all - see its CLAUDE.md section and
// docs/decisions/bundle-format-replacement-spike.md.
public static bool IsAvailable => File.Exists(ExePath) && File.Exists(PluginPath);

public static int UnpackFile(string bundlePath, string contentRelativePath, string outputDir)
{
if (!ValidateResources(bundlePath))
Expand Down Expand Up @@ -42,10 +52,18 @@ public static int UnpackFile(string bundlePath, string contentRelativePath, stri
}
}

// Returns Array.Empty<string> (never null) when the bundle or QuickBMS itself
// can't be found: callers (ModFileIndex.BuildAsync, FileMerger.GetUnpackedFiles)
// enumerate the result directly, and a null here used to be a real NullReferenceException
// hazard reachable as soon as a caller stopped gating scans behind
// Paths.ValidateDependencyPaths() first - which WitcherScriptMerger.Headless does
// deliberately, so flat-file-only merging still works without QuickBMS/wcc_lite
// configured. ValidateResources already reports a clear error for why. Flagged in
// code review, see CLAUDE.md.
public static string[] GetBundleContentPaths(string bundlePath)
{
if (!ValidateResources(bundlePath))
return null;
return Array.Empty<string>();

var contentPaths = new List<string>();

Expand Down
50 changes: 50 additions & 0 deletions WitcherScriptMerger.Headless/App.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8" ?>

<!-- ABOUT THE SETTINGS
GameDirectory The Witcher 3 Wild Hunt folder
VanillaScriptsDirectory Where to look for vanilla scripts (default if blank: \GameDirectory\content\content0\scripts)
ModsDirectory Where to look for mod folders (default if blank: \GameDirectory\mods)

CheckScripts Whether to check for mod conflicts in .ws script files
CheckXmlFiles Whether to check for mod conflicts in .xml files
CheckBundleContents Whether to check for mod conflicts in bundle file contents. Defaults to
false here (unlike the WinForms host): this host has no QuickBMS/wcc_lite
bundled at all (see CLAUDE.md), so bundle-content conflicts can never be
merged - leaving this on would only add scan overhead and per-scan
warning noise for conflicts that can never be resolved anyway. Bundle
conflicts still fail gracefully (a clear message, then skipped) rather
than crashing if this is turned on regardless.
IgnoreModNames Which mod folders to ignore (separated by commas)

MergedModName Which mod folder to save merges in (should be 1st alphabetically, so the game loads it before others)

QuickBmsPath Where quickbms.exe is located - Windows-only, has no effect unless this
host is actually run on Windows with QuickBMS sourced separately (see
CLAUDE.md's External tool dependencies). Bundle-content conflicts remain
unsupported on Linux regardless of this setting.
QuickBmsPluginPath Where the witcher3.bms plugin for QuickBMS is located - see QuickBmsPath.
WccLitePath Where wcc_lite.exe is located - see QuickBmsPath.

This host always uses the built-in DiffPlex text-merge engine (see CLAUDE.md's "Interactive vs.
headless split" section in the root CLAUDE.md) - there's no KDiff3Path/MergeEngine setting here,
since KDiff3MergeEngine needs Win32 P/Invoke that isn't available outside the WinForms host.
-->

<configuration>
<appSettings>
<add key="GameDirectory" value="" />
<add key="VanillaScriptsDirectory" value="" />
<add key="ModsDirectory" value="" />
<add key="CheckScripts" value="true" />
<add key="CheckXmlFiles" value="true" />
<add key="CheckBundleContents" value="false" />
<add key="IgnoreModNames" value="" />
<add key="MergedModName" value="mod0000_MergedFiles" />
<add key="QuickBmsPath" value="Tools\QuickBMS\quickbms.exe" />
<add key="QuickBmsPluginPath" value="Tools\QuickBMS\witcher3.bms" />
<add key="WccLitePath" value="Tools\wcc_lite\bin\x64\wcc_lite.exe" />
</appSettings>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" />
</startup>
</configuration>
Loading
Loading