detect: replace exe-name heuristic with multi-stage resolution pipeline

Introduce resolve_game_name() as the single entry point for deriving a
display name from a game executable. Resolution order:
  1. Explicit name (caller-supplied)
  2. Static override table for known bad stems (FactoryGame, bg3, etc.)
  3. GOG goggame-*.info and Epic .egstore/*.item manifest JSON files
  4. PE VERSIONINFO scan (ProductName / FileDescription, first 8 MB)
  5. Heuristic fallback: parent directory name or CamelCase stem split

Remove prettify_game_name and humanise_stem; expose prettify_exe_name
as the public heuristic-only fallback. Resolved names are cached in a
process-wide LazyLock<Mutex<HashMap>> so repeated scans are free.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-19 10:31:43 -07:00
parent f3f5046265
commit e213377a95
+218 -25
View File
@@ -2,7 +2,9 @@ use crate::config::{Config, Launcher};
use anyhow::Result; use anyhow::Result;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::io::Read as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DetectHit { pub struct DetectHit {
@@ -111,6 +113,38 @@ const SKIP_EXES: &[&str] = &[
"aria2c", "aria2c",
]; ];
// --- Name resolution ---
/// Cache of absolute exe path → resolved display name (populated lazily).
static NAME_CACHE: LazyLock<Mutex<HashMap<PathBuf, String>>> =
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. /// Scan a launcher's Wine prefix for installed game executables.
/// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically, /// Returns (display_name, path_relative_to_drive_c) pairs, sorted alphabetically,
/// excluding the launcher's own exe and any already-configured games. /// excluding the launcher's own exe and any already-configured games.
@@ -191,27 +225,197 @@ fn scan_exe_dir(
if !seen.insert(rel_lower) { if !seen.insert(rel_lower) {
continue; continue;
} }
let display = prettify_game_name(&path); let display = resolve_game_name(&path, None);
out.push((display, rel_str)); 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 /// Resolution pipeline (first hit wins):
/// `Program Files/Call of Duty/game.exe`) unless it looks generic /// 1. Explicit name — if `explicit_name` is `Some`, return it immediately.
/// (like "Bin", "x64", "Binaries"), in which case walk up. Falls back /// 2. Static override — known bad exe stems (e.g. `factorygame` → "Satisfactory").
/// to humanising the exe file stem by inserting spaces before capitals. /// 3. Store/platform metadata — reserved for future integration.
fn prettify_game_name(path: &Path) -> String { /// 4. Game manifest files — GOG `.info` and Epic `.egstore/*.item` JSON.
// Generic directory names that don't make good game labels /// 5. PE VERSIONINFO — `ProductName` / `FileDescription` from the binary.
/// 6. Heuristic fallback — parent directory name or humanised exe stem.
///
/// Results from stages 26 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<String> {
let dir = exe_path.parent()?;
// GOG: goggame-<id>.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::<serde_json::Value>(&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::<serde_json::Value>(&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<String> {
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<String> {
// Encode key as null-terminated UTF-16LE
let key_bytes: Vec<u8> = 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<u16> = 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] = &[ const GENERIC_DIRS: &[&str] = &[
"bin", "binaries", "x64", "x86", "win64", "win32", "retail", "bin", "binaries", "x64", "x86", "win64", "win32", "retail",
"shipping", "game", "runtime", "_retail_", "_commonredist", "shipping", "game", "runtime", "_retail_", "_commonredist",
"launcher", "engine", "client", "launcher", "engine", "client",
]; ];
// Try parent directories (closest first, up to 3 levels)
let mut dir = path.parent(); let mut dir = path.parent();
for _ in 0..3 { for _ in 0..3 {
let Some(d) = dir else { break }; let Some(d) = dir else { break };
@@ -226,34 +430,24 @@ fn prettify_game_name(path: &Path) -> String {
dir = d.parent(); dir = d.parent();
} }
// Fallback: humanise the exe stem ("BlackOps6" → "Black Ops 6")
let stem = path let stem = path
.file_stem() .file_stem()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.unwrap_or("Unknown"); .unwrap_or("Unknown");
humanise_stem(stem)
}
/// Insert spaces before uppercase runs and digit boundaries. // Insert spaces at CamelCase and digit boundaries.
/// "BlackOps6" → "Black Ops 6", "HITMAN3" → "HITMAN 3" let mut out = String::with_capacity(stem.len() + 4);
fn humanise_stem(s: &str) -> String { let chars: Vec<char> = stem.chars().collect();
let mut out = String::with_capacity(s.len() + 4);
let chars: Vec<char> = s.chars().collect();
for (i, &c) in chars.iter().enumerate() { for (i, &c) in chars.iter().enumerate() {
if i > 0 { if i > 0 {
let prev = chars[i - 1]; let prev = chars[i - 1];
// Letter→digit or digit→letter boundary
if (prev.is_alphabetic() && c.is_ascii_digit()) if (prev.is_alphabetic() && c.is_ascii_digit())
|| (prev.is_ascii_digit() && c.is_alphabetic()) || (prev.is_ascii_digit() && c.is_alphabetic())
{ {
out.push(' '); out.push(' ');
} } else if prev.is_lowercase() && c.is_uppercase() {
// lowercase→uppercase ("nO" in "BlackOps")
else if prev.is_lowercase() && c.is_uppercase() {
out.push(' '); out.push(' ');
} } else if i + 1 < chars.len()
// UPPER run ending: "ABCdef" → "AB Cdef"
else if i + 1 < chars.len()
&& prev.is_uppercase() && prev.is_uppercase()
&& c.is_uppercase() && c.is_uppercase()
&& chars[i + 1].is_lowercase() && chars[i + 1].is_lowercase()
@@ -261,7 +455,6 @@ fn humanise_stem(s: &str) -> String {
out.push(' '); out.push(' ');
} }
} }
// Replace underscores / hyphens with spaces
if c == '_' || c == '-' { if c == '_' || c == '-' {
out.push(' '); out.push(' ');
} else { } else {