Initial import

This commit is contained in:
2026-03-30 22:51:56 -04:00
commit 08e2910b9d
103 changed files with 35475 additions and 0 deletions
+455
View File
@@ -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()