mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-07 20:27:45 +02:00
791 lines
36 KiB
Python
791 lines
36 KiB
Python
"""Wine/Proton operation methods for ModlistHandler (Mixin)."""
|
|
from pathlib import Path
|
|
from typing import Tuple, Optional, List
|
|
import os
|
|
import logging
|
|
import subprocess
|
|
import shutil
|
|
import time
|
|
import vdf
|
|
import json
|
|
import configparser
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ModlistWineOpsMixin:
|
|
"""Mixin providing Wine and Proton operation methods for ModlistHandler."""
|
|
|
|
def verify_proton_setup(self, appid_to_check: str) -> Tuple[bool, str]:
|
|
"""Verifies that Proton is correctly set up for a given AppID.
|
|
|
|
Checks config.vdf for Proton Experimental and existence of compatdata/pfx dir.
|
|
|
|
Args:
|
|
appid_to_check: The AppID string to verify.
|
|
|
|
Returns:
|
|
tuple: (bool success, str status_code)
|
|
Status codes: 'ok', 'invalid_appid', 'config_vdf_missing',
|
|
'config_vdf_error', 'proton_check_failed',
|
|
'wrong_proton_version', 'compatdata_missing',
|
|
'prefix_missing'
|
|
"""
|
|
self.logger.info(f"Verifying Proton setup for AppID: {appid_to_check}")
|
|
|
|
if not appid_to_check or not appid_to_check.isdigit():
|
|
self.logger.error("Invalid AppID provided for verification.")
|
|
return False, 'invalid_appid'
|
|
|
|
proton_tool_name = None
|
|
compatdata_path_found = None
|
|
prefix_exists = False
|
|
|
|
# 1. Find and Parse config.vdf
|
|
config_vdf_path = None
|
|
possible_steam_paths = [
|
|
Path.home() / ".steam/steam",
|
|
Path.home() / ".local/share/Steam",
|
|
Path.home() / ".steam/root"
|
|
]
|
|
for steam_path in possible_steam_paths:
|
|
potential_path = steam_path / "config/config.vdf"
|
|
if potential_path.is_file():
|
|
config_vdf_path = potential_path
|
|
self.logger.debug(f"Found config.vdf at: {config_vdf_path}")
|
|
break
|
|
|
|
if not config_vdf_path:
|
|
self.logger.error("Could not locate Steam's config.vdf file.")
|
|
return False, 'config_vdf_missing'
|
|
|
|
try:
|
|
self.logger.debug(f"Loading config.vdf: {config_vdf_path}")
|
|
with open(str(config_vdf_path), 'r') as f:
|
|
config_data = vdf.load(f, mapper=vdf.VDFDict)
|
|
|
|
# Navigate the structure: Software -> Valve -> Steam -> CompatToolMapping -> appid_to_check -> Name
|
|
compat_mapping = steam_config_section.get('CompatToolMapping', {})
|
|
app_mapping = compat_mapping.get(appid_to_check, {})
|
|
proton_tool_name = app_mapping.get('name') # CORRECTED: Use lowercase 'name'
|
|
self.proton_ver = proton_tool_name # Store detected version
|
|
|
|
if proton_tool_name:
|
|
self.logger.info(f"Proton tool name from config.vdf: {proton_tool_name}")
|
|
else:
|
|
self.logger.warning(f"CompatToolMapping entry not found for AppID {appid_to_check} in config.vdf.")
|
|
# Add more debug info here about what *was* found
|
|
self.logger.debug(f"CompatToolMapping contents: {json.dumps(compat_mapping.get(appid_to_check, 'Key not found'), indent=2)}")
|
|
return False, 'proton_check_failed' # Compatibility not explicitly set
|
|
|
|
except FileNotFoundError:
|
|
self.logger.error(f"Config.vdf file not found during load attempt: {config_vdf_path}")
|
|
return False, 'config_vdf_missing'
|
|
except Exception as e:
|
|
self.logger.error(f"Error parsing config.vdf: {e}", exc_info=True)
|
|
return False, 'config_vdf_error'
|
|
|
|
# 2. Check if the correct Proton version is set (allowing variations)
|
|
# Target: Proton Experimental
|
|
if not proton_tool_name or 'experimental' not in proton_tool_name.lower():
|
|
self.logger.warning(f"Incorrect Proton version detected: '{proton_tool_name}'. Expected 'Proton Experimental'.")
|
|
return False, 'wrong_proton_version'
|
|
|
|
self.logger.info("Proton version check passed ('Proton Experimental' set).")
|
|
|
|
# 3. Check for compatdata / prefix directory existence
|
|
possible_compat_bases = [
|
|
Path.home() / ".steam/steam/steamapps/compatdata",
|
|
Path.home() / ".local/share/Steam/steamapps/compatdata",
|
|
# Add SD card paths if necessary / detectable
|
|
# Path("/run/media/mmcblk0p1/steamapps/compatdata") # Example
|
|
]
|
|
|
|
compat_dir_found = False
|
|
for base_path in possible_compat_bases:
|
|
potential_compat_path = base_path / appid_to_check
|
|
if potential_compat_path.is_dir():
|
|
self.logger.debug(f"Found compatdata directory: {potential_compat_path}")
|
|
compat_dir_found = True
|
|
# Check for prefix *within* the found compatdata dir
|
|
prefix_path = potential_compat_path / "pfx"
|
|
if prefix_path.is_dir():
|
|
self.logger.info(f"Wine prefix directory verified: {prefix_path}")
|
|
prefix_exists = True
|
|
break # Found both compatdata and prefix, exit loop
|
|
else:
|
|
self.logger.warning(f"Compatdata directory found, but prefix missing: {prefix_path}")
|
|
# Keep searching other base paths in case prefix exists elsewhere
|
|
|
|
if not compat_dir_found:
|
|
self.logger.error(f"Compatdata directory not found for AppID {appid_to_check} in standard locations.")
|
|
return False, 'compatdata_missing'
|
|
|
|
if not prefix_exists:
|
|
# Found compatdata but no pfx inside any of them
|
|
self.logger.error(f"Wine prefix directory (pfx) not found within any located compatdata directory for AppID {appid_to_check}.")
|
|
return False, 'prefix_missing'
|
|
|
|
# All checks passed
|
|
self.logger.info(f"Proton setup verification successful for AppID {appid_to_check}.")
|
|
return True, 'ok'
|
|
|
|
def set_steam_grid_images(self, appid: str, modlist_dir: str, game_type: str = None):
|
|
"""
|
|
Copies artwork from the modlist's SteamIcons directory to Steam's grid folder.
|
|
Falls back to SteamGridDB if no SteamIcons directory is present and an API key
|
|
is configured.
|
|
"""
|
|
if modlist_dir:
|
|
try:
|
|
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
|
|
detected_game_type = detect_game_type_from_modlist(modlist_dir)
|
|
if detected_game_type:
|
|
game_type = detected_game_type
|
|
except Exception as e:
|
|
self.logger.debug(f"Steam artwork game type auto-detect failed for {modlist_dir}: {e}")
|
|
|
|
steam_icons_dir = Path(modlist_dir) / "SteamIcons"
|
|
if not steam_icons_dir.is_dir():
|
|
self._try_steamgriddb_artwork(appid, game_type, modlist_dir)
|
|
return
|
|
|
|
# Find all non-zero Steam user directories
|
|
userdata_base = Path.home() / ".steam/steam/userdata"
|
|
if not userdata_base.is_dir():
|
|
self.logger.error(f"Steam userdata directory not found at {userdata_base}")
|
|
return
|
|
|
|
for user_dir in userdata_base.iterdir():
|
|
if not user_dir.is_dir() or user_dir.name == "0":
|
|
continue
|
|
grid_dir = user_dir / "config/grid"
|
|
grid_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
images = [
|
|
("grid-hero.png", f"{appid}_hero.png"),
|
|
("grid-logo.png", f"{appid}_logo.png"),
|
|
("grid-tall.png", f"{appid}p.png"),
|
|
("grid-wide.png", f"{appid}.png"),
|
|
]
|
|
|
|
for src_name, dest_name in images:
|
|
src_path = steam_icons_dir / src_name
|
|
dest_path = grid_dir / dest_name
|
|
if src_path.exists():
|
|
try:
|
|
shutil.copyfile(src_path, dest_path)
|
|
self.logger.info(f"Copied {src_path} to {dest_path}")
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to copy {src_path} to {dest_path}: {e}")
|
|
else:
|
|
self.logger.debug(f"Image {src_path} not found; skipping.")
|
|
|
|
# Tenfoot: use explicit file if provided, otherwise resize the landscape grid
|
|
tenfoot_src = steam_icons_dir / "grid-tenfoot.png"
|
|
tenfoot_dest = grid_dir / f"{appid}_tenfoot.png"
|
|
wide_src = steam_icons_dir / "grid-wide.png"
|
|
if tenfoot_src.exists():
|
|
try:
|
|
shutil.copyfile(tenfoot_src, tenfoot_dest)
|
|
self.logger.info(f"Copied {tenfoot_src} to {tenfoot_dest}")
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to copy tenfoot image: {e}")
|
|
elif wide_src.exists():
|
|
try:
|
|
from PySide6.QtGui import QImage
|
|
img = QImage(str(wide_src))
|
|
if not img.isNull():
|
|
scaled = img.scaled(600, 350)
|
|
scaled.save(str(tenfoot_dest))
|
|
self.logger.info(f"Generated tenfoot image from landscape: {tenfoot_dest}")
|
|
else:
|
|
self.logger.warning(f"Could not load landscape image for tenfoot generation: {wide_src}")
|
|
except Exception as e:
|
|
self.logger.warning(f"Could not generate tenfoot image: {e}")
|
|
|
|
def _try_steamgriddb_artwork(self, appid: str, game_type: str = None, modlist_dir: str = None):
|
|
"""Fetch default artwork from SteamGridDB when no modlist-provided SteamIcons exist."""
|
|
if not game_type and modlist_dir:
|
|
from jackify.backend.services.steamgriddb_service import detect_game_type_from_modlist
|
|
game_type = detect_game_type_from_modlist(modlist_dir)
|
|
if not game_type:
|
|
self.logger.warning(f"SteamGridDB fallback skipped: could not detect game type for {modlist_dir}")
|
|
return
|
|
|
|
userdata_base = Path.home() / ".steam/steam/userdata"
|
|
if not userdata_base.is_dir():
|
|
return
|
|
|
|
import tempfile
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
tmp_dir = Path(tmp)
|
|
from jackify.backend.services.steamgriddb_service import fetch_artwork
|
|
count = fetch_artwork(game_type, tmp_dir)
|
|
if count == 0:
|
|
self.logger.debug(f"SteamGridDB returned no artwork for game type: {game_type}")
|
|
return
|
|
|
|
for user_dir in userdata_base.iterdir():
|
|
if not user_dir.is_dir() or user_dir.name == "0":
|
|
continue
|
|
grid_dir = user_dir / "config/grid"
|
|
grid_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
images = [
|
|
("grid-tall.png", f"{appid}p.png"),
|
|
("grid-wide.png", f"{appid}.png"),
|
|
("grid-hero.png", f"{appid}_hero.png"),
|
|
("grid-logo.png", f"{appid}_logo.png"),
|
|
]
|
|
for src_name, dest_name in images:
|
|
src = tmp_dir / src_name
|
|
if src.exists():
|
|
try:
|
|
shutil.copyfile(src, grid_dir / dest_name)
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to copy {src_name}: {e}")
|
|
|
|
# Generate tenfoot from landscape
|
|
wide = tmp_dir / "grid-wide.png"
|
|
if wide.exists():
|
|
try:
|
|
from PySide6.QtGui import QImage
|
|
img = QImage(str(wide))
|
|
if not img.isNull():
|
|
img.scaled(600, 350).save(str(grid_dir / f"{appid}_tenfoot.png"))
|
|
except Exception as e:
|
|
self.logger.debug(f"Could not generate tenfoot: {e}")
|
|
|
|
self.logger.info(f"Applied SteamGridDB artwork for game type '{game_type}' ({count} images)")
|
|
|
|
def get_modlist_wine_components(self, modlist_name, game_var_full=None):
|
|
"""
|
|
Returns the full list of Wine components to install for a given modlist/game.
|
|
- Always includes the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022)
|
|
- Adds game-specific extras (from bash script logic)
|
|
- Adds any modlist-specific extras (from MODLIST_WINE_COMPONENTS)
|
|
"""
|
|
default_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
|
|
extras = []
|
|
# Determine game type
|
|
game = (game_var_full or modlist_name or "").lower().replace(" ", "")
|
|
# Add game-specific extras
|
|
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
|
|
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
|
elif "falloutnewvegas" in game or "fnv" in game or "fallout3" in game or "fo3" in game or "oblivion" in game:
|
|
extras += ["d3dx9_43", "d3dx9"]
|
|
elif "cp2077" in game or "cyberpunk" in game:
|
|
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
|
elif "bg3" in game or "baldursgate" in game:
|
|
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6"]
|
|
else:
|
|
# Unknown game type - install the union of all known component sets
|
|
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7", "dotnet8", "dotnetdesktop6", "d3dx9_43", "d3dx9"]
|
|
# Add modlist-specific extras
|
|
modlist_lower = modlist_name.lower().replace(" ", "") if modlist_name else ""
|
|
for key, components in self.MODLIST_WINE_COMPONENTS.items():
|
|
if key in modlist_lower:
|
|
extras += components
|
|
# Remove duplicates while preserving order
|
|
seen = set()
|
|
full_list = [x for x in default_components + extras if not (x in seen or seen.add(x))]
|
|
return full_list
|
|
|
|
def _re_enforce_windows_10_mode(self):
|
|
"""
|
|
Re-enforce the final Windows version after modlist-specific configurations.
|
|
Re-applies win10 after modlist-specific winetricks components, which can
|
|
leave the prefix at a lower version.
|
|
"""
|
|
try:
|
|
if not hasattr(self, 'appid') or not self.appid:
|
|
self.logger.warning("Cannot re-enforce Windows 11 mode - no AppID available")
|
|
return
|
|
|
|
from ..handlers.winetricks_handler import WinetricksHandler
|
|
from ..handlers.path_handler import PathHandler
|
|
|
|
# Get prefix path for the AppID - must be compatdata/pfx/, not compatdata/
|
|
compatdata_path = PathHandler.find_compat_data(str(self.appid))
|
|
if not compatdata_path:
|
|
self.logger.warning("Cannot re-enforce Windows 11 mode - prefix path not found")
|
|
return
|
|
prefix_path = compatdata_path / "pfx"
|
|
|
|
# Use winetricks handler to set Windows 11 mode
|
|
winetricks_handler = WinetricksHandler()
|
|
wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path))
|
|
if not wine_binary:
|
|
self.logger.warning("Cannot re-enforce Windows 11 mode - wine binary not found")
|
|
return
|
|
|
|
env = os.environ.copy()
|
|
env['WINEPREFIX'] = str(prefix_path)
|
|
env['WINE'] = wine_binary
|
|
result = subprocess.run(
|
|
[winetricks_handler.winetricks_path, '-q', 'win10'],
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300
|
|
)
|
|
if result.returncode == 0:
|
|
self.logger.info("Windows 11 mode re-enforced after modlist-specific configurations")
|
|
else:
|
|
self.logger.warning("Could not set Windows 11 mode: %s", result.stderr)
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Error re-enforcing Windows 11 mode: {e}")
|
|
|
|
def _handle_symlinked_downloads(self) -> bool:
|
|
"""
|
|
Check if downloads_directory in ModOrganizer.ini points to a symlink.
|
|
If it does, comment out the line to force MO2 to use default behavior.
|
|
|
|
Returns:
|
|
bool: True on success or no action needed, False on error
|
|
"""
|
|
try:
|
|
if not self.modlist_ini or not os.path.exists(self.modlist_ini):
|
|
self.logger.warning("ModOrganizer.ini not found for symlink check")
|
|
return True # Non-critical
|
|
|
|
# Read the INI file
|
|
# Allow duplicate sections/keys since some ModOrganizer.ini variants repeat [General]
|
|
# Latest occurrence wins, which matches how we only need the final downloads_directory value.
|
|
config = configparser.ConfigParser(allow_no_value=True, delimiters=['='], strict=False)
|
|
config.optionxform = str # Preserve case sensitivity
|
|
|
|
try:
|
|
# Read file manually to handle BOM
|
|
with open(self.modlist_ini, 'r', encoding='utf-8-sig') as f:
|
|
config.read_file(f)
|
|
except UnicodeDecodeError:
|
|
with open(self.modlist_ini, 'r', encoding='latin-1') as f:
|
|
config.read_file(f)
|
|
|
|
# Check if downloads_directory or download_directory exists and is a symlink
|
|
downloads_key = None
|
|
downloads_path = None
|
|
|
|
if 'General' in config:
|
|
# Check for both possible key names
|
|
if 'downloads_directory' in config['General']:
|
|
downloads_key = 'downloads_directory'
|
|
downloads_path = config['General']['downloads_directory']
|
|
elif 'download_directory' in config['General']:
|
|
downloads_key = 'download_directory'
|
|
downloads_path = config['General']['download_directory']
|
|
|
|
if downloads_path:
|
|
|
|
if downloads_path and os.path.exists(downloads_path):
|
|
# Check if the path or any parent directory contains symlinks
|
|
def has_symlink_in_path(path):
|
|
"""Check if path or any parent directory is a symlink"""
|
|
current_path = Path(path).resolve()
|
|
check_path = Path(path)
|
|
|
|
# Walk up the path checking each component
|
|
for parent in [check_path] + list(check_path.parents):
|
|
if parent.is_symlink():
|
|
return True, str(parent)
|
|
return False, None
|
|
|
|
has_symlink, symlink_path = has_symlink_in_path(downloads_path)
|
|
if has_symlink:
|
|
self.logger.info(f"Detected symlink in downloads directory path: {symlink_path} -> {downloads_path}")
|
|
self.logger.info("Commenting out downloads_directory to avoid Wine symlink issues")
|
|
|
|
# Read the file manually to preserve comments and formatting
|
|
with open(self.modlist_ini, 'r', encoding='utf-8') as f:
|
|
lines = f.readlines()
|
|
|
|
# Find and comment out the downloads directory line
|
|
modified = False
|
|
for i, line in enumerate(lines):
|
|
if line.strip().startswith(f'{downloads_key}='):
|
|
lines[i] = '#' + line # Comment out the line
|
|
modified = True
|
|
break
|
|
|
|
if modified:
|
|
# Write the modified file back
|
|
with open(self.modlist_ini, 'w', encoding='utf-8') as f:
|
|
f.writelines(lines)
|
|
self.logger.info(f"{downloads_key} line commented out successfully")
|
|
else:
|
|
self.logger.warning("downloads_directory line not found in file")
|
|
else:
|
|
self.logger.debug(f"downloads_directory is not a symlink: {downloads_path}")
|
|
else:
|
|
self.logger.debug("downloads_directory path does not exist or is empty")
|
|
else:
|
|
self.logger.debug("No downloads_directory found in ModOrganizer.ini")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error handling symlinked downloads: {e}", exc_info=True)
|
|
return False
|
|
|
|
def _apply_universal_dotnet_fixes(self):
|
|
"""
|
|
Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
|
|
Now called AFTER wine component installation to prevent overwrites.
|
|
Includes wineserver shutdown/flush to ensure persistence.
|
|
"""
|
|
try:
|
|
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
|
|
if not os.path.exists(prefix_path):
|
|
self.logger.warning(f"Prefix path not found: {prefix_path}")
|
|
return False
|
|
|
|
self.logger.info("Applying universal dotnet4.x compatibility registry fixes (post-component installation)...")
|
|
|
|
# Find the appropriate Wine binary to use for registry operations
|
|
wine_binary = self._find_wine_binary_for_registry()
|
|
if not wine_binary:
|
|
self.logger.error("Could not find Wine binary for registry operations")
|
|
return False
|
|
|
|
# Find wineserver binary for flushing registry changes
|
|
wine_dir = os.path.dirname(wine_binary)
|
|
wineserver_binary = os.path.join(wine_dir, 'wineserver')
|
|
if not os.path.exists(wineserver_binary):
|
|
self.logger.warning(f"wineserver not found at {wineserver_binary}, registry flush may not work")
|
|
wineserver_binary = None
|
|
|
|
# Set environment for Wine registry operations
|
|
env = os.environ.copy()
|
|
env['WINEPREFIX'] = prefix_path
|
|
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
|
|
|
|
self._wait_for_wineserver(prefix_path)
|
|
|
|
# Registry fix 1: Set *mscoree=native as a per-exe AppDefaults override for
|
|
# SkyrimSE.exe only. A global DllOverrides entry breaks .NET 9/10 bootstrap
|
|
# (Synthesis), because the override intercepts mscoree loading for ALL processes
|
|
# including the SDK host. Scoping it to SkyrimSE.exe isolates the fix to the
|
|
# game process without affecting Synthesis or any other .NET tool.
|
|
self.logger.debug("Setting *mscoree=native AppDefaults override for SkyrimSE.exe...")
|
|
cmd1 = [
|
|
wine_binary, 'reg', 'add',
|
|
'HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\SkyrimSE.exe\\DllOverrides',
|
|
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
|
|
]
|
|
|
|
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
|
|
self.logger.info(f"*mscoree registry command result: returncode={result1.returncode}, stdout={result1.stdout[:200]}, stderr={result1.stderr[:200]}")
|
|
if result1.returncode == 0:
|
|
self.logger.info("Successfully applied *mscoree=native DLL override")
|
|
else:
|
|
self.logger.error(f"Failed to set *mscoree DLL override: returncode={result1.returncode}, stderr={result1.stderr}")
|
|
|
|
# Registry fix 2: Set OnlyUseLatestCLR=1
|
|
# Use latest CLR to avoid .NET version conflicts
|
|
self.logger.debug("Setting OnlyUseLatestCLR=1 registry entry...")
|
|
cmd2 = [
|
|
wine_binary, 'reg', 'add',
|
|
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
|
|
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
|
|
]
|
|
|
|
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
|
|
self.logger.info(f"OnlyUseLatestCLR registry command result: returncode={result2.returncode}, stdout={result2.stdout[:200]}, stderr={result2.stderr[:200]}")
|
|
if result2.returncode == 0:
|
|
self.logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
|
|
else:
|
|
self.logger.error(f"Failed to set OnlyUseLatestCLR: returncode={result2.returncode}, stderr={result2.stderr}")
|
|
|
|
# Force wineserver to flush registry changes to disk
|
|
if wineserver_binary:
|
|
self.logger.debug("Flushing registry changes to disk via wineserver shutdown...")
|
|
try:
|
|
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
|
|
self.logger.debug("Registry changes flushed to disk")
|
|
except Exception as e:
|
|
self.logger.warning(f"Registry flush failed (non-critical): {e}")
|
|
|
|
ok = result1.returncode == 0 and result2.returncode == 0
|
|
if ok:
|
|
self.logger.info("Universal dotnet4.x compatibility fixes applied and flushed")
|
|
else:
|
|
self.logger.error("One or more dotnet4.x registry commands failed - see errors above")
|
|
return ok
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
|
|
return False
|
|
|
|
def _find_wine_binary_for_registry(self) -> Optional[str]:
|
|
"""Find wine binary from Install Proton path"""
|
|
try:
|
|
# Use Install Proton from config (used by jackify-engine)
|
|
from ..handlers.config_handler import ConfigHandler
|
|
config_handler = ConfigHandler()
|
|
proton_path = config_handler.get_proton_path()
|
|
|
|
if proton_path:
|
|
proton_path = Path(proton_path).expanduser()
|
|
|
|
# Check both GE-Proton and Valve Proton structures
|
|
wine_candidates = [
|
|
proton_path / "files" / "bin" / "wine", # GE-Proton
|
|
proton_path / "dist" / "bin" / "wine" # Valve Proton
|
|
]
|
|
|
|
for wine_bin in wine_candidates:
|
|
if wine_bin.exists() and wine_bin.is_file():
|
|
return str(wine_bin)
|
|
|
|
# Fallback: use best detected Proton
|
|
from ..handlers.wine_utils import WineUtils
|
|
best_proton = WineUtils.select_best_proton()
|
|
if best_proton:
|
|
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
|
|
if wine_binary:
|
|
return wine_binary
|
|
|
|
return None
|
|
except Exception as e:
|
|
self.logger.error(f"Error finding Wine binary: {e}")
|
|
return None
|
|
|
|
def _wait_for_wineserver(self, prefix_path: str) -> None:
|
|
"""Wait for wineserver to stop for the given prefix before direct file edits.
|
|
|
|
Harmless if wineserver is already stopped - exits immediately.
|
|
Prevents in-memory hive flush from overwriting direct .reg file edits.
|
|
"""
|
|
wine_binary = self._find_wine_binary_for_registry()
|
|
if not wine_binary:
|
|
self.logger.debug("No wine binary found; skipping wineserver wait")
|
|
return
|
|
wineserver = os.path.join(os.path.dirname(wine_binary), "wineserver")
|
|
if not os.path.exists(wineserver):
|
|
self.logger.debug("wineserver binary not found; skipping wait")
|
|
return
|
|
env = os.environ.copy()
|
|
env["WINEPREFIX"] = prefix_path
|
|
env["WINEDEBUG"] = "-all"
|
|
try:
|
|
subprocess.run([wineserver, "-w"], env=env, timeout=30, capture_output=True)
|
|
self.logger.debug("wineserver stopped for prefix %s", prefix_path)
|
|
except Exception as e:
|
|
self.logger.debug("wineserver wait returned non-zero (likely already stopped): %s", e)
|
|
|
|
def _apply_modlist_registry_tweaks(self) -> bool:
|
|
"""Write user.reg values required for modlist operation.
|
|
|
|
- FontSmoothing/Type/Gamma/Orientation (ClearType subpixel rendering)
|
|
- HIGHDPIAWARE (prevents Wine DPI scaling on tools)
|
|
- ShowDotFiles=Y (MO2 must see hidden dirs inside the prefix)
|
|
"""
|
|
try:
|
|
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
|
|
user_reg = os.path.join(prefix_path, "user.reg")
|
|
if not os.path.exists(user_reg):
|
|
self.logger.warning("user.reg not found at %s; skipping modlist registry tweaks", user_reg)
|
|
return False
|
|
|
|
self._wait_for_wineserver(prefix_path)
|
|
|
|
tweaks = [
|
|
(
|
|
"[Control Panel\\\\Desktop]",
|
|
'"FontSmoothing"',
|
|
'"2"',
|
|
),
|
|
(
|
|
"[Control Panel\\\\Desktop]",
|
|
'"FontSmoothingGamma"',
|
|
"dword:00000578",
|
|
),
|
|
(
|
|
"[Control Panel\\\\Desktop]",
|
|
'"FontSmoothingOrientation"',
|
|
"dword:00000001",
|
|
),
|
|
(
|
|
"[Control Panel\\\\Desktop]",
|
|
'"FontSmoothingType"',
|
|
"dword:00000002",
|
|
),
|
|
(
|
|
"[Software\\\\Microsoft\\\\Windows NT\\\\CurrentVersion\\\\AppCompatFlags\\\\Layers]",
|
|
'@',
|
|
'"~ HIGHDPIAWARE"',
|
|
),
|
|
(
|
|
"[Software\\\\Wine]",
|
|
'"ShowDotFiles"',
|
|
'"Y"',
|
|
),
|
|
]
|
|
|
|
with open(user_reg, "r", encoding="utf-8", errors="ignore") as f:
|
|
lines = f.readlines()
|
|
|
|
for section, key, value in tweaks:
|
|
in_section = False
|
|
updated = False
|
|
insert_at = None
|
|
for i, line in enumerate(lines):
|
|
stripped = line.strip()
|
|
if stripped.lower() == section.lower():
|
|
in_section = True
|
|
continue
|
|
if stripped.startswith("[") and in_section:
|
|
insert_at = i
|
|
break
|
|
if in_section and stripped.lower().startswith(key.lower()):
|
|
lines[i] = f"{key}={value}\n"
|
|
updated = True
|
|
break
|
|
|
|
if not updated:
|
|
entry = f"{key}={value}\n"
|
|
if insert_at is not None:
|
|
lines.insert(insert_at, entry)
|
|
elif in_section:
|
|
lines.append(entry)
|
|
else:
|
|
lines.append(f"\n{section}\n")
|
|
lines.append(entry)
|
|
|
|
with open(user_reg, "w", encoding="utf-8") as f:
|
|
f.writelines(lines)
|
|
|
|
self.logger.info("Modlist registry tweaks applied (font smoothing, HIGHDPIAWARE, ShowDotFiles)")
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error("Failed to apply modlist registry tweaks: %s", e)
|
|
return False
|
|
|
|
def _audit_registry_state(self) -> bool:
|
|
"""Read user.reg and system.reg and log whether every expected value is present.
|
|
|
|
Returns True only when all checks pass. Logs a WARNING for each missing or
|
|
wrong value so the application log always carries a clear post-configuration
|
|
record of registry state.
|
|
"""
|
|
try:
|
|
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
|
|
user_reg = os.path.join(prefix_path, "user.reg")
|
|
system_reg = os.path.join(prefix_path, "system.reg")
|
|
|
|
def _read(path):
|
|
if not os.path.exists(path):
|
|
return ""
|
|
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
return f.read()
|
|
|
|
user_content = _read(user_reg)
|
|
system_content = _read(system_reg)
|
|
|
|
checks = [
|
|
# (description, file_content, expected_substring)
|
|
(
|
|
"ShowDotFiles=Y (user.reg)",
|
|
user_content,
|
|
'"ShowDotFiles"="Y"',
|
|
),
|
|
(
|
|
"FontSmoothing=2 (user.reg)",
|
|
user_content,
|
|
'"FontSmoothing"="2"',
|
|
),
|
|
(
|
|
"FontSmoothingType=2 (user.reg)",
|
|
user_content,
|
|
'"FontSmoothingType"=dword:00000002',
|
|
),
|
|
(
|
|
"FontSmoothingGamma (user.reg)",
|
|
user_content,
|
|
'"FontSmoothingGamma"=dword:00000578',
|
|
),
|
|
(
|
|
"FontSmoothingOrientation (user.reg)",
|
|
user_content,
|
|
'"FontSmoothingOrientation"=dword:00000001',
|
|
),
|
|
(
|
|
"HIGHDPIAWARE (user.reg)",
|
|
user_content,
|
|
'HIGHDPIAWARE',
|
|
),
|
|
(
|
|
"*mscoree=native (user.reg)",
|
|
user_content,
|
|
'"*mscoree"="native"',
|
|
),
|
|
(
|
|
"OnlyUseLatestCLR=1 (system.reg)",
|
|
system_content,
|
|
'"OnlyUseLatestCLR"=dword:00000001',
|
|
),
|
|
]
|
|
|
|
all_ok = True
|
|
for description, content, needle in checks:
|
|
if needle in content:
|
|
self.logger.info("Registry audit [OK] %s", description)
|
|
else:
|
|
self.logger.warning("Registry audit [MISSING] %s", description)
|
|
all_ok = False
|
|
|
|
if all_ok:
|
|
self.logger.info("Registry audit complete - all values confirmed present")
|
|
else:
|
|
self.logger.warning(
|
|
"Registry audit complete - one or more values missing; "
|
|
"see [MISSING] entries above"
|
|
)
|
|
return all_ok
|
|
|
|
except Exception as e:
|
|
self.logger.error("Registry audit failed with exception: %s", e)
|
|
return False
|
|
|
|
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
|
|
depth = len(Path(root).relative_to(proton_path).parts)
|
|
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):
|
|
self.logger.debug(f"Found wine binary at: {wine_path}")
|
|
return str(wine_path)
|
|
|
|
return None
|
|
except Exception as e:
|
|
self.logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
|
|
return None
|