Release v0.7.1.1 - Synthesis, Starfield, and Stability Fixes

This commit is contained in:
Omni
2026-07-08 18:06:18 +01:00
parent e5d329a2dc
commit b72b3daa30
18 changed files with 805 additions and 311 deletions
+13 -144
View File
@@ -19,11 +19,13 @@ from jackify.shared.colors import COLOR_PROMPT, COLOR_RESET
from .filesystem_handler_download import FilesystemDownloadMixin
from .filesystem_handler_ownership import FilesystemOwnershipMixin
from .filesystem_handler_steam import FilesystemSteamMixin
from .filesystem_handler_first_launch import FilesystemFirstLaunchMixin
logger = logging.getLogger(__name__)
class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, FilesystemSteamMixin):
class FileSystemHandler(
FilesystemDownloadMixin, FilesystemOwnershipMixin, FilesystemSteamMixin, FilesystemFirstLaunchMixin
):
def __init__(self):
self.logger = logging.getLogger(__name__)
@@ -384,8 +386,6 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
logger.error(f"Failed to add backupPath entry to {modlist_ini}: {e}")
return False # Backup succeeded, but adding entry failed
def copy_file(self, src: Path, dst: Path, overwrite: bool = False) -> bool:
"""
Copy a file from source to destination.
@@ -474,19 +474,20 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
self.logger.error(f"Error deleting directory: {e}")
return False
def create_required_dirs(self, game_name: str, appid: str) -> bool:
def create_required_dirs(self, game_name: str, appid: str, modlist_dir: Optional[str] = None) -> bool:
"""
Create required directories for a game modlist
This includes both Linux home directories and Wine prefix directories.
Creating the Wine prefix Documents directories is critical for USVFS
to work properly on first launch - USVFS needs the target directory
to exist before it can virtualize profile INI files.
Args:
game_name: Name of the game (e.g., skyrimse, fallout4)
appid: Steam AppID of the modlist
modlist_dir: Modlist install directory, used to seed the MO2 profile's own
INI files for games whose plugin has no bundled default-ini fallback
Returns:
bool: True if directories were created successfully, False otherwise
"""
@@ -502,8 +503,10 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
"fallout4vr": "Fallout4VR",
"falloutnv": "FalloutNV",
"oblivion": "Oblivion",
"oblivion_remastered": "Oblivion Remastered",
"enderal": "Enderal Special Edition",
"enderalse": "Enderal Special Edition",
"starfield": "Starfield",
}
game_dirs = {
@@ -585,6 +588,8 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
self._seed_skyrimvr_first_launch_files(prefix_user, docs_dir_name)
elif game_name == "fallout4vr":
self._seed_fallout4vr_first_launch_files(prefix_user, docs_dir_name)
elif game_name == "starfield":
self._seed_starfield_first_launch_files(prefix_user, docs_dir_name, modlist_dir)
else:
self.logger.warning(f"Could not find compatdata path for AppID {appid}, skipping Wine prefix directory creation")
@@ -593,139 +598,3 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, Files
self.logger.error(f"Error creating required directories: {e}")
return False
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Skyrim SE/AE needs on first launch.
Two files must exist before first launch to avoid USVFS and engine issues:
1. AppData/Local/Skyrim Special Edition/Plugins.txt - empty anchor file.
USVFS builds its VFS tree at MO2 startup. If this path does not exist,
USVFS logs the directory as missing and skips adding Plugins.txt to the
initial tree. It then tries to reroute the file dynamically, but a mutex
deadlock (thread never releases the write mutex on first launch) blocks
the reroute. The game falls through to the real filesystem, finds no
Plugins.txt, and loads only base-game ESPs - causing a null form crash
for any SKSE plugin that expects modlist ESPs (e.g. BladeAndBlunt.dll).
On second launch the directory exists, USVFS initialises correctly, no crash.
Pre-seeding an empty file gives USVFS its anchor; content is irrelevant
because USVFS reroutes reads to the active MO2 profile's plugins.txt anyway.
2. Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini - minimal stub.
The CC/AE download prompt is triggered by bDownloadCC=0 (or absent) in
SkyrimPrefs.ini. This check fires before PrivateProfileRedirector (PPR)
hooks the Windows INI API, so the game reads the real prefix path directly,
not the MO2 profile version. A minimal stub with bDownloadCC=1 suppresses
the prompt. PPR redirects all subsequent reads to the active profile once
it loads, so this stub is never read again after early engine init.
Only created if the file does not already exist.
"""
# Fix 1: empty Plugins.txt anchor for USVFS
appdata_sse = os.path.join(prefix_user, "AppData", "Local", "Skyrim Special Edition")
plugins_txt = os.path.join(appdata_sse, "Plugins.txt")
try:
os.makedirs(appdata_sse, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
# Fix 2: minimal SkyrimPrefs.ini at real Documents path to suppress AE popup
skyrimprefs_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
)
try:
if not os.path.exists(skyrimprefs_path):
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
f.write("[General]\nbDownloadCC=1\n")
self.logger.info(f"Created SkyrimPrefs.ini stub to suppress AE popup: {skyrimprefs_path}")
else:
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
except Exception as e:
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Fallout 4 needs on first launch.
1. AppData/Local/Fallout4/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE - confirmed to apply to FO4.
INI stub for CC popup suppression is intentionally omitted until the correct
key name in Fallout4Prefs.ini is confirmed via testing.
"""
appdata_fo4 = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_fo4, "Plugins.txt")
try:
os.makedirs(appdata_fo4, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Skyrim VR needs on first launch.
1. AppData/Local/Skyrim VR/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE applies to VR.
2. Documents/My Games/Skyrim VR/SkyrimPrefs.ini - minimal stub with two keys:
- bDownloadCC=1: suppresses the AE/CC download prompt (same engine behaviour
as Skyrim SE; fires before PPR hooks the INI API).
- bLoadVRPlayroom=0: prevents the game loading the Bethesda VR playroom
tutorial on first launch. Without this, SkyrimVR skips the main menu and
drops the user into the playroom, bypassing the modlist's startup sequence.
"""
appdata_vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_vr, "Plugins.txt")
try:
os.makedirs(appdata_vr, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
skyrimprefs_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
)
try:
if not os.path.exists(skyrimprefs_path):
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
f.write("[General]\nbDownloadCC=1\nbLoadVRPlayroom=0\n")
self.logger.info(f"Created SkyrimPrefs.ini stub for VR first-launch: {skyrimprefs_path}")
else:
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
except Exception as e:
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Fallout 4 VR needs on first launch.
1. AppData/Local/Fallout4VR/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE and FO4 applies to VR.
INI stub is intentionally omitted - the correct key name in Fallout4VRPrefs.ini
has not been confirmed via testing.
"""
appdata_fo4vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_fo4vr, "Plugins.txt")
try:
os.makedirs(appdata_fo4vr, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
@@ -0,0 +1,245 @@
"""First-launch INI/anchor file seeding for Bethesda games (Mixin)."""
import os
from typing import Optional
class FilesystemFirstLaunchMixin:
"""Mixin providing per-game first-launch file seeding methods."""
def _seed_skyrim_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Skyrim SE/AE needs on first launch.
Two files must exist before first launch to avoid USVFS and engine issues:
1. AppData/Local/Skyrim Special Edition/Plugins.txt - empty anchor file.
USVFS builds its VFS tree at MO2 startup. If this path does not exist,
USVFS logs the directory as missing and skips adding Plugins.txt to the
initial tree. It then tries to reroute the file dynamically, but a mutex
deadlock (thread never releases the write mutex on first launch) blocks
the reroute. The game falls through to the real filesystem, finds no
Plugins.txt, and loads only base-game ESPs - causing a null form crash
for any SKSE plugin that expects modlist ESPs (e.g. BladeAndBlunt.dll).
On second launch the directory exists, USVFS initialises correctly, no crash.
Pre-seeding an empty file gives USVFS its anchor; content is irrelevant
because USVFS reroutes reads to the active MO2 profile's plugins.txt anyway.
2. Documents/My Games/Skyrim Special Edition/SkyrimPrefs.ini - minimal stub.
The CC/AE download prompt is triggered by bDownloadCC=0 (or absent) in
SkyrimPrefs.ini. This check fires before PrivateProfileRedirector (PPR)
hooks the Windows INI API, so the game reads the real prefix path directly,
not the MO2 profile version. A minimal stub with bDownloadCC=1 suppresses
the prompt. PPR redirects all subsequent reads to the active profile once
it loads, so this stub is never read again after early engine init.
Only created if the file does not already exist.
"""
# Fix 1: empty Plugins.txt anchor for USVFS
appdata_sse = os.path.join(prefix_user, "AppData", "Local", "Skyrim Special Edition")
plugins_txt = os.path.join(appdata_sse, "Plugins.txt")
try:
os.makedirs(appdata_sse, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
# Fix 2: minimal SkyrimPrefs.ini at real Documents path to suppress AE popup
skyrimprefs_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
)
try:
if not os.path.exists(skyrimprefs_path):
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
f.write("[General]\nbDownloadCC=1\n")
self.logger.info(f"Created SkyrimPrefs.ini stub to suppress AE popup: {skyrimprefs_path}")
else:
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
except Exception as e:
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
def _seed_fo4_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Fallout 4 needs on first launch.
1. AppData/Local/Fallout4/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE - confirmed to apply to FO4.
INI stub for CC popup suppression is intentionally omitted until the correct
key name in Fallout4Prefs.ini is confirmed via testing.
"""
appdata_fo4 = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_fo4, "Plugins.txt")
try:
os.makedirs(appdata_fo4, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
def _seed_starfield_first_launch_files(
self, prefix_user: str, docs_dir_name: str, modlist_dir: Optional[str] = None
) -> None:
"""
Pre-seed files in the Wine prefix that Starfield needs on first launch.
1. AppData/Local/Starfield/Plugins.txt - empty anchor file for USVFS.
Same mechanism as Skyrim SE/FO4.
2. Documents/My Games/Starfield/StarfieldPrefs.ini and StarfieldCustom.ini -
minimal stubs. MO2's Starfield plugin has no bundled default-ini fallback
(unlike Skyrim/FO4, which fall back to a default ini shipped with the game).
It lists both files as required (GameStarfield::iniFiles()) and copies both
from this real path into the profile. On Windows they already exist because
the user has launched vanilla Starfield at least once; a fresh Jackify
install never does that, so MO2 finds nothing to copy from.
3. profiles/<selected profile>/StarfieldPrefs.ini and StarfieldCustom.ini -
same stubs, written directly into the active MO2 profile. MO2's missing-ini
check (Profile::localSettingsEnabled) only looks at the profile folder itself,
which starts empty regardless of step 2 above - it always warns
"Missing profile-specific game INI files!" once unless the profile already
has these files before MO2 ever checks. Only written if not already present,
since a modlist's shipped profile may already carry real ini tweaks from
the author (observed for StarfieldCustom.ini in practice).
"""
appdata_starfield = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_starfield, "Plugins.txt")
try:
os.makedirs(appdata_starfield, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
starfieldprefs_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name, "StarfieldPrefs.ini"
)
try:
if not os.path.exists(starfieldprefs_path):
with open(starfieldprefs_path, 'w', encoding='utf-8') as f:
f.write("[Display]\n")
self.logger.info(f"Created StarfieldPrefs.ini stub for first launch: {starfieldprefs_path}")
else:
self.logger.debug(f"StarfieldPrefs.ini already exists, skipping: {starfieldprefs_path}")
except Exception as e:
self.logger.warning(f"Could not create StarfieldPrefs.ini stub: {e}")
starfieldcustom_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name, "StarfieldCustom.ini"
)
try:
if not os.path.exists(starfieldcustom_path):
with open(starfieldcustom_path, 'w', encoding='utf-8') as f:
f.write("[General]\n")
self.logger.info(f"Created StarfieldCustom.ini stub for first launch: {starfieldcustom_path}")
else:
self.logger.debug(f"StarfieldCustom.ini already exists, skipping: {starfieldcustom_path}")
except Exception as e:
self.logger.warning(f"Could not create StarfieldCustom.ini stub: {e}")
if not modlist_dir:
return
try:
from jackify.backend.utils.modlist_meta import _read_selected_profile
profile_name = _read_selected_profile(modlist_dir)
except Exception as e:
self.logger.debug(f"Could not read selected_profile for Starfield ini pre-seed: {e}")
return
if not profile_name:
self.logger.debug("No selected_profile found, skipping Starfield profile ini pre-seed")
return
profile_dir = os.path.join(modlist_dir, "profiles", profile_name)
try:
os.makedirs(profile_dir, exist_ok=True)
except Exception as e:
self.logger.warning(f"Could not create profile directory {profile_dir}: {e}")
return
for filename, stub_content in (
("StarfieldPrefs.ini", "[Display]\n"),
("StarfieldCustom.ini", "[General]\n"),
):
profile_ini_path = os.path.join(profile_dir, filename)
try:
if not os.path.exists(profile_ini_path):
with open(profile_ini_path, 'w', encoding='utf-8') as f:
f.write(stub_content)
self.logger.info(f"Created {filename} stub in profile to suppress missing-ini warning: {profile_ini_path}")
else:
self.logger.debug(f"{filename} already exists in profile, skipping: {profile_ini_path}")
except Exception as e:
self.logger.warning(f"Could not create {filename} stub in profile: {e}")
def _seed_skyrimvr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Skyrim VR needs on first launch.
1. AppData/Local/Skyrim VR/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE applies to VR.
2. Documents/My Games/Skyrim VR/SkyrimPrefs.ini - minimal stub with two keys:
- bDownloadCC=1: suppresses the AE/CC download prompt (same engine behaviour
as Skyrim SE; fires before PPR hooks the INI API).
- bLoadVRPlayroom=0: prevents the game loading the Bethesda VR playroom
tutorial on first launch. Without this, SkyrimVR skips the main menu and
drops the user into the playroom, bypassing the modlist's startup sequence.
"""
appdata_vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_vr, "Plugins.txt")
try:
os.makedirs(appdata_vr, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
skyrimprefs_path = os.path.join(
prefix_user, "Documents", "My Games", docs_dir_name, "SkyrimPrefs.ini"
)
try:
if not os.path.exists(skyrimprefs_path):
with open(skyrimprefs_path, 'w', encoding='utf-8') as f:
f.write("[General]\nbDownloadCC=1\nbLoadVRPlayroom=0\n")
self.logger.info(f"Created SkyrimPrefs.ini stub for VR first-launch: {skyrimprefs_path}")
else:
self.logger.debug(f"SkyrimPrefs.ini already exists, skipping: {skyrimprefs_path}")
except Exception as e:
self.logger.warning(f"Could not create SkyrimPrefs.ini stub: {e}")
def _seed_fallout4vr_first_launch_files(self, prefix_user: str, docs_dir_name: str) -> None:
"""
Pre-seed files in the Wine prefix that Fallout 4 VR needs on first launch.
1. AppData/Local/Fallout4VR/Plugins.txt - empty anchor file for USVFS.
Same mutex deadlock mechanism as Skyrim SE and FO4 applies to VR.
INI stub is intentionally omitted - the correct key name in Fallout4VRPrefs.ini
has not been confirmed via testing.
"""
appdata_fo4vr = os.path.join(prefix_user, "AppData", "Local", docs_dir_name)
plugins_txt = os.path.join(appdata_fo4vr, "Plugins.txt")
try:
os.makedirs(appdata_fo4vr, exist_ok=True)
if not os.path.exists(plugins_txt):
open(plugins_txt, 'w').close()
self.logger.info(f"Created Plugins.txt anchor for USVFS: {plugins_txt}")
else:
self.logger.debug(f"Plugins.txt already exists, skipping: {plugins_txt}")
except Exception as e:
self.logger.warning(f"Could not create Plugins.txt anchor: {e}")
@@ -237,6 +237,7 @@ class ModlistMenuHandler:
mo2_dir = os.path.dirname(mo2_path)
# --- Auto-create nxmhandler.ini to suppress NXM Handling popup (MOVED UP) ---
self.shortcut_handler.write_nxmhandler_ini(mo2_dir, mo2_path)
self.shortcut_handler.repair_dlls_manifest(mo2_dir)
steam_icons_path = os.path.join(mo2_dir, "Steam Icons")
steamicons_path = os.path.join(mo2_dir, "SteamIcons")
if os.path.isdir(steam_icons_path) and not os.path.isdir(steamicons_path):
@@ -454,6 +455,7 @@ class ModlistMenuHandler:
_mo2_dir = os.path.dirname(_mo2_exe)
if _mo2_dir and os.path.isdir(_mo2_dir):
self.shortcut_handler.write_nxmhandler_ini(_mo2_dir, _mo2_exe)
self.shortcut_handler.repair_dlls_manifest(_mo2_dir)
# Robust AppID lookup for GUI/CLI: if appid missing but mo2_exe_path present, look it up
if 'appid' not in context or not context.get('appid'):
if 'mo2_exe_path' in context and context['mo2_exe_path']:
@@ -209,6 +209,7 @@ class ModlistConfigurationMixin:
"fnv": "falloutnv",
"falloutnv": "falloutnv",
"oblivion": "oblivion",
"oblivion_remastered": "oblivion_remastered",
"enderal": "enderalse",
"enderalspecialedition": "enderalse",
"bg3": "bg3",
@@ -232,7 +233,9 @@ class ModlistConfigurationMixin:
if game_name:
appid_str = str(self.appid)
if self.filesystem_handler.create_required_dirs(game_name, appid_str):
if self.filesystem_handler.create_required_dirs(
game_name, appid_str, modlist_dir=str(self.modlist_dir) if self.modlist_dir else None
):
self.logger.info("Wine prefix Documents directories created successfully for USVFS")
else:
self.logger.warning("Failed to create Wine prefix Documents directories (non-critical, continuing)")
@@ -461,6 +461,7 @@ class ModlistInstallCLIConfigurationMixin:
from .shortcut_handler import ShortcutHandler
shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=False)
shortcut_handler.write_nxmhandler_ini(install_dir_str, mo2_exe_path)
shortcut_handler.repair_dlls_manifest(install_dir_str)
from ..services.automated_prefix_service import AutomatedPrefixService
prefix_service = AutomatedPrefixService()
@@ -84,7 +84,16 @@ class ProgressStateProcessingMixin:
updated = True
if parsed.data_info:
self.state.data_processed, self.state.data_total = parsed.data_info
new_processed, new_total = parsed.data_info
self.state.data_processed = new_processed
if self.state.phase == InstallationPhase.DOWNLOAD and self._download_total_bytes > 0:
# The engine only reports remaining bytes during download; the parser
# extrapolates a total from it and that estimate drifts both up and
# down line to line as archive sizes vary. Prefer the total already
# accumulated from real per-file sizes below once it's available.
self.state.data_total = self._download_total_bytes
else:
self.state.data_total = new_total
if self.state.data_total > 0 and self.state.overall_percent == 0.0:
self.state.overall_percent = (self.state.data_processed / self.state.data_total) * 100.0
updated = True
@@ -1,12 +1,15 @@
"""Launch options and icon methods for ShortcutHandler (Mixin)."""
import logging
import os
import re
import shutil
import time
import vdf
logger = logging.getLogger(__name__)
_MANIFEST_FILE_RE = re.compile(r'<file\s+name="([^"]+)"\s*/>', re.IGNORECASE)
class ShortcutLaunchOptionsMixin:
"""Mixin providing launch options and icon methods."""
@@ -258,20 +261,77 @@ class ShortcutLaunchOptionsMixin:
logger.debug("[DEBUG] No SteamIcons directory found; shortcut will have no icon.")
return ""
def repair_dlls_manifest(self, mo2_dir: str) -> None:
"""
Some MO2 builds ship a dlls/dlls.manifest (Windows SxS assembly manifest) that omits
an entry for a DLL actually present in dlls/. Wine's loader only redirects lookups for
files declared in the manifest, so an undeclared DLL is invisible to dependents even
though it exists on disk (e.g. Qt6Core.dll importing icuuc.dll) - ModOrganizer.exe then
fails to load entirely (status c0000135). Patch in any missing entries so the manifest
matches what's actually in the directory. Never removes existing entries.
"""
dlls_dir = os.path.join(mo2_dir, "dlls")
manifest_path = os.path.join(dlls_dir, "dlls.manifest")
if not os.path.isfile(manifest_path):
return
try:
with open(manifest_path, "r", encoding="utf-8") as f:
content = f.read()
except Exception as e:
self.logger.debug(f"Could not read dlls.manifest: {e}")
return
declared = {m.group(1).lower() for m in _MANIFEST_FILE_RE.finditer(content)}
try:
actual = {name for name in os.listdir(dlls_dir) if name.lower().endswith(".dll")}
except Exception as e:
self.logger.debug(f"Could not list dlls directory: {e}")
return
missing = sorted(name for name in actual if name.lower() not in declared)
if not missing:
return
if "</assembly>" not in content:
self.logger.warning(f"dlls.manifest at {manifest_path} has unexpected format - skipping repair")
return
insert = "".join(f' <file name="{name}" />\n' for name in missing)
patched = content.replace("</assembly>", insert + "</assembly>")
try:
with open(manifest_path, "w", encoding="utf-8") as f:
f.write(patched)
self.logger.info(f"Added missing dlls.manifest entries: {', '.join(missing)}")
except Exception as e:
self.logger.error(f"Failed to patch dlls.manifest: {e}")
def write_nxmhandler_ini(self, modlist_dir, mo2_exe_path):
"""
Create nxmhandler.ini in the modlist directory to suppress the NXM Handling popup on first MO2 launch.
If the file already exists, do nothing.
The executable path will be written as Z:\\<absolute path with double backslashes>, matching MO2's format.
Some modlists ship their own nxmhandler.ini bundled with the packaged MO2 install,
carrying handler entries from the author's own machine (wrong drive letters, paths
to other modlists). Only skip writing if an existing file already references this
modlist's own executable path - otherwise regenerate it, since stale foreign entries
can make MO2 fail to re-verify its self-registration on close.
"""
ini_path = os.path.join(modlist_dir, "nxmhandler.ini")
if os.path.exists(ini_path):
self.logger.info(f"nxmhandler.ini already exists at {ini_path}")
return
abs_path = os.path.abspath(mo2_exe_path)
z_path = f"Z:{abs_path}"
win_path = z_path.replace('/', '\\')
win_path = win_path.replace('\\', '\\\\')
if os.path.exists(ini_path):
with open(ini_path, "r", encoding="utf-8") as f:
existing = f.read()
if win_path in existing:
self.logger.info(f"nxmhandler.ini already configured for this modlist at {ini_path}")
return
self.logger.info(f"nxmhandler.ini exists but does not reference this modlist, regenerating: {ini_path}")
content = (
"[handlers]\n"
"size=1\n"