Initial commit
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user