"""Registry operations mixin for AutomatedPrefixService.""" import os import re import subprocess import logging from pathlib import Path from typing import Optional logger = logging.getLogger(__name__) class RegistryOperationsMixin: """Mixin providing Wine/Proton registry operations.""" def _update_registry_path(self, system_reg_path: str, section_name: str, path_key: str, new_path: str) -> bool: """Update a specific path value in Wine registry, preserving other entries""" if not os.path.exists(system_reg_path): return False try: # Read existing content with open(system_reg_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.readlines() in_target_section = False path_updated = False # Determine Wine drive letter based on SD card detection from jackify.backend.handlers.filesystem_handler import FileSystemHandler from jackify.backend.handlers.path_handler import PathHandler linux_path = Path(new_path) if FileSystemHandler.is_sd_card(linux_path): # SD card paths use D: drive # Strip SD card prefix using the same method as other handlers relative_sd_path_str = PathHandler._strip_sdcard_path_prefix(linux_path) wine_path = relative_sd_path_str.replace('/', '\\\\') wine_drive = "D:" logger.debug(f"SD card path detected: {new_path} -> D:\\{wine_path}") else: # Regular paths use Z: drive with full path wine_path = new_path.strip('/').replace('/', '\\\\') wine_drive = "Z:" logger.debug(f"Regular path: {new_path} -> Z:\\{wine_path}") # Update existing path if found for i, line in enumerate(lines): stripped_line = line.strip() # Case-insensitive comparison for section name (Wine registry is case-insensitive) if stripped_line.split(']')[0].lower() + ']' == section_name.lower() if ']' in stripped_line else stripped_line.lower() == section_name.lower(): in_target_section = True elif stripped_line.startswith('[') and in_target_section: in_target_section = False elif in_target_section and f'"{path_key}"' in line: lines[i] = f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n' # Add trailing backslashes path_updated = True break # Add new section if path wasn't updated if not path_updated: lines.append(f'\n{section_name}\n') lines.append(f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n') # Add trailing backslashes # Write updated content with open(system_reg_path, 'w', encoding='utf-8') as f: f.writelines(lines) return True except Exception as e: logger.error(f"Failed to update registry path: {e}") return False def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str): """Apply universal dotnet4.x compatibility registry fixes to ALL modlists. Direct file editing is preferred over `wine reg add` - faster, no Wine process overhead, and works even when Proton isn't on PATH. Falls back to subprocess wine reg add when the reg files haven't been created yet. """ try: prefix_path = os.path.join(modlist_compatdata_path, "pfx") if not os.path.exists(prefix_path): logger.warning(f"Prefix path not found: {prefix_path}") return False logger.info("Applying universal dotnet4.x compatibility registry fixes...") user_reg = os.path.join(prefix_path, "user.reg") system_reg = os.path.join(prefix_path, "system.reg") fix1 = fix2 = False # Targeted per-exe override for SkyrimSE.exe only - see modlist_wine_ops.py # for rationale. Global DllOverrides entry breaks .NET 9/10 bootstrap. if os.path.exists(user_reg): fix1 = self._reg_set_value( user_reg, "[Software\\\\Wine\\\\AppDefaults\\\\SkyrimSE.exe\\\\DllOverrides]", '"*mscoree"', '"native"', ) if os.path.exists(system_reg): fix2 = self._reg_set_value( system_reg, "[Software\\\\Microsoft\\\\.NETFramework]", '"OnlyUseLatestCLR"', "dword:00000001", ) if fix1 and fix2: logger.info("Universal dotnet4.x compatibility fixes applied via direct reg file editing") return True # Fall back to wine reg add when reg files are not present yet logger.debug("Reg files not ready; falling back to wine reg add") wine_binary = self._find_wine_binary_for_registry(modlist_compatdata_path) if not wine_binary: logger.error("Could not find Wine binary for registry fallback") return False env = os.environ.copy() env['WINEPREFIX'] = prefix_path env['WINEDEBUG'] = '-all' r1 = subprocess.run( [wine_binary, 'reg', 'add', 'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides', '/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'], env=env, capture_output=True, text=True, errors='replace', ) r2 = subprocess.run( [wine_binary, 'reg', 'add', 'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework', '/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'], env=env, capture_output=True, text=True, errors='replace', ) ok = r1.returncode == 0 and r2.returncode == 0 if ok: logger.info("Universal dotnet4.x fixes applied via wine reg add fallback") else: logger.warning("Some dotnet4.x registry fixes failed") return ok except Exception as e: logger.error(f"Failed to apply universal dotnet4.x fixes: {e}") return False def _apply_cp2077_dll_overrides(self, modlist_compatdata_path: str) -> bool: """Write CP2077 DLL overrides directly into the prefix user.reg. MO2 on Linux launches each executable through a separate Proton invocation, so WINEDLLOVERRIDES set in Steam launch options is not inherited by the game process. Writing the overrides into user.reg ensures they are always applied regardless of how the process is started. version and winmm are the entry-point DLLs for CET and Red4ext respectively. Without native,builtin for both, neither mod framework can inject into the game process and CP2077 exits immediately. """ try: user_reg = os.path.join(modlist_compatdata_path, "pfx", "user.reg") if not os.path.exists(user_reg): logger.warning("user.reg not found, cannot apply CP2077 DLL overrides") return False section = "[Software\\\\Wine\\\\DllOverrides]" overrides = [ ('"version"', '"native,builtin"'), ('"winmm"', '"native,builtin"'), ] for key, val in overrides: self._reg_set_value(user_reg, section, key, val) logger.info("Applied CP2077 DLL overrides (version, winmm) to prefix registry") return True except Exception as e: logger.error(f"Failed to apply CP2077 DLL overrides: {e}") return False @staticmethod def _wow64_counterpart(section: str) -> str: """Return the Wow6432Node counterpart for a registry section, or vice versa. NaK writes both paths for every game so both 32-bit and 64-bit lookups resolve correctly regardless of the calling process's bitness. """ low = section.lower() if "wow6432node" in low: # Strip Wow6432Node to get the 64-bit path return re.sub(r'(?i)wow6432node\\\\', '', section) else: # Insert Wow6432Node after the opening [Software\\ return re.sub(r'(?i)(\[Software\\\\)', r'\1Wow6432Node\\\\', section) def _reg_set_value(self, reg_path: str, section: str, key: str, value: str) -> bool: """Set or add a key=value pair in a Wine .reg text file.""" try: with open(reg_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.readlines() in_section = False updated = False for i, line in enumerate(lines): stripped = line.strip() if stripped.lower() == section.lower(): in_section = True elif stripped.startswith('[') and in_section: # Reached next section without finding key; insert before it lines.insert(i, f'{key}={value}\n') updated = True break elif in_section and stripped.startswith(key.lower()) or (in_section and stripped.lower().startswith(key.lower())): lines[i] = f'{key}={value}\n' updated = True break if not updated: if not in_section: lines.append(f'\n{section}\n') lines.append(f'{key}={value}\n') with open(reg_path, 'w', encoding='utf-8') as f: f.writelines(lines) return True except Exception as e: logger.debug(f"_reg_set_value failed for {reg_path}: {e}") return False def _find_wine_binary_for_registry(self, modlist_compatdata_path: str) -> Optional[str]: """Find the appropriate Wine binary for registry operations""" try: from ..handlers.config_handler import ConfigHandler from ..handlers.wine_utils import WineUtils # Method 1: Use the user's configured Proton version from settings config_handler = ConfigHandler() user_proton_path = config_handler.get_game_proton_path() if user_proton_path and user_proton_path != 'auto': # User has selected a specific Proton version proton_path = Path(user_proton_path).expanduser() # Check for wine binary in both GE-Proton and Valve Proton structures wine_candidates = [ proton_path / "files" / "bin" / "wine", # GE-Proton structure proton_path / "dist" / "bin" / "wine" # Valve Proton structure ] for wine_path in wine_candidates: if wine_path.exists() and wine_path.is_file(): logger.info(f"Using Wine binary from user's configured Proton: {wine_path}") return str(wine_path) # Wine binary not found at expected paths - search recursively in Proton directory logger.debug(f"Wine binary not found at expected paths in {proton_path}, searching recursively...") wine_binary = self._search_wine_in_proton_directory(proton_path) if wine_binary: logger.info(f"Found Wine binary via recursive search in Proton directory: {wine_binary}") return wine_binary logger.warning(f"User's configured Proton path has no wine binary: {user_proton_path}") # Method 2: Fallback to auto-detection using WineUtils best_proton = WineUtils.select_best_proton() if best_proton: wine_binary = WineUtils.find_proton_binary(best_proton['name']) if wine_binary: logger.info(f"Using Wine binary from detected Proton: {wine_binary}") return wine_binary # NEVER fall back to system wine - it will break Proton prefixes with architecture mismatches logger.error("No suitable Proton Wine binary found for registry operations") return None except Exception as e: logger.error(f"Error finding Wine binary: {e}") return None def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]: """ Recursively search for wine binary within a Proton directory. This handles cases where the directory structure might differ between Proton versions. Args: proton_path: Path to the Proton directory to search Returns: Path to wine binary if found, None otherwise """ try: if not proton_path.exists() or not proton_path.is_dir(): return None # Search for 'wine' executable (not 'wine64' or 'wine-preloader') # Limit search depth to avoid scanning entire filesystem max_depth = 5 for root, dirs, files in os.walk(proton_path, followlinks=False): # Calculate depth relative to proton_path try: depth = len(Path(root).relative_to(proton_path).parts) except ValueError: # Path is not relative to proton_path (shouldn't happen, but be safe) continue if depth > max_depth: dirs.clear() # Don't descend further continue # Check if 'wine' is in this directory if 'wine' in files: wine_path = Path(root) / 'wine' # Verify it's actually an executable file if wine_path.is_file() and os.access(wine_path, os.X_OK): logger.debug(f"Found wine binary at: {wine_path}") return str(wine_path) return None except Exception as e: logger.debug(f"Error during recursive wine search in {proton_path}: {e}") return None def _create_canonical_game_symlink(self, pfx_path: Path, real_game_path: str) -> bool: """Symlink the real game dir into the prefix at the canonical Windows Steam path. The Bethesda launcher validates that Installed Path looks like a proper Windows Steam path (C:\\Program Files...). A raw Z:\\ or D:\\ path passes the existence check on the user's own machine but fails for other users whose Wine path translation differs. By symlinking the real directory into drive_c/Program Files (x86)/Steam/steamapps/common/, we write a canonical C:\\ path to the registry that satisfies the launcher, while NVSE follows the symlink to reach the actual executable. """ try: real_path = Path(real_game_path) game_dir_name = real_path.name symlink_parent = pfx_path / "drive_c" / "Program Files (x86)" / "Steam" / "steamapps" / "common" symlink_parent.mkdir(parents=True, exist_ok=True) symlink_path = symlink_parent / game_dir_name if symlink_path.is_symlink(): symlink_path.unlink() elif symlink_path.exists(): logger.warning(f"Real directory already exists at symlink target {symlink_path}, skipping") return False symlink_path.symlink_to(real_path) logger.info(f"Created game symlink: {symlink_path} -> {real_path}") return True except Exception as e: logger.warning(f"Failed to create canonical game symlink: {e}") return False def _inject_game_registry_entries(self, modlist_compatdata_path: str, special_game_type: str): """Detect and inject FNV/FO3/Enderal game paths into the modlist prefix registry.""" system_reg_path = os.path.join(modlist_compatdata_path, "pfx", "system.reg") if not os.path.exists(system_reg_path): logger.warning("system.reg not found, skipping game path injection") return logger.info("Detecting game registry entries...") games_config = { "22380": { # Fallout New Vegas AppID "name": "Fallout New Vegas", "common_names": ["Fallout New Vegas", "FalloutNV"], "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]", "path_key": "installed path", }, "22300": { # Fallout 3 AppID "name": "Fallout 3", "common_names": ["Fallout 3", "Fallout3", "Fallout 3 GOTY"], "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]", "path_key": "installed path", }, "22370": { # Fallout 3 GOTY AppID alias "name": "Fallout 3", "common_names": ["Fallout 3 GOTY", "Fallout 3"], "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\fallout3]", "path_key": "installed path", }, "976620": { # Enderal Special Edition AppID "name": "Enderal", "common_names": ["Enderal: Forgotten Stories (Special Edition)", "Enderal Special Edition", "Enderal"], "registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]", "path_key": "installed path", }, "1091500": { # Cyberpunk 2077 AppID "name": "Cyberpunk 2077", "common_names": ["Cyberpunk 2077"], "registry_section": "[Software\\\\CD Projekt Red\\\\Cyberpunk 2077]", "path_key": "InstallFolder", }, "1086940": { # Baldur's Gate 3 AppID "name": "Baldur's Gate 3", "common_names": ["Baldur's Gate 3", "BaldursGate3"], "registry_section": "[Software\\\\Larian Studios\\\\Baldur's Gate 3]", "path_key": "InstallDir", }, "611670": { # Skyrim VR AppID (64-bit, no Wow6432Node) "name": "Skyrim VR", "common_names": ["Skyrim VR", "SkyrimVR"], "registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim VR]", "path_key": "Installed Path", }, "611660": { # Fallout 4 VR AppID (64-bit, no Wow6432Node) "name": "Fallout 4 VR", "common_names": ["Fallout 4 VR", "Fallout4VR"], "registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout 4 VR]", "path_key": "Installed Path", }, "22330": { # Oblivion AppID "name": "Oblivion", "common_names": ["Oblivion", "Elder Scrolls IV Oblivion"], "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\oblivion]", "path_key": "installed path", }, "1716740": { # Starfield AppID (64-bit, no Wow6432Node) "name": "Starfield", "common_names": ["Starfield"], "registry_section": "[Software\\\\Bethesda Softworks\\\\Starfield]", "path_key": "Installed Path", }, "489830": { # Skyrim Special Edition AppID (64-bit, no Wow6432Node) "name": "Skyrim Special Edition", "common_names": ["Skyrim Special Edition", "SkyrimSE", "Skyrim Anniversary Edition"], "registry_section": "[Software\\\\Bethesda Softworks\\\\Skyrim Special Edition]", "path_key": "Installed Path", }, "377160": { # Fallout 4 AppID (64-bit, no Wow6432Node) "name": "Fallout 4", "common_names": ["Fallout 4", "Fallout4"], "registry_section": "[Software\\\\Bethesda Softworks\\\\Fallout4]", "path_key": "Installed Path", }, "22320": { # Morrowind AppID (32-bit, Wow6432Node) "name": "Morrowind", "common_names": ["Morrowind", "Elder Scrolls III Morrowind"], "registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\morrowind]", "path_key": "Installed Path", }, "292030": { # The Witcher 3 AppID (64-bit, no Wow6432Node) "name": "The Witcher 3", "common_names": ["The Witcher 3", "Witcher 3", "The Witcher 3 Wild Hunt"], "registry_section": "[Software\\\\CD Projekt Red\\\\The Witcher 3]", "path_key": "InstallFolder", }, "2623190": { # Oblivion Remastered AppID (64-bit UE5, no Wow6432Node) "name": "Oblivion Remastered", "common_names": ["Oblivion Remastered", "OblivionRemastered"], "registry_section": "[Software\\\\Bethesda Softworks\\\\Oblivion Remastered]", "path_key": "Installed Path", }, } pfx_path = Path(modlist_compatdata_path) / "pfx" for app_id, config in games_config.items(): game_path = self._find_steam_game(app_id, config["common_names"]) if not game_path: logger.debug(f"{config['name']} not found in Steam libraries") continue logger.info(f"Detected {config['name']} at: {game_path}") # Create a symlink inside the prefix at the canonical Windows Steam path so the # Bethesda launcher sees a proper C:\ path while NVSE can still resolve the exe. symlink_ok = self._create_canonical_game_symlink(pfx_path, game_path) if symlink_ok: game_dir_name = Path(game_path).name canonical_win_path = f"C:\\Program Files (x86)\\Steam\\steamapps\\common\\{game_dir_name}" wine_val = canonical_win_path.replace("\\", "\\\\") + "\\\\" key = f'"{config["path_key"]}"' val = f'"{wine_val}"' success = self._reg_set_value(system_reg_path, config["registry_section"], key, val) self._reg_set_value(system_reg_path, self._wow64_counterpart(config["registry_section"]), key, val) if success: logger.info(f"Registry set to canonical path for {config['name']}: {canonical_win_path}") else: logger.warning(f"Failed to set canonical registry path for {config['name']}") else: # Symlink failed - fall back to writing the real Z:/D: path logger.warning(f"Symlink failed for {config['name']}, writing real path to registry") success = self._update_registry_path( system_reg_path, config["registry_section"], config["path_key"], game_path ) self._update_registry_path( system_reg_path, self._wow64_counterpart(config["registry_section"]), config["path_key"], game_path ) if success: logger.info(f"Updated registry entry for {config['name']} (real path fallback)") else: logger.warning(f"Failed to update registry entry for {config['name']}") logger.info("Game registry injection completed")