diff --git a/lmm/cli.py b/lmm/cli.py index 7677667..9609102 100644 --- a/lmm/cli.py +++ b/lmm/cli.py @@ -9,6 +9,7 @@ from lmm.mods.bg3 import install_mod as bg3_install_mod import click + def get_game_by_name(name: str) -> Optional[Game]: games = load_game_configs() for game in games: @@ -16,12 +17,14 @@ def get_game_by_name(name: str) -> Optional[Game]: return game return None + @click.group() @click.pass_context def cli(ctx): if ctx.invoked_subcommand is None: click.echo("Invoked without a subcommand") + @cli.command() @click.option("-g", "--game", required=True) @click.option("-p", "--profile", required=True) @@ -30,13 +33,13 @@ def launch(game: str, profile: str): if game is None: click.echo("Game not found") return - + profile = game.get_profile_by_name(profile) if profile is None: click.echo("Profile not found") return - runner = None + runner = None if profile.runner is not None: runner = profile.runner elif game.default_runner is not None: @@ -56,17 +59,18 @@ def launch(game: str, profile: str): break print(f"Mounted overlayfs at {result}") - + # Launch the game if mounts_ok: - #runner.run() - #game.wait() + # runner.run() + # game.wait() print(f"Stub runner: Would use {runner.__class__.__name__}") # Unmount for mount in mounts: mount.unmount() + @cli.command() @click.option("-g", "--game", required=True) @click.option("-P", "--path", required=True) @@ -75,11 +79,12 @@ def install(game: str, path: str): BG3: bg3_install_mod, }.get(game, None) if install is None: - click.echo(f"Unknown game \"{game}\"") + click.echo(f'Unknown game "{game}"') return - + install(Path(path)) + @cli.command() @click.option("-g", "--game") def mods(game: Optional[str]): @@ -87,7 +92,9 @@ def mods(game: Optional[str]): games = [game] else: games = [ - item for item in os.listdir(LMM_GAMES_PATH) if (LMM_GAMES_PATH / item).is_dir() + item + for item in os.listdir(LMM_GAMES_PATH) + if (LMM_GAMES_PATH / item).is_dir() ] for idx, game_name in enumerate(games): @@ -98,10 +105,11 @@ def mods(game: Optional[str]): continue click.echo(f"- {item}") - + if idx < len(games) - 1: click.echo() + @cli.command def list(): games = load_game_configs() @@ -126,12 +134,14 @@ def list(): click.echo(f" - {profile.name}") click.echo(f" Runner: {runner_name}") click.echo(f" Mods: {mods}") - + if idx < len(games) - 1: click.echo() + def main(): cli() + if __name__ == "__main__": - cli() \ No newline at end of file + cli() diff --git a/lmm/cmd.py b/lmm/cmd.py index 7ca83ae..4c66eb4 100644 --- a/lmm/cmd.py +++ b/lmm/cmd.py @@ -3,6 +3,7 @@ import subprocess from typing import Optional from pathlib import Path + def run_cmd(args: list[str], cd: Optional[Path] = None) -> int: cmdline = " ".join(args) if cd is not None: @@ -16,17 +17,20 @@ def run_cmd(args: list[str], cd: Optional[Path] = None) -> int: os.chdir(cwd) return ret + def run_sudo_cmd(args: list[str], cd: Optional[Path] = None) -> int: return run_cmd( [ "pkexec", - "--user", "root", + "--user", + "root", "--keep-cwd", *args, ], cd=cd, ) + def run_cmd_shell(cmd: str) -> None: print("Executing:", cmd) subprocess.run( @@ -34,6 +38,7 @@ def run_cmd_shell(cmd: str) -> None: shell=True, ) + def run_cmd_nonblocking(args: list[str]) -> subprocess.Popen: cmdline = " ".join(args) print("Executing:", cmdline) diff --git a/lmm/const.py b/lmm/const.py index 355bb3a..a69ec66 100644 --- a/lmm/const.py +++ b/lmm/const.py @@ -2,4 +2,4 @@ from pathlib import Path LMM_PATH: Path = Path.home() / ".local" / "share" / "lmm" -LMM_GAMES_PATH: Path = LMM_PATH / "games" \ No newline at end of file +LMM_GAMES_PATH: Path = LMM_PATH / "games" diff --git a/lmm/games/bg3.py b/lmm/games/bg3.py index 759d2a7..668f7a2 100644 --- a/lmm/games/bg3.py +++ b/lmm/games/bg3.py @@ -12,6 +12,7 @@ from lmm.runners.base import runner_from_config GAME_NAME = "BaldursGate3" + class BaldursGate3ModType(Enum): # Override the game files ROOT = 1 @@ -19,6 +20,7 @@ class BaldursGate3ModType(Enum): # Override the Paks in the compatdata prefix PAK = 2 + @dataclass class BaldursGate3Mod: # The directory name @@ -36,12 +38,14 @@ class BaldursGate3Profile(Profile): super().__init__(GAME_NAME, name, **kwargs) self.mods = mods - def get_mods(self, type: BaldursGate3ModType) -> Generator[BaldursGate3Mod, None, None]: + def get_mods( + self, type: BaldursGate3ModType + ) -> Generator[BaldursGate3Mod, None, None]: """Yields all mods of type @type""" for mod in self.mods: if not mod.type == type: continue - + yield mod def has_mods_of_type(self, type: BaldursGate3ModType) -> bool: @@ -51,6 +55,7 @@ class BaldursGate3Profile(Profile): def get_mod_names(self) -> list[str]: return [mod.name for mod in self.mods] + class BaldursGate3Game(ProtonGame): def __init__(self, profiles: list[BaldursGate3Profile], **kwargs): super().__init__(GAME_NAME, "1086940", profiles, **kwargs) @@ -65,7 +70,18 @@ class BaldursGate3Game(ProtonGame): @property def user_mods_path(self) -> Path: - return self.compat_path / "pfx" / "drive_c" / "users" / "steamuser" / "AppData" / "Local" / "Larian Studios" / "Baldur's Gate 3" / "Mods" + return ( + self.compat_path + / "pfx" + / "drive_c" + / "users" + / "steamuser" + / "AppData" + / "Local" + / "Larian Studios" + / "Baldur's Gate 3" + / "Mods" + ) def can_start(self) -> bool: return self.user_mods_path.exists() and self.installation_path.exists() @@ -87,9 +103,7 @@ class BaldursGate3Game(ProtonGame): if profile.has_mods_of_type(BaldursGate3ModType.ROOT): mods = list(profile.get_mods(BaldursGate3ModType.ROOT)) - mod_paths = [ - LMM_GAMES_PATH / GAME_NAME / mod.name for mod in mods - ] + mod_paths = [LMM_GAMES_PATH / GAME_NAME / mod.name for mod in mods] overlays.append( OverlayFSMount( self.installation_path, @@ -102,9 +116,7 @@ class BaldursGate3Game(ProtonGame): print(f"Loaded root mods: {mod_names}") if profile.has_mods_of_type(BaldursGate3ModType.PAK): mods = list(profile.get_mods(BaldursGate3ModType.PAK)) - mod_paths = [ - os.path.join("./", mod.name) for mod in mods - ] + mod_paths = [os.path.join("./", mod.name) for mod in mods] # Merge all mods overlays.append( @@ -134,9 +146,11 @@ class BaldursGate3Game(ProtonGame): { "name": mod.name, "type": mod.type.value, - } for mod in profile.mods - ] - } for profile in self.profiles + } + for mod in profile.mods + ], + } + for profile in self.profiles ] } @@ -149,7 +163,9 @@ class BaldursGate3Game(ProtonGame): mods.append( BaldursGate3Mod( mod["name"], - BaldursGate3ModType.ROOT if mod["type"] == 1 else BaldursGate3ModType.PAK, + BaldursGate3ModType.ROOT + if mod["type"] == 1 + else BaldursGate3ModType.PAK, ), ) @@ -165,10 +181,10 @@ class BaldursGate3Game(ProtonGame): runner=runner, ), ) - + if "default_runner" in data: default_runner = runner_from_config(data["default_runner"]) else: default_runner = None - return BaldursGate3Game(profiles, default_runner=default_runner) \ No newline at end of file + return BaldursGate3Game(profiles, default_runner=default_runner) diff --git a/lmm/games/config.py b/lmm/games/config.py index a6723ca..8d6b8b5 100644 --- a/lmm/games/config.py +++ b/lmm/games/config.py @@ -47,4 +47,4 @@ def load_game_configs() -> list[Game]: ) except Exception as ex: print(f"Failed to load game {item}: {ex}") - return games \ No newline at end of file + return games diff --git a/lmm/games/game.py b/lmm/games/game.py index 995910e..368cd2f 100644 --- a/lmm/games/game.py +++ b/lmm/games/game.py @@ -9,9 +9,11 @@ from lmm.overlayfs import OverlayFSMount from lmm.profile import Profile from lmm.steam import find_library_folder_for_game + class CannotStartReason: pass + @dataclass class PathNotExistingReason(CannotStartReason): # The path that is not existing @@ -20,6 +22,7 @@ class PathNotExistingReason(CannotStartReason): def __str__(self) -> str: return f"Path {self.path} does not exist" + class Game(abc.ABC): # The name of the game. name: str @@ -30,7 +33,9 @@ class Game(abc.ABC): # The default runner to use when launching the game. default_runner: Optional["Runner"] - def __init__(self, name: str, profiles: list[Profile], default_runner: Optional["Runner"]): + def __init__( + self, name: str, profiles: list[Profile], default_runner: Optional["Runner"] + ): self.name = name self.profiles = profiles self.default_runner = default_runner @@ -63,6 +68,7 @@ class Game(abc.ABC): def wait(self): return None + class ProtonGame(Game, abc.ABC): # The appid of the game in Steam. appid: str @@ -70,7 +76,13 @@ class ProtonGame(Game, abc.ABC): # The PID of the game. _pid: Optional[str] - def __init__(self, name: str, appid: str, profiles: list[Profile], default_runner: Optional["Runner"]): + def __init__( + self, + name: str, + appid: str, + profiles: list[Profile], + default_runner: Optional["Runner"], + ): super().__init__(name, profiles, default_runner) self._pid = None self.appid = appid @@ -106,7 +118,9 @@ class ProtonGame(Game, abc.ABC): # Workaround for games like Baldur's Gate 3 that have a relative path in the cmdline. # Note that the cmdline contains null bytes that we have to remove. abs_cmdline = os.path.abspath(cmdline).replace("\x00", "") - if cmdline.startswith(wine_exe_path) or abs_cmdline == str(self.game_executable): + if cmdline.startswith(wine_exe_path) or abs_cmdline == str( + self.game_executable + ): self._pid = dir break except: diff --git a/lmm/games/pdmm.py b/lmm/games/pdmm.py index ea17f1c..64c8434 100644 --- a/lmm/games/pdmm.py +++ b/lmm/games/pdmm.py @@ -9,6 +9,7 @@ from lmm.runners.base import runner_from_config GAME_NAME = "ProjectDivaMegaMix" + class ProjectDivaMegaMixProfile(Profile): # The names of the directories the the mods directory. mods: list[str] @@ -21,19 +22,25 @@ class ProjectDivaMegaMixProfile(Profile): def get_mod_names(self) -> list[str]: return self.mods + class ProjectDivaMegaMixGame(ProtonGame): def __init__(self, profiles: list[ProjectDivaMegaMixProfile], **kwargs): super().__init__(GAME_NAME, "1761390", profiles, **kwargs) @property def installation_path(self) -> Path: - return self.steam_library / "steamapps/common/Hatsune Miku Project DIVA Mega Mix Plus" + return ( + self.steam_library + / "steamapps/common/Hatsune Miku Project DIVA Mega Mix Plus" + ) @property def game_executable(self) -> Path: return self.installation_path / "DivaMegaMix.exe" - def prepare_overlays(self, profile: ProjectDivaMegaMixProfile) -> list[OverlayFSMount]: + def prepare_overlays( + self, profile: ProjectDivaMegaMixProfile + ) -> list[OverlayFSMount]: return [ OverlayFSMount( upper=self.installation_path, @@ -51,9 +58,11 @@ class ProjectDivaMegaMixGame(ProtonGame): "mods": [ { "name": mod, - } for mod in profile.mods - ] - } for profile in self.profiles + } + for mod in profile.mods + ], + } + for profile in self.profiles ] } @@ -61,9 +70,7 @@ class ProjectDivaMegaMixGame(ProtonGame): def from_dict(cls, data: dict[str, Any]) -> "ProjectDivaMegaMixGame": profiles = [] for profile in data["profiles"]: - mods = [ - mod["name"] for mod in profile["mods"] - ] + mods = [mod["name"] for mod in profile["mods"]] if "runner" in profile: runner = runner_from_config(profile["runner"]) @@ -83,4 +90,4 @@ class ProjectDivaMegaMixGame(ProtonGame): else: default_runner = None - return ProjectDivaMegaMixGame(profiles, default_runner=default_runner) \ No newline at end of file + return ProjectDivaMegaMixGame(profiles, default_runner=default_runner) diff --git a/lmm/games/ron.py b/lmm/games/ron.py index a5e093e..9fb2573 100644 --- a/lmm/games/ron.py +++ b/lmm/games/ron.py @@ -7,6 +7,7 @@ from lmm.games.game import ProtonGame GAME_NAME = "ReadyOrNot" + class ReadyOrNotProfile(Profile): # Names of directories inside the game's mods directory. mods: list[str] @@ -18,6 +19,7 @@ class ReadyOrNotProfile(Profile): def get_mod_names(self) -> list[str]: return self.mods + class ReadyOrNotGame(ProtonGame): def __init__(self, profiles: list[ReadyOrNotProfile], **kwargs): super().__init__(GAME_NAME, "1144200", profiles, **kwargs) @@ -55,9 +57,11 @@ class ReadyOrNotGame(ProtonGame): "mods": [ { "name": mod, - } for mod in profile.mods - ] - } for profile in self.profiles + } + for mod in profile.mods + ], + } + for profile in self.profiles ] } @@ -65,9 +69,7 @@ class ReadyOrNotGame(ProtonGame): def from_dict(cls, data: dict[str, Any]) -> "ReadyOrNotGame": profiles = [] for profile in data["profiles"]: - mods = [ - mod["name"] for mod in profile["mods"] - ] + mods = [mod["name"] for mod in profile["mods"]] if "runner" in profile: runner = runner_from_config(profile["runner"]) @@ -87,4 +89,4 @@ class ReadyOrNotGame(ProtonGame): else: default_runner = None - return ReadyOrNotGame(profiles, default_runner=default_runner) \ No newline at end of file + return ReadyOrNotGame(profiles, default_runner=default_runner) diff --git a/lmm/mods/bg3.py b/lmm/mods/bg3.py index cfdf22e..e76bcae 100644 --- a/lmm/mods/bg3.py +++ b/lmm/mods/bg3.py @@ -5,6 +5,7 @@ import zipfile from lmm.const import LMM_GAMES_PATH from lmm.games.bg3 import GAME_NAME + def install_mod(mod: Path): match mod.suffix: case ".zip": @@ -12,6 +13,7 @@ def install_mod(mod: Path): case _: print("Unknown mod") + def install_zip_mod(mod: Path): # Inspect the archive to see if it's a PAK mod with zipfile.ZipFile(mod, "r") as f: diff --git a/lmm/overlayfs.py b/lmm/overlayfs.py index b0e9b7b..310c9d5 100644 --- a/lmm/overlayfs.py +++ b/lmm/overlayfs.py @@ -4,6 +4,7 @@ from typing import Optional from lmm.cmd import run_sudo_cmd + def compute_workdirs_location(mount: Path) -> Path: with open("/proc/mounts", "r") as f: mounts = f.read().split("\n") @@ -18,7 +19,7 @@ def compute_workdirs_location(mount: Path) -> Path: if str(mount).startswith(m) and len(m) > len(closest_mount or ""): closest_mount = m continue - + return Path("/tmp") if closest_mount is None else Path(closest_mount) / ".temp" @@ -35,7 +36,13 @@ class OverlayFSMount: cd: Optional[Path] - def __init__(self, upper: Optional[Path], lower: list[Path | str], mount: Path, cd: Optional[Path] = None): + def __init__( + self, + upper: Optional[Path], + lower: list[Path | str], + mount: Path, + cd: Optional[Path] = None, + ): self.upper = upper self.lower = lower self.mount_path = mount @@ -58,9 +65,7 @@ class OverlayFSMount: return Path(self.workdir.name) def mount(self) -> Optional[Path]: - lower = ":".join([ - str(l) for l in self.lower - ]) + lower = ":".join([str(l) for l in self.lower]) options = f"lowerdir={lower}" if self.upper is not None: workdir = self.create_workdir() @@ -69,8 +74,11 @@ class OverlayFSMount: result = run_sudo_cmd( [ "mount", - "-t", "overlay", "overlay", - "-o", options, + "-t", + "overlay", + "overlay", + "-o", + options, str(self.mount_path), ], cd=self.cd, @@ -85,12 +93,14 @@ class OverlayFSMount: def unmount(self): if self._mounted: print("Unmounting...") - + # Remove the mount - run_sudo_cmd([ - "umount", - str(self.mount_path), - ]) + run_sudo_cmd( + [ + "umount", + str(self.mount_path), + ] + ) # Remove the temporary workdir if self.workdir is not None: diff --git a/lmm/profile.py b/lmm/profile.py index af69ba8..c7fce7a 100644 --- a/lmm/profile.py +++ b/lmm/profile.py @@ -3,6 +3,7 @@ from typing import Optional from lmm.const import LMM_GAMES_PATH + class Profile: # The name of the game. game: str @@ -19,4 +20,4 @@ class Profile: self.runner = runner def get_mod_names(self) -> list[str]: - raise NotImplementedError() \ No newline at end of file + raise NotImplementedError() diff --git a/lmm/runners/base.py b/lmm/runners/base.py index 9e94f83..e68c5c8 100644 --- a/lmm/runners/base.py +++ b/lmm/runners/base.py @@ -7,6 +7,7 @@ from lmm.games.game import Game, ProtonGame from lmm.runners.runner import Runner from lmm.runners.steam import SteamFlatpakAppIdRunner + def runner_from_config(data: dict[str, Any]) -> Optional[Runner]: match data["type"]: case "steam.flatpak": diff --git a/lmm/runners/runner.py b/lmm/runners/runner.py index ebe90c1..ae2c688 100644 --- a/lmm/runners/runner.py +++ b/lmm/runners/runner.py @@ -2,6 +2,7 @@ import abc from lmm.games.game import Game + class Runner(abc.ABC): # Type identifier of the runner. runner_type: str @@ -10,4 +11,4 @@ class Runner(abc.ABC): self.runner_type = runner_type def run(self, game: Game): - raise NotImplementedError() \ No newline at end of file + raise NotImplementedError() diff --git a/lmm/runners/steam.py b/lmm/runners/steam.py index c9e48d3..bffbfe5 100644 --- a/lmm/runners/steam.py +++ b/lmm/runners/steam.py @@ -4,10 +4,12 @@ from pathlib import Path from lmm.runners.runner import Runner from lmm.games.game import Game + class SteamRuntime(abc.ABC): def get_run_script(self) -> Path: raise NotImplementedError() + class SteamSniperRuntime(SteamRuntime): library: Path @@ -15,7 +17,13 @@ class SteamSniperRuntime(SteamRuntime): self.library = library def get_run_script(self) -> Path: - return self.library / "steamapps" / "common" / "SteamLinuxRuntime_sniper" / "run-in-sniper" + return ( + self.library + / "steamapps" + / "common" + / "SteamLinuxRuntime_sniper" + / "run-in-sniper" + ) class SteamSoldierRuntime(SteamRuntime): @@ -25,7 +33,14 @@ class SteamSoldierRuntime(SteamRuntime): self.library = library def get_run_script(self) -> Path: - return self.library / "steamapps" / "common" / "SteamLinuxRuntime_soldier" / "run-in-soldier" + return ( + self.library + / "steamapps" + / "common" + / "SteamLinuxRuntime_soldier" + / "run-in-soldier" + ) + class SteamFlatpakAppIdRunner(Runner): def __init__(self): @@ -34,12 +49,15 @@ class SteamFlatpakAppIdRunner(Runner): def run(self, game: Game): assert isinstance(game, ProtonGame) - run_cmd_nonblocking([ - "flatpak", - "run", - "com.valvesoftware.Steam", - f"steam://launch/{game.appid}/", - ]) + run_cmd_nonblocking( + [ + "flatpak", + "run", + "com.valvesoftware.Steam", + f"steam://launch/{game.appid}/", + ] + ) + class SteamFlatpakProtonRunner(Runner): # Path to the Proton prefix @@ -55,7 +73,14 @@ class SteamFlatpakProtonRunner(Runner): # "waitforexitandrun" | "run" launchmode: str - def __init__(self, compat_data: Path, runtime: SteamRuntime, proton_version: str, game: str, launchmode: str = "waitforexitandrun"): + def __init__( + self, + compat_data: Path, + runtime: SteamRuntime, + proton_version: str, + game: str, + launchmode: str = "waitforexitandrun", + ): self.compat_data = compat_data self.runtime = runtime self.game = game @@ -72,16 +97,22 @@ class SteamFlatpakProtonRunner(Runner): return [ f"--env=STEAM_COMPAT_CLIENT_INSTALL_PATH={self._steam_install_path}", f"--env=STEAM_COMPAT_DATA_PATH={self.compat_data}", - #f"--env=WINEDLLPATH={proton}/files/lib64/wine:{proton}/files/lib/wine", - #f"--env=WINEPREFIX={self.compat_data}/pfx", - #f"--env=SteamGameId=1144200", - #"--env=WINEDLLOVERRIDES=steam.exe=b" - #f"--env=WINEDEBUG=-all", + # f"--env=WINEDLLPATH={proton}/files/lib64/wine:{proton}/files/lib/wine", + # f"--env=WINEPREFIX={self.compat_data}/pfx", + # f"--env=SteamGameId=1144200", + # "--env=WINEDLLOVERRIDES=steam.exe=b" + # f"--env=WINEDEBUG=-all", ] @property def _proton_path(self) -> Path: - return Path.home() / "data" / "Steam" / "compatibilitytools.d" / self.proton_version + return ( + Path.home() + / "data" + / "Steam" + / "compatibilitytools.d" + / self.proton_version + ) def _build_launch_command(self) -> str: command = [ @@ -94,12 +125,16 @@ class SteamFlatpakProtonRunner(Runner): return commandline def run(self): - run_cmd_shell(" ".join([ - "flatpak", - "run", - "--command=bash", - *self._build_environ(), - "com.valvesoftware.Steam", - "-c", - f'"{self._build_launch_command()}"', - ])) + run_cmd_shell( + " ".join( + [ + "flatpak", + "run", + "--command=bash", + *self._build_environ(), + "com.valvesoftware.Steam", + "-c", + f'"{self._build_launch_command()}"', + ] + ) + ) diff --git a/lmm/steam.py b/lmm/steam.py index afeaac9..b6d3410 100644 --- a/lmm/steam.py +++ b/lmm/steam.py @@ -7,6 +7,7 @@ STEAM_PATHS = [ Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam", ] + def find_library_folder_for_game(appid: str) -> Optional[Path]: for steam_path in STEAM_PATHS: if not steam_path.exists(): @@ -18,7 +19,7 @@ def find_library_folder_for_game(appid: str) -> Optional[Path]: with open(library_folders_vdf, "r") as f: content = vdf.load(f) - + for index in content["libraryfolders"]: library = content["libraryfolders"][index] @@ -28,4 +29,4 @@ def find_library_folder_for_game(appid: str) -> Optional[Path]: if appid in library["apps"]: return Path(library["path"]) - return None \ No newline at end of file + return None diff --git a/pyproject.toml b/pyproject.toml index f986791..de841b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,5 +7,11 @@ dependencies = [ "pyyaml" ] +[project.optional-dependencies] +dev = [ + "black", + "pylint" +] + [project.scripts] lmm = "lmm.cli:main" \ No newline at end of file