//! Launch the game directly, without an external shell script. //! //! # Why this exists //! //! The launcher used to shell out to a user-written script (`game_launch_command`) //! that set the Proton environment, prepared the Wine prefix, regenerated the //! DRM licence and finally ran the game. That script lived on the user's Desktop //! — and on 2026-08-11 it was moved to the Trash, after which every launch failed //! with `sh: No such file or directory`. Three unrelated client-side faults that //! morning each looked like "the game crashed"; none of them were. //! //! Everything the script did is mechanical and belongs inside the launcher, where //! it cannot be deleted, is covered by tests, and reports failures into the same //! log buffer as the rest of the launch. //! //! # What stays out of this file //! //! Every FIFA-17 fact — the runner, the executable, the prefix path, the `w:` //! drive symlink, the licence file id — is [`GameProfile`] *data*, not code. //! OpenFUT is not a FIFA 17 project; FIFA 17 is its first reference target. A //! second game must be a different profile, never a second branch in here. //! //! `game_launch_command` remains as an escape hatch: an unconfigured profile //! falls back to it, so an existing working setup cannot be broken by upgrading. use parking_lot::Mutex; #[cfg(unix)] use std::collections::BTreeMap; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::Arc; #[cfg(unix)] use std::time::{Duration, Instant}; use crate::config::GameProfile; use crate::logs::LogBuffer; type Log = Arc>; fn say(log: &Log, msg: impl Into) { log.lock().push(msg.into()); } /// Prepare the prefix, satisfy the licence precondition, and start the game. /// /// Returns once the game process has been spawned; its output continues to /// stream into `log` on background threads. `on_exit` fires when the process /// ends, which is how the launch state machine leaves its Running state. #[cfg(unix)] pub fn launch( profile: &GameProfile, log: &Log, on_exit: impl FnOnce() + Send + 'static, ) -> anyhow::Result<()> { profile.validate().map_err(anyhow::Error::msg)?; let game_dir = PathBuf::from(&profile.game_dir); if !game_dir.is_dir() { anyhow::bail!("game_dir does not exist: {}", game_dir.display()); } prepare_prefix(profile, log)?; ensure_dll_override(profile, log); ensure_license(profile, log)?; let mut cmd = Command::new(&profile.runner); cmd.arg(&profile.executable) .current_dir(&game_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()); for (k, v) in &profile.env { cmd.env(k, v); } cmd.env("WINEDLLOVERRIDES", hook_dll_overrides(&profile.env)); if !profile.wine_prefix.trim().is_empty() { cmd.env("WINEPREFIX", &profile.wine_prefix); } say( log, format!( "[launcher] launching {} {} (cwd {})", profile.runner, profile.executable, game_dir.display() ), ); let child = cmd .spawn() .map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?; stream( child, log.clone(), "[launcher] game process exited.", on_exit, ); Ok(()) } /// Windows-native launch: no Wine prefix, no `WINEDLLOVERRIDES` (the game loads /// the `version.dll` hook from its own directory through the normal search /// order), and no licence regeneration (the native loader handles DRM). /// Routing is the `openfut.cfg` that the client-files step already wrote into /// the game directory. /// /// The launcher must itself be running elevated (its shortcut carries the /// RunAsAdmin bit): the loader requires administrator rights, and a child /// started with `CreateProcess` inherits the launcher's token instead of /// raising its own UAC prompt. #[cfg(windows)] pub fn launch( profile: &GameProfile, log: &Log, on_exit: impl FnOnce() + Send + 'static, ) -> anyhow::Result<()> { profile.validate().map_err(anyhow::Error::msg)?; let game_dir = PathBuf::from(&profile.game_dir); if !game_dir.is_dir() { anyhow::bail!("game_dir does not exist: {}", game_dir.display()); } let exe = game_dir.join(&profile.executable); if !exe.is_file() { anyhow::bail!("game executable not found: {}", exe.display()); } let mut cmd = Command::new(&exe); cmd.current_dir(&game_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()); for (k, v) in &profile.env { cmd.env(k, v); } say( log, format!( "[launcher] launching {} (cwd {})", exe.display(), game_dir.display() ), ); let child = cmd .spawn() .map_err(|e| anyhow::anyhow!("could not start {}: {e}", exe.display()))?; stream( child, log.clone(), "[launcher] game process exited.", on_exit, ); Ok(()) } /// The registry key Wine reads DLL overrides from, and the one value the hook needs. /// /// Wine loads its own builtin `version.dll` unless an override says otherwise, so the /// game-directory proxy is ignored by default. `WINEDLLOVERRIDES` fixes that only for /// a process we spawn ourselves — it cannot help a player who presses Play in Steam, /// which is why the old advice was to paste launch options by hand (see /// `setup::STEAM_LAUNCH_OPTIONS`). Asking a player to edit launch options is exactly /// the kind of step that makes this unusable for anyone who does not already know what /// a DLL override is. /// /// Persisting the override in the prefix registry removes the manual step entirely: it /// survives restarts and applies to every launch path, including Steam. This mirrors /// what BepInEx documents for Proton (configure the proxy in winecfg rather than the /// environment) and what Proton itself already does in this prefix for other titles. #[cfg(unix)] const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides"; #[cfg(unix)] const HOOK_DLL_VALUE: &str = "version"; #[cfg(unix)] const HOOK_DLL_OVERRIDE: &str = "native,builtin"; /// `reg add` argv that persists the hook's DLL override, native-first with a builtin /// fallback. `/f` makes it idempotent, so this is safe to run on every launch and /// repairs a prefix a player has reset or replaced. #[cfg(unix)] fn dll_override_args() -> [&'static str; 10] { [ "reg", "add", DLL_OVERRIDE_KEY, "/v", HOOK_DLL_VALUE, "/t", "REG_SZ", "/d", HOOK_DLL_OVERRIDE, "/f", ] } /// Persist the hook's DLL override into the prefix, so the game loads the proxy no /// matter how it is started. /// /// Best-effort by design: a failure here is not fatal, because a launch we spawn also /// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine /// error, since the player cannot act on the latter. #[cfg(unix)] fn ensure_dll_override(profile: &GameProfile, log: &Log) { if profile.wine_prefix.trim().is_empty() { return; } let mut cmd = Command::new(&profile.runner); cmd.args(dll_override_args()) .current_dir(&profile.game_dir) .stdout(Stdio::null()) .stderr(Stdio::null()); for (k, v) in &profile.env { cmd.env(k, v); } cmd.env("WINEPREFIX", &profile.wine_prefix); match cmd.status() { Ok(status) if status.success() => { say(log, "[launcher] game files ready (mod support enabled)"); } Ok(_) | Err(_) => say( log, "[launcher] could not pre-enable mod support in the game prefix; \ launching anyway (this launch still enables it directly)", ), } } #[cfg(all(test, unix))] mod override_tests { use super::*; #[test] fn dll_override_is_persisted_native_first_and_idempotently() { let args = dll_override_args(); assert_eq!(args[0], "reg"); assert_eq!(args[1], "add"); assert_eq!( args[2], r"HKCU\Software\Wine\DllOverrides", "Wine reads overrides from this key; a typo silently leaves the hook unloaded" ); assert_eq!(args[4], "version", "the hook ships as a version.dll proxy"); assert_eq!( args[8], "native,builtin", "native first so the proxy wins, builtin as fallback so a missing proxy \ cannot make the game unlaunchable" ); assert_eq!( args[9], "/f", "idempotent, so running it on every launch repairs a reset prefix" ); } } /// The `WINEDLLOVERRIDES` value the game must be started with. /// /// The hook ships as a `version.dll` proxy inside the game directory, and Proton /// prefers a local DLL over its own builtin ONLY when `WINEDLLOVERRIDES` names it /// (see `setup::STEAM_LAUNCH_OPTIONS`). Steam users get that from their launch /// options; when the launcher spawns the runner itself, nothing else supplies it. /// /// Without it the failure is silent and badly misleading: the hook never loads, so /// the `openfut.cfg` the launcher just wrote is inert, the game ignores the /// configured Blaze ports, and `/etc/hosts` quietly routes it to whatever answers /// on EA's real ports. It looks like a working launch against the configured /// server while actually talking to a different one. /// /// A profile that already pins `version=` wins: an operator overriding the hijack /// deliberately must not be silently overruled. #[cfg(unix)] fn hook_dll_overrides(env: &BTreeMap) -> String { const HOOK: &str = "version=n,b"; match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) { Some(existing) if existing.contains("version=") => existing.to_string(), Some(existing) if !existing.is_empty() => format!("{existing};{HOOK}"), _ => HOOK.to_string(), } } /// Create the Wine prefix's `dosdevices` entries the profile asks for. /// /// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn `: /// an existing link is replaced, so re-running is harmless. #[cfg(unix)] fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() { return Ok(()); } let prefix = PathBuf::from(&profile.wine_prefix); for link in &profile.prefix_links { let path = prefix.join(&link.link); let parent = path .parent() .ok_or_else(|| anyhow::anyhow!("prefix link has no parent: {}", link.link))?; std::fs::create_dir_all(parent)?; // Replace rather than fail: `ln -sfn` semantics. Only ever remove a // symlink — refusing on a real file avoids destroying prefix contents // if a profile is misconfigured. match std::fs::symlink_metadata(&path) { Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_file(&path)?, Ok(_) => anyhow::bail!( "refusing to replace {}: it exists and is not a symlink", path.display() ), Err(_) => {} } std::os::unix::fs::symlink(&link.target, &path)?; say( log, format!( "[launcher] prefix link {} -> {}", path.display(), link.target ), ); } Ok(()) } /// Make sure the DRM licence file exists, running the generator if it does not. /// /// A crashed or failed launch deletes the licence, so this runs before every /// launch rather than only on first setup — that is the behaviour the shell /// script proved, and it is why a crash is normally self-healing on the next try. #[cfg(unix)] fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> { let Some(lic) = &profile.license else { return Ok(()); }; let path = resolve_under_prefix(&profile.wine_prefix, &lic.path); if non_empty_file(&path) { return Ok(()); } say( log, format!( "[launcher] licence missing ({}) — running {} to regenerate it", path.display(), lic.generator ), ); let mut cmd = Command::new(&profile.runner); cmd.arg(&lic.generator) .current_dir(&profile.game_dir) .stdout(Stdio::null()) .stderr(Stdio::null()); for (k, v) in &profile.env { cmd.env(k, v); } if !profile.wine_prefix.trim().is_empty() { cmd.env("WINEPREFIX", &profile.wine_prefix); } let mut child = cmd .spawn() .map_err(|e| anyhow::anyhow!("could not start licence generator: {e}"))?; let deadline = Instant::now() + Duration::from_secs(lic.timeout_secs.max(1)); while Instant::now() < deadline { if non_empty_file(&path) { stop_generator(&mut child, lic, log); say(log, "[launcher] licence regenerated."); return Ok(()); } std::thread::sleep(Duration::from_millis(500)); } stop_generator(&mut child, lic, log); anyhow::bail!( "{} did not create {} within {}s. Run it manually, choose GENERATE, then launch again.", lic.generator, path.display(), lic.timeout_secs ) } /// Stop the licence generator and the Windows process it started. /// /// Killing the runner is not enough: it launches the executable through Proton, /// so the `.exe` outlives its parent. The shell script used `pkill -f` for this /// and it is reproduced deliberately — the pattern is a Windows executable name, /// which cannot match the launcher or a shell running it. (A `pkill -f` pattern /// that *can* match its own caller is a real hazard; this one cannot.) #[cfg(unix)] fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) { let _ = child.kill(); let _ = child.wait(); match Command::new("pkill").arg("-f").arg(&lic.generator).status() { Ok(_) => {} Err(e) => say( log, format!( "[launcher] note: could not run pkill for {}: {e}", lic.generator ), ), } } /// A relative licence path is taken as relative to the Wine prefix; an absolute /// one is used as given. #[cfg(unix)] fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf { let p = Path::new(path); if p.is_absolute() || prefix.trim().is_empty() { p.to_path_buf() } else { Path::new(prefix).join(p) } } /// The script's `[[ -s FILE ]]`: present *and* non-empty. A zero-byte licence is /// as useless as a missing one, and treating it as valid would skip the /// regeneration that fixes it. #[cfg(unix)] fn non_empty_file(path: &Path) -> bool { std::fs::metadata(path) .map(|m| m.len() > 0) .unwrap_or(false) } /// Pump a child's stdout and stderr into the log buffer and reap it. pub fn stream( mut child: Child, log: Log, exit_msg: &'static str, on_exit: impl FnOnce() + Send + 'static, ) { if let Some(out) = child.stdout.take() { let buf = Arc::clone(&log); std::thread::spawn(move || { for line in BufReader::new(out).lines().map_while(Result::ok) { buf.lock().push(line); } }); } if let Some(err) = child.stderr.take() { let buf = Arc::clone(&log); std::thread::spawn(move || { for line in BufReader::new(err).lines().map_while(Result::ok) { buf.lock().push(line); } }); } std::thread::spawn(move || { let _ = child.wait(); log.lock().push(exit_msg.to_string()); on_exit(); }); } #[cfg(all(test, unix))] mod tests { use super::*; use crate::config::{LicenseCheck, PrefixLink}; fn log() -> Log { Arc::new(Mutex::new(LogBuffer::new())) } fn tmpdir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("openfut-launch-test-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } #[test] fn a_relative_licence_path_is_resolved_under_the_prefix() { assert_eq!( resolve_under_prefix("/p", "drive_c/lic.dlf"), PathBuf::from("/p/drive_c/lic.dlf") ); // Absolute wins, so a profile can point outside the prefix. assert_eq!( resolve_under_prefix("/p", "/elsewhere/lic.dlf"), PathBuf::from("/elsewhere/lic.dlf") ); } #[test] fn a_zero_byte_licence_does_not_count_as_present() { let d = tmpdir("empty-lic"); let f = d.join("lic.dlf"); std::fs::write(&f, b"").unwrap(); assert!( !non_empty_file(&f), "an empty licence must trigger regeneration" ); std::fs::write(&f, b"x").unwrap(); assert!(non_empty_file(&f)); } #[test] fn prefix_links_are_created_and_are_idempotent() { let d = tmpdir("links"); let prefix = d.join("prefix"); let target = d.join("target"); std::fs::create_dir_all(&target).unwrap(); let profile = GameProfile { runner: "true".into(), executable: "x.exe".into(), game_dir: d.to_string_lossy().into(), wine_prefix: prefix.to_string_lossy().into(), prefix_links: vec![PrefixLink { link: "dosdevices/w:".into(), target: target.to_string_lossy().into(), }], ..GameProfile::default() }; prepare_prefix(&profile, &log()).expect("first run creates the link"); let link = prefix.join("dosdevices/w:"); assert!(std::fs::symlink_metadata(&link) .unwrap() .file_type() .is_symlink()); // Re-running must not fail — the launcher prepares the prefix on EVERY // launch, so a second launch would break if this were not idempotent. prepare_prefix(&profile, &log()).expect("second run replaces the link"); assert_eq!(std::fs::read_link(&link).unwrap(), target); } #[test] fn a_real_file_where_a_link_belongs_is_refused_not_deleted() { let d = tmpdir("clobber"); let prefix = d.join("prefix"); std::fs::create_dir_all(prefix.join("dosdevices")).unwrap(); let occupied = prefix.join("dosdevices/w:"); std::fs::write(&occupied, b"important").unwrap(); let profile = GameProfile { runner: "true".into(), executable: "x.exe".into(), game_dir: d.to_string_lossy().into(), wine_prefix: prefix.to_string_lossy().into(), prefix_links: vec![PrefixLink { link: "dosdevices/w:".into(), target: "/tmp".into(), }], ..GameProfile::default() }; assert!(prepare_prefix(&profile, &log()).is_err()); assert_eq!( std::fs::read(&occupied).unwrap(), b"important", "a misconfigured profile must not destroy prefix contents" ); } #[test] fn a_present_licence_skips_the_generator_entirely() { let d = tmpdir("lic-present"); let lic = d.join("lic.dlf"); std::fs::write(&lic, b"valid").unwrap(); let profile = GameProfile { runner: "/nonexistent/runner".into(), // would fail if it were run executable: "x.exe".into(), game_dir: d.to_string_lossy().into(), wine_prefix: d.to_string_lossy().into(), license: Some(LicenseCheck { path: "lic.dlf".into(), generator: "_gen.exe".into(), timeout_secs: 1, }), ..GameProfile::default() }; // Proves the skip: the runner path is invalid, so reaching the generator // would error. Ok() means it never tried. ensure_license(&profile, &log()).expect("present licence must short-circuit"); } /// The whole point of the licence step: a missing licence must actually run /// the generator and wait for it. Without this, deleting `ensure_license` /// entirely would still pass every other test in this file. #[test] fn a_missing_licence_runs_the_generator_and_waits_for_it() { let d = tmpdir("lic-regen"); let lic = d.join("lic.dlf"); let gen = d.join("gen.sh"); // Sleeps first, so passing requires actually waiting rather than // happening to observe a file that was already there. The target path // is baked in: `generator` is passed as ONE argument, exactly as // `umu-run "_fifa17.exe"` is. std::fs::write( &gen, format!( "#!/bin/sh\nsleep 1\nprintf licensed > '{}'\n", lic.display() ), ) .unwrap(); let profile = GameProfile { runner: "/bin/sh".into(), executable: "unused".into(), game_dir: d.to_string_lossy().into(), wine_prefix: d.to_string_lossy().into(), license: Some(LicenseCheck { generator: gen.to_string_lossy().into(), path: "lic.dlf".into(), timeout_secs: 10, }), ..GameProfile::default() }; assert!(!non_empty_file(&lic)); ensure_license(&profile, &log()).expect("generator should produce the licence"); assert!(non_empty_file(&lic), "licence was not created"); } #[test] fn a_generator_that_never_delivers_times_out_with_an_actionable_error() { let d = tmpdir("lic-timeout"); let gen = d.join("gen.sh"); std::fs::write(&gen, "#!/bin/sh\nexit 0\n").unwrap(); let profile = GameProfile { runner: "/bin/sh".into(), executable: "unused".into(), game_dir: d.to_string_lossy().into(), wine_prefix: d.to_string_lossy().into(), license: Some(LicenseCheck { generator: gen.to_string_lossy().into(), // runs, writes nothing path: "lic.dlf".into(), timeout_secs: 1, }), ..GameProfile::default() }; let err = ensure_license(&profile, &log()).unwrap_err().to_string(); assert!(err.contains("did not create"), "{err}"); assert!( err.contains("GENERATE"), "the error must say what to do: {err}" ); } #[test] fn launch_refuses_a_missing_game_dir_before_touching_anything() { let profile = GameProfile { runner: "true".into(), executable: "x.exe".into(), game_dir: "/definitely/not/here".into(), ..GameProfile::default() }; let err = launch(&profile, &log(), || {}).unwrap_err().to_string(); assert!(err.contains("game_dir does not exist"), "{err}"); } #[test] fn a_profile_without_overrides_still_gets_the_hook_hijack() { // The regression this guards: FIFA launched from the launcher ignored the // configured Blaze ports entirely, because Proton loaded its own builtin // version.dll and the hook proxy never ran. The launch looked healthy. assert_eq!(hook_dll_overrides(&BTreeMap::new()), "version=n,b"); } #[test] fn unrelated_overrides_are_preserved_and_appended_to() { let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "d3d11=n".to_string())]); assert_eq!(hook_dll_overrides(&env), "d3d11=n;version=n,b"); } #[test] fn an_explicit_version_override_is_never_overruled() { // An operator disabling the hijack on purpose must win, otherwise the // setting is a lie. let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "version=b".to_string())]); assert_eq!(hook_dll_overrides(&env), "version=b"); } #[test] fn a_blank_override_is_treated_as_absent_rather_than_appended_to() { let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), " ".to_string())]); assert_eq!(hook_dll_overrides(&env), "version=n,b"); } }