"""Steam discovery: VDF parsing, app lookup and launch-option handling.""" from __future__ import annotations import os import re import shutil import time from dataclasses import dataclass from pathlib import Path from typing import Any from .util import atomic_write_text APP_ID = "2246340" # Monster Hunter Wilds REQUIRED_LAUNCH_OPTIONS = 'WINEDLLOVERRIDES="dinput8.dll=n,b" %command%' _OVERRIDE = 'dinput8.dll=n,b' _TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])|//[^\n]*|\s+') _ESC = {"n": "\n", "t": "\t", "\\": "\\", '"': '"'} class VDFError(ValueError): pass def _unescape(s: str) -> str: return re.sub(r"\\(.)", lambda m: _ESC.get(m.group(1), m.group(0)), s) def _escape(s: str) -> str: return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\t", "\\t") def parse_vdf(text: str) -> dict[str, Any]: """Parse Valve text KeyValues into nested dicts (duplicate keys: last wins).""" root: dict[str, Any] = {} stack = [root] key: str | None = None pos = 0 while pos < len(text): m = _TOKEN.match(text, pos) if not m: raise VDFError(f"unexpected character at offset {pos}") pos = m.end() if m.group(1) is not None: s = _unescape(m.group(1)) if key is None: key = s else: stack[-1][key] = s key = None elif m.group(2) == "{": if key is None: raise VDFError("'{' without a key") child: dict[str, Any] = {} stack[-1][key] = child stack.append(child) key = None elif m.group(2) == "}": if len(stack) == 1: raise VDFError("unbalanced '}'") stack.pop() if len(stack) != 1: raise VDFError("unterminated block") return root def dump_vdf(data: dict[str, Any], depth: int = 0) -> str: out = [] ind = "\t" * depth for k, v in data.items(): if isinstance(v, dict): out.append(f'{ind}"{_escape(k)}"\n{ind}{{\n{dump_vdf(v, depth + 1)}{ind}}}\n') else: out.append(f'{ind}"{_escape(k)}"\t\t"{_escape(str(v))}"\n') return "".join(out) def vdf_get(data: dict, *path: str, default: Any = None) -> Any: """Case-insensitive nested lookup (Steam is inconsistent about key case).""" cur: Any = data for part in path: if not isinstance(cur, dict): return default hit = next((k for k in cur if k.lower() == part.lower()), None) if hit is None: return default cur = cur[hit] return cur def read_vdf(path: Path) -> dict[str, Any]: return parse_vdf(Path(path).read_text(encoding="utf-8", errors="replace")) @dataclass class GameInstall: app_id: str name: str steam_root: Path library: Path game_dir: Path build_id: str = "" def steam_roots() -> list[Path]: home = Path.home() cands = [home / ".steam/steam", home / ".local/share/Steam", home / ".var/app/com.valvesoftware.Steam/.local/share/Steam"] seen, out = set(), [] for c in cands: if (c / "steamapps").is_dir(): real = c.resolve() if real not in seen: seen.add(real) out.append(real) return out def library_folders(steam_root: Path) -> list[Path]: libs = [Path(steam_root)] vdf = Path(steam_root) / "steamapps/libraryfolders.vdf" if vdf.is_file(): try: data = vdf_get(read_vdf(vdf), "libraryfolders", default={}) except VDFError: data = {} for entry in data.values() if isinstance(data, dict) else []: p = entry.get("path") if isinstance(entry, dict) else None if p and Path(p) not in libs: libs.append(Path(p)) return libs def find_app(app_id: str = APP_ID, roots: list[Path] | None = None) -> GameInstall | None: for root in roots if roots is not None else steam_roots(): for lib in library_folders(root): acf = lib / "steamapps" / f"appmanifest_{app_id}.acf" if not acf.is_file(): continue try: state = vdf_get(read_vdf(acf), "AppState", default={}) except VDFError: continue installdir = state.get("installdir") if isinstance(state, dict) else None if not installdir: continue game_dir = lib / "steamapps/common" / installdir if game_dir.is_dir(): return GameInstall(app_id, state.get("name", installdir), root, lib, game_dir, state.get("buildid", "")) return None def userdata_ids(steam_root: Path) -> list[str]: ud = Path(steam_root) / "userdata" if not ud.is_dir(): return [] return sorted(p.name for p in ud.iterdir() if p.name.isdigit() and (p / "config").is_dir()) def localconfig_path(steam_root: Path, user_id: str = "") -> Path | None: ids = userdata_ids(steam_root) uid = user_id if user_id in ids else (ids[0] if ids else None) return Path(steam_root) / "userdata" / uid / "config/localconfig.vdf" if uid else None _APP_PATH = ("UserLocalConfigStore", "Software", "Valve", "Steam", "apps") def read_launch_options(localconfig: Path, app_id: str = APP_ID) -> str | None: try: data = read_vdf(localconfig) except (OSError, VDFError): return None return vdf_get(data, *_APP_PATH, app_id, "LaunchOptions", default="") def steam_running() -> bool: try: for p in Path("/proc").iterdir(): if p.name.isdigit(): try: if (p / "comm").read_text().strip() == "steam": return True except OSError: continue except OSError: pass return False def launch_options_ok(opts: str | None) -> bool: return bool(opts) and re.search(r"WINEDLLOVERRIDES=[\"']?[^\s\"']*dinput8(?:\.dll)?=n", opts) is not None def suggest_launch_options(existing: str | None) -> str: """Return a launch-option string that includes the dinput8 override, keeping user args.""" existing = (existing or "").strip() if not existing: return REQUIRED_LAUNCH_OPTIONS if launch_options_ok(existing): return existing m = re.search(r"WINEDLLOVERRIDES=([\"']?)([^\"'\s]*)\1", existing) if m: q = m.group(1) merged = f"WINEDLLOVERRIDES={q}{m.group(2)};{_OVERRIDE}{q}" return existing[:m.start()] + merged + existing[m.end():] if "%command%" in existing: return f'WINEDLLOVERRIDES="{_OVERRIDE}" ' + existing return f'WINEDLLOVERRIDES="{_OVERRIDE}" %command% {existing}' class SteamRunningError(RuntimeError): pass def write_launch_options(localconfig: Path, value: str, app_id: str = APP_ID) -> Path: """Opt-in write. Refuses while Steam runs (it would overwrite the file on exit). Returns backup path.""" if steam_running(): raise SteamRunningError("Steam is running; it would overwrite localconfig.vdf on exit. Close Steam first.") data = read_vdf(localconfig) node = data for part in (*_APP_PATH, app_id): hit = next((k for k in node if k.lower() == part.lower()), part) nxt = node.setdefault(hit, {}) if not isinstance(nxt, dict): raise VDFError(f"unexpected value at {part}") node = nxt node["LaunchOptions"] = value backup = localconfig.with_name(f"{localconfig.name}.mhwmm-bak-{int(time.time())}") shutil.copy2(localconfig, backup) atomic_write_text(localconfig, dump_vdf(data)) return backup