fix(game): detect live Steam appid via /proc SteamAppId, not stale registry.vdf
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled

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/<pid>/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.
This commit is contained in:
2026-06-21 16:11:17 -04:00
parent 3878e716dd
commit fad65a4fcf
+89 -11
View File
@@ -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/<pid>/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<u32> {
#[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<PathBuf> {
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<u32> {
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<u32> {
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::<u32>()
&& id != 0
{
return Some(id);
}
}
None
}
/// The live Steam appid of a running game, found by scanning `/proc/<pid>/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<u32> {
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" }"#;