235 lines
7.5 KiB
Python
235 lines
7.5 KiB
Python
"""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
|