Initial commit

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

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)