Skip to content

Commit 8c726af

Browse files
Merge pull request #40 from TheValiantOne/feature/vortex-exe-config-interop
Read and emit the WitcherScriptMerger.exe.config Vortex configures
2 parents a8b4c52 + 19756db commit 8c726af

6 files changed

Lines changed: 279 additions & 4 deletions

File tree

WitcherScriptMerger.Core/AppSettings.cs

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
using System;
22
using System.Configuration;
3+
using System.IO;
4+
using System.Linq;
35
using System.Reflection;
6+
using System.Xml.Linq;
47

58
namespace WitcherScriptMerger
69
{
@@ -52,8 +55,9 @@ public static string GetEnvironmentOverride(string key)
5255
}
5356

5457
// Resolves a key's raw string value: an environment-variable override first, then
55-
// falling through to the existing ConfigurationManager-backed lookup. Both Get and
56-
// Get<T> route through this single place so an env-var-sourced value goes through
58+
// the existing ConfigurationManager-backed lookup, then - only when that yields
59+
// nothing usable - the Vortex-managed sidecar (see VortexSidecarFileName). Both Get
60+
// and Get<T> route through this single place so a value from any source goes through
5761
// the exact same downstream handling (Get<T>'s Parse-based conversion in particular)
5862
// as one read from App.config - never a separate ad-hoc parser.
5963
string GetRawValue(string key)
@@ -63,12 +67,99 @@ string GetRawValue(string key)
6367
return envValue;
6468

6569
if (CachedConfig.HasFile)
66-
return CachedConfig.AppSettings.Settings[key].Value;
70+
{
71+
// Null-conditional, not a bare .Value: Settings[key] returns null for a key
72+
// that isn't in App.config at all, which used to throw here and get swallowed
73+
// by Get/Get<T>'s catch. Returning null instead is observably identical for
74+
// both of those (empty string / default(T)), and it lets a key that exists
75+
// ONLY in the sidecar still be found below rather than dying first.
76+
var value = CachedConfig.AppSettings.Settings[key]?.Value;
77+
if (!string.IsNullOrWhiteSpace(value))
78+
return value;
79+
80+
// Blank in our own config. For the path settings that ship blank on purpose
81+
// (GameDirectory/ModsDirectory/VanillaScriptsDirectory - blank means "derive
82+
// from the working directory"), a Vortex-written sidecar value is strictly
83+
// better information than deriving, so prefer it when there is one.
84+
return ReadVortexSidecarSetting(key) ?? value;
85+
}
6786

6887
AppState.Notifier.ShowError($"Config file doesn't exist:\n\n{CachedConfig.FilePath}");
6988
return null;
7089
}
7190

91+
// Vortex's bundled game-witcher3 extension reads AND writes a script-merger config
92+
// at "<merger dir>\WitcherScriptMerger.exe.config" - the .NET Framework naming
93+
// convention this project used before the .NET 10 modernization. It parses that file
94+
// for MergedModName (scriptmerger.ts::getMergedModName) and writes GameDirectory,
95+
// VanillaScriptsDirectory and ModsDirectory into it (scriptmerger.ts::setMergerConfig)
96+
// when it configures a merger install. A modern .NET app's own configuration is
97+
// "<assembly>.dll.config" instead, so without this the two never meet: Vortex writes
98+
// a file WSM never reads, and the user "configures WSM through Vortex" with no
99+
// effect at all.
100+
//
101+
// Reading it as a *fallback* rather than an override is deliberate. A non-blank
102+
// value in our own config is an explicit choice (the GUI's own settings screen
103+
// writes there via Set/Save, and Vortex never writes MergedModName), so it must
104+
// win; the sidecar only fills in what we'd otherwise have to guess. Env overrides
105+
// still beat both, unchanged.
106+
public const string VortexSidecarFileName = "WitcherScriptMerger.exe.config";
107+
108+
string _sidecarPath;
109+
bool _sidecarChecked;
110+
string _sidecarXml;
111+
112+
string ReadVortexSidecarSetting(string key)
113+
{
114+
if (!_sidecarChecked)
115+
{
116+
_sidecarChecked = true;
117+
try
118+
{
119+
_sidecarPath = Path.Combine(Path.GetDirectoryName(_assemblyPath) ?? string.Empty, VortexSidecarFileName);
120+
if (File.Exists(_sidecarPath))
121+
_sidecarXml = File.ReadAllText(_sidecarPath);
122+
}
123+
catch
124+
{
125+
// Unreadable/inaccessible sidecar is not an error - it's an optional
126+
// interop file that usually isn't there at all. Never prompt, never
127+
// throw: this runs inside every settings read, including on scan paths.
128+
_sidecarXml = null;
129+
}
130+
}
131+
132+
return _sidecarXml == null ? null : ParseAppSettingValue(_sidecarXml, key);
133+
}
134+
135+
// Split out as a pure string-in/string-out function so the sidecar parsing is
136+
// directly unit-testable without a filesystem, a live AppSettings instance, or
137+
// AppState - see WitcherScriptMerger.Tests/CLAUDE.md's "AppState.Settings-safety
138+
// constraints". Returns null for anything it can't confidently read (malformed XML,
139+
// missing key, blank value), so every caller falls through to its existing
140+
// behavior rather than acting on a half-parsed file.
141+
public static string ParseAppSettingValue(string xml, string key)
142+
{
143+
if (string.IsNullOrWhiteSpace(xml) || string.IsNullOrWhiteSpace(key))
144+
return null;
145+
146+
try
147+
{
148+
var value = XDocument.Parse(xml)
149+
.Root?.Elements("appSettings")
150+
.Elements("add")
151+
.Where(e => string.Equals((string)e.Attribute("key"), key, StringComparison.Ordinal))
152+
.Select(e => (string)e.Attribute("value"))
153+
.FirstOrDefault();
154+
155+
return string.IsNullOrWhiteSpace(value) ? null : value;
156+
}
157+
catch
158+
{
159+
return null;
160+
}
161+
}
162+
72163
// Deliberately unaware of GetEnvironmentOverride: this still only ever writes to
73164
// CachedConfig/App.config, same as before the env-var override existed. If a
74165
// WSM_<key> override is active for a key this call targets, Get/Get<T> keep

WitcherScriptMerger.Core/CLAUDE.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,3 +602,43 @@ the synthetic-edge-cases + real-recorded-hash cross-check pattern described in
602602
- Merge history: `MergeInventory.xml`, via `XmlSerializer` (`Inventory/MergeInventory.cs`).
603603
- Game load order: `LoadOrder/CustomLoadOrder.cs` reads the game's own `mods.settings`
604604
file.
605+
606+
### Vortex-managed sidecar config (`WitcherScriptMerger.exe.config`)
607+
608+
`GetRawValue` resolves a key in three steps, first non-blank wins:
609+
610+
1. `WSM_<key>` environment variable (`GetEnvironmentOverride`).
611+
2. Our own config — `<AssemblyName>.dll.config`, via `ConfigurationManager`.
612+
3. **The Vortex-managed sidecar**, `AppSettings.VortexSidecarFileName`
613+
(`WitcherScriptMerger.exe.config`) beside the entry assembly.
614+
615+
Step 3 exists because Vortex's bundled `game-witcher3` extension both **reads and writes**
616+
a script-merger config under the .NET Framework `<exe>.exe.config` name this project
617+
stopped using at the .NET 10 modernization. It parses that file for `MergedModName`
618+
(`scriptmerger.ts::getMergedModName`) and writes `GameDirectory`,
619+
`VanillaScriptsDirectory` and `ModsDirectory` into it when configuring a merger install
620+
(`scriptmerger.ts::setMergerConfig`). Without this fallback the two never meet: Vortex
621+
writes a file WSM never reads, so a user who "configures WSM through Vortex" changes
622+
nothing at all — and Vortex logs `failed to ascertain merged mod name - using
623+
"mod0000_MergedFiles"` and silently falls back to a hardcoded guess.
624+
625+
**A fallback, not an override**, deliberately: a non-blank value in our own config is an
626+
explicit choice (the GUI's settings screen writes there via `Set`/`Save`, and Vortex never
627+
writes `MergedModName`), so it must win. The sidecar only fills in what we would otherwise
628+
have to derive — which is exactly the shape of the three keys Vortex writes, since
629+
`GameDirectory`/`ModsDirectory`/`VanillaScriptsDirectory` all ship blank meaning "derive
630+
from the working directory". Env overrides still beat both.
631+
632+
`ParseAppSettingValue(xml, key)` is a pure static over the file's text — no filesystem, no
633+
`AppState` — so it's directly unit-testable (`AppSettingsTests`); it returns `null` for
634+
anything it can't confidently read (malformed XML, missing key, blank value) so every
635+
caller falls through to existing behavior instead of acting on a half-parsed file. The
636+
read is cached after the first attempt and never throws or prompts: it runs inside every
637+
settings read, including scan paths where an exception would surface as a merge failure.
638+
`Settings[key]` is dereferenced with `?.` so a key present *only* in the sidecar still
639+
resolves rather than throwing first.
640+
641+
The WinForms host's csproj emits this file at build and publish (never overwriting an
642+
existing one — Vortex owns it once written); see `WitcherScriptMerger/CLAUDE.md`.
643+
`WitcherScriptMerger.Headless` deliberately does not, since Vortex's extension only ever
644+
looks for a merger named `WitcherScriptMerger.exe`.

WitcherScriptMerger.Tests/AppSettingsTests.cs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,5 +149,96 @@ static void WithEnvironmentVariable(string key, string value, Action action)
149149
Environment.SetEnvironmentVariable(envVarName, originalValue);
150150
}
151151
}
152+
153+
#region Vortex sidecar config (WitcherScriptMerger.exe.config)
154+
155+
// Coverage for AppSettings.ParseAppSettingValue, the parser behind the
156+
// Vortex-managed sidecar GetRawValue falls back to when our own config leaves a key
157+
// blank - see AppSettings.cs's own comment on VortexSidecarFileName for why that
158+
// file exists (Vortex's bundled game-witcher3 extension reads MergedModName from it
159+
// and writes GameDirectory/VanillaScriptsDirectory/ModsDirectory into it, under the
160+
// .NET Framework "<exe>.exe.config" name a modern .NET app doesn't use).
161+
//
162+
// Exercised as a pure static over an XML string: no filesystem, no AppSettings
163+
// instance, no AppState - see WitcherScriptMerger.Tests/CLAUDE.md's
164+
// "AppState.Settings-safety constraints".
165+
const string SidecarXml = @"<?xml version=""1.0"" encoding=""utf-8""?>
166+
<configuration>
167+
<appSettings>
168+
<add key=""GameDirectory"" value=""G:\Games\Witcher3"" />
169+
<add key=""ModsDirectory"" value=""G:\Games\Witcher3\mods"" />
170+
<add key=""MergedModName"" value=""mod0000_MergedFiles"" />
171+
<add key=""BlankOne"" value="""" />
172+
</appSettings>
173+
</configuration>";
174+
175+
[Theory]
176+
[InlineData("GameDirectory", @"G:\Games\Witcher3")]
177+
[InlineData("ModsDirectory", @"G:\Games\Witcher3\mods")]
178+
[InlineData("MergedModName", "mod0000_MergedFiles")]
179+
public void ParseAppSettingValue_KeyPresent_ReturnsItsValue(string key, string expected)
180+
{
181+
Assert.Equal(expected, AppSettings.ParseAppSettingValue(SidecarXml, key));
182+
}
183+
184+
// Null, never string.Empty, for anything unusable - GetRawValue's `?? value` fallback
185+
// relies on that to fall through to its own (blank) config value rather than
186+
// treating a blank sidecar entry as an answer.
187+
[Theory]
188+
[InlineData("NotInTheFile")]
189+
[InlineData("BlankOne")]
190+
public void ParseAppSettingValue_MissingOrBlankValue_ReturnsNull(string key)
191+
{
192+
Assert.Null(AppSettings.ParseAppSettingValue(SidecarXml, key));
193+
}
194+
195+
// Matching is case-sensitive, matching ConfigurationManager's own <appSettings>
196+
// behavior - "gamedirectory" must not resolve "GameDirectory".
197+
[Fact]
198+
public void ParseAppSettingValue_KeyCaseDiffers_ReturnsNull()
199+
{
200+
Assert.Null(AppSettings.ParseAppSettingValue(SidecarXml, "gamedirectory"));
201+
}
202+
203+
// A malformed/truncated sidecar (Vortex interrupted mid-write, say) must degrade to
204+
// "no answer" rather than throwing: this parser runs inside every settings read,
205+
// including on scan paths where an exception would surface as a merge failure.
206+
[Theory]
207+
[InlineData("<configuration><appSettings><add key=\"GameDirectory\" value=\"x\" />")]
208+
[InlineData("not xml at all")]
209+
[InlineData("<configuration />")]
210+
[InlineData("<configuration><appSettings /></configuration>")]
211+
public void ParseAppSettingValue_MalformedOrEmptyXml_ReturnsNullWithoutThrowing(string xml)
212+
{
213+
Assert.Null(AppSettings.ParseAppSettingValue(xml, "GameDirectory"));
214+
}
215+
216+
[Theory]
217+
[InlineData(null)]
218+
[InlineData("")]
219+
[InlineData(" ")]
220+
public void ParseAppSettingValue_NoXml_ReturnsNull(string xml)
221+
{
222+
Assert.Null(AppSettings.ParseAppSettingValue(xml, "GameDirectory"));
223+
}
224+
225+
[Theory]
226+
[InlineData(null)]
227+
[InlineData("")]
228+
[InlineData(" ")]
229+
public void ParseAppSettingValue_NoKey_ReturnsNull(string key)
230+
{
231+
Assert.Null(AppSettings.ParseAppSettingValue(SidecarXml, key));
232+
}
233+
234+
// The file name is the interop contract with Vortex's hardcoded
235+
// scriptmerger.ts::MERGER_CONFIG_FILE - it is not ours to rename.
236+
[Fact]
237+
public void VortexSidecarFileName_MatchesTheNameVortexLooksFor()
238+
{
239+
Assert.Equal("WitcherScriptMerger.exe.config", AppSettings.VortexSidecarFileName);
240+
}
241+
242+
#endregion
152243
}
153244
}

WitcherScriptMerger.Tests/CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ does.
6464
`DiffPlexMergeEngine` against a real `KDiff3.exe` binary, when a developer happens to
6565
have one locally (WSM no longer bundles or requires KDiff3 itself — see
6666
`docs/decisions/kdiff3-retirement.md`).
67+
- `AppSettingsTests.cs``AppSettings`'s `WSM_<key>` environment-variable override, and
68+
(added alongside the Vortex sidecar interop) `AppSettings.ParseAppSettingValue`: the pure
69+
parser behind the `WitcherScriptMerger.exe.config` fallback Vortex's bundled
70+
`game-witcher3` extension reads and writes. Covers key lookup, case-sensitivity, a blank
71+
value and a missing key both yielding `null` (which is what makes `GetRawValue`'s `??`
72+
fall through correctly), malformed/truncated/empty XML degrading to `null` rather than
73+
throwing, null/blank inputs, and that `VortexSidecarFileName` still matches the name
74+
Vortex hardcodes — see Core's `CLAUDE.md`'s "Vortex-managed sidecar config" section.
6775
- `LiveInstall.cs` — see "Live-install cross-check tests" below.
6876

6977
## `AppState.Settings`-safety constraints

WitcherScriptMerger/CLAUDE.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,17 @@ merges via `InteractiveMergeRunner`, and wires up async callbacks.
3737
The publish's `<AssemblyName>.dll.config` (the `App.config` copy
3838
`System.Configuration.ConfigurationManager` actually reads, via Core's
3939
`AppSettings.cs`) lands next to the executable — copy it there if deploying the exe on
40-
its own. This resolves correctly even in a single-file publish despite
40+
its own. A second copy, `WitcherScriptMerger.exe.config`, lands beside it via the
41+
`EmitVortexCompatConfigForBuild`/`EmitVortexCompatConfigForPublish` targets in this
42+
project's csproj — that's the .NET Framework name Vortex's bundled `game-witcher3`
43+
extension hardcodes and both reads and writes (see Core's `CLAUDE.md`'s "Vortex-managed
44+
sidecar config"). It is only written when absent: Vortex owns that file once it has
45+
configured a merger install, and clobbering it on every rebuild would throw away the
46+
paths it wrote. Two separate targets rather than one with
47+
`AfterTargets="Build;Publish"` because the SDK defines `$(PublishDir)`
48+
unconditionally (defaulting to `$(OutDir)publish\`), so a single target choosing
49+
"PublishDir if set, else OutDir" silently wrote to the publish folder during an ordinary
50+
build and left the build output without the file. This resolves correctly even in a single-file publish despite
4151
`Assembly.GetEntryAssembly().Location` being documented (and confirmed via a real
4252
build's `IL3000` warning) to always return `""` for a single-file-bundled assembly —
4353
`ConfigurationManager.OpenExeConfiguration("")` still finds and reads the real

WitcherScriptMerger/WitcherScriptMerger.csproj

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,39 @@
2828
<ProjectReference Include="..\WitcherScriptMerger.Core\WitcherScriptMerger.Core.csproj" />
2929
</ItemGroup>
3030

31+
<!--
32+
Also emit the config under the .NET Framework name ("WitcherScriptMerger.exe.config")
33+
alongside the SDK's own "WitcherScriptMerger.dll.config". Vortex's bundled
34+
game-witcher3 extension hardcodes the .exe.config name (scriptmerger.ts's
35+
MERGER_CONFIG_FILE): it parses that file for MergedModName, and writes GameDirectory /
36+
VanillaScriptsDirectory / ModsDirectory into it when configuring a merger install.
37+
Without this file it can do neither, and logs "failed to ascertain merged mod name -
38+
using mod0000_MergedFiles", silently falling back to a hardcoded guess.
39+
40+
Only written when it doesn't already exist: Vortex *owns* this file once it has written
41+
to it, and clobbering it on every rebuild would throw away the paths it configured.
42+
AppSettings.ReadVortexSidecarSetting is the reading half of this interop - see its
43+
comment for the precedence rules.
44+
45+
Headless deliberately doesn't do this: Vortex's extension only ever looks for a merger
46+
named WitcherScriptMerger.exe, so a WitcherScriptMerger.Headless.exe.config would be
47+
read by nothing.
48+
49+
Two targets rather than one with AfterTargets="Build;Publish": the SDK defines
50+
$(PublishDir) unconditionally (it defaults to "$(OutDir)publish\"), so a single target
51+
picking "PublishDir if set, else OutDir" silently wrote to the publish folder during an
52+
ordinary build and left the build output without the file.
53+
-->
54+
<Target Name="EmitVortexCompatConfigForBuild" AfterTargets="Build">
55+
<Copy SourceFiles="App.config"
56+
DestinationFiles="$(OutDir)$(AssemblyName).exe.config"
57+
Condition="!Exists('$(OutDir)$(AssemblyName).exe.config')" />
58+
</Target>
59+
60+
<Target Name="EmitVortexCompatConfigForPublish" AfterTargets="Publish">
61+
<Copy SourceFiles="App.config"
62+
DestinationFiles="$(PublishDir)$(AssemblyName).exe.config"
63+
Condition="!Exists('$(PublishDir)$(AssemblyName).exe.config')" />
64+
</Target>
65+
3166
</Project>

0 commit comments

Comments
 (0)