aboutsummaryrefslogtreecommitdiff
path: root/libs/basic_games
diff options
context:
space:
mode:
Diffstat (limited to 'libs/basic_games')
-rw-r--r--libs/basic_games/basic_game.py78
-rw-r--r--libs/basic_games/games/baldursgate3/bg3_file_mapper.py33
-rw-r--r--libs/basic_games/games/baldursgate3/lslib_retriever.py77
-rw-r--r--libs/basic_games/games/baldursgate3/pak_parser.py97
-rw-r--r--libs/basic_games/games/game_baldursgate3.py1
-rw-r--r--libs/basic_games/games/game_oblivion_remaster.py4
-rw-r--r--libs/basic_games/games/oblivion_remaster/mod_data_checker.py2
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/model.py2
-rw-r--r--libs/basic_games/games/oblivion_remaster/paks/widget.py2
-rw-r--r--libs/basic_games/games/oblivion_remaster/script_extender.py2
-rw-r--r--libs/basic_games/winreg.py11
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")