diff options
| author | SulfurNitride <lukew19@proton.me> | 2026-05-16 12:56:34 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-16 12:56:34 -0500 |
| commit | fa0579c7f5703aba9669f7f41900c67e9759fca0 (patch) | |
| tree | f68c7674a3d433d6f9233ad9520a41a634c6bad7 | |
| parent | 95a7f6bc85ec5695388def1ccfd785a631d3958b (diff) | |
| parent | a09fb2fbefba58faa0b50eb15dd46945ca288294 (diff) | |
Merge pull request #78 from saghm/larian-formats
Replace LsLib via proton with native larian-formats Python package
| -rw-r--r-- | .github/workflows/ci.yml | 2 | ||||
| -rw-r--r-- | docker/Dockerfile | 2 | ||||
| -rwxr-xr-x | docker/build-inner.sh | 2 | ||||
| -rw-r--r-- | libs/basic_games/games/baldursgate3/bg3_utils.py | 38 | ||||
| -rw-r--r-- | libs/basic_games/games/baldursgate3/lslib_retriever.py | 193 | ||||
| -rw-r--r-- | libs/basic_games/games/baldursgate3/pak_parser.py | 241 | ||||
| -rw-r--r-- | libs/basic_games/games/baldursgate3/plugins/__init__.py | 2 | ||||
| -rw-r--r-- | libs/basic_games/games/baldursgate3/plugins/check_for_lslib_updates_plugin.py | 15 | ||||
| -rw-r--r-- | libs/basic_games/plugin-requirements.txt | 1 | ||||
| -rw-r--r-- | libs/basic_games/pyproject.toml | 1 | ||||
| -rw-r--r-- | src/src/processrunner.cpp | 101 |
11 files changed, 146 insertions, 452 deletions
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: | diff --git a/docker/Dockerfile b/docker/Dockerfile index 13a67d5..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 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/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..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 @@ -73,9 +74,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 +197,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,13 +259,30 @@ 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: - 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) 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..f051703 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, @@ -40,42 +41,6 @@ class BG3PakParser: } @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,87 +130,27 @@ 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")) - return self.metadata_to_ini( - config, - file, - mod, - meta_ini, - len(meta_files) > 0, - lambda: meta_files[0], - ) + + larian_formats.pack_loose_files(file.parent, pak_path) + output = "" + + try: + output = self.metadata_to_ini( + config, + pak_path, + mod, + meta_ini, + ) + except: + pass + + return output else: return "" except Exception: 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 +162,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/<folder_name>/ other than the meta.lsx file, or - # 3. it has files in Public/<folder_name> - 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..f95c470 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.8.1" lzokay = "1.1.5" pyqt6 = "6.7.0" pyyaml = "^6.0.2" 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: |
