From 393e71eebd027b8208fc57ad5f014128b5bf15ee Mon Sep 17 00:00:00 2001 From: Saghm Rossi Date: Wed, 13 May 2026 22:25:20 -0400 Subject: Replace LsLib via proton with native larian-formats Python package --- docker/Dockerfile | 2 +- docker/build-inner.sh | 2 +- libs/basic_games/games/baldursgate3/bg3_utils.py | 11 +- .../games/baldursgate3/lslib_retriever.py | 193 ------------------ libs/basic_games/games/baldursgate3/pak_parser.py | 224 ++------------------- .../games/baldursgate3/plugins/__init__.py | 2 - .../plugins/check_for_lslib_updates_plugin.py | 15 -- libs/basic_games/plugin-requirements.txt | 1 + libs/basic_games/pyproject.toml | 1 + 9 files changed, 25 insertions(+), 426 deletions(-) delete mode 100644 libs/basic_games/games/baldursgate3/lslib_retriever.py delete mode 100644 libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py diff --git a/docker/Dockerfile b/docker/Dockerfile index 13a67d5..309d09b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -71,7 +71,7 @@ ENV PATH="/opt/python-bundled/bin:${PATH}" # ── Build-time Python packages ── # pybind11/sip/etc. for compiling mobase.so; PyQt6 is staged into the distribution. RUN /opt/python-bundled/bin/pip3 install --no-cache-dir \ - pybind11==2.13.6 sip psutil vdf "PyQt6>=6.10,<6.11" + pybind11==2.13.6 sip psutil larian-formats==0.2.0 vdf "PyQt6>=6.10,<6.11" # ── Rust toolchain ── RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ diff --git a/docker/build-inner.sh b/docker/build-inner.sh index cd6c3e1..865e0b4 100755 --- a/docker/build-inner.sh +++ b/docker/build-inner.sh @@ -289,7 +289,7 @@ rm -f "${PYTHON_OUT}/lib/python3.12/turtle.py" rm -rf "${PYTHON_OUT}/lib/python3.12/site-packages" mkdir -p "${PYTHON_OUT}/lib/python3.12/site-packages" # Copy runtime-required packages back in -for pkg in psutil vdf; do +for pkg in psutil vdf larian_formats; do pkg_dir="$("${PBS_SRC}/bin/python3" -c "import importlib.util; s=importlib.util.find_spec('${pkg}'); print(s.submodule_search_locations[0] if s and s.submodule_search_locations else (s.origin if s else ''))" 2>/dev/null || true)" if [ -d "${pkg_dir}" ]; then cp -a "${pkg_dir}" "${PYTHON_OUT}/lib/python3.12/site-packages/" diff --git a/libs/basic_games/games/baldursgate3/bg3_utils.py b/libs/basic_games/games/baldursgate3/bg3_utils.py index 958d173..bbe5cda 100644 --- a/libs/basic_games/games/baldursgate3/bg3_utils.py +++ b/libs/basic_games/games/baldursgate3/bg3_utils.py @@ -73,9 +73,8 @@ class BG3Utils: def __init__(self, name: str): self.main_window = None self._name = name - from . import lslib_retriever, pak_parser + from . import pak_parser - self.lslib_retriever = lslib_retriever.LSLibRetriever(self) self._pak_parser = pak_parser.BG3PakParser(self) def init(self, organizer: mobase.IOrganizer): @@ -197,10 +196,7 @@ class BG3Utils: args: str = "", force_reparse_metadata: bool = False, ) -> bool: - if ( - "bin/bg3" not in exec_path - or not self.lslib_retriever.download_lslib_if_missing() - ): + if "bin/bg3" not in exec_path: return True active_mods = self.active_mods() progress = self.create_progress_window( @@ -262,8 +258,7 @@ class BG3Utils: return True def on_mod_installed(self, mod: mobase.IModInterface) -> None: - if self.lslib_retriever.download_lslib_if_missing(): - self._pak_parser.get_metadata_for_files_in_mod(mod, True) + self._pak_parser.get_metadata_for_files_in_mod(mod, True) def create_dir_if_needed(path: Path) -> Path: diff --git a/libs/basic_games/games/baldursgate3/lslib_retriever.py b/libs/basic_games/games/baldursgate3/lslib_retriever.py deleted file mode 100644 index 8b0e1dc..0000000 --- a/libs/basic_games/games/baldursgate3/lslib_retriever.py +++ /dev/null @@ -1,193 +0,0 @@ -import json -import shutil -import traceback -import urllib.request -import zipfile -from functools import cached_property - -from PyQt6.QtCore import qDebug, qWarning -from PyQt6.QtWidgets import QApplication, QMessageBox - -from . import bg3_utils - - -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 self._REQUIRED_FILES | self._OPTIONAL_FILES - } - - def download_lslib_if_missing(self, force: bool = False) -> bool: - # 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) - downloaded = False - - def reporthook(block_num: int, block_size: int, total_size: int) -> None: - if total_size > 0: - progress.setValue( - min(int(block_num * block_size * 100 / total_size), 100) - ) - QApplication.processEvents() - - with urllib.request.urlopen( - "https://api.github.com/repos/Norbyte/lslib/releases/latest" - ) as response: - assets = json.loads(response.read().decode("utf-8"))["assets"][0] - zip_path = self._utils.tools_dir / assets["name"] - if not zip_path.exists(): - old_archives = list(self._utils.tools_dir.glob("*.zip")) - msg_box = QMessageBox(self._utils.main_window) - msg_box.setWindowTitle( - self._utils.tr("Baldur's Gate 3 Plugin - Missing dependencies") - ) - if old_archives: - msg_box.setText(self._utils.tr("LSLib update available.")) - else: - msg_box.setText( - self._utils.tr( - "LSLib tools are missing.\nThese are necessary for the plugin to create the load order file for BG3." - ) - ) - msg_box.addButton( - self._utils.tr("Download"), - QMessageBox.ButtonRole.DestructiveRole, - ) - exit_btn = msg_box.addButton( - self._utils.tr("Exit"), QMessageBox.ButtonRole.ActionRole - ) - msg_box.setIcon(QMessageBox.Icon.Warning) - msg_box.exec() - - if msg_box.clickedButton() == exit_btn: - if not old_archives: - err = QMessageBox(self._utils.main_window) - err.setIcon(QMessageBox.Icon.Critical) - err.setText( - "LSLib tools are required for the proper generation of the modsettings.xml file, file will not be generated" - ) - err.exec() - return False - else: - progress = self._utils.create_progress_window( - "Downloading LSLib", 100, cancelable=False - ) - urllib.request.urlretrieve( - assets["browser_download_url"], str(zip_path), reporthook - ) - progress.close() - downloaded = True - for archive in old_archives: - archive.unlink() - old_archives = [] - else: - old_archives = [] - new_msg = QMessageBox(self._utils.main_window) - new_msg.setIcon(QMessageBox.Icon.Information) - new_msg.setText( - self._utils.tr("Latest version of LSLib already downloaded!") - ) - new_msg.exec() - - except Exception as e: - qDebug(f"Download failed: {e}") - err = QMessageBox(self._utils.main_window) - err.setIcon(QMessageBox.Icon.Critical) - err.setText( - self._utils.tr( - f"Failed to download LSLib tools:\n{traceback.format_exc()}" - ) - ) - err.exec() - return False - try: - if old_archives: - zip_path = sorted(old_archives)[-1] - if old_archives or not downloaded: - dialog_message = "Ensuring all necessary LSLib files have been extracted from archive..." - win_title = "Verifying LSLib files" - else: - dialog_message = "Extracting/Updating LSLib files..." - win_title = "Extracting LSLib" - x_progress = self._utils.create_progress_window( - 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(): - 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() - # 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) - err.setIcon(QMessageBox.Icon.Critical) - err.setText( - self._utils.tr( - f"Failed to extract LSLib tools:\n{traceback.format_exc()}" - ) - ) - err.exec() - return False - return True diff --git a/libs/basic_games/games/baldursgate3/pak_parser.py b/libs/basic_games/games/baldursgate3/pak_parser.py index 533c72e..fab4c89 100644 --- a/libs/basic_games/games/baldursgate3/pak_parser.py +++ b/libs/basic_games/games/baldursgate3/pak_parser.py @@ -14,6 +14,7 @@ from typing import Callable from xml.etree import ElementTree from xml.etree.ElementTree import Element +import larian_formats from PyQt6.QtCore import ( qDebug, qInfo, @@ -39,42 +40,6 @@ class BG3PakParser: "Version64": "0", } - @cached_property - def _divine_command(self): - 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): return re.compile("Data|Script Extender|bin|Mods") @@ -113,66 +78,17 @@ class BG3PakParser: config.read(meta_ini, encoding="utf-8") try: if file.name.endswith("pak"): - meta_file = ( - self._utils.plugin_data_path - / "temp" - / "extracted_metadata" - / f"{file.name[: int(len(file.name) / 2)]}-{hashlib.md5(str(file).encode(), usedforsecurity=False).hexdigest()[:5]}.lsx" - ) - try: - if ( - not force_reparse_metadata - and config.has_section(file.name) - and ( - "override" in config[file.name].keys() - or "Folder" in config[file.name].keys() - ) - ): - return get_module_short_desc(config, file) - meta_file.parent.mkdir(parents=True, exist_ok=True) - meta_file.unlink(missing_ok=True) - out_dir = ( - str(meta_file)[:-4] if self._utils.extract_full_package else "" + if ( + not force_reparse_metadata + and config.has_section(file.name) + and ( + "override" in config[file.name].keys() + or "Folder" in config[file.name].keys() ) - can_continue = True - if self.run_divine( - f'{"extract-package" if self._utils.extract_full_package else "extract-single-file -f meta.lsx"} -d "{meta_file if not self._utils.extract_full_package else out_dir}"', - file, - ).returncode: - can_continue = False - if can_continue and self._utils.extract_full_package: - qDebug(f"archive {file} extracted to {out_dir}") - if self.run_divine( - f'convert-resources -d "{out_dir}" -i lsf -o lsx -x "*.lsf"', - out_dir, - ).returncode: - qDebug( - f"failed to convert lsf files in {out_dir} to readable lsx" - ) - extracted_meta_files = list(Path(out_dir).rglob("meta.lsx")) - if len(extracted_meta_files) == 0: - qInfo( - f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" - ) - can_continue = False - else: - shutil.copyfile( - extracted_meta_files[0], - meta_file, - ) - elif can_continue and not meta_file.exists(): - qInfo( - f"No meta.lsx files found in {file.name}, {file.name} determined to be an override mod" - ) - can_continue = False - return self.metadata_to_ini( - config, file, mod, meta_ini, can_continue, lambda: meta_file - ) - finally: - if self._utils.remove_extracted_metadata: - meta_file.unlink(missing_ok=True) - if self._utils.extract_full_package: - Path(str(meta_file)[:-4]).unlink(missing_ok=True) + ): + return get_module_short_desc(config, file) + + return self.metadata_to_ini(config, file, mod, meta_ini) elif file.is_dir(): if self._folder_pattern.search(file.name): return "" @@ -214,18 +130,13 @@ class BG3PakParser: build_pak = False if build_pak: pak_path.unlink(missing_ok=True) - if self.run_divine( - f'create-package -d "{pak_path}"', file - ).returncode: - return "" - meta_files = list(file.glob("Mods/*/meta.lsx")) + larian_formats.pack_loose_files(file, pak_path) + return self.metadata_to_ini( config, - file, + pak_path, mod, meta_ini, - len(meta_files) > 0, - lambda: meta_files[0], ) else: return "" @@ -233,68 +144,6 @@ 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]: - 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 - - 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')}" - f" returned stdout: {result.stdout}, stderr: {result.stderr}, code {result.returncode}" - ) - return result - def get_attr_value(self, root: Element, attr_id: str) -> str: default_val = self._types.get(attr_id) or "" attr = root.find(f".//attribute[@id='{attr_id}']") @@ -306,49 +155,12 @@ class BG3PakParser: file: Path, mod: mobase.IModInterface, meta_ini: Path, - condition: bool, - to_parse: Callable[[], Path], ): config[file.name] = {} - if condition: - root = ( - ElementTree.parse(to_parse()) - .getroot() - .find(".//node[@id='ModuleInfo']") - ) - if root is None: - qInfo(f"No ModuleInfo node found in meta.lsx for {mod.name()} ") - else: - section = config[file.name] - folder_name = self.get_attr_value(root, "Folder") - if file.is_dir(): - self._mod_cache[file] = ( - len(list(file.glob(f"*/{folder_name}/**"))) > 1 - or len( - list(file.glob("Public/Engine/Timeline/MaterialGroups/*")) - ) - > 0 - ) - elif file not in self._mod_cache: - # a mod which has a meta.lsx and is not an override mod meets at least one of three conditions: - # 1. it has files in Public/Engine/Timeline/MaterialGroups, or - # 2. it has files in Mods// other than the meta.lsx file, or - # 3. it has files in Public/ - result = self.run_divine( - f'list-package --use-regex -x "(/{re.escape(folder_name)}/(?!meta\\.lsx))|(Public/Engine/Timeline/MaterialGroups)"', - file, - ) - self._mod_cache[file] = ( - result.returncode == 0 and result.stdout.strip() != "" - ) - if self._mod_cache[file]: - for key in self._types: - section[key] = self.get_attr_value(root, key) - else: - qInfo(f"pak {file.name} determined to be an override mod") - section["override"] = "True" - section["Folder"] = folder_name - else: + metadata = larian_formats.get_metadata_for_file(file) + config[file.name].update({k: str(v) for k, v in metadata.items()}) + + if larian_formats.is_override(file): config[file.name]["override"] = "True" with open(meta_ini, "w+", encoding="utf-8") as f: config.write(f) diff --git a/libs/basic_games/games/baldursgate3/plugins/__init__.py b/libs/basic_games/games/baldursgate3/plugins/__init__.py index 1b69faf..f0b4b54 100644 --- a/libs/basic_games/games/baldursgate3/plugins/__init__.py +++ b/libs/basic_games/games/baldursgate3/plugins/__init__.py @@ -1,13 +1,11 @@ import mobase -from .check_for_lslib_updates_plugin import BG3ToolCheckForLsLibUpdates from .convert_jsons_to_yaml_plugin import BG3ToolConvertJsonsToYaml from .reparse_pak_metadata_plugin import BG3ToolReparsePakMetadata def createPlugins() -> list[mobase.IPluginTool]: return [ - BG3ToolCheckForLsLibUpdates(), BG3ToolReparsePakMetadata(), BG3ToolConvertJsonsToYaml(), ] diff --git a/libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py b/libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py deleted file mode 100644 index b534e8a..0000000 --- a/libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py +++ /dev/null @@ -1,15 +0,0 @@ -from .bg3_tool_plugin import BG3ToolPlugin -from .icons import download - - -class BG3ToolCheckForLsLibUpdates(BG3ToolPlugin): - icon_bytes = download - sub_name = "Check For LsLib Updates" - desc = "Check to see if there has been a new release of LSLib and create download dialog if so." - - def display(self): - from ...game_baldursgate3 import BG3Game - - game_plugin = self._organizer.managedGame() - if isinstance(game_plugin, BG3Game): - game_plugin.utils.lslib_retriever.download_lslib_if_missing(True) diff --git a/libs/basic_games/plugin-requirements.txt b/libs/basic_games/plugin-requirements.txt index f945efd..c31bc1a 100644 --- a/libs/basic_games/plugin-requirements.txt +++ b/libs/basic_games/plugin-requirements.txt @@ -2,3 +2,4 @@ psutil==5.8.0 vdf==3.4 lzokay==1.1.5 pyyaml==6.0.2 +larian-formats==0.2.0 diff --git a/libs/basic_games/pyproject.toml b/libs/basic_games/pyproject.toml index 89778ca..35c5274 100644 --- a/libs/basic_games/pyproject.toml +++ b/libs/basic_games/pyproject.toml @@ -16,6 +16,7 @@ package-mode = false [tool.poetry.dependencies] psutil = "^5.8.0" vdf = "3.4" +larian-formats = "0.2.0" lzokay = "1.1.5" pyqt6 = "6.7.0" pyyaml = "^6.0.2" -- cgit v1.3.1 From 2d9e7607f38ac77d7cc66946b3afa25d2b4da356 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Thu, 14 May 2026 19:53:59 -0500 Subject: ci: install runtime python deps inside build container The builder image is pulled from GHCR and reused across CI runs, so Dockerfile changes to its pip install line don't take effect until docker.yml is manually triggered. Move the install into the CI build step so PRs that add new python deps work without an image rebuild. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ac6839..4fa6b01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: -e FLUORINE_BUILD_COMMIT=${{ steps.channel.outputs.commit }} \ -w /src \ fluorine-builder:latest \ - bash -c 'ccache -s; bash /src/docker/build-inner.sh; status=$?; echo "=== ccache stats after build ==="; ccache -s; exit $status' + bash -c 'set -e; /opt/python-bundled/bin/pip3 install --no-cache-dir --upgrade pybind11==2.13.6 sip psutil larian-formats==0.2.0 vdf "PyQt6>=6.10,<6.11"; ccache -s; bash /src/docker/build-inner.sh; status=$?; echo "=== ccache stats after build ==="; ccache -s; exit $status' - name: Package tarball run: | -- cgit v1.3.1 From fce34782ae99291a5ffe520266db34e9d9c6a67d Mon Sep 17 00:00:00 2001 From: Saghm Rossi Date: Thu, 14 May 2026 21:14:37 -0400 Subject: skip log warning when parsing packed loose files --- libs/basic_games/games/baldursgate3/pak_parser.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/libs/basic_games/games/baldursgate3/pak_parser.py b/libs/basic_games/games/baldursgate3/pak_parser.py index fab4c89..be1bfa3 100644 --- a/libs/basic_games/games/baldursgate3/pak_parser.py +++ b/libs/basic_games/games/baldursgate3/pak_parser.py @@ -132,12 +132,19 @@ class BG3PakParser: pak_path.unlink(missing_ok=True) larian_formats.pack_loose_files(file, pak_path) - return self.metadata_to_ini( - config, - pak_path, - mod, - meta_ini, - ) + output = "" + + try: + output = self.metadata_to_ini( + config, + pak_path, + mod, + meta_ini, + ) + except: + pass + + return output else: return "" except Exception: -- cgit v1.3.1 From 63aa1c73f31b8972f15b3c89b5a8475d357d6f61 Mon Sep 17 00:00:00 2001 From: Saghm Rossi Date: Thu, 14 May 2026 21:28:45 -0400 Subject: pass correct path when packing loose files --- libs/basic_games/games/baldursgate3/pak_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/basic_games/games/baldursgate3/pak_parser.py b/libs/basic_games/games/baldursgate3/pak_parser.py index be1bfa3..f051703 100644 --- a/libs/basic_games/games/baldursgate3/pak_parser.py +++ b/libs/basic_games/games/baldursgate3/pak_parser.py @@ -130,8 +130,8 @@ class BG3PakParser: build_pak = False if build_pak: pak_path.unlink(missing_ok=True) - larian_formats.pack_loose_files(file, pak_path) + larian_formats.pack_loose_files(file.parent, pak_path) output = "" try: -- cgit v1.3.1 From 089542fa4dc43a59a08bdab06a96d399fbf6c53b Mon Sep 17 00:00:00 2001 From: Saghm Rossi Date: Sat, 16 May 2026 11:20:10 -0400 Subject: bump to larian-formats 0.8.1 Contains the following changes: * Older mod formats (v15 and v16) supported * Less strict meta.lsx parsing (empty values will use the default for the type) * zstd compression support --- docker/Dockerfile | 2 +- libs/basic_games/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 309d09b..c9952ad 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -71,7 +71,7 @@ ENV PATH="/opt/python-bundled/bin:${PATH}" # ── Build-time Python packages ── # pybind11/sip/etc. for compiling mobase.so; PyQt6 is staged into the distribution. RUN /opt/python-bundled/bin/pip3 install --no-cache-dir \ - pybind11==2.13.6 sip psutil larian-formats==0.2.0 vdf "PyQt6>=6.10,<6.11" + pybind11==2.13.6 sip psutil larian-formats==0.8.1 vdf "PyQt6>=6.10,<6.11" # ── Rust toolchain ── RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \ diff --git a/libs/basic_games/pyproject.toml b/libs/basic_games/pyproject.toml index 35c5274..f95c470 100644 --- a/libs/basic_games/pyproject.toml +++ b/libs/basic_games/pyproject.toml @@ -16,7 +16,7 @@ package-mode = false [tool.poetry.dependencies] psutil = "^5.8.0" vdf = "3.4" -larian-formats = "0.2.0" +larian-formats = "0.8.1" lzokay = "1.1.5" pyqt6 = "6.7.0" pyyaml = "^6.0.2" -- cgit v1.3.1 From 25de000982a6281e3efdd8db896c731c333e7b48 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sat, 16 May 2026 11:20:29 -0500 Subject: bg3: tolerate dangling Wine-prefix symlinks in metadata dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the BG3 metadata path (e.g. AppData/Local/Larian Studios) is a symlink left over from a previous prefix setup that points at a Steam compatdata path which no longer resolves, pathlib's mkdir(parents=True, exist_ok=True) raises FileExistsError — exist_ok only suppresses the error when the path is an actual directory, and a dangling symlink fails is_dir(). The launch crashed in bg3_file_mapper.create_mapping before any mods were deployed. Walk the path top-down on FileExistsError. For each component that's a dangling symlink, resolve its target via os.path.realpath and create the directory there so the symlink becomes valid and the original mkdir succeeds. Cross-prefix sharing (the reason these symlinks exist) is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/basic_games/games/baldursgate3/bg3_utils.py | 27 ++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/libs/basic_games/games/baldursgate3/bg3_utils.py b/libs/basic_games/games/baldursgate3/bg3_utils.py index bbe5cda..23f0a8c 100644 --- a/libs/basic_games/games/baldursgate3/bg3_utils.py +++ b/libs/basic_games/games/baldursgate3/bg3_utils.py @@ -1,4 +1,5 @@ import functools +import os import shutil import typing from pathlib import Path @@ -262,8 +263,26 @@ class BG3Utils: def create_dir_if_needed(path: Path) -> Path: - if "." not in path.name[1:]: - path.mkdir(parents=True, exist_ok=True) - else: - path.parent.mkdir(parents=True, exist_ok=True) + target = path if "." not in path.name[1:] else path.parent + _mkdir_resolving_symlinks(target) return path + + +def _mkdir_resolving_symlinks(target: Path) -> None: + try: + target.mkdir(parents=True, exist_ok=True) + return + except FileExistsError: + pass + # A component along the chain exists but is not a directory — typically a + # dangling symlink left over from a Wine prefix that points at a Steam + # compatdata path that doesn't exist yet. Walk top-down, materialize each + # dangling-symlink target as a real directory, then retry. + for component in list(reversed(target.parents)) + [target]: + if component.is_dir(): + continue + if component.is_symlink(): + real = Path(os.path.realpath(component)) + real.mkdir(parents=True, exist_ok=True) + continue + component.mkdir(exist_ok=True) -- cgit v1.3.1 From a09fb2fbefba58faa0b50eb15dd46945ca288294 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sat, 16 May 2026 11:20:42 -0500 Subject: ui: take down full process tree and unmount VFS on force-unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous force-unlock path SIGTERMed only displayPid (one tracked .exe) and called killWineserverForPrefix(winePrefix), but winePrefix was read from the root pid — a Proton wrapper that frequently lacks WINEPREFIX in its environ even though the game pid underneath has it. When that lookup returned empty, killWineserverForPrefix bailed early, wineserver stayed alive, and the rest of the Wine process tree survived. Skyrim via SKSE was the canonical failure mode: skse_loader exits early, the tracked pid becomes SkyrimSE.exe, SIGTERM hits that one pid, audio/physics workers keep running. ForceUnlocked also returned without calling afterRun(), because shouldRefresh(ForceUnlocked) returned false to "avoid racing with file updates." That gated the FUSE unmount along with the directory refresh, so the VFS mount under the game directory leaked across launches. Add killProcessTree(pid_t root) using the existing children/descendant helpers — SIGTERM the whole tree, wait briefly, SIGKILL survivors. Merge ForceUnlocked and Cancelled into a shared branch that: - resolves an effective WINEPREFIX by falling through displayPid, lastTrackedPid, and root pid until one carries the env var; - kills the descendant tree of the root pid (covers launcher .exe grandchildren that would otherwise survive); - hard-kills wineserver for the resolved prefix; - sets exitCode = 1 so afterRun()'s plugin-sync gate refuses to trust possibly-half-written Plugins.txt. Flip shouldRefresh(ForceUnlocked) to true so afterRun() runs and the FUSE VFS unmounts. The "racing with writes" concern is moot now that every Wine process is dead before we return from the branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/src/processrunner.cpp | 101 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 85 insertions(+), 16 deletions(-) diff --git a/src/src/processrunner.cpp b/src/src/processrunner.cpp index ed081e2..684ca92 100644 --- a/src/src/processrunner.cpp +++ b/src/src/processrunner.cpp @@ -511,6 +511,7 @@ pid_t findGameProcessInPrefix(const QStringList& expected, const QString& winePr void killWineserverForPrefix(const QString& winePrefix) { if (winePrefix.isEmpty()) { + log::debug("killWineserverForPrefix: skipping (no WINEPREFIX resolved)"); return; } @@ -539,6 +540,48 @@ void killWineserverForPrefix(const QString& winePrefix) } } +// SIGTERM every descendant of |root| (and root itself), wait briefly, then +// SIGKILL any survivor. Used on force-unlock so launcher .exe grandchildren +// (skse_loader → SkyrimSE.exe → audio/physics workers) all go down even when +// wineserver wasn't reachable. +void killProcessTree(pid_t root) +{ + if (root <= 0) { + return; + } + + const auto children = buildProcChildrenMap(); + auto descendants = collectDescendants(root, children); + descendants.insert(root); + + for (pid_t p : descendants) { + if (::kill(p, SIGTERM) != 0 && errno != ESRCH) { + log::debug("SIGTERM on {} failed, errno={}", p, errno); + } + } + + for (int i = 0; i < 5; ++i) { + bool anyAlive = false; + for (pid_t p : descendants) { + if (::kill(p, 0) == 0) { + anyAlive = true; + break; + } + } + if (!anyAlive) { + return; + } + QThread::msleep(100); + } + + for (pid_t p : descendants) { + if (::kill(p, 0) == 0) { + log::warn("process {} did not exit on SIGTERM, sending SIGKILL", p); + ::kill(p, SIGKILL); + } + } +} + DWORD exitCodeFromWaitStatus(int status) { if (WIFEXITED(status)) { @@ -732,20 +775,41 @@ ProcessRunner::Results waitForPid(pid_t pid, LPDWORD exitCode, break; case UILocker::ForceUnlocked: - log::debug("waiting for {} force unlocked by user", pid); - return ProcessRunner::ForceUnlocked; + case UILocker::Cancelled: { + const bool cancelled = (UILocker::Session::result() == UILocker::Cancelled); + log::debug("waiting for {} {} by user, terminating", displayPid, + cancelled ? "cancelled" : "force unlocked"); + + // The root pid (Proton wrapper) often doesn't carry WINEPREFIX in + // its environ even though the actual game process below it does. + // Fall through the known PIDs until we find one that has it set. + QString effectivePrefix = winePrefix; + for (pid_t candidate : {displayPid, lastTrackedPid, pid}) { + if (!effectivePrefix.isEmpty()) { + break; + } + if (candidate > 0) { + effectivePrefix = readProcEnvVar(candidate, "WINEPREFIX"); + } + } - case UILocker::Cancelled: - log::debug("waiting for {} cancelled by user, terminating", displayPid); - if (::kill(displayPid, SIGTERM) != 0 && errno != ESRCH) { - log::warn("failed to terminate {}, errno={}", displayPid, errno); + // Take down the whole descendant tree first — launcher .exe's + // (skse_loader, nvse_loader, f4se_loader) often exit before the real + // game does, leaving grandchildren that a single SIGTERM on + // displayPid wouldn't reach. Then SIGKILL wineserver so Proton's + // session manager can't keep the prefix open and the next launch + // starts from a clean state. + killProcessTree(pid); + killWineserverForPrefix(effectivePrefix); + + // Signal abnormal termination so afterRun()'s plugin-sync gate + // skips the prefix (we may have killed the game mid-write). + if (exitCode != nullptr) { + *exitCode = 1; } - // User clicked Unlock — they're asking us to stop waiting on the - // prefix, so also hard-kill the wineserver. Otherwise Proton's - // session manager keeps it alive for its idle timeout and the next - // launch inherits a dirty prefix. - killWineserverForPrefix(winePrefix); - return ProcessRunner::Cancelled; + return cancelled ? ProcessRunner::Cancelled + : ProcessRunner::ForceUnlocked; + } case UILocker::NoResult: default: @@ -1169,10 +1233,15 @@ bool ProcessRunner::shouldRefresh(Results r) const } case ForceUnlocked: { - // The process may still be running when the user force-unlocks. Refreshing - // in that state can race with file updates. - log::debug("process runner: not refreshing because the ui was force unlocked"); - return false; + // The ForceUnlocked branch in waitForPid has already taken down the + // game's process tree and wineserver, so by the time we're here no + // Wine process is still writing under the prefix. Run afterRun() so + // the FUSE VFS is unmounted, game-dir permissions are restored, and + // local saves are synced back. The exit code is set non-zero in that + // branch, which gates plugin sync-back (Plugins.txt may have been + // half-written when we killed the game). + log::debug("process runner: running afterRun to unmount VFS after force unlock"); + return true; } case Error: -- cgit v1.3.1