Files
Jackify/jackify/backend/services/automated_prefix_registry.py
2026-02-25 20:54:28 +00:00

388 lines
18 KiB
Python

"""Registry operations mixin for AutomatedPrefixService."""
import os
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
if os.path.exists(user_reg):
fix1 = self._reg_set_value(
user_reg,
"[Software\\\\Wine\\\\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\\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 _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",
},
}
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("\\", "\\\\") + "\\\\"
success = self._reg_set_value(
system_reg_path,
config["registry_section"],
f'"{config["path_key"]}"',
f'"{wine_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
)
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")