zeroed-some/wanda / da099f6

Browse files

add inject command for Steam launch option WeMod injection

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
da099f6a0de0015a590dfc7bcd4cc22e1fd3f180
Parents
e03a3fe
Tree
3f2a6f1

1 changed file

StatusFile+-
A crates/wanda-cli/src/commands/inject.rs 871 0
crates/wanda-cli/src/commands/inject.rsadded
@@ -0,0 +1,871 @@
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
+}