A tool to inspect local steam metadata (like app manifests) Extends steamutil python library significantlymaster
							parent
							
								
									908fe47ff5
								
							
						
					
					
						commit
						0df69011e4
					
				
				 3 changed files with 736 additions and 18 deletions
			
			
		@ -0,0 +1,364 @@ | 
				
			||||
#!/usr/bin/env python3 | 
				
			||||
# Protontool | 
				
			||||
# Make it easier to deal with proton games | 
				
			||||
# (c) 2020 Taeyeon Mori | 
				
			||||
 | 
				
			||||
 | 
				
			||||
import steamutil | 
				
			||||
import argparse | 
				
			||||
import sys, os | 
				
			||||
from pathlib import Path | 
				
			||||
 | 
				
			||||
 | 
				
			||||
def print_error(*msg): | 
				
			||||
    print("\033[1;31mError\033[22m:", *msg, "\033[0m") | 
				
			||||
 | 
				
			||||
def print_info(*msg): | 
				
			||||
    print("\033[1;33mInfo\033[0m:", *msg) | 
				
			||||
 | 
				
			||||
def print_warn(*msg): | 
				
			||||
    print("\033[1;33mWarning\033[22m", *msg, "\033[0m") | 
				
			||||
 | 
				
			||||
def format_size(bytes): | 
				
			||||
    units = ["B", "kiB", "MiB", "GiB"] | 
				
			||||
    unit = 0 | 
				
			||||
    while bytes > 750: | 
				
			||||
        bytes /= 1024 | 
				
			||||
        unit += 1 | 
				
			||||
    return "%3.2f %s" % (bytes, units[unit]) | 
				
			||||
 | 
				
			||||
 | 
				
			||||
def die(int, msg): | 
				
			||||
    print_error(msg) | 
				
			||||
    sys.exit(int) | 
				
			||||
 | 
				
			||||
 | 
				
			||||
class ProtonTool: | 
				
			||||
    steam: steamutil.Steam | 
				
			||||
    app: steamutil.App | 
				
			||||
 | 
				
			||||
    def __init__(self, args): | 
				
			||||
        self.args = args | 
				
			||||
        self.steam = steamutil.Steam(install_path=args.steam_path) | 
				
			||||
 | 
				
			||||
        if not isinstance(args.game, str): | 
				
			||||
            self.app = args.game | 
				
			||||
        if args.game.isdigit(): | 
				
			||||
            self.app = self.steam.get_app(int(args.game), installed=False) | 
				
			||||
            if self.app is None: | 
				
			||||
                die(1, "Could not find App #%s." % args.game) | 
				
			||||
        else: | 
				
			||||
            apps = list(self.steam.find_apps(args.game)) | 
				
			||||
            if len(apps) > 1: | 
				
			||||
                print_error("Found more than one installed game matching /%s/, please be more specific" % args.game) | 
				
			||||
                for app in apps: | 
				
			||||
                    print("\t", app) | 
				
			||||
                sys.exit(2) | 
				
			||||
            elif not apps: | 
				
			||||
                die(1, "Could not find any App matching /%s/. Make sure it is installed." % args.game) | 
				
			||||
            self.app = apps[0] | 
				
			||||
 | 
				
			||||
        print_info("Found game: %s" % self.app.name) | 
				
			||||
     | 
				
			||||
    @steamutil.CachedProperty | 
				
			||||
    def user(self) -> steamutil.LoginUser: | 
				
			||||
        if "userid" in self.args: | 
				
			||||
            die(255, "userid not implemented") | 
				
			||||
        else: | 
				
			||||
            user = self.steam.most_recent_user | 
				
			||||
            if not user: | 
				
			||||
                die(10, "Could not detect Steam user. Have you logged in on this Computer before?") | 
				
			||||
            print_info("Using steam profile: %s (%s)" % (user.username, user.account_name)) | 
				
			||||
            return user | 
				
			||||
 | 
				
			||||
    class ListAction(argparse.Action): | 
				
			||||
        def __init__(self, option_strings, dest, help=None): | 
				
			||||
            super().__init__(option_strings, dest, nargs=0, help=help) | 
				
			||||
 | 
				
			||||
        def __call__(self, parser, ns, values, opt): | 
				
			||||
            steam = steamutil.Steam(install_path=ns.steam_path if "steam_path" in ns else None) | 
				
			||||
            for name, info in steam.compat_tools.items(): | 
				
			||||
                proton, path = ProtonTool._format_tool_info(steam, {"name": name}, info) | 
				
			||||
                print(proton) | 
				
			||||
                print("   ", path) | 
				
			||||
            parser.exit(0) | 
				
			||||
 | 
				
			||||
 | 
				
			||||
    @classmethod | 
				
			||||
    def parse_args(cls, args, prog=None): | 
				
			||||
        parser = argparse.ArgumentParser(prog=prog) | 
				
			||||
        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") | 
				
			||||
 | 
				
			||||
        commands = parser.add_subparsers() | 
				
			||||
 | 
				
			||||
        info = commands.add_parser("info", description="Get information about a game") | 
				
			||||
        info.set_defaults(cmd=cls.cmd_info) | 
				
			||||
 | 
				
			||||
        exec = commands.add_parser("exec", description="Run a command (e.g. winetricks) with the correct environment") | 
				
			||||
        exec.set_defaults(cmd=cls.cmd_exec) | 
				
			||||
        exec.add_argument("cmdline", nargs="+", help="Command and arguments to run") | 
				
			||||
 | 
				
			||||
        run = commands.add_parser("run", description="Run game") | 
				
			||||
        run.set_defaults(cmd=cls.cmd_run) | 
				
			||||
        run.add_argument("--waitforexit", action="store_true", help="use waitforexitandrun action") | 
				
			||||
        run.add_argument("--tool", help="Override compatibility tool selection") | 
				
			||||
        run.add_argument("--config", help="Override compatibility tool config") | 
				
			||||
 | 
				
			||||
        appinfo = commands.add_parser("appinfo", description="Dump the Steam product info") | 
				
			||||
        appinfo.set_defaults(cmd=cls.cmd_appinfo) | 
				
			||||
        appinfo.add_argument("--json", action="store_true", help="Output JSON") | 
				
			||||
 | 
				
			||||
        proton = commands.add_parser("proton", description="Run configured proton with custom arguments") | 
				
			||||
        proton.set_defaults(cmd=cls.cmd_proton, cmd_proton="proton") | 
				
			||||
        proton.add_argument("args", nargs="+", help="Proton arguments") | 
				
			||||
        proton.add_argument("--tool", help="Override compatibility tool selection") | 
				
			||||
        proton.add_argument("--config", help="Override compatibility tool config") | 
				
			||||
 | 
				
			||||
        wine = commands.add_parser("wine", description="Run appropriate wine binary") | 
				
			||||
        wine.set_defaults(cmd=cls.cmd_proton, cmd_proton="wine") | 
				
			||||
        wine.add_argument("args", nargs="+", help="Wine arguments") | 
				
			||||
        wine.add_argument("--tool", help="Override compatibility tool selection") | 
				
			||||
        wine.add_argument("--config", help="Override compatibility tool config") | 
				
			||||
 | 
				
			||||
        #enter = commands.add_parser("wine", description="Run the correct wine in the correct prefix") | 
				
			||||
        #enter.add_argument("command", nargs="+") | 
				
			||||
 | 
				
			||||
        #config = commands.add_parser("config") | 
				
			||||
 | 
				
			||||
        return parser.parse_args(args) | 
				
			||||
 | 
				
			||||
    @steamutil.CachedProperty | 
				
			||||
    def appinfo(self): | 
				
			||||
        return self.steam.appinfo | 
				
			||||
     | 
				
			||||
    @property | 
				
			||||
    def compat_tools(self) -> dict: | 
				
			||||
        self.steam.steamplay_manifest = self.appinfo[891390]["appinfo"] | 
				
			||||
        return self.steam.compat_tools | 
				
			||||
     | 
				
			||||
    @property | 
				
			||||
    def compat_tool(self) -> dict: | 
				
			||||
        return self.app.compat_tool | 
				
			||||
 | 
				
			||||
    @classmethod | 
				
			||||
    def _format_tool_info(cls, steam, tool, toolinfo=None) -> (str, str): | 
				
			||||
        if toolinfo is None: | 
				
			||||
            toolinfo = steam.compat_tools.get(tool["name"], None) | 
				
			||||
                 | 
				
			||||
        proton = [] | 
				
			||||
         | 
				
			||||
        if not toolinfo: | 
				
			||||
            proton.append("\033[31mNot found:\033[0m") | 
				
			||||
 | 
				
			||||
        if toolinfo and "display_name" in toolinfo and toolinfo["display_name"] != tool["name"]: | 
				
			||||
            proton.append("\033[1m%s\033[0m (%s)" % (toolinfo["display_name"], tool["name"])) | 
				
			||||
        else: | 
				
			||||
            proton.append("'\033[1m%s\033[0m'" % tool["name"]) | 
				
			||||
 | 
				
			||||
        if toolinfo and "appid" in toolinfo: | 
				
			||||
            proton.append("#%s" % toolinfo["appid"]) | 
				
			||||
        if "source" in tool: | 
				
			||||
            if tool["source"] == "user": | 
				
			||||
                proton.append("[per-game override]") | 
				
			||||
            elif tool["source"] == "valve": | 
				
			||||
                proton.append("[official]") | 
				
			||||
            elif tool["source"] == "default": | 
				
			||||
                proton.append("[user default]") | 
				
			||||
        if "config" in tool and tool["config"]: | 
				
			||||
            proton.append("{%s}" % tool["config"]) | 
				
			||||
        if toolinfo and "install_path" in toolinfo: | 
				
			||||
            proton_path = toolinfo["install_path"] | 
				
			||||
            if not os.path.exists(toolinfo["install_path"]): | 
				
			||||
                proton_path += " <<Not Accessible>>" | 
				
			||||
        else: | 
				
			||||
            proton_path = "<<Not Installed>>" | 
				
			||||
        proton = " ".join(proton) | 
				
			||||
 | 
				
			||||
        return proton, proton_path | 
				
			||||
 | 
				
			||||
    def cmd_info(self): | 
				
			||||
        uc = self.user.get_app_config(self.app) | 
				
			||||
         | 
				
			||||
        if self.app.installed: | 
				
			||||
            installed = "[%s] %s" % (self.app.language, format_size(self.app.declared_install_size)) | 
				
			||||
 | 
				
			||||
            if self.app.is_proton_app: | 
				
			||||
                # Find out version | 
				
			||||
                tool = self.compat_tool | 
				
			||||
                proton, proton_path = self._format_tool_info(self.steam, tool) | 
				
			||||
            else: | 
				
			||||
                proton = "No" | 
				
			||||
                proton_path = None | 
				
			||||
        else: | 
				
			||||
            installed = "No" | 
				
			||||
 | 
				
			||||
        print("\033[1;35mName      \033[0;1m:", self.app.name) | 
				
			||||
        print("\033[1;35mAppID     \033[0m:", self.app.appid) | 
				
			||||
        print("\033[1;35mInstalled \033[0m:", installed) | 
				
			||||
        if self.app.installed: | 
				
			||||
            print("          @", self.app.install_path) | 
				
			||||
            print("\033[1;35mLaunchOpt.\033[0m:", uc.launch_options) | 
				
			||||
            print("\033[1;35mProton    \033[0m:", proton) | 
				
			||||
            if proton_path: | 
				
			||||
                print("          @", proton_path) | 
				
			||||
            if self.app.is_proton_app: | 
				
			||||
                print("\033[1;35mProtonDrv.\033[0m:", self.app.compat_drive) | 
				
			||||
     | 
				
			||||
    def setup_proton_env(self, tool, wine=False): | 
				
			||||
        info = self.compat_tools[tool["name"]] | 
				
			||||
        if not info: | 
				
			||||
            die(20, "Compatibility tool '%s' could not be found. Is it installed?" % tool["name"]) | 
				
			||||
        os.environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = str(self.steam.root) | 
				
			||||
        os.environ["STEAM_COMPAT_DATA_PATH"] = str(self.app.compat_path) | 
				
			||||
        os.environ["STEAM_COMPAT_CONFIG"] = tool.get("config", "") | 
				
			||||
        os.environ["SteamAppId"] = str(self.app.appid) | 
				
			||||
        os.environ["SteamGameId"] = str(self.app.appid) | 
				
			||||
        os.environ["WINEPREFIX"] = str(self.app.compat_path / "pfx") | 
				
			||||
        proton_dir = info["install_path"] | 
				
			||||
        os.environ["WINEROOT"] = str(proton_dir / "dist") | 
				
			||||
        if wine: | 
				
			||||
            # FIXME: correctly detect WINEESYNC | 
				
			||||
            os.environ["WINEESYNC"] = "1" | 
				
			||||
            os.environ["LD_LIBRARY_PATH"] = ":".join(x for x in [ | 
				
			||||
                str(proton_dir / "dist/lib64"), | 
				
			||||
                str(proton_dir / "dist/lib"), | 
				
			||||
                os.environ.get("LD_LIBRARY_PATH", None)] if x) | 
				
			||||
            os.environ["PATH"] = ":".join((str(Path(info["install_path"]) / "dist" / "bin"), os.environ["PATH"])) | 
				
			||||
 | 
				
			||||
    def cmd_exec(self): | 
				
			||||
        tool = self.compat_tool | 
				
			||||
 | 
				
			||||
        self.setup_proton_env(tool, wine=True) | 
				
			||||
         | 
				
			||||
        return os.execvp(self.args.cmdline[0], self.args.cmdline) | 
				
			||||
     | 
				
			||||
    def cmd_run(self): | 
				
			||||
        if not self.app.installed: | 
				
			||||
            print_error("App is not installed.") | 
				
			||||
            return 50 | 
				
			||||
 | 
				
			||||
        if self.args.tool: | 
				
			||||
            tool = {"name": self.args.tool} | 
				
			||||
        elif self.app.is_proton_app: | 
				
			||||
            tool = self.compat_tool | 
				
			||||
        else: | 
				
			||||
            tool = None | 
				
			||||
         | 
				
			||||
        if tool is not None: | 
				
			||||
            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) | 
				
			||||
             | 
				
			||||
            print_info("Using tool", info_tool) | 
				
			||||
 | 
				
			||||
            if self.args.waitforexit: | 
				
			||||
                verb = "commandline_waitforexitandrun" | 
				
			||||
            else: | 
				
			||||
                verb = "commandline" | 
				
			||||
 | 
				
			||||
            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) | 
				
			||||
         | 
				
			||||
        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"]]) | 
				
			||||
     | 
				
			||||
    def cmd_proton(self): | 
				
			||||
        # proton and wine subcommands: | 
				
			||||
        # Run proton respective wine with arguments | 
				
			||||
        if not self.app.installed or not self.app.is_proton_app: | 
				
			||||
            print_error("App not installed or not a Proton app") | 
				
			||||
            return 51 | 
				
			||||
 | 
				
			||||
        if self.args.tool: | 
				
			||||
            tool = {"name": self.args.tool, "source": "args"} | 
				
			||||
        else: | 
				
			||||
            tool = self.compat_tool | 
				
			||||
 | 
				
			||||
        if self.args.config: | 
				
			||||
            tool["config"] = self.args.config | 
				
			||||
 | 
				
			||||
        try: | 
				
			||||
            toolinfo = self.compat_tools[tool["name"]] | 
				
			||||
        except KeyError: | 
				
			||||
            print_error("Tool not found: '%s'" % tool["name"]) | 
				
			||||
 | 
				
			||||
        info_tool, _ = self._format_tool_info(self.steam, tool, toolinfo) | 
				
			||||
 | 
				
			||||
        print_info("Using tool", info_tool) | 
				
			||||
 | 
				
			||||
        if self.args.cmd_proton == "proton": | 
				
			||||
            path = str(toolinfo["install_path"] / "proton") | 
				
			||||
            argv = ["proton"] + self.args.args | 
				
			||||
        elif self.args.cmd_proton == "wine": | 
				
			||||
            path = str(toolinfo["install_path"] / "dist/bin/wine") | 
				
			||||
            argv = [path] + self.args.args | 
				
			||||
        else: | 
				
			||||
            print_error("Unknown cmd_proton verb:", self.args.cmd_proton) | 
				
			||||
            return 111 | 
				
			||||
 | 
				
			||||
        if not os.path.exists(path): | 
				
			||||
            print_error("No such file:", path) | 
				
			||||
            return 53 | 
				
			||||
 | 
				
			||||
        self.setup_proton_env(tool, wine=self.args.cmd_proton=="wine") | 
				
			||||
 | 
				
			||||
        print_info("Running", path, *argv[1:]) | 
				
			||||
 | 
				
			||||
        return os.execv(path, argv) | 
				
			||||
 | 
				
			||||
    def cmd_appinfo(self): | 
				
			||||
        info = self.appinfo.get(self.app.appid, {}) | 
				
			||||
        if self.args.json: | 
				
			||||
            import json | 
				
			||||
            json.dump(info.dict, sys.stdout) | 
				
			||||
        else: | 
				
			||||
            print("AppId:", info.id) | 
				
			||||
            print("AppState:", info.state) | 
				
			||||
            print("Last Update:", info.last_update) | 
				
			||||
 | 
				
			||||
            print() | 
				
			||||
 | 
				
			||||
            self._pretty_dump(info.dict) | 
				
			||||
     | 
				
			||||
    def _pretty_dump(self, d: dict, level=0): | 
				
			||||
        for key, value in d.items(): | 
				
			||||
            print("    " * level, "\033[1m", key, "\033[0m", end="", sep="") | 
				
			||||
            if isinstance(value, dict): | 
				
			||||
                print(":") | 
				
			||||
                self._pretty_dump(value, level+1) | 
				
			||||
            else: | 
				
			||||
                print(" = ", value, sep="") | 
				
			||||
 | 
				
			||||
    @classmethod | 
				
			||||
    def main(cls, argv: [str]) -> int: | 
				
			||||
        args = cls.parse_args(argv[1:], argv[0]) | 
				
			||||
        self = cls(args) | 
				
			||||
        if "cmd" not in args or not args.cmd: | 
				
			||||
            print_error("No command given. see %s --help" % argv[0]) | 
				
			||||
            return 255 | 
				
			||||
        return args.cmd(self) | 
				
			||||
 | 
				
			||||
 | 
				
			||||
if __name__ == "__main__": | 
				
			||||
    sys.exit(ProtonTool.main(sys.argv)) | 
				
			||||
					Loading…
					
					
				
		Reference in new issue