11using System ;
22using System . Configuration ;
3+ using System . IO ;
4+ using System . Linq ;
35using System . Reflection ;
6+ using System . Xml . Linq ;
47
58namespace 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
0 commit comments