Files
Jackify/jackify/backend/handlers/wine_utils_proton.py
2026-02-07 18:26:54 +00:00

483 lines
23 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Proton scanning and selection mixin for WineUtils.
Extracted from wine_utils for file-size and domain separation.
"""
import os
import re
import logging
from pathlib import Path
from typing import Optional, Tuple, List, Dict, Any
logger = logging.getLogger(__name__)
VALVE_PROTON_APPID_MAP = {
'2805730': 'proton_9',
'3658110': 'proton_10',
'1493710': 'proton_experimental',
'2180100': 'proton_hotfix',
'1887720': 'proton_8',
}
class WineUtilsProtonMixin:
"""Mixin providing Proton scanning, selection, and path resolution."""
@staticmethod
def get_proton_version(compat_data_path: str) -> str:
"""
Detect the Proton version used by a Steam game/shortcut.
Args:
compat_data_path: Path to the compatibility data directory.
Returns:
Detected Proton version or 'Unknown' if not found.
"""
logger.info("Detecting Proton version...")
if not os.path.isdir(compat_data_path):
logger.warning(f"Compatdata directory not found at '{compat_data_path}'")
return "Unknown"
system_reg_path = os.path.join(compat_data_path, "pfx", "system.reg")
if os.path.isfile(system_reg_path):
try:
with open(system_reg_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
match = re.search(r'"SteamClientProtonVersion"="([^"]+)"', content)
if match:
version = match.group(1).strip()
proton_ver = version if "GE" in version else f"Proton {version}"
logger.debug(f"Detected Proton version from registry: {proton_ver}")
return proton_ver
except Exception as e:
logger.debug(f"Error reading system.reg: {e}")
config_info_path = os.path.join(compat_data_path, "config_info")
if os.path.isfile(config_info_path):
try:
with open(config_info_path, "r") as f:
config_ver = f.readline().strip()
if config_ver:
proton_ver = config_ver if "GE" in config_ver else f"Proton {config_ver}"
logger.debug(f"Detected Proton version from config_info: {proton_ver}")
return proton_ver
except Exception as e:
logger.debug(f"Error reading config_info: {e}")
logger.warning("Could not detect Proton version")
return "Unknown"
@staticmethod
def find_proton_binary(proton_version: str) -> Optional[str]:
"""
Find the full path to the Proton binary given a version string.
Returns the path to 'files/bin/wine', or None if not found.
"""
version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')]
steam_common_paths = []
compatibility_paths = []
try:
from .path_handler import PathHandler
root_steam_libs = PathHandler.get_all_steam_library_paths()
for lib_path in root_steam_libs:
lib = Path(lib_path)
if lib.exists():
common_path = lib / "steamapps/common"
if common_path.exists():
steam_common_paths.append(common_path)
compatibility_paths.append(lib / "compatibilitytools.d")
except Exception as e:
logger.warning(f"Could not detect Steam libraries from libraryfolders.vdf: {e}")
if not steam_common_paths:
steam_common_paths = [
Path.home() / ".steam/steam/steamapps/common",
Path.home() / ".local/share/Steam/steamapps/common",
Path.home() / ".steam/root/steamapps/common"
]
if not compatibility_paths:
compatibility_paths = [
Path.home() / ".steam/steam/compatibilitytools.d",
Path.home() / ".local/share/Steam/compatibilitytools.d"
]
compatibility_paths.extend([
Path.home() / ".steam/root/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton"
])
if proton_version.strip().startswith("Proton 9"):
proton9_candidates = ["Proton 9.0", "Proton 9.0 (Beta)"]
for base_path in steam_common_paths:
for name in proton9_candidates:
candidate = base_path / name / "files/bin/wine"
if candidate.is_file():
return str(candidate)
for subdir in base_path.glob("Proton 9*"):
wine_bin = subdir / "files/bin/wine"
if wine_bin.is_file():
return str(wine_bin)
all_paths = steam_common_paths + compatibility_paths
for base_path in all_paths:
if not base_path.is_dir():
continue
for pattern in version_patterns:
proton_dir = base_path / pattern
wine_bin = proton_dir / "files/bin/wine"
if wine_bin.is_file():
return str(wine_bin)
for subdir in base_path.glob(f"*{pattern}*"):
wine_bin = subdir / "files/bin/wine"
if wine_bin.is_file():
return str(wine_bin)
try:
from .config_handler import ConfigHandler
config = ConfigHandler()
fallback_path = config.get_proton_path()
if fallback_path != 'auto':
fallback_wine_bin = Path(fallback_path) / "files/bin/wine"
if fallback_wine_bin.is_file():
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
return str(fallback_wine_bin)
except Exception:
pass
for base_path in steam_common_paths:
wine_bin = base_path / "Proton - Experimental" / "files/bin/wine"
if wine_bin.is_file():
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to 'Proton - Experimental'.")
return str(wine_bin)
return None
@staticmethod
def get_proton_paths(appid: str) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""
Get the Proton paths for a given AppID.
Returns (compatdata_path, proton_path, wine_bin) or (None, None, None) if not found.
"""
logger.info(f"Getting Proton paths for AppID {appid}")
possible_compat_bases = [
Path.home() / ".steam/steam/steamapps/compatdata",
Path.home() / ".local/share/Steam/steamapps/compatdata"
]
compatdata_path = None
for base_path in possible_compat_bases:
potential_compat_path = base_path / appid
if potential_compat_path.is_dir():
compatdata_path = str(potential_compat_path)
logger.debug(f"Found compatdata directory: {compatdata_path}")
break
if not compatdata_path:
logger.error(f"Could not find compatdata directory for AppID {appid}")
return None, None, None
proton_version = WineUtilsProtonMixin.get_proton_version(compatdata_path)
if proton_version == "Unknown":
logger.error(f"Could not determine Proton version for AppID {appid}")
return None, None, None
wine_bin = WineUtilsProtonMixin.find_proton_binary(proton_version)
if not wine_bin:
logger.error(f"Could not find Proton binary for version {proton_version}")
return None, None, None
proton_path = str(Path(wine_bin).parent.parent)
logger.debug(f"Found Proton path: {proton_path}")
return compatdata_path, proton_path, wine_bin
@staticmethod
def get_steam_library_paths() -> List[Path]:
"""Get all Steam library paths from libraryfolders.vdf."""
steam_common_paths = []
try:
from .path_handler import PathHandler
library_paths = PathHandler.get_all_steam_library_paths()
logger.info(f"PathHandler found Steam libraries: {library_paths}")
for lib_path in library_paths:
common_path = lib_path / "steamapps" / "common"
if common_path.exists():
steam_common_paths.append(common_path)
logger.debug(f"Added Steam library: {common_path}")
else:
logger.debug(f"Steam library path doesn't exist: {common_path}")
except Exception as e:
logger.error(f"PathHandler failed to read libraryfolders.vdf: {e}")
fallback_paths = [
Path.home() / ".steam/steam/steamapps/common",
Path.home() / ".local/share/Steam/steamapps/common",
Path.home() / ".steam/root/steamapps/common"
]
for fallback_path in fallback_paths:
if fallback_path.exists() and fallback_path not in steam_common_paths:
steam_common_paths.append(fallback_path)
logger.debug(f"Added fallback Steam library: {fallback_path}")
logger.info(f"Final Steam library paths for Proton scanning: {steam_common_paths}")
return steam_common_paths
@staticmethod
def get_compatibility_tool_paths() -> List[Path]:
"""Get all compatibility tool paths for GE-Proton and other custom Proton versions."""
compat_paths = [
Path.home() / ".steam/steam/compatibilitytools.d",
Path.home() / ".local/share/Steam/compatibilitytools.d",
Path.home() / ".steam/root/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton"
]
return [path for path in compat_paths if path.exists()]
@staticmethod
def _parse_compat_tool_name(proton_dir: Path) -> Optional[str]:
"""Parse the Steam internal name from a compatibilitytool.vdf file."""
vdf_path = proton_dir / "compatibilitytool.vdf"
if not vdf_path.exists():
return None
try:
with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
match = re.search(r'"compat_tools"\s*\{[^{]*"([^"]+)"\s*(?://[^\n]*)?\s*\{', content, re.DOTALL)
if match:
return match.group(1)
except Exception as e:
logger.warning(f"Failed to parse {vdf_path}: {e}")
return None
@staticmethod
def _find_valve_proton_appid(proton_dir_name: str) -> Optional[str]:
"""Find the Steam App ID for a Valve Proton by matching appmanifest installdir."""
steam_libs = WineUtilsProtonMixin.get_steam_library_paths()
for lib_path in steam_libs:
steamapps_dir = lib_path.parent
for manifest in steamapps_dir.glob("appmanifest_*.acf"):
try:
with open(manifest, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
installdir_match = re.search(r'"installdir"\s+"([^"]+)"', content)
appid_match = re.search(r'"appid"\s+"(\d+)"', content)
if installdir_match and appid_match and installdir_match.group(1) == proton_dir_name:
return appid_match.group(1)
except Exception:
continue
return None
@staticmethod
def resolve_steam_compat_name(proton_path: Any) -> Optional[str]:
"""
Resolve the correct Steam config.vdf internal name for a Proton installation.
Returns internal name for CompatToolMapping, or None if unresolvable.
"""
proton_path = Path(proton_path)
if not proton_path.is_dir():
logger.warning(f"Proton path not found: {proton_path}")
return None
compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_path)
if compat_name:
logger.debug(f"Resolved compat name from vdf: {proton_path.name} -> {compat_name}")
return compat_name
dir_name = proton_path.name
appid = WineUtilsProtonMixin._find_valve_proton_appid(dir_name)
if appid and appid in VALVE_PROTON_APPID_MAP:
name = VALVE_PROTON_APPID_MAP[appid]
logger.debug(f"Resolved Valve Proton: {dir_name} (AppID {appid}) -> {name}")
return name
if dir_name.startswith('GE-Proton'):
return dir_name
logger.warning(f"Could not resolve Steam compat name for: {proton_path}")
return None
@staticmethod
def scan_thirdparty_proton_versions() -> List[Dict[str, Any]]:
"""Scan for non-GE third-party Proton versions in compatibilitytools.d directories."""
logger.info("Scanning for third-party Proton versions...")
found_versions = []
seen_names = set()
compat_paths = WineUtilsProtonMixin.get_compatibility_tool_paths()
if not compat_paths:
return []
for compat_path in compat_paths:
try:
for proton_dir in compat_path.iterdir():
if not proton_dir.is_dir():
continue
dir_name = proton_dir.name
if dir_name.startswith("GE-Proton"):
continue
wine_bin = proton_dir / "files" / "bin" / "wine"
if not wine_bin.exists():
continue
compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_dir)
if not compat_name:
continue
vdf_path = proton_dir / "compatibilitytool.vdf"
try:
with open(vdf_path, 'r', encoding='utf-8', errors='ignore') as f:
vdf_content = f.read()
if '"from_oslist" "linux"' in vdf_content:
continue
except Exception:
pass
if 'hotfix' in compat_name.lower():
continue
if compat_name in seen_names:
continue
seen_names.add(compat_name)
found_versions.append({
'name': dir_name,
'path': proton_dir,
'wine_bin': wine_bin,
'priority': 175,
'type': 'ThirdParty-Proton',
'steam_compat_name': compat_name,
})
logger.debug(f"Found third-party Proton: {dir_name} (compat name: {compat_name})")
except Exception as e:
logger.warning(f"Error scanning {compat_path}: {e}")
logger.info(f"Found {len(found_versions)} third-party Proton version(s)")
return found_versions
@staticmethod
def scan_ge_proton_versions() -> List[Dict[str, Any]]:
"""Scan for available GE-Proton versions in compatibilitytools.d directories."""
logger.info("Scanning for available GE-Proton versions...")
found_versions = []
compat_paths = WineUtilsProtonMixin.get_compatibility_tool_paths()
if not compat_paths:
logger.warning("No compatibility tool paths found")
return []
for compat_path in compat_paths:
logger.debug(f"Scanning compatibility tools: {compat_path}")
try:
for proton_dir in compat_path.iterdir():
if not proton_dir.is_dir():
continue
dir_name = proton_dir.name
if not dir_name.startswith("GE-Proton"):
continue
wine_bin = proton_dir / "files" / "bin" / "wine"
if not wine_bin.exists() or not wine_bin.is_file():
logger.debug(f"Skipping {dir_name} - no wine binary found")
continue
version_match = re.match(r'GE-Proton(\d+)-(\d+)', dir_name)
if version_match:
major_ver = int(version_match.group(1))
minor_ver = int(version_match.group(2))
priority = 200 + (major_ver * 10) + minor_ver
compat_name = WineUtilsProtonMixin._parse_compat_tool_name(proton_dir) or dir_name
found_versions.append({
'name': dir_name,
'path': proton_dir,
'wine_bin': wine_bin,
'priority': priority,
'major_version': major_ver,
'minor_version': minor_ver,
'type': 'GE-Proton',
'steam_compat_name': compat_name,
})
logger.debug(f"Found {dir_name} at {proton_dir} (priority: {priority})")
else:
logger.debug(f"Skipping {dir_name} - unknown GE-Proton version format")
except Exception as e:
logger.warning(f"Error scanning {compat_path}: {e}")
found_versions.sort(key=lambda x: x['priority'], reverse=True)
logger.info(f"Found {len(found_versions)} GE-Proton version(s)")
return found_versions
@staticmethod
def scan_valve_proton_versions() -> List[Dict[str, Any]]:
"""Scan for available Valve Proton versions with fallback priority."""
logger.info("Scanning for available Valve Proton versions...")
found_versions = []
steam_libs = WineUtilsProtonMixin.get_steam_library_paths()
if not steam_libs:
logger.warning("No Steam library paths found")
return []
preferred_versions = [
("Proton - Experimental", 150),
("Proton 10.0", 140),
("Proton 9.0", 130),
("Proton 9.0 (Beta)", 125)
]
for steam_path in steam_libs:
logger.debug(f"Scanning Steam library: {steam_path}")
for version_name, priority in preferred_versions:
proton_path = steam_path / version_name
wine_bin = proton_path / "files" / "bin" / "wine"
if wine_bin.exists() and wine_bin.is_file():
compat_name = WineUtilsProtonMixin.resolve_steam_compat_name(proton_path)
found_versions.append({
'name': version_name,
'path': proton_path,
'wine_bin': wine_bin,
'priority': priority,
'type': 'Valve-Proton',
'steam_compat_name': compat_name,
})
logger.debug(f"Found {version_name} at {proton_path}")
found_versions.sort(key=lambda x: x['priority'], reverse=True)
unique_versions = []
seen_names = set()
for version in found_versions:
if version['name'] not in seen_names:
unique_versions.append(version)
seen_names.add(version['name'])
logger.info(f"Found {len(unique_versions)} unique Valve Proton version(s)")
return unique_versions
@staticmethod
def scan_all_proton_versions() -> List[Dict[str, Any]]:
"""Scan for all available Proton versions (GE + third-party + Valve) with unified priority."""
logger.info("Scanning for all available Proton versions...")
all_versions = []
all_versions.extend(WineUtilsProtonMixin.scan_ge_proton_versions())
all_versions.extend(WineUtilsProtonMixin.scan_thirdparty_proton_versions())
all_versions.extend(WineUtilsProtonMixin.scan_valve_proton_versions())
all_versions.sort(key=lambda x: x['priority'], reverse=True)
unique_versions = []
seen_names = set()
for version in all_versions:
if version['name'] not in seen_names:
unique_versions.append(version)
seen_names.add(version['name'])
if unique_versions:
logger.debug(f"Found {len(unique_versions)} total Proton version(s)")
logger.debug(f"Best available: {unique_versions[0]['name']} ({unique_versions[0]['type']})")
else:
logger.warning("No Proton versions found")
return unique_versions
@staticmethod
def select_best_proton() -> Optional[Dict[str, Any]]:
"""Select the best available Proton (GE or Valve). Excludes third-party builds."""
available_versions = WineUtilsProtonMixin.scan_all_proton_versions()
if not available_versions:
logger.warning("No compatible Proton versions found")
return None
compatible_versions = [v for v in available_versions if v.get('type') in ('GE-Proton', 'Valve-Proton')]
if not compatible_versions:
logger.warning("No compatible Proton versions found (only third-party builds available)")
return None
best_version = compatible_versions[0]
logger.info(f"Selected best Proton version: {best_version['name']} ({best_version['type']})")
return best_version
@staticmethod
def select_best_valve_proton() -> Optional[Dict[str, Any]]:
"""Select the best available Valve Proton. Kept for backward compatibility."""
available_versions = WineUtilsProtonMixin.scan_valve_proton_versions()
if not available_versions:
logger.warning("No compatible Valve Proton versions found")
return None
best_version = available_versions[0]
logger.info(f"Selected Valve Proton version: {best_version['name']}")
return best_version
@staticmethod
def check_proton_requirements() -> Tuple[bool, str, Optional[Dict[str, Any]]]:
"""Check if a compatible Proton version is available for workflows."""
logger.info("Checking Proton requirements for workflow...")
best_proton = WineUtilsProtonMixin.select_best_proton()
if best_proton:
proton_type = best_proton.get('type', 'Unknown')
status_msg = f"[OK] Using {best_proton['name']} ({proton_type}) for this workflow"
logger.info(f"Proton requirements satisfied: {best_proton['name']} ({proton_type})")
return True, status_msg, best_proton
status_msg = "[FAIL] No compatible Proton version found (GE-Proton 10+, Proton 9+, 10, or Experimental required)"
logger.warning("Proton requirements not met - no compatible version found")
return False, status_msg, None