Files
MHW-Mod-Manager/squashfs-root/usr/share/mhwmm/core/steam.py
T

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