Initial commit

This commit is contained in:
PapaTutuWawa 2023-12-02 17:45:16 +01:00
commit d352e70956
18 changed files with 1074 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
venv/
**/__pycache__/
*.egg-info/

0
lmm/__init__.py Normal file
View File

137
lmm/cli.py Normal file
View File

@ -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()

40
lmm/cmd.py Normal file
View File

@ -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)

5
lmm/const.py Normal file
View File

@ -0,0 +1,5 @@
from pathlib import Path
LMM_PATH: Path = Path.home() / ".local" / "share" / "lmm"
LMM_GAMES_PATH: Path = LMM_PATH / "games"

174
lmm/games/bg3.py Normal file
View File

@ -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)

50
lmm/games/config.py Normal file
View File

@ -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

125
lmm/games/game.py Normal file
View File

@ -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)

86
lmm/games/pdmm.py Normal file
View File

@ -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)

90
lmm/games/ron.py Normal file
View File

@ -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)

61
lmm/mods/bg3.py Normal file
View File

@ -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}")

106
lmm/overlayfs.py Normal file
View File

@ -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()

22
lmm/profile.py Normal file
View File

@ -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()

15
lmm/runners/base.py Normal file
View File

@ -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

13
lmm/runners/runner.py Normal file
View File

@ -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()

105
lmm/runners/steam.py Normal file
View File

@ -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()}"',
]))

31
lmm/steam.py Normal file
View File

@ -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

11
pyproject.toml Normal file
View File

@ -0,0 +1,11 @@
[project]
name = "lmm"
version = "0.1.0"
requires-python = ">= 3.10"
dependencies = [
"vdf",
"pyyaml"
]
[project.scripts]
lmm = "lmm.cli:main"