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:
+218
-25
@@ -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 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<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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user