Release v0.7.2.1 - Mojave Express Support, BSA Decompressor Fix

This commit is contained in:
Omni
2026-07-25 13:26:02 +01:00
parent ada21f90c8
commit 22bf5e30e5
21 changed files with 1488 additions and 169 deletions
@@ -0,0 +1,216 @@
"""
MEW Integration Helper
Helper functions to integrate Mojave Express post-install automation into modlist workflows.
Mirrors vnv_integration_helper.py. Handles detection, confirmation, and execution for:
- Install Modlist
- Configure New Modlist
- Configure Existing Modlist
"""
import logging
import configparser
import re
from pathlib import Path
from typing import Optional, Callable, Tuple
from .mew_post_install_service import MEWPostInstallService
logger = logging.getLogger(__name__)
def _parse_bytearray_value(value: str) -> str:
"""Parse Qt @ByteArray format, e.g. '@ByteArray(Mojave Express)' -> 'Mojave Express'."""
match = re.match(r'@ByteArray\((.*)\)', value)
if match:
return match.group(1)
return value
def _check_modorganizer_ini_profile(modlist_install_location: Path) -> bool:
"""Check ModOrganizer.ini for a MEW profile name."""
try:
mo_ini_path = modlist_install_location / "ModOrganizer.ini"
if not mo_ini_path.exists():
logger.debug(f"ModOrganizer.ini not found at {mo_ini_path}")
return False
config = configparser.ConfigParser()
config.read(mo_ini_path, encoding='utf-8-sig')
if 'General' not in config:
logger.debug("No [General] section in ModOrganizer.ini")
return False
selected_profile_raw = config.get('General', 'selected_profile', fallback='')
if not selected_profile_raw:
logger.debug("No selected_profile in ModOrganizer.ini")
return False
selected_profile = _parse_bytearray_value(selected_profile_raw).strip().lower()
logger.debug(f"Found selected_profile: {selected_profile}")
return selected_profile in ("mojave express", "mew")
except Exception as e:
logger.debug(f"Error checking ModOrganizer.ini for MEW profile: {e}")
return False
def should_offer_mew_automation(modlist_name: str, modlist_install_location: Optional[Path] = None) -> bool:
"""
Check if MEW automation should be offered for this modlist.
Detection methods (in order of reliability):
1. Check ModOrganizer.ini selected_profile (most reliable)
2. Check modlist name for MEW patterns
"""
if modlist_install_location:
if _check_modorganizer_ini_profile(modlist_install_location):
logger.info(f"MEW detected via ModOrganizer.ini profile in {modlist_install_location}")
return True
modlist_name_lower = modlist_name.lower()
if "mojave express" in modlist_name_lower or modlist_name_lower == "mew":
logger.info(f"MEW detected via name pattern in '{modlist_name}'")
return True
return False
def _find_wine_binary() -> Optional[str]:
"""
Locate a wine binary from the configured Proton install.
Same lookup ModlistWineOpsMixin._find_wine_binary_for_registry() uses; duplicated here
(rather than instantiating that mixin's host class) since it has no other dependency.
"""
try:
from ..handlers.config_handler import ConfigHandler
proton_path = ConfigHandler().get_proton_path()
if proton_path:
proton_path = Path(proton_path).expanduser()
for candidate in (proton_path / "files" / "bin" / "wine", proton_path / "dist" / "bin" / "wine"):
if candidate.is_file():
return str(candidate)
from ..handlers.wine_utils import WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
return WineUtils.find_proton_binary(best_proton['name'])
except Exception as e:
logger.debug(f"Error finding Wine binary for MEW automation: {e}")
return None
def run_mew_automation_if_applicable(
modlist_name: str,
modlist_install_location: Path,
game_root: Optional[Path],
appid: Optional[str] = None,
ttw_installer_path: Optional[Path] = None,
progress_callback: Optional[Callable[[str], None]] = None,
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None,
confirmation_callback: Optional[Callable[[str], bool]] = None
) -> Tuple[bool, Optional[str]]:
"""
Check if MEW automation should run, get user confirmation, and execute if confirmed.
Args:
modlist_name: Name of the installed modlist
modlist_install_location: Path to modlist installation
game_root: Path to game root directory
appid: Steam AppID for the modlist's shortcut, used to resolve the Wine prefix for
the Radio Fix step. Radio Fix is skipped (non-fatal) if not provided.
ttw_installer_path: Optional path to TTW_Linux_Installer (for BSA decompression)
progress_callback: Optional callback for progress updates
manual_file_callback: Optional callback for manual file selection (non-Premium)
confirmation_callback: Optional callback for user confirmation
Returns:
Tuple of (automation_was_run: bool, error_message: Optional[str])
"""
try:
if not should_offer_mew_automation(modlist_name, modlist_install_location):
logger.debug(f"Modlist '{modlist_name}' does not require MEW automation")
return False, None
logger.info(f"MEW detected: {modlist_name}")
resolved_game_root = game_root
if resolved_game_root is None:
try:
from jackify.backend.handlers.path_handler import PathHandler
game_paths = PathHandler().find_vanilla_game_paths()
resolved_game_root = game_paths.get('Fallout New Vegas')
except Exception as detect_err:
logger.debug(f"MEW game root auto-detection failed: {detect_err}")
if resolved_game_root is None:
logger.warning("MEW detected but Fallout New Vegas game root could not be resolved")
if progress_callback:
progress_callback("MEW automation skipped: Fallout New Vegas path not found")
return False, None
wineprefix = None
wine_binary = None
if appid:
try:
from ..handlers.protontricks_handler import ProtontricksHandler
wineprefix = ProtontricksHandler(steamdeck=False).get_wine_prefix_path(appid)
except Exception as e:
logger.debug(f"MEW wineprefix lookup failed: {e}")
wine_binary = _find_wine_binary()
if not wineprefix or not wine_binary:
logger.warning("MEW Radio Fix will be skipped: wine prefix/binary unavailable")
else:
logger.warning("MEW Radio Fix will be skipped: no appid provided")
mew_service = MEWPostInstallService(
modlist_install_location=modlist_install_location,
game_root=resolved_game_root,
ttw_installer_path=ttw_installer_path,
wineprefix=wineprefix,
wine_binary=wine_binary,
)
completed = mew_service.check_already_completed()
if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed'] and completed['radio_fix']:
logger.info("MEW automation steps already completed")
if progress_callback:
progress_callback("MEW post-install steps already completed")
return False, None
if not confirmation_callback:
logger.error("MEW automation requires confirmation_callback")
return False, "MEW automation requires user confirmation"
description = mew_service.get_automation_description()
if not confirmation_callback(description):
logger.info("User declined MEW automation")
if progress_callback:
progress_callback("MEW automation skipped by user")
return False, None
logger.info("Starting MEW post-install automation")
if progress_callback:
progress_callback("Running MEW post-install automation...")
success, message = mew_service.run_all_steps(
progress_callback=progress_callback,
manual_file_callback=manual_file_callback
)
if success:
logger.info(f"MEW automation completed: {message}")
if progress_callback:
progress_callback(f"MEW automation: {message}")
return True, None
else:
logger.error(f"MEW automation failed: {message}")
return True, message
except Exception as e:
error_msg = f"MEW automation error: {str(e)}"
logger.error(error_msg, exc_info=True)
return True, error_msg
@@ -0,0 +1,248 @@
"""
Mojave Express (MEW) Post-Install Service
Automates the post-installation steps documented at:
https://mojaveexpressguide.com/docs/Installation
1. Root Mods - copy files from the GOG/Steam manual-install folder to game root
2. 4GB Patcher / BSA Decompression - these are FNV-wide fixes, not MEW-specific; the same
native Linux tools VNVPostInstallService already downloads and runs are reused here via
composition rather than duplicated.
3. Radio Fix - runs the modlist's bundled Windows batch script under Wine. Best-effort: MEW's
guide does not document a consequence of skipping this step (only that new radio songs
won't play), so a failure here is reported but never blocks the rest of automation.
"""
import logging
import shutil
import subprocess
from pathlib import Path
from typing import Optional, Callable
from .vnv_post_install_service import VNVPostInstallService
from ..handlers.subprocess_utils import get_clean_subprocess_env
logger = logging.getLogger(__name__)
class MEWPostInstallService:
"""Handles automated post-installation tasks for the Mojave Express modlist."""
ROOT_MODS_DIR_NAME = "__GOG or STEAM ONLY Files Requiring Manual Install"
RADIO_FIX_DIR_NAME = "__Radio Fix"
RADIO_FIX_BAT_NAME = "Run This.bat"
RADIO_FIX_SUCCESS_MARKER = "conversion complete"
def __init__(self, modlist_install_location: Path, game_root: Path,
ttw_installer_path: Optional[Path] = None,
wineprefix: Optional[str] = None,
wine_binary: Optional[str] = None):
"""
Args:
modlist_install_location: Path to the MEW installation
game_root: Path to Fallout New Vegas game root
ttw_installer_path: Path to TTW_Linux_Installer (used for BSA decompression)
wineprefix: WINEPREFIX for the modlist's Steam prefix (required for Radio Fix only)
wine_binary: Path to the wine binary to use (required for Radio Fix only)
"""
self.modlist_install = modlist_install_location
self.game_root = game_root
self.wineprefix = wineprefix
self.wine_binary = wine_binary
self.root_mods_dir = self.modlist_install / self.ROOT_MODS_DIR_NAME
self.radio_fix_dir = self.modlist_install / self.RADIO_FIX_DIR_NAME
# 4GB patch and BSA decompression are FNV-wide fixes with no VNV-specific logic in
# their implementation; reuse VNVPostInstallService for these two steps (including
# its shared download cache) instead of duplicating them.
self.fnv_tools = VNVPostInstallService(
modlist_install_location=modlist_install_location,
game_root=game_root,
ttw_installer_path=ttw_installer_path,
)
def should_run_automation(self, modlist_name: str) -> bool:
"""Check if this modlist should trigger MEW automation."""
name = modlist_name.lower()
return "mojave express" in name or name == "mew"
def get_automation_description(self) -> str:
return (
"Mojave Express Automation\n\n"
"Jackify can automatically perform the following post-install steps:\n\n"
"1. Copy root mods to game directory\n"
"2. Download and run Linux 4GB patcher\n"
"3. Download and run BSA decompressor (reduces loading times)\n"
"4. Run the Radio Fix (adds new radio songs to in-game radios)\n\n"
"Jackify will download the required tools automatically where possible.\n"
"If you are not a Nexus Premium member, you will be prompted to\n"
"manually download any tools that cannot be fetched automatically.\n\n"
"Would you like Jackify to automate these steps?"
)
def get_manual_download_items(self, include_bsa: bool = False) -> list:
return self.fnv_tools.get_manual_download_items(include_bsa=include_bsa)
def check_already_completed(self) -> dict:
"""
Check which MEW automation steps have already been completed.
Returns:
Dict with keys: 'root_mods', '4gb_patch', 'bsa_decompressed', 'radio_fix'
"""
fnv_status = self.fnv_tools.check_already_completed()
radio_marker = self.game_root / ".jackify_mew_radio_fixed"
return {
'root_mods': (self.game_root / "FNVpatch.exe").exists(),
'4gb_patch': fnv_status['4gb_patch'],
'bsa_decompressed': fnv_status['bsa_decompressed'],
'radio_fix': radio_marker.exists(),
}
def copy_root_mods(self) -> tuple[bool, str]:
"""
Copy files from the GOG/Steam manual-install folder to game root.
Returns:
(success: bool, message: str)
"""
try:
if not self.root_mods_dir.exists():
return False, (
f"Manual install directory not found: {self.root_mods_dir}. "
"Epic Games installs are not currently supported by this automation."
)
if not self.game_root.exists():
return False, f"Game root directory not found: {self.game_root}"
copied_files = []
for item in self.root_mods_dir.iterdir():
dest = self.game_root / item.name
if item.is_file():
shutil.copy2(item, dest)
copied_files.append(item.name)
elif item.is_dir():
shutil.copytree(item, dest, dirs_exist_ok=True)
copied_files.append(f"{item.name}/")
if not copied_files:
return False, "No files found to copy"
logger.info(f"Copied {len(copied_files)} items to game root")
return True, f"Copied {len(copied_files)} items to game root"
except Exception as e:
error_msg = f"Failed to copy root mods: {str(e)}"
logger.error(error_msg, exc_info=True)
return False, error_msg
def run_4gb_patcher(self, progress_callback: Optional[Callable[[str], None]] = None,
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None) -> tuple[bool, str]:
return self.fnv_tools.run_4gb_patcher(progress_callback, manual_file_callback)
def run_bsa_decompressor(self, progress_callback: Optional[Callable[[str], None]] = None,
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None) -> tuple[bool, str]:
return self.fnv_tools.run_bsa_decompressor(progress_callback, manual_file_callback)
def run_radio_fix(self, progress_callback: Optional[Callable[[str], None]] = None) -> tuple[bool, str]:
"""
Run the bundled Radio Fix batch script under Wine.
Non-critical step - see class docstring.
Returns:
(success: bool, message: str)
"""
marker = self.game_root / ".jackify_mew_radio_fixed"
if marker.exists():
return True, "Radio Fix already completed"
bat_path = self.radio_fix_dir / self.RADIO_FIX_BAT_NAME
if not bat_path.exists():
return False, f"Radio Fix script not found: {bat_path}"
if not self.wine_binary or not self.wineprefix:
return False, "Radio Fix requires a Wine prefix, but none was available"
if progress_callback:
progress_callback("Running Radio Fix...")
env = get_clean_subprocess_env()
env['WINEPREFIX'] = self.wineprefix
env['WINEDEBUG'] = '-all'
try:
result = subprocess.run(
[self.wine_binary, "cmd", "/c", self.RADIO_FIX_BAT_NAME],
cwd=str(self.radio_fix_dir),
env=env,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=1800,
)
except subprocess.TimeoutExpired:
return False, "Radio Fix timed out after 30 minutes"
except Exception as e:
error_msg = f"Failed to run Radio Fix: {str(e)}"
logger.error(error_msg, exc_info=True)
return False, error_msg
output = (result.stdout or "") + (result.stderr or "")
if self.RADIO_FIX_SUCCESS_MARKER in output.lower():
marker.touch()
logger.info("Radio Fix completed successfully")
return True, "Radio Fix completed successfully"
logger.warning(f"Radio Fix did not report success. Output:\n{output[-2000:]}")
return False, "Radio Fix ran but did not report success - it may need to be run manually"
def run_all_steps(self, progress_callback: Optional[Callable[[str], None]] = None,
manual_file_callback: Optional[Callable[[str, str], Optional[Path]]] = None) -> tuple[bool, str]:
"""
Run all MEW post-install steps in sequence.
Radio Fix failure is logged and reported but does not fail the overall result.
Returns:
(success: bool, message: str)
"""
def update_progress(msg: str):
if progress_callback:
progress_callback(msg)
logger.info(msg)
try:
update_progress("Step 1/4: Copying root mods to game directory...")
success, msg = self.copy_root_mods()
if not success:
return False, f"Root mods failed: {msg}"
update_progress(f"Root mods: {msg}")
update_progress("Step 2/4: Downloading and running 4GB patcher...")
success, msg = self.run_4gb_patcher(update_progress, manual_file_callback)
if not success:
return False, f"4GB patcher failed: {msg}"
update_progress(f"4GB patcher: {msg}")
update_progress("Step 3/4: Downloading and running BSA decompressor...")
success, msg = self.run_bsa_decompressor(update_progress, manual_file_callback)
if not success:
return False, f"BSA decompression failed: {msg}"
update_progress(f"BSA decompression: {msg}")
update_progress("Step 4/4: Running Radio Fix...")
success, msg = self.run_radio_fix(update_progress)
if success:
update_progress(f"Radio Fix: {msg}")
else:
update_progress(f"Radio Fix skipped: {msg}")
logger.warning(f"Radio Fix non-fatal failure: {msg}")
return True, "MEW post-install completed"
except Exception as e:
error_msg = f"MEW post-install failed: {str(e)}"
logger.error(error_msg, exc_info=True)
return False, error_msg
@@ -113,7 +113,7 @@ class NativeSteamOperationsService:
compatdata_paths = self._find_compatdata_paths()
for compatdata_base in compatdata_paths:
prefix_path = compatdata_base / appid / "pfx"
prefix_path = compatdata_base / str(appid) / "pfx"
logger.debug(f"Checking prefix path: {prefix_path}")
if prefix_path.exists():
@@ -13,11 +13,9 @@ Uses native Linux tools (no Wine required) by downloading from Nexus with OAuth.
import logging
import os
import json
import shutil
import subprocess
import stat
import tempfile
import zipfile
from pathlib import Path
from typing import Optional, Callable
@@ -521,6 +519,15 @@ class VNVPostInstallService:
"""
Download FNV BSA Decompressor MPI and run via TTW_Linux_Installer.
TTW_Linux_Installer v0.2.0 (the pinned build since Jackify v0.7.2) is a Rust rewrite
with a plain `install --mpi ... --fnv ... --dest ...` CLI; the old v0.0.7 C# build's
`--start` flag and PascalCase ttw-config.json are gone. `--dest` is passed the game
root directly (not a scratch dir) because the installer resolves manifest asset
locations relative to it and writes final output there itself - see
AssetProcessor::new(..., request.destination_path.clone(), ...) in that project's
install_runner.rs. Any scratch subdirectory it creates under `--dest` is cleaned up
by the installer itself before it exits.
Args:
progress_callback: Optional callback for progress updates
manual_file_callback: Optional callback for manual file selection
@@ -590,171 +597,81 @@ class VNVPostInstallService:
return False, f"Failed to prepare BSA Decompressor package: {msg}"
logger.info(msg)
# Create temp output directory
with tempfile.TemporaryDirectory() as temp_output:
temp_output_path = Path(temp_output)
if progress_callback:
progress_callback("Running BSA decompressor...")
# Create config file for TTW_Linux_Installer (handles spaces in paths better)
config_file = self.ttw_installer_path.parent / "ttw-config.json"
config_data = {
"FalloutNVRoot": str(self.game_root),
"MpiPackagePath": str(mpi_path),
"DestinationPath": str(temp_output_path)
}
with open(config_file, 'w') as f:
json.dump(config_data, f, indent=2)
logger.debug(f"Created MPI config file: {config_file}")
cmd = [
str(self.ttw_installer_path),
"install",
"--mpi", str(mpi_path),
"--fnv", str(self.game_root),
"--dest", str(self.game_root),
]
# Run via TTW_Linux_Installer
if progress_callback:
progress_callback("Ensuring TTW_Linux_Installer is available...")
progress_callback("Running BSA decompressor...")
logger.info(f"Running BSA decompressor: {' '.join(cmd)}")
cmd = [
str(self.ttw_installer_path),
"--start"
]
env = get_clean_subprocess_env()
logger.info(f"Running BSA decompressor: {' '.join(cmd)}")
logger.debug(f"Using config file: {config_file}")
logger.debug(f"Config: {json.dumps(config_data, indent=2)}")
# Stream output and parse progress
import re
process = subprocess.Popen(
cmd,
cwd=str(self.ttw_installer_path.parent),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
env = get_clean_subprocess_env()
# Rust installer logs "Progress: NN% - message" lines (see cli.rs run_install)
progress_pattern = re.compile(r'Progress:\s*(\d+)%\s*-\s*(.*)')
last_percent = None
# Stream output and parse progress
import re
process = subprocess.Popen(
cmd,
cwd=str(self.ttw_installer_path.parent),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
all_output = []
installation_complete = False
# Pattern to match progress: "Assets processed: 12345/48649"
progress_pattern = re.compile(r'Assets processed: (\d+)/(\d+)')
last_progress = None
for line in process.stdout:
line = line.rstrip()
all_output.append(line)
# Capture all output for diagnostics
all_output = []
already_modified_detected = False
if "=== Installation Complete ===" in line:
installation_complete = True
# Stream output line by line
for line in process.stdout:
line = line.rstrip()
all_output.append(line)
match = progress_pattern.search(line)
if match:
percent = int(match.group(1))
detail = match.group(2)
if last_percent is None or percent != last_percent:
if progress_callback:
progress_callback(f"Decompressing BSA files: {percent}% - {detail}")
last_percent = percent
# Check for "already modified" messages
if "already" in line.lower() and ("modified" in line.lower() or "decompressed" in line.lower()):
already_modified_detected = True
logger.info(f"BSA decompressor reports: {line}")
return_code = process.wait(timeout=600)
# Check for progress updates
match = progress_pattern.search(line)
if match:
current = int(match.group(1))
total = int(match.group(2))
percent = (current / total * 100) if total > 0 else 0
progress_msg = f"Decompressing BSA files: {current}/{total} ({percent:.1f}%)"
if return_code != 0:
logger.debug("BSA decompressor output:\n" + "\n".join(all_output[-50:]))
# Only send update if progress changed significantly
if last_progress is None or current - last_progress >= total // 100:
if progress_callback:
progress_callback(progress_msg)
# Log progress updates (not every single file)
logger.debug(f"BSA decompression progress: {current}/{total} ({percent:.1f}%)")
last_progress = current
if return_code == 0 and installation_complete:
marker_file = self.game_root / ".jackify_bsa_decompressed"
marker_file.touch()
logger.info("BSA decompression completed successfully")
return True, "BSA decompression completed successfully"
# Wait for process to complete
return_code = process.wait(timeout=600)
marker_file = self.game_root / ".jackify_bsa_decompressed"
if marker_file.exists():
logger.info("BSA decompressor returned an error but marker file exists - assuming already completed")
return True, "BSA decompression already completed"
# Log full output for debugging failures
if return_code != 0:
logger.debug(f"BSA decompressor output:\n" + "\n".join(all_output[-50:])) # Last 50 lines
# Clean up config file after execution
try:
if config_file.exists():
config_file.unlink()
logger.debug(f"Cleaned up config file: {config_file}")
except Exception as e:
logger.warning(f"Failed to clean up config file: {e}")
if return_code == 0:
# Check if files were actually extracted to temp directory
extracted_files = list(temp_output_path.rglob("*"))
if extracted_files:
logger.info(f"BSA decompression extracted {len(extracted_files)} files")
# Copy extracted files back to game Data directory
data_dir = self.game_root / "Data"
copied_count = 0
for extracted_file in extracted_files:
if extracted_file.is_file():
# Preserve relative path structure
relative_path = extracted_file.relative_to(temp_output_path)
dest_file = data_dir / relative_path
dest_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(extracted_file, dest_file)
copied_count += 1
logger.info(f"Copied {copied_count} decompressed files to {data_dir}")
# Create marker file to indicate completion
marker_file = self.game_root / ".jackify_bsa_decompressed"
marker_file.touch()
logger.info("BSA decompression completed successfully")
return True, "BSA decompression completed successfully"
else:
# No files extracted - might be already decompressed or failed silently
logger.warning("BSA decompressor returned 0 but no files were extracted")
# Check if already decompressed by looking for marker
marker_file = self.game_root / ".jackify_bsa_decompressed"
if marker_file.exists():
logger.info("BSA files already decompressed (marker file exists)")
return True, "BSA files already decompressed"
else:
return False, "BSA decompressor completed but no files were extracted"
else:
# Exit code 1 often means "already decompressed" - check output and marker
marker_file = self.game_root / ".jackify_bsa_decompressed"
# If output explicitly said "already modified/decompressed", treat as success
if already_modified_detected:
logger.info("BSA decompressor reports files already modified - marking as completed")
marker_file.touch()
return True, "BSA files already decompressed"
# Check marker file
if marker_file.exists():
logger.info("BSA decompressor returned error but marker file exists - assuming already completed")
return True, "BSA decompression already completed"
# Try to provide helpful error message based on exit code and output
logger.error(f"BSA decompressor failed with exit code {return_code}")
error_details = f"BSA decompressor failed with exit code {return_code}."
if return_code == 1:
error_details += (
"\n\nThis may indicate the BSA files are already decompressed or modified. "
"If you've run this before, the step may have already completed. "
"Otherwise, try running the decompressor manually from: "
"https://www.nexusmods.com/newvegas/mods/65854"
)
else:
error_details += (
f"\n\nPlease check that:\n"
f"1. Fallout New Vegas is properly installed at: {self.game_root}\n"
f"2. The BSA files exist in the Data directory\n"
f"3. You have write permissions to the game directory\n\n"
f"You can complete this step manually using the guide at:\n"
f"https://vivanewvegas.moddinglinked.com/wabbajack.html"
)
return False, error_details
logger.error(f"BSA decompressor failed with exit code {return_code}")
error_details = (
f"BSA decompressor failed with exit code {return_code}.\n\n"
f"Please check that:\n"
f"1. Fallout New Vegas is properly installed at: {self.game_root}\n"
f"2. The BSA files exist in the Data directory\n"
f"3. You have write permissions to the game directory"
)
return False, error_details
except subprocess.TimeoutExpired:
return False, "BSA decompression timed out after 10 minutes"