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 owo_colors::OwoColorize;
use std::collections::{HashMap, HashSet};
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
@@ -119,32 +118,6 @@ const SKIP_EXES: &[&str] = &[
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.
@@ -235,13 +208,15 @@ fn scan_exe_dir(
///
/// 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.
/// 2. Store/platform metadata — reserved for future integration.
/// 3. Manifest walk — walks up from the exe looking for GOG `.info` and
/// Epic `.egstore/*.item` JSON files at the game's installation root.
/// 4. Launcher path — reads the game name from well-known directory
/// 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 {
if let Some(name) = explicit_name {
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 {
let stem = exe_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let stem_lower = stem.to_lowercase();
// Stage 2 store/platform metadata (future integration point)
// 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
// Stage 3 manifest files at the game's installation root
if let Some(name) = read_manifest_name(exe_path) {
return name;
}
// Stage 5 PE VERSIONINFO
if let Some(name) = read_pe_name(exe_path) {
// Stage 4 game name from known launcher directory structures
if let Some(name) = name_from_launcher_path(exe_path) {
return name;
}
// Stage 6 heuristic fallback
// Stage 5 heuristic fallback
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> {
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 Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
if let Some(name) = read_gog_manifest(d).or_else(|| read_epic_manifest(d)) {
return Some(name);
}
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 = 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();
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("gameName")?.as_str()?.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' {
fn read_epic_manifest(dir: &Path) -> Option<String> {
let egstore = dir.join(".egstore");
if !egstore.is_dir() {
return None;
}
for key in &["ProductName", "FileDescription"] {
if let Some(name) = find_pe_version_string(&data, key) {
return Some(name);
for entry in std::fs::read_dir(&egstore).ok()?.flatten() {
if entry.path().extension().and_then(|e| e.to_str()) == Some("item") {
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
}
/// 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();
/// Extract a game name from well-known launcher directory conventions.
///
/// Launchers install each game into a named subdirectory of their own folder.
/// That subdirectory name *is* the display name:
/// - Epic: `…/Epic Games/<GameName>/…`
/// - GOG: `…/GOG Games/<GameName>/…`
/// - Steam: `…/steamapps/common/<GameName>/…`
/// - 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;
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;
for (i, comp) in comps.iter().enumerate() {
let lower = comp.to_str().unwrap_or("").to_lowercase();
match lower.as_str() {
"epic games" | "gog games" | "rockstar games" => {
return comps.get(i + 1).and_then(|c| c.to_str()).map(str::to_string);
}
if c < 0x20 && c != 0x09 {
chars.clear();
break;
"common"
if i > 0
&& 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
}