Files
openfut-launcher/src/game_launch.rs
T
funman300 057cf92c3b feat(launcher): native Windows support
Port the egui launcher to run natively on Windows (no Wine/Proton). The GUI,
launch state machine, config, health/account monitors, and openfut.cfg writing
are unchanged and cross-platform; only the effect layer is branched:

- game_launch: cfg(windows) launch spawns the game executable directly with its
  working dir (the version.dll hijack loads from the game dir; no WINEDLLOVERRIDES,
  Wine prefix, or licence regen). Requires the launcher to run elevated so the
  child inherits admin. Linux Proton path gated cfg(unix).
- arm: cfg(windows) is a no-op (routing is openfut.cfg, written by the client-files
  step; no ptrace_scope/DNAT/hosts). Linux arming gated cfg(unix).
- local_services: on Windows LSX/autopatch are in-process (stp-origin_emu.dll +
  version.dll hook), so ensure_running reports ready without spawning. Gated the
  unix-only CommandExt/process_group.
- preflight: cfg(windows) run() keeps only backend-reachable + hook-config checks.
- config: GameProfile configured()/validate() accept a runner-less Windows profile.

theme: fix a latent cross-platform panic — egui 0.29 keeps a Style per theme, so
set_style only reached the active one and TextStyle::resolve("Hero") panicked when
the other theme rendered. Install the full style into both themes and pin Dark.

Cross-built for x86_64-pc-windows-gnu; Linux build + 75 tests unchanged.
2026-08-20 19:21:01 +00:00

685 lines
24 KiB
Rust

//! 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<Mutex<LogBuffer>>;
fn say(log: &Log, msg: impl Into<String>) {
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, String>) -> 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 <target> <link>`:
/// 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");
}
}