Rust · 32647 bytes Raw Blame History
1 //! Game launching with WeMod
2 //!
3 //! Handles launching Steam games alongside WeMod through Proton.
4 //!
5 //! Key insight: WeMod and the game MUST run in the same Wine prefix
6 //! for WeMod to be able to hook into the game process. We achieve this by
7 //! running both in a single Proton session using a batch file coordinator.
8
9 use crate::error::{Result, WandaError};
10 use crate::prefix::WandaPrefix;
11 use crate::steam::{ProtonVersion, SteamApp, SteamInstallation};
12 use std::collections::HashMap;
13 use std::fs::OpenOptions;
14 use std::io::Write;
15 use std::path::PathBuf;
16 use std::process::Stdio;
17 use std::time::Duration;
18 use tokio::process::{Child, Command};
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 }
47
48 /// Configuration for launching a game
49 #[derive(Debug, Clone)]
50 pub struct LaunchConfig {
51 /// The game to launch
52 pub app_id: u32,
53 /// Whether to launch with WeMod
54 pub with_wemod: bool,
55 /// Additional command-line arguments for the game
56 pub extra_args: Vec<String>,
57 /// Additional environment variables
58 pub extra_env: HashMap<String, String>,
59 /// Delay between starting WeMod and the game (in seconds)
60 pub wemod_delay: u64,
61 /// Standalone mode: launch WeMod only, user starts game from WeMod UI
62 pub standalone: bool,
63 }
64
65 impl Default for LaunchConfig {
66 fn default() -> Self {
67 Self {
68 app_id: 0,
69 with_wemod: true,
70 extra_args: Vec::new(),
71 extra_env: HashMap::new(),
72 wemod_delay: 3,
73 standalone: false,
74 }
75 }
76 }
77
78 /// Handle to a launched game session
79 pub struct LaunchHandle {
80 /// The main process (proton running the batch file)
81 process: Option<Child>,
82 /// Game App ID
83 pub app_id: u32,
84 /// Start time
85 start_time: std::time::Instant,
86 }
87
88 impl LaunchHandle {
89 /// Check if the session is still running
90 pub fn is_running(&mut self) -> bool {
91 if let Some(ref mut proc) = self.process {
92 match proc.try_wait() {
93 Ok(Some(_)) => return false,
94 Ok(None) => return true,
95 Err(_) => return false,
96 }
97 }
98 false
99 }
100
101 /// Get elapsed time since launch
102 pub fn elapsed(&self) -> Duration {
103 self.start_time.elapsed()
104 }
105
106 /// Terminate the session
107 pub async fn terminate(&mut self) -> Result<()> {
108 if let Some(ref mut proc) = self.process {
109 let _ = proc.kill().await;
110 }
111 Ok(())
112 }
113
114 /// Wait for the session to exit
115 pub async fn wait(&mut self) -> Result<()> {
116 if let Some(ref mut proc) = self.process {
117 let _ = proc.wait().await;
118 }
119 Ok(())
120 }
121 }
122
123 /// Game launcher that coordinates WeMod and game startup
124 pub struct GameLauncher<'a> {
125 steam: &'a SteamInstallation,
126 prefix: &'a WandaPrefix,
127 proton: &'a ProtonVersion,
128 }
129
130 impl<'a> GameLauncher<'a> {
131 /// Create a new game launcher
132 pub fn new(
133 steam: &'a SteamInstallation,
134 prefix: &'a WandaPrefix,
135 proton: &'a ProtonVersion,
136 ) -> Self {
137 Self { steam, prefix, proton }
138 }
139
140 /// Build environment variables for launching
141 fn build_env(&self, app_id: Option<u32>) -> HashMap<String, String> {
142 let mut env = HashMap::new();
143
144 // Primary Proton/Wine environment
145 env.insert(
146 "STEAM_COMPAT_DATA_PATH".to_string(),
147 self.prefix.path.to_string_lossy().to_string(),
148 );
149 env.insert(
150 "STEAM_COMPAT_CLIENT_INSTALL_PATH".to_string(),
151 self.steam.root_path.to_string_lossy().to_string(),
152 );
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());
182 env.insert("PROTON_NO_ESYNC".to_string(), "1".to_string());
183 env.insert("PROTON_NO_FSYNC".to_string(), "1".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 }
205
206 env
207 }
208
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(());
579 }
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(())
615 }
616
617 /// Launch a game with WeMod
618 pub async fn launch(&self, config: LaunchConfig) -> Result<LaunchHandle> {
619 log_to_file("=== WANDA LAUNCH START ===");
620
621 let game = self
622 .steam
623 .find_game(config.app_id)
624 .ok_or(WandaError::GameNotFound { app_id: config.app_id })?;
625
626 let launch_msg = format!(
627 "Launching {} (AppID: {}) {}",
628 game.name,
629 game.app_id,
630 if config.with_wemod { "with WeMod" } else { "without WeMod" }
631 );
632 info!("{}", launch_msg);
633 log_to_file(&launch_msg);
634
635 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()));
638
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()));
642
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));
648
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());
655
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()));
662 return Err(WandaError::WemodNotInstalled);
663 }
664
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 };
677
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)
729 .envs(&env)
730 .stdout(Stdio::inherit())
731 .stderr(Stdio::inherit())
732 .spawn()
733 .map_err(|e| {
734 log_to_file(&format!("Failed to launch: {}", e));
735 WandaError::LaunchFailed {
736 reason: format!("Failed to launch: {}", e),
737 }
738 })?;
739
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 })?;
769
770 log_to_file("Game launched successfully");
771 Some(child)
772 };
773
774 Ok(LaunchHandle {
775 process,
776 app_id: config.app_id,
777 start_time: std::time::Instant::now(),
778 })
779 }
780
781 /// Find the main executable for a game
782 fn find_game_executable(&self, game: &SteamApp) -> Result<PathBuf> {
783 let install_path = &game.install_path;
784
785 if !install_path.exists() {
786 return Err(WandaError::GameNotFound { app_id: game.app_id });
787 }
788
789 // Generate name patterns from install_dir (e.g., "ELDEN RING" -> "eldenring")
790 let normalized_name = game.install_dir.to_lowercase().replace(' ', "");
791
792 let patterns = [
793 format!("{}.exe", game.install_dir),
794 format!("{}.exe", normalized_name), // eldenring.exe
795 "game.exe".to_string(),
796 "start.exe".to_string(),
797 "launcher.exe".to_string(),
798 ];
799
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
809 for pattern in &patterns {
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 }
848 }
849 }
850
851 // Fallback: return first non-excluded exe found
852 for entry in walkdir::WalkDir::new(install_path)
853 .max_depth(3)
854 .into_iter()
855 .flatten()
856 {
857 let path = entry.path();
858 if let Some(ext) = path.extension() {
859 if ext == "exe" {
860 let name = path.file_name().unwrap_or_default().to_string_lossy();
861 let name_lower = name.to_lowercase();
862
863 if !exclusions.iter().any(|ex| name_lower.contains(ex)) {
864 return Ok(path.to_path_buf());
865 }
866 }
867 }
868 }
869
870 Err(WandaError::LaunchFailed {
871 reason: format!("Could not find executable for {}", game.name),
872 })
873 }
874 }
875