diff options
Diffstat (limited to 'libs/basic_games/games')
9 files changed, 186 insertions, 34 deletions
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() ) |
