Initial import
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user