From 817e8f5cd26739c69d930d21cd9dc4c0b6e4984e Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sat, 14 Feb 2026 02:45:12 -0600 Subject: Add FUSE external mapping support, BG3/Oblivion Remastered fixes, fomod-plus and NaK integration FUSE VFS now deploys non-data-dir mod mappings (Paks, OBSE, UE4SS, etc.) via real symlinks and injects file-level data-dir mappings (plugins.txt, loadorder.txt) into the VFS tree. Fixes game launches for Oblivion Remastered (Root Builder path resolution, script extender support) and BG3 (Wine prefix documents directory, file mapper symlinks on Linux). Vendors mo2-fomod-plus plugin and NaK crate for FOMOD installer and game finder/runtime support. Co-Authored-By: Claude Opus 4.6 --- libs/basic_games/basic_game.py | 78 +++++++++++++++-- .../games/baldursgate3/bg3_file_mapper.py | 33 ++++++++ .../games/baldursgate3/lslib_retriever.py | 77 ++++++++++++----- libs/basic_games/games/baldursgate3/pak_parser.py | 97 ++++++++++++++++++++-- libs/basic_games/games/game_baldursgate3.py | 1 + libs/basic_games/games/game_oblivion_remaster.py | 4 +- .../games/oblivion_remaster/mod_data_checker.py | 2 +- .../games/oblivion_remaster/paks/model.py | 2 +- .../games/oblivion_remaster/paks/widget.py | 2 +- .../games/oblivion_remaster/script_extender.py | 2 +- libs/basic_games/winreg.py | 11 +++ 11 files changed, 267 insertions(+), 42 deletions(-) (limited to 'libs/basic_games') 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. + ``/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") -- cgit v1.3.1