Initial commit
This commit is contained in:
commit
d352e70956
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
venv/
|
||||||
|
**/__pycache__/
|
||||||
|
*.egg-info/
|
0
lmm/__init__.py
Normal file
0
lmm/__init__.py
Normal file
137
lmm/cli.py
Normal file
137
lmm/cli.py
Normal 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
40
lmm/cmd.py
Normal 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
5
lmm/const.py
Normal 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
174
lmm/games/bg3.py
Normal 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
50
lmm/games/config.py
Normal 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
125
lmm/games/game.py
Normal 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
86
lmm/games/pdmm.py
Normal 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
90
lmm/games/ron.py
Normal 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
61
lmm/mods/bg3.py
Normal 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
106
lmm/overlayfs.py
Normal 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
22
lmm/profile.py
Normal 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
15
lmm/runners/base.py
Normal 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
13
lmm/runners/runner.py
Normal 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
105
lmm/runners/steam.py
Normal 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
31
lmm/steam.py
Normal 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
11
pyproject.toml
Normal 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"
|
Loading…
Reference in New Issue
Block a user