mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 02:23:45 +02:00
Release v0.7.1.1 - Synthesis, Starfield, and Stability Fixes
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user