Files
Jackify/jackify/backend/services/automated_prefix_registry.py
2026-04-20 20:57:23 +01:00

502 lines
24 KiB
Python

"""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")