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
+16
View File
@@ -1,5 +1,21 @@
# Jackify Changelog
## v0.7.1.1 - Synthesis, Starfield, and Stability Fixes
**Release Date:** 2026-07-08
### Fixes
- Fixed vcrun2022/vcrun2012 native component installs leaving Wine's own runtime DLLs in effect instead of the real ones, causing DLL load failures in some situations.
- Fixed instances where Synthesis under Proton spawning huge numbers of dotnet processes and running the system out of memory during patcher compilation.
- Improved Synthesis' required NuGet security certificate installation.
- Fixed a related NuGet signature validation failure (NU3037/NU3028) affecting some patchers that depend on older signed packages.
- Fixed Starfield modlists failing to launch on first use and showing a MO2 warning about missing profile INI files. Added recognition for Oblivion Remastered.
- Fixed the resolution dropdown being duplicated across three separate screens, and added detection of the actual connected screen resolutions.
- Fixed an intermittent crash when closing the install/configure window while CPU usage tracking was still running in the background.
- Fixed the archive download progress total sometimes shrinking or climbing indefinitely during large downloads.
- Fixed some modlists showing a "Failed to start the helper application: nxmhandler.exe" error when closing MO2.
---
## v0.7.1 - Remote Manifest System, Stability Fixes
**Release Date:** 2026-07-02
+1 -1
View File
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems.
"""
__version__ = "0.7.1"
__version__ = "0.7.1.1"
+15 -15
View File
@@ -64,18 +64,18 @@ Windows Registry Editor Version 5.00
"xactengine3_5"="native,builtin"
"xactengine3_6"="native,builtin"
"xactengine3_7"="native,builtin"
"atl110"="native,builtin"
"msvcp110"="native,builtin"
"msvcr110"="native,builtin"
"vcomp110"="native,builtin"
"concrt140"="native,builtin"
"msvcp140"="native,builtin"
"msvcp140_1"="native,builtin"
"msvcp140_2"="native,builtin"
"msvcp140_atomic_wait"="native,builtin"
"msvcp140_codecvt_ids"="native,builtin"
"vcamp140"="native,builtin"
"vccorlib140"="native,builtin"
"vcomp140"="native,builtin"
"vcruntime140"="native,builtin"
"vcruntime140_1"="native,builtin"
"*atl110"="native,builtin"
"*msvcp110"="native,builtin"
"*msvcr110"="native,builtin"
"*vcomp110"="native,builtin"
"*concrt140"="native,builtin"
"*msvcp140"="native,builtin"
"*msvcp140_1"="native,builtin"
"*msvcp140_2"="native,builtin"
"*msvcp140_atomic_wait"="native,builtin"
"*msvcp140_codecvt_ids"="native,builtin"
"*vcamp140"="native,builtin"
"*vccorlib140"="native,builtin"
"*vcomp140"="native,builtin"
"*vcruntime140"="native,builtin"
"*vcruntime140_1"="native,builtin"
+11 -142
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,7 +474,7 @@ 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
@@ -486,7 +486,8 @@ class FileSystemHandler(FilesystemDownloadMixin, FilesystemOwnershipMixin, 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"
@@ -0,0 +1,253 @@
"""
NuGet package signature trust configuration for Wine prefixes.
Synthesis compiles C# patchers under Wine using an installed .NET SDK, which
requires NuGet package signature validation to succeed. Split out of
tool_config_service.py to keep that file under the project's size guardrail.
Old (2018-2021 era) Microsoft BCL packages carry timestamp signatures chaining
to legacy VeriSign/Symantec roots that modern distros have removed from their
trust stores. Wine seeds its Root store from the host bundle, so those roots
are missing in the prefix and NuGet fails restore with NU3028 ("timestamping
certificate is not trusted"). NuGet treats an untrusted root on the TIMESTAMP
chain as a hard error unconditionally (see Timestamp.Verify in NuGet.Client -
UntrustedRoot is logged as Error regardless of signatureValidationMode,
allowUntrustedRoot, or any env var), so the only fix is making the roots
actually trusted in the prefix's cert store.
"""
import base64
import hashlib
import logging
import os
import re
import struct
import subprocess
import tempfile
from pathlib import Path
from typing import Callable, Iterator, Optional, Tuple
logger = logging.getLogger(__name__)
# Every NuGet.Config we write pins nuget.org's repository signing certs by
# fingerprint with allowUntrustedRoot, so the SIGNING chain does not depend on
# the CA root chain being trusted. The TIMESTAMP chain is not covered by this
# (see module docstring) - that requires the root cert import below.
# Fingerprints are nuget.org's own signing certs (public, stable across signing
# key rotations) - see SulfurNitride/Fluorine-Manager commit d453ed91b6dc332fd45987c642e9d230efe870c8.
_NUGET_CONFIG_TEMPLATE = """<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config>
<add key="signatureValidationMode" value="accept" />
</config>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<trustedSigners>
<repository name="nuget.org" serviceIndex="https://api.nuget.org/v3/index.json">
<certificate fingerprint="0E5F38F57DC1BCC806D8494F4F90FBCEDD988B46760709CBEEC6F4219AA6157D" hashAlgorithm="SHA256" allowUntrustedRoot="true" />
<certificate fingerprint="5A2901D6ADA3D18260B9C6DFE2133C95D74B9EEF6AE0E5DC334C8454D1477DF4" hashAlgorithm="SHA256" allowUntrustedRoot="true" />
<certificate fingerprint="1F4B311D9ACC115C8DC8018B5A49E00FCE6DA8E2855F9F014CA6F34570BC482D" hashAlgorithm="SHA256" allowUntrustedRoot="true" />
</repository>
</trustedSigners>
</configuration>
"""
_PEM_CERT_RE = re.compile(
r"-----BEGIN CERTIFICATE-----(.*?)-----END CERTIFICATE-----", re.DOTALL
)
# Registry path Wine's crypt32 reads trusted roots from - the same location it
# seeds with the host CA bundle at prefix init.
_ROOT_STORE_KEY = "HKEY_LOCAL_MACHINE\\Software\\Microsoft\\SystemCertificates\\Root\\Certificates"
CERT_CERT_PROP_ID = 32
def _find_sdk_trusted_roots(prefix_path: Path) -> Optional[Tuple[Path, Path]]:
"""
Locate the newest installed .NET SDK's bundled trustedroots PEM files.
Returns (codesignctl.pem, timestampctl.pem) or None if not found.
"""
sdk_root = prefix_path / "drive_c" / "Program Files" / "dotnet" / "sdk"
if not sdk_root.is_dir():
return None
for version_dir in sorted(sdk_root.iterdir(), reverse=True):
trustedroots = version_dir / "trustedroots"
codesign = trustedroots / "codesignctl.pem"
timestamp = trustedroots / "timestampctl.pem"
if codesign.exists() and timestamp.exists():
return codesign, timestamp
return None
def _read_pem_bundle(bundle_path: Path) -> Iterator[bytes]:
"""Yield DER-encoded certificates from a concatenated PEM bundle."""
text = bundle_path.read_text(encoding="utf-8", errors="replace")
for match in _PEM_CERT_RE.finditer(text):
yield base64.b64decode("".join(match.group(1).split()))
def _cert_registry_blob(der: bytes) -> bytes:
"""
Serialize a certificate the way crypt32 stores it in the registry: a
property record of DWORD propid, DWORD reserved (1), DWORD length, then
the raw DER. A blob containing only the CERT_CERT_PROP_ID record is valid.
"""
return struct.pack("<III", CERT_CERT_PROP_ID, 1, len(der)) + der
def _hex_reg_value(name: str, data: bytes) -> str:
"""Format a REG_BINARY value as regedit .reg hex syntax with line wrapping."""
hex_bytes = [f"{b:02x}" for b in data]
lines = []
line = f'"{name}"=hex:'
for hb in hex_bytes:
if len(line) + len(hb) + 2 > 76:
lines.append(line + "\\")
line = " "
line += hb + ","
lines.append(line.rstrip(","))
return "\n".join(lines)
def _build_certs_reg_content(bundles: Tuple[Path, ...]) -> Tuple[str, int]:
"""
Build a .reg file adding every cert in the given PEM bundles to the Wine
prefix's Root store. Returns (reg_content, unique_cert_count). Re-importing
is idempotent - regedit overwrites identical keys in place.
"""
seen = set()
parts = ["Windows Registry Editor Version 5.00"]
for bundle in bundles:
for der in _read_pem_bundle(bundle):
thumbprint = hashlib.sha1(der).hexdigest().upper()
if thumbprint in seen:
continue
seen.add(thumbprint)
parts.append("")
parts.append(f"[{_ROOT_STORE_KEY}\\{thumbprint}]")
parts.append(_hex_reg_value("Blob", _cert_registry_blob(der)))
parts.append("")
return "\n".join(parts), len(seen)
def install_nuget_cert(
prefix_path: Path,
wine_bin: str,
log: Callable[[str], None],
) -> bool:
"""
Import the .NET SDK's bundled code-signing and timestamp trusted-root
certificates into the Wine prefix's Root cert store, by writing serialized
cert blobs directly into the registry via regedit.
Regedit is the only mechanism that actually persists here: certutil.exe
and rundll32 cryptext.dll are unimplemented stubs in Wine, and crypt32
cert store writes (X509Store.Add / CertAddCertificateContextToStore) under
GE-Proton report success but never reach wineserver's registry - the added
certs are visible only inside the importing process and are gone once it
exits (confirmed on CachyOS + GE-Proton10-14: store reported the add,
cross-process reg query and the on-disk .reg files never saw it).
Requires the .NET SDK already installed in the prefix - the PEM bundles
ship inside the SDK and are the exact trust anchors NuGet itself uses for
package and timestamp signature validation on Linux.
"""
trusted_roots = _find_sdk_trusted_roots(prefix_path)
if trusted_roots is None:
log(".NET SDK trusted root bundles not found - skipping NuGet certificate import")
return False
try:
reg_content, cert_count = _build_certs_reg_content(trusted_roots)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".reg", delete=False, encoding="utf-8"
) as tf:
tf.write(reg_content)
reg_file = tf.name
log(f"Importing {cert_count} code-signing and timestamp roots into Wine cert store...")
env = os.environ.copy()
env["WINEPREFIX"] = str(prefix_path)
env["WINEDEBUG"] = "-all"
env["WINEDLLOVERRIDES"] = "winemenubuilder.exe=d"
env["DISPLAY"] = env.get("DISPLAY", ":0")
try:
result = subprocess.run(
[wine_bin, "regedit", reg_file],
env=env,
capture_output=True,
text=True,
timeout=120,
)
finally:
try:
os.unlink(reg_file)
except Exception:
pass
if result.returncode != 0:
log(f"Certificate registry import exited with code {result.returncode}")
log(f"stderr: {result.stderr[:500]}")
return False
# wineserver batches registry writes in memory and flushes to the
# on-disk .reg files lazily. "wineserver -w" blocks until wineserver
# exits, forcing the flush - same pattern used elsewhere for registry
# writes (see modlist_wine_ops.py).
wineserver_bin = os.path.join(os.path.dirname(wine_bin), "wineserver")
if os.path.exists(wineserver_bin):
try:
subprocess.run(
[wineserver_bin, "-w"], env=env, timeout=60, capture_output=True,
)
except Exception as e:
log(f"wineserver flush failed (non-fatal): {e}")
else:
log(f"wineserver not found at {wineserver_bin}; registry flush may not persist")
log(f"Imported {cert_count} certificates into Wine cert store")
return True
except Exception as e:
log(f"Failed to install NuGet certificates: {e}")
return False
def configure_nuget_signature_policy(
prefix_path: Path,
log: Callable[[str], None],
) -> bool:
"""
Write a NuGet.Config that pins nuget.org's signing certificates by
fingerprint with allowUntrustedRoot, so signing-chain validation does not
depend on the CA root chain. Timestamp-chain trust is handled separately
by install_nuget_cert (see module docstring).
Steam/Proton prefixes always use "steamuser" as the Windows user.
Must run before the first dotnet invocation in the prefix: the SDK
auto-generates a bare-bones NuGet.Config (no trust policy) as a side
effect of any restore if none exists yet, and this function only writes
when the file is absent.
"""
config_dir = prefix_path / "drive_c" / "users" / "steamuser" / "AppData" / "Roaming" / "NuGet"
config_path = config_dir / "NuGet.Config"
if config_path.exists():
log("NuGet.Config already exists - leaving it unchanged")
return True
try:
config_dir.mkdir(parents=True, exist_ok=True)
config_path.write_text(_NUGET_CONFIG_TEMPLATE, encoding="utf-8")
log("NuGet signature policy configured (nuget.org signers trusted by fingerprint)")
return True
except Exception as e:
log(f"Failed to write NuGet.Config: {e}")
return False
@@ -19,6 +19,30 @@ class ResolutionService:
Handles saving, loading, and validation of resolution settings
"""
COMMON_RESOLUTIONS = [
"1280x720",
"1280x800 (Steam Deck)",
"1366x768",
"1440x900",
"1600x900",
"1600x1200",
"1680x1050",
"1920x1080",
"1920x1200",
"2048x1152",
"2560x1080",
"2560x1440",
"2560x1600",
"3440x1440",
"3840x1080",
"3840x1600",
"3840x2160",
"3840x2400",
"5120x1440",
"5120x2160",
"7680x4320",
]
def __init__(self):
"""Initialize the resolution service"""
self.config_handler = ConfigHandler()
@@ -140,6 +164,37 @@ class ResolutionService:
logger.warning(f"Resolution does not match WxH format: {resolution}")
return False
def get_resolution_list(self) -> list:
"""
Build the resolution dropdown list: the curated common resolutions,
plus any resolution reported by a connected Qt screen that isn't
already covered, so unusual native resolutions (e.g. ultrawide)
still show up. Falls back to the curated list alone if screen
detection is unavailable (headless, sandboxed launch, etc).
Returns:
list: Resolution labels sorted by pixel dimensions
"""
resolutions = {}
for label in self.COMMON_RESOLUTIONS:
key = label.split(' ')[0]
resolutions[key] = label
try:
from PySide6.QtGui import QGuiApplication
for screen in QGuiApplication.screens():
size = screen.size()
key = f"{size.width()}x{size.height()}"
resolutions.setdefault(key, key)
except Exception as e:
logger.debug(f"Could not query Qt screens for resolution detection: {e}")
def sort_key(label: str):
width, height = label.split(' ')[0].split('x')
return (int(width), int(height))
return sorted(resolutions.values(), key=sort_key)
def get_resolution_index(self, resolution: str, combo_items: list) -> int:
"""
Get the index of a resolution in a combo box list
+57 -70
View File
@@ -18,6 +18,11 @@ import zipfile
from pathlib import Path
from typing import Callable, Optional
from jackify.backend.services.nuget_signature_service import (
configure_nuget_signature_policy,
install_nuget_cert,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@@ -90,6 +95,30 @@ def _build_reg_content() -> str:
lines.append(f'"{dll}"="native,builtin"')
lines.append("")
# Disable MSBuild/Roslyn shared compiler server. Under Wine, VBCSCompiler's
# named pipe IPC is unreliable (mono/mono#11406), so each dotnet build falls
# back to spawning its own dotnet.exe instead of reusing one server process.
# Synthesis compiles one patcher per mod - without this, those pile up
# unreaped and can consume tens of GB of RAM. Writing to HKCU\Environment is
# the registry equivalent of setx, so every process in the prefix (including
# Synthesis's internal dotnet build calls) inherits these automatically.
#
# The two NUGET_* entries are needed for NuGet package signature validation
# (Synthesis fails to compile patchers without it): offline revocation mode
# avoids online CRL/OCSP checks that routinely fail or time out under Wine's
# sandboxed networking, and the experimental chain-build retry policy works
# around a known NuGet flake where the first chain build after a fresh cert
# import races and fails (NuGet/Home#11099). Both are written here rather
# than only passed to our own subprocess calls so Synthesis itself inherits
# them when launched later via Steam, matching Fluorine-Manager's
# confirmed-working NuGet signature fix.
lines.append("[HKEY_CURRENT_USER\\Environment]")
lines.append('"UseSharedCompilation"="false"')
lines.append('"MSBUILDDISABLENODEREUSE"="1"')
lines.append('"NUGET_CERT_REVOCATION_MODE"="offline"')
lines.append('"NUGET_EXPERIMENTAL_CHAIN_BUILD_RETRY_POLICY"="10,1000"')
lines.append("")
return "\r\n".join(lines)
@@ -104,13 +133,6 @@ _DOTNET9_SDK_FILENAME = "dotnet-sdk-9.0.310-win-x64.zip"
_DOTNET10_DESKTOP_URL = "https://builds.dotnet.microsoft.com/dotnet/WindowsDesktop/10.0.2/windowsdesktop-runtime-10.0.2-win-x64.exe"
_DOTNET10_DESKTOP_FILENAME = "windowsdesktop-runtime-10.0.2-win-x64.exe"
# DigiCert Universal Root CA - required for NuGet package signature validation.
# Without this, dotnet fails to verify NuGet package signatures when Synthesis
# compiles patchers. Imported into the Wine prefix Windows cert store so no
# system-level changes are needed.
_DIGICERT_CERT_URL = "https://cacerts.digicert.com/DigiCertTrustedRootG4.crt.pem"
_DIGICERT_CERT_FILENAME = "DigiCertTrustedRootG4.crt.pem"
# fxc2 build of d3dcompiler_47 - required for Community Shaders shader compilation.
# The winetricks-provided d3dcompiler_47 lacks support for certain shader models
# used by Community Shaders, causing "failed shaders" during compilation.
@@ -204,56 +226,6 @@ def _install_dotnet10_desktop_runtime(
return False
def _install_nuget_cert(
prefix_path: Path,
wine_bin: str,
log: Callable[[str], None],
) -> bool:
"""
Import the DigiCert Trusted Root G4 CA into the Wine prefix Windows cert
store. Required for NuGet package signature validation when Synthesis
compiles patchers. Uses wine certutil so no system-level changes are needed.
"""
try:
from jackify.shared.paths import get_jackify_data_dir
cache_dir = get_jackify_data_dir() / "cache"
cache_dir.mkdir(parents=True, exist_ok=True)
cert_file = cache_dir / _DIGICERT_CERT_FILENAME
if not cert_file.exists():
log(f"Downloading DigiCert Trusted Root G4 certificate...")
urllib.request.urlretrieve(_DIGICERT_CERT_URL, cert_file)
log("Certificate downloaded")
else:
log("DigiCert certificate already cached, skipping download")
log("Importing certificate into Wine prefix cert store...")
env = os.environ.copy()
env["WINEPREFIX"] = str(prefix_path)
env["WINEDEBUG"] = "-all"
env["WINEDLLOVERRIDES"] = "winemenubuilder.exe=d"
env["DISPLAY"] = env.get("DISPLAY", ":0")
result = subprocess.run(
[wine_bin, "certutil", "-addstore", "Root", str(cert_file)],
env=env,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
log(f"certutil exited with code {result.returncode} (may already be installed)")
else:
log("DigiCert certificate imported into Wine cert store")
return True
except Exception as e:
log(f"Failed to install NuGet certificate: {e}")
return False
def _install_fxc2_d3dcompiler(
prefix_path: Path,
log: Callable[[str], None],
@@ -360,8 +332,9 @@ def apply_tool_config(
prefix to Windows 11, which are required for Synthesis. Intentionally opt-in -
the download is ~220MB and the win11 flip has not been verified against NSF/CSF
prefixes (see preserve_global_mscoree).
The NuGet Root CA cert (also required for Synthesis) is applied regardless of
this flag, since it has no interaction with mscoree hosting or Windows version.
The NuGet Root CA certs (also required for Synthesis) are only imported when
the SDK is present, since the trusted-root PEM bundles ship inside the SDK
(see nuget_signature_service.install_nuget_cert).
install_fxc2_d3dcompiler=True replaces d3dcompiler_47.dll with the Mozilla
fxc2 build. Only appropriate for Skyrim SE/AE modlists using Community Shaders.
@@ -387,12 +360,6 @@ def apply_tool_config(
_install_dotnet10_desktop_runtime(prefix_path, wine_bin, _log)
_set_windows_version_win11(prefix_path, wine_bin, _log)
# NuGet cert import is independent of the SDK/win11 install above - it only adds a
# Root CA entry and has no interaction with mscoree hosting, so it must not be
# skipped for NSF/CSF modlists (which pass install_dotnet9_sdk=False to avoid the
# win11 flip). Synthesis still needs this cert even on an NSF/CSF prefix.
_install_nuget_cert(prefix_path, wine_bin, _log)
# Remove legacy global *mscoree=native from DllOverrides if present.
# Old installs wrote this globally, which breaks .NET 9/10 bootstrap (Synthesis).
# The targeted AppDefaults\SkyrimSE.exe entry written below replaces it.
@@ -416,7 +383,15 @@ def apply_tool_config(
except Exception as e:
_log(f"Note: could not remove legacy mscoree entry (non-fatal): {e}")
# _build_reg_content() writes UseSharedCompilation=false and
# MSBUILDDISABLENODEREUSE=1, which prevent the Roslyn shared compiler server
# hang under Wine (see module docstring history). Must be in place before
# anything invokes dotnet in the prefix - without these keys the first
# "dotnet run" can hang on VBCSCompiler's named pipe and leave an orphaned
# dotnet.exe/winedevice.exe spinning at high CPU indefinitely (confirmed
# reproducible during the original cert-import ordering bug).
reg_content = _build_reg_content()
regedit_ok = False
try:
with tempfile.NamedTemporaryFile(
@@ -441,23 +416,35 @@ def apply_tool_config(
if result.returncode != 0:
_log(f"wine regedit exited with code {result.returncode}: {result.stderr[:200]}")
return False
else:
_log(f"Tool compatibility settings applied ({len(_XEDIT_EXECUTABLES)} xEdit variants, Pandora, {len(_DLL_OVERRIDES)} DLL overrides)")
return True
regedit_ok = True
except subprocess.TimeoutExpired:
_log("wine regedit timed out after 30 seconds")
return False
except Exception as e:
_log(f"Failed to apply tool config: {e}")
return False
finally:
try:
os.unlink(reg_file)
except Exception:
pass
# Must run before the first dotnet invocation in this prefix: the SDK
# auto-generates a bare-bones NuGet.Config (nuget.org source only, no trust
# policy) as a side effect of any restore if none exists yet, and
# configure_nuget_signature_policy skips writing when the file already
# exists - so a stub landing first means our trust policy never applies.
configure_nuget_signature_policy(prefix_path, _log)
# NuGet cert import requires the .NET SDK already present (the trusted-root
# PEM bundles ship inside the SDK). On NSF/CSF prefixes
# (install_dotnet9_sdk=False) the SDK is not installed, so this is skipped
# there - those modlists don't run Synthesis in the same prefix.
install_nuget_cert(prefix_path, wine_bin, _log)
return regedit_ok
def setup_nemesis_compatibility(
modlist_dir: str,
@@ -144,28 +144,7 @@ class ConfigureExistingModlistUIMixin:
self.resolution_combo = QComboBox()
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.resolution_combo.addItem("Leave unchanged")
self.resolution_combo.addItems([
"1280x720",
"1280x800 (Steam Deck)",
"1366x768",
"1440x900",
"1600x900",
"1600x1200",
"1680x1050",
"1920x1080",
"1920x1200",
"2048x1152",
"2560x1080",
"2560x1440",
"2560x1600",
"3440x1440",
"3840x1600",
"3840x2160",
"3840x2400",
"5120x1440",
"5120x2160",
"7680x4320"
])
self.resolution_combo.addItems(self.resolution_service.get_resolution_list())
form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addWidget(self.resolution_combo, 2, 1)
@@ -140,28 +140,7 @@ class ConfigureNewModlistUISetupMixin:
self.resolution_combo = QComboBox()
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.resolution_combo.addItem("Leave unchanged")
self.resolution_combo.addItems([
"1280x720",
"1280x800 (Steam Deck)",
"1366x768",
"1440x900",
"1600x900",
"1600x1200",
"1680x1050",
"1920x1080",
"1920x1200",
"2048x1152",
"2560x1080",
"2560x1440",
"2560x1600",
"3440x1440",
"3840x1600",
"3840x2160",
"3840x2400",
"5120x1440",
"5120x2160",
"7680x4320"
])
self.resolution_combo.addItems(self.resolution_service.get_resolution_list())
form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
# Load saved resolution if available
@@ -259,28 +259,7 @@ class InstallModlistUISetupMixin:
self.resolution_combo = QComboBox()
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.resolution_combo.addItem("Leave unchanged")
self.resolution_combo.addItems([
"1280x720",
"1280x800 (Steam Deck)",
"1366x768",
"1440x900",
"1600x900",
"1600x1200",
"1680x1050",
"1920x1080",
"1920x1200",
"2048x1152",
"2560x1080",
"2560x1440",
"2560x1600",
"3440x1440",
"3840x1600",
"3840x2160",
"3840x2400",
"5120x1440",
"5120x2160",
"7680x4320"
])
self.resolution_combo.addItems(self.resolution_service.get_resolution_list())
# Load saved resolution if available
saved_resolution = self.resolution_service.get_saved_resolution()
is_steam_deck = False
@@ -343,10 +343,19 @@ class FileProgressList(QWidget):
def stop_cpu_tracking(self):
self._cpu_timer.stop()
if self._cpu_worker and self._cpu_worker.isRunning():
self._cpu_worker.quit()
self._cpu_worker.wait(1000)
worker = self._cpu_worker
if worker is None:
return
self._cpu_worker = None
if not worker.isRunning():
return
worker.wait(2000)
if worker.isRunning():
# psutil scan (baselining new child processes) didn't finish in time.
# Dropping the last Python reference to a still-running QThread makes
# Qt abort ("QThread: Destroyed while thread is still running") - let
# it finish naturally and clean itself up instead.
worker.finished.connect(worker.deleteLater)
def _start_cpu_worker(self):
# Skip if a worker is already running to avoid pileup
+51 -3
View File
@@ -524,7 +524,7 @@ def check_user_directories(pfx: Path, game_type: str, r: Results):
if my_games.is_dir():
r.ok(f"My Games/{MY_GAMES_DIR[game_type]} exists")
else:
r.fail(f"My Games/{MY_GAMES_DIR[game_type]} missing")
r.warn(f"My Games/{MY_GAMES_DIR[game_type]} missing")
# AppData/Local
if game_type in APPDATA_LOCAL_DIRS:
@@ -1124,8 +1124,13 @@ def check_tool_compat_config(pfx: Path, game_type: str, r: Results):
# .NET 9 SDK is installed natively (not via winetricks) for Synthesis.
# NSF/CSF prefixes use global *mscoree=native and deliberately skip dotnet9 (win11 flip
# breaks NSF), so suppress the check when that override is present.
_nsf_prefix = '"*mscoree"="native"' in content
# breaks NSF), so suppress the check when that override is present. Must check the
# global [Software\Wine\DllOverrides] section specifically, not just any occurrence of
# the string - every Skyrim modlist (NSF or not) also gets a scoped
# AppDefaults\SkyrimSE.exe\DllOverrides entry with the same value, which a plain
# substring search can't tell apart from the global one.
_global_overrides_match = re.search(r"\[Software\\\\Wine\\\\DllOverrides\][^\[]*", content)
_nsf_prefix = bool(_global_overrides_match and '"*mscoree"="native"' in _global_overrides_match.group(0))
sdk_base = pfx / "drive_c" / "Program Files" / "dotnet" / "sdk"
if _nsf_prefix:
r.ok(".NET 9 SDK check skipped (NSF/CSF prefix - global mscoree=native detected)")
@@ -1139,6 +1144,49 @@ def check_tool_compat_config(pfx: Path, game_type: str, r: Results):
else:
r.warn(".NET 9 SDK not found (drive_c/Program Files/dotnet/sdk/ missing; run Configure Tool Compatibility)")
if not _nsf_prefix:
sys_content = system_reg.read_text(errors="replace") if system_reg.exists() else ""
_check_nuget_signature_config(pfx, content, sys_content, r)
def _check_nuget_signature_config(pfx: Path, user_reg_content: str, system_reg_content: str, r: Results):
"""
Check NuGet package signature validation is configured for Synthesis.
Three independent pieces, all required (see nuget_signature_service.py):
the trust-pin NuGet.Config, the SDK's own trusted-root certs imported into
Wine's cert store, and the offline-revocation/retry-policy env vars.
"""
config_path = pfx / "drive_c" / "users" / "steamuser" / "AppData" / "Roaming" / "NuGet" / "NuGet.Config"
if not config_path.exists():
r.warn("NuGet.Config not found (Synthesis package restore will likely fail)")
else:
config_content = config_path.read_text(errors="replace")
if "allowUntrustedRoot" in config_content and "trustedSigners" in config_content:
r.ok("NuGet signature trust policy configured")
else:
r.warn(
"NuGet.Config exists but lacks the trust policy - likely the SDK's "
"auto-generated stub (dotnet wrote it before Jackify could); delete "
"it and re-run Configure Tool Compatibility"
)
# X509Store(StoreName.Root, StoreLocation.CurrentUser) lands in user.reg
# (HKCU) on some Wine versions and system.reg (HKLM) on others - confirmed
# via a real CachyOS repro (wine-11.0) where the import genuinely succeeded
# but landed in system.reg while an older Wine build on another machine put
# it in user.reg. Check both rather than assume one.
marker = "SystemCertificates\\\\Root\\\\Certificates\\\\"
root_cert_count = user_reg_content.count(marker) + system_reg_content.count(marker)
if root_cert_count > 0:
r.ok(f".NET SDK trusted-root certs imported into Wine cert store ({root_cert_count} entries)")
else:
r.warn(".NET SDK trusted-root certs not found in Wine cert store")
if "NUGET_CERT_REVOCATION_MODE" in user_reg_content and "NUGET_EXPERIMENTAL_CHAIN_BUILD_RETRY_POLICY" in user_reg_content:
r.ok("NuGet offline-revocation/retry-policy env vars set")
else:
r.warn("NuGet offline-revocation/retry-policy env vars not found in HKCU\\Environment")
def check_steam_artwork(appid: str, r: Results):
"""Check whether Steam grid artwork was applied for this AppID."""