commit d352e709567fd4306bd4011a5ebb0b604ce598aa Author: Alexander "PapaTutuWawa Date: Sat Dec 2 17:45:16 2023 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..843aa8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +venv/ +**/__pycache__/ +*.egg-info/ \ No newline at end of file diff --git a/lmm/__init__.py b/lmm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lmm/cli.py b/lmm/cli.py new file mode 100644 index 0000000..7677667 --- /dev/null +++ b/lmm/cli.py @@ -0,0 +1,137 @@ +import os +from typing import Optional + +from lmm.const import LMM_GAMES_PATH +from lmm.games.config import load_game_configs +from lmm.games.game import Game +from lmm.games.bg3 import GAME_NAME as BG3 +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: + if game.name == name: + 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) +def launch(game: str, profile: str): + game = get_game_by_name(game) + 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 + if profile.runner is not None: + runner = profile.runner + elif game.default_runner is not None: + runner = game.default_runner + if runner is None: + click.echo("Cannot launch profile as no runner or default runner is configured") + return + + # Prepare mounts + mounts_ok = True + mounts = game.prepare_overlays(profile) + for mount in mounts: + result = mount.mount() + if result is None: + click.echo("Mount failed") + mounts_ok = False + break + + print(f"Mounted overlayfs at {result}") + + # Launch the game + if mounts_ok: + #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) +def install(game: str, path: str): + install = { + BG3: bg3_install_mod, + }.get(game, None) + if install is None: + click.echo(f"Unknown game \"{game}\"") + return + + install(Path(path)) + +@cli.command() +@click.option("-g", "--game") +def mods(game: Optional[str]): + if game is not None: + games = [game] + else: + games = [ + item for item in os.listdir(LMM_GAMES_PATH) if (LMM_GAMES_PATH / item).is_dir() + ] + + for idx, game_name in enumerate(games): + click.echo(f"Installed mods for {game_name}:") + for item in os.listdir(LMM_GAMES_PATH / game_name): + path = LMM_GAMES_PATH / game_name / item + if not path.is_dir(): + continue + + click.echo(f"- {item}") + + if idx < len(games) - 1: + click.echo() + +@cli.command +def list(): + games = load_game_configs() + for idx, game in enumerate(games): + if game.default_runner is not None: + default_runner_name = game.default_runner.__class__.__name__ + else: + default_runner_name = "None" + + click.echo(f"- {game.name}") + click.echo(f" {game.installation_path}") + click.echo(f" Can start: {game.can_start()}") + click.echo(f" Default runner: {default_runner_name}") + click.echo(" Profiles:") + for profile in game.profiles: + mods = ", ".join(profile.get_mod_names()) + if profile.runner is not None: + runner_name = profile.runner.__class__.__name__ + else: + runner_name = "None" + + 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 diff --git a/lmm/cmd.py b/lmm/cmd.py new file mode 100644 index 0000000..7ca83ae --- /dev/null +++ b/lmm/cmd.py @@ -0,0 +1,40 @@ +import os +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: + print("Settings CWD to", cd) + print("Executing:", cmdline) + + cwd = os.getcwd() + if cd is not None: + os.chdir(cd) + ret = subprocess.call(args, cwd=cd) + os.chdir(cwd) + return ret + +def run_sudo_cmd(args: list[str], cd: Optional[Path] = None) -> int: + return run_cmd( + [ + "pkexec", + "--user", "root", + "--keep-cwd", + *args, + ], + cd=cd, + ) + +def run_cmd_shell(cmd: str) -> None: + print("Executing:", cmd) + subprocess.run( + cmd, + shell=True, + ) + +def run_cmd_nonblocking(args: list[str]) -> subprocess.Popen: + cmdline = " ".join(args) + print("Executing:", cmdline) + return subprocess.Popen(args) diff --git a/lmm/const.py b/lmm/const.py new file mode 100644 index 0000000..355bb3a --- /dev/null +++ b/lmm/const.py @@ -0,0 +1,5 @@ +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 diff --git a/lmm/games/bg3.py b/lmm/games/bg3.py new file mode 100644 index 0000000..759d2a7 --- /dev/null +++ b/lmm/games/bg3.py @@ -0,0 +1,174 @@ +import os +from pathlib import Path +from enum import Enum, auto +from dataclasses import dataclass +from typing import Generator, Any + +from lmm.const import LMM_GAMES_PATH +from lmm.profile import Profile +from lmm.overlayfs import OverlayFSMount +from lmm.games.game import ProtonGame, CannotStartReason, PathNotExistingReason +from lmm.runners.base import runner_from_config + +GAME_NAME = "BaldursGate3" + +class BaldursGate3ModType(Enum): + # Override the game files + ROOT = 1 + + # Override the Paks in the compatdata prefix + PAK = 2 + +@dataclass +class BaldursGate3Mod: + # The directory name + name: str + + # The type of mod + type: BaldursGate3ModType + + +@dataclass +class BaldursGate3Profile(Profile): + mods: list[BaldursGate3Mod] + + def __init__(self, name: str, mods: list[BaldursGate3Mod], **kwargs): + super().__init__(GAME_NAME, name, **kwargs) + self.mods = mods + + 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: + """Returns whether the profile has any mods of type @type""" + return any(self.get_mods(type)) + + 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) + + @property + def installation_path(self) -> Path: + return self.steam_library / "steamapps" / "common" / "Baldurs Gate 3" + + @property + def compat_path(self) -> Path: + return self.steam_library / "steamapps" / "compatdata" / self.appid + + @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" + + def can_start(self) -> bool: + return self.user_mods_path.exists() and self.installation_path.exists() + + def cannot_start_reasons(self) -> list[CannotStartReason]: + issues = [] + if not self.user_mods_path.exists(): + issues.append( + PathNotExistingReason(self.user_mods_path), + ) + if not self.installation_path.exists(): + issues.append( + PathNotExistingReason(self.installation_path), + ) + return issues + + def prepare_overlays(self, profile: BaldursGate3Profile) -> list[OverlayFSMount]: + overlays = [] + + 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 + ] + overlays.append( + OverlayFSMount( + self.installation_path, + mod_paths, + mount=self.installation_path, + ), + ) + + mod_names = ", ".join([mod.name for mod in mods]) + 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 + ] + + # Merge all mods + overlays.append( + OverlayFSMount( + self.user_mods_path, + mod_paths, + self.user_mods_path, + cd=LMM_GAMES_PATH / self.name, + ), + ) + + mod_names = ", ".join([mod.name for mod in mods]) + print(f"Loaded PAK mods: {mod_names}") + + return overlays + + @property + def game_executable(self) -> Path: + return self.installation_path / "bin" / "bg3.exe" + + def to_dict(self) -> dict[str, Any]: + return { + "profiles": [ + { + "name": profile.name, + "mods": [ + { + "name": mod.name, + "type": mod.type.value, + } for mod in profile.mods + ] + } for profile in self.profiles + ] + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "BaldursGate3Game": + profiles = [] + for profile in data["profiles"]: + mods = [] + for mod in profile["mods"]: + mods.append( + BaldursGate3Mod( + mod["name"], + BaldursGate3ModType.ROOT if mod["type"] == 1 else BaldursGate3ModType.PAK, + ), + ) + + if "runner" in profile: + runner = runner_from_config(profile["runner"]) + else: + runner = None + + profiles.append( + BaldursGate3Profile( + profile["name"], + mods, + 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 diff --git a/lmm/games/config.py b/lmm/games/config.py new file mode 100644 index 0000000..a6723ca --- /dev/null +++ b/lmm/games/config.py @@ -0,0 +1,50 @@ +import json +import os + +from lmm.const import LMM_GAMES_PATH +from lmm.games.game import Game +from lmm.games.bg3 import ( + GAME_NAME as BG3, + BaldursGate3Game, +) +from lmm.games.pdmm import ( + GAME_NAME as PDMM, + ProjectDivaMegaMixGame, +) +from lmm.games.ron import ( + GAME_NAME as RON, + ReadyOrNotGame, +) + +import yaml + + +def load_game_configs() -> list[Game]: + games = [] + for item in os.listdir(LMM_GAMES_PATH): + path = LMM_GAMES_PATH / item + if not path.is_dir(): + continue + + config = path / "config.yaml" + if not config.exists(): + continue + + game_cls = { + BG3: BaldursGate3Game, + PDMM: ProjectDivaMegaMixGame, + RON: ReadyOrNotGame, + }.get(item, None) + if game_cls is None: + print(f"Unknown game {item}") + continue + + with open(config, "r") as f: + try: + config_data = yaml.load(f, Loader=yaml.CLoader) + games.append( + game_cls.from_dict(config_data), + ) + except Exception as ex: + print(f"Failed to load game {item}: {ex}") + return games \ No newline at end of file diff --git a/lmm/games/game.py b/lmm/games/game.py new file mode 100644 index 0000000..995910e --- /dev/null +++ b/lmm/games/game.py @@ -0,0 +1,125 @@ +import abc +import os +from pathlib import Path +import time +from typing import Optional, Any +from dataclasses import dataclass + +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 + path: Path + + def __str__(self) -> str: + return f"Path {self.path} does not exist" + +class Game(abc.ABC): + # The name of the game. + name: str + + # List of profiles. + profiles: list[Profile] + + # 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"]): + self.name = name + self.profiles = profiles + self.default_runner = default_runner + + def get_profile_by_name(self, name: str) -> Optional[Profile]: + for profile in self.profiles: + if profile.name == name: + return profile + return None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Game": + raise NotImplementedError() + + def to_dict(self) -> dict[str, Any]: + raise NotImplementedError() + + def installation_path(self) -> Path: + raise NotImplementedError() + + def prepare_overlays(self, profile: Profile) -> list[OverlayFSMount]: + raise NotImplementedError() + + def can_start(self) -> bool: + return True + + def cannot_start_reasons(self) -> list[CannotStartReason]: + return [] + + def wait(self): + return None + +class ProtonGame(Game, abc.ABC): + # The appid of the game in Steam. + appid: str + + # The PID of the game. + _pid: Optional[str] + + 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 + + def compat_path(self) -> Path: + raise NotImplementedError() + + @property + def game_executable(self) -> Path: + raise NotImplementedError() + + @property + def steam_library(self) -> Optional[Path]: + return find_library_folder_for_game(self.appid) + + def wait(self): + # Wait until we started the game. + proc = Path("/proc") + windows_path = str(self.game_executable).replace("/", "\\") + wine_exe_path = f"Z:{windows_path}" + print(wine_exe_path) + print("Polling for game process...") + while True: + for dir in os.listdir(proc): + if not dir.isnumeric(): + continue + + proc_path = proc / dir / "cmdline" + try: + with open(proc_path, "r") as f: + cmdline = f.read() + + # 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): + self._pid = dir + break + except: + pass + if self._pid is not None: + print(f"Found {self.name} at PID {self._pid}") + break + print(".") + time.sleep(2) + print() + + # Poll until the game quits. + print(f"Polling {self._pid}") + pid_path = proc / self._pid + while pid_path.exists(): + time.sleep(2) diff --git a/lmm/games/pdmm.py b/lmm/games/pdmm.py new file mode 100644 index 0000000..ea17f1c --- /dev/null +++ b/lmm/games/pdmm.py @@ -0,0 +1,86 @@ +from pathlib import Path +from typing import Any + +from lmm.games.game import ProtonGame +from lmm.profile import Profile +from lmm.overlayfs import OverlayFSMount +from lmm.const import LMM_GAMES_PATH +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] + + def __init__(self, name: str, mods: list[str], **kwargs): + super().__init__(GAME_NAME, name, **kwargs) + + self.mods = mods + + 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" + + @property + def game_executable(self) -> Path: + return self.installation_path / "DivaMegaMix.exe" + + def prepare_overlays(self, profile: ProjectDivaMegaMixProfile) -> list[OverlayFSMount]: + return [ + OverlayFSMount( + upper=self.installation_path, + lower=profile.mods, + mount=self.installation_path, + cd=LMM_GAMES_PATH / self.name, + ), + ] + + def to_dict(self) -> dict[str, Any]: + return { + "profiles": [ + { + "name": profile.name, + "mods": [ + { + "name": mod, + } for mod in profile.mods + ] + } for profile in self.profiles + ] + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ProjectDivaMegaMixGame": + profiles = [] + for profile in data["profiles"]: + mods = [ + mod["name"] for mod in profile["mods"] + ] + + if "runner" in profile: + runner = runner_from_config(profile["runner"]) + else: + runner = None + + profiles.append( + ProjectDivaMegaMixProfile( + profile["name"], + mods, + runner=runner, + ), + ) + + if "default_runner" in data: + default_runner = runner_from_config(data["default_runner"]) + else: + default_runner = None + + return ProjectDivaMegaMixGame(profiles, default_runner=default_runner) \ No newline at end of file diff --git a/lmm/games/ron.py b/lmm/games/ron.py new file mode 100644 index 0000000..a5e093e --- /dev/null +++ b/lmm/games/ron.py @@ -0,0 +1,90 @@ +from pathlib import Path +from typing import Any + +from lmm.profile import Profile +from lmm.overlayfs import OverlayFSMount +from lmm.games.game import ProtonGame + +GAME_NAME = "ReadyOrNot" + +class ReadyOrNotProfile(Profile): + # Names of directories inside the game's mods directory. + mods: list[str] + + def __init__(self, name: str, mods: list[str], **kwargs): + super().__init__(GAME_NAME, name, **kwargs) + self.mods = mods + + 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) + + @property + def installation_path(self) -> Path: + return self.steam_library / "steamapps" / "common" / "Ready Or Not" + + @property + def compat_path(self) -> Path: + return self.steam_library / "steamapps" / "compatdata" / self.appid + + def can_start(self) -> bool: + return self.steam_library is not None + + def prepare_overlays(self, profile: Profile) -> list[OverlayFSMount]: + return [ + OverlayFSMount( + self.installation_path, + profile.mod_paths, + mount=self.installation_path, + workdir=profile.workdir, + ), + ] + + @property + def game_executable(self) -> Path: + return self.installation_path / "ReadyOrNot.exe" + + def to_dict(self) -> dict[str, Any]: + return { + "profiles": [ + { + "name": profile.name, + "mods": [ + { + "name": mod, + } for mod in profile.mods + ] + } for profile in self.profiles + ] + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ReadyOrNotGame": + profiles = [] + for profile in data["profiles"]: + mods = [ + mod["name"] for mod in profile["mods"] + ] + + if "runner" in profile: + runner = runner_from_config(profile["runner"]) + else: + runner = None + + profiles.append( + ReadyOrNotProfile( + profile["name"], + mods, + runner=runner, + ), + ) + + if "default_runner" in data: + default_runner = runner_from_config(data["default_runner"]) + else: + default_runner = None + + return ReadyOrNotGame(profiles, default_runner=default_runner) \ No newline at end of file diff --git a/lmm/mods/bg3.py b/lmm/mods/bg3.py new file mode 100644 index 0000000..cfdf22e --- /dev/null +++ b/lmm/mods/bg3.py @@ -0,0 +1,61 @@ +import json +from pathlib import Path +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": + install_zip_mod(mod) + 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: + paks = [] + info_json = None + for member in f.infolist(): + if member.filename.endswith(".pak"): + paks.append(member) + elif member.filename == "info.json": + info_json = member + + if not paks: + print("Automatic installation of non-PAK mods not supported yet") + pass + + # This mod is a PAK only mod + if info_json is not None: + with f.open("info.json", "r") as info: + data = json.load(info) + name = data["Mods"][0]["Name"].replace(" ", "") + elif len(paks) == 1: + print("Guessing name from PAK file") + name = paks[0].filename.replace(".pak", "").replace(" ", "") + else: + print("Cannot deduce mod name!") + try: + name = input("Mod name: ") + except InterruptedError: + print("Not installing mod") + return + except EOFError: + print("Not installing mod") + return + + # Check if we can install the mod + installed_mod_path = LMM_GAMES_PATH / GAME_NAME / name + if installed_mod_path.exists(): + print(f"Mod already exists: {name}") + return + + # Install the mod + installed_mod_path.mkdir(parents=True) + for pak in paks: + print(f"Extracting {pak.filename}...") + f.extract(pak, installed_mod_path) + + print(f"Installed mod as {name}") diff --git a/lmm/overlayfs.py b/lmm/overlayfs.py new file mode 100644 index 0000000..b0e9b7b --- /dev/null +++ b/lmm/overlayfs.py @@ -0,0 +1,106 @@ +from pathlib import Path +import tempfile +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") + + mounted_dirs = [ + m for line in mounts if line != "" and (m := line.strip().split(" ")[1]) != "/" + ] + mounted_dirs.sort() + + closest_mount = None + for m in mounted_dirs: + 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" + + +class OverlayFSMount: + upper: Optional[Path] + + lower: list[Path | str] + + workdir: Optional[tempfile.TemporaryDirectory] + + mount_path: Path + + _mounted: bool + + cd: Optional[Path] + + 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 + self._mounted = False + self.cd = cd + + assert len(lower) < 500, "overlayfs only supports up-to 500 lower directories" + + def create_workdir(self) -> Path: + # Ensure the temporary directory for workdirs exists + workdir_location = compute_workdirs_location(self.mount_path) + if not workdir_location.exists(): + workdir_location.mkdir(parents=True, exist_ok=True) + + # Create the workdir + self.workdir = tempfile.TemporaryDirectory( + dir=workdir_location, + ) + print(f"Temporary workdir {self.workdir.name} created") + return Path(self.workdir.name) + + def mount(self) -> Optional[Path]: + lower = ":".join([ + str(l) for l in self.lower + ]) + options = f"lowerdir={lower}" + if self.upper is not None: + workdir = self.create_workdir() + options += f",upperdir={self.upper},workdir={workdir}" + + result = run_sudo_cmd( + [ + "mount", + "-t", "overlay", "overlay", + "-o", options, + str(self.mount_path), + ], + cd=self.cd, + ) + print("Mount result:", result) + if not result == 0: + return None + + self._mounted = True + return self.mount_path + + def unmount(self): + if self._mounted: + print("Unmounting...") + + # Remove the mount + run_sudo_cmd([ + "umount", + str(self.mount_path), + ]) + + # Remove the temporary workdir + if self.workdir is not None: + try: + # TODO: This fails because the workdir is owned by root + self.workdir.cleanup() + except PermissionError: + print(f"Failed to clean temporary directory {self.workdir.name}") + + self._mounted = False + + def __del__(self): + self.unmount() diff --git a/lmm/profile.py b/lmm/profile.py new file mode 100644 index 0000000..af69ba8 --- /dev/null +++ b/lmm/profile.py @@ -0,0 +1,22 @@ +from pathlib import Path +from typing import Optional + +from lmm.const import LMM_GAMES_PATH + +class Profile: + # The name of the game. + game: str + + # The name of the profile. + name: str + + # The runner to use for the profile. + runner: Optional["Runner"] + + def __init__(self, game: str, name: str, runner: Optional["Runner"]): + self.game = game + self.name = name + self.runner = runner + + def get_mod_names(self) -> list[str]: + raise NotImplementedError() \ No newline at end of file diff --git a/lmm/runners/base.py b/lmm/runners/base.py new file mode 100644 index 0000000..9e94f83 --- /dev/null +++ b/lmm/runners/base.py @@ -0,0 +1,15 @@ +import abc +from pathlib import Path +from typing import Any, Optional + +from lmm.cmd import run_cmd_nonblocking +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": + return SteamFlatpakAppIdRunner() + case _: + return None diff --git a/lmm/runners/runner.py b/lmm/runners/runner.py new file mode 100644 index 0000000..ebe90c1 --- /dev/null +++ b/lmm/runners/runner.py @@ -0,0 +1,13 @@ +import abc + +from lmm.games.game import Game + +class Runner(abc.ABC): + # Type identifier of the runner. + runner_type: str + + def __init__(self, runner_type: str): + self.runner_type = runner_type + + def run(self, game: Game): + raise NotImplementedError() \ No newline at end of file diff --git a/lmm/runners/steam.py b/lmm/runners/steam.py new file mode 100644 index 0000000..c9e48d3 --- /dev/null +++ b/lmm/runners/steam.py @@ -0,0 +1,105 @@ +import abc +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 + + def __init__(self, library: Path): + self.library = library + + def get_run_script(self) -> Path: + return self.library / "steamapps" / "common" / "SteamLinuxRuntime_sniper" / "run-in-sniper" + + +class SteamSoldierRuntime(SteamRuntime): + library: Path + + def __init__(self, library: Path): + self.library = library + + def get_run_script(self) -> Path: + return self.library / "steamapps" / "common" / "SteamLinuxRuntime_soldier" / "run-in-soldier" + +class SteamFlatpakAppIdRunner(Runner): + def __init__(self): + super().__init__("steam.flatpak") + + def run(self, game: Game): + assert isinstance(game, ProtonGame) + + run_cmd_nonblocking([ + "flatpak", + "run", + "com.valvesoftware.Steam", + f"steam://launch/{game.appid}/", + ]) + +class SteamFlatpakProtonRunner(Runner): + # Path to the Proton prefix + compat_data: Path + + proton_version: str + + runtime: SteamRuntime + + game: str + + # Launchmode as passed to Proton. + # "waitforexitandrun" | "run" + launchmode: str + + 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 + self.proton_version = proton_version + + self.launchmode = launchmode + + @property + def _steam_install_path(self) -> Path: + return Path.home() / ".steam" / "steam" + + def _build_environ(self) -> list[str]: + proton = self._proton_path + 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", + ] + + @property + def _proton_path(self) -> Path: + return Path.home() / "data" / "Steam" / "compatibilitytools.d" / self.proton_version + + def _build_launch_command(self) -> str: + command = [ + str(self.runtime.get_run_script()), + str(self._proton_path / "proton"), + self.launchmode, + f"'{self.game}'", + ] + commandline = " ".join(command) + 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()}"', + ])) diff --git a/lmm/steam.py b/lmm/steam.py new file mode 100644 index 0000000..afeaac9 --- /dev/null +++ b/lmm/steam.py @@ -0,0 +1,31 @@ +from typing import Optional +from pathlib import Path + +import vdf + +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(): + continue + + library_folders_vdf = steam_path / "steamapps/libraryfolders.vdf" + if not library_folders_vdf.exists(): + continue + + with open(library_folders_vdf, "r") as f: + content = vdf.load(f) + + for index in content["libraryfolders"]: + library = content["libraryfolders"][index] + + if not Path(library["path"]).exists(): + continue + + if appid in library["apps"]: + return Path(library["path"]) + + return None \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f986791 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "lmm" +version = "0.1.0" +requires-python = ">= 3.10" +dependencies = [ + "vdf", + "pyyaml" +] + +[project.scripts] +lmm = "lmm.cli:main" \ No newline at end of file