detect: resolve game names from install directory structure, not guesswork

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/<GameName>/…
   - GOG Games/<GameName>/…
   - steamapps/common/<GameName>/…
   - Rockstar Games/<GameName>/…
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 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-19 10:40:09 -07:00
parent e213377a95
commit 2b538a286a
+74 -134
View File
@@ -2,7 +2,6 @@ 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}; use std::sync::{LazyLock, Mutex};
@@ -119,32 +118,6 @@ const SKIP_EXES: &[&str] = &[
static NAME_CACHE: LazyLock<Mutex<HashMap<PathBuf, String>>> = static NAME_CACHE: LazyLock<Mutex<HashMap<PathBuf, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new())); 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.
@@ -235,13 +208,15 @@ fn scan_exe_dir(
/// ///
/// Resolution pipeline (first hit wins): /// Resolution pipeline (first hit wins):
/// 1. Explicit name — if `explicit_name` is `Some`, return it immediately. /// 1. Explicit name — if `explicit_name` is `Some`, return it immediately.
/// 2. Static override — known bad exe stems (e.g. `factorygame` → "Satisfactory"). /// 2. Store/platform metadata — reserved for future integration.
/// 3. Store/platform metadata — reserved for future integration. /// 3. Manifest walk — walks up from the exe looking for GOG `.info` and
/// 4. Game manifest files — GOG `.info` and Epic `.egstore/*.item` JSON. /// Epic `.egstore/*.item` JSON files at the game's installation root.
/// 5. PE VERSIONINFO — `ProductName` / `FileDescription` from the binary. /// 4. Launcher path — reads the game name from well-known directory
/// 6. Heuristic fallback — parent directory name or humanised exe stem. /// structures laid down by the launcher (e.g. `Epic Games/<Name>/`).
/// 5. Heuristic fallback — nearest non-generic parent directory name,
/// or the exe stem with CamelCase / digit boundaries split into words.
/// ///
/// Results from stages 26 are cached by path after first computation. /// Results from stages 25 are cached by path after first computation.
pub fn resolve_game_name(exe_path: &Path, explicit_name: Option<&str>) -> String { pub fn resolve_game_name(exe_path: &Path, explicit_name: Option<&str>) -> String {
if let Some(name) = explicit_name { if let Some(name) = explicit_name {
return name.to_string(); 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 { fn resolve_uncached(exe_path: &Path) -> String {
let stem = exe_path // Stage 2 store/platform metadata (future integration point)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let stem_lower = stem.to_lowercase();
// Stage 2 static override // Stage 3 manifest files at the game's installation root
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) { if let Some(name) = read_manifest_name(exe_path) {
return name; return name;
} }
// Stage 5 PE VERSIONINFO // Stage 4 game name from known launcher directory structures
if let Some(name) = read_pe_name(exe_path) { if let Some(name) = name_from_launcher_path(exe_path) {
return name; return name;
} }
// Stage 6 heuristic fallback // Stage 5 heuristic fallback
prettify_exe_name(exe_path) 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-<id>.info` → `{ "gameName": "..." }`
/// - Epic: `.egstore/<id>.item` → `{ "DisplayName": "..." }`
fn read_manifest_name(exe_path: &Path) -> Option<String> { fn read_manifest_name(exe_path: &Path) -> Option<String> {
let dir = exe_path.parent()?; 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;
}
// GOG: goggame-<id>.info files contain { "gameName": "..." } if let Some(name) = read_gog_manifest(d).or_else(|| read_epic_manifest(d)) {
if let Ok(entries) = std::fs::read_dir(dir) { return Some(name);
for entry in entries.flatten() { }
dir = d.parent();
}
None
}
fn read_gog_manifest(dir: &Path) -> Option<String> {
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let fname = entry.file_name(); let fname = entry.file_name();
let fname = fname.to_string_lossy(); let fname = fname.to_string_lossy();
if fname.starts_with("goggame-") && fname.ends_with(".info") { if fname.starts_with("goggame-") && fname.ends_with(".info") {
if let Ok(text) = std::fs::read_to_string(entry.path()) { let text = std::fs::read_to_string(entry.path()).ok()?;
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) { let json: serde_json::Value = serde_json::from_str(&text).ok()?;
if let Some(title) = json.get("gameName").and_then(|v| v.as_str()) { let t = json.get("gameName")?.as_str()?.trim();
let t = title.trim();
if !t.is_empty() { if !t.is_empty() {
return Some(t.to_string()); 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 None
} }
/// Read `ProductName` or `FileDescription` from the PE VERSIONINFO resource. fn read_epic_manifest(dir: &Path) -> Option<String> {
/// Reads at most 8 MB of the file to stay fast on large game binaries. let egstore = dir.join(".egstore");
fn read_pe_name(exe_path: &Path) -> Option<String> { if !egstore.is_dir() {
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; return None;
} }
for key in &["ProductName", "FileDescription"] { for entry in std::fs::read_dir(&egstore).ok()?.flatten() {
if let Some(name) = find_pe_version_string(&data, key) { if entry.path().extension().and_then(|e| e.to_str()) == Some("item") {
return Some(name); 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());
}
} }
} }
None None
} }
/// Scan raw PE bytes for a UTF-16LE VERSIONINFO string entry by key. /// Extract a game name from well-known launcher directory conventions.
fn find_pe_version_string(data: &[u8], key: &str) -> Option<String> { ///
// Encode key as null-terminated UTF-16LE /// Launchers install each game into a named subdirectory of their own folder.
let key_bytes: Vec<u8> = key /// That subdirectory name *is* the display name:
.encode_utf16() /// - Epic: `…/Epic Games/<GameName>/…`
.chain(std::iter::once(0u16)) /// - GOG: `…/GOG Games/<GameName>/…`
.flat_map(|c| c.to_le_bytes()) /// - Steam: `…/steamapps/common/<GameName>/…`
.collect(); /// - Rockstar:`…/Rockstar Games/<GameName>/…`
fn name_from_launcher_path(exe_path: &Path) -> Option<String> {
let comps: Vec<&std::ffi::OsStr> = exe_path.components().map(|c| c.as_os_str()).collect();
let mut search = 0usize; for (i, comp) in comps.iter().enumerate() {
while search + key_bytes.len() <= data.len() { let lower = comp.to_str().unwrap_or("").to_lowercase();
let Some(rel) = data[search..].windows(key_bytes.len()).position(|w| w == key_bytes.as_slice()) else { match lower.as_str() {
break; "epic games" | "gog games" | "rockstar games" => {
}; return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string);
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 { "common"
chars.clear(); if i > 0
break; && 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);
} }
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 None
} }