mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 19:47:00 +01:00
Sync from development - prepare for v0.1.1
This commit is contained in:
@@ -28,15 +28,25 @@ def get_appimage_path() -> Optional[Path]:
|
||||
This uses the APPIMAGE environment variable set by the AppImage runtime.
|
||||
This is the standard, reliable method for AppImage path detection.
|
||||
|
||||
For security, this validates that the AppImage is actually Jackify to prevent
|
||||
accidentally updating other AppImages when running from development environments.
|
||||
|
||||
Returns:
|
||||
Optional[Path]: Path to the AppImage file if running as AppImage, None otherwise
|
||||
Optional[Path]: Path to the AppImage file if running as Jackify AppImage, None otherwise
|
||||
"""
|
||||
if not is_appimage():
|
||||
return None
|
||||
|
||||
appimage_path = os.environ.get('APPIMAGE')
|
||||
if appimage_path and os.path.exists(appimage_path):
|
||||
return Path(appimage_path)
|
||||
path = Path(appimage_path)
|
||||
|
||||
# Validate this is actually a Jackify AppImage to prevent updating wrong apps
|
||||
if 'jackify' in path.name.lower():
|
||||
return path
|
||||
else:
|
||||
# Running from different AppImage (e.g., development in Cursor.AppImage)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,958 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Path Handler Module
|
||||
Handles path-related operations for ModOrganizer.ini and other configuration files
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union, Dict, Any, List, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
# Initialize logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- Configuration (Adapted from Proposal) ---
|
||||
# Define known script extender executables (lowercase for comparisons)
|
||||
TARGET_EXECUTABLES_LOWER = ["skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "falloutnv.exe"]
|
||||
# Define known stock game folder names (case-sensitive, as they appear on disk)
|
||||
STOCK_GAME_FOLDERS = ["Stock Game", "Game Root", "Stock Folder", "Skyrim Stock"]
|
||||
# Define the SD card path prefix on Steam Deck/Linux
|
||||
SDCARD_PREFIX = '/run/media/mmcblk0p1/'
|
||||
|
||||
class PathHandler:
|
||||
"""
|
||||
Handles path-related operations for ModOrganizer.ini and other configuration files
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
|
||||
"""
|
||||
Removes the '/run/media/mmcblk0p1/' prefix if present.
|
||||
Returns the path as a POSIX-style string (using /).
|
||||
"""
|
||||
path_str = path_obj.as_posix() # Work with consistent forward slashes
|
||||
if path_str.lower().startswith(SDCARD_PREFIX.lower()):
|
||||
# Return the part *after* the prefix, ensuring no leading slash remains unless root
|
||||
relative_part = path_str[len(SDCARD_PREFIX):]
|
||||
return relative_part if relative_part else "." # Return '.' if it was exactly the prefix
|
||||
return path_str
|
||||
|
||||
@staticmethod
|
||||
def update_mo2_ini_paths(
|
||||
modlist_ini_path: Path,
|
||||
modlist_dir_path: Path,
|
||||
modlist_sdcard: bool,
|
||||
steam_library_common_path: Optional[Path] = None,
|
||||
basegame_dir_name: Optional[str] = None,
|
||||
basegame_sdcard: bool = False # Default to False if not provided
|
||||
) -> bool:
|
||||
logger.info(f"[DEBUG] update_mo2_ini_paths called with: modlist_ini_path={modlist_ini_path}, modlist_dir_path={modlist_dir_path}, modlist_sdcard={modlist_sdcard}, steam_library_common_path={steam_library_common_path}, basegame_dir_name={basegame_dir_name}, basegame_sdcard={basegame_sdcard}")
|
||||
if not modlist_ini_path.is_file():
|
||||
logger.error(f"ModOrganizer.ini not found at specified path: {modlist_ini_path}")
|
||||
# Attempt to create a minimal INI
|
||||
try:
|
||||
logger.warning("Creating minimal ModOrganizer.ini with [General] section.")
|
||||
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.write('[General]\n')
|
||||
# Continue as if file existed
|
||||
except Exception as e:
|
||||
logger.critical(f"Failed to create minimal ModOrganizer.ini: {e}")
|
||||
return False
|
||||
if not modlist_dir_path.is_dir():
|
||||
logger.error(f"Modlist directory not found or not a directory: {modlist_dir_path}")
|
||||
# Warn but continue
|
||||
|
||||
# --- Bulletproof game directory detection ---
|
||||
# 1. Get all Steam libraries and log them
|
||||
all_steam_libraries = PathHandler.get_all_steam_library_paths()
|
||||
logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}")
|
||||
import sys
|
||||
if hasattr(sys, 'argv') and any(arg in ('--debug', '-d') for arg in sys.argv):
|
||||
# Debug logging for Steam libraries detection - use logger if available
|
||||
if hasattr(globals(), 'logger') and logger:
|
||||
logger.debug(f"Detected Steam libraries: {all_steam_libraries}")
|
||||
# If no logger available, this debug info is not critical for user operation
|
||||
|
||||
# 2. For each library, check for the canonical vanilla game directory
|
||||
GAME_DIR_NAMES = {
|
||||
"Skyrim Special Edition": "Skyrim Special Edition",
|
||||
"Fallout 4": "Fallout 4",
|
||||
"Fallout New Vegas": "Fallout New Vegas",
|
||||
"Oblivion": "Oblivion"
|
||||
}
|
||||
canonical_name = None
|
||||
if basegame_dir_name and basegame_dir_name in GAME_DIR_NAMES:
|
||||
canonical_name = GAME_DIR_NAMES[basegame_dir_name]
|
||||
elif basegame_dir_name:
|
||||
canonical_name = basegame_dir_name # fallback, but should match above
|
||||
gamepath_target_dir = None
|
||||
gamepath_target_is_sdcard = modlist_sdcard
|
||||
checked_candidates = []
|
||||
if canonical_name:
|
||||
for lib in all_steam_libraries:
|
||||
candidate = lib / "steamapps" / "common" / canonical_name
|
||||
checked_candidates.append(str(candidate))
|
||||
logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}")
|
||||
if candidate.is_dir():
|
||||
gamepath_target_dir = candidate
|
||||
logger.info(f"Found vanilla game directory: {candidate}")
|
||||
break
|
||||
if not gamepath_target_dir:
|
||||
logger.error(f"Could not find vanilla game directory '{canonical_name}' in any Steam library. Checked: {checked_candidates}")
|
||||
# 4. Prompt the user for the path
|
||||
print("\nCould not automatically detect a Stock Game or vanilla game directory.")
|
||||
print("Please enter the full path to your vanilla game directory (e.g., /path/to/Skyrim Special Edition):")
|
||||
while True:
|
||||
user_input = input("Game directory path: ").strip()
|
||||
user_path = Path(user_input)
|
||||
logger.info(f"[DEBUG] User entered: {user_input}")
|
||||
if user_path.is_dir():
|
||||
exe_candidates = list(user_path.glob('*.exe'))
|
||||
logger.info(f"[DEBUG] .exe files in user path: {exe_candidates}")
|
||||
if exe_candidates:
|
||||
gamepath_target_dir = user_path
|
||||
logger.info(f"User provided valid vanilla game directory: {gamepath_target_dir}")
|
||||
break
|
||||
else:
|
||||
print("Directory exists but does not appear to contain the game executable. Please check and try again.")
|
||||
logger.warning("User path exists but no .exe files found.")
|
||||
else:
|
||||
print("Directory not found. Please enter a valid path.")
|
||||
logger.warning("User path does not exist.")
|
||||
if not gamepath_target_dir:
|
||||
logger.critical("[FATAL] Could not determine a valid target directory for gamePath. Check configuration and paths. Aborting update.")
|
||||
return False
|
||||
|
||||
# 3. Update gamePath, binary, and workingDirectory entries in the INI
|
||||
logger.debug(f"Determined gamePath target directory: {gamepath_target_dir}")
|
||||
logger.debug(f"gamePath target is on SD card: {gamepath_target_is_sdcard}")
|
||||
try:
|
||||
logger.debug(f"Reading original INI file: {modlist_ini_path}")
|
||||
with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
original_lines = f.readlines()
|
||||
|
||||
# --- Find and robustly update gamePath line ---
|
||||
gamepath_line_num = -1
|
||||
general_section_line = -1
|
||||
for i, line in enumerate(original_lines):
|
||||
if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE):
|
||||
general_section_line = i
|
||||
if re.match(r'^\s*gamepath\s*=\s*', line, re.IGNORECASE):
|
||||
gamepath_line_num = i
|
||||
break
|
||||
processed_str = PathHandler._strip_sdcard_path_prefix(gamepath_target_dir)
|
||||
windows_style_single = processed_str.replace('/', '\\')
|
||||
gamepath_drive_letter = "D:" if gamepath_target_is_sdcard else "Z:"
|
||||
# Use robust formatter
|
||||
formatted_gamepath = PathHandler._format_gamepath_for_mo2(f'{gamepath_drive_letter}{windows_style_single}')
|
||||
new_gamepath_line = f'gamePath = @ByteArray({formatted_gamepath})\n'
|
||||
if gamepath_line_num != -1:
|
||||
logger.info(f"Updating existing gamePath line: {original_lines[gamepath_line_num].strip()} -> {new_gamepath_line.strip()}")
|
||||
original_lines[gamepath_line_num] = new_gamepath_line
|
||||
else:
|
||||
insert_at = general_section_line + 1 if general_section_line != -1 else 0
|
||||
logger.info(f"Adding missing gamePath line at line {insert_at+1}: {new_gamepath_line.strip()}")
|
||||
original_lines.insert(insert_at, new_gamepath_line)
|
||||
|
||||
# --- Update customExecutables binaries and workingDirectories ---
|
||||
TARGET_EXECUTABLES_LOWER = [
|
||||
"skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "falloutnv.exe"
|
||||
]
|
||||
in_custom_exec = False
|
||||
for i, line in enumerate(original_lines):
|
||||
if re.match(r'^\s*\[customExecutables\]\s*$', line, re.IGNORECASE):
|
||||
in_custom_exec = True
|
||||
continue
|
||||
if in_custom_exec and re.match(r'^\s*\[.*\]\s*$', line):
|
||||
in_custom_exec = False
|
||||
if in_custom_exec:
|
||||
m = re.match(r'^(\d+)\\binary\s*=\s*(.*)$', line.strip(), re.IGNORECASE)
|
||||
if m:
|
||||
idx, old_path = m.group(1), m.group(2)
|
||||
exe_name = os.path.basename(old_path).lower()
|
||||
if exe_name in TARGET_EXECUTABLES_LOWER:
|
||||
new_path = f'{gamepath_drive_letter}/{PathHandler._strip_sdcard_path_prefix(gamepath_target_dir)}/{exe_name}'
|
||||
# Use robust formatter
|
||||
new_path = PathHandler._format_binary_for_mo2(new_path)
|
||||
logger.info(f"Updating binary for entry {idx}: {old_path} -> {new_path}")
|
||||
original_lines[i] = f'{idx}\\binary = {new_path}\n'
|
||||
m_wd = re.match(r'^(\d+)\\workingDirectory\s*=\s*(.*)$', line.strip(), re.IGNORECASE)
|
||||
if m_wd:
|
||||
idx, old_wd = m_wd.group(1), m_wd.group(2)
|
||||
new_wd = f'{gamepath_drive_letter}{windows_style_single}'
|
||||
# Use robust formatter
|
||||
new_wd = PathHandler._format_workingdir_for_mo2(new_wd)
|
||||
logger.info(f"Updating workingDirectory for entry {idx}: {old_wd} -> {new_wd}")
|
||||
original_lines[i] = f'{idx}\\workingDirectory = {new_wd}\n'
|
||||
|
||||
# --- Backup and Write New INI ---
|
||||
backup_path = modlist_ini_path.with_suffix(f".{datetime.now().strftime('%Y%m%d_%H%M%S')}.bak")
|
||||
try:
|
||||
shutil.copy2(modlist_ini_path, backup_path)
|
||||
logger.info(f"Backed up original INI to: {backup_path}")
|
||||
except Exception as bak_err:
|
||||
logger.error(f"Failed to backup original INI file: {bak_err}")
|
||||
return False
|
||||
try:
|
||||
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(original_lines)
|
||||
logger.info(f"Successfully wrote updated paths to {modlist_ini_path}")
|
||||
return True
|
||||
except Exception as write_err:
|
||||
logger.error(f"Failed to write updated INI file {modlist_ini_path}: {write_err}", exc_info=True)
|
||||
logger.error("Attempting to restore from backup...")
|
||||
try:
|
||||
shutil.move(backup_path, modlist_ini_path)
|
||||
logger.info("Successfully restored original INI from backup.")
|
||||
except Exception as restore_err:
|
||||
logger.critical(f"CRITICAL FAILURE: Could not write new INI and failed to restore backup {backup_path}. Manual intervention required at {modlist_ini_path}! Error: {restore_err}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred during INI path update: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def edit_resolution(modlist_ini, resolution):
|
||||
"""
|
||||
Edit resolution settings in ModOrganizer.ini
|
||||
|
||||
Args:
|
||||
modlist_ini (str): Path to ModOrganizer.ini
|
||||
resolution (str): Resolution in the format "1920x1080"
|
||||
|
||||
Returns:
|
||||
bool: True on success, False on failure
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Editing resolution settings to {resolution}...")
|
||||
|
||||
# Parse resolution
|
||||
width, height = resolution.split('x')
|
||||
|
||||
# Read the current ModOrganizer.ini
|
||||
with open(modlist_ini, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Replace width and height settings
|
||||
content = re.sub(r'^width\s*=\s*\d+$', f'width = {width}', content, flags=re.MULTILINE)
|
||||
content = re.sub(r'^height\s*=\s*\d+$', f'height = {height}', content, flags=re.MULTILINE)
|
||||
|
||||
# Write the updated content back to the file
|
||||
with open(modlist_ini, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info("Resolution settings edited successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error editing resolution settings: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full):
|
||||
"""
|
||||
Create dxvk.conf file in the appropriate location
|
||||
|
||||
Args:
|
||||
modlist_dir (str): Path to the modlist directory
|
||||
modlist_sdcard (bool): Whether the modlist is on an SD card
|
||||
steam_library (str): Path to the Steam library
|
||||
basegame_sdcard (bool): Whether the base game is on an SD card
|
||||
game_var_full (str): Full name of the game (e.g., "Skyrim Special Edition")
|
||||
|
||||
Returns:
|
||||
bool: True on success, False on failure
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating dxvk.conf file...")
|
||||
|
||||
# Determine the location for dxvk.conf
|
||||
dxvk_conf_path = None
|
||||
|
||||
# Check for common stock game directories
|
||||
stock_game_paths = [
|
||||
os.path.join(modlist_dir, "Stock Game"),
|
||||
os.path.join(modlist_dir, "STOCK GAME"),
|
||||
os.path.join(modlist_dir, "Game Root"),
|
||||
os.path.join(modlist_dir, "Stock Folder"),
|
||||
os.path.join(modlist_dir, "Skyrim Stock"),
|
||||
os.path.join(modlist_dir, "root", "Skyrim Special Edition"),
|
||||
os.path.join(steam_library, game_var_full)
|
||||
]
|
||||
|
||||
for path in stock_game_paths:
|
||||
if os.path.exists(path):
|
||||
dxvk_conf_path = os.path.join(path, "dxvk.conf")
|
||||
break
|
||||
|
||||
if not dxvk_conf_path:
|
||||
logger.error("Could not determine location for dxvk.conf")
|
||||
return False
|
||||
|
||||
# Create dxvk.conf content
|
||||
dxvk_conf_content = "dxvk.enableGraphicsPipelineLibrary = False\n"
|
||||
|
||||
# Write dxvk.conf to the appropriate location
|
||||
with open(dxvk_conf_path, 'w') as f:
|
||||
f.write(dxvk_conf_content)
|
||||
|
||||
logger.info(f"dxvk.conf created successfully at {dxvk_conf_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating dxvk.conf: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def find_steam_config_vdf() -> Optional[Path]:
|
||||
"""Finds the active Steam config.vdf file."""
|
||||
logger.debug("Searching for Steam config.vdf...")
|
||||
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():
|
||||
logger.info(f"Found config.vdf at: {potential_path}")
|
||||
return potential_path # Return Path object
|
||||
|
||||
logger.warning("Could not locate Steam's config.vdf file in standard locations.")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_steam_library() -> Optional[Path]:
|
||||
"""Find the primary Steam library common directory containing games."""
|
||||
logger.debug("Attempting to find Steam library...")
|
||||
|
||||
# Potential locations for libraryfolders.vdf
|
||||
libraryfolders_vdf_paths = [
|
||||
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
|
||||
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
|
||||
# Add other potential standard locations if necessary
|
||||
]
|
||||
|
||||
# Simple backup mechanism (optional but good practice)
|
||||
for path in libraryfolders_vdf_paths:
|
||||
if os.path.exists(path):
|
||||
backup_dir = os.path.join(os.path.dirname(path), "backups")
|
||||
if not os.path.exists(backup_dir):
|
||||
try:
|
||||
os.makedirs(backup_dir)
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not create backup directory {backup_dir}: {e}")
|
||||
|
||||
# Create timestamped backup if it doesn't exist for today
|
||||
timestamp = datetime.now().strftime("%Y%m%d")
|
||||
backup_filename = f"libraryfolders_{timestamp}.vdf.bak"
|
||||
backup_path = os.path.join(backup_dir, backup_filename)
|
||||
|
||||
if not os.path.exists(backup_path):
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(path, backup_path)
|
||||
logger.debug(f"Created backup of libraryfolders.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of libraryfolders.vdf: {e}")
|
||||
# Continue anyway, as we're only reading the file
|
||||
pass
|
||||
|
||||
libraryfolders_vdf_path_obj = None # Will hold the Path object
|
||||
found_path_str = None
|
||||
for path_str in libraryfolders_vdf_paths:
|
||||
if os.path.exists(path_str):
|
||||
found_path_str = path_str # Keep the string path for logging/opening
|
||||
libraryfolders_vdf_path_obj = Path(path_str) # Convert to Path object here
|
||||
logger.debug(f"Found libraryfolders.vdf at: {path_str}")
|
||||
break
|
||||
|
||||
# Check using the Path object's is_file() method
|
||||
if not libraryfolders_vdf_path_obj or not libraryfolders_vdf_path_obj.is_file():
|
||||
logger.warning("libraryfolders.vdf not found or is not a file. Cannot automatically detect Steam Library.")
|
||||
return None
|
||||
|
||||
# Parse the VDF file to extract library paths
|
||||
library_paths = []
|
||||
try:
|
||||
# Open using the original string path is fine, or use the Path object
|
||||
with open(found_path_str, 'r') as f: # Or use libraryfolders_vdf_path_obj
|
||||
content = f.read()
|
||||
|
||||
# Use regex to find all path entries
|
||||
path_matches = re.finditer(r'"path"\s*"([^"]+)"', content)
|
||||
for match in path_matches:
|
||||
library_path_str = match.group(1).replace('\\\\', '\\') # Fix potential double escapes
|
||||
common_path = os.path.join(library_path_str, "steamapps", "common")
|
||||
if os.path.isdir(common_path): # Verify the common path exists
|
||||
library_paths.append(Path(common_path))
|
||||
logger.debug(f"Found potential common path: {common_path}")
|
||||
else:
|
||||
logger.debug(f"Skipping non-existent common path derived from VDF: {common_path}")
|
||||
|
||||
logger.debug(f"Found {len(library_paths)} valid library common paths from VDF.")
|
||||
|
||||
# Return the first valid path found
|
||||
if library_paths:
|
||||
logger.info(f"Using Steam library common path: {library_paths[0]}")
|
||||
return library_paths[0]
|
||||
|
||||
# If no valid paths found in VDF, try the default structure
|
||||
logger.debug("No valid common paths found in VDF, checking default location...")
|
||||
default_common_path = Path.home() / ".steam/steam/steamapps/common"
|
||||
if default_common_path.is_dir():
|
||||
logger.info(f"Using default Steam library common path: {default_common_path}")
|
||||
return default_common_path
|
||||
|
||||
default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common"
|
||||
if default_common_path_local.is_dir():
|
||||
logger.info(f"Using default local Steam library common path: {default_common_path_local}")
|
||||
return default_common_path_local
|
||||
|
||||
logger.error("No valid Steam library common path found in VDF or default locations.")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing libraryfolders.vdf or finding Steam library: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_compat_data(appid: str) -> Optional[Path]:
|
||||
"""Find the compatdata directory for a given AppID."""
|
||||
if not appid or not appid.isdigit():
|
||||
logger.error(f"Invalid AppID provided for compatdata search: {appid}")
|
||||
return None
|
||||
|
||||
logger.debug(f"Searching for compatdata directory for AppID: {appid}")
|
||||
|
||||
# Prefer standard Steam locations
|
||||
possible_bases = [
|
||||
Path.home() / ".steam/steam/steamapps/compatdata",
|
||||
Path.home() / ".local/share/Steam/steamapps/compatdata",
|
||||
# Add likely SD card mount points if applicable
|
||||
# Path("/run/media/mmcblk0p1/steamapps/compatdata")
|
||||
]
|
||||
|
||||
# Check user's Steam Library path if available (more reliable)
|
||||
# Assuming PathHandler might store or be passed the library path
|
||||
# steam_lib_path = self.find_steam_library() # Or get from instance var if stored
|
||||
# if steam_lib_path and (steam_lib_path / "steamapps/compatdata").is_dir():
|
||||
# possible_bases.insert(0, steam_lib_path / "steamapps/compatdata") # Prioritize
|
||||
|
||||
for base_path in possible_bases:
|
||||
if not base_path.is_dir():
|
||||
logger.debug(f"Compatdata base path does not exist or is not a directory: {base_path}")
|
||||
continue
|
||||
|
||||
potential_path = base_path / appid
|
||||
if potential_path.is_dir():
|
||||
logger.info(f"Found compatdata directory: {potential_path}")
|
||||
return potential_path # Return Path object
|
||||
else:
|
||||
logger.debug(f"Compatdata for {appid} not found in {base_path}")
|
||||
|
||||
# Fallback: Broad search (can be slow, consider if needed)
|
||||
# try:
|
||||
# logger.debug(f"Compatdata not found in standard locations, attempting wider search...")
|
||||
# # This can be very slow and resource-intensive
|
||||
# # find_output = subprocess.check_output(['find', '/', '-type', 'd', '-name', appid, '-path', '*/compatdata/*', '-print', '-quit', '2>/dev/null'], text=True).strip()
|
||||
# # if find_output:
|
||||
# # logger.info(f"Found compatdata via find command: {find_output}")
|
||||
# # return Path(find_output)
|
||||
# except Exception as e:
|
||||
# logger.warning(f"Error during 'find' command for compatdata: {e}")
|
||||
|
||||
logger.warning(f"Compatdata directory for AppID {appid} not found.")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def detect_stock_game_path(game_type: str, steam_library: Path) -> Optional[Path]:
|
||||
"""
|
||||
Detect the stock game path for a given game type and Steam library
|
||||
Returns the path if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Map of game types to their Steam App IDs
|
||||
game_app_ids = {
|
||||
'skyrim': '489830', # Skyrim Special Edition
|
||||
'fallout4': '377160', # Fallout 4
|
||||
'fnv': '22380', # Fallout: New Vegas
|
||||
'oblivion': '22330' # The Elder Scrolls IV: Oblivion
|
||||
}
|
||||
|
||||
if game_type not in game_app_ids:
|
||||
return None
|
||||
|
||||
app_id = game_app_ids[game_type]
|
||||
game_path = steam_library / 'steamapps' / 'common'
|
||||
|
||||
# List of possible game directory names
|
||||
possible_names = {
|
||||
'skyrim': ['Skyrim Special Edition', 'Skyrim'],
|
||||
'fallout4': ['Fallout 4'],
|
||||
'fnv': ['Fallout New Vegas', 'FalloutNV'],
|
||||
'oblivion': ['Oblivion']
|
||||
}
|
||||
|
||||
if game_type not in possible_names:
|
||||
return None
|
||||
|
||||
# Check each possible directory name
|
||||
for name in possible_names[game_type]:
|
||||
potential_path = game_path / name
|
||||
if potential_path.exists():
|
||||
return potential_path
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error detecting stock game path: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_steam_library_path(steam_path: str) -> Optional[str]:
|
||||
"""Get the Steam library path from libraryfolders.vdf."""
|
||||
try:
|
||||
libraryfolders_path = os.path.join(steam_path, 'steamapps', 'libraryfolders.vdf')
|
||||
if not os.path.exists(libraryfolders_path):
|
||||
return None
|
||||
|
||||
with open(libraryfolders_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse the VDF content
|
||||
libraries = {}
|
||||
current_library = None
|
||||
for line in content.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith('"path"'):
|
||||
current_library = line.split('"')[3].replace('\\\\', '\\')
|
||||
elif line.startswith('"apps"') and current_library:
|
||||
libraries[current_library] = True
|
||||
|
||||
# Return the first library path that exists
|
||||
for library_path in libraries:
|
||||
if os.path.exists(library_path):
|
||||
return library_path
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Steam library path: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_all_steam_library_paths() -> List[Path]:
|
||||
"""Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak)."""
|
||||
logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...")
|
||||
vdf_paths = [
|
||||
Path.home() / ".steam/steam/config/libraryfolders.vdf",
|
||||
Path.home() / ".local/share/Steam/config/libraryfolders.vdf",
|
||||
Path.home() / ".steam/root/config/libraryfolders.vdf",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf", # Flatpak
|
||||
]
|
||||
library_paths = set()
|
||||
for vdf_path in vdf_paths:
|
||||
if vdf_path.is_file():
|
||||
logger.info(f"[DEBUG] Parsing libraryfolders.vdf: {vdf_path}")
|
||||
try:
|
||||
with open(vdf_path) as f:
|
||||
for line in f:
|
||||
m = re.search(r'"path"\s*"([^"]+)"', line)
|
||||
if m:
|
||||
lib_path = Path(m.group(1))
|
||||
library_paths.add(lib_path)
|
||||
except Exception as e:
|
||||
logger.error(f"[DEBUG] Failed to parse {vdf_path}: {e}")
|
||||
logger.info(f"[DEBUG] All detected Steam libraries: {library_paths}")
|
||||
return list(library_paths)
|
||||
|
||||
# Moved _find_shortcuts_vdf here from ShortcutHandler
|
||||
def _find_shortcuts_vdf(self) -> Optional[str]:
|
||||
"""Helper to find the active shortcuts.vdf file for a user.
|
||||
|
||||
Iterates through userdata directories and returns the path to the
|
||||
first found shortcuts.vdf file.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The full path to the shortcuts.vdf file, or None if not found.
|
||||
"""
|
||||
# This implementation was moved from ShortcutHandler
|
||||
userdata_base_paths = [
|
||||
os.path.expanduser("~/.steam/steam/userdata"),
|
||||
os.path.expanduser("~/.local/share/Steam/userdata"),
|
||||
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/userdata")
|
||||
]
|
||||
found_vdf_path = None
|
||||
for base_path in userdata_base_paths:
|
||||
if not os.path.isdir(base_path):
|
||||
logger.debug(f"Userdata base path not found or not a directory: {base_path}")
|
||||
continue
|
||||
logger.debug(f"Searching for user IDs in: {base_path}")
|
||||
try:
|
||||
for item in os.listdir(base_path):
|
||||
user_path = os.path.join(base_path, item)
|
||||
if os.path.isdir(user_path) and item.isdigit():
|
||||
logger.debug(f"Checking user directory: {user_path}")
|
||||
config_path = os.path.join(user_path, "config")
|
||||
shortcuts_file = os.path.join(config_path, "shortcuts.vdf")
|
||||
if os.path.isfile(shortcuts_file):
|
||||
logger.info(f"Found shortcuts.vdf at: {shortcuts_file}")
|
||||
found_vdf_path = shortcuts_file
|
||||
break # Found it for this base path
|
||||
else:
|
||||
logger.debug(f"shortcuts.vdf not found in {config_path}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not access directory {base_path}: {e}")
|
||||
continue # Try next base path
|
||||
if found_vdf_path:
|
||||
break # Found it in this base path
|
||||
if not found_vdf_path:
|
||||
logger.error("Could not find any shortcuts.vdf file in common Steam locations.")
|
||||
return found_vdf_path
|
||||
|
||||
@staticmethod
|
||||
def find_game_install_paths(target_appids: Dict[str, str]) -> Dict[str, Path]:
|
||||
"""
|
||||
Find installation paths for multiple specified games using Steam app IDs.
|
||||
|
||||
Args:
|
||||
target_appids: Dictionary mapping game names to app IDs
|
||||
|
||||
Returns:
|
||||
Dictionary mapping game names to their installation paths
|
||||
"""
|
||||
# Get all Steam library paths
|
||||
library_paths = PathHandler.get_all_steam_library_paths()
|
||||
if not library_paths:
|
||||
logger.warning("Failed to find any Steam library paths")
|
||||
return {}
|
||||
|
||||
results = {}
|
||||
|
||||
# For each library path, look for each target game
|
||||
for library_path in library_paths:
|
||||
# Check if the common directory exists
|
||||
common_dir = library_path / "common"
|
||||
if not common_dir.is_dir():
|
||||
logger.debug(f"No 'common' directory in library: {library_path}")
|
||||
continue
|
||||
|
||||
# Get subdirectories in common dir
|
||||
try:
|
||||
game_dirs = [d for d in common_dir.iterdir() if d.is_dir()]
|
||||
except (PermissionError, OSError) as e:
|
||||
logger.warning(f"Cannot access directory {common_dir}: {e}")
|
||||
continue
|
||||
|
||||
# For each app ID, check if we find its directory
|
||||
for game_name, app_id in target_appids.items():
|
||||
if game_name in results:
|
||||
continue # Already found this game
|
||||
|
||||
# Try to find by appmanifest
|
||||
appmanifest_path = library_path / f"appmanifest_{app_id}.acf"
|
||||
if appmanifest_path.is_file():
|
||||
# Find the installdir value
|
||||
try:
|
||||
with open(appmanifest_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
match = re.search(r'"installdir"\s+"([^"]+)"', content)
|
||||
if match:
|
||||
install_dir_name = match.group(1)
|
||||
install_path = common_dir / install_dir_name
|
||||
if install_path.is_dir():
|
||||
results[game_name] = install_path
|
||||
logger.info(f"Found {game_name} at {install_path}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading appmanifest for {game_name}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def replace_gamepath(self, modlist_ini_path: Path, new_game_path: Path, modlist_sdcard: bool = False) -> bool:
|
||||
"""
|
||||
Updates the gamePath value in ModOrganizer.ini to the specified path.
|
||||
Strictly matches the bash script: only replaces an existing gamePath line.
|
||||
If the file or line does not exist, logs error and aborts.
|
||||
"""
|
||||
logger.info(f"Replacing gamePath in {modlist_ini_path} with {new_game_path}")
|
||||
if not modlist_ini_path.is_file():
|
||||
logger.error(f"ModOrganizer.ini not found at: {modlist_ini_path}")
|
||||
return False
|
||||
try:
|
||||
with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
drive_letter = "D:" if modlist_sdcard else "Z:"
|
||||
processed_path = self._strip_sdcard_path_prefix(new_game_path)
|
||||
windows_style = processed_path.replace('/', '\\')
|
||||
windows_style_double = windows_style.replace('\\', '\\\\')
|
||||
new_gamepath_line = f'gamePath=@ByteArray({drive_letter}{windows_style_double})\n'
|
||||
gamepath_found = False
|
||||
for i, line in enumerate(lines):
|
||||
# Make the check case-insensitive and robust to whitespace
|
||||
if re.match(r'^\s*gamepath\s*=.*$', line, re.IGNORECASE):
|
||||
lines[i] = new_gamepath_line
|
||||
gamepath_found = True
|
||||
break
|
||||
if not gamepath_found:
|
||||
logger.error("No gamePath line found in ModOrganizer.ini")
|
||||
return False
|
||||
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
logger.info(f"Successfully updated gamePath to {new_game_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error replacing gamePath: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
# =====================================================================================
|
||||
# CRITICAL: DO NOT CHANGE THIS FUNCTION WITHOUT UPDATING TESTS AND CONSULTING PROJECT LEAD
|
||||
# This function implements the exact path rewriting logic required for ModOrganizer.ini
|
||||
# to match the original, robust bash script. Any change here risks breaking modlist
|
||||
# configuration for users. If you must change this, update all relevant tests and
|
||||
# consult the Project Lead for Jackify. See also omni-guides.sh for reference logic.
|
||||
# =====================================================================================
|
||||
def edit_binary_working_paths(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool, steam_libraries: Optional[List[Path]] = None) -> bool:
|
||||
"""
|
||||
Update all binary paths and working directories in a ModOrganizer.ini file.
|
||||
Handles various ModOrganizer.ini formats (single or double backslashes in keys).
|
||||
When updating gamePath, binary, and workingDirectory, retain the original stock folder (Stock Game, Game Root, etc) if present in the current value.
|
||||
steam_libraries: Optional[List[Path]] - already-discovered Steam library paths to use for vanilla detection.
|
||||
|
||||
# DO NOT CHANGE THIS LOGIC WITHOUT UPDATING TESTS AND CONSULTING THE PROJECT LEAD
|
||||
# This is a critical, regression-prone area. See omni-guides.sh for reference.
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"Updating binary paths and working directories in {modlist_ini_path} to use root: {modlist_dir_path}")
|
||||
if not modlist_ini_path.is_file():
|
||||
logger.error(f"INI file {modlist_ini_path} does not exist")
|
||||
return False
|
||||
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
game_path_updated = False
|
||||
binary_paths_updated = 0
|
||||
working_dirs_updated = 0
|
||||
binary_lines = []
|
||||
working_dir_lines = []
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
binary_match = re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE)
|
||||
if binary_match:
|
||||
index = binary_match.group(1)
|
||||
backslash_style = binary_match.group(2)
|
||||
binary_lines.append((i, stripped, index, backslash_style))
|
||||
wd_match = re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE)
|
||||
if wd_match:
|
||||
index = wd_match.group(1)
|
||||
backslash_style = wd_match.group(2)
|
||||
working_dir_lines.append((i, stripped, index, backslash_style))
|
||||
binary_paths_by_index = {}
|
||||
# Use provided steam_libraries if available, else detect
|
||||
if steam_libraries is None or not steam_libraries:
|
||||
steam_libraries = PathHandler.get_all_steam_library_paths()
|
||||
for i, line, index, backslash_style in binary_lines:
|
||||
parts = line.split('=', 1)
|
||||
if len(parts) != 2:
|
||||
logger.error(f"Malformed binary line: {line}")
|
||||
continue
|
||||
key_part, value_part = parts
|
||||
exe_name = os.path.basename(value_part)
|
||||
drive_prefix = "D:" if modlist_sdcard else "Z:"
|
||||
rel_path = None
|
||||
# --- BEGIN: FULL PARITY LOGIC ---
|
||||
if 'steamapps' in value_part:
|
||||
idx = value_part.index('steamapps')
|
||||
subpath = value_part[idx:].lstrip('/')
|
||||
correct_steam_lib = None
|
||||
for lib in steam_libraries:
|
||||
if (lib / subpath.split('/')[2]).exists():
|
||||
correct_steam_lib = lib.parent
|
||||
break
|
||||
if not correct_steam_lib and steam_libraries:
|
||||
correct_steam_lib = steam_libraries[0].parent
|
||||
if correct_steam_lib:
|
||||
new_binary_path = f"{drive_prefix}/{correct_steam_lib}/{subpath}".replace('\\', '/').replace('//', '/')
|
||||
else:
|
||||
logger.error("Could not determine correct Steam library for vanilla game path.")
|
||||
continue
|
||||
else:
|
||||
found_stock = None
|
||||
for folder in STOCK_GAME_FOLDERS:
|
||||
folder_pattern = f"/{folder.replace(' ', '')}".lower()
|
||||
value_part_lower = value_part.replace(' ', '').lower()
|
||||
if folder_pattern in value_part_lower:
|
||||
idx = value_part_lower.index(folder_pattern)
|
||||
rel_path = value_part[idx:].lstrip('/')
|
||||
found_stock = folder
|
||||
break
|
||||
if not rel_path:
|
||||
mods_pattern = "/mods/"
|
||||
if mods_pattern in value_part:
|
||||
idx = value_part.index(mods_pattern)
|
||||
rel_path = value_part[idx:].lstrip('/')
|
||||
else:
|
||||
rel_path = exe_name
|
||||
new_binary_path = f"{drive_prefix}/{modlist_dir_path}/{rel_path}".replace('\\', '/').replace('//', '/')
|
||||
formatted_binary_path = PathHandler._format_binary_for_mo2(new_binary_path)
|
||||
new_binary_line = f"{index}{backslash_style}binary={formatted_binary_path}"
|
||||
logger.debug(f"Updating binary path: {line.strip()} -> {new_binary_line}")
|
||||
lines[i] = new_binary_line + "\n"
|
||||
binary_paths_updated += 1
|
||||
binary_paths_by_index[index] = formatted_binary_path
|
||||
for j, wd_line, index, backslash_style in working_dir_lines:
|
||||
if index in binary_paths_by_index:
|
||||
binary_path = binary_paths_by_index[index]
|
||||
wd_path = os.path.dirname(binary_path)
|
||||
drive_prefix = "D:" if modlist_sdcard else "Z:"
|
||||
if wd_path.startswith("D:") or wd_path.startswith("Z:"):
|
||||
wd_path = wd_path[2:]
|
||||
wd_path = drive_prefix + wd_path
|
||||
formatted_wd_path = PathHandler._format_workingdir_for_mo2(wd_path)
|
||||
key_part = f"{index}{backslash_style}workingDirectory"
|
||||
new_wd_line = f"{key_part}={formatted_wd_path}"
|
||||
logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}")
|
||||
lines[j] = new_wd_line + "\n"
|
||||
working_dirs_updated += 1
|
||||
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
logger.info(f"edit_binary_working_paths completed: Game path updated: {game_path_updated}, Binary paths updated: {binary_paths_updated}, Working directories updated: {working_dirs_updated}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating binary paths in {modlist_ini_path}: {str(e)}")
|
||||
return False
|
||||
|
||||
def _format_path_for_mo2(self, path: str) -> str:
|
||||
"""Format a path for MO2's ModOrganizer.ini file (working directories)."""
|
||||
# Replace forward slashes with double backslashes
|
||||
formatted = path.replace('/', '\\')
|
||||
# Ensure we have a Windows drive letter format
|
||||
if not re.match(r'^[A-Za-z]:', formatted):
|
||||
formatted = 'D:' + formatted
|
||||
# Double the backslashes for the INI file format
|
||||
formatted = formatted.replace('\\', '\\\\')
|
||||
return formatted
|
||||
|
||||
def _format_binary_path_for_mo2(self, path_str):
|
||||
"""Format a binary path for MO2 config file.
|
||||
|
||||
Binary paths need forward slashes (/) in the path portion.
|
||||
"""
|
||||
# Replace backslashes with forward slashes
|
||||
return path_str.replace('\\', '/')
|
||||
|
||||
def _format_working_dir_for_mo2(self, path_str):
|
||||
"""
|
||||
Format a working directory path for MO2 config file.
|
||||
Ensures double backslashes throughout, as required by ModOrganizer.ini.
|
||||
"""
|
||||
import re
|
||||
path = path_str.replace('/', '\\')
|
||||
path = path.replace('\\', '\\\\') # Double all backslashes
|
||||
# Ensure only one double backslash after drive letter
|
||||
path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def find_vanilla_game_paths(game_names=None) -> Dict[str, Path]:
|
||||
"""
|
||||
For each known game, iterate all Steam libraries and look for the canonical game directory name in steamapps/common.
|
||||
Returns a dict of found games and their paths.
|
||||
Args:
|
||||
game_names: Optional list of game names to check. If None, uses default supported games.
|
||||
Returns:
|
||||
Dict[str, Path]: Mapping of game name to found install Path.
|
||||
"""
|
||||
# Canonical game directory names (allow list for Fallout 3)
|
||||
GAME_DIR_NAMES = {
|
||||
"Skyrim Special Edition": ["Skyrim Special Edition"],
|
||||
"Fallout 4": ["Fallout 4"],
|
||||
"Fallout New Vegas": ["Fallout New Vegas"],
|
||||
"Oblivion": ["Oblivion"],
|
||||
"Fallout 3": ["Fallout 3", "Fallout 3 goty"]
|
||||
}
|
||||
if game_names is None:
|
||||
game_names = list(GAME_DIR_NAMES.keys())
|
||||
all_steam_libraries = PathHandler.get_all_steam_library_paths()
|
||||
logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}")
|
||||
found_games = {}
|
||||
for game in game_names:
|
||||
possible_names = GAME_DIR_NAMES.get(game, [game])
|
||||
for lib in all_steam_libraries:
|
||||
for name in possible_names:
|
||||
candidate = lib / "steamapps" / "common" / name
|
||||
logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}")
|
||||
if candidate.is_dir():
|
||||
found_games[game] = candidate
|
||||
logger.info(f"Found vanilla game directory for {game}: {candidate}")
|
||||
break # Stop after first found location
|
||||
if game in found_games:
|
||||
break
|
||||
return found_games
|
||||
|
||||
def _detect_stock_game_path(self):
|
||||
"""Detects common 'Stock Game' or 'Game Root' directories within the modlist path."""
|
||||
self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...")
|
||||
if not self.modlist_dir:
|
||||
self.logger.error("Modlist directory not set, cannot detect stock game path.")
|
||||
return False
|
||||
|
||||
modlist_path = Path(self.modlist_dir)
|
||||
# Always prefer 'Stock Game' if it exists, then fallback to others
|
||||
preferred_order = [
|
||||
"Stock Game",
|
||||
"STOCK GAME",
|
||||
"Skyrim Stock",
|
||||
"Stock Game Folder",
|
||||
"Stock Folder",
|
||||
Path("root/Skyrim Special Edition"),
|
||||
"Game Root" # 'Game Root' is now last
|
||||
]
|
||||
|
||||
found_path = None
|
||||
for name in preferred_order:
|
||||
potential_path = modlist_path / name
|
||||
if potential_path.is_dir():
|
||||
found_path = str(potential_path)
|
||||
self.logger.info(f"Found potential stock game directory: {found_path}")
|
||||
break # Found the first match
|
||||
if found_path:
|
||||
self.stock_game_path = found_path
|
||||
return True
|
||||
else:
|
||||
self.stock_game_path = None
|
||||
self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.")
|
||||
return True
|
||||
|
||||
# --- Add robust path formatters for INI fields ---
|
||||
@staticmethod
|
||||
def _format_gamepath_for_mo2(path: str) -> str:
|
||||
import re
|
||||
path = path.replace('/', '\\')
|
||||
path = re.sub(r'\\+', r'\\', path) # Collapse multiple backslashes
|
||||
# Ensure only one double backslash after drive letter
|
||||
path = re.sub(r'^([A-Z]:)\\+', r'\1\\', path)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _format_binary_for_mo2(path: str) -> str:
|
||||
import re
|
||||
path = path.replace('\\', '/')
|
||||
# Collapse multiple forward slashes after drive letter
|
||||
path = re.sub(r'^([A-Z]:)//+', r'\1/', path)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _format_workingdir_for_mo2(path: str) -> str:
|
||||
import re
|
||||
path = path.replace('/', '\\')
|
||||
path = path.replace('\\', '\\\\') # Double all backslashes
|
||||
# Ensure only one double backslash after drive letter
|
||||
path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path)
|
||||
return path
|
||||
|
||||
# --- End of PathHandler ---
|
||||
Reference in New Issue
Block a user