aboutsummaryrefslogtreecommitdiff
path: root/libs/nak/src/game_finder
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-14 02:45:12 -0600
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-02-14 02:45:25 -0600
commit817e8f5cd26739c69d930d21cd9dc4c0b6e4984e (patch)
tree52aed11d6751bb7d20b06acf197d56fac113203d /libs/nak/src/game_finder
parent51a9f8f197727f00896e5de44569b098923527dd (diff)
Add FUSE external mapping support, BG3/Oblivion Remastered fixes, fomod-plus and NaK integration
FUSE VFS now deploys non-data-dir mod mappings (Paks, OBSE, UE4SS, etc.) via real symlinks and injects file-level data-dir mappings (plugins.txt, loadorder.txt) into the VFS tree. Fixes game launches for Oblivion Remastered (Root Builder path resolution, script extender support) and BG3 (Wine prefix documents directory, file mapper symlinks on Linux). Vendors mo2-fomod-plus plugin and NaK crate for FOMOD installer and game finder/runtime support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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()));
+ }
+}