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
29 changes: 19 additions & 10 deletions CLAUDE.md

Large diffs are not rendered by default.

49 changes: 44 additions & 5 deletions WitcherScriptMerger.Core/AppState.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using WitcherScriptMerger.Inventory;
using System.Threading;
using WitcherScriptMerger.Inventory;
using WitcherScriptMerger.LoadOrder;
using WitcherScriptMerger.Tools;

Expand All @@ -19,16 +20,54 @@ namespace WitcherScriptMerger
// 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.
// Startup flow). Paths.cs used to have the same beforefieldinit hazard one hop
// further out (its own static field initializers read Settings.Get(...)
// eagerly) - fixed by making those Paths properties compute on every access
// instead of caching via a field initializer, so Settings' laziness isn't
// undermined transitively; see Paths.cs.
public static class AppState
{
// Defaults to the headless implementation so it's safe to use from the very
// first line of Main() - the GUI path swaps it out for MainForm once
// constructed. See CLAUDE.md's IMergeNotifier section.
public static IMergeNotifier Notifier = new HeadlessMergeNotifier();
public static AppSettings Settings = new AppSettings();

// Lazy rather than a field initializer: AppSettings' constructor calls
// Environment.Exit(1) if it can't find a config file next to the entry
// assembly (see AppSettings.cs) - appropriate for the real GUI/CLI/MCP entry
// points, where that's genuinely fatal, but not for WitcherScriptMerger.Tests,
// whose test host has no matching .config. Since C# runs ALL of a type's
// static field initializers together on first touch of ANY static member,
// Settings being a plain field-with-initializer meant merely reading
// AppState.Notifier (which Core code - e.g. DiffPlexMergeEngine's headless
// skip/guard messages - legitimately does on its own, unprompted by test code)
// silently also ran `new AppSettings()` and crashed the whole test process.
// Making Settings lazy decouples the two: touching Notifier alone no longer
// forces Settings to construct. Confirmed no call site assigns AppState.Settings
// or Program.Settings, so keeping this settable (for symmetry with the other
// fields here, and in case a future test wants to inject a stub) is a safe,
// behavior-preserving change for every existing GUI/CLI/MCP call site: first
// real access still runs the identical `new AppSettings()` and identical
// crash-on-missing-config behavior, just deferred to that first access instead
// of eagerly.
//
// LazyInitializer.EnsureInitialized (rather than the simpler
// `_settings ?? (_settings = new AppSettings())`) makes this thread-safe: the
// simpler form is a classic non-atomic check-then-act race that could, under
// concurrent first access, construct AppSettings() more than once (each with
// its own real side effects, including a possible Environment.Exit(1)).
// Currently unreachable from any shipped entry point or the test suite (all
// single-threaded at this point in startup) - flagged in code review as a
// latent risk anyway, since other Core statics (e.g. QuickBms.cs/WccLite.cs)
// also read AppState.Settings.Get(...) from their own static field
// initializers, and nothing prevents a future concurrent caller.
static AppSettings _settings;
public static AppSettings Settings
{
get => LazyInitializer.EnsureInitialized(ref _settings, () => new AppSettings());
set => _settings = value;
}

public static CustomLoadOrder LoadOrder = null;
public static MergeInventory Inventory = null;

Expand Down
6 changes: 5 additions & 1 deletion WitcherScriptMerger.Core/Inventory/FileMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,11 @@ void MergeBundleFileInteractive(InteractiveMergeRequest file, Merge merge, bool

FileInfo MergeTextInteractive(Merge merge, MergeSource source1, MergeSource source2)
{
ProgressInfo.CurrentAction = $"Merging {source1.Name} && {source2.Name} — waiting for KDiff3 to close";
// Deliberately engine-neutral wording: this used to name KDiff3 explicitly
// ("waiting for KDiff3 to close"), which is wrong when MergeEngine is
// DiffPlexMergeEngine instead - no external process or window is involved
// there at all. Flagged in code review, see CLAUDE.md.
ProgressInfo.CurrentAction = $"Merging {source1.Name} && {source2.Name}";

var result = MergeEngine.Merge(source1, source2, _vanillaFile, _outputPath);

Expand Down
44 changes: 36 additions & 8 deletions WitcherScriptMerger.Core/Paths.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ public static class Paths
public const string TempBundleContent = "tempbundlecontent";
public static string MergedBundleContent = "Merged Bundle Content";
public static string MergedBundleContentAbsolute = Path.Combine(Environment.CurrentDirectory, MergedBundleContent);

// A dedicated top-level directory for DiffPlexMergeEngine's conflict-marker
// sidecar files (Tools/DiffPlexMergeEngine.cs::GetConflictMarkerPath) -
// deliberately NOT a subdirectory of TempBundleContent, even though both are
// "scratch-ish" locations conceptually: FileMerger.CleanUpTempFiles() deletes
// the entire TempBundleContent tree wholesale at the end of every headless
// merge run (to clear QuickBMS-unpacked bundle scratch content), which would
// otherwise delete every sidecar moments after DiffPlexMergeEngine wrote it -
// confirmed by direct observation running the real CLI end-to-end: the sidecar
// briefly existed during the run (the "conflict markers written to..." message
// printed a real path) but was gone by the time the process exited. A separate,
// unrelated top-level name sidesteps that collision entirely while keeping the
// same original benefits (out of the live Paths.ModsDirectory tree, out of
// Paths.MergedBundleContent's wholesale-packed tree - see DiffPlexMergeEngine's
// own comment on GetConflictMarkerPath for those two reasons).
public const string DiffPlexConflictsDirectory = "DiffPlexConflicts";
public const string Inventory = "MergeInventory.xml";
public static string ModScriptBase = Path.Combine("content", "scripts");
public static string VanillaScriptBase = Path.Combine("content", "content0", "scripts");
Expand All @@ -22,31 +38,43 @@ public static class Paths

public static string DlcDirectory => Path.Combine(GameDirectory, "DLC");

static string _scriptsDirSetting = AppState.Settings.Get("VanillaScriptsDirectory");
// Deliberately not cached in a static field (as these two used to be): a field
// initializer here would run alongside every other static field initializer of
// this type on first touch of ANY of them (C#'s beforefieldinit semantics),
// which would eagerly call AppState.Settings.Get(...) - forcing
// AppState.Settings to construct (see its own lazy-property comment in
// AppState.cs) merely from touching an unrelated static member of Paths, e.g. a
// plain string helper like GetRelativePath with no settings dependency at all.
// That's exactly the crash-in-a-dotnet-test-host scenario AppState.Settings'
// laziness exists to avoid, one hop removed - flagged in code review, see
// CLAUDE.md. AppState.Settings.Get(...) already reads from AppSettings' own
// cached ConfigurationManager state, so re-reading it on every call here (rather
// than caching again at this layer) costs nothing meaningful.
public static string ScriptsDirectory
{
get
{
return (!string.IsNullOrWhiteSpace(_scriptsDirSetting)
? _scriptsDirSetting
var setting = AppState.Settings.Get("VanillaScriptsDirectory");
return (!string.IsNullOrWhiteSpace(setting)
? setting
: Path.Combine(GameDirectory, VanillaScriptBase));
}
}

static string _modsDirSetting = AppState.Settings.Get("ModsDirectory");
public static string ModsDirectory
{
get
{
return (!string.IsNullOrWhiteSpace(_modsDirSetting)
? _modsDirSetting
var setting = AppState.Settings.Get("ModsDirectory");
return (!string.IsNullOrWhiteSpace(setting)
? setting
: Path.Combine(GameDirectory, "Mods"));
}
}

public static bool IsScriptsDirectoryDerived => string.IsNullOrWhiteSpace(_scriptsDirSetting);
public static bool IsScriptsDirectoryDerived => string.IsNullOrWhiteSpace(AppState.Settings.Get("VanillaScriptsDirectory"));

public static bool IsModsDirectoryDerived => string.IsNullOrWhiteSpace(_modsDirSetting);
public static bool IsModsDirectoryDerived => string.IsNullOrWhiteSpace(AppState.Settings.Get("ModsDirectory"));

public static string GetRelativePath(string fullPath, string basePath)
{
Expand Down
Loading
Loading