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 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 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 {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user