aboutsummaryrefslogtreecommitdiff
path: root/libs/nak/src/game_finder
diff options
context:
space:
mode:
Diffstat (limited to 'libs/nak/src/game_finder')
-rw-r--r--libs/nak/src/game_finder/bottles.rs147
-rw-r--r--libs/nak/src/game_finder/heroic.rs277
-rw-r--r--libs/nak/src/game_finder/known_games.rs245
-rw-r--r--libs/nak/src/game_finder/mod.rs191
-rw-r--r--libs/nak/src/game_finder/registry.rs227
-rw-r--r--libs/nak/src/game_finder/steam.rs251
-rw-r--r--libs/nak/src/game_finder/vdf.rs254
7 files changed, 1592 insertions, 0 deletions
diff --git a/libs/nak/src/game_finder/bottles.rs b/libs/nak/src/game_finder/bottles.rs
new file mode 100644
index 0000000..7d974e8
--- /dev/null
+++ b/libs/nak/src/game_finder/bottles.rs
@@ -0,0 +1,147 @@
+//! Bottles prefix detection
+//!
+//! Detects games installed in Bottles prefixes by scanning registry files
+//! for known game registry entries.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use super::known_games::KNOWN_GAMES;
+use super::registry::{read_registry_value, wine_path_to_linux};
+use super::{Game, Launcher};
+use crate::logging::log_info;
+
+/// Possible Bottles data paths
+const BOTTLES_PATHS: &[&str] = &[
+ ".local/share/bottles/bottles",
+ ".var/app/com.usebottles.bottles/data/bottles/bottles",
+];
+
+/// Detect all games in Bottles prefixes
+pub fn detect_bottles_games() -> Vec<Game> {
+ let mut games = Vec::new();
+ let home = match std::env::var("HOME") {
+ Ok(h) => h,
+ Err(_) => return games,
+ };
+
+ for relative_path in BOTTLES_PATHS {
+ let bottles_path = PathBuf::from(&home).join(relative_path);
+ if !bottles_path.exists() {
+ continue;
+ }
+
+ log_info(&format!("Found Bottles installation: {}", bottles_path.display()));
+
+ // Scan each bottle
+ let Ok(entries) = fs::read_dir(&bottles_path) else {
+ continue;
+ };
+
+ for entry in entries.flatten() {
+ let bottle_path = entry.path();
+ if !bottle_path.is_dir() {
+ continue;
+ }
+
+ // Each bottle might have games
+ let bottle_games = scan_bottle(&bottle_path);
+ games.extend(bottle_games);
+ }
+ }
+
+ log_info(&format!("Bottles: Found {} installed games", games.len()));
+ games
+}
+
+/// Scan a single Bottles bottle for known games
+fn scan_bottle(bottle_path: &Path) -> Vec<Game> {
+ let mut games = Vec::new();
+
+ // The prefix is directly in the bottle folder (not in pfx subfolder like Steam)
+ // Check for drive_c to confirm it's a valid Wine prefix
+ let drive_c = bottle_path.join("drive_c");
+ if !drive_c.exists() {
+ return games;
+ }
+
+ let bottle_name = bottle_path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("Unknown");
+
+ // Check for each known game by registry entry
+ for known_game in KNOWN_GAMES {
+ if let Some(install_path_wine) =
+ read_registry_value(bottle_path, known_game.registry_path, known_game.registry_value)
+ {
+ // Convert Wine path to Linux path
+ let install_path = match wine_path_to_linux(&install_path_wine) {
+ Some(p) => p,
+ None => {
+ // Try as a relative path within drive_c
+ if install_path_wine.starts_with("C:") || install_path_wine.starts_with("c:") {
+ let relative = install_path_wine[2..].replace('\\', "/");
+ drive_c.join(relative.trim_start_matches('/'))
+ } else {
+ continue;
+ }
+ }
+ };
+
+ if !install_path.exists() {
+ continue;
+ }
+
+ log_info(&format!(
+ "Found {} in Bottles bottle '{}'",
+ known_game.name, bottle_name
+ ));
+
+ games.push(Game {
+ name: known_game.name.to_string(),
+ app_id: format!("bottles-{}", known_game.steam_app_id),
+ install_path,
+ prefix_path: Some(bottle_path.to_path_buf()),
+ launcher: Launcher::Bottles,
+ my_games_folder: known_game.my_games_folder.map(String::from),
+ appdata_local_folder: known_game.appdata_local_folder.map(String::from),
+ appdata_roaming_folder: known_game.appdata_roaming_folder.map(String::from),
+ registry_path: Some(known_game.registry_path.to_string()),
+ registry_value: Some(known_game.registry_value.to_string()),
+ });
+ }
+ }
+
+ games
+}
+
+/// Find all Bottles prefixes (for manual prefix selection)
+pub fn find_bottles_prefixes() -> Vec<PathBuf> {
+ let mut prefixes = Vec::new();
+ let home = match std::env::var("HOME") {
+ Ok(h) => h,
+ Err(_) => return prefixes,
+ };
+
+ for relative_path in BOTTLES_PATHS {
+ let bottles_path = PathBuf::from(&home).join(relative_path);
+ if !bottles_path.exists() {
+ continue;
+ }
+
+ let Ok(entries) = fs::read_dir(&bottles_path) else {
+ continue;
+ };
+
+ for entry in entries.flatten() {
+ let bottle_path = entry.path();
+ // Verify it's a valid Wine prefix
+ if bottle_path.is_dir() && bottle_path.join("drive_c").exists() {
+ prefixes.push(bottle_path);
+ }
+ }
+ }
+
+ prefixes
+}
diff --git a/libs/nak/src/game_finder/heroic.rs b/libs/nak/src/game_finder/heroic.rs
new file mode 100644
index 0000000..7bdda09
--- /dev/null
+++ b/libs/nak/src/game_finder/heroic.rs
@@ -0,0 +1,277 @@
+//! Heroic Games Launcher detection
+//!
+//! Detects games installed via Heroic (GOG and Epic Games).
+//! Parses installed.json and GamesConfig/*.json for game and prefix info.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use serde::Deserialize;
+
+use super::known_games::find_by_gog_id;
+use super::{Game, HeroicStore, Launcher};
+use crate::logging::{log_info, log_warning};
+
+/// Possible Heroic configuration paths
+const HEROIC_PATHS: &[&str] = &[
+ ".config/heroic", // Native
+ ".var/app/com.heroicgameslauncher.hgl/config/heroic", // Flatpak
+];
+
+/// Detect all Heroic games
+pub fn detect_heroic_games() -> Vec<Game> {
+ let mut games = Vec::new();
+ let home = match std::env::var("HOME") {
+ Ok(h) => h,
+ Err(_) => return games,
+ };
+
+ for relative_path in HEROIC_PATHS {
+ let heroic_path = PathBuf::from(&home).join(relative_path);
+ if !heroic_path.exists() {
+ continue;
+ }
+
+ log_info(&format!("Found Heroic installation: {}", heroic_path.display()));
+
+ // Detect GOG games
+ let gog_games = detect_gog_games(&heroic_path);
+ games.extend(gog_games);
+
+ // Detect Epic games
+ let epic_games = detect_epic_games(&heroic_path);
+ games.extend(epic_games);
+ }
+
+ log_info(&format!("Heroic: Found {} installed games", games.len()));
+ games
+}
+
+// ============================================================================
+// GOG Detection
+// ============================================================================
+
+/// GOG installed game entry from installed.json
+#[derive(Debug, Deserialize)]
+struct GogInstalledGame {
+ #[serde(rename = "appName")]
+ app_name: String,
+ title: Option<String>,
+ #[serde(rename = "install_path")]
+ install_path: Option<String>,
+ platform: Option<String>,
+}
+
+/// Wrapper for Heroic's GOG installed.json format: {"installed": [...]}
+#[derive(Debug, Deserialize)]
+struct GogInstalledWrapper {
+ installed: Vec<GogInstalledGame>,
+}
+
+/// Detect GOG games from Heroic
+fn detect_gog_games(heroic_path: &Path) -> Vec<Game> {
+ let mut games = Vec::new();
+ let installed_json = heroic_path.join("gog_store/installed.json");
+
+ let Ok(content) = fs::read_to_string(&installed_json) else {
+ return games;
+ };
+
+ // Heroic wraps GOG games in {"installed": [...]}, but also handle bare arrays
+ let installed: Vec<GogInstalledGame> =
+ if let Ok(wrapper) = serde_json::from_str::<GogInstalledWrapper>(&content) {
+ wrapper.installed
+ } else if let Ok(list) = serde_json::from_str::<Vec<GogInstalledGame>>(&content) {
+ list
+ } else {
+ log_warning("Failed to parse Heroic GOG installed.json");
+ return games;
+ };
+
+ for gog_game in installed {
+ // Skip non-Windows games (we only care about Wine prefixes)
+ if gog_game.platform.as_deref() != Some("windows") {
+ continue;
+ }
+
+ let Some(install_path_str) = gog_game.install_path else {
+ continue;
+ };
+
+ let install_path = PathBuf::from(&install_path_str);
+ if !install_path.exists() {
+ continue;
+ }
+
+ // Get the game config for Wine prefix info
+ let prefix_path = get_heroic_game_prefix(heroic_path, &gog_game.app_name);
+
+ // Look up known game info
+ let known_game = find_by_gog_id(&gog_game.app_name);
+
+ let name = gog_game
+ .title
+ .unwrap_or_else(|| gog_game.app_name.clone());
+
+ games.push(Game {
+ name,
+ app_id: gog_game.app_name,
+ install_path,
+ prefix_path,
+ launcher: Launcher::Heroic {
+ store: HeroicStore::GOG,
+ },
+ my_games_folder: known_game.and_then(|g| g.my_games_folder.map(String::from)),
+ appdata_local_folder: known_game.and_then(|g| g.appdata_local_folder.map(String::from)),
+ appdata_roaming_folder: known_game.and_then(|g| g.appdata_roaming_folder.map(String::from)),
+ registry_path: known_game.map(|g| g.registry_path.to_string()),
+ registry_value: known_game.map(|g| g.registry_value.to_string()),
+ });
+ }
+
+ games
+}
+
+// ============================================================================
+// Epic Detection
+// ============================================================================
+
+/// Epic installed game entry from installed.json
+#[derive(Debug, Deserialize)]
+struct EpicInstalledGame {
+ #[serde(rename = "app_name")]
+ app_name: String,
+ title: Option<String>,
+ install_path: Option<String>,
+ platform: Option<String>,
+ is_installed: Option<bool>,
+}
+
+/// Detect Epic games from Heroic
+fn detect_epic_games(heroic_path: &Path) -> Vec<Game> {
+ let mut games = Vec::new();
+ let installed_json = heroic_path.join("store_cache/legendary_library.json");
+
+ // Also try the older location
+ let installed_json = if installed_json.exists() {
+ installed_json
+ } else {
+ heroic_path.join("legendaryConfig/legendary/installed.json")
+ };
+
+ let Ok(content) = fs::read_to_string(&installed_json) else {
+ return games;
+ };
+
+ // The Epic library format can vary - try parsing as an object with game keys
+ if let Ok(library) = serde_json::from_str::<serde_json::Value>(&content) {
+ if let Some(obj) = library.as_object() {
+ for (app_name, game_data) in obj {
+ let Some(game_obj) = game_data.as_object() else {
+ continue;
+ };
+
+ // Check if installed
+ let is_installed = game_obj
+ .get("is_installed")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ if !is_installed {
+ continue;
+ }
+
+ // Get platform
+ let platform = game_obj
+ .get("platform")
+ .and_then(|v| v.as_str());
+ if platform != Some("Windows") && platform != Some("windows") {
+ continue;
+ }
+
+ // Get install path
+ let Some(install_path_str) = game_obj
+ .get("install_path")
+ .and_then(|v| v.as_str())
+ else {
+ continue;
+ };
+
+ let install_path = PathBuf::from(install_path_str);
+ if !install_path.exists() {
+ continue;
+ }
+
+ // Get title
+ let name = game_obj
+ .get("title")
+ .and_then(|v| v.as_str())
+ .unwrap_or(app_name)
+ .to_string();
+
+ // Get Wine prefix
+ let prefix_path = get_heroic_game_prefix(heroic_path, app_name);
+
+ games.push(Game {
+ name,
+ app_id: app_name.clone(),
+ install_path,
+ prefix_path,
+ launcher: Launcher::Heroic {
+ store: HeroicStore::Epic,
+ },
+ my_games_folder: None,
+ appdata_local_folder: None,
+ appdata_roaming_folder: None,
+ registry_path: None,
+ registry_value: None,
+ });
+ }
+ }
+ }
+
+ games
+}
+
+// ============================================================================
+// Shared Utilities
+// ============================================================================
+
+/// Game config entry for Wine settings
+#[derive(Debug, Deserialize)]
+struct HeroicGameConfig {
+ #[serde(rename = "winePrefix")]
+ wine_prefix: Option<String>,
+ #[serde(rename = "wineVersion")]
+ wine_version: Option<WineVersion>,
+}
+
+#[derive(Debug, Deserialize)]
+struct WineVersion {
+ bin: Option<String>,
+ name: Option<String>,
+ #[serde(rename = "type")]
+ wine_type: Option<String>,
+}
+
+/// Get the Wine prefix for a Heroic game from its config file
+fn get_heroic_game_prefix(heroic_path: &Path, app_name: &str) -> Option<PathBuf> {
+ let config_path = heroic_path.join(format!("GamesConfig/{}.json", app_name));
+
+ let content = fs::read_to_string(&config_path).ok()?;
+
+ // The config can be either a direct object or wrapped in the app_name key
+ let config: serde_json::Value = serde_json::from_str(&content).ok()?;
+
+ // Try to get winePrefix from the object or from a nested object
+ let wine_prefix = config
+ .get("winePrefix")
+ .or_else(|| config.get(app_name).and_then(|v| v.get("winePrefix")))
+ .and_then(|v| v.as_str())?;
+
+ let prefix_path = PathBuf::from(wine_prefix);
+ if prefix_path.exists() {
+ Some(prefix_path)
+ } else {
+ None
+ }
+}
diff --git a/libs/nak/src/game_finder/known_games.rs b/libs/nak/src/game_finder/known_games.rs
new file mode 100644
index 0000000..8a30c18
--- /dev/null
+++ b/libs/nak/src/game_finder/known_games.rs
@@ -0,0 +1,245 @@
+//! Known games configuration
+//!
+//! Contains metadata for games that NaK supports, including:
+//! - Steam App ID
+//! - My Games folder name (Documents/My Games/*)
+//! - AppData/Local folder name
+//! - Registry path for game detection
+
+/// Configuration for a known game
+#[derive(Debug, Clone)]
+pub struct KnownGame {
+ /// Display name
+ pub name: &'static str,
+ /// Steam App ID
+ pub steam_app_id: &'static str,
+ /// GOG App ID (if available)
+ pub gog_app_id: Option<&'static str>,
+ /// Folder name in Documents/My Games (if applicable)
+ pub my_games_folder: Option<&'static str>,
+ /// Folder name in AppData/Local (if applicable)
+ pub appdata_local_folder: Option<&'static str>,
+ /// Folder name in AppData/Roaming (if applicable)
+ pub appdata_roaming_folder: Option<&'static str>,
+ /// Registry path under HKLM\Software\ (for game detection)
+ pub registry_path: &'static str,
+ /// Registry value name for install path
+ pub registry_value: &'static str,
+ /// Expected folder name in steamapps/common/
+ pub steam_folder: &'static str,
+}
+
+/// All known games that NaK supports
+pub const KNOWN_GAMES: &[KnownGame] = &[
+ // Bethesda Games
+ KnownGame {
+ name: "Enderal",
+ steam_app_id: "933480",
+ gog_app_id: None,
+ my_games_folder: Some("Enderal"),
+ appdata_local_folder: None,
+ appdata_roaming_folder: None,
+ registry_path: r"Software\SureAI\Enderal",
+ registry_value: "Install_Path",
+ steam_folder: "Enderal",
+ },
+ KnownGame {
+ name: "Enderal Special Edition",
+ steam_app_id: "976620",
+ gog_app_id: None,
+ my_games_folder: Some("Enderal Special Edition"),
+ appdata_local_folder: None,
+ appdata_roaming_folder: None,
+ registry_path: r"Software\SureAI\Enderal SE",
+ registry_value: "installed path",
+ steam_folder: "Enderal Special Edition",
+ },
+ KnownGame {
+ name: "Fallout 3",
+ steam_app_id: "22300",
+ gog_app_id: Some("1454315831"), // Fallout 3 GOTY
+ my_games_folder: Some("Fallout3"),
+ appdata_local_folder: Some("Fallout3"),
+ appdata_roaming_folder: None,
+ registry_path: r"Software\Bethesda Softworks\Fallout3",
+ registry_value: "Installed Path",
+ steam_folder: "Fallout 3",
+ },
+ KnownGame {
+ name: "Fallout 4",
+ steam_app_id: "377160",
+ gog_app_id: None,
+ my_games_folder: Some("Fallout4"),
+ appdata_local_folder: Some("Fallout4"),
+ appdata_roaming_folder: None,
+ registry_path: r"Software\Bethesda Softworks\Fallout4",
+ registry_value: "Installed Path",
+ steam_folder: "Fallout 4",
+ },
+ KnownGame {
+ name: "Fallout 4 VR",
+ steam_app_id: "611660",
+ gog_app_id: None,
+ my_games_folder: Some("Fallout4VR"),
+ appdata_local_folder: None,
+ appdata_roaming_folder: None,
+ registry_path: r"Software\Bethesda Softworks\Fallout 4 VR",
+ registry_value: "Installed Path",
+ steam_folder: "Fallout 4 VR",
+ },
+ KnownGame {
+ name: "Fallout New Vegas",
+ steam_app_id: "22380",
+ gog_app_id: Some("1454587428"), // Fallout NV Ultimate
+ my_games_folder: Some("FalloutNV"),
+ appdata_local_folder: Some("FalloutNV"),
+ appdata_roaming_folder: None,
+ registry_path: r"Software\Bethesda Softworks\FalloutNV",
+ registry_value: "Installed Path",
+ steam_folder: "Fallout New Vegas",
+ },
+ KnownGame {
+ name: "Morrowind",
+ steam_app_id: "22320",
+ gog_app_id: Some("1440163901"), // Morrowind GOTY
+ my_games_folder: Some("Morrowind"),
+ appdata_local_folder: None,
+ appdata_roaming_folder: None,
+ registry_path: r"Software\Bethesda Softworks\Morrowind",
+ registry_value: "Installed Path",
+ steam_folder: "Morrowind",
+ },
+ KnownGame {
+ name: "Oblivion",
+ steam_app_id: "22330",
+ gog_app_id: Some("1458058109"), // Oblivion GOTY Deluxe
+ my_games_folder: Some("Oblivion"),
+ appdata_local_folder: Some("Oblivion"),
+ appdata_roaming_folder: None,
+ registry_path: r"Software\Bethesda Softworks\Oblivion",
+ registry_value: "Installed Path",
+ steam_folder: "Oblivion",
+ },
+ KnownGame {
+ name: "Skyrim",
+ steam_app_id: "72850",
+ gog_app_id: None, // Not on GOG
+ my_games_folder: Some("Skyrim"),
+ appdata_local_folder: Some("Skyrim"),
+ appdata_roaming_folder: None,
+ registry_path: r"Software\Bethesda Softworks\Skyrim",
+ registry_value: "Installed Path",
+ steam_folder: "Skyrim",
+ },
+ KnownGame {
+ name: "Skyrim Special Edition",
+ steam_app_id: "489830",
+ gog_app_id: Some("1711230643"), // Skyrim SE Anniversary Edition
+ my_games_folder: Some("Skyrim Special Edition"),
+ appdata_local_folder: Some("Skyrim Special Edition"),
+ appdata_roaming_folder: None,
+ registry_path: r"Software\Bethesda Softworks\Skyrim Special Edition",
+ registry_value: "Installed Path",
+ steam_folder: "Skyrim Special Edition",
+ },
+ KnownGame {
+ name: "Skyrim VR",
+ steam_app_id: "611670",
+ gog_app_id: None,
+ my_games_folder: Some("Skyrim VR"),
+ appdata_local_folder: None,
+ appdata_roaming_folder: None,
+ registry_path: r"Software\Bethesda Softworks\Skyrim VR",
+ registry_value: "Installed Path",
+ steam_folder: "Skyrim VR",
+ },
+ KnownGame {
+ name: "Starfield",
+ steam_app_id: "1716740",
+ gog_app_id: None,
+ my_games_folder: Some("Starfield"),
+ appdata_local_folder: None,
+ appdata_roaming_folder: None,
+ registry_path: r"Software\Bethesda Softworks\Starfield",
+ registry_value: "Installed Path",
+ steam_folder: "Starfield",
+ },
+ // CD Projekt RED Games
+ KnownGame {
+ name: "The Witcher 3",
+ steam_app_id: "292030",
+ gog_app_id: Some("1495134320"), // Witcher 3 GOTY
+ my_games_folder: Some("The Witcher 3"),
+ appdata_local_folder: None,
+ appdata_roaming_folder: None,
+ registry_path: r"Software\CD Projekt Red\The Witcher 3",
+ registry_value: "InstallFolder",
+ steam_folder: "The Witcher 3 Wild Hunt",
+ },
+ KnownGame {
+ name: "Cyberpunk 2077",
+ steam_app_id: "1091500",
+ gog_app_id: Some("1423049311"),
+ my_games_folder: None,
+ appdata_local_folder: Some("CD Projekt Red/Cyberpunk 2077"),
+ appdata_roaming_folder: None,
+ registry_path: r"Software\CD Projekt Red\Cyberpunk 2077",
+ registry_value: "InstallFolder",
+ steam_folder: "Cyberpunk 2077",
+ },
+ // Other popular moddable games
+ KnownGame {
+ name: "Baldur's Gate 3",
+ steam_app_id: "1086940",
+ gog_app_id: Some("1456460669"),
+ my_games_folder: None,
+ appdata_local_folder: Some("Larian Studios/Baldur's Gate 3"),
+ appdata_roaming_folder: None,
+ registry_path: r"Software\Larian Studios\Baldur's Gate 3",
+ registry_value: "InstallDir",
+ steam_folder: "Baldurs Gate 3",
+ },
+];
+
+/// Find a known game by Steam App ID
+pub fn find_by_steam_id(app_id: &str) -> Option<&'static KnownGame> {
+ let normalized_id = normalize_steam_id(app_id);
+ KNOWN_GAMES.iter().find(|g| g.steam_app_id == normalized_id)
+}
+
+/// Find a known game by GOG App ID
+pub fn find_by_gog_id(app_id: &str) -> Option<&'static KnownGame> {
+ KNOWN_GAMES
+ .iter()
+ .find(|g| g.gog_app_id == Some(app_id))
+}
+
+/// Find a known game by name (case-insensitive)
+pub fn find_by_name(name: &str) -> Option<&'static KnownGame> {
+ let name_lower = name.to_lowercase();
+ KNOWN_GAMES
+ .iter()
+ .find(|g| g.name.to_lowercase() == name_lower)
+}
+
+/// Normalize Steam App IDs that have equivalent variants.
+fn normalize_steam_id(app_id: &str) -> &str {
+ match app_id {
+ // Fallout 3 often appears as GOTY App ID 22370.
+ // We treat it as Fallout 3 for shared metadata/registry mapping.
+ "22370" => "22300",
+ _ => app_id,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::find_by_steam_id;
+
+ #[test]
+ fn fallout_3_goty_alias_maps_to_fallout_3() {
+ let game = find_by_steam_id("22370").expect("22370 should map to Fallout 3");
+ assert_eq!(game.name, "Fallout 3");
+ assert_eq!(game.steam_app_id, "22300");
+ }
+}
diff --git a/libs/nak/src/game_finder/mod.rs b/libs/nak/src/game_finder/mod.rs
new file mode 100644
index 0000000..c383c8a
--- /dev/null
+++ b/libs/nak/src/game_finder/mod.rs
@@ -0,0 +1,191 @@
+//! Game detection module
+//!
+//! Provides unified game detection across multiple launchers:
+//! - Steam (native, Flatpak, Snap)
+//! - Heroic (GOG, Epic)
+//! - Bottles
+
+// Allow unused items - this is a public API module
+#![allow(dead_code)]
+#![allow(unused_imports)]
+
+mod bottles;
+mod heroic;
+pub mod known_games;
+mod registry;
+mod steam;
+mod vdf;
+
+use std::path::PathBuf;
+
+pub use bottles::detect_bottles_games;
+pub use heroic::detect_heroic_games;
+pub use known_games::{find_by_gog_id, find_by_name, find_by_steam_id, KnownGame, KNOWN_GAMES};
+pub use registry::{read_registry_value, wine_path_to_linux};
+pub use steam::{detect_steam_games, find_game_install_path, find_game_prefix_path, get_known_game};
+
+// ============================================================================
+// Core Types
+// ============================================================================
+
+/// The launcher/store a game was installed from
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Launcher {
+ Steam { is_flatpak: bool, is_snap: bool },
+ Heroic { store: HeroicStore },
+ Bottles,
+}
+
+impl Launcher {
+ pub fn display_name(&self) -> &'static str {
+ match self {
+ Launcher::Steam { is_flatpak: true, .. } => "Steam (Flatpak)",
+ Launcher::Steam { is_snap: true, .. } => "Steam (Snap)",
+ Launcher::Steam { .. } => "Steam",
+ Launcher::Heroic { store: HeroicStore::GOG } => "Heroic (GOG)",
+ Launcher::Heroic { store: HeroicStore::Epic } => "Heroic (Epic)",
+ Launcher::Bottles => "Bottles",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HeroicStore {
+ GOG,
+ Epic,
+}
+
+/// A detected game installation
+#[derive(Debug, Clone)]
+pub struct Game {
+ pub name: String,
+ pub app_id: String,
+ pub install_path: PathBuf,
+ pub prefix_path: Option<PathBuf>,
+ pub launcher: Launcher,
+ pub my_games_folder: Option<String>,
+ pub appdata_local_folder: Option<String>,
+ pub appdata_roaming_folder: Option<String>,
+ pub registry_path: Option<String>,
+ pub registry_value: Option<String>,
+}
+
+impl Game {
+ pub fn has_prefix(&self) -> bool {
+ self.prefix_path.is_some()
+ }
+
+ pub fn get_prefix_user_path(&self) -> Option<PathBuf> {
+ let prefix = self.prefix_path.as_ref()?;
+ let users_dir = prefix.join("drive_c/users");
+
+ if let Ok(entries) = std::fs::read_dir(&users_dir) {
+ for entry in entries.flatten() {
+ let name = entry.file_name().to_string_lossy().to_string();
+ if name != "Public" && name != "root" {
+ return Some(users_dir.join(name));
+ }
+ }
+ }
+
+ Some(users_dir.join("steamuser"))
+ }
+
+ pub fn get_prefix_documents_path(&self) -> Option<PathBuf> {
+ self.get_prefix_user_path().map(|p| p.join("Documents"))
+ }
+
+ pub fn get_prefix_my_games_path(&self) -> Option<PathBuf> {
+ let docs = self.get_prefix_documents_path()?;
+ let folder = self.my_games_folder.as_ref()?;
+ Some(docs.join("My Games").join(folder))
+ }
+
+ pub fn get_prefix_appdata_local_path(&self) -> Option<PathBuf> {
+ let user = self.get_prefix_user_path()?;
+ let folder = self.appdata_local_folder.as_ref()?;
+ Some(user.join("AppData/Local").join(folder))
+ }
+
+ pub fn get_prefix_appdata_roaming_path(&self) -> Option<PathBuf> {
+ let user = self.get_prefix_user_path()?;
+ let folder = self.appdata_roaming_folder.as_ref()?;
+ Some(user.join("AppData/Roaming").join(folder))
+ }
+}
+
+// ============================================================================
+// Scan Results
+// ============================================================================
+
+#[derive(Debug, Default)]
+pub struct GameScanResult {
+ pub games: Vec<Game>,
+ pub steam_count: usize,
+ pub heroic_count: usize,
+ pub bottles_count: usize,
+}
+
+impl GameScanResult {
+ pub fn games_with_prefixes(&self) -> impl Iterator<Item = &Game> {
+ self.games.iter().filter(|g| g.has_prefix())
+ }
+
+ pub fn games_by_launcher(&self, launcher_type: &str) -> Vec<&Game> {
+ self.games
+ .iter()
+ .filter(|g| {
+ matches!(
+ (&g.launcher, launcher_type),
+ (Launcher::Steam { .. }, "steam")
+ | (Launcher::Heroic { .. }, "heroic")
+ | (Launcher::Bottles, "bottles")
+ )
+ })
+ .collect()
+ }
+
+ pub fn find_by_name(&self, name: &str) -> Option<&Game> {
+ let name_lower = name.to_lowercase();
+ self.games
+ .iter()
+ .find(|g| g.name.to_lowercase() == name_lower)
+ }
+
+ pub fn find_by_app_id(&self, app_id: &str) -> Option<&Game> {
+ self.games.iter().find(|g| g.app_id == app_id)
+ }
+}
+
+// ============================================================================
+// Public API
+// ============================================================================
+
+/// Detect all installed games from all supported launchers
+pub fn detect_all_games() -> GameScanResult {
+ let mut result = GameScanResult::default();
+
+ let steam_games = detect_steam_games();
+ result.steam_count = steam_games.len();
+ result.games.extend(steam_games);
+
+ let heroic_games = detect_heroic_games();
+ result.heroic_count = heroic_games.len();
+ result.games.extend(heroic_games);
+
+ let bottles_games = detect_bottles_games();
+ result.bottles_count = bottles_games.len();
+ result.games.extend(bottles_games);
+
+ result
+}
+
+/// Detect only Steam games
+pub fn detect_steam_only() -> GameScanResult {
+ let steam_games = detect_steam_games();
+ GameScanResult {
+ steam_count: steam_games.len(),
+ games: steam_games,
+ ..Default::default()
+ }
+}
diff --git a/libs/nak/src/game_finder/registry.rs b/libs/nak/src/game_finder/registry.rs
new file mode 100644
index 0000000..e843b6e
--- /dev/null
+++ b/libs/nak/src/game_finder/registry.rs
@@ -0,0 +1,227 @@
+//! Wine registry parsing utilities
+//!
+//! Parses Wine registry files (system.reg, user.reg) to find game install paths.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use crate::logging::log_warning;
+
+/// Read a registry value from a Wine prefix registry file
+///
+/// # Arguments
+/// * `prefix_path` - Path to the Wine prefix (containing system.reg, user.reg)
+/// * `key_path` - Registry key path (e.g., "Software\\Bethesda Softworks\\Skyrim")
+/// * `value_name` - Name of the value to read (e.g., "Installed Path")
+///
+/// Returns the value as a string, or None if not found
+pub fn read_registry_value(
+ prefix_path: &Path,
+ key_path: &str,
+ value_name: &str,
+) -> Option<String> {
+ // Try system.reg first (HKEY_LOCAL_MACHINE)
+ let system_reg = prefix_path.join("system.reg");
+ if let Some(value) = read_value_from_reg_file(&system_reg, key_path, value_name) {
+ return Some(value);
+ }
+
+ // Try user.reg (HKEY_CURRENT_USER)
+ let user_reg = prefix_path.join("user.reg");
+ if let Some(value) = read_value_from_reg_file(&user_reg, key_path, value_name) {
+ return Some(value);
+ }
+
+ None
+}
+
+/// Read a value from a specific .reg file
+fn read_value_from_reg_file(reg_file: &Path, key_path: &str, value_name: &str) -> Option<String> {
+ let content = fs::read_to_string(reg_file).ok()?;
+
+ // Convert the key path to Wine's format
+ // Wine uses lowercase keys with escaped backslashes
+ // e.g., [Software\\Bethesda Softworks\\Skyrim] becomes [software\\\\bethesda softworks\\\\skyrim]
+ let wine_key = format!(
+ "[{}]",
+ key_path.to_lowercase().replace('\\', "\\\\")
+ );
+
+ // Also try with the Wow6432Node variant for 32-bit apps on 64-bit Wine
+ let wine_key_wow64 = format!(
+ "[software\\\\wow6432node\\\\{}]",
+ key_path
+ .strip_prefix("Software\\")
+ .unwrap_or(key_path)
+ .to_lowercase()
+ .replace('\\', "\\\\")
+ );
+
+ // Find the key section and extract the value
+ for key_to_find in [&wine_key, &wine_key_wow64] {
+ if let Some(value) = find_value_in_content(&content, key_to_find, value_name) {
+ return Some(value);
+ }
+ }
+
+ None
+}
+
+/// Find a value within registry file content
+fn find_value_in_content(content: &str, key: &str, value_name: &str) -> Option<String> {
+ let mut in_target_key = false;
+ let value_name_lower = value_name.to_lowercase();
+
+ for line in content.lines() {
+ let trimmed = line.trim();
+
+ // Check for key header
+ if trimmed.starts_with('[') && trimmed.ends_with(']') {
+ in_target_key = trimmed.to_lowercase() == key.to_lowercase();
+ continue;
+ }
+
+ // If we're in the target key, look for the value
+ if in_target_key {
+ // Empty line or new section means we've left the key
+ if trimmed.is_empty() {
+ continue;
+ }
+ if trimmed.starts_with('[') {
+ break;
+ }
+
+ // Parse value line: "ValueName"="value" or @="default value"
+ if let Some((name, value)) = parse_reg_value_line(trimmed) {
+ if name.to_lowercase() == value_name_lower {
+ return Some(value);
+ }
+ }
+ }
+ }
+
+ None
+}
+
+/// Parse a registry value line like "ValueName"="value"
+fn parse_reg_value_line(line: &str) -> Option<(String, String)> {
+ // Format: "name"="value" or "name"=dword:00000000 or @="default"
+ let line = line.trim();
+
+ // Find the = separator
+ let eq_pos = line.find('=')?;
+ let (name_part, value_part) = line.split_at(eq_pos);
+ let value_part = &value_part[1..]; // Skip the '='
+
+ // Extract name (remove quotes)
+ let name = if name_part == "@" {
+ "@".to_string()
+ } else {
+ name_part.trim().trim_matches('"').to_string()
+ };
+
+ // Extract value
+ let value = if value_part.starts_with('"') {
+ // String value - need to handle escapes
+ parse_quoted_reg_value(value_part)?
+ } else if value_part.starts_with("dword:") {
+ // DWORD value - convert to decimal string
+ let hex = value_part.strip_prefix("dword:")?;
+ let num = u32::from_str_radix(hex, 16).ok()?;
+ num.to_string()
+ } else {
+ // Other types - return as-is
+ value_part.to_string()
+ };
+
+ Some((name, value))
+}
+
+/// Parse a quoted registry value, handling Wine's escape sequences
+fn parse_quoted_reg_value(s: &str) -> Option<String> {
+ let s = s.trim();
+ if !s.starts_with('"') {
+ return None;
+ }
+
+ let mut result = String::new();
+ let chars = s[1..].chars();
+ let mut prev_was_backslash = false;
+
+ for c in chars {
+ if prev_was_backslash {
+ match c {
+ 'n' => result.push('\n'),
+ 'r' => result.push('\r'),
+ 't' => result.push('\t'),
+ '\\' => result.push('\\'),
+ '"' => result.push('"'),
+ _ => {
+ result.push('\\');
+ result.push(c);
+ }
+ }
+ prev_was_backslash = false;
+ } else if c == '\\' {
+ prev_was_backslash = true;
+ } else if c == '"' {
+ break; // End of string
+ } else {
+ result.push(c);
+ }
+ }
+
+ Some(result)
+}
+
+/// Convert a Wine path (Z:\path\to\file) to a Linux path
+pub fn wine_path_to_linux(wine_path: &str) -> Option<PathBuf> {
+ let path = wine_path.trim();
+
+ // Handle Z: drive (maps to /)
+ if path.starts_with("Z:") || path.starts_with("z:") {
+ let linux_path = path[2..].replace('\\', "/");
+ return Some(PathBuf::from(linux_path));
+ }
+
+ // Handle C: drive (maps to prefix/drive_c)
+ // Note: This requires knowing the prefix path, so we can't convert it here
+ // For now, we'll just return None for C: paths
+ if path.starts_with("C:") || path.starts_with("c:") {
+ log_warning(&format!(
+ "Cannot convert C: drive path without prefix: {}",
+ path
+ ));
+ return None;
+ }
+
+ None
+}
+
+/// Check if a Wine prefix contains a specific game by registry key
+pub fn has_game_registry(
+ prefix_path: &Path,
+ registry_path: &str,
+ registry_value: &str,
+) -> bool {
+ read_registry_value(prefix_path, registry_path, registry_value).is_some()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_reg_value_line() {
+ let (name, value) =
+ parse_reg_value_line(r#""Installed Path"="Z:\\mnt\\games\\Skyrim""#).unwrap();
+ assert_eq!(name, "Installed Path");
+ assert_eq!(value, r"Z:\mnt\games\Skyrim");
+ }
+
+ #[test]
+ fn test_wine_path_to_linux() {
+ let linux = wine_path_to_linux(r"Z:\mnt\games\Skyrim").unwrap();
+ assert_eq!(linux, PathBuf::from("/mnt/games/Skyrim"));
+ }
+}
diff --git a/libs/nak/src/game_finder/steam.rs b/libs/nak/src/game_finder/steam.rs
new file mode 100644
index 0000000..b88a0b0
--- /dev/null
+++ b/libs/nak/src/game_finder/steam.rs
@@ -0,0 +1,251 @@
+//! Steam game detection
+//!
+//! Detects games installed via Steam by parsing appmanifest_*.acf files.
+//! Supports native, Flatpak, and Snap Steam installations.
+
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use super::known_games::{find_by_steam_id, KnownGame};
+use super::vdf::{parse_library_folders, AppManifest};
+use super::{Game, Launcher};
+use crate::logging::log_info;
+
+/// All possible Steam installation paths to check
+const STEAM_PATHS: &[&str] = &[
+ ".local/share/Steam",
+ ".steam/debian-installation",
+ ".steam/steam",
+ ".var/app/com.valvesoftware.Steam/data/Steam",
+ ".var/app/com.valvesoftware.Steam/.local/share/Steam",
+ "snap/steam/common/.local/share/Steam",
+];
+
+/// Detect all Steam games across all installations
+pub fn detect_steam_games() -> Vec<Game> {
+ let mut games = Vec::new();
+ let home = match std::env::var("HOME") {
+ Ok(h) => h,
+ Err(_) => return games,
+ };
+
+ // Find all Steam installations
+ for steam_info in find_steam_installations(&home) {
+ let libraries = get_library_folders(&steam_info.path);
+
+ for library_path in libraries {
+ let steamapps = library_path.join("steamapps");
+ if !steamapps.exists() {
+ continue;
+ }
+
+ // Scan for appmanifest_*.acf files
+ let Ok(entries) = fs::read_dir(&steamapps) else {
+ continue;
+ };
+
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
+ continue;
+ };
+
+ if name.starts_with("appmanifest_") && name.ends_with(".acf") {
+ if let Some(game) = parse_appmanifest(&path, &steamapps, &steam_info) {
+ games.push(game);
+ }
+ }
+ }
+ }
+ }
+
+ log_info(&format!("Steam: Found {} installed games", games.len()));
+ games
+}
+
+/// Information about a Steam installation
+struct SteamInstallation {
+ path: PathBuf,
+ is_flatpak: bool,
+ is_snap: bool,
+}
+
+/// Find all Steam installations on the system
+fn find_steam_installations(home: &str) -> Vec<SteamInstallation> {
+ let mut installations = Vec::new();
+
+ for relative_path in STEAM_PATHS {
+ let full_path = PathBuf::from(home).join(relative_path);
+
+ // Check if this is a valid Steam installation
+ if full_path.join("steamapps").exists() || full_path.join("steam.pid").exists() {
+ let is_flatpak = relative_path.contains(".var/app/com.valvesoftware.Steam");
+ let is_snap = relative_path.contains("snap/steam");
+
+ // Avoid duplicates (symlinks can cause the same installation to appear twice)
+ let canonical = full_path.canonicalize().unwrap_or(full_path.clone());
+ if !installations.iter().any(|i: &SteamInstallation| {
+ i.path.canonicalize().unwrap_or(i.path.clone()) == canonical
+ }) {
+ log_info(&format!(
+ "Found Steam installation: {} (flatpak={}, snap={})",
+ full_path.display(),
+ is_flatpak,
+ is_snap
+ ));
+ installations.push(SteamInstallation {
+ path: full_path,
+ is_flatpak,
+ is_snap,
+ });
+ }
+ }
+ }
+
+ installations
+}
+
+/// Get all library folders for a Steam installation
+fn get_library_folders(steam_path: &Path) -> Vec<PathBuf> {
+ let mut folders = Vec::new();
+
+ // The Steam installation directory itself is always a library
+ folders.push(steam_path.to_path_buf());
+
+ // Parse libraryfolders.vdf for additional libraries
+ let vdf_path = steam_path.join("steamapps/libraryfolders.vdf");
+ if let Ok(content) = fs::read_to_string(&vdf_path) {
+ for path_str in parse_library_folders(&content) {
+ let path = PathBuf::from(&path_str);
+ if path.exists() && !folders.contains(&path) {
+ folders.push(path);
+ }
+ }
+ }
+
+ // Also check the older config/libraryfolders.vdf location
+ let old_vdf_path = steam_path.join("config/libraryfolders.vdf");
+ if old_vdf_path != vdf_path {
+ if let Ok(content) = fs::read_to_string(&old_vdf_path) {
+ for path_str in parse_library_folders(&content) {
+ let path = PathBuf::from(&path_str);
+ if path.exists() && !folders.contains(&path) {
+ folders.push(path);
+ }
+ }
+ }
+ }
+
+ folders
+}
+
+/// Parse an appmanifest_*.acf file and create a Game struct
+fn parse_appmanifest(
+ manifest_path: &Path,
+ steamapps_path: &Path,
+ steam_info: &SteamInstallation,
+) -> Option<Game> {
+ let content = fs::read_to_string(manifest_path).ok()?;
+ let manifest = AppManifest::from_vdf(&content)?;
+
+ // Only consider fully installed games
+ if !manifest.is_installed() {
+ return None;
+ }
+
+ // Build the install path
+ let install_path = steamapps_path.join("common").join(&manifest.install_dir);
+ if !install_path.exists() {
+ return None;
+ }
+
+ // Build the prefix path
+ let prefix_path = steamapps_path
+ .join("compatdata")
+ .join(&manifest.app_id)
+ .join("pfx");
+
+ let prefix_path = if prefix_path.exists() {
+ Some(prefix_path)
+ } else {
+ None
+ };
+
+ // Look up known game info
+ let known_game = find_by_steam_id(&manifest.app_id);
+
+ Some(Game {
+ name: manifest.name,
+ app_id: manifest.app_id,
+ install_path,
+ prefix_path,
+ launcher: Launcher::Steam {
+ is_flatpak: steam_info.is_flatpak,
+ is_snap: steam_info.is_snap,
+ },
+ my_games_folder: known_game.and_then(|g| g.my_games_folder.map(String::from)),
+ appdata_local_folder: known_game.and_then(|g| g.appdata_local_folder.map(String::from)),
+ appdata_roaming_folder: known_game.and_then(|g| g.appdata_roaming_folder.map(String::from)),
+ registry_path: known_game.map(|g| g.registry_path.to_string()),
+ registry_value: known_game.map(|g| g.registry_value.to_string()),
+ })
+}
+
+/// Find the installation path for a specific Steam game by App ID
+pub fn find_game_install_path(app_id: &str) -> Option<PathBuf> {
+ let home = std::env::var("HOME").ok()?;
+
+ for steam_info in find_steam_installations(&home) {
+ let libraries = get_library_folders(&steam_info.path);
+
+ for library_path in libraries {
+ let manifest_path = library_path
+ .join("steamapps")
+ .join(format!("appmanifest_{}.acf", app_id));
+
+ if manifest_path.exists() {
+ let content = fs::read_to_string(&manifest_path).ok()?;
+ let manifest = AppManifest::from_vdf(&content)?;
+
+ if manifest.is_installed() {
+ let install_path = library_path
+ .join("steamapps/common")
+ .join(&manifest.install_dir);
+
+ if install_path.exists() {
+ return Some(install_path);
+ }
+ }
+ }
+ }
+ }
+
+ None
+}
+
+/// Find the Wine prefix for a specific Steam game by App ID
+pub fn find_game_prefix_path(app_id: &str) -> Option<PathBuf> {
+ let home = std::env::var("HOME").ok()?;
+
+ for steam_info in find_steam_installations(&home) {
+ let libraries = get_library_folders(&steam_info.path);
+
+ for library_path in libraries {
+ let prefix_path = library_path
+ .join("steamapps/compatdata")
+ .join(app_id)
+ .join("pfx");
+
+ if prefix_path.exists() {
+ return Some(prefix_path);
+ }
+ }
+ }
+
+ None
+}
+
+/// Get the known game configuration for a Steam App ID
+pub fn get_known_game(app_id: &str) -> Option<&'static KnownGame> {
+ find_by_steam_id(app_id)
+}
diff --git a/libs/nak/src/game_finder/vdf.rs b/libs/nak/src/game_finder/vdf.rs
new file mode 100644
index 0000000..c296f27
--- /dev/null
+++ b/libs/nak/src/game_finder/vdf.rs
@@ -0,0 +1,254 @@
+//! Hand-rolled VDF (Valve Data Format) parser
+//!
+//! Parses VDF files like appmanifest_*.acf and libraryfolders.vdf
+//! without external dependencies.
+
+use std::collections::HashMap;
+
+/// A VDF value - either a string or a nested object
+#[derive(Debug, Clone)]
+pub enum VdfValue {
+ String(String),
+ Object(HashMap<String, VdfValue>),
+}
+
+impl VdfValue {
+ /// Get as string reference
+ pub fn as_str(&self) -> Option<&str> {
+ match self {
+ VdfValue::String(s) => Some(s),
+ VdfValue::Object(_) => None,
+ }
+ }
+
+ /// Get as object reference
+ pub fn as_object(&self) -> Option<&HashMap<String, VdfValue>> {
+ match self {
+ VdfValue::String(_) => None,
+ VdfValue::Object(o) => Some(o),
+ }
+ }
+
+ /// Get a nested value by key
+ pub fn get(&self, key: &str) -> Option<&VdfValue> {
+ self.as_object()?.get(key)
+ }
+
+ /// Get a string value by key
+ pub fn get_str(&self, key: &str) -> Option<&str> {
+ self.get(key)?.as_str()
+ }
+}
+
+/// Parse a VDF file content into a root object
+pub fn parse_vdf(content: &str) -> Option<VdfValue> {
+ let mut chars = content.chars().peekable();
+ parse_object(&mut chars)
+}
+
+/// Parse an object (including the root level)
+fn parse_object<I: Iterator<Item = char>>(chars: &mut std::iter::Peekable<I>) -> Option<VdfValue> {
+ let mut map = HashMap::new();
+
+ loop {
+ skip_whitespace_and_comments(chars);
+
+ match chars.peek() {
+ None => break,
+ Some('}') => {
+ chars.next();
+ break;
+ }
+ Some('"') => {
+ // Parse key
+ let key = parse_quoted_string(chars)?;
+ skip_whitespace_and_comments(chars);
+
+ // Check what follows - string value or object
+ match chars.peek() {
+ Some('"') => {
+ // String value
+ let value = parse_quoted_string(chars)?;
+ map.insert(key, VdfValue::String(value));
+ }
+ Some('{') => {
+ // Object value
+ chars.next(); // consume '{'
+ let value = parse_object(chars)?;
+ map.insert(key, value);
+ }
+ _ => return None, // Unexpected token
+ }
+ }
+ _ => {
+ // Skip unexpected characters
+ chars.next();
+ }
+ }
+ }
+
+ Some(VdfValue::Object(map))
+}
+
+/// Parse a quoted string "..."
+fn parse_quoted_string<I: Iterator<Item = char>>(
+ chars: &mut std::iter::Peekable<I>,
+) -> Option<String> {
+ // Expect opening quote
+ if chars.next() != Some('"') {
+ return None;
+ }
+
+ let mut result = String::new();
+
+ loop {
+ match chars.next() {
+ None => return None, // Unterminated string
+ Some('"') => break,
+ Some('\\') => {
+ // Handle escape sequences
+ match chars.next() {
+ Some('n') => result.push('\n'),
+ Some('t') => result.push('\t'),
+ Some('\\') => result.push('\\'),
+ Some('"') => result.push('"'),
+ Some(c) => {
+ result.push('\\');
+ result.push(c);
+ }
+ None => return None,
+ }
+ }
+ Some(c) => result.push(c),
+ }
+ }
+
+ Some(result)
+}
+
+/// Skip whitespace and // comments
+fn skip_whitespace_and_comments<I: Iterator<Item = char>>(chars: &mut std::iter::Peekable<I>) {
+ loop {
+ // Skip whitespace
+ while chars.peek().is_some_and(|c| c.is_whitespace()) {
+ chars.next();
+ }
+
+ // Check for // comment
+ // Need to peek ahead without cloning - consume first / and check second
+ if chars.peek() == Some(&'/') {
+ // Consume first /
+ chars.next();
+ if chars.peek() == Some(&'/') {
+ // It's a comment - skip to end of line
+ chars.next();
+ while chars.peek().is_some_and(|c| *c != '\n') {
+ chars.next();
+ }
+ continue;
+ }
+ // Not a comment, but we consumed a '/' - this shouldn't happen in valid VDF
+ // Just continue processing
+ }
+
+ break;
+ }
+}
+
+/// Parse an appmanifest_*.acf file and extract app info
+#[derive(Debug, Clone)]
+pub struct AppManifest {
+ pub app_id: String,
+ pub name: String,
+ pub install_dir: String,
+ pub state_flags: u32,
+}
+
+impl AppManifest {
+ /// Parse from VDF content
+ pub fn from_vdf(content: &str) -> Option<Self> {
+ let root = parse_vdf(content)?;
+ let app_state = root.get("AppState")?;
+
+ Some(Self {
+ app_id: app_state.get_str("appid")?.to_string(),
+ name: app_state.get_str("name")?.to_string(),
+ install_dir: app_state.get_str("installdir")?.to_string(),
+ state_flags: app_state.get_str("StateFlags")?.parse().unwrap_or(0),
+ })
+ }
+
+ /// Check if the game is fully installed (StateFlags == 4)
+ pub fn is_installed(&self) -> bool {
+ self.state_flags == 4
+ }
+}
+
+/// Parse libraryfolders.vdf and extract library paths
+pub fn parse_library_folders(content: &str) -> Vec<String> {
+ let mut paths = Vec::new();
+
+ let Some(root) = parse_vdf(content) else {
+ return paths;
+ };
+
+ let Some(library_folders) = root.get("libraryfolders").and_then(|v| v.as_object()) else {
+ return paths;
+ };
+
+ // Library folders are keyed by index: "0", "1", "2", etc.
+ for value in library_folders.values() {
+ if let Some(path) = value.get_str("path") {
+ paths.push(path.to_string());
+ }
+ }
+
+ paths
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_appmanifest() {
+ let content = r#"
+"AppState"
+{
+ "appid" "489830"
+ "Universe" "1"
+ "name" "Skyrim Special Edition"
+ "StateFlags" "4"
+ "installdir" "Skyrim Special Edition"
+}
+"#;
+ let manifest = AppManifest::from_vdf(content).unwrap();
+ assert_eq!(manifest.app_id, "489830");
+ assert_eq!(manifest.name, "Skyrim Special Edition");
+ assert_eq!(manifest.install_dir, "Skyrim Special Edition");
+ assert!(manifest.is_installed());
+ }
+
+ #[test]
+ fn test_parse_library_folders() {
+ let content = r#"
+"libraryfolders"
+{
+ "0"
+ {
+ "path" "/home/user/.local/share/Steam"
+ "label" ""
+ }
+ "1"
+ {
+ "path" "/mnt/games/SteamLibrary"
+ "label" "Games"
+ }
+}
+"#;
+ let paths = parse_library_folders(content);
+ assert_eq!(paths.len(), 2);
+ assert!(paths.contains(&"/home/user/.local/share/Steam".to_string()));
+ assert!(paths.contains(&"/mnt/games/SteamLibrary".to_string()));
+ }
+}