diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-02-11 02:37:39 -0600 |
| commit | 7ee008e150bc5bcf76082d726f719ee0fdfda982 (patch) | |
| tree | 27fb39be241fdb5ac2734c574de678977d1856d0 /libs/basic_games/basic_game.py | |
Fluorine Manager: full Linux port of Mod Organizer 2
Complete native Linux port with FUSE-based virtual filesystem,
Proton/umu-run integration, and Flatpak packaging.
Key features:
- FUSE VFS replacing Windows USVFS (in-process + standalone helper for Flatpak)
- Proton/GE-Proton/umu-run launcher with env var forwarding
- Flatpak support (sandbox-aware VFS, NXM handler, umu-run)
- Wine prefix management UI
- Case-insensitive path resolution for Linux filesystems
- QSettings-safe INI handling (avoids Bethesda INI corruption)
- Portable instance support with auto-generated launcher scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'libs/basic_games/basic_game.py')
| -rw-r--r-- | libs/basic_games/basic_game.py | 668 |
1 files changed, 668 insertions, 0 deletions
diff --git a/libs/basic_games/basic_game.py b/libs/basic_games/basic_game.py new file mode 100644 index 0000000..e0f7ba5 --- /dev/null +++ b/libs/basic_games/basic_game.py @@ -0,0 +1,668 @@ +from __future__ import annotations + +import shutil +import sys +from pathlib import Path +from typing import Callable, Generic, TypeVar + +from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import QMessageBox + +import mobase + +from .basic_features.basic_save_game_info import ( + BasicGameSaveGame, + BasicGameSaveGameInfo, +) + + +def replace_variables(value: str, game: BasicGame) -> str: + """Replace special paths in the given value.""" + + if value.find("%DOCUMENTS%") != -1: + value = value.replace( + "%DOCUMENTS%", + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.DocumentsLocation + ), + ) + if value.find("%USERPROFILE%") != -1: + value = value.replace( + "%USERPROFILE%", + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.HomeLocation + ), + ) + if value.find("%GAME_DOCUMENTS%") != -1: + value = value.replace( + "%GAME_DOCUMENTS%", game.documentsDirectory().absolutePath() + ) + if value.find("%GAME_PATH%") != -1: + value = value.replace("%GAME_PATH%", game.gameDirectory().absolutePath()) + + return value + + +_T = TypeVar("_T") + + +class BasicGameMapping(Generic[_T]): + # The game: + _game: "BasicGame" + + # Name of the attribute for exposure: + _exposed_name: str + + # Name of the internal method: + _internal_method_name: str + + # Required: + _required: bool + + # Callable returning a default value (if not required): + _default: Callable[["BasicGame"], _T] + + # Function to apply to the value: + _apply_fn: Callable[[_T | str], _T] | None + + def __init__( + self, + game: BasicGame, + exposed_name: str, + internal_method: str, + default: Callable[[BasicGame], _T] | None = None, + apply_fn: Callable[[_T | str], _T] | None = None, + ): + self._game = game + self._exposed_name = exposed_name + self._internal_method_name = internal_method + self._apply_fn = apply_fn + + if hasattr(game, self._exposed_name): + value = getattr(game, self._exposed_name) + + if self._apply_fn is not None: + try: + value = self._apply_fn(value) + except Exception as err: + raise ValueError( + "Basic game plugin from {} has an invalid {} property.".format( + game._fromName, # pyright: ignore[reportPrivateUsage] + self._exposed_name, + ) + ) from err + self._default = lambda game: value # type: ignore + elif default is not None: + self._default = default # type: ignore + elif getattr(game.__class__, self._internal_method_name) is getattr( + BasicGame, self._internal_method_name + ): + raise ValueError( + "Basic game plugin from {} is missing {} property.".format( + game._fromName, # pyright: ignore[reportPrivateUsage] + self._exposed_name, + ) + ) + + def get(self) -> _T: + """Return the value of this mapping.""" + value = self._default(self._game) # type: ignore + + if isinstance(value, str): + return replace_variables(value, self._game) # type: ignore + elif isinstance(value, QDir): + return QDir(replace_variables(value.path(), self._game)) # type: ignore + + # MO2 does not support Path anywhere so we always convert to str: + elif isinstance(value, Path): + return replace_variables(str(value), self._game) # type: ignore + + return value + + +class BasicGameOptionsMapping(BasicGameMapping[list[_T]]): + """ + Represents a game mappings for which multiple options are possible. The game + plugin is responsible to choose the right option depending on the context. + """ + + _index: int + + def __init__( + self, + game: BasicGame, + exposed_name: str, + internal_method: str, + default: Callable[[BasicGame], _T] | None = None, + apply_fn: Callable[[list[_T] | str], list[_T]] | None = None, + ): + super().__init__(game, exposed_name, internal_method, lambda g: [], apply_fn) + self._index = -1 + self._current_default = default + + def set_index(self, index: int): + """ + Set the index of the option to use. + + Args: + index: Index of the option to use. + """ + self._index = index + + def set_value(self, value: _T): + """ + Set the index corresponding of the given value. If the value is not present, + the index is set to -1. + + Args: + value: The value to set the index to. + """ + try: + self._index = self.get().index(value) + except ValueError: + self._index = -1 + + def has_value(self) -> bool: + """ + Check if a value was set for this options mapping. + + Returns: + True if a value was set, False otherwise. + """ + return self._index != -1 + + def current(self) -> _T: + values = self._default(self._game) # type: ignore + + if not values: + return self._current_default(self._game) # type: ignore + + if self._index == -1: + value = values[0] + else: + value = values[self._index] + + if isinstance(value, str): + return replace_variables(value, self._game) # type: ignore + elif isinstance(value, QDir): + return QDir(replace_variables(value.path(), self._game)) # type: ignore + + return value + + +class BasicGameMappings: + name: BasicGameMapping[str] + author: BasicGameMapping[str] + version: BasicGameMapping[mobase.VersionInfo] + description: BasicGameMapping[str] + gameName: BasicGameMapping[str] + gameShortName: BasicGameMapping[str] + gameNexusName: BasicGameMapping[str] + gameThunderstoreName: BasicGameMapping[str] + validShortNames: BasicGameMapping[list[str]] + nexusGameId: BasicGameMapping[int] + binaryName: BasicGameMapping[str] + launcherName: BasicGameMapping[str] + dataDirectory: BasicGameMapping[str] + documentsDirectory: BasicGameMapping[QDir] + iniFiles: BasicGameMapping[list[str]] + savesDirectory: BasicGameMapping[QDir] + savegameExtension: BasicGameMapping[str] + steamAPPId: BasicGameOptionsMapping[str] + gogAPPId: BasicGameOptionsMapping[str] + originManifestIds: BasicGameOptionsMapping[str] + originWatcherExecutables: BasicGameMapping[list[str]] + epicAPPId: BasicGameOptionsMapping[str] + eaDesktopContentId: BasicGameOptionsMapping[str] + supportURL: BasicGameMapping[str] + + @staticmethod + def _default_documents_directory(game: mobase.IPluginGame): + folders = [ + "{}/My Games/{}".format( + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.DocumentsLocation + ), + game.gameName(), + ), + "{}/{}".format( + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.DocumentsLocation + ), + game.gameName(), + ), + ] + for folder in folders: + qdir = QDir(folder) + if qdir.exists(): + return qdir + + return QDir() + + # Game mappings: + def __init__(self, game: BasicGame): + self._game = game + + self.name = BasicGameMapping(game, "Name", "name") + self.author = BasicGameMapping(game, "Author", "author") + self.version = BasicGameMapping( + game, + "Version", + "version", + apply_fn=lambda s: mobase.VersionInfo(s) if isinstance(s, str) else s, + ) + self.description = BasicGameMapping( + game, + "Description", + "description", + lambda g: "Adds basic support for game {}.".format(g.gameName()), + ) + self.gameName = BasicGameMapping(game, "GameName", "gameName") + self.gameShortName = BasicGameMapping(game, "GameShortName", "gameShortName") + self.gameNexusName = BasicGameMapping( + game, + "GameNexusName", + "gameNexusName", + default=lambda g: g.gameShortName(), + ) + self.gameThunderstoreName = BasicGameMapping( + game, + "GameThunderstoreName", + "gameThunderstoreName", + default=lambda g: "", + ) + self.validShortNames = BasicGameMapping( + game, + "GameValidShortNames", + "validShortNames", + default=lambda g: [], + apply_fn=lambda value: ( + [c.strip() for c in value.split(",")] # type: ignore + if isinstance(value, str) + else value + ), + ) + self.nexusGameId = BasicGameMapping( + game, "GameNexusId", "nexusGameID", default=lambda g: 0, apply_fn=int + ) + self.binaryName = BasicGameMapping(game, "GameBinary", "binaryName") + self.launcherName = BasicGameMapping( + game, + "GameLauncher", + "getLauncherName", + default=lambda g: "", + ) + self.dataDirectory = BasicGameMapping(game, "GameDataPath", "dataDirectory") + self.documentsDirectory = BasicGameMapping( + game, + "GameDocumentsDirectory", + "documentsDirectory", + apply_fn=lambda s: QDir(s) if isinstance(s, str) else s, + default=BasicGameMappings._default_documents_directory, + ) + self.iniFiles = BasicGameMapping( + game, + "GameIniFiles", + "iniFiles", + lambda g: [], + apply_fn=lambda value: ( + [c.strip() for c in value.split(",")] + if isinstance(value, str) + else value + ), + ) + self.savesDirectory = BasicGameMapping( + game, + "GameSavesDirectory", + "savesDirectory", + apply_fn=lambda s: QDir(s) if isinstance(s, str) else s, + default=lambda g: g.documentsDirectory(), + ) + self.savegameExtension = BasicGameMapping( + game, "GameSaveExtension", "savegameExtension", default=lambda g: "save" + ) + + # Convert Union[int, str, List[Union[int, str]]] to List[str]. + def ids_apply(v: list[int] | list[str] | int | str) -> list[str]: + """ + Convert various types to a list of string. If the given value is already a + list, returns a new list with all values converted to string, otherwise + returns a list with the value convert to a string as its only element. + """ + if isinstance(v, (int, str)): + v = [str(v)] + return [str(x) for x in v] + + self.steamAPPId = BasicGameOptionsMapping( + game, "GameSteamId", "steamAPPId", default=lambda g: "", apply_fn=ids_apply + ) + self.gogAPPId = BasicGameOptionsMapping( + game, "GameGogId", "gogAPPId", default=lambda g: "", apply_fn=ids_apply + ) + self.originManifestIds = BasicGameOptionsMapping( + game, + "GameOriginManifestIds", + "originManifestIds", + default=lambda g: "", + apply_fn=ids_apply, + ) + self.originWatcherExecutables = BasicGameMapping( + game, + "GameOriginWatcherExecutables", + "originWatcherExecutables", + apply_fn=lambda s: [s] if isinstance(s, str) else s, + default=lambda g: [], + ) + self.epicAPPId = BasicGameOptionsMapping( + game, "GameEpicId", "epicAPPId", default=lambda g: "", apply_fn=ids_apply + ) + self.eaDesktopContentId = BasicGameOptionsMapping( + game, + "GameEaDesktopId", + "eaDesktopContentId", + default=lambda g: "", + apply_fn=ids_apply, + ) + self.supportURL = BasicGameMapping( + game, "GameSupportURL", "supportURL", default=lambda g: "" + ) + + +class BasicGame(mobase.IPluginGame): + """This class implements some methods from mobase.IPluginGame + to make it easier to create game plugins without having to implement + all the methods of mobase.IPluginGame.""" + + # List of steam, GOG, origin and Epic games: + steam_games: dict[str, Path] + gog_games: dict[str, Path] + origin_games: dict[str, Path] + epic_games: dict[str, Path] + eadesktop_games: dict[str, Path] + + @staticmethod + def setup(): + from .eadesktop_utils import find_games as find_eadesktop_games + from .epic_utils import find_games as find_epic_games + from .gog_utils import find_games as find_gog_games + from .origin_utils import find_games as find_origin_games + from .steam_utils import find_games as find_steam_games + + errors: list[tuple[str, Exception]] = [] + BasicGame.steam_games = find_steam_games() + BasicGame.gog_games = find_gog_games() + BasicGame.origin_games = find_origin_games() + BasicGame.epic_games = find_epic_games(errors) + BasicGame.eadesktop_games = find_eadesktop_games(errors) + + if errors: + QMessageBox.critical( + None, + "Errors loading game list", + ( + "The following errors occurred while loading the list of available games:\n" + f"\n- {'\n\n- '.join('\n '.join(str(e) for e in messageError) for messageError in errors)}" + ), + ) + + # File containing the plugin: + _fromName: str + + # Organizer obtained in init: + _organizer: mobase.IOrganizer + + # Path to the game, as set by MO2: + _gamePath: str + + def __init__(self): + super(BasicGame, self).__init__() + + if not hasattr(self, "_fromName"): + self._fromName = self.__class__.__name__ + + self._gamePath = "" + + self._mappings: BasicGameMappings = BasicGameMappings(self) + + def _register_feature(self, feature: mobase.GameFeature) -> bool: + return self._organizer.gameFeatures().registerFeature(self, feature, 0, True) + + # Specific to BasicGame: + def is_steam(self) -> bool: + return self._mappings.steamAPPId.has_value() + + def is_gog(self) -> bool: + return self._mappings.gogAPPId.has_value() + + def is_origin(self) -> bool: + return self._mappings.originManifestIds.has_value() + + def is_epic(self) -> bool: + return self._mappings.epicAPPId.has_value() + + def is_eadesktop(self) -> bool: + return self._mappings.eaDesktopContentId.has_value() + + # IPlugin interface: + + def init(self, organizer: mobase.IOrganizer) -> bool: + self._organizer = organizer + + self._register_feature(BasicGameSaveGameInfo()) + + if self._mappings.originWatcherExecutables.get(): + from .origin_utils import OriginWatcher + + self.origin_watcher = OriginWatcher( + self._mappings.originWatcherExecutables.get() + ) + if not self._organizer.onAboutToRun( + lambda appName: self.origin_watcher.spawn_origin_watcher() + ): + print("Failed to register onAboutToRun callback!", file=sys.stderr) + return False + if not self._organizer.onFinishedRun( + lambda appName, result: self.origin_watcher.stop_origin_watcher() + ): + print("Failed to register onFinishedRun callback!", file=sys.stderr) + return False + return True + + def name(self) -> str: + return self._mappings.name.get() + + def author(self) -> str: + return self._mappings.author.get() + + def description(self) -> str: + return self._mappings.description.get() + + def version(self) -> mobase.VersionInfo: + return self._mappings.version.get() + + def isActive(self) -> bool: + if not self._organizer.managedGame(): + return False + + # Note: self is self._organizer.managedGame() does not work: + return self.name() == self._organizer.managedGame().name() + + def settings(self) -> list[mobase.PluginSetting]: + return [] + + # IPluginGame interface: + + def detectGame(self): + for steam_id in self._mappings.steamAPPId.get(): + if steam_id in BasicGame.steam_games: + self.setGamePath(BasicGame.steam_games[steam_id]) + return + + for gog_id in self._mappings.gogAPPId.get(): + if gog_id in BasicGame.gog_games: + self.setGamePath(BasicGame.gog_games[gog_id]) + return + + for origin_manifest_id in self._mappings.originManifestIds.get(): + if origin_manifest_id in BasicGame.origin_games: + self.setGamePath(BasicGame.origin_games[origin_manifest_id]) + return + + for epic_id in self._mappings.epicAPPId.get(): + if epic_id in BasicGame.epic_games: + self.setGamePath(BasicGame.epic_games[epic_id]) + return + + for eadesktop_content_id in self._mappings.eaDesktopContentId.get(): + if eadesktop_content_id in BasicGame.eadesktop_games: + self.setGamePath(BasicGame.eadesktop_games[eadesktop_content_id]) + return + + def gameName(self) -> str: + return self._mappings.gameName.get() + + def gameShortName(self) -> str: + return self._mappings.gameShortName.get() + + def gameIcon(self) -> QIcon: + return mobase.getIconForExecutable( + self.gameDirectory().absoluteFilePath(self.binaryName()) + ) + + def validShortNames(self) -> list[str]: + return self._mappings.validShortNames.get() + + def gameNexusName(self) -> str: + return self._mappings.gameNexusName.get() + + def gameThunderstoreName(self) -> str: + return self._mappings.gameThunderstoreName.get() + + def nexusModOrganizerID(self) -> int: + return 0 + + def nexusGameID(self) -> int: + return self._mappings.nexusGameId.get() + + def steamAPPId(self) -> str: + return self._mappings.steamAPPId.current() + + def gogAPPId(self) -> str: + return self._mappings.gogAPPId.current() + + def epicAPPId(self) -> str: + return self._mappings.epicAPPId.current() + + def eaDesktopContentId(self) -> str: + return self._mappings.eaDesktopContentId.current() + + def binaryName(self) -> str: + return self._mappings.binaryName.get() + + def getLauncherName(self) -> str: + return self._mappings.launcherName.get() + + def getSupportURL(self) -> str: + return self._mappings.supportURL.get() + + def iniFiles(self) -> list[str]: + return self._mappings.iniFiles.get() + + def executables(self) -> list[mobase.ExecutableInfo]: + execs: list[mobase.ExecutableInfo] = [] + if self.getLauncherName(): + execs.append( + mobase.ExecutableInfo( + self.gameName(), + QFileInfo( + self.gameDirectory().absoluteFilePath(self.getLauncherName()) + ), + ) + ) + execs.append( + mobase.ExecutableInfo( + self.gameName(), + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ) + ) + return execs + + def executableForcedLoads(self) -> list[mobase.ExecutableForcedLoadSetting]: + return [] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + ext = self._mappings.savegameExtension.get() + return [ + BasicGameSaveGame(path) + for path in Path(folder.absolutePath()).glob(f"**/*.{ext}") + ] + + def initializeProfile( + self, directory: QDir, settings: mobase.ProfileSetting + ) -> None: + if settings & mobase.ProfileSetting.CONFIGURATION: + for iniFile in self.iniFiles(): + try: + shutil.copyfile( + self.documentsDirectory().absoluteFilePath(iniFile), + directory.absoluteFilePath(QFileInfo(iniFile).fileName()), + ) + except FileNotFoundError: + Path( + directory.absoluteFilePath(QFileInfo(iniFile).fileName()) + ).touch() + + def setGameVariant(self, variant: str) -> None: + pass + + def gameVersion(self) -> str: + return mobase.getFileVersion( + self.gameDirectory().absoluteFilePath(self.binaryName()) + ) + + def looksValid(self, directory: QDir): + return directory.exists(self.binaryName()) + + def isInstalled(self) -> bool: + return bool(self._gamePath) + + def gameDirectory(self) -> QDir: + """ + @return directory (QDir) to the game installation. + """ + return QDir(self._gamePath) + + def dataDirectory(self) -> QDir: + return QDir( + self.gameDirectory().absoluteFilePath(self._mappings.dataDirectory.get()) + ) + + def setGamePath(self, path: Path | str) -> None: + self._gamePath = str(path) + + path = Path(path) + + # Check if we have a matching steam, GOG, Origin or EA Desktop id and set the + # index accordingly: + for steamid, steampath in BasicGame.steam_games.items(): + if steampath == path: + self._mappings.steamAPPId.set_value(steamid) + for gogid, gogpath in BasicGame.gog_games.items(): + if gogpath == path: + self._mappings.gogAPPId.set_value(gogid) + for originid, originpath in BasicGame.origin_games.items(): + if originpath == path: + self._mappings.originManifestIds.set_value(originid) + for epicid, epicpath in BasicGame.epic_games.items(): + if epicpath == path: + self._mappings.epicAPPId.set_value(epicid) + for eadesktopid, eadesktoppath in BasicGame.eadesktop_games.items(): + if eadesktoppath == path: + self._mappings.eaDesktopContentId.set_value(eadesktopid) + + def documentsDirectory(self) -> QDir: + return self._mappings.documentsDirectory.get() + + def savesDirectory(self) -> QDir: + return self._mappings.savesDirectory.get() |
