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

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