Rust · 10733 bytes Raw Blame History
1 //! Game launching with WeMod
2 //!
3 //! Handles launching Steam games alongside WeMod through Proton.
4
5 use crate::error::{Result, WandaError};
6 use crate::prefix::WandaPrefix;
7 use crate::steam::{ProtonVersion, SteamApp, SteamInstallation};
8 use std::collections::HashMap;
9 use std::path::PathBuf;
10 use std::process::Stdio;
11 use std::time::Duration;
12 use tokio::process::{Child, Command};
13 use tracing::{debug, info, warn};
14
15 /// Configuration for launching a game
16 #[derive(Debug, Clone)]
17 pub struct LaunchConfig {
18 /// The game to launch
19 pub app_id: u32,
20 /// Whether to launch with WeMod
21 pub with_wemod: bool,
22 /// Additional command-line arguments for the game
23 pub extra_args: Vec<String>,
24 /// Additional environment variables
25 pub extra_env: HashMap<String, String>,
26 /// Delay between starting WeMod and the game (in seconds)
27 pub wemod_delay: u64,
28 }
29
30 impl Default for LaunchConfig {
31 fn default() -> Self {
32 Self {
33 app_id: 0,
34 with_wemod: true,
35 extra_args: Vec::new(),
36 extra_env: HashMap::new(),
37 wemod_delay: 3,
38 }
39 }
40 }
41
42 /// Handle to a launched game session
43 pub struct LaunchHandle {
44 /// WeMod process (if launched with WeMod)
45 wemod_process: Option<Child>,
46 /// Game App ID
47 pub app_id: u32,
48 /// Start time
49 start_time: std::time::Instant,
50 }
51
52 impl LaunchHandle {
53 /// Check if the session is still running
54 pub fn is_running(&mut self) -> bool {
55 if let Some(ref mut wemod) = self.wemod_process {
56 match wemod.try_wait() {
57 Ok(Some(_)) => return false, // WeMod exited
58 Ok(None) => return true, // Still running
59 Err(_) => return false,
60 }
61 }
62 false
63 }
64
65 /// Get elapsed time since launch
66 pub fn elapsed(&self) -> Duration {
67 self.start_time.elapsed()
68 }
69
70 /// Terminate the session
71 pub async fn terminate(&mut self) -> Result<()> {
72 if let Some(ref mut wemod) = self.wemod_process {
73 let _ = wemod.kill().await;
74 }
75 Ok(())
76 }
77
78 /// Wait for WeMod to exit
79 pub async fn wait(&mut self) -> Result<()> {
80 if let Some(ref mut wemod) = self.wemod_process {
81 let _ = wemod.wait().await;
82 }
83 Ok(())
84 }
85 }
86
87 /// Game launcher that coordinates WeMod and game startup
88 pub struct GameLauncher<'a> {
89 /// Steam installation
90 steam: &'a SteamInstallation,
91 /// WANDA prefix with WeMod
92 prefix: &'a WandaPrefix,
93 /// Proton version to use
94 proton: &'a ProtonVersion,
95 }
96
97 impl<'a> GameLauncher<'a> {
98 /// Create a new game launcher
99 pub fn new(
100 steam: &'a SteamInstallation,
101 prefix: &'a WandaPrefix,
102 proton: &'a ProtonVersion,
103 ) -> Self {
104 Self {
105 steam,
106 prefix,
107 proton,
108 }
109 }
110
111 /// Build environment variables for launching
112 fn build_env(&self, game: &SteamApp) -> HashMap<String, String> {
113 let mut env = HashMap::new();
114
115 // Wine prefix (WANDA's prefix for WeMod)
116 env.insert(
117 "WINEPREFIX".to_string(),
118 self.prefix.path.to_string_lossy().to_string(),
119 );
120
121 // Steam compatibility data path (for the game's own prefix if needed)
122 if let Some(ref compat_path) = game.compat_data_path {
123 env.insert(
124 "STEAM_COMPAT_DATA_PATH".to_string(),
125 compat_path.to_string_lossy().to_string(),
126 );
127 }
128
129 // Steam client install path
130 env.insert(
131 "STEAM_COMPAT_CLIENT_INSTALL_PATH".to_string(),
132 self.steam.root_path.to_string_lossy().to_string(),
133 );
134
135 // Proton flags that may help stability
136 env.insert("PROTON_NO_ESYNC".to_string(), "1".to_string());
137 env.insert("PROTON_NO_FSYNC".to_string(), "1".to_string());
138
139 // Reduce Wine debug noise
140 env.insert("WINEDEBUG".to_string(), "-all".to_string());
141
142 env
143 }
144
145 /// Get the Wine executable path
146 fn wine_exe(&self) -> PathBuf {
147 let proton_wine = self.proton.wine_exe();
148 if proton_wine.exists() {
149 proton_wine
150 } else {
151 PathBuf::from("wine")
152 }
153 }
154
155 /// Launch a game with WeMod
156 pub async fn launch(&self, config: LaunchConfig) -> Result<LaunchHandle> {
157 let game = self
158 .steam
159 .find_game(config.app_id)
160 .ok_or(WandaError::GameNotFound {
161 app_id: config.app_id,
162 })?;
163
164 info!(
165 "Launching {} (AppID: {}) {}",
166 game.name,
167 game.app_id,
168 if config.with_wemod {
169 "with WeMod"
170 } else {
171 "without WeMod"
172 }
173 );
174
175 let mut env = self.build_env(game);
176 env.extend(config.extra_env);
177
178 let wine = self.wine_exe();
179 let mut wemod_process = None;
180
181 // Start WeMod first if requested
182 if config.with_wemod {
183 let wemod_exe = self.prefix.wemod_exe();
184 if !wemod_exe.exists() {
185 return Err(WandaError::WemodNotInstalled);
186 }
187
188 info!("Starting WeMod...");
189 let child = Command::new(&wine)
190 .arg(&wemod_exe)
191 .envs(&env)
192 .stdout(Stdio::null())
193 .stderr(Stdio::null())
194 .spawn()
195 .map_err(|e| WandaError::LaunchFailed {
196 reason: format!("Failed to start WeMod: {}", e),
197 })?;
198
199 wemod_process = Some(child);
200
201 // Wait for WeMod to initialize
202 info!(
203 "Waiting {} seconds for WeMod to initialize...",
204 config.wemod_delay
205 );
206 tokio::time::sleep(Duration::from_secs(config.wemod_delay)).await;
207 }
208
209 // Launch the game via Steam
210 // Using steam:// URL protocol ensures Steam handles Proton setup correctly
211 info!("Launching game via Steam...");
212 self.launch_via_steam(config.app_id).await?;
213
214 Ok(LaunchHandle {
215 wemod_process,
216 app_id: config.app_id,
217 start_time: std::time::Instant::now(),
218 })
219 }
220
221 /// Launch a game via Steam's URL protocol
222 async fn launch_via_steam(&self, app_id: u32) -> Result<()> {
223 // Use xdg-open to launch via steam:// protocol
224 // This ensures Steam handles all the Proton setup correctly
225 let steam_url = format!("steam://rungameid/{}", app_id);
226
227 let status = Command::new("xdg-open")
228 .arg(&steam_url)
229 .stdout(Stdio::null())
230 .stderr(Stdio::null())
231 .status()
232 .await
233 .map_err(|e| WandaError::LaunchFailed {
234 reason: format!("Failed to launch Steam URL: {}", e),
235 })?;
236
237 if !status.success() {
238 // Try alternative: steam command directly
239 let status = Command::new("steam")
240 .arg(&steam_url)
241 .stdout(Stdio::null())
242 .stderr(Stdio::null())
243 .status()
244 .await
245 .map_err(|e| WandaError::LaunchFailed {
246 reason: format!("Failed to launch via steam command: {}", e),
247 })?;
248
249 if !status.success() {
250 warn!("Steam launch returned non-zero, game may still start");
251 }
252 }
253
254 debug!("Game launch initiated via Steam");
255 Ok(())
256 }
257
258 /// Launch a game directly via Proton (without Steam)
259 /// This is an alternative method that gives more control
260 #[allow(dead_code)]
261 async fn launch_directly(&self, game: &SteamApp, args: &[String]) -> Result<Child> {
262 let proton_exe = self.proton.proton_exe();
263
264 if !proton_exe.exists() {
265 return Err(WandaError::ProtonNotFound);
266 }
267
268 // Find the game executable
269 let game_exe = self.find_game_executable(game)?;
270
271 let mut env = self.build_env(game);
272
273 // Set up Proton environment
274 env.insert("STEAM_COMPAT_DATA_PATH".to_string(),
275 game.compat_data_path
276 .as_ref()
277 .map(|p| p.to_string_lossy().to_string())
278 .unwrap_or_else(|| {
279 self.steam.root_path
280 .join("steamapps/compatdata")
281 .join(game.app_id.to_string())
282 .to_string_lossy()
283 .to_string()
284 })
285 );
286
287 let mut cmd = Command::new(&proton_exe);
288 cmd.arg("run").arg(&game_exe);
289 cmd.args(args);
290 cmd.envs(&env);
291 cmd.stdout(Stdio::null());
292 cmd.stderr(Stdio::null());
293
294 let child = cmd.spawn().map_err(|e| WandaError::LaunchFailed {
295 reason: format!("Failed to start game: {}", e),
296 })?;
297
298 Ok(child)
299 }
300
301 /// Try to find the main executable for a game
302 fn find_game_executable(&self, game: &SteamApp) -> Result<PathBuf> {
303 let install_path = &game.install_path;
304
305 if !install_path.exists() {
306 return Err(WandaError::GameNotFound {
307 app_id: game.app_id,
308 });
309 }
310
311 // Common executable patterns
312 let patterns = [
313 format!("{}.exe", game.install_dir),
314 "game.exe".to_string(),
315 "start.exe".to_string(),
316 "launcher.exe".to_string(),
317 ];
318
319 // Try to find a matching executable
320 for pattern in &patterns {
321 let exe_path = install_path.join(pattern);
322 if exe_path.exists() {
323 return Ok(exe_path);
324 }
325 }
326
327 // Walk the directory looking for .exe files
328 for entry in walkdir::WalkDir::new(install_path)
329 .max_depth(2)
330 .into_iter()
331 .flatten()
332 {
333 let path = entry.path();
334 if let Some(ext) = path.extension() {
335 if ext == "exe" {
336 let name = path.file_name().unwrap_or_default().to_string_lossy();
337 // Skip common non-game executables
338 if !name.to_lowercase().contains("unins")
339 && !name.to_lowercase().contains("redist")
340 && !name.to_lowercase().contains("setup")
341 {
342 return Ok(path.to_path_buf());
343 }
344 }
345 }
346 }
347
348 Err(WandaError::LaunchFailed {
349 reason: format!("Could not find executable for {}", game.name),
350 })
351 }
352 }
353