diff --git a/src/detect.rs b/src/detect.rs index 2658219..cf0aff6 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -2,7 +2,9 @@ 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}; #[derive(Debug, Clone)] pub struct DetectHit { @@ -111,6 +113,38 @@ const SKIP_EXES: &[&str] = &[ "aria2c", ]; +// --- Name resolution --- + +/// Cache of absolute exe path → resolved display name (populated lazily). +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. @@ -191,27 +225,197 @@ fn scan_exe_dir( if !seen.insert(rel_lower) { continue; } - let display = prettify_game_name(&path); + let display = resolve_game_name(&path, None); out.push((display, rel_str)); } } } -/// Derive a human-readable game name from an exe path. +/// Resolve a human-readable display name for a game exe. /// -/// Strategy: use the parent directory name (e.g. "Call of Duty" from -/// `Program Files/Call of Duty/game.exe`) unless it looks generic -/// (like "Bin", "x64", "Binaries"), in which case walk up. Falls back -/// to humanising the exe file stem by inserting spaces before capitals. -fn prettify_game_name(path: &Path) -> String { - // Generic directory names that don't make good game labels +/// 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. +/// +/// Results from stages 2–6 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(); + } + + { + let cache = NAME_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(cached) = cache.get(exe_path) { + return cached.clone(); + } + } + + let name = resolve_uncached(exe_path); + + NAME_CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(exe_path.to_path_buf(), name.clone()); + + name +} + +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 – 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 + if let Some(name) = read_manifest_name(exe_path) { + return name; + } + + // Stage 5 – PE VERSIONINFO + if let Some(name) = read_pe_name(exe_path) { + return name; + } + + // Stage 6 – heuristic fallback + prettify_exe_name(exe_path) +} + +/// Parse GOG `.info` and Epic `.egstore/*.item` manifest files near `exe_path`. +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()); + } + } + } + } + } + } + } + + // 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) { + return Some(name); + } + } + 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(); + + 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); + } + } + } + search += rel + 1; + } + None +} + +/// Heuristic last-resort name derivation from an exe path. +/// +/// Walks up parent directories looking for a non-generic name; falls back to +/// inserting spaces into the CamelCase / digit-boundary exe stem. +pub fn prettify_exe_name(path: &Path) -> String { const GENERIC_DIRS: &[&str] = &[ "bin", "binaries", "x64", "x86", "win64", "win32", "retail", "shipping", "game", "runtime", "_retail_", "_commonredist", "launcher", "engine", "client", ]; - // Try parent directories (closest first, up to 3 levels) let mut dir = path.parent(); for _ in 0..3 { let Some(d) = dir else { break }; @@ -226,34 +430,24 @@ fn prettify_game_name(path: &Path) -> String { dir = d.parent(); } - // Fallback: humanise the exe stem ("BlackOps6" → "Black Ops 6") let stem = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("Unknown"); - humanise_stem(stem) -} -/// Insert spaces before uppercase runs and digit boundaries. -/// "BlackOps6" → "Black Ops 6", "HITMAN3" → "HITMAN 3" -fn humanise_stem(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 4); - let chars: Vec = s.chars().collect(); + // Insert spaces at CamelCase and digit boundaries. + let mut out = String::with_capacity(stem.len() + 4); + let chars: Vec = stem.chars().collect(); for (i, &c) in chars.iter().enumerate() { if i > 0 { let prev = chars[i - 1]; - // Letter→digit or digit→letter boundary if (prev.is_alphabetic() && c.is_ascii_digit()) || (prev.is_ascii_digit() && c.is_alphabetic()) { out.push(' '); - } - // lowercase→uppercase ("nO" in "BlackOps") - else if prev.is_lowercase() && c.is_uppercase() { + } else if prev.is_lowercase() && c.is_uppercase() { out.push(' '); - } - // UPPER run ending: "ABCdef" → "AB Cdef" - else if i + 1 < chars.len() + } else if i + 1 < chars.len() && prev.is_uppercase() && c.is_uppercase() && chars[i + 1].is_lowercase() @@ -261,7 +455,6 @@ fn humanise_stem(s: &str) -> String { out.push(' '); } } - // Replace underscores / hyphens with spaces if c == '_' || c == '-' { out.push(' '); } else {