From fad65a4fcff3ffa5b1fa787f4b0e7eb7cf56ffa4 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sun, 21 Jun 2026 16:11:17 -0400 Subject: [PATCH] fix(game): detect live Steam appid via /proc SteamAppId, not stale registry.vdf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field test found Steam games were never detected on Linux. Root cause: Steam rewrites ~/.steam/registry.vdf only on SHUTDOWN, so its RunningAppID is stale (often absent) while a game is actually running — polling it can never see the live game. Fix: on Linux, read the live appid from the running game's environment (SteamAppId in /proc//environ, the var Steam exports to every game process — the same signal MangoHud uses; readable for our own processes). registry.vdf stays as a best-effort fallback. Windows still reads the real registry's RunningAppID, which IS updated live there. Other Unix keeps the registry.vdf fallback. Pure parse_steam_app_id_from_environ() is unit-tested (nonzero filter, absent, substring-not-fooled, garbage). Also fixes a latent bug in the first draft where a single non-UTF8 SteamAppId value would abort the whole scan via ? instead of skipping. 396 lib tests, clippy --all-targets clean. --- src/game/steam.rs | 100 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 11 deletions(-) diff --git a/src/game/steam.rs b/src/game/steam.rs index b8d2985..dae87bf 100644 --- a/src/game/steam.rs +++ b/src/game/steam.rs @@ -138,23 +138,27 @@ impl SteamProbe { }) } - /// The live RunningAppID (nonzero), or `None`. Linux/macOS read the client's - /// `registry.vdf`; Windows reads the real registry. + /// The live RunningAppID (nonzero), or `None`. + /// + /// Platform notes: on **Windows** the real registry's `RunningAppID` is updated + /// live, so we read it. On **Linux** the client's `registry.vdf` is only + /// rewritten on Steam *shutdown* — it's stale while a game runs — so the live + /// signal is the running game process's `SteamAppId` environment variable + /// (`/proc//environ`, readable for our own processes; the same approach + /// MangoHud uses); `registry.vdf` stays as a best-effort fallback. Other Unix + /// (macOS) only has the `registry.vdf` fallback for now. fn running_app_id(&self) -> Option { #[cfg(windows)] { win::running_app_id() } - #[cfg(not(windows))] + #[cfg(target_os = "linux")] { - for path in registry_vdf_candidates() { - if let Some(contents) = read_capped(&path) - && let Some(id) = parse_running_app_id(&contents) - { - return Some(id); - } - } - None + running_app_id_from_environ().or_else(registry_running_app_id) + } + #[cfg(not(any(windows, target_os = "linux")))] + { + registry_running_app_id() } } @@ -266,6 +270,64 @@ fn registry_vdf_candidates() -> Vec { out } +/// Best-effort `RunningAppID` from the on-disk `registry.vdf`. ⚠️ Stale while a +/// game runs (Steam rewrites the file only on shutdown), so this is a *fallback* +/// behind the live `/proc` `SteamAppId` scan on Linux — not the primary signal. +#[cfg(not(windows))] +fn registry_running_app_id() -> Option { + for path in registry_vdf_candidates() { + if let Some(contents) = read_capped(&path) + && let Some(id) = parse_running_app_id(&contents) + { + return Some(id); + } + } + None +} + +/// Parse a Steam appid out of a process's raw `environ` blob (NUL-separated +/// `KEY=VALUE` pairs), reading the `SteamAppId` variable Steam exports to every +/// game process. Returns the appid only when present and nonzero. Pure + +/// unit-tested; the `/proc` iteration is the thin edge in +/// [`running_app_id_from_environ`]. +#[cfg(target_os = "linux")] +pub fn parse_steam_app_id_from_environ(environ: &[u8]) -> Option { + for kv in environ.split(|&b| b == 0) { + if let Some(val) = kv.strip_prefix(b"SteamAppId=") + && let Ok(s) = std::str::from_utf8(val) + && let Ok(id) = s.trim().parse::() + && id != 0 + { + return Some(id); + } + } + None +} + +/// The live Steam appid of a running game, found by scanning `/proc//environ` +/// for the `SteamAppId` Steam exports to the game's process tree. `environ` is +/// readable only for our own processes — exactly the ones a Steam game we launched +/// runs as — and we skip the rest. The live signal that replaces the stale +/// on-disk `registry.vdf` on Linux. +#[cfg(target_os = "linux")] +fn running_app_id_from_environ() -> Option { + let entries = std::fs::read_dir("/proc").ok()?; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if !name.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + // Cap the read: an environ is small; this bounds a pathological case. + if let Some(environ) = read_capped(&entry.path().join("environ")) + && let Some(id) = parse_steam_app_id_from_environ(environ.as_bytes()) + { + return Some(id); + } + } + None +} + #[cfg(windows)] mod win { //! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg` @@ -417,6 +479,22 @@ mod tests { assert!(parse_library_paths("totally broken {{{").is_empty()); } + #[cfg(target_os = "linux")] + #[test] + fn steam_app_id_parsed_from_environ_blob() { + // A realistic NUL-separated environ with SteamAppId among other vars. + let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0"; + assert_eq!(parse_steam_app_id_from_environ(environ), Some(440)); + // Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored. + assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None); + // Absent → None (a non-Steam process). + assert_eq!(parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), None); + // Not fooled by a different var that merely contains the substring. + assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None); + // Garbage value → None, no panic. + assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None); + } + #[test] fn app_name_extracts_and_filters_empty() { let acf = r#""AppState" { "appid" "440" "name" "Team Fortress 2" }"#;