Rust · 29645 bytes Raw Blame History
1 //! wanda inject - Steam launch option wrapper for WeMod injection
2 //!
3 //! Usage: Set Steam launch options for a game to:
4 //! wanda inject %command%
5 //!
6 //! This intercepts the game's launch, sets up WeMod in the game's own Proton
7 //! prefix, and creates a batch file that:
8 //! - On first run: starts WeMod and keeps the session alive
9 //! - On subsequent runs (from WeMod's Play button): launches the game directly
10 //! into the existing Wine session so WeMod can detect and hook it
11
12 use clap::Args;
13 use std::fs;
14 use std::io::Write;
15 use std::os::unix::process::CommandExt;
16 use std::path::{Path, PathBuf};
17 use tracing::{debug, info, warn};
18 use wanda_core::{
19 config::WandaConfig,
20 prefix::PrefixBuilder,
21 steam::{ProtonCompatibility, ProtonVersion},
22 Result, WandaError,
23 };
24
25 #[derive(Args)]
26 pub struct InjectArgs {
27 /// The Steam launch command (%command%)
28 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
29 command: Vec<String>,
30 }
31
32 /// Log a message to the wanda log file
33 fn log_to_file(msg: &str) {
34 let log_path = WandaConfig::default_data_dir().join("wanda.log");
35 if let Some(parent) = log_path.parent() {
36 let _ = fs::create_dir_all(parent);
37 }
38 if let Ok(mut file) = fs::OpenOptions::new()
39 .create(true)
40 .append(true)
41 .open(&log_path)
42 {
43 let timestamp = std::time::SystemTime::now()
44 .duration_since(std::time::UNIX_EPOCH)
45 .map(|d| d.as_secs())
46 .unwrap_or(0);
47 let _ = writeln!(file, "[{}] [inject] {}", timestamp, msg);
48 }
49 }
50
51 /// Parsed components of the Steam launch command
52 struct ParsedCommand {
53 /// Path to the Proton installation directory
54 proton_dir: PathBuf,
55 /// Path to the game executable (Linux path) — Steam's original
56 game_exe: PathBuf,
57 /// Path to the real game executable (bypassing EAC launcher if present)
58 real_game_exe: PathBuf,
59 /// Index of the game exe in the original args (for replacement)
60 game_exe_idx: usize,
61 }
62
63 /// Parse the Steam launch command by finding the Proton script and game exe.
64 ///
65 /// Steam's launch pipeline uses multiple `--` separators:
66 /// ```text
67 /// steam-launch-wrapper -- reaper SteamLaunch AppId=XXXX --
68 /// _v2-entry-point --verb=waitforexitandrun --
69 /// proton waitforexitandrun /path/to/game.exe
70 /// ```
71 ///
72 /// We find the actual `proton` script by scanning for a path whose filename
73 /// is `proton` and exists on disk, then take the game exe as the arg after
74 /// the verb.
75 fn parse_command(raw_args: &[String]) -> Result<ParsedCommand> {
76 // Find the Proton script: last arg whose filename is "proton" and exists
77 let proton_idx = raw_args
78 .iter()
79 .rposition(|arg| {
80 let path = Path::new(arg);
81 path.file_name().map_or(false, |name| name == "proton")
82 && path.exists()
83 })
84 .ok_or_else(|| WandaError::LaunchFailed {
85 reason: format!(
86 "Could not find Proton script in command args: {:?}",
87 raw_args
88 ),
89 })?;
90
91 let proton_exe = PathBuf::from(&raw_args[proton_idx]);
92 let proton_dir = proton_exe
93 .parent()
94 .ok_or_else(|| WandaError::LaunchFailed {
95 reason: format!(
96 "Cannot determine Proton directory from: {}",
97 proton_exe.display()
98 ),
99 })?
100 .to_path_buf();
101
102 // Verb is the arg after proton (e.g., "waitforexitandrun")
103 let verb_idx = proton_idx + 1;
104 if verb_idx >= raw_args.len() {
105 return Err(WandaError::LaunchFailed {
106 reason: "No verb found after Proton script".into(),
107 });
108 }
109
110 // Game exe is the arg after the verb
111 let game_idx = verb_idx + 1;
112 if game_idx >= raw_args.len() {
113 return Err(WandaError::LaunchFailed {
114 reason: "No game executable found after Proton verb".into(),
115 });
116 }
117
118 let game_exe = PathBuf::from(&raw_args[game_idx]);
119
120 // Find the real game exe, bypassing anti-cheat launchers.
121 // Steam launches `start_protected_game.exe` (EAC) which internally
122 // starts the real game. WeMod scans for the real exe, not the
123 // launcher. Look for a sibling exe that isn't an anti-cheat launcher.
124 let real_game_exe = find_real_game_exe(&game_exe).unwrap_or_else(|| game_exe.clone());
125
126 Ok(ParsedCommand {
127 proton_dir,
128 game_exe,
129 real_game_exe,
130 game_exe_idx: game_idx,
131 })
132 }
133
134 /// Find the real game executable, bypassing anti-cheat launchers.
135 ///
136 /// When Steam launches `start_protected_game.exe` (EAC) or similar,
137 /// the real game exe is usually a sibling in the same directory.
138 /// WeMod scans for the real exe via QueryFullProcessImageName.
139 fn find_real_game_exe(steam_game_exe: &Path) -> Option<PathBuf> {
140 let name = steam_game_exe
141 .file_name()?
142 .to_string_lossy()
143 .to_lowercase();
144
145 // If Steam's exe isn't an anti-cheat launcher, use it directly
146 let ac_patterns = [
147 "start_protected",
148 "easyanticheat",
149 "battleye",
150 "eac_",
151 ];
152 if !ac_patterns.iter().any(|p| name.contains(p)) {
153 return None; // Already the real exe
154 }
155
156 let dir = steam_game_exe.parent()?;
157 let exclude = [
158 "start_protected",
159 "easyanticheat",
160 "battleye",
161 "eac_",
162 "unins",
163 "crash",
164 "report",
165 "redist",
166 "setup",
167 "dxsetup",
168 "vcredist",
169 "dotnet",
170 "directx",
171 "ue4prereq",
172 "installer",
173 "updater",
174 ];
175
176 // Look for a non-excluded .exe in the same directory
177 let mut candidates: Vec<PathBuf> = Vec::new();
178 if let Ok(entries) = fs::read_dir(dir) {
179 for entry in entries.flatten() {
180 let path = entry.path();
181 if path.extension().map_or(false, |e| e == "exe") && path.is_file() {
182 let fname = path
183 .file_name()
184 .unwrap_or_default()
185 .to_string_lossy()
186 .to_lowercase();
187 if !exclude.iter().any(|ex| fname.contains(ex)) {
188 candidates.push(path);
189 }
190 }
191 }
192 }
193
194 // Prefer the largest exe (usually the real game, not a small launcher)
195 candidates.sort_by(|a, b| {
196 let sa = fs::metadata(a).map(|m| m.len()).unwrap_or(0);
197 let sb = fs::metadata(b).map(|m| m.len()).unwrap_or(0);
198 sb.cmp(&sa)
199 });
200
201 candidates.into_iter().next()
202 }
203
204 /// Find the WeMod installation in wanda's default prefix
205 fn find_wanda_wemod(config: &WandaConfig) -> Result<PathBuf> {
206 let prefix_base = config.prefix_base_path();
207 let default_prefix = prefix_base.join("default");
208
209 // Check WeMod branding (v11.x)
210 let wemod_path =
211 default_prefix.join("pfx/drive_c/users/steamuser/AppData/Local/WeMod");
212 if wemod_path.exists() {
213 return Ok(wemod_path);
214 }
215
216 // Check Wand branding (v12.x)
217 let wand_path =
218 default_prefix.join("pfx/drive_c/users/steamuser/AppData/Local/Wand");
219 if wand_path.exists() {
220 return Ok(wand_path);
221 }
222
223 Err(WandaError::WemodNotInstalled)
224 }
225
226 /// Find the real WeMod app exe (not the Squirrel stub) in a WeMod directory.
227 /// Rejects v12+ which causes black window/renderer crashes under Wine.
228 fn find_wemod_app_exe(wemod_dir: &Path) -> Option<PathBuf> {
229 let mut best: Option<(String, PathBuf)> = None;
230 if let Ok(entries) = fs::read_dir(wemod_dir) {
231 for entry in entries.flatten() {
232 let name = entry.file_name().to_string_lossy().to_string();
233 if name.starts_with("app-") && entry.path().is_dir() {
234 // Skip v12+ (black screen under Wine)
235 let version_part = name.trim_start_matches("app-");
236 if let Some(major) = version_part.split('.').next() {
237 if major.parse::<u32>().unwrap_or(0) >= 12 {
238 debug!("Skipping incompatible WeMod version: {}", name);
239 continue;
240 }
241 }
242 let exe = entry.path().join("WeMod.exe");
243 if exe.exists() {
244 if best.as_ref().map_or(true, |(v, _)| name > *v) {
245 best = Some((name, exe));
246 }
247 }
248 }
249 }
250 }
251 best.map(|(_, path)| path)
252 }
253
254 /// Symlink WeMod from wanda's prefix into the game's prefix
255 fn sync_wemod_to_prefix(wanda_wemod: &Path, compat_data: &Path) -> Result<()> {
256 let target_local =
257 compat_data.join("pfx/drive_c/users/steamuser/AppData/Local");
258 fs::create_dir_all(&target_local).map_err(|e| WandaError::LaunchFailed {
259 reason: format!("Failed to create AppData/Local in game prefix: {}", e),
260 })?;
261
262 let dir_name = wanda_wemod
263 .file_name()
264 .unwrap_or_default()
265 .to_string_lossy()
266 .to_string();
267 let link_path = target_local.join(&dir_name);
268
269 if link_path.exists() {
270 if link_path.is_symlink() {
271 debug!("WeMod symlink already exists at {}", link_path.display());
272 return Ok(());
273 }
274 // Real directory exists — leave it alone, WeMod may have been installed
275 // directly into this prefix
276 debug!(
277 "WeMod directory already exists (not a symlink) at {}",
278 link_path.display()
279 );
280 return Ok(());
281 }
282
283 info!(
284 "Symlinking WeMod: {} -> {}",
285 link_path.display(),
286 wanda_wemod.display()
287 );
288 log_to_file(&format!(
289 "Symlinking WeMod: {} -> {}",
290 link_path.display(),
291 wanda_wemod.display()
292 ));
293 std::os::unix::fs::symlink(wanda_wemod, &link_path).map_err(|e| {
294 WandaError::LaunchFailed {
295 reason: format!("Failed to symlink WeMod: {}", e),
296 }
297 })?;
298
299 Ok(())
300 }
301
302 /// Extract the Steam library root from a game exe path.
303 ///
304 /// Game exe is like: `/path/to/steamapps/common/GAME/game.exe`
305 /// Library root is: `/path/to`
306 fn library_root_from_game_exe(game_exe: &Path) -> Option<&Path> {
307 let game_str = game_exe.to_str()?;
308 let pos = game_str.find("/steamapps/common/")?;
309 Some(Path::new(&game_str[..pos]))
310 }
311
312 /// Set up Steam library structure in the game's prefix so WeMod can find games
313 fn setup_steam_library(compat_data: &Path, game_exe: &Path) -> Result<()> {
314 let steamapps_dir =
315 compat_data.join("pfx/drive_c/Program Files (x86)/Steam/steamapps");
316 fs::create_dir_all(&steamapps_dir).map_err(|e| WandaError::LaunchFailed {
317 reason: format!("Failed to create Steam library in prefix: {}", e),
318 })?;
319
320 let library_root = match library_root_from_game_exe(game_exe) {
321 Some(root) => root,
322 None => {
323 warn!(
324 "Could not find steamapps/common in game path: {}",
325 game_exe.display()
326 );
327 return Ok(());
328 }
329 };
330
331 // Symlink common/ → real steamapps/common/
332 let common_link = steamapps_dir.join("common");
333 let common_target = library_root.join("steamapps/common");
334 if !common_link.exists() {
335 info!(
336 "Symlinking Steam common: {} -> {}",
337 common_link.display(),
338 common_target.display()
339 );
340 log_to_file(&format!(
341 "Symlinking common: {} -> {}",
342 common_link.display(),
343 common_target.display()
344 ));
345 std::os::unix::fs::symlink(&common_target, &common_link).map_err(
346 |e| WandaError::LaunchFailed {
347 reason: format!("Failed to symlink common: {}", e),
348 },
349 )?;
350 }
351
352 // Symlink appmanifest and write libraryfolders.vdf using app ID from env
353 if let Ok(app_id) = std::env::var("SteamAppId") {
354 let manifest_name = format!("appmanifest_{}.acf", app_id);
355 let manifest_target =
356 library_root.join("steamapps").join(&manifest_name);
357 let manifest_link = steamapps_dir.join(&manifest_name);
358
359 if !manifest_link.exists() && manifest_target.exists() {
360 info!("Symlinking appmanifest: {}", manifest_name);
361 log_to_file(&format!(
362 "Symlinking appmanifest: {} -> {}",
363 manifest_link.display(),
364 manifest_target.display()
365 ));
366 std::os::unix::fs::symlink(&manifest_target, &manifest_link)
367 .map_err(|e| WandaError::LaunchFailed {
368 reason: format!("Failed to symlink appmanifest: {}", e),
369 })?;
370 }
371
372 // Write libraryfolders.vdf so WeMod can discover the game library
373 let vdf_path = steamapps_dir.join("libraryfolders.vdf");
374 let vdf_content = format!(
375 r#""libraryfolders"
376 {{
377 "0"
378 {{
379 "path" "C:\\Program Files (x86)\\Steam"
380 "apps"
381 {{
382 "{}" "0"
383 }}
384 }}
385 }}"#,
386 app_id,
387 );
388 fs::write(&vdf_path, &vdf_content).map_err(|e| {
389 WandaError::LaunchFailed {
390 reason: format!("Failed to create libraryfolders.vdf: {}", e),
391 }
392 })?;
393 }
394
395 // Add Steam registry entries
396 setup_steam_registry(compat_data)?;
397
398 Ok(())
399 }
400
401 /// Write Steam registry entries into the game's Wine prefix
402 fn setup_steam_registry(compat_data: &Path) -> Result<()> {
403 let user_reg = compat_data.join("pfx/user.reg");
404 if !user_reg.exists() {
405 return Ok(());
406 }
407
408 let content = fs::read_to_string(&user_reg).map_err(|e| {
409 WandaError::LaunchFailed {
410 reason: format!("Failed to read user.reg: {}", e),
411 }
412 })?;
413
414 if content.contains("[Software\\\\Valve\\\\Steam]") {
415 debug!("Steam registry entries already exist");
416 return Ok(());
417 }
418
419 let app_id = std::env::var("SteamAppId").unwrap_or_default();
420
421 let timestamp = std::time::SystemTime::now()
422 .duration_since(std::time::UNIX_EPOCH)
423 .map(|d| d.as_secs())
424 .unwrap_or(0);
425
426 let steam_entry = format!(
427 r#"
428
429 [Software\\Valve\\Steam] {ts}
430 "Language"="english"
431 "SteamExe"="C:\\\\Program Files (x86)\\\\Steam\\\\steam.exe"
432 "SteamPath"="C:\\\\Program Files (x86)\\\\Steam"
433
434 [Software\\Valve\\Steam\\Apps\\{app_id}] {ts}
435 "Installed"=dword:00000001
436 "Running"=dword:00000000
437 "#,
438 ts = timestamp,
439 app_id = app_id,
440 );
441
442 info!("Adding Steam registry entries");
443 log_to_file("Adding Steam registry entries to game prefix");
444
445 let mut file = fs::OpenOptions::new()
446 .append(true)
447 .open(&user_reg)
448 .map_err(|e| WandaError::LaunchFailed {
449 reason: format!("Failed to open user.reg: {}", e),
450 })?;
451 file.write_all(steam_entry.as_bytes()).map_err(|e| {
452 WandaError::LaunchFailed {
453 reason: format!("Failed to write Steam registry: {}", e),
454 }
455 })?;
456
457 Ok(())
458 }
459
460 /// Convert a game exe path to its C: drive equivalent through the Steam
461 /// library symlink set up in the prefix.
462 ///
463 /// `/path/to/steamapps/common/GAME/game.exe`
464 /// → `C:\Program Files (x86)\Steam\steamapps\common\GAME\game.exe`
465 fn to_c_drive_game_path(game_exe: &Path) -> Option<String> {
466 let game_str = game_exe.to_string_lossy();
467 let pos = game_str.find("/steamapps/common/")?;
468 let rel_path = &game_str[pos + "/steamapps/common/".len()..];
469 Some(format!(
470 "C:\\Program Files (x86)\\Steam\\steamapps\\common\\{}",
471 rel_path.replace('/', "\\")
472 ))
473 }
474
475 /// Compute the C: drive path for the WeMod exe inside the game's prefix.
476 ///
477 /// The WeMod directory is symlinked at:
478 /// `<prefix>/pfx/drive_c/users/steamuser/AppData/Local/WeMod/`
479 /// so the Windows path is:
480 /// `C:\users\steamuser\AppData\Local\WeMod\app-X.Y.Z\WeMod.exe`
481 fn wemod_c_drive_path(
482 compat_data: &Path,
483 wanda_wemod_dir: &Path,
484 ) -> Option<String> {
485 let dir_name = wanda_wemod_dir.file_name()?.to_string_lossy();
486 let prefix_wemod = compat_data
487 .join("pfx/drive_c/users/steamuser/AppData/Local")
488 .join(dir_name.as_ref());
489
490 // Find the app-X.Y.Z subdirectory (skip v12+ — black screen under Wine)
491 let mut best: Option<(String, String)> = None;
492 if let Ok(entries) = fs::read_dir(&prefix_wemod) {
493 for entry in entries.flatten() {
494 let name = entry.file_name().to_string_lossy().to_string();
495 if name.starts_with("app-") && entry.path().is_dir() {
496 let version_part = name.trim_start_matches("app-");
497 if let Some(major) = version_part.split('.').next() {
498 if major.parse::<u32>().unwrap_or(0) >= 12 {
499 continue;
500 }
501 }
502 let exe = entry.path().join("WeMod.exe");
503 if exe.exists() {
504 if best.as_ref().map_or(true, |(v, _)| name > *v) {
505 best = Some((
506 name.clone(),
507 format!(
508 "C:\\users\\steamuser\\AppData\\Local\\{}\\{}\\WeMod.exe",
509 dir_name, name
510 ),
511 ));
512 }
513 }
514 }
515 }
516 }
517
518 best.map(|(_, path)| path).or_else(|| {
519 // Fall back to root WeMod.exe
520 let root = prefix_wemod.join("WeMod.exe");
521 if root.exists() {
522 Some(format!(
523 "C:\\users\\steamuser\\AppData\\Local\\{}\\WeMod.exe",
524 dir_name
525 ))
526 } else {
527 None
528 }
529 })
530 }
531
532 /// Create the main inject batch: starts WeMod, then polls for a signal
533 /// file that the steam:// protocol proxy creates when WeMod clicks Play.
534 /// When the signal is detected, launches the game directly in the same
535 /// Wine session.
536 fn create_inject_batch(
537 compat_data: &Path,
538 wanda_wemod_dir: &Path,
539 game_exe: &Path,
540 game_args: &[String],
541 ) -> Result<PathBuf> {
542 let batch_path = compat_data.join("wanda_inject.bat");
543
544 let wemod_win_path =
545 wemod_c_drive_path(compat_data, wanda_wemod_dir).unwrap_or_else(|| {
546 let fallback = find_wemod_app_exe(wanda_wemod_dir)
547 .unwrap_or_else(|| wanda_wemod_dir.join("WeMod.exe"));
548 format!("Z:{}", fallback.to_string_lossy().replace('/', "\\"))
549 });
550 let game_win_path = to_c_drive_game_path(game_exe).unwrap_or_else(|| {
551 format!("Z:{}", game_exe.to_string_lossy().replace('/', "\\"))
552 });
553 let game_args_str = game_args.join(" ");
554
555 // Start WeMod and keep the session alive. WeMod calls CreateProcessW
556 // directly when the user clicks Play — the game starts in the same
557 // wineserver. We must NOT launch the game ourselves for trainers
558 // that require WeMod to create the process (suspended + DLL inject).
559 let batch_content = format!(
560 r#"@echo off
561 @title WANDA Inject
562 echo [WANDA] Starting WeMod...
563 echo [WANDA] WeMod: {wemod_path}
564 echo [WANDA] Game: {game_path}
565
566 REM Clear Steam app ID env vars so steamclient.dll inside WeMod does
567 REM not associate this session with the running game. Without this,
568 REM WeMod tells Steam "launch game" and Steam says "already running"
569 REM because our batch IS the running game from Steam's perspective.
570 set SteamAppId=
571 set SteamGameId=
572 set SteamOverlayGameId=
573 set STEAM_COMPAT_APP_ID=
574
575 start "" "{wemod_path}" --no-sandbox
576
577 echo [WANDA] Waiting for WeMod to initialize...
578 ping 127.0.0.1 -n 12 > NUL 2>&1
579
580 echo.
581 echo [WANDA] WeMod is running.
582 echo [WANDA] Find your game in WeMod and click Play.
583 echo [WANDA] WeMod will launch the game directly.
584 echo [WANDA] Press Stop in Steam to end the session.
585 echo.
586
587 :keepalive
588 ping 127.0.0.1 -n 30 > NUL 2>&1
589 goto keepalive
590 "#,
591 wemod_path = wemod_win_path,
592 game_path = game_win_path,
593 );
594
595 let batch_crlf = batch_content.replace('\n', "\r\n");
596 fs::write(&batch_path, &batch_crlf).map_err(|e| {
597 WandaError::LaunchFailed {
598 reason: format!("Failed to create inject batch: {}", e),
599 }
600 })?;
601
602 log_to_file(&format!(
603 "Created inject batch at: {}",
604 batch_path.display()
605 ));
606 log_to_file(&format!("WeMod C: path: {}", wemod_win_path));
607 log_to_file(&format!("Game C: path: {}", game_win_path));
608
609 Ok(batch_path)
610 }
611
612 /// Check if a wineserver is already running for the game's prefix.
613 /// Uses Proton's wineserver binary with signal 0 (existence check).
614 fn is_wineserver_running(proton_dir: &Path, compat_data: &Path) -> bool {
615 let wineserver = proton_dir.join("files/bin/wineserver");
616 let prefix = compat_data.join("pfx");
617 if !wineserver.exists() {
618 log_to_file("wineserver binary not found");
619 return false;
620 }
621
622 // Proton's wineserver needs its own libraries
623 let lib64 = proton_dir.join("files/lib64");
624 let lib = proton_dir.join("files/lib");
625 let ld_path = format!("{}:{}", lib64.display(), lib.display());
626
627 let result = std::process::Command::new(&wineserver)
628 .env("WINEPREFIX", &prefix)
629 .env("LD_LIBRARY_PATH", &ld_path)
630 .arg("-k")
631 .arg("0")
632 .stdout(std::process::Stdio::null())
633 .stderr(std::process::Stdio::null())
634 .status();
635
636 match result {
637 Ok(status) => {
638 let running = status.success();
639 log_to_file(&format!("wineserver check: running={}", running));
640 running
641 }
642 Err(e) => {
643 log_to_file(&format!("wineserver check failed: {}", e));
644 false
645 }
646 }
647 }
648
649 /// Set up steam:// protocol proxy so WeMod's Play button launches the
650 /// game directly inside the Wine session (instead of going through
651 /// native Steam, which creates a separate Proton session).
652 fn setup_steam_protocol_proxy(
653 compat_data: &Path,
654 game_exe: &Path,
655 ) -> Result<()> {
656 let pfx = compat_data.join("pfx");
657
658 // Create proxy batch at C:\wanda\steam_proxy.bat
659 // This is triggered when WeMod calls steam://rungameid/<appid>.
660 // Instead of launching the game directly, it creates a signal file
661 // that the main inject batch polls for.
662 let wanda_dir = pfx.join("drive_c/wanda");
663 let _ = fs::create_dir_all(&wanda_dir);
664 let proxy_path = wanda_dir.join("steam_proxy.bat");
665
666 let proxy_content = r#"@echo off
667 REM Wanda Steam Protocol Proxy — signals the inject batch to launch the game
668 echo [WANDA] Steam launch intercepted: %~1
669 echo [WANDA] Creating launch signal...
670 echo launch > C:\wanda\launch_signal
671 "#;
672 let proxy_crlf = proxy_content.replace('\n', "\r\n").to_string();
673 fs::write(&proxy_path, &proxy_crlf).map_err(|e| WandaError::LaunchFailed {
674 reason: format!("Failed to create steam proxy: {}", e),
675 })?;
676
677 // Override steam:// protocol handler in user.reg
678 let user_reg = pfx.join("user.reg");
679 if !user_reg.exists() {
680 return Ok(());
681 }
682 let content = fs::read_to_string(&user_reg).unwrap_or_default();
683 if content.contains("wanda\\\\steam_proxy.bat") {
684 return Ok(());
685 }
686
687 let timestamp = std::time::SystemTime::now()
688 .duration_since(std::time::UNIX_EPOCH)
689 .map(|d| d.as_secs())
690 .unwrap_or(0);
691
692 let registry = format!(
693 concat!(
694 "\n",
695 "[Software\\\\Classes\\\\steam] {ts}\n",
696 "@=\"URL:Steam Protocol\"\n",
697 "\"URL Protocol\"=\"\"\n",
698 "\n",
699 "[Software\\\\Classes\\\\steam\\\\shell\\\\open\\\\command] {ts}\n",
700 "@=\"\\\"C:\\\\wanda\\\\steam_proxy.bat\\\" \\\"%1\\\"\"\n",
701 ),
702 ts = timestamp,
703 );
704
705 let mut file = fs::OpenOptions::new()
706 .append(true)
707 .open(&user_reg)
708 .map_err(|e| WandaError::LaunchFailed {
709 reason: format!("Failed to open user.reg: {}", e),
710 })?;
711 file.write_all(registry.as_bytes()).map_err(|e| {
712 WandaError::LaunchFailed {
713 reason: format!("Failed to write steam:// override: {}", e),
714 }
715 })?;
716
717 log_to_file("Set up steam:// protocol proxy in game prefix");
718 Ok(())
719 }
720
721 pub async fn run(_args: InjectArgs, config_path: Option<PathBuf>) -> Result<()> {
722 // Log immediately so we can tell if inject was even called
723 log_to_file("=== WANDA INJECT START ===");
724 log_to_file(&format!("PID: {}", std::process::id()));
725
726 // Get raw args from environment to preserve `--` separators
727 let raw_args: Vec<String> = std::env::args().collect();
728 log_to_file(&format!("Raw argv: {:?}", raw_args));
729
730 let inject_pos = raw_args
731 .iter()
732 .position(|a| a == "inject")
733 .ok_or_else(|| WandaError::LaunchFailed {
734 reason: "Could not find 'inject' in argv".into(),
735 })?;
736 let command_args: Vec<String> = raw_args[inject_pos + 1..].to_vec();
737
738 log_to_file(&format!("Command args: {:?}", command_args));
739
740 if command_args.is_empty() {
741 return Err(WandaError::LaunchFailed {
742 reason: "No command provided. Usage: wanda inject %command%".into(),
743 });
744 }
745
746 let parsed = parse_command(&command_args)?;
747
748 log_to_file(&format!("Proton dir: {}", parsed.proton_dir.display()));
749 log_to_file(&format!("Game exe (Steam): {}", parsed.game_exe.display()));
750 log_to_file(&format!("Game exe (real): {}", parsed.real_game_exe.display()));
751 log_to_file(&format!(
752 "Game exe at index {} in command args",
753 parsed.game_exe_idx
754 ));
755
756 let compat_data = std::env::var("STEAM_COMPAT_DATA_PATH")
757 .map(PathBuf::from)
758 .map_err(|_| WandaError::LaunchFailed {
759 reason: "STEAM_COMPAT_DATA_PATH not set. \
760 Are you running this from Steam launch options?"
761 .into(),
762 })?;
763
764 log_to_file(&format!("Compat data path: {}", compat_data.display()));
765
766 // ── Check if WeMod is already running (wineserver active) ──────────
767 //
768 // If `wanda launch --standalone` started WeMod in the game's prefix,
769 // the wineserver is already running. In that case, just pass through
770 // the original Steam command unmodified — Proton will connect to the
771 // existing wineserver, and the game will run alongside WeMod.
772 if is_wineserver_running(&parsed.proton_dir, &compat_data) {
773 log_to_file("Wineserver active — passthrough mode (WeMod already running)");
774 eprintln!("[wanda inject] WeMod session detected — launching game into existing session");
775
776 let program = &command_args[0];
777 let exec_args = &command_args[1..];
778
779 log_to_file(&format!("Passthrough exec: {} {:?}", program, exec_args));
780
781 let err = std::process::Command::new(program)
782 .args(exec_args)
783 .exec();
784
785 return Err(WandaError::LaunchFailed {
786 reason: format!("Failed to exec {}: {}", program, err),
787 });
788 }
789
790 // ── No wineserver: full setup + WeMod launch ───────────────────────
791 log_to_file("No active wineserver — full inject mode");
792
793 let config = match &config_path {
794 Some(path) => WandaConfig::load_from(path)?,
795 None => WandaConfig::load()?,
796 };
797
798 let wanda_wemod = find_wanda_wemod(&config)?;
799 log_to_file(&format!("Found WeMod at: {}", wanda_wemod.display()));
800
801 // Setup (all idempotent)
802 sync_wemod_to_prefix(&wanda_wemod, &compat_data)?;
803
804 let wanda_roaming = {
805 let prefix_base = config.prefix_base_path();
806 prefix_base.join(
807 "default/pfx/drive_c/users/steamuser/AppData/Roaming/WeMod",
808 )
809 };
810 if wanda_roaming.exists() {
811 let target_roaming = compat_data
812 .join("pfx/drive_c/users/steamuser/AppData/Roaming");
813 let _ = fs::create_dir_all(&target_roaming);
814 let roaming_link = target_roaming.join("WeMod");
815 if !roaming_link.exists() {
816 let _ =
817 std::os::unix::fs::symlink(&wanda_roaming, &roaming_link);
818 }
819 }
820
821 let proton = ProtonVersion {
822 name: parsed
823 .proton_dir
824 .file_name()
825 .unwrap_or_default()
826 .to_string_lossy()
827 .to_string(),
828 path: parsed.proton_dir.clone(),
829 version: (0, 0, 0),
830 is_ge: false,
831 is_experimental: false,
832 compatibility: ProtonCompatibility::Supported,
833 };
834 let builder = PrefixBuilder::new(&compat_data, &proton);
835 if let Err(e) = builder.patch_mscorlib_version() {
836 log_to_file(&format!("Warning: mscorlib patch failed: {}", e));
837 warn!("mscorlib patch failed (may not be critical): {}", e);
838 }
839
840 setup_steam_library(&compat_data, &parsed.game_exe)?;
841
842 let game_args = &command_args[parsed.game_exe_idx + 1..];
843 let batch_path = create_inject_batch(
844 &compat_data,
845 &wanda_wemod,
846 &parsed.real_game_exe,
847 game_args,
848 )?;
849
850 let batch_wine_path = format!("Z:{}", batch_path.to_string_lossy());
851
852 let mut exec_args: Vec<String> =
853 command_args[1..parsed.game_exe_idx].to_vec();
854 exec_args.push(batch_wine_path.clone());
855
856 let program = &command_args[0];
857
858 log_to_file(&format!("Exec: {} {:?}", program, exec_args));
859 eprintln!(
860 "[wanda inject] Starting WeMod session for {} — click Play in WeMod to launch",
861 parsed.game_exe.file_name().unwrap_or_default().to_string_lossy()
862 );
863
864 let err = std::process::Command::new(program)
865 .args(&exec_args)
866 .exec();
867
868 Err(WandaError::LaunchFailed {
869 reason: format!("Failed to exec {}: {}", program, err),
870 })
871 }
872