zeroed-some/wanda / e03a3fe

Browse files

launcher: standalone mode, batch coordination, Steam library setup

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e03a3fe93ce62d72f1ed6598cb8798760a11a044
Parents
02ba0b5
Tree
7015b78

1 changed file

StatusFile+-
M crates/wanda-core/src/launcher.rs 684 89
crates/wanda-core/src/launcher.rsmodified
@@ -4,17 +4,46 @@
44
 //!
55
 //! Key insight: WeMod and the game MUST run in the same Wine prefix
66
 //! for WeMod to be able to hook into the game process. We achieve this by
7
-//! running both from wanda's prefix (which has .NET and WeMod installed).
7
+//! running both in a single Proton session using a batch file coordinator.
88
 
99
 use crate::error::{Result, WandaError};
1010
 use crate::prefix::WandaPrefix;
1111
 use crate::steam::{ProtonVersion, SteamApp, SteamInstallation};
1212
 use std::collections::HashMap;
13
+use std::fs::OpenOptions;
14
+use std::io::Write;
1315
 use std::path::PathBuf;
1416
 use std::process::Stdio;
1517
 use std::time::Duration;
1618
 use tokio::process::{Child, Command};
17
-use tracing::info;
19
+use tracing::{debug, info};
20
+
21
+/// Get the log file path
22
+fn log_file_path() -> PathBuf {
23
+    dirs::data_local_dir()
24
+        .unwrap_or_else(|| PathBuf::from("."))
25
+        .join("wanda")
26
+        .join("wanda.log")
27
+}
28
+
29
+/// Log a message to file
30
+fn log_to_file(msg: &str) {
31
+    let log_path = log_file_path();
32
+    if let Some(parent) = log_path.parent() {
33
+        let _ = std::fs::create_dir_all(parent);
34
+    }
35
+    if let Ok(mut file) = OpenOptions::new()
36
+        .create(true)
37
+        .append(true)
38
+        .open(&log_path)
39
+    {
40
+        let timestamp = std::time::SystemTime::now()
41
+            .duration_since(std::time::UNIX_EPOCH)
42
+            .map(|d| d.as_secs())
43
+            .unwrap_or(0);
44
+        let _ = writeln!(file, "[{}] {}", timestamp, msg);
45
+    }
46
+}
1847
 
1948
 /// Configuration for launching a game
2049
 #[derive(Debug, Clone)]
@@ -29,6 +58,8 @@ pub struct LaunchConfig {
2958
     pub extra_env: HashMap<String, String>,
3059
     /// Delay between starting WeMod and the game (in seconds)
3160
     pub wemod_delay: u64,
61
+    /// Standalone mode: launch WeMod only, user starts game from WeMod UI
62
+    pub standalone: bool,
3263
 }
3364
 
3465
 impl Default for LaunchConfig {
@@ -39,14 +70,15 @@ impl Default for LaunchConfig {
3970
             extra_args: Vec::new(),
4071
             extra_env: HashMap::new(),
4172
             wemod_delay: 3,
73
+            standalone: false,
4274
         }
4375
     }
4476
 }
4577
 
4678
 /// Handle to a launched game session
4779
 pub struct LaunchHandle {
48
-    /// WeMod process (if launched with WeMod)
49
-    wemod_process: Option<Child>,
80
+    /// The main process (proton running the batch file)
81
+    process: Option<Child>,
5082
     /// Game App ID
5183
     pub app_id: u32,
5284
     /// Start time
@@ -56,8 +88,8 @@ pub struct LaunchHandle {
5688
 impl LaunchHandle {
5789
     /// Check if the session is still running
5890
     pub fn is_running(&mut self) -> bool {
59
-        if let Some(ref mut wemod) = self.wemod_process {
60
-            match wemod.try_wait() {
91
+        if let Some(ref mut proc) = self.process {
92
+            match proc.try_wait() {
6193
                 Ok(Some(_)) => return false,
6294
                 Ok(None) => return true,
6395
                 Err(_) => return false,
@@ -73,16 +105,16 @@ impl LaunchHandle {
73105
 
74106
     /// Terminate the session
75107
     pub async fn terminate(&mut self) -> Result<()> {
76
-        if let Some(ref mut wemod) = self.wemod_process {
77
-            let _ = wemod.kill().await;
108
+        if let Some(ref mut proc) = self.process {
109
+            let _ = proc.kill().await;
78110
         }
79111
         Ok(())
80112
     }
81113
 
82
-    /// Wait for WeMod to exit
114
+    /// Wait for the session to exit
83115
     pub async fn wait(&mut self) -> Result<()> {
84
-        if let Some(ref mut wemod) = self.wemod_process {
85
-            let _ = wemod.wait().await;
116
+        if let Some(ref mut proc) = self.process {
117
+            let _ = proc.wait().await;
86118
         }
87119
         Ok(())
88120
     }
@@ -106,13 +138,10 @@ impl<'a> GameLauncher<'a> {
106138
     }
107139
 
108140
     /// Build environment variables for launching
109
-    fn build_env(&self) -> HashMap<String, String> {
141
+    fn build_env(&self, app_id: Option<u32>) -> HashMap<String, String> {
110142
         let mut env = HashMap::new();
111143
 
112
-        env.insert(
113
-            "WINEPREFIX".to_string(),
114
-            self.prefix.pfx_path().to_string_lossy().to_string(),
115
-        );
144
+        // Primary Proton/Wine environment
116145
         env.insert(
117146
             "STEAM_COMPAT_DATA_PATH".to_string(),
118147
             self.prefix.path.to_string_lossy().to_string(),
@@ -121,116 +150,634 @@ impl<'a> GameLauncher<'a> {
121150
             "STEAM_COMPAT_CLIENT_INSTALL_PATH".to_string(),
122151
             self.steam.root_path.to_string_lossy().to_string(),
123152
         );
153
+
154
+        // Add Steam app ID for proper Steam API initialization
155
+        if let Some(id) = app_id {
156
+            env.insert("SteamAppId".to_string(), id.to_string());
157
+            env.insert("SteamGameId".to_string(), id.to_string());
158
+            env.insert("STEAM_COMPAT_APP_ID".to_string(), id.to_string());
159
+        }
160
+
161
+        // Pass display variables for GUI apps (Wayland/X11)
162
+        if let Ok(disp) = std::env::var("DISPLAY") {
163
+            debug!("Passing DISPLAY={}", disp);
164
+            env.insert("DISPLAY".to_string(), disp);
165
+        }
166
+        if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") {
167
+            debug!("Passing WAYLAND_DISPLAY={}", wayland);
168
+            env.insert("WAYLAND_DISPLAY".to_string(), wayland);
169
+        }
170
+        if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
171
+            debug!("Passing XDG_RUNTIME_DIR={}", xdg);
172
+            env.insert("XDG_RUNTIME_DIR".to_string(), xdg);
173
+        }
174
+        // Pass XAUTHORITY for X11 authentication
175
+        if let Ok(xauth) = std::env::var("XAUTHORITY") {
176
+            debug!("Passing XAUTHORITY={}", xauth);
177
+            env.insert("XAUTHORITY".to_string(), xauth);
178
+        }
179
+        // Disable NTSync to avoid "Maximum number of clients reached" errors
180
+        env.insert("WINEESYNC".to_string(), "0".to_string());
181
+        env.insert("WINEFSYNC".to_string(), "0".to_string());
124182
         env.insert("PROTON_NO_ESYNC".to_string(), "1".to_string());
125183
         env.insert("PROTON_NO_FSYNC".to_string(), "1".to_string());
126
-        env.insert("WINEDEBUG".to_string(), "-all".to_string());
184
+
185
+        // Proton library paths for proper dependency resolution
186
+        let proton_lib64 = self.proton.path.join("files/lib64");
187
+        let proton_lib = self.proton.path.join("files/lib");
188
+        if proton_lib64.exists() || proton_lib.exists() {
189
+            let mut ld_path = String::new();
190
+            if proton_lib64.exists() {
191
+                ld_path.push_str(&proton_lib64.to_string_lossy());
192
+            }
193
+            if proton_lib.exists() {
194
+                if !ld_path.is_empty() {
195
+                    ld_path.push(':');
196
+                }
197
+                ld_path.push_str(&proton_lib.to_string_lossy());
198
+            }
199
+            if let Ok(existing) = std::env::var("LD_LIBRARY_PATH") {
200
+                ld_path.push(':');
201
+                ld_path.push_str(&existing);
202
+            }
203
+            env.insert("LD_LIBRARY_PATH".to_string(), ld_path);
204
+        }
127205
 
128206
         env
129207
     }
130208
 
131
-    /// Get the Wine executable path
132
-    fn wine_exe(&self) -> PathBuf {
133
-        let proton_wine = self.proton.wine_exe();
134
-        if proton_wine.exists() {
135
-            proton_wine
136
-        } else {
137
-            PathBuf::from("wine")
209
+    /// Convert a Linux path to a Wine path (Z: drive)
210
+    /// Uses forward slashes which Wine accepts and avoids bash escaping issues
211
+    fn to_wine_path(path: &std::path::Path) -> String {
212
+        format!("Z:{}", path.to_string_lossy())
213
+    }
214
+
215
+    /// Convert a game executable's Linux path to the corresponding C: drive
216
+    /// path through the Steam library symlink inside the prefix.
217
+    ///
218
+    /// WeMod detects game processes by matching `QueryFullProcessImageName`
219
+    /// against the game's expected install directory. If we launch the game
220
+    /// via its `Z:\...` Linux path, the imagePath won't match WeMod's
221
+    /// `C:\Program Files (x86)\Steam\steamapps\common\...` expectation.
222
+    /// Launching through the symlinked C: path makes the paths match.
223
+    fn to_prefix_game_path(game: &SteamApp, game_exe: &std::path::Path) -> Option<String> {
224
+        let common_dir = game.library_path.join("steamapps/common");
225
+        let rel_path = game_exe.strip_prefix(&common_dir).ok()?;
226
+        Some(format!(
227
+            "C:\\Program Files (x86)\\Steam\\steamapps\\common\\{}",
228
+            rel_path.to_string_lossy().replace('/', "\\")
229
+        ))
230
+    }
231
+
232
+    /// Create a batch file that coordinates WeMod and game startup
233
+    fn create_launch_batch(
234
+        &self,
235
+        wemod_exe: &std::path::Path,
236
+        game: &SteamApp,
237
+        game_exe: &std::path::Path,
238
+        game_args: &[String],
239
+        delay_seconds: u64,
240
+    ) -> Result<PathBuf> {
241
+        let batch_path = self.prefix.path.join("wanda_launch.bat");
242
+
243
+        // For batch file internal paths, use backslashes (Windows convention)
244
+        let wemod_win_path = format!("Z:{}", wemod_exe.to_string_lossy().replace('/', "\\"));
245
+        // Use C: drive path through the Steam library symlink so that
246
+        // QueryFullProcessImageName returns a path WeMod recognizes
247
+        let game_win_path = Self::to_prefix_game_path(game, game_exe)
248
+            .unwrap_or_else(|| format!("Z:{}", game_exe.to_string_lossy().replace('/', "\\")));
249
+        let game_args_str = game_args.join(" ");
250
+
251
+        // Create batch file content
252
+        // Uses Windows batch syntax to:
253
+        // 1. Start WeMod in the background
254
+        // 2. Wait for WeMod to initialize (using ping for delay)
255
+        // 3. Start the game and wait for it to exit
256
+        // 4. Kill WeMod when game exits
257
+        let batch_content = format!(
258
+r#"@echo off
259
+@title WANDA Launcher
260
+
261
+echo WANDA Launcher starting...
262
+echo.
263
+
264
+REM WeMod executable path
265
+SET wemodpath={wemod_path}
266
+SET wemodname=WeMod.exe
267
+
268
+echo Starting WeMod: %wemodpath%
269
+start "" "%wemodpath%" --no-sandbox
270
+
271
+echo Waiting {delay} seconds for WeMod to initialize...
272
+ping localhost -n {ping_count} > NUL 2>&1
273
+
274
+echo.
275
+echo Starting game: {game_path}
276
+echo Arguments: {game_args}
277
+echo.
278
+
279
+REM Start the game and wait for it to exit
280
+start /wait "" "{game_path}" {game_args}
281
+
282
+echo.
283
+echo Game exited, closing WeMod...
284
+
285
+REM Find and kill WeMod process
286
+for /F "TOKENS=1,2,*" %%a in ('C:/windows/system32/tasklist /FI "IMAGENAME eq %wemodname%"') do (
287
+    set wemodPID=%%b
288
+)
289
+if defined wemodPID (
290
+    C:/windows/system32/taskkill.exe /PID %wemodPID% /F 2>NUL
291
+    echo Closed WeMod
292
+)
293
+
294
+echo.
295
+echo WANDA Launcher finished
296
+"#,
297
+            wemod_path = wemod_win_path,
298
+            delay = delay_seconds,
299
+            ping_count = delay_seconds + 1, // ping -n N waits N-1 seconds
300
+            game_path = game_win_path,
301
+            game_args = game_args_str,
302
+        );
303
+
304
+        // Windows cmd.exe requires CRLF line endings
305
+        let batch_content = batch_content.replace('\n', "\r\n");
306
+        std::fs::write(&batch_path, batch_content).map_err(|e| WandaError::LaunchFailed {
307
+            reason: format!("Failed to create launch batch file: {}", e),
308
+        })?;
309
+
310
+        log_to_file(&format!("Created batch file at: {}", batch_path.display()));
311
+        debug!("Created batch file at: {}", batch_path.display());
312
+
313
+        Ok(batch_path)
314
+    }
315
+
316
+    /// Create a batch file for standalone mode (WeMod only, no game)
317
+    ///
318
+    /// Launches WeMod via the Squirrel stub (identical to normal mode) then
319
+    /// keeps cmd.exe alive with an infinite ping loop so Wine doesn't tear
320
+    /// down the session. The user closes WeMod from its UI and Ctrl+C's the
321
+    /// terminal to end the Proton session.
322
+    fn create_standalone_batch(&self, wemod_exe: &std::path::Path) -> Result<PathBuf> {
323
+        let batch_path = self.prefix.path.join("wanda_standalone.bat");
324
+        let wemod_win_path = format!("Z:{}", wemod_exe.to_string_lossy().replace('/', "\\"));
325
+
326
+        let batch_content = format!(
327
+r#"@echo off
328
+@title WANDA Standalone Launcher
329
+
330
+echo WANDA Standalone Launcher starting...
331
+echo.
332
+
333
+SET wemodpath={wemod_path}
334
+
335
+echo Starting WeMod: %wemodpath%
336
+start "" "%wemodpath%" --no-sandbox
337
+
338
+echo Waiting for WeMod to initialize...
339
+ping localhost -n 6 > NUL 2>&1
340
+
341
+echo.
342
+echo WeMod is running in standalone mode.
343
+echo Launch your game from the WeMod UI.
344
+echo.
345
+
346
+:wait
347
+ping localhost -n 30 > NUL 2>&1
348
+goto wait
349
+"#,
350
+            wemod_path = wemod_win_path,
351
+        );
352
+
353
+        // Windows cmd.exe requires CRLF line endings
354
+        let batch_content = batch_content.replace('\n', "\r\n");
355
+        std::fs::write(&batch_path, batch_content).map_err(|e| WandaError::LaunchFailed {
356
+            reason: format!("Failed to create standalone batch file: {}", e),
357
+        })?;
358
+
359
+        log_to_file(&format!("Created standalone batch at: {}", batch_path.display()));
360
+        debug!("Created standalone batch at: {}", batch_path.display());
361
+
362
+        Ok(batch_path)
363
+    }
364
+
365
+    /// Set up Steam library structure inside the prefix so WeMod can find games
366
+    ///
367
+    /// WeMod locates games via the Windows registry path
368
+    /// `C:\Program Files (x86)\Steam` -> `steamapps\common\{game}\`. We create
369
+    /// symlinks from the prefix into the real Steam library so WeMod sees the
370
+    /// game without copying any files.
371
+    fn setup_steam_library(&self, game: &SteamApp) -> Result<()> {
372
+        let steamapps_dir = self.prefix.path
373
+            .join("pfx/drive_c/Program Files (x86)/Steam/steamapps");
374
+
375
+        // Create the steamapps directory structure
376
+        std::fs::create_dir_all(&steamapps_dir).map_err(|e| WandaError::LaunchFailed {
377
+            reason: format!("Failed to create Steam library structure in prefix: {}", e),
378
+        })?;
379
+
380
+        // Symlink common/ directory
381
+        let common_link = steamapps_dir.join("common");
382
+        let common_target = game.library_path.join("steamapps/common");
383
+        if !common_link.exists() {
384
+            info!("Symlinking Steam common: {} -> {}", common_link.display(), common_target.display());
385
+            log_to_file(&format!("Symlinking common: {} -> {}", common_link.display(), common_target.display()));
386
+            std::os::unix::fs::symlink(&common_target, &common_link).map_err(|e| {
387
+                WandaError::LaunchFailed {
388
+                    reason: format!(
389
+                        "Failed to symlink Steam common directory: {} -> {}: {}",
390
+                        common_link.display(), common_target.display(), e
391
+                    ),
392
+                }
393
+            })?;
394
+        }
395
+
396
+        // Symlink the game's appmanifest
397
+        let manifest_name = format!("appmanifest_{}.acf", game.app_id);
398
+        let manifest_link = steamapps_dir.join(&manifest_name);
399
+        let manifest_target = game.library_path.join("steamapps").join(&manifest_name);
400
+        if !manifest_link.exists() && manifest_target.exists() {
401
+            info!("Symlinking appmanifest: {} -> {}", manifest_link.display(), manifest_target.display());
402
+            log_to_file(&format!("Symlinking appmanifest: {} -> {}", manifest_link.display(), manifest_target.display()));
403
+            std::os::unix::fs::symlink(&manifest_target, &manifest_link).map_err(|e| {
404
+                WandaError::LaunchFailed {
405
+                    reason: format!(
406
+                        "Failed to symlink appmanifest: {} -> {}: {}",
407
+                        manifest_link.display(), manifest_target.display(), e
408
+                    ),
409
+                }
410
+            })?;
411
+        }
412
+
413
+        // Create libraryfolders.vdf so WeMod can discover the game library
414
+        let vdf_path = steamapps_dir.join("libraryfolders.vdf");
415
+        let vdf_content = format!(
416
+            r#""libraryfolders"
417
+{{
418
+	"0"
419
+	{{
420
+		"path"		"C:\\Program Files (x86)\\Steam"
421
+		"apps"
422
+		{{
423
+			"{app_id}"		"0"
424
+		}}
425
+	}}
426
+}}"#,
427
+            app_id = game.app_id,
428
+        );
429
+        info!("Writing libraryfolders.vdf with app {}", game.app_id);
430
+        log_to_file(&format!("Writing libraryfolders.vdf for app {}", game.app_id));
431
+        std::fs::write(&vdf_path, &vdf_content).map_err(|e| WandaError::LaunchFailed {
432
+            reason: format!("Failed to create libraryfolders.vdf: {}", e),
433
+        })?;
434
+
435
+        // Add Steam registry entries so WeMod can find the fake Steam installation
436
+        self.setup_steam_registry(game)?;
437
+
438
+        Ok(())
439
+    }
440
+
441
+    /// Write Steam registry entries into the Wine prefix so WeMod can locate Steam.
442
+    ///
443
+    /// WeMod checks `HKCU\Software\Valve\Steam\SteamPath` to find the Steam
444
+    /// installation directory and then reads `steamapps/libraryfolders.vdf`.
445
+    fn setup_steam_registry(&self, game: &SteamApp) -> Result<()> {
446
+        let user_reg = self.prefix.path.join("pfx/user.reg");
447
+        if !user_reg.exists() {
448
+            debug!("user.reg not found, skipping Steam registry setup");
449
+            return Ok(());
450
+        }
451
+
452
+        let content = std::fs::read_to_string(&user_reg).map_err(|e| WandaError::LaunchFailed {
453
+            reason: format!("Failed to read user.reg: {}", e),
454
+        })?;
455
+
456
+        // Only add if Valve\Steam key doesn't already exist
457
+        if content.contains("[Software\\\\Valve\\\\Steam]") {
458
+            debug!("Steam registry entries already exist");
459
+            return Ok(());
460
+        }
461
+
462
+        let timestamp = std::time::SystemTime::now()
463
+            .duration_since(std::time::UNIX_EPOCH)
464
+            .map(|d| d.as_secs())
465
+            .unwrap_or(0);
466
+
467
+        let steam_entry = format!(
468
+            r#"
469
+
470
+[Software\\Valve\\Steam] {ts}
471
+"Language"="english"
472
+"SteamExe"="C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe"
473
+"SteamPath"="C:\\\\Program Files (x86)\\\\Steam"
474
+
475
+[Software\\Valve\\Steam\\Apps\\{app_id}] {ts}
476
+"Installed"=dword:00000001
477
+"Name"="{game_name}"
478
+"Running"=dword:00000000
479
+"#,
480
+            ts = timestamp,
481
+            app_id = game.app_id,
482
+            game_name = game.name.replace('\\', "\\\\").replace('"', ""),
483
+        );
484
+
485
+        info!("Adding Steam registry entries for app {}", game.app_id);
486
+        log_to_file(&format!("Adding Steam registry entries for app {}", game.app_id));
487
+
488
+        let mut file = OpenOptions::new()
489
+            .append(true)
490
+            .open(&user_reg)
491
+            .map_err(|e| WandaError::LaunchFailed {
492
+                reason: format!("Failed to open user.reg for writing: {}", e),
493
+            })?;
494
+        file.write_all(steam_entry.as_bytes()).map_err(|e| WandaError::LaunchFailed {
495
+            reason: format!("Failed to write Steam registry entries: {}", e),
496
+        })?;
497
+
498
+        Ok(())
499
+    }
500
+
501
+    /// Set up a Steam launch proxy so that when WeMod clicks "Play", the game
502
+    /// launches directly in the same Wine session instead of going through the
503
+    /// native Linux Steam client (which creates a separate Proton session that
504
+    /// WeMod cannot hook into).
505
+    ///
506
+    /// This works by:
507
+    /// 1. Writing a batch file at `C:\wanda\steam_proxy.bat` that launches
508
+    ///    the game executable directly
509
+    /// 2. Overriding the `steam://` protocol handler in the Wine registry
510
+    ///    to point to this batch file instead of Proton's `steam.exe` stub
511
+    ///
512
+    /// When WeMod calls `steam://run/<APPID>`, Wine routes it through our
513
+    /// proxy, which starts the game process in WeMod's Wine session.
514
+    fn setup_steam_launch_proxy(
515
+        &self,
516
+        game: &SteamApp,
517
+        game_exe: &std::path::Path,
518
+    ) -> Result<()> {
519
+        let pfx_path = self.prefix.path.join("pfx");
520
+
521
+        // Create the proxy batch file at C:\wanda\steam_proxy.bat
522
+        let wanda_dir = pfx_path.join("drive_c/wanda");
523
+        std::fs::create_dir_all(&wanda_dir).map_err(|e| WandaError::LaunchFailed {
524
+            reason: format!("Failed to create wanda directory in prefix: {}", e),
525
+        })?;
526
+
527
+        let proxy_path = wanda_dir.join("steam_proxy.bat");
528
+        // Use C: drive path so QueryFullProcessImageName returns a path
529
+        // that matches WeMod's expected Steam library location
530
+        let game_win_path = Self::to_prefix_game_path(game, game_exe)
531
+            .unwrap_or_else(|| format!("Z:{}", game_exe.to_string_lossy().replace('/', "\\")));
532
+
533
+        let proxy_content = format!(
534
+            r#"@echo off
535
+REM Wanda Steam Protocol Proxy
536
+REM Intercepts steam://run requests from WeMod and launches the game
537
+REM directly in the same Wine session (instead of via native Steam
538
+REM which would create a separate Proton session).
539
+REM
540
+REM Game: {game_name} (AppID: {app_id})
541
+
542
+echo [WANDA] Steam launch intercepted: %~1
543
+echo [WANDA] Launching: {game_path}
544
+
545
+start "" "{game_path}"
546
+"#,
547
+            game_name = game.name,
548
+            app_id = game.app_id,
549
+            game_path = game_win_path,
550
+        );
551
+
552
+        std::fs::write(&proxy_path, &proxy_content).map_err(|e| WandaError::LaunchFailed {
553
+            reason: format!("Failed to create Steam proxy batch: {}", e),
554
+        })?;
555
+
556
+        info!("Created Steam launch proxy at {}", proxy_path.display());
557
+        log_to_file(&format!(
558
+            "Created Steam launch proxy for {} ({})",
559
+            game.name, game.app_id
560
+        ));
561
+
562
+        // Override the steam:// protocol handler in user.reg so Wine routes
563
+        // steam://run/APPID to our proxy batch instead of Proton's steam.exe.
564
+        // Later entries in user.reg take precedence, so we always append.
565
+        let user_reg = self.prefix.path.join("pfx/user.reg");
566
+        if !user_reg.exists() {
567
+            debug!("user.reg not found, skipping steam:// protocol override");
568
+            return Ok(());
569
+        }
570
+
571
+        let content = std::fs::read_to_string(&user_reg).map_err(|e| WandaError::LaunchFailed {
572
+            reason: format!("Failed to read user.reg: {}", e),
573
+        })?;
574
+
575
+        // Skip if already configured (avoid growing user.reg on repeated launches)
576
+        if content.contains("wanda\\\\steam_proxy.bat") {
577
+            debug!("Steam protocol proxy already configured in registry");
578
+            return Ok(());
138579
         }
580
+
581
+        let timestamp = std::time::SystemTime::now()
582
+            .duration_since(std::time::UNIX_EPOCH)
583
+            .map(|d| d.as_secs())
584
+            .unwrap_or(0);
585
+
586
+        // Wine user.reg format: \\ = literal backslash, \" = literal quote
587
+        let registry_override = format!(
588
+            concat!(
589
+                "\n",
590
+                "[Software\\\\Classes\\\\steam] {ts}\n",
591
+                "@=\"URL:Steam Protocol\"\n",
592
+                "\"URL Protocol\"=\"\"\n",
593
+                "\n",
594
+                "[Software\\\\Classes\\\\steam\\\\shell\\\\open\\\\command] {ts}\n",
595
+                "@=\"\\\"C:\\\\wanda\\\\steam_proxy.bat\\\" \\\"%1\\\"\"\n",
596
+            ),
597
+            ts = timestamp,
598
+        );
599
+
600
+        let mut file = OpenOptions::new()
601
+            .append(true)
602
+            .open(&user_reg)
603
+            .map_err(|e| WandaError::LaunchFailed {
604
+                reason: format!("Failed to open user.reg: {}", e),
605
+            })?;
606
+        file.write_all(registry_override.as_bytes())
607
+            .map_err(|e| WandaError::LaunchFailed {
608
+                reason: format!("Failed to write steam:// protocol override: {}", e),
609
+            })?;
610
+
611
+        info!("Overrode steam:// protocol handler to use Wanda proxy");
612
+        log_to_file("Overrode steam:// protocol handler → C:\\wanda\\steam_proxy.bat");
613
+
614
+        Ok(())
139615
     }
140616
 
141617
     /// Launch a game with WeMod
142618
     pub async fn launch(&self, config: LaunchConfig) -> Result<LaunchHandle> {
619
+        log_to_file("=== WANDA LAUNCH START ===");
620
+
143621
         let game = self
144622
             .steam
145623
             .find_game(config.app_id)
146624
             .ok_or(WandaError::GameNotFound { app_id: config.app_id })?;
147625
 
148
-        info!(
626
+        let launch_msg = format!(
149627
             "Launching {} (AppID: {}) {}",
150628
             game.name,
151629
             game.app_id,
152630
             if config.with_wemod { "with WeMod" } else { "without WeMod" }
153631
         );
632
+        info!("{}", launch_msg);
633
+        log_to_file(&launch_msg);
154634
 
155635
         info!("Using WANDA prefix: {}", self.prefix.path.display());
636
+        log_to_file(&format!("WANDA prefix: {}", self.prefix.path.display()));
637
+        log_to_file(&format!("Proton: {} at {}", self.proton.name, self.proton.path.display()));
156638
 
157
-        // Kill stale wineservers to avoid conflicts
158
-        info!("Cleaning up stale wineservers...");
159
-        let _ = Command::new("pkill").args(["-9", "wineserver"]).output().await;
160
-        tokio::time::sleep(Duration::from_secs(1)).await;
639
+        let game_exe = self.find_game_executable(&game)?;
640
+        info!("Game executable: {}", game_exe.display());
641
+        log_to_file(&format!("Game executable: {}", game_exe.display()));
161642
 
162
-        let mut env = self.build_env();
163
-        env.insert("SteamAppId".to_string(), config.app_id.to_string());
164
-        env.insert("SteamGameId".to_string(), config.app_id.to_string());
165
-        env.extend(config.extra_env);
643
+        // Always include SteamAppId — the game needs it for Steam API
644
+        // initialization. Without it, many games exit immediately.
645
+        // In standalone CLI mode this is safe since Steam tracks games
646
+        // via reaper/launch-wrapper, not env vars.
647
+        let mut env = self.build_env(Some(config.app_id));
166648
 
167
-        let wine = self.wine_exe();
168
-        let mut wemod_process = None;
649
+        // Set STEAM_COMPAT_INSTALL_PATH so ProtonFixes knows where the game is
650
+        env.insert(
651
+            "STEAM_COMPAT_INSTALL_PATH".to_string(),
652
+            game.install_path.to_string_lossy().to_string(),
653
+        );
654
+        debug!("Setting STEAM_COMPAT_INSTALL_PATH={}", game.install_path.display());
169655
 
170
-        if config.with_wemod {
171
-            let wemod_exe = self.prefix.wemod_exe();
172
-            if !wemod_exe.exists() {
656
+        env.extend(config.extra_env);
657
+
658
+        let process = if config.with_wemod {
659
+            let wemod_stub = self.prefix.wemod_exe();
660
+            if !wemod_stub.exists() {
661
+                log_to_file(&format!("WeMod not found at: {}", wemod_stub.display()));
173662
                 return Err(WandaError::WemodNotInstalled);
174663
             }
175664
 
176
-            info!("Starting WeMod...");
177
-            info!("WeMod path: {}", wemod_exe.display());
665
+            // Prefer the real Electron exe (app-X.Y.Z/WeMod.exe) over the Squirrel stub.
666
+            // The stub exits immediately after spawning the real app, which can cause
667
+            // Proton to tear down the Wine session before WeMod fully starts.
668
+            let wemod_exe = if let Some(app_exe) = self.prefix.wemod_app_exe() {
669
+                info!("Using real WeMod app exe: {}", app_exe.display());
670
+                log_to_file(&format!("Using real WeMod app exe: {}", app_exe.display()));
671
+                app_exe
672
+            } else {
673
+                info!("Real WeMod app exe not found, falling back to stub: {}", wemod_stub.display());
674
+                log_to_file(&format!("Real WeMod app exe not found, falling back to stub: {}", wemod_stub.display()));
675
+                wemod_stub
676
+            };
178677
 
179
-            let child = Command::new(&wine)
180
-                .arg(&wemod_exe)
181
-                .arg("--no-sandbox")
678
+            info!("WeMod path: {}", wemod_exe.display());
679
+            log_to_file(&format!("WeMod path: {}", wemod_exe.display()));
680
+
681
+            // Set up Steam library so WeMod can find the game (both modes)
682
+            info!("Setting up Steam library in prefix");
683
+            log_to_file("Setting up Steam library in prefix");
684
+            self.setup_steam_library(&game)?;
685
+
686
+            // Override the steam:// protocol handler so WeMod launches the game
687
+            // directly in this Wine session instead of through the native Steam
688
+            // client (which would create a separate, unhookable Proton session)
689
+            self.setup_steam_launch_proxy(&game, &game_exe)?;
690
+
691
+            let cmd_str = if config.standalone {
692
+                let batch_path = self.create_standalone_batch(&wemod_exe)?;
693
+                let batch_win_path = Self::to_wine_path(&batch_path);
694
+
695
+                let cmd = format!(
696
+                    "cd \"{}\" && \"{}\" waitforexitandrun \"{}\"",
697
+                    self.proton.path.display(),
698
+                    self.proton.proton_exe().display(),
699
+                    batch_win_path,
700
+                );
701
+                info!("Launching WeMod standalone via batch: {}", cmd);
702
+                log_to_file(&format!("Standalone launch command: {}", cmd));
703
+                cmd
704
+            } else {
705
+                // Normal mode: batch file coordinates WeMod + game startup
706
+                let batch_path = self.create_launch_batch(
707
+                    &wemod_exe,
708
+                    &game,
709
+                    &game_exe,
710
+                    &config.extra_args,
711
+                    config.wemod_delay,
712
+                )?;
713
+                let batch_win_path = Self::to_wine_path(&batch_path);
714
+
715
+                let cmd = format!(
716
+                    "cd \"{}\" && \"{}\" waitforexitandrun \"{}\"",
717
+                    self.proton.path.display(),
718
+                    self.proton.proton_exe().display(),
719
+                    batch_win_path,
720
+                );
721
+                info!("Launching via batch file: {}", cmd);
722
+                log_to_file(&format!("Launch command: {}", cmd));
723
+                cmd
724
+            };
725
+
726
+            let child = Command::new("bash")
727
+                .arg("-c")
728
+                .arg(&cmd_str)
182729
                 .envs(&env)
183730
                 .stdout(Stdio::inherit())
184731
                 .stderr(Stdio::inherit())
185732
                 .spawn()
186
-                .map_err(|e| WandaError::LaunchFailed {
187
-                    reason: format!("Failed to start WeMod: {}", e),
733
+                .map_err(|e| {
734
+                    log_to_file(&format!("Failed to launch: {}", e));
735
+                    WandaError::LaunchFailed {
736
+                        reason: format!("Failed to launch: {}", e),
737
+                    }
188738
                 })?;
189739
 
190
-            wemod_process = Some(child);
191
-
192
-            info!("Waiting {} seconds for WeMod to initialize...", config.wemod_delay);
193
-            tokio::time::sleep(Duration::from_secs(config.wemod_delay)).await;
194
-        }
740
+            log_to_file("Launch process started successfully");
741
+            Some(child)
742
+        } else {
743
+            // Launch game without WeMod using direct Proton invocation
744
+            let game_win_path = Self::to_wine_path(&game_exe);
745
+            let cmd_str = format!(
746
+                "cd \"{}\" && \"{}\" waitforexitandrun \"{}\" {}",
747
+                self.proton.path.display(),
748
+                self.proton.proton_exe().display(),
749
+                game_win_path,
750
+                config.extra_args.join(" ")
751
+            );
752
+
753
+            info!("Launching game directly: {}", cmd_str);
754
+            log_to_file(&format!("Launch command: {}", cmd_str));
755
+
756
+            let child = Command::new("bash")
757
+                .arg("-c")
758
+                .arg(&cmd_str)
759
+                .envs(&env)
760
+                .stdout(Stdio::inherit())
761
+                .stderr(Stdio::inherit())
762
+                .spawn()
763
+                .map_err(|e| {
764
+                    log_to_file(&format!("Failed to launch game: {}", e));
765
+                    WandaError::LaunchFailed {
766
+                        reason: format!("Failed to launch game: {}", e),
767
+                    }
768
+                })?;
195769
 
196
-        info!("Launching game via Proton...");
197
-        self.launch_game_via_proton(&game, &config.extra_args, &env).await?;
770
+            log_to_file("Game launched successfully");
771
+            Some(child)
772
+        };
198773
 
199774
         Ok(LaunchHandle {
200
-            wemod_process,
775
+            process,
201776
             app_id: config.app_id,
202777
             start_time: std::time::Instant::now(),
203778
         })
204779
     }
205780
 
206
-    /// Launch game via Proton
207
-    async fn launch_game_via_proton(
208
-        &self,
209
-        game: &SteamApp,
210
-        args: &[String],
211
-        env: &HashMap<String, String>,
212
-    ) -> Result<()> {
213
-        let proton_exe = self.proton.proton_exe();
214
-        if !proton_exe.exists() {
215
-            return Err(WandaError::ProtonNotFound);
216
-        }
217
-
218
-        let game_exe = self.find_game_executable(game)?;
219
-        info!("Game executable: {}", game_exe.display());
220
-
221
-        let mut cmd = Command::new(&proton_exe);
222
-        cmd.arg("run").arg(&game_exe).args(args).envs(env);
223
-        cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
224
-
225
-        info!("Running: {} run {}", proton_exe.display(), game_exe.display());
226
-
227
-        cmd.spawn().map_err(|e| WandaError::LaunchFailed {
228
-            reason: format!("Failed to start game: {}", e),
229
-        })?;
230
-
231
-        Ok(())
232
-    }
233
-
234781
     /// Find the main executable for a game
235782
     fn find_game_executable(&self, game: &SteamApp) -> Result<PathBuf> {
236783
         let install_path = &game.install_path;
@@ -239,22 +786,71 @@ impl<'a> GameLauncher<'a> {
239786
             return Err(WandaError::GameNotFound { app_id: game.app_id });
240787
         }
241788
 
789
+        // Generate name patterns from install_dir (e.g., "ELDEN RING" -> "eldenring")
790
+        let normalized_name = game.install_dir.to_lowercase().replace(' ', "");
791
+
242792
         let patterns = [
243793
             format!("{}.exe", game.install_dir),
794
+            format!("{}.exe", normalized_name),  // eldenring.exe
244795
             "game.exe".to_string(),
245796
             "start.exe".to_string(),
246797
             "launcher.exe".to_string(),
247798
         ];
248799
 
800
+        // Check in root and Game/ subdirectory (common structure)
801
+        let search_dirs = [
802
+            install_path.clone(),
803
+            install_path.join("Game"),
804
+            install_path.join("game"),
805
+            install_path.join("Bin"),
806
+            install_path.join("bin"),
807
+        ];
808
+
249809
         for pattern in &patterns {
250
-            let exe_path = install_path.join(pattern);
251
-            if exe_path.exists() {
252
-                return Ok(exe_path);
810
+            for dir in &search_dirs {
811
+                let exe_path = dir.join(pattern);
812
+                if exe_path.exists() {
813
+                    return Ok(exe_path);
814
+                }
815
+            }
816
+        }
817
+
818
+        // Exclusion list for anti-cheat, launchers, and utilities
819
+        let exclusions = [
820
+            "unins", "redist", "setup", "crash", "report",
821
+            "start_protected", "easyanticheat", "battleye", "eac_",
822
+            "dxsetup", "vcredist", "dotnet", "directx",
823
+            "ue4prereq", "installer", "updater",
824
+        ];
825
+
826
+        // Walk directory looking for game executables
827
+        for entry in walkdir::WalkDir::new(install_path)
828
+            .max_depth(3)
829
+            .into_iter()
830
+            .flatten()
831
+        {
832
+            let path = entry.path();
833
+            if let Some(ext) = path.extension() {
834
+                if ext == "exe" {
835
+                    let name = path.file_name().unwrap_or_default().to_string_lossy();
836
+                    let name_lower = name.to_lowercase();
837
+
838
+                    // Skip excluded executables
839
+                    if exclusions.iter().any(|ex| name_lower.contains(ex)) {
840
+                        continue;
841
+                    }
842
+
843
+                    // Prefer executables that match the game name
844
+                    if name_lower.contains(&normalized_name) {
845
+                        return Ok(path.to_path_buf());
846
+                    }
847
+                }
253848
             }
254849
         }
255850
 
851
+        // Fallback: return first non-excluded exe found
256852
         for entry in walkdir::WalkDir::new(install_path)
257
-            .max_depth(2)
853
+            .max_depth(3)
258854
             .into_iter()
259855
             .flatten()
260856
         {
@@ -262,10 +858,9 @@ impl<'a> GameLauncher<'a> {
262858
             if let Some(ext) = path.extension() {
263859
                 if ext == "exe" {
264860
                     let name = path.file_name().unwrap_or_default().to_string_lossy();
265
-                    if !name.to_lowercase().contains("unins")
266
-                        && !name.to_lowercase().contains("redist")
267
-                        && !name.to_lowercase().contains("setup")
268
-                    {
861
+                    let name_lower = name.to_lowercase();
862
+
863
+                    if !exclusions.iter().any(|ex| name_lower.contains(ex)) {
269864
                         return Ok(path.to_path_buf());
270865
                     }
271866
                 }