diff options
Diffstat (limited to 'libs/basic_games')
| -rw-r--r-- | libs/basic_games/basic_game.py | 78 | ||||
| -rw-r--r-- | libs/basic_games/games/baldursgate3/bg3_file_mapper.py | 33 | ||||
| -rw-r--r-- | libs/basic_games/games/baldursgate3/lslib_retriever.py | 77 | ||||
| -rw-r--r-- | libs/basic_games/games/baldursgate3/pak_parser.py | 97 | ||||
| -rw-r--r-- | libs/basic_games/games/game_baldursgate3.py | 1 | ||||
| -rw-r--r-- | libs/basic_games/games/game_oblivion_remaster.py | 4 | ||||
| -rw-r--r-- | libs/basic_games/games/oblivion_remaster/mod_data_checker.py | 2 | ||||
| -rw-r--r-- | libs/basic_games/games/oblivion_remaster/paks/model.py | 2 | ||||
| -rw-r--r-- | libs/basic_games/games/oblivion_remaster/paks/widget.py | 2 | ||||
| -rw-r--r-- | libs/basic_games/games/oblivion_remaster/script_extender.py | 2 | ||||
| -rw-r--r-- | libs/basic_games/winreg.py | 11 |
11 files changed, 267 insertions, 42 deletions
diff --git a/libs/basic_games/basic_game.py b/libs/basic_games/basic_game.py index e0f7ba5..b3ff73d 100644 --- a/libs/basic_games/basic_game.py +++ b/libs/basic_games/basic_game.py @@ -1,11 +1,13 @@ from __future__ import annotations +import os +import platform import shutil import sys from pathlib import Path from typing import Callable, Generic, TypeVar -from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths +from PyQt6.QtCore import QDir, QFileInfo, QStandardPaths, qWarning from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QMessageBox @@ -17,6 +19,49 @@ from .basic_features.basic_save_game_info import ( ) +def _find_wine_userprofile() -> str | None: + """On Linux, find the Wine/Proton user profile directory inside the prefix. + + Returns the host path equivalent of %USERPROFILE% (e.g. + ``<prefix>/drive_c/users/steamuser``) or *None* if no valid prefix is + found. + """ + if platform.system() == "Windows": + return None + + candidates: list[str] = [] + + # 1. Flatpak data prefix (most common for Fluorine) + flatpak_pfx = os.path.expanduser( + "~/.var/app/com.fluorine.manager/Prefix/pfx" + ) + candidates.append(flatpak_pfx) + + # 2. Fluorine config prefix_path + try: + config_dir = os.environ.get( + "XDG_CONFIG_HOME", os.path.join(str(Path.home()), ".config") + ) + cfg_path = os.path.join(config_dir, "fluorine", "config.json") + if os.path.isfile(cfg_path): + import json + + with open(cfg_path, "r") as f: + cfg = json.load(f) + pfx = cfg.get("prefix_path", "") + if pfx: + candidates.append(pfx) + except Exception: + pass + + for pfx in candidates: + user_dir = os.path.join(pfx, "drive_c", "users", "steamuser") + if os.path.isdir(user_dir): + return user_dir + + return None + + def replace_variables(value: str, game: BasicGame) -> str: """Replace special paths in the given value.""" @@ -28,12 +73,23 @@ def replace_variables(value: str, game: BasicGame) -> str: ), ) if value.find("%USERPROFILE%") != -1: - value = value.replace( - "%USERPROFILE%", - QStandardPaths.writableLocation( - QStandardPaths.StandardLocation.HomeLocation - ), - ) + if platform.system() != "Windows": + wine_profile = _find_wine_userprofile() + if wine_profile: + value = value.replace("%USERPROFILE%", wine_profile) + else: + qWarning( + "No Wine/Proton prefix found. " + "Ensure a prefix is configured in Settings > Proton." + ) + value = value.replace("%USERPROFILE%", "") + else: + value = value.replace( + "%USERPROFILE%", + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.HomeLocation + ), + ) if value.find("%GAME_DOCUMENTS%") != -1: value = value.replace( "%GAME_DOCUMENTS%", game.documentsDirectory().absolutePath() @@ -41,6 +97,9 @@ def replace_variables(value: str, game: BasicGame) -> str: if value.find("%GAME_PATH%") != -1: value = value.replace("%GAME_PATH%", game.gameDirectory().absolutePath()) + # Normalize Windows backslash path separators for Linux. + value = value.replace("\\", "/") + return value @@ -634,8 +693,11 @@ class BasicGame(mobase.IPluginGame): return QDir(self._gamePath) def dataDirectory(self) -> QDir: + data_path = self._mappings.dataDirectory.get() + if data_path and QDir.isAbsolutePath(data_path): + return QDir(data_path) return QDir( - self.gameDirectory().absoluteFilePath(self._mappings.dataDirectory.get()) + self.gameDirectory().absoluteFilePath(data_path) ) def setGamePath(self, path: Path | str) -> None: diff --git a/libs/basic_games/games/baldursgate3/bg3_file_mapper.py b/libs/basic_games/games/baldursgate3/bg3_file_mapper.py index 0ae6ef8..e2c54e9 100644 --- a/libs/basic_games/games/baldursgate3/bg3_file_mapper.py +++ b/libs/basic_games/games/baldursgate3/bg3_file_mapper.py @@ -1,5 +1,6 @@ import functools import os +import platform from pathlib import Path from typing import Callable, Optional @@ -10,9 +11,12 @@ import mobase from . import bg3_utils +_IS_LINUX = platform.system() != "Windows" + class BG3FileMapper(mobase.IPluginFileMapper): current_mappings: list[mobase.Mapping] = [] + _linux_symlinks: list[Path] = [] def __init__(self, utils: bg3_utils.BG3Utils, doc_dir: Callable[[], QDir]): super().__init__() @@ -26,6 +30,8 @@ class BG3FileMapper(mobase.IPluginFileMapper): def mappings(self) -> list[mobase.Mapping]: qInfo("creating custom bg3 mappings") self.current_mappings.clear() + if _IS_LINUX: + self._cleanup_linux_symlinks() active_mods = self._utils.active_mods() if not active_mods: return [] @@ -121,6 +127,18 @@ class BG3FileMapper(mobase.IPluginFileMapper): def create_mapping(self, file: Path, dest: Path): bg3_utils.create_dir_if_needed(dest) + # On Linux, FUSE cannot handle file mappings outside the game's data + # directory. Create real symlinks so the game (via Proton) can find + # mod files in the documents/prefix directory. + if _IS_LINUX and not file.is_dir(): + try: + if dest.is_symlink() or dest.exists(): + dest.unlink() + dest.symlink_to(file) + self._linux_symlinks.append(dest) + except OSError as e: + qWarning(f"BG3: failed to symlink {dest} -> {file}: {e}") + self.current_mappings.append( mobase.Mapping( source=str(file), @@ -129,3 +147,18 @@ class BG3FileMapper(mobase.IPluginFileMapper): create_target=True, ) ) + + def _cleanup_linux_symlinks(self): + """Remove symlinks created by previous mappings() calls.""" + for link in self._linux_symlinks: + try: + if link.is_symlink(): + link.unlink() + except OSError: + pass + self._linux_symlinks.clear() + + def cleanup(self): + """Public cleanup method — call from onFinishedRun.""" + if _IS_LINUX: + self._cleanup_linux_symlinks() diff --git a/libs/basic_games/games/baldursgate3/lslib_retriever.py b/libs/basic_games/games/baldursgate3/lslib_retriever.py index ee1122c..8b0e1dc 100644 --- a/libs/basic_games/games/baldursgate3/lslib_retriever.py +++ b/libs/basic_games/games/baldursgate3/lslib_retriever.py @@ -15,29 +15,41 @@ class LSLibRetriever: def __init__(self, utils: bg3_utils.BG3Utils): self._utils = utils + # Files that MUST be present for Divine to work. + _REQUIRED_FILES = { + "Divine.dll", + "Divine.exe", + "LSLib.dll", + } + + # Additional files that may or may not be in a given LSLib release. + _OPTIONAL_FILES = { + "CommandLineArgumentsParser.dll", + "Divine.dll.config", + "Divine.runtimeconfig.json", + "K4os.Compression.LZ4.dll", + "K4os.Compression.LZ4.Streams.dll", + "LSLibNative.dll", + "LZ4.dll", + "Newtonsoft.Json.dll", + "System.IO.Hashing.dll", + "ZstdSharp.dll", + } + @cached_property def _needed_lslib_files(self): return { self._utils.tools_dir / x - for x in { - "CommandLineArgumentsParser.dll", - "Divine.dll", - "Divine.dll.config", - "Divine.exe", - "Divine.runtimeconfig.json", - "K4os.Compression.LZ4.dll", - "K4os.Compression.LZ4.Streams.dll", - "LSLib.dll", - "LSLibNative.dll", - "LZ4.dll", - "Newtonsoft.Json.dll", - "System.IO.Hashing.dll", - "ZstdSharp.dll", - } + for x in self._REQUIRED_FILES | self._OPTIONAL_FILES } def download_lslib_if_missing(self, force: bool = False) -> bool: - if not force and all(x.exists() for x in self._needed_lslib_files): + # Only check required files to avoid re-downloading when optional + # files were not present in the archive. + required_paths = { + self._utils.tools_dir / x for x in self._REQUIRED_FILES + } + if not force and all(x.exists() for x in required_paths): return True try: self._utils.tools_dir.mkdir(exist_ok=True, parents=True) @@ -133,21 +145,40 @@ class LSLibRetriever: win_title, len(self._needed_lslib_files), msg=dialog_message ) with zipfile.ZipFile(zip_path, "r") as zip_ref: + # Build a lookup from basename → zip entry so we can handle + # any zip directory layout (Packed/Tools/, Tools/, flat, etc.) + zip_names = zip_ref.namelist() + entry_map: dict[str, str] = {} + for zn in zip_names: + base = zn.rsplit("/", 1)[-1] if "/" in zn else zn + if base: + entry_map[base] = zn + for file in self._needed_lslib_files: if downloaded or not file.exists(): - shutil.move( - zip_ref.extract( - f"Packed/Tools/{file.name}", self._utils.tools_dir - ), - file, - ) + zip_entry = entry_map.get(file.name) + if zip_entry is None: + if file.name in self._REQUIRED_FILES: + raise KeyError( + f"Required file '{file.name}' not found in archive" + ) + qDebug(f"LSLib: optional file '{file.name}' not in archive, skipping") + continue + extracted = zip_ref.extract(zip_entry, self._utils.tools_dir) + # Move from nested zip path to flat tools_dir + if extracted != str(file): + shutil.move(extracted, file) x_progress.setValue(x_progress.value() + 1) QApplication.processEvents() if x_progress.wasCanceled(): qWarning("processing canceled by user") return False x_progress.close() - shutil.rmtree(self._utils.tools_dir / "Packed", ignore_errors=True) + # Clean up any nested directories left from extraction + for subdir in ("Packed", "Tools"): + nested = self._utils.tools_dir / subdir + if nested.is_dir(): + shutil.rmtree(nested, ignore_errors=True) except Exception as e: qDebug(f"Extraction failed: {e}") err = QMessageBox(self._utils.main_window) diff --git a/libs/basic_games/games/baldursgate3/pak_parser.py b/libs/basic_games/games/baldursgate3/pak_parser.py index 18598a9..4ebc83c 100644 --- a/libs/basic_games/games/baldursgate3/pak_parser.py +++ b/libs/basic_games/games/baldursgate3/pak_parser.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import configparser import hashlib import os +import platform import re import shutil import subprocess @@ -38,7 +41,39 @@ class BG3PakParser: @cached_property def _divine_command(self): - return f"{self._utils.tools_dir / 'Divine.exe'} -g bg3 -l info" + divine_exe = self._utils.tools_dir / "Divine.exe" + if platform.system() != "Windows": + wine = self._find_proton_wine() + if wine: + return f'"{wine}" "{divine_exe}" -g bg3 -l info' + qWarning( + "BG3: could not find Proton/Wine to run Divine.exe. " + "Ensure a Proton version is configured in Settings > Proton." + ) + return f'"{divine_exe}" -g bg3 -l info' + + def _find_proton_wine(self) -> str | None: + """Locate the wine binary from the configured Proton installation.""" + try: + config_dir = os.environ.get( + "XDG_CONFIG_HOME", os.path.join(Path.home(), ".config") + ) + cfg_path = os.path.join(config_dir, "fluorine", "config.json") + if not os.path.isfile(cfg_path): + return None + import json as _json + with open(cfg_path, "r") as f: + cfg = _json.load(f) + proton_path = cfg.get("proton_path", "") + if not proton_path: + return None + for subdir in ("files/bin/wine", "dist/bin/wine"): + candidate = os.path.join(proton_path, subdir) + if os.path.isfile(candidate): + return candidate + except Exception as e: + qDebug(f"BG3: failed to read Fluorine config for Proton wine: {e}") + return None @cached_property def _folder_pattern(self): @@ -198,18 +233,70 @@ class BG3PakParser: qWarning(traceback.format_exc()) return "" + @staticmethod + def _to_wine_path(path: str) -> str: + """Convert a Linux absolute path to a Wine Z: drive path.""" + if path.startswith("/"): + return "Z:" + path.replace("/", "\\") + return path + def run_divine( self, action: str, source: Path | str ) -> subprocess.CompletedProcess[str]: - command = f'{self._divine_command} -a {action} -s "{source}"' - result = subprocess.run( - command, - creationflags=subprocess.CREATE_NO_WINDOW, + if platform.system() != "Windows": + # Divine.exe is a .NET Windows app — it needs Windows-style paths. + # Wine maps Z:\ to the Linux root, so /home/... becomes Z:\home\... + wine_source = self._to_wine_path(str(source)) + wine_action = re.sub( + r'"(/[^"]*)"', + lambda m: '"' + self._to_wine_path(m.group(1)) + '"', + action, + ) + command = f'{self._divine_command} -a {wine_action} -s "{wine_source}"' + else: + command = f'{self._divine_command} -a {action} -s "{source}"' + kwargs: dict = dict( check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) + if platform.system() == "Windows": + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + kwargs["shell"] = True + result = subprocess.run(command, **kwargs) + else: + # Set WINEPREFIX so Proton's wine uses the game's prefix. + wine_prefix = "" + try: + config_dir = os.environ.get( + "XDG_CONFIG_HOME", os.path.join(Path.home(), ".config") + ) + cfg_path = os.path.join(config_dir, "fluorine", "config.json") + if os.path.isfile(cfg_path): + import json as _json + with open(cfg_path, "r") as f: + cfg = _json.load(f) + wine_prefix = cfg.get("prefix_path", "") + except Exception: + pass + + if os.path.exists("/.flatpak-info"): + # In Flatpak, host wine binaries can't execute directly + # (missing host libraries). Use flatpak-spawn to run on host. + spawn_cmd = ["flatpak-spawn", "--host"] + if wine_prefix: + spawn_cmd.append(f"--env=WINEPREFIX={wine_prefix}") + spawn_cmd.extend(["bash", "-c", command]) + result = subprocess.run(spawn_cmd, **kwargs) + else: + env = os.environ.copy() + if wine_prefix: + env["WINEPREFIX"] = wine_prefix + kwargs["env"] = env + kwargs["shell"] = True + result = subprocess.run(command, **kwargs) + if result.returncode: qWarning( f"{command.replace(str(Path.home()), '~', 1).replace(str(Path.home()), '$HOME')}" diff --git a/libs/basic_games/games/game_baldursgate3.py b/libs/basic_games/games/game_baldursgate3.py index 4898696..718d4f3 100644 --- a/libs/basic_games/games/game_baldursgate3.py +++ b/libs/basic_games/games/game_baldursgate3.py @@ -170,6 +170,7 @@ class BG3Game(BasicGame, bg3_file_mapper.BG3FileMapper): def _on_finished_run(self, exec_path: str, exit_code: int): if "bin/bg3" not in exec_path: return + self.cleanup() # Remove symlinks created by the file mapper on Linux cat = QLoggingCategory.defaultCategory() self.utils.log_dir.mkdir(parents=True, exist_ok=True) if ( diff --git a/libs/basic_games/games/game_oblivion_remaster.py b/libs/basic_games/games/game_oblivion_remaster.py index 218bb04..32d1723 100644 --- a/libs/basic_games/games/game_oblivion_remaster.py +++ b/libs/basic_games/games/game_oblivion_remaster.py @@ -238,10 +238,10 @@ class OblivionRemasteredGame( ) -> None: if settings & mobase.ProfileSetting.CONFIGURATION: game_ini_file = self.gameDirectory().absoluteFilePath( - r"OblivionRemastered\Content\Dev\ObvData\Oblivion.ini" + "OblivionRemastered/Content/Dev/ObvData/Oblivion.ini" ) game_default_ini = self.gameDirectory().absoluteFilePath( - r"OblivionRemastered\Content\Dev\ObvData\Oblivion_default.ini" + "OblivionRemastered/Content/Dev/ObvData/Oblivion_default.ini" ) profile_ini = directory.absoluteFilePath( QFileInfo("Oblivion.ini").fileName() diff --git a/libs/basic_games/games/oblivion_remaster/mod_data_checker.py b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py index 51875b2..2105796 100644 --- a/libs/basic_games/games/oblivion_remaster/mod_data_checker.py +++ b/libs/basic_games/games/oblivion_remaster/mod_data_checker.py @@ -181,7 +181,7 @@ class OblivionRemasteredModDataChecker(mobase.ModDataChecker): # Similar to the above, many mods pack files relative to the root game directory. Some common paths can be # automatically moved into the appropriate directory structures to avoid needless iteration. - exe_dir = filetree.find(r"OblivionRemastered\Binaries\Win64") + exe_dir = filetree.find("OblivionRemastered/Binaries/Win64") if isinstance(exe_dir, mobase.IFileTree): gamesettings_dir = exe_dir.find("GameSettings") if isinstance(gamesettings_dir, mobase.IFileTree): diff --git a/libs/basic_games/games/oblivion_remaster/paks/model.py b/libs/basic_games/games/oblivion_remaster/paks/model.py index c43cec0..e4bfc44 100644 --- a/libs/basic_games/games/oblivion_remaster/paks/model.py +++ b/libs/basic_games/games/oblivion_remaster/paks/model.py @@ -39,7 +39,7 @@ class PaksModel(QAbstractItemModel): profile = QDir(self._organizer.profilePath()) paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) if paks_txt.exists(): - with open(paks_txt.absoluteFilePath(), "r") as paks_file: + with open(paks_txt.absoluteFilePath(), "r", encoding="utf-8", errors="replace") as paks_file: index = 0 for line in paks_file: self.paks[index] = (line, "", "", "") diff --git a/libs/basic_games/games/oblivion_remaster/paks/widget.py b/libs/basic_games/games/oblivion_remaster/paks/widget.py index efc3038..40e21eb 100644 --- a/libs/basic_games/games/oblivion_remaster/paks/widget.py +++ b/libs/basic_games/games/oblivion_remaster/paks/widget.py @@ -52,7 +52,7 @@ class PaksTabWidget(QWidget): paks_txt = QFileInfo(profile.absoluteFilePath("paks.txt")) paks_list: list[str] = [] if paks_txt.exists(): - with open(paks_txt.absoluteFilePath(), "r") as paks_file: + with open(paks_txt.absoluteFilePath(), "r", encoding="utf-8", errors="replace") as paks_file: for line in paks_file: paks_list.append(line.strip()) return paks_list diff --git a/libs/basic_games/games/oblivion_remaster/script_extender.py b/libs/basic_games/games/oblivion_remaster/script_extender.py index 571e675..6f38c09 100644 --- a/libs/basic_games/games/oblivion_remaster/script_extender.py +++ b/libs/basic_games/games/oblivion_remaster/script_extender.py @@ -17,7 +17,7 @@ class OblivionRemasteredScriptExtender(mobase.ScriptExtender): def loaderPath(self) -> str: return ( self._game.gameDirectory().absolutePath() - + "\\OblivionRemastered\\Binaries\\Win64\\" + + "/OblivionRemastered/Binaries/Win64/" + self.loaderName() ) diff --git a/libs/basic_games/winreg.py b/libs/basic_games/winreg.py index e1303dc..2644088 100644 --- a/libs/basic_games/winreg.py +++ b/libs/basic_games/winreg.py @@ -10,5 +10,16 @@ def OpenKey(*_args, **_kwargs): raise FileNotFoundError("winreg is not available on this platform") +OpenKeyEx = OpenKey + + def QueryValueEx(*_args, **_kwargs): raise FileNotFoundError("winreg is not available on this platform") + + +def QueryInfoKey(*_args, **_kwargs): + raise FileNotFoundError("winreg is not available on this platform") + + +def EnumKey(*_args, **_kwargs): + raise FileNotFoundError("winreg is not available on this platform") |
