Files
Jackify/jackify/backend/handlers/wine_utils.py
Omni cd591c14e3 Initial public release v0.1.0 - Linux Wabbajack Modlist Application
Jackify provides native Linux support for Wabbajack modlist installation
   and management with automated Steam integration and Proton configuration.

   Key Features:
   - Almost Native Linux implementation (texconv.exe run via proton)
   - Automated Steam shortcut creation and Proton prefix management
   - Both CLI and GUI interfaces, with Steam Deck optimization

   Supported Games:
   - Skyrim Special Edition
   - Fallout 4
   - Fallout New Vegas
   - Oblivion, Starfield, Enderal, and diverse other games

   Technical Architecture:
   - Clean separation between frontend and backend services
   - Powered by jackify-engine 0.3.x for Wabbajack-matching modlist installation
2025-09-05 20:46:24 +01:00

701 lines
32 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Wine Utilities Module
Handles wine-related operations and utilities
"""
import os
import re
import subprocess
import logging
import shutil
import time
from pathlib import Path
import glob
from typing import Optional, Tuple
from .subprocess_utils import get_clean_subprocess_env
# Initialize logger
logger = logging.getLogger(__name__)
class WineUtils:
"""
Utilities for wine-related operations
"""
@staticmethod
def cleanup_wine_processes():
"""
Clean up wine processes
Returns True on success, False on failure
"""
try:
# Find and kill processes containing various process names
processes = subprocess.run(
"pgrep -f 'win7|win10|ShowDotFiles|protontricks'",
shell=True,
capture_output=True,
text=True,
env=get_clean_subprocess_env()
).stdout.strip()
if processes:
for pid in processes.split("\n"):
try:
subprocess.run(f"kill -9 {pid}", shell=True, check=True, env=get_clean_subprocess_env())
except subprocess.CalledProcessError:
logger.warning(f"Failed to kill process {pid}")
logger.debug("Processes killed successfully")
else:
logger.debug("No matching processes found")
# Kill winetricks processes
subprocess.run("pkill -9 winetricks", shell=True, env=get_clean_subprocess_env())
return True
except Exception as e:
logger.error(f"Failed to cleanup wine processes: {e}")
return False
@staticmethod
def edit_binary_working_paths(modlist_ini, modlist_dir, modlist_sdcard, steam_library, basegame_sdcard):
"""
Edit binary and working directory paths in ModOrganizer.ini
Returns True on success, False on failure
"""
if not os.path.isfile(modlist_ini):
logger.error(f"ModOrganizer.ini not found at {modlist_ini}")
return False
try:
# Read the file
with open(modlist_ini, 'r', encoding='utf-8', errors='ignore') as f:
content = f.readlines()
modified_content = []
found_skse = False
# First pass to identify SKSE/F4SE launcher entries
skse_lines = []
for i, line in enumerate(content):
if re.search(r'skse64_loader\.exe|f4se_loader\.exe', line):
skse_lines.append((i, line))
found_skse = True
if not found_skse:
logger.debug("No SKSE/F4SE launcher entries found")
return False
# Process each SKSE/F4SE entry
for line_num, orig_line in skse_lines:
# Split the line into key and value
if '=' not in orig_line:
continue
binary_num, skse_loc = orig_line.split('=', 1)
# Set drive letter based on whether using SD card
if modlist_sdcard:
drive_letter = " = D:"
else:
drive_letter = " = Z:"
# Determine the working directory key
just_num = binary_num.split('\\')[0]
bin_path_start = binary_num.strip().replace('\\', '\\\\')
path_start = f"{just_num}\\\\workingDirectory".replace('\\', '\\\\')
# Process the path based on its type
if "mods" in orig_line:
# mods path type
if modlist_sdcard:
path_middle = WineUtils._strip_sdcard_path(modlist_dir)
else:
path_middle = modlist_dir
path_end = re.sub(r'.*/mods', '/mods', skse_loc.split('/')[0])
bin_path_end = re.sub(r'.*/mods', '/mods', skse_loc)
elif any(term in orig_line for term in ["Stock Game", "Game Root", "STOCK GAME", "Stock Game Folder", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition"]):
# Stock Game or Game Root type
if modlist_sdcard:
path_middle = WineUtils._strip_sdcard_path(modlist_dir)
else:
path_middle = modlist_dir
# Determine the specific stock folder type
if "Stock Game" in orig_line:
dir_type = "stockgame"
path_end = re.sub(r'.*/Stock Game', '/Stock Game', os.path.dirname(skse_loc))
bin_path_end = re.sub(r'.*/Stock Game', '/Stock Game', skse_loc)
elif "Game Root" in orig_line:
dir_type = "gameroot"
path_end = re.sub(r'.*/Game Root', '/Game Root', os.path.dirname(skse_loc))
bin_path_end = re.sub(r'.*/Game Root', '/Game Root', skse_loc)
elif "STOCK GAME" in orig_line:
dir_type = "STOCKGAME"
path_end = re.sub(r'.*/STOCK GAME', '/STOCK GAME', os.path.dirname(skse_loc))
bin_path_end = re.sub(r'.*/STOCK GAME', '/STOCK GAME', skse_loc)
elif "Stock Folder" in orig_line:
dir_type = "stockfolder"
path_end = re.sub(r'.*/Stock Folder', '/Stock Folder', os.path.dirname(skse_loc))
bin_path_end = re.sub(r'.*/Stock Folder', '/Stock Folder', skse_loc)
elif "Skyrim Stock" in orig_line:
dir_type = "skyrimstock"
path_end = re.sub(r'.*/Skyrim Stock', '/Skyrim Stock', os.path.dirname(skse_loc))
bin_path_end = re.sub(r'.*/Skyrim Stock', '/Skyrim Stock', skse_loc)
elif "Stock Game Folder" in orig_line:
dir_type = "stockgamefolder"
path_end = re.sub(r'.*/Stock Game Folder', '/Stock Game Folder', skse_loc)
bin_path_end = path_end
elif "root/Skyrim Special Edition" in orig_line:
dir_type = "rootskyrimse"
path_end = '/' + skse_loc.lstrip()
bin_path_end = path_end
else:
logger.error(f"Unknown stock game type in line: {orig_line}")
continue
elif "steamapps" in orig_line:
# Steam apps path type
if basegame_sdcard:
path_middle = WineUtils._strip_sdcard_path(steam_library)
drive_letter = " = D:"
else:
path_middle = steam_library.split('steamapps')[0]
path_end = re.sub(r'.*/steamapps', '/steamapps', os.path.dirname(skse_loc))
bin_path_end = re.sub(r'.*/steamapps', '/steamapps', skse_loc)
else:
logger.warning(f"No matching pattern found in the path: {orig_line}")
continue
# Combine paths
full_bin_path = f"{bin_path_start}{drive_letter}{path_middle}{bin_path_end}"
full_path = f"{path_start}{drive_letter}{path_middle}{path_end}"
# Replace forward slashes with double backslashes for Windows paths
new_path = full_path.replace('/', '\\\\')
# Update the content with new paths
for i, line in enumerate(content):
if line.startswith(bin_path_start):
content[i] = f"{full_bin_path}\n"
elif line.startswith(path_start):
content[i] = f"{new_path}\n"
# Write back the modified content
with open(modlist_ini, 'w', encoding='utf-8') as f:
f.writelines(content)
logger.debug("Updated binary and working directory paths successfully")
return True
except Exception as e:
logger.error(f"Error editing binary working paths: {e}")
return False
@staticmethod
def _strip_sdcard_path(path):
"""
Strip /run/media/deck/UUID from SD card paths
Internal helper method
"""
if path.startswith("/run/media/deck/"):
parts = path.split("/", 5)
if len(parts) >= 6:
return "/" + parts[5]
return path
@staticmethod
def all_owned_by_user(path):
"""
Returns True if all files and directories under 'path' are owned by the current user.
"""
uid = os.getuid()
gid = os.getgid()
for root, dirs, files in os.walk(path):
for name in dirs + files:
full_path = os.path.join(root, name)
try:
stat = os.stat(full_path)
if stat.st_uid != uid or stat.st_gid != gid:
return False
except Exception:
return False
return True
@staticmethod
def chown_chmod_modlist_dir(modlist_dir):
"""
Change ownership and permissions of modlist directory
Returns True on success, False on failure
"""
if WineUtils.all_owned_by_user(modlist_dir):
logger.info(f"All files in {modlist_dir} are already owned by the current user. Skipping sudo chown/chmod.")
return True
logger.warn("Changing Ownership and Permissions of modlist directory (may require sudo password)")
try:
user = subprocess.run("whoami", shell=True, capture_output=True, text=True).stdout.strip()
group = subprocess.run("id -gn", shell=True, capture_output=True, text=True).stdout.strip()
logger.debug(f"User is {user} and Group is {group}")
# Change ownership
result1 = subprocess.run(
f"sudo chown -R {user}:{group} \"{modlist_dir}\"",
shell=True,
capture_output=True,
text=True
)
# Change permissions
result2 = subprocess.run(
f"sudo chmod -R 755 \"{modlist_dir}\"",
shell=True,
capture_output=True,
text=True
)
if result1.returncode != 0 or result2.returncode != 0:
logger.error("Failed to change ownership/permissions")
logger.error(f"chown output: {result1.stderr}")
logger.error(f"chmod output: {result2.stderr}")
return False
return True
except Exception as e:
logger.error(f"Error changing ownership and permissions: {e}")
return False
@staticmethod
def create_dxvk_file(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full):
"""
Create DXVK file in the modlist directory
"""
try:
# Construct the path to the game directory
game_dir = os.path.join(steam_library, game_var_full)
# Create the DXVK file
dxvk_file = os.path.join(modlist_dir, "DXVK")
with open(dxvk_file, 'w') as f:
f.write(game_dir)
logger.debug(f"Created DXVK file at {dxvk_file} pointing to {game_dir}")
return True
except Exception as e:
logger.error(f"Error creating DXVK file: {e}")
return False
@staticmethod
def small_additional_tasks(modlist_dir, compat_data_path):
"""
Perform small additional tasks like deleting unsupported plugins
Returns True on success, False on failure
"""
try:
# Delete MO2 plugins that don't work via Proton
file_to_delete = os.path.join(modlist_dir, "plugins/FixGameRegKey.py")
if os.path.exists(file_to_delete):
os.remove(file_to_delete)
logger.debug(f"File deleted: {file_to_delete}")
# Download Font to support Bethini
if compat_data_path and os.path.isdir(compat_data_path):
font_path = os.path.join(compat_data_path, "pfx/drive_c/windows/Fonts/seguisym.ttf")
font_dir = os.path.dirname(font_path)
# Ensure the directory exists
os.makedirs(font_dir, exist_ok=True)
# Download the font
font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf"
subprocess.run(
f"wget {font_url} -q -nc -O \"{font_path}\"",
shell=True,
check=True
)
logger.debug(f"Downloaded font to: {font_path}")
return True
except Exception as e:
logger.error(f"Error performing additional tasks: {e}")
return False
@staticmethod
def modlist_specific_steps(modlist, appid):
"""
Perform modlist-specific steps
Returns True on success, False on failure
"""
try:
# Define modlist-specific configurations
modlist_configs = {
"wildlander": ["dotnet48", "dotnet472", "vcrun2019"],
"septimus|sigernacollection|licentia|aldrnari|phoenix": ["dotnet48", "dotnet472"],
"masterstroke": ["dotnet48", "dotnet472"],
"diablo": ["dotnet48", "dotnet472"],
"living_skyrim": ["dotnet48", "dotnet472", "dotnet462"],
"nolvus": ["dotnet8"]
}
modlist_lower = modlist.lower().replace(" ", "")
# Check for wildlander special case
if "wildlander" in modlist_lower:
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
# Implementation for wildlander-specific steps
return True
# Check for other modlists
for pattern, components in modlist_configs.items():
if re.search(pattern.replace("|", "|.*"), modlist_lower):
logger.info(f"Running steps specific to {modlist}. This can take some time, be patient!")
# Install components
for component in components:
if component == "dotnet8":
# Special handling for .NET 8
logger.info("Downloading .NET 8 Runtime")
# Implementation for .NET 8 installation
pass
else:
# Standard component installation
logger.info(f"Installing {component}...")
# Implementation for standard component installation
pass
# Set Windows 10 prefix
# Implementation for setting Windows 10 prefix
return True
# No specific steps for this modlist
logger.debug(f"No specific steps needed for {modlist}")
return True
except Exception as e:
logger.error(f"Error performing modlist-specific steps: {e}")
return False
@staticmethod
def fnv_launch_options(game_var, compat_data_path, modlist):
"""
Set up Fallout New Vegas launch options
Returns True on success, False on failure
"""
if game_var != "Fallout New Vegas":
return True
try:
appid_to_check = "22380" # Fallout New Vegas AppID
for path in [
os.path.expanduser("~/.local/share/Steam/steamapps/compatdata"),
os.path.expanduser("~/.steam/steam/steamapps/compatdata"),
os.path.expanduser("~/.steam/root/steamapps/compatdata")
]:
compat_path = os.path.join(path, appid_to_check)
if os.path.exists(compat_path):
logger.warning(f"\nFor {modlist}, please add the following line to the Launch Options in Steam for your '{modlist}' entry:")
logger.info(f"\nSTEAM_COMPAT_DATA_PATH=\"{compat_path}\" %command%")
logger.warning("\nThis is essential for the modlist to load correctly.")
return True
logger.error("Could not determine the compatdata path for Fallout New Vegas")
return False
except Exception as e:
logger.error(f"Error setting FNV launch options: {e}")
return False
@staticmethod
def get_proton_version(compat_data_path):
"""
Detect the Proton version used by a Steam game/shortcut
Args:
compat_data_path (str): Path to the compatibility data directory
Returns:
str: Detected Proton version or 'Unknown' if not found
"""
logger.info("Detecting Proton version...")
# Validate the compatdata path exists
if not os.path.isdir(compat_data_path):
logger.warning(f"Compatdata directory not found at '{compat_data_path}'")
return "Unknown"
# First try to get Proton version from the registry
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()
# Use regex to find SteamClientProtonVersion entry
match = re.search(r'"SteamClientProtonVersion"="([^"]+)"', content)
if match:
version = match.group(1).strip()
# Keep GE versions as is, otherwise prefix with "Proton"
if "GE" in version:
proton_ver = version
else:
proton_ver = 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}")
# Fallback to config_info if registry method fails
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:
# Keep GE versions as is, otherwise prefix with "Proton"
if "GE" in config_ver:
proton_ver = config_ver
else:
proton_ver = 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 update_executables(modlist_ini, modlist_dir, modlist_sdcard, steam_library, basegame_sdcard):
"""
Update executable paths in ModOrganizer.ini
"""
logger.info("Updating executable paths in ModOrganizer.ini...")
try:
# Find SKSE or F4SE loader entries
with open(modlist_ini, 'r') as f:
lines = f.readlines()
# Process each line
for i, line in enumerate(lines):
if "skse64_loader.exe" in line or "f4se_loader.exe" in line:
# Extract the binary path
binary_path = line.strip().split('=', 1)[1] if '=' in line else ""
# Determine drive letter
drive_letter = "D:" if modlist_sdcard else "Z:"
# Extract binary number
binary_num = line.strip().split('=', 1)[0] if '=' in line else ""
# Find the equivalent workingDirectory
justnum = binary_num.split('\\')[0] if '\\' in binary_num else binary_num
bin_path_start = binary_num.replace('\\', '\\\\')
path_start = f"{justnum}\\workingDirectory".replace('\\', '\\\\')
# Determine path type and construct new paths
if "mods" in binary_path:
# mods path type found
if modlist_sdcard:
path_middle = modlist_dir.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in modlist_dir else modlist_dir
# Strip /run/media/deck/UUID if present
if '/run/media/' in path_middle:
path_middle = '/' + path_middle.split('/run/media/', 1)[1].split('/', 2)[2]
else:
path_middle = modlist_dir
path_end = '/' + '/'.join(binary_path.split('/mods/', 1)[1].split('/')[:-1]) if '/mods/' in binary_path else ""
bin_path_end = '/' + '/'.join(binary_path.split('/mods/', 1)[1].split('/')) if '/mods/' in binary_path else ""
elif any(x in binary_path for x in ["Stock Game", "Game Root", "STOCK GAME", "Stock Game Folder", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition"]):
# Stock/Game Root found
if modlist_sdcard:
path_middle = modlist_dir.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in modlist_dir else modlist_dir
# Strip /run/media/deck/UUID if present
if '/run/media/' in path_middle:
path_middle = '/' + path_middle.split('/run/media/', 1)[1].split('/', 2)[2]
else:
path_middle = modlist_dir
# Determine directory type
if "Stock Game" in binary_path:
dir_type = "stockgame"
path_end = '/' + '/'.join(binary_path.split('/Stock Game/', 1)[1].split('/')[:-1]) if '/Stock Game/' in binary_path else ""
bin_path_end = '/' + '/'.join(binary_path.split('/Stock Game/', 1)[1].split('/')) if '/Stock Game/' in binary_path else ""
elif "Game Root" in binary_path:
dir_type = "gameroot"
path_end = '/' + '/'.join(binary_path.split('/Game Root/', 1)[1].split('/')[:-1]) if '/Game Root/' in binary_path else ""
bin_path_end = '/' + '/'.join(binary_path.split('/Game Root/', 1)[1].split('/')) if '/Game Root/' in binary_path else ""
elif "STOCK GAME" in binary_path:
dir_type = "STOCKGAME"
path_end = '/' + '/'.join(binary_path.split('/STOCK GAME/', 1)[1].split('/')[:-1]) if '/STOCK GAME/' in binary_path else ""
bin_path_end = '/' + '/'.join(binary_path.split('/STOCK GAME/', 1)[1].split('/')) if '/STOCK GAME/' in binary_path else ""
elif "Stock Folder" in binary_path:
dir_type = "stockfolder"
path_end = '/' + '/'.join(binary_path.split('/Stock Folder/', 1)[1].split('/')[:-1]) if '/Stock Folder/' in binary_path else ""
bin_path_end = '/' + '/'.join(binary_path.split('/Stock Folder/', 1)[1].split('/')) if '/Stock Folder/' in binary_path else ""
elif "Skyrim Stock" in binary_path:
dir_type = "skyrimstock"
path_end = '/' + '/'.join(binary_path.split('/Skyrim Stock/', 1)[1].split('/')[:-1]) if '/Skyrim Stock/' in binary_path else ""
bin_path_end = '/' + '/'.join(binary_path.split('/Skyrim Stock/', 1)[1].split('/')) if '/Skyrim Stock/' in binary_path else ""
elif "Stock Game Folder" in binary_path:
dir_type = "stockgamefolder"
path_end = '/' + '/'.join(binary_path.split('/Stock Game Folder/', 1)[1].split('/')) if '/Stock Game Folder/' in binary_path else ""
elif "root/Skyrim Special Edition" in binary_path:
dir_type = "rootskyrimse"
path_end = '/' + binary_path.split('root/Skyrim Special Edition', 1)[1] if 'root/Skyrim Special Edition' in binary_path else ""
bin_path_end = '/' + binary_path.split('root/Skyrim Special Edition', 1)[1] if 'root/Skyrim Special Edition' in binary_path else ""
elif "steamapps" in binary_path:
# Steamapps found
if basegame_sdcard:
path_middle = steam_library.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in steam_library else steam_library
drive_letter = "D:"
else:
path_middle = steam_library.split('steamapps', 1)[0] if 'steamapps' in steam_library else steam_library
path_end = '/' + '/'.join(binary_path.split('/steamapps/', 1)[1].split('/')[:-1]) if '/steamapps/' in binary_path else ""
bin_path_end = '/' + '/'.join(binary_path.split('/steamapps/', 1)[1].split('/')) if '/steamapps/' in binary_path else ""
else:
logger.warning(f"No matching pattern found in the path: {binary_path}")
continue
# Combine paths
full_bin_path = f"{bin_path_start}={drive_letter}{path_middle}{bin_path_end}"
full_path = f"{path_start}={drive_letter}{path_middle}{path_end}"
# Replace forward slashes with double backslashes
new_path = full_path.replace('/', '\\\\')
# Update the lines
lines[i] = f"{full_bin_path}\n"
# Find and update the workingDirectory line
for j, working_line in enumerate(lines):
if working_line.startswith(path_start):
lines[j] = f"{new_path}\n"
break
# Write the updated content back to the file
with open(modlist_ini, 'w') as f:
f.writelines(lines)
logger.info("Executable paths updated successfully")
return True
except Exception as e:
logger.error(f"Error updating executable paths: {e}")
return False
@staticmethod
def find_proton_binary(proton_version: str):
"""
Find the full path to the Proton binary given a version string (e.g., 'Proton 8.0', 'GE-Proton8-15').
Searches standard Steam library locations.
Returns the path to the 'files/bin/wine' executable, or None if not found.
"""
# Clean up the version string for directory matching
version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')]
# Standard Steam library locations
steam_common_paths = [
Path.home() / ".steam/steam/steamapps/common",
Path.home() / ".local/share/Steam/steamapps/common",
Path.home() / ".steam/root/steamapps/common"
]
# Special handling for Proton 9: try all possible directory names
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)
# Fallback: any Proton 9* directory
for subdir in base_path.glob("Proton 9*"):
wine_bin = subdir / "files/bin/wine"
if wine_bin.is_file():
return str(wine_bin)
# General case: try version patterns
for base_path in steam_common_paths:
if not base_path.is_dir():
continue
for pattern in version_patterns:
# Try direct match for Proton directory
proton_dir = base_path / pattern
wine_bin = proton_dir / "files/bin/wine"
if wine_bin.is_file():
return str(wine_bin)
# Try glob for GE/other variants
for subdir in base_path.glob(f"*{pattern}*"):
wine_bin = subdir / "files/bin/wine"
if wine_bin.is_file():
return str(wine_bin)
# Fallback: Try 'Proton - Experimental' if present
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.
Args:
appid (str): The Steam AppID to get paths for
Returns:
tuple: (compatdata_path, proton_path, wine_bin) or (None, None, None) if not found
"""
logger.info(f"Getting Proton paths for AppID {appid}")
# Find compatdata path
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
# Get Proton version
proton_version = WineUtils.get_proton_version(compatdata_path)
if proton_version == "Unknown":
logger.error(f"Could not determine Proton version for AppID {appid}")
return None, None, None
# Find Proton binary
wine_bin = WineUtils.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
# Get Proton path (parent of wine binary)
proton_path = str(Path(wine_bin).parent.parent)
logger.debug(f"Found Proton path: {proton_path}")
return compatdata_path, proton_path, wine_bin