Files
punktfunk/crates/punktfunk-host/src/library/steam.rs
T
enricobuehlerandClaude Opus 4.8 1a6deeb781
apple / swift (push) Successful in 1m21s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 57s
apple / screenshots (push) Successful in 6m41s
ci / bench (push) Successful in 6m22s
ci / rust (push) Successful in 24m42s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
decky / build-publish (push) Successful in 28s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
android-screenshots / screenshots (push) Successful in 3m10s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m41s
windows-host / package (push) Successful in 9m53s
linux-client-screenshots / screenshots (push) Successful in 7m11s
deb / build-publish (push) Successful in 11m1s
docker / deploy-docs (push) Successful in 24s
deb / build-publish-host (push) Successful in 11m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m31s
arch / build-publish (push) Successful in 12m57s
web-screenshots / screenshots (push) Successful in 3m12s
android / android (push) Successful in 13m46s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m0s
release / apple (push) Successful in 10m32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m45s
flatpak / build-publish (push) Successful in 5m51s
feat(host/library): scan the user's non-Steam shortcuts into the game library
Steam's "Add a Non-Steam Game to My Library" entries carry no `appmanifest`, so
`scan_manifests` can't see them and they never appear in the streamed library.
Read each Steam account's binary `userdata/<id>/config/shortcuts.vdf` directly
(a binary KeyValues parse) to surface the user's own custom entries, plus their
grid artwork from `userdata/<id>/config/grid`. Best-effort: an unreadable or
absent shortcuts.vdf contributes nothing, and hidden shortcuts / duplicate
appids are dropped. Verified: clippy -D warnings + compile green on Linux.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:45:25 +02:00

772 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Steam store provider: installed-title scan (local `libraryfolders.vdf` + app manifests, no
//! API key) and Steam-CDN / local-`librarycache` artwork. Split out of the `library` facade (plan §W5).
use super::art::fetch_image;
use super::*;
/// Reads the **local** Steam install — no Steam Web API key, no network. Installed titles come
/// from `steamapps/appmanifest_<appid>.acf`; extra library folders from
/// `steamapps/libraryfolders.vdf`; the user's own non-Steam shortcuts ("Add a Non-Steam Game to My
/// Library") from each account's binary `userdata/<id>/config/shortcuts.vdf`; artwork from the
/// public Steam CDN by appid, or the user's per-account `grid/` overrides (all a shortcut ever has).
pub struct SteamProvider;
impl LibraryProvider for SteamProvider {
fn store(&self) -> &'static str {
"steam"
}
fn list(&self) -> Vec<GameEntry> {
let mut by_appid: std::collections::BTreeMap<u32, String> = Default::default();
for steamapps in steam_library_dirs() {
for (appid, name) in scan_manifests(&steamapps) {
by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids
}
}
let mut games: Vec<GameEntry> = by_appid
.into_iter()
.filter(|(appid, name)| !is_steam_tool(*appid, name))
.map(|(appid, title)| GameEntry {
provider: None,
id: format!("steam:{appid}"),
store: "steam".into(),
title,
art: steam_art(appid),
launch: Some(LaunchSpec {
kind: "steam_appid".into(),
value: appid.to_string(),
}),
})
.collect();
// Non-Steam shortcuts have no `appmanifest` — [`scan_manifests`] can't see them, so the
// user's own custom entries are gathered separately from `shortcuts.vdf`.
games.extend(steam_shortcuts());
games
}
}
/// The Steam CDN poster/hero/logo/header for an appid — relative proxy paths the *client* resolves
/// against the host it just talked to (so they work the same whichever interface/port the client
/// reached the host on), backed by [`steam_art_bytes`] on the way out. Not every appid has a
/// 600×900 capsule, but `header.jpg` is effectively universal — the client falls back to it.
fn steam_art(appid: u32) -> Artwork {
let url = |kind: &str| Some(format!("/api/v1/library/art/steam:{appid}/{kind}"));
Artwork {
portrait: url("portrait"),
hero: url("hero"),
logo: url("logo"),
header: url("header"),
}
}
/// Resolve one Steam cover-art kind to bytes: the host's own local Steam cache first (exact — it's
/// literally what the user's Steam client already shows for this title), then the user's per-account
/// `grid/` overrides (the *only* art a non-Steam shortcut ever has), then the legacy flat CDN URL.
/// `None` when none has it (the client then falls through to its next art candidate). Blocking
/// (disk + network) — call off the async runtime.
pub fn steam_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
if let Some(local) =
steam_local_art_bytes(appid, kind).or_else(|| steam_grid_art_bytes(appid, kind))
{
return Some(local);
}
// A non-Steam shortcut's appid has the high bit set (see [`shortcut_appid`]) and is never a real
// store appid, so the CDN would only 404 — skip the wasted request and fall through cleanly.
if appid & 0x8000_0000 != 0 {
return None;
}
let url = format!(
"https://cdn.cloudflare.steamstatic.com/steam/apps/{appid}/{}",
kind.cdn_filename()
);
fetch_image(&url)
}
/// Cap on a local librarycache file we'll read into memory — generous for a Steam-quality JPEG/PNG
/// (these run well under 2 MiB in practice) while bounding a pathological file.
const LOCAL_ART_MAX_BYTES: u64 = 8 * 1024 * 1024;
/// `appcache/librarycache/<appid>/<hash>/<filename>` across every Steam root, for whichever
/// `<hash>` subdirectory actually has this kind's file (Steam reuses one hash dir per asset
/// version, so there's normally exactly one candidate per kind).
fn steam_local_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
steam_roots()
.into_iter()
.find_map(|root| find_local_art_file(&root, appid, kind))
.and_then(|path| {
let bytes = std::fs::read(&path).ok()?;
let ctype = if path.extension().is_some_and(|e| e == "png") {
"image/png"
} else {
"image/jpeg"
};
Some((bytes, ctype.to_string()))
})
}
/// Find this kind's cached file under one Steam root's `appcache/librarycache/<appid>/<hash>/`,
/// trying each hash subdirectory (normally just one) and each candidate filename in priority
/// order. Pure path lookup — no env/HOME dependency — so it's unit-testable against a plain
/// directory fixture.
fn find_local_art_file(root: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
let cache_dir = root
.join("appcache")
.join("librarycache")
.join(appid.to_string());
let hash_dirs = std::fs::read_dir(&cache_dir).ok()?;
for hash_dir in hash_dirs.flatten() {
for name in kind.local_filenames() {
let path = hash_dir.path().join(name);
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
return Some(path);
}
}
}
None
}
/// Artwork a user set in Steam itself for a **non-Steam shortcut** (or a `grid/` override for a real
/// title), stored per-account under `userdata/<id>/config/grid/`, keyed by the same 32-bit appid the
/// shortcut carries. Tried before the CDN — a shortcut has no CDN art at all, so this is where its
/// poster lives.
fn steam_grid_art_bytes(appid: u32, kind: ArtKind) -> Option<(Vec<u8>, String)> {
for root in steam_roots() {
let Ok(users) = std::fs::read_dir(root.join("userdata")) else {
continue;
};
for user in users.flatten() {
let grid = user.path().join("config").join("grid");
if let Some(path) = find_grid_art_file(&grid, appid, kind) {
if let Ok(bytes) = std::fs::read(&path) {
let ctype = if path.extension().is_some_and(|e| e == "png") {
"image/png"
} else {
"image/jpeg"
};
return Some((bytes, ctype.to_string()));
}
}
}
}
None
}
/// The `grid/` filenames Steam stores this art kind under for appid `<A>`, tried in order (PNG then
/// JPG — Steam accepts either). Pure path logic, so it's unit-testable against a directory fixture.
fn find_grid_art_file(grid: &Path, appid: u32, kind: ArtKind) -> Option<PathBuf> {
for name in grid_filenames(kind, appid) {
let path = grid.join(&name);
if let Ok(meta) = std::fs::metadata(&path) {
if meta.len() > 0 && meta.len() <= LOCAL_ART_MAX_BYTES {
return Some(path);
}
}
}
None
}
/// The `userdata/<id>/config/grid/` basenames Steam names each art kind under for appid `<A>`:
/// portrait `<A>p`, hero `<A>_hero`, logo `<A>_logo`, and the wide capsule `<A>` — each as `.png`
/// then `.jpg`.
fn grid_filenames(kind: ArtKind, appid: u32) -> Vec<String> {
let both = |base: String| vec![format!("{base}.png"), format!("{base}.jpg")];
match kind {
ArtKind::Portrait => both(format!("{appid}p")),
ArtKind::Hero => both(format!("{appid}_hero")),
ArtKind::Logo => both(format!("{appid}_logo")),
ArtKind::Header => both(format!("{appid}")),
}
}
/// Candidate Steam roots (classic, Flatpak, Deck) that actually exist, canonicalized + deduped.
#[cfg(not(target_os = "windows"))]
fn steam_roots() -> Vec<PathBuf> {
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
return Vec::new();
};
let candidates = [
home.join(".local/share/Steam"),
home.join(".steam/steam"),
home.join(".steam/root"),
home.join(".var/app/com.valvesoftware.Steam/.local/share/Steam"), // Flatpak Steam
];
steam_roots_existing(candidates)
}
/// Windows Steam roots: the default install dirs under Program Files. Games installed on other
/// drives are still found via each root's `libraryfolders.vdf` (see [`steam_library_dirs`]). A
/// non-default Steam install dir (registry `Valve\Steam\InstallPath`) isn't covered yet.
#[cfg(target_os = "windows")]
fn steam_roots() -> Vec<PathBuf> {
let mut candidates = Vec::new();
for var in ["ProgramFiles(x86)", "ProgramFiles", "ProgramW6432"] {
if let Some(pf) = std::env::var_os(var) {
candidates.push(PathBuf::from(pf).join("Steam"));
}
}
steam_roots_existing(candidates)
}
/// Keep only the candidate roots that exist (have a `steamapps` dir), canonicalized + deduped.
fn steam_roots_existing(candidates: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut roots = Vec::new();
for c in candidates {
if let Ok(canon) = c.canonicalize() {
if canon.join("steamapps").is_dir() && seen.insert(canon.clone()) {
roots.push(canon);
}
}
}
roots
}
/// Every `steamapps` dir holding installed titles: each root's own, plus the extra library
/// folders listed in `libraryfolders.vdf` (Steam lets you install games on other drives).
fn steam_library_dirs() -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut dirs = Vec::new();
let mut push = |steamapps: PathBuf, dirs: &mut Vec<PathBuf>| {
if let Ok(canon) = steamapps.canonicalize() {
if canon.is_dir() && seen.insert(canon.clone()) {
dirs.push(canon);
}
}
};
for root in steam_roots() {
let steamapps = root.join("steamapps");
if let Ok(text) = std::fs::read_to_string(steamapps.join("libraryfolders.vdf")) {
for path in vdf_paths(&text) {
push(PathBuf::from(path).join("steamapps"), &mut dirs);
}
}
push(steamapps, &mut dirs);
}
dirs
}
/// Pull every `"path" "<dir>"` value out of a `libraryfolders.vdf`. We don't need a full VDF
/// parser for the two flat fields we read. On Windows the values are backslash-escaped
/// (`D:\\SteamLibrary`), so unescape `\\` → `\`; Linux paths need no unescaping.
fn vdf_paths(text: &str) -> Vec<String> {
text.lines()
.filter_map(|l| vdf_value(l.trim(), "path"))
.map(|p| {
#[cfg(target_os = "windows")]
{
p.replace("\\\\", "\\")
}
#[cfg(not(target_os = "windows"))]
{
p.to_string()
}
})
.collect()
}
/// `"<key>" "<value>"` on a single line → `<value>`. Used for both VDF and ACF flat fields.
fn vdf_value<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let rest = line.strip_prefix(&format!("\"{key}\""))?;
let after = &rest[rest.find('"')? + 1..];
Some(&after[..after.find('"')?])
}
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → (appid, name) of installed titles.
fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> {
let Ok(rd) = std::fs::read_dir(steamapps) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in rd.flatten() {
let fname = entry.file_name();
let fname = fname.to_string_lossy();
if !(fname.starts_with("appmanifest_") && fname.ends_with(".acf")) {
continue;
}
if let Ok(text) = std::fs::read_to_string(entry.path()) {
let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid"));
let name = text.lines().find_map(|l| vdf_value(l.trim(), "name"));
if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::<u32>), name) {
out.push((appid, name.to_string()));
}
}
}
out
}
/// Steam installs runtimes/redistributables as "apps" too — keep them out of a *game* library.
fn is_steam_tool(appid: u32, name: &str) -> bool {
// Steamworks Common Redistributables; Steam Linux Runtime 1.0/2.0/3.0 (Sniper/Soldier).
const TOOL_IDS: &[u32] = &[228980, 1070560, 1391110, 1628350, 1493710];
if TOOL_IDS.contains(&appid) {
return true;
}
let n = name.to_ascii_lowercase();
n.contains("proton")
|| n.starts_with("steam linux runtime")
|| n.contains("steamworks common")
|| n.contains("steamvr")
}
/// One non-Steam shortcut ("Add a Non-Steam Game to My Library"), as read from `shortcuts.vdf`.
struct Shortcut {
/// The 32-bit shortcut appid Steam assigns it (high bit set — see [`shortcut_appid`]). Keys the
/// entry id and its `grid/` artwork; the 64-bit launch id derives from it ([`shortcut_gameid`]).
appid: u32,
/// Display name (`AppName`).
name: String,
/// Whether Steam has this shortcut hidden from the library (`IsHidden`) — we honor that.
hidden: bool,
}
/// Every non-Steam shortcut across all Steam accounts on this host, as launchable [`GameEntry`]s.
/// These carry no `appmanifest`, so [`scan_manifests`] never sees them — this is the only path that
/// surfaces a user's custom Steam entries. Best-effort: an unreadable/absent `shortcuts.vdf`
/// contributes nothing; hidden shortcuts and duplicate appids are dropped.
fn steam_shortcuts() -> Vec<GameEntry> {
let mut seen = HashSet::new();
let mut out = Vec::new();
for path in shortcuts_files() {
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
for sc in parse_shortcuts(&bytes) {
if seen.insert(sc.appid) {
if let Some(entry) = shortcut_entry(sc) {
out.push(entry);
}
}
}
}
out
}
/// Map one parsed [`Shortcut`] to a library entry, or `None` if Steam has it hidden. Launch reuses
/// the `steam_appid` recipe (`steam steam://rungameid/<id>`) — the value is the 64-bit shortcut
/// game id, not the 32-bit appid, because the plain appid won't launch a non-Steam shortcut.
fn shortcut_entry(sc: Shortcut) -> Option<GameEntry> {
if sc.hidden {
return None;
}
Some(GameEntry {
provider: None,
id: format!("steam:{}", sc.appid),
store: "steam".into(),
title: sc.name,
art: steam_art(sc.appid),
launch: Some(LaunchSpec {
kind: "steam_appid".into(),
value: shortcut_gameid(sc.appid).to_string(),
}),
})
}
/// Every `userdata/<id>/config/shortcuts.vdf` under each Steam root — one file per Steam account
/// that has signed in on this host.
fn shortcuts_files() -> Vec<PathBuf> {
let mut files = Vec::new();
for root in steam_roots() {
let Ok(users) = std::fs::read_dir(root.join("userdata")) else {
continue;
};
for user in users.flatten() {
let path = user.path().join("config").join("shortcuts.vdf");
if path.is_file() {
files.push(path);
}
}
}
files
}
/// The 64-bit game id `steam://rungameid/` needs to launch a non-Steam shortcut: high dword = the
/// 32-bit shortcut appid, low dword = the shortcut marker `0x0200_0000`. (Handing `rungameid` the
/// bare 32-bit appid does not launch a shortcut — it must be this composed id.)
fn shortcut_gameid(appid: u32) -> u64 {
((appid as u64) << 32) | 0x0200_0000
}
/// The 32-bit appid Steam derives for a shortcut from its target+name — `crc32(exe + name)` with the
/// high bit set. Only used when `shortcuts.vdf` omits the stored `appid` (very old Steam); modern
/// Steam writes it, and we prefer the stored value.
fn shortcut_appid(exe: &str, name: &str) -> u32 {
crc32(format!("{exe}{name}").as_bytes()) | 0x8000_0000
}
/// Standard reflected (IEEE) CRC-32 — a few short strings' worth per scan, so a table-free bitwise
/// loop is plenty. Matches what Steam uses to hash a shortcut's `exe + name`.
fn crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
/// Parse a **binary** `shortcuts.vdf` into its shortcuts. The format is Steam's binary KeyValues: a
/// 1-byte type tag (`0x00` nested map, `0x01` string, `0x02` int32), a NUL-terminated key, then a
/// type-specific payload; `0x08` closes the current map. The whole file is one `shortcuts` map whose
/// children (keyed `"0"`, `"1"`, …) are the individual shortcuts. Lenient and panic-free: a
/// truncated file or an unrecognized tag stops the walk and returns whatever parsed so far.
fn parse_shortcuts(buf: &[u8]) -> Vec<Shortcut> {
let mut out = Vec::new();
let mut pos = 0usize;
// Enter the top-level map (`<0x00> "shortcuts" <NUL>`); tolerate any key name.
if buf.first() != Some(&0x00) {
return out;
}
pos += 1;
if read_cstr(buf, &mut pos).is_none() {
return out;
}
// Each child is a map describing one shortcut, until the map-closing `0x08`.
while let Some(&tag) = buf.get(pos) {
pos += 1;
if tag != 0x00 {
break; // `0x08` (end of shortcuts) or anything unexpected
}
if read_cstr(buf, &mut pos).is_none() {
break; // the index key ("0", "1", …)
}
match parse_one_shortcut(buf, &mut pos) {
Some(sc) => out.push(sc),
None => break,
}
}
out
}
/// Parse one shortcut's fields (positioned just after its index key) up to the map-closing `0x08`,
/// pulling the ones we surface. `None` on a truncated/garbled entry.
fn parse_one_shortcut(buf: &[u8], pos: &mut usize) -> Option<Shortcut> {
let mut appid: Option<u32> = None;
let mut name = String::new();
let mut exe = String::new();
let mut hidden = false;
loop {
let tag = *buf.get(*pos)?;
*pos += 1;
if tag == 0x08 {
break; // end of this shortcut
}
let key = read_cstr(buf, pos)?.to_ascii_lowercase();
match tag {
0x00 => skip_map(buf, pos)?, // nested map (e.g. `tags`) — not needed
0x01 => {
let val = read_cstr(buf, pos)?;
match key.as_str() {
"appname" => name = val,
"exe" => exe = val,
_ => {}
}
}
0x02 => {
let val = read_i32(buf, pos)?;
match key.as_str() {
"appid" => appid = Some(val as u32),
"ishidden" => hidden = val != 0,
_ => {}
}
}
0x07 => *pos += 8, // uint64 — skip
_ => return None, // unknown tag: payload size unknown, can't continue safely
}
}
if name.trim().is_empty() {
return None; // nothing worth showing
}
// Prefer the stored appid; fall back to Steam's derivation when it's absent (0 / missing).
let appid = appid
.filter(|a| *a != 0)
.unwrap_or_else(|| shortcut_appid(&exe, &name));
Some(Shortcut {
appid,
name,
hidden,
})
}
/// Skip a nested map's contents (positioned just after its key) up to and including its `0x08`.
fn skip_map(buf: &[u8], pos: &mut usize) -> Option<()> {
loop {
let tag = *buf.get(*pos)?;
*pos += 1;
if tag == 0x08 {
return Some(());
}
read_cstr(buf, pos)?; // key
match tag {
0x00 => skip_map(buf, pos)?,
0x01 => {
read_cstr(buf, pos)?;
}
0x02 => *pos += 4,
0x07 => *pos += 8,
_ => return None,
}
}
}
/// Read a NUL-terminated UTF-8 string (lossy) starting at `pos`, advancing past the terminator.
/// `None` if the buffer ends before a NUL.
fn read_cstr(buf: &[u8], pos: &mut usize) -> Option<String> {
let start = *pos;
let end = buf.get(start..)?.iter().position(|&b| b == 0)? + start;
let s = String::from_utf8_lossy(&buf[start..end]).into_owned();
*pos = end + 1;
Some(s)
}
/// Read a little-endian int32 at `pos`, advancing 4 bytes. `None` if fewer than 4 bytes remain.
fn read_i32(buf: &[u8], pos: &mut usize) -> Option<i32> {
let bytes: [u8; 4] = buf.get(*pos..*pos + 4)?.try_into().ok()?;
*pos += 4;
Some(i32::from_le_bytes(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vdf_value_extracts_quoted_field() {
assert_eq!(
vdf_value("\"path\"\t\t\"/mnt/games/SteamLibrary\"", "path"),
Some("/mnt/games/SteamLibrary")
);
assert_eq!(vdf_value("\"appid\"\t\t\"570\"", "appid"), Some("570"));
assert_eq!(vdf_value("\"name\"\t\t\"Dota 2\"", "name"), Some("Dota 2"));
assert_eq!(vdf_value("\"installdir\"\t\t\"x\"", "appid"), None);
}
#[test]
fn vdf_paths_pulls_all_library_folders() {
let vdf = r#"
"libraryfolders"
{
"0"
{
"path" "/home/u/.local/share/Steam"
"apps" { "570" "123" }
}
"1"
{
"path" "/mnt/ssd/SteamLibrary"
}
}
"#;
assert_eq!(
vdf_paths(vdf),
vec![
"/home/u/.local/share/Steam".to_string(),
"/mnt/ssd/SteamLibrary".to_string()
]
);
}
#[test]
fn tools_are_filtered_but_games_kept() {
assert!(is_steam_tool(228980, "Steamworks Common Redistributables"));
assert!(is_steam_tool(1493710, "Proton Experimental"));
assert!(is_steam_tool(0, "Steam Linux Runtime 3.0 (sniper)"));
assert!(!is_steam_tool(570, "Dota 2"));
assert!(!is_steam_tool(1245620, "ELDEN RING"));
}
#[test]
fn steam_art_points_at_the_host_art_proxy() {
let art = steam_art(570);
assert_eq!(
art.portrait.as_deref(),
Some("/api/v1/library/art/steam:570/portrait")
);
assert_eq!(
art.header.as_deref(),
Some("/api/v1/library/art/steam:570/header")
);
}
#[test]
fn find_local_art_file_matches_the_hashed_librarycache_layout() {
let dir = tempfile::tempdir().unwrap();
let cache = dir
.path()
.join("appcache/librarycache/3527290/480bd879ac737921bfa2529a6fea15961267ad21");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("library_600x900.jpg"), b"not really a jpeg").unwrap();
let found = find_local_art_file(dir.path(), 3527290, ArtKind::Portrait).unwrap();
assert_eq!(found, cache.join("library_600x900.jpg"));
// A kind with no cached file, and an appid with no cache dir at all, both miss cleanly.
assert_eq!(
find_local_art_file(dir.path(), 3527290, ArtKind::Hero),
None
);
assert_eq!(
find_local_art_file(dir.path(), 570, ArtKind::Portrait),
None
);
}
#[test]
fn find_local_art_file_prefers_the_2x_portrait() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("appcache/librarycache/570/somehash");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(cache.join("library_600x900.jpg"), b"1x").unwrap();
std::fs::write(cache.join("library_600x900_2x.jpg"), b"2x").unwrap();
let found = find_local_art_file(dir.path(), 570, ArtKind::Portrait).unwrap();
assert_eq!(found, cache.join("library_600x900_2x.jpg"));
}
// --- Non-Steam shortcuts (custom Steam entries) ---
/// Build one binary-VDF field for a test `shortcuts.vdf`.
fn field_str(key: &str, val: &str) -> Vec<u8> {
let mut v = vec![0x01u8];
v.extend_from_slice(key.as_bytes());
v.push(0);
v.extend_from_slice(val.as_bytes());
v.push(0);
v
}
fn field_i32(key: &str, val: i32) -> Vec<u8> {
let mut v = vec![0x02u8];
v.extend_from_slice(key.as_bytes());
v.push(0);
v.extend_from_slice(&val.to_le_bytes());
v
}
fn map_open(key: &str) -> Vec<u8> {
let mut v = vec![0x00u8];
v.extend_from_slice(key.as_bytes());
v.push(0);
v
}
#[test]
fn parse_shortcuts_reads_entries_honors_hidden_and_key_case() {
let mut buf = Vec::new();
buf.extend(map_open("shortcuts"));
// Entry 0: a normal shortcut, with a nested `tags` map to exercise skip_map, and mixed-case
// keys (Steam has shipped both `AppName` and `appname`). appid stored as a negative i32.
buf.extend(map_open("0"));
buf.extend(field_i32("appid", -1838178284)); // == 2456789012 as u32
buf.extend(field_str("AppName", "My Emulator"));
buf.extend(field_str("Exe", "\"/usr/bin/foo\""));
buf.extend(field_i32("IsHidden", 0));
buf.extend(map_open("tags"));
buf.extend(field_str("0", "emulator"));
buf.push(0x08); // end tags
buf.push(0x08); // end entry 0
// Entry 1: hidden, lowercase key variant.
buf.extend(map_open("1"));
buf.extend(field_str("appname", "Hidden Game"));
buf.extend(field_i32("ishidden", 1));
buf.push(0x08); // end entry 1
buf.push(0x08); // end shortcuts
let scs = parse_shortcuts(&buf);
assert_eq!(scs.len(), 2);
assert_eq!(scs[0].appid, 2_456_789_012);
assert_eq!(scs[0].name, "My Emulator");
assert!(!scs[0].hidden);
assert_eq!(scs[1].name, "Hidden Game");
assert!(scs[1].hidden);
// A hidden shortcut is dropped from the surfaced library; a visible one launches via its
// 64-bit game id (not the bare appid).
assert!(shortcut_entry(scs.into_iter().nth(1).unwrap()).is_none());
}
#[test]
fn parse_shortcuts_is_lenient() {
assert!(parse_shortcuts(b"").is_empty()); // not even a top-level map
assert!(parse_shortcuts(b"{not binary vdf}").is_empty());
// A truncated entry (buffer ends mid-int) yields what parsed cleanly before it — here, none.
let mut buf = Vec::new();
buf.extend(map_open("shortcuts"));
buf.extend(map_open("0"));
buf.extend_from_slice(b"\x02appid\x00\x01\x02"); // 2 of 4 int bytes, then EOF
assert!(parse_shortcuts(&buf).is_empty());
}
#[test]
fn shortcut_entry_launches_via_rungameid() {
let sc = Shortcut {
appid: 2_456_789_012,
name: "My Emulator".into(),
hidden: false,
};
let entry = shortcut_entry(sc).unwrap();
assert_eq!(entry.id, "steam:2456789012");
assert_eq!(entry.store, "steam");
let launch = entry.launch.unwrap();
assert_eq!(launch.kind, "steam_appid");
// Value is the 64-bit game id — digits only, so it passes the shared appid guard.
assert_eq!(launch.value, shortcut_gameid(2_456_789_012).to_string());
assert!(launch.value.bytes().all(|b| b.is_ascii_digit()));
}
#[test]
fn shortcut_gameid_composes_appid_and_marker() {
let id = shortcut_gameid(0x8000_0000);
assert_eq!(id >> 32, 0x8000_0000); // high dword is the appid
assert_eq!(id & 0xFFFF_FFFF, 0x0200_0000); // low dword is the shortcut marker
}
#[test]
fn crc32_matches_the_known_check_value_and_derives_a_high_bit_appid() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926); // IEEE CRC-32 check value
// A derived shortcut appid always has the high bit set (so it never collides with a real
// store appid, and its CDN art fetch is skipped).
assert_ne!(
shortcut_appid("\"/usr/bin/foo\"", "My Emulator") & 0x8000_0000,
0
);
}
#[test]
fn grid_filenames_follow_steams_naming() {
assert_eq!(
grid_filenames(ArtKind::Portrait, 42),
vec!["42p.png", "42p.jpg"]
);
assert_eq!(
grid_filenames(ArtKind::Hero, 42),
vec!["42_hero.png", "42_hero.jpg"]
);
assert_eq!(
grid_filenames(ArtKind::Logo, 42),
vec!["42_logo.png", "42_logo.jpg"]
);
assert_eq!(
grid_filenames(ArtKind::Header, 42),
vec!["42.png", "42.jpg"]
);
}
#[test]
fn find_grid_art_file_matches_the_userdata_grid_layout() {
let grid = tempfile::tempdir().unwrap();
std::fs::write(grid.path().join("2456789012p.jpg"), b"poster").unwrap();
let found = find_grid_art_file(grid.path(), 2_456_789_012, ArtKind::Portrait).unwrap();
assert_eq!(found, grid.path().join("2456789012p.jpg"));
// Missing kinds and a zero-byte file both miss cleanly.
assert_eq!(
find_grid_art_file(grid.path(), 2_456_789_012, ArtKind::Hero),
None
);
std::fs::write(grid.path().join("42p.png"), b"").unwrap();
assert_eq!(find_grid_art_file(grid.path(), 42, ArtKind::Portrait), None);
}
}