mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 11:37:01 +01:00
1091 lines
48 KiB
Python
1091 lines
48 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, List, Dict
|
|
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 _get_sd_card_mounts():
|
|
"""
|
|
Detect SD card mount points using df.
|
|
Returns list of actual mount paths from /run/media (e.g., /run/media/deck/MicroSD).
|
|
"""
|
|
import subprocess
|
|
import re
|
|
|
|
result = subprocess.run(['df', '-h'], capture_output=True, text=True, timeout=5)
|
|
sd_mounts = []
|
|
|
|
for line in result.stdout.split('\n'):
|
|
if '/run/media' in line:
|
|
parts = line.split()
|
|
if len(parts) >= 6:
|
|
mount_point = parts[-1] # Last column is the mount point
|
|
if mount_point.startswith('/run/media/'):
|
|
sd_mounts.append(mount_point)
|
|
|
|
# Sort by length (longest first) to match most specific paths first
|
|
sd_mounts.sort(key=len, reverse=True)
|
|
logger.debug(f"Detected SD card mounts from df: {sd_mounts}")
|
|
return sd_mounts
|
|
|
|
@staticmethod
|
|
def _strip_sdcard_path(path):
|
|
"""
|
|
Strip SD card mount prefix from path.
|
|
Handles both /run/media/mmcblk0p1 and /run/media/deck/UUID patterns.
|
|
Pattern: /run/media/deck/UUID/Games/... becomes /Games/...
|
|
Pattern: /run/media/mmcblk0p1/Games/... becomes /Games/...
|
|
"""
|
|
import re
|
|
|
|
# Pattern 1: /run/media/deck/UUID/... strip everything up to and including UUID
|
|
# This matches the bash: "${path#*/run/media/deck/*/*}"
|
|
deck_pattern = r'^/run/media/deck/[^/]+(/.*)?$'
|
|
match = re.match(deck_pattern, path)
|
|
if match:
|
|
stripped = match.group(1) if match.group(1) else "/"
|
|
logger.debug(f"Stripped SD card path (deck pattern): {path} -> {stripped}")
|
|
return stripped
|
|
|
|
# Pattern 2: /run/media/mmcblk0p1/... strip /run/media/mmcblk0p1
|
|
# This matches the bash: "${path#*mmcblk0p1}"
|
|
if path.startswith('/run/media/mmcblk0p1/'):
|
|
stripped = path.replace('/run/media/mmcblk0p1', '', 1)
|
|
logger.debug(f"Stripped SD card path (mmcblk pattern): {path} -> {stripped}")
|
|
return stripped
|
|
|
|
# No SD card pattern matched
|
|
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 = WineUtils._strip_sdcard_path(modlist_dir)
|
|
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 = WineUtils._strip_sdcard_path(modlist_dir)
|
|
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 = WineUtils._strip_sdcard_path(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(' ', '')]
|
|
|
|
# Get actual Steam library paths from libraryfolders.vdf (smart detection)
|
|
steam_common_paths = []
|
|
compatibility_paths = []
|
|
|
|
try:
|
|
from .path_handler import PathHandler
|
|
# Get root Steam library paths (without /steamapps/common suffix)
|
|
root_steam_libs = PathHandler.get_all_steam_library_paths()
|
|
for lib_path in root_steam_libs:
|
|
lib = Path(lib_path)
|
|
if lib.exists():
|
|
# Valve Proton: {library}/steamapps/common
|
|
common_path = lib / "steamapps/common"
|
|
if common_path.exists():
|
|
steam_common_paths.append(common_path)
|
|
# GE-Proton: same Steam installation root + compatibilitytools.d
|
|
compatibility_paths.append(lib / "compatibilitytools.d")
|
|
except Exception as e:
|
|
logger.warning(f"Could not detect Steam libraries from libraryfolders.vdf: {e}")
|
|
|
|
# Fallback locations if dynamic detection fails
|
|
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"
|
|
]
|
|
|
|
# Add standard compatibility tool locations (covers edge cases like Flatpak)
|
|
compatibility_paths.extend([
|
|
Path.home() / ".steam/root/compatibilitytools.d",
|
|
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d",
|
|
# Flatpak GE-Proton extension paths
|
|
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"
|
|
])
|
|
# 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 in both steamapps and compatibilitytools.d
|
|
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:
|
|
# 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 user's configured Proton version
|
|
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
|
|
|
|
# Final 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
|
|
|
|
@staticmethod
|
|
def get_steam_library_paths() -> List[Path]:
|
|
"""
|
|
Get all Steam library paths from libraryfolders.vdf (handles Flatpak, custom locations, etc.).
|
|
|
|
Returns:
|
|
List of Path objects for Steam library directories
|
|
"""
|
|
steam_common_paths = []
|
|
|
|
try:
|
|
from .path_handler import PathHandler
|
|
# Use existing PathHandler that reads libraryfolders.vdf
|
|
library_paths = PathHandler.get_all_steam_library_paths()
|
|
logger.info(f"PathHandler found Steam libraries: {library_paths}")
|
|
|
|
# Convert to steamapps/common paths for Proton scanning
|
|
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}")
|
|
|
|
# Always add fallback paths in case PathHandler missed something
|
|
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.
|
|
|
|
Returns:
|
|
List of Path objects for compatibility tool directories
|
|
"""
|
|
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",
|
|
# Flatpak GE-Proton extension paths
|
|
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 only existing paths
|
|
return [path for path in compat_paths if path.exists()]
|
|
|
|
@staticmethod
|
|
def scan_ge_proton_versions() -> List[Dict[str, any]]:
|
|
"""
|
|
Scan for available GE-Proton versions in compatibilitytools.d directories.
|
|
|
|
Returns:
|
|
List of dicts with version info, sorted by priority (newest first)
|
|
"""
|
|
logger.info("Scanning for available GE-Proton versions...")
|
|
|
|
found_versions = []
|
|
compat_paths = WineUtils.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:
|
|
# Look for GE-Proton directories
|
|
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
|
|
|
|
# Check for wine binary
|
|
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
|
|
|
|
# Parse version from directory name (e.g., "GE-Proton10-16")
|
|
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))
|
|
|
|
# Calculate priority: GE-Proton gets highest priority
|
|
# Priority format: 200 (base) + major*10 + minor (e.g., 200 + 100 + 16 = 316)
|
|
priority = 200 + (major_ver * 10) + minor_ver
|
|
|
|
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'
|
|
})
|
|
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}")
|
|
|
|
# Sort by priority (highest first, so newest GE-Proton versions come first)
|
|
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.
|
|
|
|
Returns:
|
|
List of dicts with version info, sorted by priority (best first)
|
|
"""
|
|
logger.info("Scanning for available Valve Proton versions...")
|
|
|
|
found_versions = []
|
|
steam_libs = WineUtils.get_steam_library_paths()
|
|
|
|
if not steam_libs:
|
|
logger.warning("No Steam library paths found")
|
|
return []
|
|
|
|
# Priority order for Valve Proton versions
|
|
# Note: GE-Proton uses 200+ range, so Valve Proton gets 100+ range
|
|
preferred_versions = [
|
|
("Proton - Experimental", 150), # Higher priority than regular Valve Proton
|
|
("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():
|
|
found_versions.append({
|
|
'name': version_name,
|
|
'path': proton_path,
|
|
'wine_bin': wine_bin,
|
|
'priority': priority,
|
|
'type': 'Valve-Proton'
|
|
})
|
|
logger.debug(f"Found {version_name} at {proton_path}")
|
|
|
|
# Sort by priority (highest first)
|
|
found_versions.sort(key=lambda x: x['priority'], reverse=True)
|
|
|
|
# Remove duplicates while preserving order
|
|
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-Proton + Valve Proton) with unified priority.
|
|
|
|
Priority Chain (highest to lowest):
|
|
1. GE-Proton10-16+ (priority 316+)
|
|
2. GE-Proton10-* (priority 200+)
|
|
3. Proton - Experimental (priority 150)
|
|
4. Proton 10.0 (priority 140)
|
|
5. Proton 9.0 (priority 130)
|
|
6. Proton 9.0 (Beta) (priority 125)
|
|
|
|
Returns:
|
|
List of dicts with version info, sorted by priority (best first)
|
|
"""
|
|
logger.info("Scanning for all available Proton versions...")
|
|
|
|
all_versions = []
|
|
|
|
# Scan GE-Proton versions (highest priority)
|
|
ge_versions = WineUtils.scan_ge_proton_versions()
|
|
all_versions.extend(ge_versions)
|
|
|
|
# Scan Valve Proton versions
|
|
valve_versions = WineUtils.scan_valve_proton_versions()
|
|
all_versions.extend(valve_versions)
|
|
|
|
# Sort by priority (highest first)
|
|
all_versions.sort(key=lambda x: x['priority'], reverse=True)
|
|
|
|
# Remove duplicates while preserving order
|
|
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 version (GE-Proton or Valve Proton) using unified precedence.
|
|
|
|
Returns:
|
|
Dict with version info for the best Proton, or None if none found
|
|
"""
|
|
available_versions = WineUtils.scan_all_proton_versions()
|
|
|
|
if not available_versions:
|
|
logger.warning("No compatible Proton versions found")
|
|
return None
|
|
|
|
# Return the highest priority version (first in sorted list)
|
|
best_version = available_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 version using fallback precedence.
|
|
Note: This method is kept for backward compatibility. Consider using select_best_proton() instead.
|
|
|
|
Returns:
|
|
Dict with version info for the best Proton, or None if none found
|
|
"""
|
|
available_versions = WineUtils.scan_valve_proton_versions()
|
|
|
|
if not available_versions:
|
|
logger.warning("No compatible Valve Proton versions found")
|
|
return None
|
|
|
|
# Return the highest priority version (first in sorted list)
|
|
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 compatible Proton version is available for workflows.
|
|
|
|
Returns:
|
|
tuple: (requirements_met, status_message, proton_info)
|
|
- requirements_met: True if compatible Proton found
|
|
- status_message: Human-readable status for display to user
|
|
- proton_info: Dict with Proton details if found, None otherwise
|
|
"""
|
|
logger.info("Checking Proton requirements for workflow...")
|
|
|
|
# Scan for available Proton versions (includes GE-Proton + Valve Proton)
|
|
best_proton = WineUtils.select_best_proton()
|
|
|
|
if best_proton:
|
|
# Compatible Proton found
|
|
proton_type = best_proton.get('type', 'Unknown')
|
|
status_msg = f"✓ 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
|
|
else:
|
|
# No compatible Proton found
|
|
status_msg = "✗ 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 |