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 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<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.
/// 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 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] = &[
"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<char> = s.chars().collect();
// Insert spaces at CamelCase and digit boundaries.
let mut out = String::with_capacity(stem.len() + 4);
let chars: Vec<char> = 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 {