diff --git a/bin/protontool b/bin/protontool index 24a379c..124e258 100755 --- a/bin/protontool +++ b/bin/protontool @@ -48,7 +48,7 @@ class ProtonTool: if self.app is None: die(1, "Could not find App #%s." % args.game) 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: print_error("Found more than one installed game matching /%s/, please be more specific" % args.game) 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("--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("-a", "--show-all", action="store_true", default=False, help="Search all games in local appinfo cache") commands = parser.add_subparsers() @@ -235,10 +236,13 @@ class ProtonTool: return os.execvp(self.args.cmdline[0], self.args.cmdline) def cmd_run(self): + """ Run a game executable directly, possibly using a compat tool """ + # Obviously has to be installed if not self.app.installed: print_error("App is not installed.") return 50 + # Check if we want to use a compat tool if self.args.tool: tool = {"name": self.args.tool} elif self.app.is_proton_app: @@ -246,14 +250,22 @@ class ProtonTool: else: 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: + # XXX: what about other tools? Like Steam Linux Runtime if self.args.config: tool["config"] = self.args.config - + 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) if self.args.waitforexit: @@ -264,25 +276,34 @@ class ProtonTool: with open(toolinfo["install_path"] / "toolmanifest.vdf") as f: mfst = steamutil._vdf.parse(f) cmdline = mfst["manifest"][verb] - + path, *args = cmdline.split() path = str(toolinfo["install_path"]) + path self.setup_proton_env(tool) - for cfg in self.app.launch_configs: - # TODO: Handle multiple - if cfg["type"] == "default" and toolinfo.get("from_oslist", "windows") in cfg["config"]["oslist"]: - argv = [path, *args, str(self.app.install_path / cfg["executable"])] - print_info("Executing", " ".join(argv)) - return os.execv(path, argv) - + # Set tool oslist and prepare arguments + target_oslist = toolinfo.get("from_oslist", "windows") + tool_argv = [path, *args] else: - # TODO windows support? - for cfg in self.app.launch_configs: - if cfg["type"] == "default" and "linux" in cfg["config"]["oslist"]: - return os.execv(self.app.install_path / cfg["executable"], [cfg["executable"]]) - + tool_argv = [] + + # Read launch configurations + 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): # proton and wine subcommands: # Run proton respective wine with arguments diff --git a/lib/python/steamutil.py b/lib/python/steamutil.py index 10626fc..627f764 100644 --- a/lib/python/steamutil.py +++ b/lib/python/steamutil.py @@ -7,7 +7,7 @@ import re, fnmatch, datetime from pathlib import Path 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: @@ -94,7 +94,10 @@ class AppInfo: self.appid = appid if appinfo_data is not None: self.__dict__["appinfo"] = appinfo_data - + + def __repr__(self): + return "" % (self.appid, self.name, self.install_dir) + installed = False # AppInfo @@ -106,10 +109,10 @@ class AppInfo: @property def launch_configs(self): 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(",")) - install_dir = DictPathRoProperty("appinfo", ("appinfo", "config", "installdir")) + install_dir = DictPathRoProperty("appinfo", ("appinfo", "config", "installdir"), default=None) languages = DictPathRoProperty("appinfo", ("appinfo", "common", "supported_languages")) gameid = DictPathRoProperty("appinfo", ("appinfo", "common", "gameid"), type=int) @@ -156,7 +159,7 @@ class App(AppInfo): self.manifest_path = manifest_path 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) else: self.manifest = manifest_data @@ -169,8 +172,8 @@ class App(AppInfo): installed = True def __repr__(self): - return "" % (self.appid, self.name, self.install_path) - + return "" % (self.appid, self.name, self.install_path) + # Basic info name = DictPathRoProperty("manifest", ("AppState", "name")) language = DictPathRoProperty("manifest", ("AppState", "UserConfig", "language"), None) @@ -276,7 +279,7 @@ class LibraryFolder: def find_apps_re(self, regexp: str) -> Iterable[App]: reg = re.compile(r'"name"\s+".*%s.*"' % regexp, re.IGNORECASE) 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() if reg.search(content): yield App(self, manifest, manifest_data=_vdf.parse_string(content)) @@ -351,7 +354,7 @@ class LoginUser: @CachedProperty 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) # Game config @@ -426,8 +429,10 @@ class Steam: @CachedProperty def most_recent_user(self) -> Optional[LoginUser]: try: - with open(self.loginusers_vdf) as f: - data = _vdf.parse(f) + # Apparently, Steam doesn't care about case in the config/*.vdf keys + 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(): if info["mostrecent"] == "1": return LoginUser(self, int(id), info) @@ -443,7 +448,7 @@ class Steam: # Config @CachedProperty 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) config_install_store = DictPathProperty("config", ("InstallConfigStore",)) @@ -485,7 +490,7 @@ class Steam: if c.exists(): manifests.append(c) for mfst_path in manifests: - with open(mfst_path) as f: + with open(mfst_path, encoding="utf-8") as f: mfst = _vdf.parse(f) for name, t in mfst["compatibilitytools"]["compat_tools"].items(): # TODO warn duplicate name @@ -496,7 +501,7 @@ class Steam: # Game/App Library @CachedProperty 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()] @CachedProperty @@ -518,12 +523,49 @@ class Steam: if appid == id: 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 - yield from lf.find_apps(pattern) - - def find_app(self, pattern: str) -> Optional[App]: - for app in self.find_apps(pattern): - return app - + for manifest in lf.appmanifests: #pylint:disable=not-an-iterable + with open(manifest, encoding="utf-8") as f: + content = f.read() + if reg.search(content): + 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 diff --git a/lib/python/vdfparser.py b/lib/python/vdfparser.py index 2b4cb0f..8d51723 100644 --- a/lib/python/vdfparser.py +++ b/lib/python/vdfparser.py @@ -15,6 +15,26 @@ from typing import Dict, Union, Mapping, Tuple 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: """ Simple Steam/Source VDF parser