456 lines
12 KiB
Python
456 lines
12 KiB
Python
#!/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()
|