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:
+74
-134
@@ -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 2–6 are cached by path after first computation.
|
||||
/// Results from stages 2–5 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user