python/steamutil,protontool: Search appinfo cache by name, run game on windows

master
Taeyeon Mori 4 years ago
parent 0df69011e4
commit a9ec47f54a
  1. 55
      bin/protontool
  2. 86
      lib/python/steamutil.py
  3. 20
      lib/python/vdfparser.py

@ -48,7 +48,7 @@ class ProtonTool:
if self.app is None: if self.app is None:
die(1, "Could not find App #%s." % args.game) die(1, "Could not find App #%s." % args.game)
else: else:
apps = list(self.steam.find_apps(args.game)) apps = list(self.steam.find_apps(args.game, installed=not args.show_all))
if len(apps) > 1: if len(apps) > 1:
print_error("Found more than one installed game matching /%s/, please be more specific" % args.game) print_error("Found more than one installed game matching /%s/, please be more specific" % args.game)
for app in apps: for app in apps:
@ -90,6 +90,7 @@ class ProtonTool:
parser.add_argument("game", help="Can be either the appid or a name pattern to look for") parser.add_argument("game", help="Can be either the appid or a name pattern to look for")
parser.add_argument("--steam-path", help="Steam install path", default=None) parser.add_argument("--steam-path", help="Steam install path", default=None)
parser.add_argument("-l", "--list-tools", action=cls.ListAction, help="List all known compatibility tools") parser.add_argument("-l", "--list-tools", action=cls.ListAction, help="List all known compatibility tools")
parser.add_argument("-a", "--show-all", action="store_true", default=False, help="Search all games in local appinfo cache")
commands = parser.add_subparsers() commands = parser.add_subparsers()
@ -235,10 +236,13 @@ class ProtonTool:
return os.execvp(self.args.cmdline[0], self.args.cmdline) return os.execvp(self.args.cmdline[0], self.args.cmdline)
def cmd_run(self): def cmd_run(self):
""" Run a game executable directly, possibly using a compat tool """
# Obviously has to be installed
if not self.app.installed: if not self.app.installed:
print_error("App is not installed.") print_error("App is not installed.")
return 50 return 50
# Check if we want to use a compat tool
if self.args.tool: if self.args.tool:
tool = {"name": self.args.tool} tool = {"name": self.args.tool}
elif self.app.is_proton_app: elif self.app.is_proton_app:
@ -246,14 +250,22 @@ class ProtonTool:
else: else:
tool = None tool = None
# Default to native oslist
if sys.platform.startswith("linux"):
target_oslist = "linux"
elif sys.platform.startswith("win"):
target_oslist = "windows"
# Set up compat tool
if tool is not None: if tool is not None:
# XXX: what about other tools? Like Steam Linux Runtime
if self.args.config: if self.args.config:
tool["config"] = self.args.config tool["config"] = self.args.config
toolinfo = self.compat_tools[tool["name"]] toolinfo = self.compat_tools[tool["name"]]
info_tool, info_path = self._format_tool_info(self.steam, tool, toolinfo) info_tool, _ = self._format_tool_info(self.steam, tool, toolinfo)
print_info("Using tool", info_tool) print_info("Using tool", info_tool)
if self.args.waitforexit: if self.args.waitforexit:
@ -264,25 +276,34 @@ class ProtonTool:
with open(toolinfo["install_path"] / "toolmanifest.vdf") as f: with open(toolinfo["install_path"] / "toolmanifest.vdf") as f:
mfst = steamutil._vdf.parse(f) mfst = steamutil._vdf.parse(f)
cmdline = mfst["manifest"][verb] cmdline = mfst["manifest"][verb]
path, *args = cmdline.split() path, *args = cmdline.split()
path = str(toolinfo["install_path"]) + path path = str(toolinfo["install_path"]) + path
self.setup_proton_env(tool) self.setup_proton_env(tool)
for cfg in self.app.launch_configs: # Set tool oslist and prepare arguments
# TODO: Handle multiple target_oslist = toolinfo.get("from_oslist", "windows")
if cfg["type"] == "default" and toolinfo.get("from_oslist", "windows") in cfg["config"]["oslist"]: tool_argv = [path, *args]
argv = [path, *args, str(self.app.install_path / cfg["executable"])]
print_info("Executing", " ".join(argv))
return os.execv(path, argv)
else: else:
# TODO windows support? tool_argv = []
for cfg in self.app.launch_configs:
if cfg["type"] == "default" and "linux" in cfg["config"]["oslist"]: # Read launch configurations
return os.execv(self.app.install_path / cfg["executable"], [cfg["executable"]]) for cfg in self.app.launch_configs:
# Filter ones that won't work
if "type" in cfg and cfg["type"] != "default": continue
if "oslist" in cfg and target_oslist not in cfg["oslist"]: continue
if "executable" not in cfg: continue
# Execute
game_path = self.app.install_path / cfg["executable"]
argv = [*tool_argv, str(game_path)] # TODO: Use Steam user custom args?
print_info("Launching", " ".join(argv))
os.chdir(game_path.parent)
return os.execv(argv[0], argv)
else:
print_error("No matching launch configuration in appinfo")
return 51
def cmd_proton(self): def cmd_proton(self):
# proton and wine subcommands: # proton and wine subcommands:
# Run proton respective wine with arguments # Run proton respective wine with arguments

@ -7,7 +7,7 @@ import re, fnmatch, datetime
from pathlib import Path from pathlib import Path
from typing import List, Iterable, Dict, Tuple, Callable, Optional, Union from typing import List, Iterable, Dict, Tuple, Callable, Optional, Union
from vdfparser import VdfParser, DeepDict, AppInfoFile from vdfparser import VdfParser, DeepDict, AppInfoFile, LowerCaseNormalizingDict
class CachedProperty: class CachedProperty:
@ -94,7 +94,10 @@ class AppInfo:
self.appid = appid self.appid = appid
if appinfo_data is not None: if appinfo_data is not None:
self.__dict__["appinfo"] = appinfo_data self.__dict__["appinfo"] = appinfo_data
def __repr__(self):
return "<steamutil.AppInfo #%7d '%s' (%s)>" % (self.appid, self.name, self.install_dir)
installed = False installed = False
# AppInfo # AppInfo
@ -106,10 +109,10 @@ class AppInfo:
@property @property
def launch_configs(self): def launch_configs(self):
return self.appinfo["appinfo"]["config"]["launch"].values() return self.appinfo["appinfo"]["config"]["launch"].values()
name = DictPathRoProperty("appinfo", ("appinfo", "common", "name")) name = DictPathRoProperty("appinfo", ("appinfo", "common", "name"), default=None)
oslist = DictPathRoProperty("appinfo", ("appinfo", "common", "oslist"), type=lambda s: s.split(",")) oslist = DictPathRoProperty("appinfo", ("appinfo", "common", "oslist"), type=lambda s: s.split(","))
install_dir = DictPathRoProperty("appinfo", ("appinfo", "config", "installdir")) install_dir = DictPathRoProperty("appinfo", ("appinfo", "config", "installdir"), default=None)
languages = DictPathRoProperty("appinfo", ("appinfo", "common", "supported_languages")) languages = DictPathRoProperty("appinfo", ("appinfo", "common", "supported_languages"))
gameid = DictPathRoProperty("appinfo", ("appinfo", "common", "gameid"), type=int) gameid = DictPathRoProperty("appinfo", ("appinfo", "common", "gameid"), type=int)
@ -156,7 +159,7 @@ class App(AppInfo):
self.manifest_path = manifest_path self.manifest_path = manifest_path
if manifest_data is None: if manifest_data is None:
with open(manifest_path) as f: with open(manifest_path, encoding="utf-8") as f:
self.manifest = _vdf.parse(f) self.manifest = _vdf.parse(f)
else: else:
self.manifest = manifest_data self.manifest = manifest_data
@ -169,8 +172,8 @@ class App(AppInfo):
installed = True installed = True
def __repr__(self): def __repr__(self):
return "<steamutil.App %d '%s' @ \"%s\">" % (self.appid, self.name, self.install_path) return "<steamutil.App #%7d '%s' @ \"%s\">" % (self.appid, self.name, self.install_path)
# Basic info # Basic info
name = DictPathRoProperty("manifest", ("AppState", "name")) name = DictPathRoProperty("manifest", ("AppState", "name"))
language = DictPathRoProperty("manifest", ("AppState", "UserConfig", "language"), None) language = DictPathRoProperty("manifest", ("AppState", "UserConfig", "language"), None)
@ -276,7 +279,7 @@ class LibraryFolder:
def find_apps_re(self, regexp: str) -> Iterable[App]: def find_apps_re(self, regexp: str) -> Iterable[App]:
reg = re.compile(r'"name"\s+".*%s.*"' % regexp, re.IGNORECASE) reg = re.compile(r'"name"\s+".*%s.*"' % regexp, re.IGNORECASE)
for manifest in self.appmanifests: #pylint:disable=not-an-iterable for manifest in self.appmanifests: #pylint:disable=not-an-iterable
with open(manifest) as f: with open(manifest, encoding="utf-8") as f:
content = f.read() content = f.read()
if reg.search(content): if reg.search(content):
yield App(self, manifest, manifest_data=_vdf.parse_string(content)) yield App(self, manifest, manifest_data=_vdf.parse_string(content))
@ -351,7 +354,7 @@ class LoginUser:
@CachedProperty @CachedProperty
def localconfig(self) -> DeepDict: def localconfig(self) -> DeepDict:
with open(self.localconfig_vdf) as f: with open(self.localconfig_vdf, encoding="utf-8") as f:
return _vdf.parse(f) return _vdf.parse(f)
# Game config # Game config
@ -426,8 +429,10 @@ class Steam:
@CachedProperty @CachedProperty
def most_recent_user(self) -> Optional[LoginUser]: def most_recent_user(self) -> Optional[LoginUser]:
try: try:
with open(self.loginusers_vdf) as f: # Apparently, Steam doesn't care about case in the config/*.vdf keys
data = _vdf.parse(f) vdf_ci = VdfParser(factory=LowerCaseNormalizingDict)
with open(self.loginusers_vdf, encoding="utf-8") as f:
data = vdf_ci.parse(f)
for id, info in data["users"].items(): for id, info in data["users"].items():
if info["mostrecent"] == "1": if info["mostrecent"] == "1":
return LoginUser(self, int(id), info) return LoginUser(self, int(id), info)
@ -443,7 +448,7 @@ class Steam:
# Config # Config
@CachedProperty @CachedProperty
def config(self) -> DeepDict: def config(self) -> DeepDict:
with open(self.config_vdf) as f: with open(self.config_vdf, encoding="utf-8") as f:
return _vdf.parse(f) return _vdf.parse(f)
config_install_store = DictPathProperty("config", ("InstallConfigStore",)) config_install_store = DictPathProperty("config", ("InstallConfigStore",))
@ -485,7 +490,7 @@ class Steam:
if c.exists(): if c.exists():
manifests.append(c) manifests.append(c)
for mfst_path in manifests: for mfst_path in manifests:
with open(mfst_path) as f: with open(mfst_path, encoding="utf-8") as f:
mfst = _vdf.parse(f) mfst = _vdf.parse(f)
for name, t in mfst["compatibilitytools"]["compat_tools"].items(): for name, t in mfst["compatibilitytools"]["compat_tools"].items():
# TODO warn duplicate name # TODO warn duplicate name
@ -496,7 +501,7 @@ class Steam:
# Game/App Library # Game/App Library
@CachedProperty @CachedProperty
def library_folder_paths(self) -> List[Path]: def library_folder_paths(self) -> List[Path]:
with open(self.libraryfolders_vdf) as f: with open(self.libraryfolders_vdf, encoding="utf-8") as f:
return [Path(v) for k,v in _vdf.parse(f)["LibraryFolders"].items() if k.isdigit()] return [Path(v) for k,v in _vdf.parse(f)["LibraryFolders"].items() if k.isdigit()]
@CachedProperty @CachedProperty
@ -518,12 +523,49 @@ class Steam:
if appid == id: if appid == id:
return AppInfo(self, appid, appinfo_data=appinfo) return AppInfo(self, appid, appinfo_data=appinfo)
def find_apps(self, pattern: str) -> Iterable[App]: def find_apps_re(self, regexp: str, installed=True) -> Iterable[App]:
""" Find all apps by regular expression """
if not installed:
# Search whole appinfo cache
reg = re.compile(regexp, re.IGNORECASE)
broken_ids = set()
try:
for appid, appinfo in self.appinfo.items():
# Skip broken entries
try:
name = appinfo["appinfo"]["common"]["name"]
except KeyError:
broken_ids.add(appid)
continue
if reg.search(name):
for lf in self.library_folders: #pylint:disable=not-an-iterable
app = lf.get_app(appid)
if app:
yield app
break
else:
yield AppInfo(self, appid, appinfo_data=appinfo)
except:
import traceback
traceback.print_exc()
print("[SteamUtil] Warning: could not read non-installed apps from Steam appinfo cache. Searching locally")
else:
return
finally:
if broken_ids:
print("[SteamUtil] Warning: found broken entries in appinfo cache:", ",".join(map(str, broken_ids)))
# Search local manifests directly
reg = re.compile(r'"name"\s+".*%s.*"' % regexp, re.IGNORECASE)
for lf in self.library_folders: #pylint:disable=not-an-iterable for lf in self.library_folders: #pylint:disable=not-an-iterable
yield from lf.find_apps(pattern) for manifest in lf.appmanifests: #pylint:disable=not-an-iterable
with open(manifest, encoding="utf-8") as f:
def find_app(self, pattern: str) -> Optional[App]: content = f.read()
for app in self.find_apps(pattern): if reg.search(content):
return app yield App(lf, manifest, manifest_data=_vdf.parse_string(content))
def find_apps(self, pattern: str, installed=True) -> Iterable[App]:
return self.find_apps_re(fnmatch.translate(pattern).rstrip("\\Z"), installed=installed)
def find_app(self, pattern: str, installed=True) -> Optional[App]:
for app in self.find_apps(pattern, installed=installed):
return app

@ -15,6 +15,26 @@ from typing import Dict, Union, Mapping, Tuple
DeepDict = Mapping[str, Union[str, "DeepDict"]] DeepDict = Mapping[str, Union[str, "DeepDict"]]
class LowerCaseNormalizingDict(dict):
def __init__(self, *args, **kwds):
super().__init__()
# XXX: is there a better way to do this?
for k,v in dict(*args,**kwds).items():
k_ = k.lower()
if k_ in self:
raise KeyError("Duplicate key in LowerCaseNormalizingDict arguments: %s" % k_)
self[k_] = v
def __setitem__(self, key, value):
return super().__setitem__(key.lower(), value)
def __getitem__(self, key):
return super().__getitem__(key.lower())
def get(self, key, default=None):
return super().get(key.lower(), default=default)
class VdfParser: class VdfParser:
""" """
Simple Steam/Source VDF parser Simple Steam/Source VDF parser

Loading…
Cancel
Save