#!/usr/bin/env python3 from __future__ import annotations import json import os import re from pathlib import Path ROOT = Path(__file__).resolve().parent.parent OUTPUT = ROOT / "data" / "game_config_db.toml" IGNORE_CANDIDATES = { "steam_autocloud", "unitycrashhandler64", "unitycrashhandler32", "crashhandler", "crashreporter", "launcher", "launch", "start", "start_protected_game", "eac_launcher", "eosbootstrapper", "eossdk-win64-shipping", "notification_helper", "steamerrorreporter", "steam-launch-wrapper", "proton", "wine", "wine64-preloader", "wine-preloader", "winetricks", "dxsetup", "vc_redist.x64", "vc_redist.x86", "game", "app", "boot", "data", "options", "quickref", "language", "settings", "config", "global", "system", "resources", "resource", "readme", "desc", "icon", "logo", "catalog", "test", "tests", "steam", "lang", "langs", "level0", "level1", "level2", "level3", "level4", "level5", "level6", "level7", "level8", "level9", "unityplayer", "gameassembly", "baselib", "il2cpp", "openimagedenoise", "epicwebhelper", "steam_api", "steam_api64", "sonynp", "unirx", "demilib", "tbb", "tbb12", "tbbmalloc", } IGNORE_PREFIXES = ( "pakchunk", "steam_api", "unitycrashhandler", "crashreport", "crashpad", "openxr", "fmod", "lib", ) IGNORE_EXACT_CASEFOLD = { "readme", "readme.txt", "eula", "license", "version", "preview", "developer", "server", "console", "temp", "meta", "init", "core", "path", "base", "keys", "header", "music", "sfx_ui", "master", "env", "static", "wiki", "mod", "fish", "bees", "note", "shine", "track", "glyph", "death", "prog", "build", "run", "dbdata", "work", "menus", "items", "skills", "input", "bgs", } CURATED_OVERRIDES = { "730": { "title": "Counter-Strike 2", "aliases": ["cs2", "counter strike 2", "counter-strike 2", "csgo"], "candidates": ["cs2", "csgo", "wine-cs2"], "preferred": "cs2", "verification": "verified", }, "570": { "title": "Dota 2", "aliases": ["dota2", "dota"], "candidates": ["dota", "wine-dota2"], "preferred": "dota", "verification": "verified", }, "440": { "title": "Team Fortress 2", "aliases": ["tf2", "team fortress 2"], "candidates": ["tf", "wine-tf2"], "preferred": "tf", "verification": "verified", }, "620": { "title": "Portal 2", "aliases": ["portal2", "portal 2"], "candidates": ["portal2", "wine-portal2"], "preferred": "portal2", "verification": "verified", }, "892970": { "title": "Valheim", "aliases": ["valheim"], "candidates": ["valheim", "wine-valheim"], "preferred": "valheim", "verification": "verified", }, "238960": { "title": "Path of Exile", "aliases": ["path of exile", "poe"], "candidates": ["PathOfExileSteam", "wine-PathOfExileSteam"], "preferred": "PathOfExileSteam", "verification": "verified", }, "105600": { "title": "Terraria", "aliases": ["terraria"], "candidates": ["Terraria", "wine-Terraria"], "preferred": "Terraria", "verification": "verified", }, "1145360": { "title": "Hades", "aliases": ["hades"], "candidates": ["Hades", "wine-Hades"], "preferred": "Hades", "verification": "verified", }, "212680": { "title": "FTL: Faster Than Light", "aliases": ["ftl", "faster than light"], "candidates": ["FTL", "wine-FTLGame"], "preferred": "FTL", "verification": "verified", }, "283160": { "title": "House of the Dying Sun", "aliases": ["house of the dying sun", "dyingsun"], "candidates": ["dyingsun", "wine-dyingsun"], "preferred": "dyingsun", "verification": "verified", }, "1794680": { "title": "Vampire Survivors", "aliases": ["vampire survivors"], "candidates": ["VampireSurvivors", "wine-VampireSurvivors"], "preferred": "VampireSurvivors", "verification": "verified", }, "1091500": { "title": "Cyberpunk 2077", "aliases": ["cyberpunk", "cyberpunk 2077"], "candidates": ["Cyberpunk2077", "wine-Cyberpunk2077"], "preferred": "Cyberpunk2077", "verification": "verified", }, "305620": { "title": "The Long Dark", "aliases": ["the long dark", "long dark"], "candidates": ["tld", "wine-tld"], "preferred": "tld", "verification": "verified", }, } def normalize(text: str) -> str: return re.sub(r"[^a-z0-9]+", "", text.lower()) def parse_library_paths() -> list[Path]: candidates = [ Path.home() / ".local/share/Steam/steamapps/libraryfolders.vdf", Path.home() / ".steam/steam/steamapps/libraryfolders.vdf", ] for candidate in candidates: if candidate.exists(): text = candidate.read_text(errors="ignore") paths = [ Path(match.group(1).replace("\\\\", "/")) for match in re.finditer(r'"path"\s*\t\t"([^"]+)"', text) ] return list(dict.fromkeys(paths)) return [] def parse_manifest(path: Path) -> dict[str, str] | None: text = path.read_text(errors="ignore") appid = re.search(r'"appid"\s*\t+"(\d+)"', text) name = re.search(r'"name"\s*\t+"([^"]+)"', text) installdir = re.search(r'"installdir"\s*\t+"([^"]+)"', text) if not appid or not name or not installdir: return None return { "appid": appid.group(1), "title": name.group(1), "installdir": installdir.group(1), } def candidate_score(candidate: str, title: str, installdir: str) -> tuple[int, int]: norm_candidate = normalize(candidate) norm_title = normalize(title) norm_installdir = normalize(installdir) score = 0 if norm_candidate == norm_installdir: score += 100 if norm_candidate == norm_title: score += 90 if len(norm_candidate) >= 4 and (norm_candidate in norm_title or norm_title in norm_candidate): score += 50 if len(norm_candidate) >= 4 and ( norm_candidate in norm_installdir or norm_installdir in norm_candidate ): score += 35 if candidate.lower().endswith((".sh", ".exe")): score -= 10 return (score, -len(candidate)) def is_noise_candidate(stem: str) -> bool: lowered = stem.casefold() norm = normalize(stem) if not norm or norm in IGNORE_CANDIDATES: return True if lowered in IGNORE_EXACT_CASEFOLD: return True if any(norm.startswith(prefix) for prefix in IGNORE_PREFIXES): return True if re.fullmatch(r"level\d+", lowered): return True if re.fullmatch(r"pakchunk\d+.*", lowered): return True if re.fullmatch(r"sfx[_-].*", lowered): return True if re.fullmatch(r"audiogroup\d+", lowered): return True if re.fullmatch(r"steamworks(_x64)?", lowered): return True return False def collect_candidates(install_dir: Path, title: str, installdir: str) -> list[str]: if not install_dir.exists(): return [] candidates: list[str] = [] for path in install_dir.rglob("*"): if not path.is_file(): continue if len(path.relative_to(install_dir).parts) > 4: continue suffix = path.suffix.lower() if suffix not in {".exe", ".x86_64", ".x86", ".sh", ".app", ""} and not os.access(path, os.X_OK): continue if suffix == "" and not os.access(path, os.X_OK): continue if suffix == ".app" and not os.access(path, os.X_OK): continue stem = path.stem if suffix else path.name if is_noise_candidate(stem): continue if stem.lower().startswith(("unins", "setup", "install", "crash")): continue candidates.append(stem) unique: list[str] = [] seen: set[str] = set() for candidate in candidates: key = candidate.lower() if key in seen: continue seen.add(key) unique.append(candidate) if not unique: fallback = re.sub(r"[^A-Za-z0-9_-]+", "", installdir) if fallback: unique.append(fallback) ranked = sorted(unique, key=lambda item: candidate_score(item, title, installdir), reverse=True) strong = [item for item in ranked if candidate_score(item, title, installdir)[0] > 0] if strong: return strong[:8] fallback = re.sub(r"[^A-Za-z0-9_-]+", "", installdir) if fallback: return [fallback] return ranked[:1] def build_entries() -> list[dict[str, object]]: manifests: list[Path] = [] for library in parse_library_paths(): steamapps = library / "steamapps" if steamapps.exists(): manifests.extend(sorted(steamapps.glob("appmanifest_*.acf"))) entries_by_appid: dict[str, dict[str, object]] = {} for manifest in manifests: parsed = parse_manifest(manifest) if not parsed: continue appid = parsed["appid"] install_dir = manifest.parent / "common" / parsed["installdir"] candidates = collect_candidates(install_dir, parsed["title"], parsed["installdir"]) preferred = candidates[0] if candidates else re.sub(r"[^A-Za-z0-9_-]+", "", parsed["installdir"]) entries_by_appid[appid] = { "appid": int(appid), "title": parsed["title"], "aliases": [parsed["installdir"]], "candidates": candidates, "preferred": preferred, "verification": "heuristic", } for appid, override in CURATED_OVERRIDES.items(): base = entries_by_appid.get(appid, { "appid": int(appid), "title": override["title"], "aliases": [], "candidates": [], "preferred": override["preferred"], "verification": override["verification"], }) alias_set = {alias for alias in base.get("aliases", [])} alias_set.update(override.get("aliases", [])) candidate_list = [] seen_candidates = set() for candidate in [*override.get("candidates", []), *base.get("candidates", [])]: key = candidate.lower() if key in seen_candidates: continue seen_candidates.add(key) candidate_list.append(candidate) base.update({ "title": override["title"], "aliases": sorted(alias_set), "candidates": candidate_list, "preferred": override["preferred"], "verification": override["verification"], }) entries_by_appid[appid] = base entries = list(entries_by_appid.values()) entries.sort(key=lambda entry: entry["title"].lower()) return entries def toml_escape(value: str) -> str: return value.replace("\\", "\\\\").replace("\"", "\\\"") def render_toml(entries: list[dict[str, object]]) -> str: lines = [ "# MangoTune game/executable hint database", "# verification = \"verified\" means manually confirmed", "# verification = \"heuristic\" means derived from local library scan and should be treated as a suggestion", "", ] for entry in entries: lines.append("[[game]]") lines.append(f"appid = {entry['appid']}") lines.append(f"title = \"{toml_escape(entry['title'])}\"") aliases = ", ".join(f'\"{toml_escape(alias)}\"' for alias in entry.get("aliases", [])) candidates = ", ".join( f'\"{toml_escape(candidate)}\"' for candidate in entry.get("candidates", []) ) lines.append(f"aliases = [{aliases}]") lines.append(f"candidates = [{candidates}]") lines.append(f"preferred = \"{toml_escape(entry['preferred'])}\"") lines.append(f"verification = \"{entry['verification']}\"") lines.append("") return "\n".join(lines) def main() -> None: entries = build_entries() OUTPUT.write_text(render_toml(entries)) print(f"Wrote {len(entries)} entries to {OUTPUT}") if __name__ == "__main__": main()