From 2b538a286a6109548767e01260d2a1da4e399e0a Mon Sep 17 00:00:00 2001 From: funman300 Date: Sun, 19 Apr 2026 10:40:09 -0700 Subject: [PATCH] detect: resolve game names from install directory structure, not guesswork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the hardcoded EXE_OVERRIDES lookup table and the unreliable PE byte scanner. Game names are already present in the install directory — we just need to read them from the right place. Resolution pipeline (first hit wins): 1. Explicit name supplied by the caller 2. Manifest walk: traverse up from the exe to the game root looking for GOG goggame-*.info (gameName) and Epic .egstore/*.item (DisplayName) 3. Launcher path: read the game name from known directory conventions laid down by the launcher itself: - Epic Games//… - GOG Games//… - steamapps/common//… - Rockstar Games//… 4. Heuristic: nearest non-generic parent directory name, or CamelCase stem split (unchanged, for truly custom/manual installs) Co-Authored-By: Claude Sonnet 4.6 --- src/detect.rs | 232 +++++++++++++++++++------------------------------- 1 file changed, 86 insertions(+), 146 deletions(-) diff --git a/src/detect.rs b/src/detect.rs index cf0aff6..c7263cf 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -2,7 +2,6 @@ use crate::config::{Config, Launcher}; use anyhow::Result; use owo_colors::OwoColorize; use std::collections::{HashMap, HashSet}; -use std::io::Read as _; use std::path::{Path, PathBuf}; use std::sync::{LazyLock, Mutex}; @@ -119,32 +118,6 @@ const SKIP_EXES: &[&str] = &[ static NAME_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); -/// Exe stem (lowercase) → proper game name for known unhelpful exe filenames. -const EXE_OVERRIDES: &[(&str, &str)] = &[ - ("factorygame", "Satisfactory"), - ("wow", "World of Warcraft"), - ("wowclassic", "WoW Classic"), - ("d3", "Diablo III"), - ("d4", "Diablo IV"), - ("sc2", "StarCraft II"), - ("scii", "StarCraft II"), - ("sc", "StarCraft Remastered"), - ("valorant-win64-shipping", "VALORANT"), - ("fortniteclient-win64-shipping", "Fortnite"), - ("rocketleague", "Rocket League"), - ("bg3", "Baldur's Gate 3"), - ("bg3_dx11", "Baldur's Gate 3"), - ("nms", "No Man's Sky"), - ("cyberpunkgame", "Cyberpunk 2077"), - ("witcher3", "The Witcher 3"), - ("re2", "Resident Evil 2"), - ("re3", "Resident Evil 3"), - ("re4", "Resident Evil 4"), - ("re8", "Resident Evil Village"), - ("osi", "Divinity: Original Sin"), - ("aoc", "Age of Conan"), -]; - /// Scan a launcher's Wine prefix for installed game executables. /// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically, /// excluding the launcher's own exe and any already-configured games. @@ -235,13 +208,15 @@ fn scan_exe_dir( /// /// Resolution pipeline (first hit wins): /// 1. Explicit name — if `explicit_name` is `Some`, return it immediately. -/// 2. Static override — known bad exe stems (e.g. `factorygame` → "Satisfactory"). -/// 3. Store/platform metadata — reserved for future integration. -/// 4. Game manifest files — GOG `.info` and Epic `.egstore/*.item` JSON. -/// 5. PE VERSIONINFO — `ProductName` / `FileDescription` from the binary. -/// 6. Heuristic fallback — parent directory name or humanised exe stem. +/// 2. Store/platform metadata — reserved for future integration. +/// 3. Manifest walk — walks up from the exe looking for GOG `.info` and +/// Epic `.egstore/*.item` JSON files at the game's installation root. +/// 4. Launcher path — reads the game name from well-known directory +/// structures laid down by the launcher (e.g. `Epic Games//`). +/// 5. Heuristic fallback — nearest non-generic parent directory name, +/// or the exe stem with CamelCase / digit boundaries split into words. /// -/// Results from stages 2–6 are cached by path after first computation. +/// Results from stages 2–5 are cached by path after first computation. pub fn resolve_game_name(exe_path: &Path, explicit_name: Option<&str>) -> String { if let Some(name) = explicit_name { return name.to_string(); @@ -265,142 +240,107 @@ pub fn resolve_game_name(exe_path: &Path, explicit_name: Option<&str>) -> String } fn resolve_uncached(exe_path: &Path) -> String { - let stem = exe_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(""); - let stem_lower = stem.to_lowercase(); + // Stage 2 – store/platform metadata (future integration point) - // Stage 2 – static override - for &(bad, good) in EXE_OVERRIDES { - if stem_lower == bad { - return good.to_string(); - } - } - - // Stage 3 – store/platform metadata (future integration point) - - // Stage 4 – game manifest files + // Stage 3 – manifest files at the game's installation root if let Some(name) = read_manifest_name(exe_path) { return name; } - // Stage 5 – PE VERSIONINFO - if let Some(name) = read_pe_name(exe_path) { + // Stage 4 – game name from known launcher directory structures + if let Some(name) = name_from_launcher_path(exe_path) { return name; } - // Stage 6 – heuristic fallback + // Stage 5 – heuristic fallback prettify_exe_name(exe_path) } -/// Parse GOG `.info` and Epic `.egstore/*.item` manifest files near `exe_path`. +/// Walk up from `exe_path` looking for platform manifest files that record the +/// game's display name. Manifests live at the game's installation *root*, which +/// can be several directories above the actual exe. +/// +/// Supported formats: +/// - GOG: `goggame-.info` → `{ "gameName": "..." }` +/// - Epic: `.egstore/.item` → `{ "DisplayName": "..." }` fn read_manifest_name(exe_path: &Path) -> Option { - let dir = exe_path.parent()?; - - // GOG: goggame-.info files contain { "gameName": "..." } - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let fname = entry.file_name(); - let fname = fname.to_string_lossy(); - if fname.starts_with("goggame-") && fname.ends_with(".info") { - if let Ok(text) = std::fs::read_to_string(entry.path()) { - if let Ok(json) = serde_json::from_str::(&text) { - if let Some(title) = json.get("gameName").and_then(|v| v.as_str()) { - let t = title.trim(); - if !t.is_empty() { - return Some(t.to_string()); - } - } - } - } - } + let mut dir = exe_path.parent(); + while let Some(d) = dir { + let dirname = d.file_name().and_then(|n| n.to_str()).unwrap_or("").to_lowercase(); + // Stop once we reach drive_c root or the Program Files tier — manifests + // are never above the game's installation folder. + if dirname == "drive_c" || dirname.starts_with("program files") { + break; } - } - // Epic: .egstore/*.item files contain { "DisplayName": "..." } - let egstore = dir.join(".egstore"); - if egstore.is_dir() { - if let Ok(entries) = std::fs::read_dir(&egstore) { - for entry in entries.flatten() { - if entry.path().extension().and_then(|e| e.to_str()) == Some("item") { - if let Ok(text) = std::fs::read_to_string(entry.path()) { - if let Ok(json) = serde_json::from_str::(&text) { - if let Some(title) = json.get("DisplayName").and_then(|v| v.as_str()) { - let t = title.trim(); - if !t.is_empty() { - return Some(t.to_string()); - } - } - } - } - } - } - } - } - - None -} - -/// Read `ProductName` or `FileDescription` from the PE VERSIONINFO resource. -/// Reads at most 8 MB of the file to stay fast on large game binaries. -fn read_pe_name(exe_path: &Path) -> Option { - let mut file = std::fs::File::open(exe_path).ok()?; - let mut data = vec![0u8; 8 * 1024 * 1024]; - let n = file.read(&mut data).ok()?; - data.truncate(n); - if data.len() < 2 || data[0] != b'M' || data[1] != b'Z' { - return None; - } - for key in &["ProductName", "FileDescription"] { - if let Some(name) = find_pe_version_string(&data, key) { + if let Some(name) = read_gog_manifest(d).or_else(|| read_epic_manifest(d)) { return Some(name); } + + dir = d.parent(); } None } -/// Scan raw PE bytes for a UTF-16LE VERSIONINFO string entry by key. -fn find_pe_version_string(data: &[u8], key: &str) -> Option { - // Encode key as null-terminated UTF-16LE - let key_bytes: Vec = key - .encode_utf16() - .chain(std::iter::once(0u16)) - .flat_map(|c| c.to_le_bytes()) - .collect(); +fn read_gog_manifest(dir: &Path) -> Option { + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let fname = entry.file_name(); + let fname = fname.to_string_lossy(); + if fname.starts_with("goggame-") && fname.ends_with(".info") { + let text = std::fs::read_to_string(entry.path()).ok()?; + let json: serde_json::Value = serde_json::from_str(&text).ok()?; + let t = json.get("gameName")?.as_str()?.trim(); + if !t.is_empty() { + return Some(t.to_string()); + } + } + } + None +} - let mut search = 0usize; - while search + key_bytes.len() <= data.len() { - let Some(rel) = data[search..].windows(key_bytes.len()).position(|w| w == key_bytes.as_slice()) else { - break; - }; - let key_end = search + rel + key_bytes.len(); - // Value immediately follows the key, aligned to a 4-byte boundary. - let value_start = (key_end + 3) & !3; - // Read UTF-16LE characters until null or non-printable. - let mut chars: Vec = Vec::new(); - let mut i = value_start; - while i + 1 < data.len() && chars.len() < 256 { - let c = u16::from_le_bytes([data[i], data[i + 1]]); - if c == 0 { - break; - } - if c < 0x20 && c != 0x09 { - chars.clear(); - break; - } - chars.push(c); - i += 2; - } - if chars.len() >= 2 { - if let Ok(s) = String::from_utf16(&chars) { - let t = s.trim().to_string(); - if !t.is_empty() && t.len() < 200 { - return Some(t); - } +fn read_epic_manifest(dir: &Path) -> Option { + let egstore = dir.join(".egstore"); + if !egstore.is_dir() { + return None; + } + for entry in std::fs::read_dir(&egstore).ok()?.flatten() { + if entry.path().extension().and_then(|e| e.to_str()) == Some("item") { + let text = std::fs::read_to_string(entry.path()).ok()?; + let json: serde_json::Value = serde_json::from_str(&text).ok()?; + let t = json.get("DisplayName")?.as_str()?.trim(); + if !t.is_empty() { + return Some(t.to_string()); } } - search += rel + 1; + } + None +} + +/// Extract a game name from well-known launcher directory conventions. +/// +/// Launchers install each game into a named subdirectory of their own folder. +/// That subdirectory name *is* the display name: +/// - Epic: `…/Epic Games//…` +/// - GOG: `…/GOG Games//…` +/// - Steam: `…/steamapps/common//…` +/// - Rockstar:`…/Rockstar Games//…` +fn name_from_launcher_path(exe_path: &Path) -> Option { + let comps: Vec<&std::ffi::OsStr> = exe_path.components().map(|c| c.as_os_str()).collect(); + + for (i, comp) in comps.iter().enumerate() { + let lower = comp.to_str().unwrap_or("").to_lowercase(); + match lower.as_str() { + "epic games" | "gog games" | "rockstar games" => { + return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string); + } + "common" + if i > 0 + && comps[i - 1].to_str().unwrap_or("").to_lowercase() == "steamapps" => + { + return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string); + } + _ => {} + } } None }