mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 03:13:44 +02:00
Release v0.7.2.1 - Mojave Express Support, BSA Decompressor Fix
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user