""" Tool compatibility configuration service. Applies Wine registry settings required for modding tools to work correctly on Linux. Applied automatically during prefix setup and available as a standalone operation for existing prefixes. Based on research into NaK's registry configuration (external reference only). """ import logging import os import subprocess import tempfile import urllib.request from pathlib import Path from typing import Callable, Optional logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Registry content # --------------------------------------------------------------------------- # xEdit family executables that require WinXP compatibility mode. # Wine's default Windows version causes xEdit to fail on certain operations. _XEDIT_EXECUTABLES = [ "SSEEdit.exe", "SSEEdit64.exe", "FO4Edit.exe", "FO4Edit64.exe", "TES4Edit.exe", "TES4Edit64.exe", "xEdit64.exe", "SF1Edit64.exe", "FNVEdit.exe", "FNVEdit64.exe", "xFOEdit.exe", "xFOEdit64.exe", "xSFEEdit.exe", "xSFEEdit64.exe", "xTESEdit.exe", "xTESEdit64.exe", "FO3Edit.exe", "FO3Edit64.exe", ] # DLL overrides applied to the prefix globally. # All set to native,builtin so game/tool-provided DLLs take priority. _DLL_OVERRIDES = [ "dwrite", "winmm", "version", "dxgi", "dbghelp", "d3d12", "wininet", "winhttp", "dinput", "dinput8", ] def _build_reg_content() -> str: lines = ["Windows Registry Editor Version 5.00", ""] # xEdit WinXP compatibility for exe in _XEDIT_EXECUTABLES: lines.append(f"[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\{exe}]") lines.append('"Version"="winxp"') lines.append("") # Pandora Behaviour Engine - decorated window causes UI glitches on Linux lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\Pandora Behaviour Engine+.exe\\X11 Driver]") lines.append('"Decorated"="N"') lines.append("") # Skyrim SE / SKSE game process needs native mscoree to load dotnet4 correctly. # Scoped to SkyrimSE.exe only so it does not interfere with .NET 9/10 tools # (Synthesis, SDK host) that run in the same prefix. lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides]") lines.append('"*mscoree"="native"') lines.append("") # Prevent Wine windows from stealing keyboard focus via WM_TAKE_FOCUS. # Without this, each Wine subprocess launched during winetricks installs # briefly grabs X11 focus (via XWayland), interrupting whatever the user # is typing in other applications. lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\X11 Driver]") lines.append('"UseTakeFocus"="N"') lines.append("") # Global DLL overrides lines.append("[HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides]") for dll in _DLL_OVERRIDES: lines.append(f'"{dll}"="native,builtin"') lines.append("") return "\r\n".join(lines) # .NET 9 SDK - direct installer, not available via winetricks. # Synthesis runs on .NET 9; the SDK (not just runtime) is required for patcher compilation. # Versions match Fluorine's confirmed-working prefix configuration. _DOTNET9_SDK_URL = "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.310/dotnet-sdk-9.0.310-win-x64.exe" _DOTNET9_SDK_FILENAME = "dotnet-sdk-9.0.310-win-x64.exe" # .NET Desktop Runtime 10 - provides NETCore.App + WindowsDesktop.App 10.0.2. # Covers Synthesis patchers targeting .NET 10 runtime. _DOTNET10_DESKTOP_URL = "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.2/windowsdesktop-runtime-10.0.2-win-x64.exe" _DOTNET10_DESKTOP_FILENAME = "windowsdesktop-runtime-10.0.2-win-x64.exe" # DigiCert Universal Root CA - required for NuGet package signature validation. # Without this, dotnet fails to verify NuGet package signatures when Synthesis # compiles patchers. Imported into the Wine prefix Windows cert store so no # system-level changes are needed. _DIGICERT_CERT_URL = "https://cacerts.digicert.com/DigiCertTrustedRootG4.crt.pem" _DIGICERT_CERT_FILENAME = "DigiCertTrustedRootG4.crt.pem" # fxc2 build of d3dcompiler_47 - required for Community Shaders shader compilation. # The winetricks-provided d3dcompiler_47 lacks support for certain shader models # used by Community Shaders, causing "failed shaders" during compilation. _FXC2_D3DCOMPILER_URL = "https://github.com/mozilla/fxc2/raw/master/dll/d3dcompiler_47.dll" _FXC2_D3DCOMPILER_FILENAME = "fxc2_d3dcompiler_47.dll" def _install_dotnet9_sdk( prefix_path: Path, wine_bin: str, log: Callable[[str], None], ) -> bool: """ Download and install the .NET 9 SDK into the Wine prefix. Cached to avoid re-downloading on subsequent runs. """ try: from jackify.shared.paths import get_jackify_data_dir cache_dir = get_jackify_data_dir() / "cache" cache_dir.mkdir(parents=True, exist_ok=True) installer = cache_dir / _DOTNET9_SDK_FILENAME if not installer.exists(): log(f"Downloading .NET 9 SDK ({_DOTNET9_SDK_FILENAME})...") urllib.request.urlretrieve(_DOTNET9_SDK_URL, installer) log(".NET 9 SDK downloaded") else: log(".NET 9 SDK installer already cached, skipping download") log("Installing .NET 9 SDK (this may take a few minutes)...") env = os.environ.copy() env["WINEPREFIX"] = str(prefix_path) env["WINEDEBUG"] = "-all" env["WINEDLLOVERRIDES"] = "mshtml=d;winemenubuilder.exe=d" env["DISPLAY"] = env.get("DISPLAY", ":0") result = subprocess.run( [wine_bin, str(installer), "/install", "/quiet", "/norestart"], env=env, capture_output=True, text=True, timeout=600, ) if result.returncode not in (0, 3010): # 3010 = success, reboot required log(f".NET 9 SDK installer exited with code {result.returncode}") return False log(".NET 9 SDK installed successfully") return True except Exception as e: log(f"Failed to install .NET 9 SDK: {e}") return False def _install_dotnet10_desktop_runtime( prefix_path: Path, wine_bin: str, log: Callable[[str], None], ) -> bool: """ Download and install the .NET Desktop Runtime 10 into the Wine prefix. Provides NETCore.App and WindowsDesktop.App 10.x for patchers targeting .NET 10. """ try: from jackify.shared.paths import get_jackify_data_dir cache_dir = get_jackify_data_dir() / "cache" cache_dir.mkdir(parents=True, exist_ok=True) installer = cache_dir / _DOTNET10_DESKTOP_FILENAME if not installer.exists(): log(f"Downloading .NET Desktop Runtime 10 ({_DOTNET10_DESKTOP_FILENAME})...") urllib.request.urlretrieve(_DOTNET10_DESKTOP_URL, installer) log(".NET Desktop Runtime 10 downloaded") else: log(".NET Desktop Runtime 10 already cached, skipping download") log("Installing .NET Desktop Runtime 10...") env = os.environ.copy() env["WINEPREFIX"] = str(prefix_path) env["WINEDEBUG"] = "-all" env["WINEDLLOVERRIDES"] = "mshtml=d;winemenubuilder.exe=d" env["DISPLAY"] = env.get("DISPLAY", ":0") result = subprocess.run( [wine_bin, str(installer), "/install", "/quiet", "/norestart"], env=env, capture_output=True, text=True, timeout=300, ) if result.returncode not in (0, 3010): log(f".NET Desktop Runtime 10 installer exited with code {result.returncode}") return False log(".NET Desktop Runtime 10 installed successfully") return True except Exception as e: log(f"Failed to install .NET Desktop Runtime 10: {e}") return False def _install_nuget_cert( prefix_path: Path, wine_bin: str, log: Callable[[str], None], ) -> bool: """ Import the DigiCert Trusted Root G4 CA into the Wine prefix Windows cert store. Required for NuGet package signature validation when Synthesis compiles patchers. Uses wine certutil so no system-level changes are needed. """ try: from jackify.shared.paths import get_jackify_data_dir cache_dir = get_jackify_data_dir() / "cache" cache_dir.mkdir(parents=True, exist_ok=True) cert_file = cache_dir / _DIGICERT_CERT_FILENAME if not cert_file.exists(): log(f"Downloading DigiCert Trusted Root G4 certificate...") urllib.request.urlretrieve(_DIGICERT_CERT_URL, cert_file) log("Certificate downloaded") else: log("DigiCert certificate already cached, skipping download") log("Importing certificate into Wine prefix cert store...") env = os.environ.copy() env["WINEPREFIX"] = str(prefix_path) env["WINEDEBUG"] = "-all" env["WINEDLLOVERRIDES"] = "winemenubuilder.exe=d" env["DISPLAY"] = env.get("DISPLAY", ":0") result = subprocess.run( [wine_bin, "certutil", "-addstore", "Root", str(cert_file)], env=env, capture_output=True, text=True, timeout=60, ) if result.returncode != 0: log(f"certutil exited with code {result.returncode} (may already be installed)") else: log("DigiCert certificate imported into Wine cert store") return True except Exception as e: log(f"Failed to install NuGet certificate: {e}") return False def _install_fxc2_d3dcompiler( prefix_path: Path, log: Callable[[str], None], ) -> bool: """ Replace the winetricks-installed d3dcompiler_47.dll with the Mozilla fxc2 build, which supports shader models required by Community Shaders. Applies to both system32 (64-bit) and syswow64 (32-bit) locations. """ try: from jackify.shared.paths import get_jackify_data_dir cache_dir = get_jackify_data_dir() / "cache" cache_dir.mkdir(parents=True, exist_ok=True) cached_dll = cache_dir / _FXC2_D3DCOMPILER_FILENAME if not cached_dll.exists(): log("Downloading fxc2 d3dcompiler_47.dll...") urllib.request.urlretrieve(_FXC2_D3DCOMPILER_URL, cached_dll) log("fxc2 d3dcompiler_47.dll downloaded") else: log("fxc2 d3dcompiler_47.dll already cached, skipping download") import shutil targets = [ prefix_path / "drive_c" / "windows" / "system32" / "d3dcompiler_47.dll", prefix_path / "drive_c" / "windows" / "syswow64" / "d3dcompiler_47.dll", ] for target in targets: if target.parent.exists(): shutil.copy2(cached_dll, target) log(f"Installed fxc2 d3dcompiler_47.dll -> {target.parent.name}") return True except Exception as e: log(f"Failed to install fxc2 d3dcompiler_47.dll (non-fatal): {e}") return False def _set_windows_version_win11( prefix_path: Path, wine_bin: str, log: Callable[[str], None], ) -> None: """ Set the Wine prefix Windows version to Windows 11. Matches Fluorine's prefix configuration; required for .NET 9/10 to run correctly. winetricks components may leave the prefix at a lower version. """ try: from pathlib import Path as _Path module_dir = _Path(__file__).parent.parent.parent winetricks_bin = str(module_dir / "tools" / "winetricks") if not os.path.exists(winetricks_bin): appdir = os.environ.get("APPDIR", "") if appdir: winetricks_bin = os.path.join(appdir, "opt", "jackify", "tools", "winetricks") if not os.path.exists(winetricks_bin): log("Bundled winetricks not found - skipping Windows version update") return log("Setting Windows version to Windows 11...") env = os.environ.copy() env["WINEPREFIX"] = str(prefix_path) env["WINE"] = wine_bin env["WINEDEBUG"] = "-all" env["DISPLAY"] = env.get("DISPLAY", ":0") result = subprocess.run( [winetricks_bin, "-q", "win11"], env=env, capture_output=True, text=True, timeout=60, ) if result.returncode != 0: log(f"winetricks win11 exited with code {result.returncode} (non-fatal)") else: log("Windows version set to Windows 11") except subprocess.TimeoutExpired: log("winetricks win10 timed out (non-fatal)") except Exception as e: log(f"Failed to set Windows version: {e} (non-fatal)") # --------------------------------------------------------------------------- # Application # --------------------------------------------------------------------------- def apply_tool_config( compatdata_path: str, wine_bin: str, log: Optional[Callable[[str], None]] = None, install_dotnet9_sdk: bool = False, install_fxc2_d3dcompiler: bool = False, ) -> bool: """ Apply tool compatibility settings to the Wine prefix. install_dotnet9_sdk=True downloads and installs the .NET 9/10 SDK, which is required for Synthesis. Intentionally opt-in - the download is ~220MB and only appropriate when the user explicitly runs Configure Tool Compatibility from Additional Tasks. install_fxc2_d3dcompiler=True replaces d3dcompiler_47.dll with the Mozilla fxc2 build. Only appropriate for Skyrim SE/AE modlists using Community Shaders. Returns True if registry settings applied successfully (dotnet SDK install failures are non-fatal since the registry settings still have value). """ def _log(msg: str): logger.info(msg) if log: log(msg) prefix_path = Path(compatdata_path) / "pfx" if not prefix_path.exists(): _log(f"Wine prefix not found at {prefix_path}") return False if install_fxc2_d3dcompiler: _install_fxc2_d3dcompiler(prefix_path, _log) if install_dotnet9_sdk: _install_dotnet9_sdk(prefix_path, wine_bin, _log) _install_dotnet10_desktop_runtime(prefix_path, wine_bin, _log) _install_nuget_cert(prefix_path, wine_bin, _log) _set_windows_version_win11(prefix_path, wine_bin, _log) # Remove legacy global *mscoree=native from DllOverrides if present. # Old installs wrote this globally, which breaks .NET 9/10 bootstrap (Synthesis). # The targeted AppDefaults\SkyrimSE.exe entry written below replaces it. try: env_clean = os.environ.copy() env_clean["WINEPREFIX"] = str(prefix_path) env_clean["WINEDEBUG"] = "-all" env_clean["DISPLAY"] = env_clean.get("DISPLAY", ":0") subprocess.run( [wine_bin, "reg", "delete", "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", "/v", "*mscoree", "/f"], env=env_clean, capture_output=True, text=True, timeout=15, ) _log("Removed legacy global *mscoree override (if present)") except Exception as e: _log(f"Note: could not remove legacy mscoree entry (non-fatal): {e}") reg_content = _build_reg_content() try: with tempfile.NamedTemporaryFile( mode="w", suffix=".reg", delete=False, encoding="utf-8" ) as tf: tf.write(reg_content) reg_file = tf.name _log("Applying tool compatibility registry settings...") env = os.environ.copy() env["WINEPREFIX"] = str(prefix_path) env["WINEDEBUG"] = "-all" env["DISPLAY"] = env.get("DISPLAY", ":0") result = subprocess.run( [wine_bin, "regedit", reg_file], env=env, capture_output=True, text=True, timeout=30, ) if result.returncode != 0: _log(f"wine regedit exited with code {result.returncode}: {result.stderr[:200]}") return False _log(f"Tool compatibility settings applied ({len(_XEDIT_EXECUTABLES)} xEdit variants, Pandora, {len(_DLL_OVERRIDES)} DLL overrides)") return True except subprocess.TimeoutExpired: _log("wine regedit timed out after 30 seconds") return False except Exception as e: _log(f"Failed to apply tool config: {e}") return False finally: try: os.unlink(reg_file) except Exception: pass def setup_nemesis_compatibility( modlist_dir: str, stock_game_path: Optional[str], log: Optional[Callable[[str], None]] = None, ) -> None: """ Prepare Nemesis Unlimited Behavior Engine to run correctly on Linux. Two issues affect Nemesis under Wine/MO2 on Linux: 1. Nemesis resolves a relative `mods` path against the filesystem root, causing a "cannot access /mods" error. Symlinking Nemesis_Engine from the mod directory into the real Data directory fixes this. 2. A non-blank "Start In" (workingDirectory) in ModOrganizer.ini causes Nemesis to hang. Blank it out for the Nemesis executable entry. Non-fatal - logs failures but does not raise. """ def _log(msg: str): logger.info(msg) if log: log(msg) modlist_path = Path(modlist_dir) mods_dir = modlist_path / "mods" if not mods_dir.is_dir(): _log("Nemesis setup: mods directory not found, skipping") return # Find the Nemesis_Engine directory inside the mods tree nemesis_engine_src: Optional[Path] = None try: for mod_dir in mods_dir.iterdir(): candidate = mod_dir / "Nemesis_Engine" if candidate.is_dir(): nemesis_engine_src = candidate break except Exception as e: _log(f"Nemesis setup: error scanning mods directory: {e}") return if nemesis_engine_src is None: _log("Nemesis setup: Nemesis_Engine not found in mods - modlist may not include Nemesis") return # Create symlink in Data/ so Nemesis can find its engine at a predictable path if stock_game_path: data_dir = Path(stock_game_path) / "Data" try: data_dir.mkdir(parents=True, exist_ok=True) symlink_path = data_dir / "Nemesis_Engine" if symlink_path.is_symlink(): existing_target = symlink_path.resolve() if existing_target == nemesis_engine_src.resolve(): _log("Nemesis setup: symlink already correct, skipping") else: symlink_path.unlink() symlink_path.symlink_to(nemesis_engine_src) _log(f"Nemesis setup: updated symlink at {symlink_path}") elif symlink_path.exists(): _log(f"Nemesis setup: {symlink_path} exists and is not a symlink - leaving it alone") else: symlink_path.symlink_to(nemesis_engine_src) _log(f"Nemesis setup: created symlink {symlink_path} -> {nemesis_engine_src}") except Exception as e: _log(f"Nemesis setup: failed to create symlink: {e}") else: _log("Nemesis setup: no stock game path available - skipping symlink") # Blank workingDirectory for the Nemesis executable in ModOrganizer.ini mo2_ini = modlist_path / "ModOrganizer.ini" if not mo2_ini.is_file(): _log("Nemesis setup: ModOrganizer.ini not found, skipping workingDirectory fix") return try: content = mo2_ini.read_text(encoding="utf-8") except Exception as e: _log(f"Nemesis setup: could not read ModOrganizer.ini: {e}") return import re # Find all executable indices whose binary points to Nemesis nemesis_indices = re.findall( r'^(\d+)\\binary=.*Nemesis Unlimited Behavior Engine\.exe', content, re.MULTILINE | re.IGNORECASE, ) if not nemesis_indices: _log("Nemesis setup: no Nemesis executable entry found in ModOrganizer.ini") return modified = content changed = 0 for idx in nemesis_indices: # Replace non-blank workingDirectory for this index pattern = rf'^({re.escape(idx)}\\workingDirectory=).+$' replacement = rf'\g<1>' new_content, n = re.subn(pattern, replacement, modified, flags=re.MULTILINE) if n: modified = new_content changed += n if changed: try: mo2_ini.write_text(modified, encoding="utf-8") _log(f"Nemesis setup: blanked workingDirectory for {len(nemesis_indices)} Nemesis executable entry(s) in ModOrganizer.ini") except Exception as e: _log(f"Nemesis setup: failed to write ModOrganizer.ini: {e}") else: _log("Nemesis setup: workingDirectory already blank for all Nemesis entries") def apply_tool_config_for_appid( appid: str, log: Optional[Callable[[str], None]] = None, install_dotnet9_sdk: bool = True, ) -> bool: """ Resolve compatdata path and wine binary from an AppID, then apply tool config. Convenience wrapper for the standalone Additional Tasks flow. """ def _log(msg: str): logger.info(msg) if log: log(msg) try: from jackify.backend.handlers.wine_utils_proton import WineUtilsProtonMixin compatdata_path, _, wine_bin = WineUtilsProtonMixin.get_proton_paths(appid) except Exception as e: _log(f"Could not resolve Proton paths for AppID {appid}: {e}") return False if not compatdata_path or not wine_bin: _log(f"Could not resolve Wine prefix for AppID {appid}. Is this modlist configured in Steam?") return False return apply_tool_config(compatdata_path, wine_bin, log, install_dotnet9_sdk=install_dotnet9_sdk, install_fxc2_d3dcompiler=True)