mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 00:23:42 +02:00
237 lines
9.4 KiB
Python
237 lines
9.4 KiB
Python
"""
|
|
Synthesis patcher version bump for modlists that bundle it.
|
|
|
|
Bundled Synthesis builds as old as 0.29.2 and as recent as 0.35.3 crash listing
|
|
patchers whenever the live Mutagen-Modding/Synthesis.Registry listing contains a
|
|
GameRelease enum member the bundled Mutagen.Bethesda assembly doesn't recognize
|
|
(confirmed 2026-07-17: the registry added "EnderalSEGog" on 2026-07-14, breaking
|
|
every Synthesis build up to and including 0.35.3, on Linux and Windows alike).
|
|
0.36.5 ships the mutagen bump that fixes the parser. Since Synthesis isn't
|
|
installed by Jackify at all - it comes entirely from whatever the modlist's own
|
|
MO2 download bundled - the fix is to overwrite it in place with a known-good
|
|
release, only for modlists that actually include it.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import urllib.request
|
|
import zipfile
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TARGET_VERSION = "0.36.5"
|
|
_SYNTHESIS_ZIP_URL = (
|
|
f"https://github.com/Mutagen-Modding/Synthesis/releases/download/{_TARGET_VERSION}/Synthesis.zip"
|
|
)
|
|
_VERSION_MARKER_FILENAME = ".jackify_synthesis_version"
|
|
_GUI_SETTINGS_FILENAME = "GuiSettings.json"
|
|
_PIPELINE_SETTINGS_FILENAME = "PipelineSettings.json"
|
|
|
|
|
|
def _resolve_mo2_binary_path(raw_value: str) -> Optional[Path]:
|
|
"""Convert a ModOrganizer.ini `\\binary` value (e.g. `Z:/home/deck/.../Synthesis.exe`
|
|
or `D:\\\\home\\\\deck\\\\...\\\\Synthesis.exe`) into a real Linux Path."""
|
|
m = re.match(r"(?i)^[a-z]:[\\/](.+)$", raw_value.strip())
|
|
if not m:
|
|
return None
|
|
linux_path = m.group(1).replace("\\\\", "/").replace("\\", "/")
|
|
return Path("/" + linux_path)
|
|
|
|
|
|
def find_synthesis_dir(modlist_dir: str) -> Optional[Path]:
|
|
"""
|
|
Find Synthesis's install directory for a modlist by reading its
|
|
`[customExecutables]` entry in ModOrganizer.ini, the same way
|
|
setup_nemesis_compatibility locates Nemesis. Returns None if the
|
|
modlist doesn't include Synthesis.
|
|
"""
|
|
mo2_ini = Path(modlist_dir) / "ModOrganizer.ini"
|
|
if not mo2_ini.is_file():
|
|
return None
|
|
|
|
try:
|
|
content = mo2_ini.read_text(encoding="utf-8")
|
|
except Exception as e:
|
|
logger.warning("Synthesis detection: could not read ModOrganizer.ini: %s", e)
|
|
return None
|
|
|
|
# jackify-engine's fresh Wabbajack extraction pads "key = value"; MO2's own
|
|
# resave (after the user opens it once) tightens it to "key=value" - both
|
|
# forms occur in the wild, so whitespace around '=' must be tolerated.
|
|
match = re.search(
|
|
r'^\d+\\binary\s*=\s*(.*Synthesis\.exe)\s*$',
|
|
content,
|
|
re.MULTILINE | re.IGNORECASE,
|
|
)
|
|
if not match:
|
|
return None
|
|
|
|
binary_path = _resolve_mo2_binary_path(match.group(1))
|
|
if binary_path is None or not binary_path.is_file():
|
|
return None
|
|
|
|
return binary_path.parent
|
|
|
|
|
|
def _resolve_game_data_path(modlist_dir: str) -> Optional[str]:
|
|
"""
|
|
Read MO2's own `gamePath` out of ModOrganizer.ini and return it as a
|
|
Windows-style `<gamePath>\\Data` string, for use as Synthesis's
|
|
DataPathOverride. gamePath is whatever Jackify/CLF3 already resolved as
|
|
this modlist's managed game directory (StockGame, Game Root, a vanilla
|
|
Steam/GOG/Epic install, etc.) - no separate detection needed here.
|
|
"""
|
|
mo2_ini = Path(modlist_dir) / "ModOrganizer.ini"
|
|
if not mo2_ini.is_file():
|
|
return None
|
|
|
|
try:
|
|
content = mo2_ini.read_text(encoding="utf-8")
|
|
except Exception as e:
|
|
logger.warning("Synthesis DataPathOverride: could not read ModOrganizer.ini: %s", e)
|
|
return None
|
|
|
|
match = re.search(
|
|
r'^gamePath\s*=\s*@ByteArray\((.*)\)\s*$',
|
|
content,
|
|
re.MULTILINE | re.IGNORECASE,
|
|
)
|
|
if not match:
|
|
return None
|
|
|
|
# MO2 doubles every backslash (Qt escaping); some writers double that
|
|
# again. Collapse any run of backslashes down to one, then append Data.
|
|
game_path = re.sub(r"\\+", r"\\", match.group(1).strip())
|
|
if not game_path:
|
|
return None
|
|
return f"{game_path}\\Data"
|
|
|
|
|
|
def _set_data_path_override(synthesis_dir: Path, modlist_dir: str, log: Callable[[str], None]) -> None:
|
|
"""
|
|
Merge-patch each profile's DataPathOverride to point at this modlist's
|
|
actual game data folder, if it isn't already correct. Bundled Synthesis
|
|
installs often ship with this null, which leaves the field blank in
|
|
Synthesis's own UI until the user fills it in manually.
|
|
"""
|
|
expected = _resolve_game_data_path(modlist_dir)
|
|
if not expected:
|
|
return
|
|
|
|
pipeline_settings_path = synthesis_dir / _PIPELINE_SETTINGS_FILENAME
|
|
try:
|
|
pipeline_settings = json.loads(pipeline_settings_path.read_text(encoding="utf-8")) if pipeline_settings_path.is_file() else {"Version": 2, "Profiles": []}
|
|
changed = False
|
|
for profile in pipeline_settings.get("Profiles", []):
|
|
if profile.get("DataPathOverride") != expected:
|
|
profile["DataPathOverride"] = expected
|
|
changed = True
|
|
if changed:
|
|
pipeline_settings_path.write_text(json.dumps(pipeline_settings, indent=4), encoding="utf-8")
|
|
log(f"Synthesis update: set DataPathOverride to {expected}")
|
|
except Exception as e:
|
|
log(f"Synthesis update: could not set DataPathOverride (non-fatal): {e}")
|
|
|
|
|
|
def get_installed_version(synthesis_dir: Path) -> Optional[str]:
|
|
"""Read the version Jackify last wrote to this Synthesis install, if any."""
|
|
marker = synthesis_dir / _VERSION_MARKER_FILENAME
|
|
if not marker.is_file():
|
|
return None
|
|
try:
|
|
return marker.read_text(encoding="utf-8").strip()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _disable_mo2_mode(synthesis_dir: Path, log: Callable[[str], None]) -> None:
|
|
"""
|
|
Merge-patch Synthesis's own settings files to default MO2 Mode off and
|
|
suppress its first-run prompt, without disturbing anything else - these
|
|
files ship pre-populated inside the Wabbajack archive with the modlist
|
|
author's own curated patcher list, which must survive untouched.
|
|
|
|
MO2 Mode blocks patcher building while running inside MO2's VFS (which
|
|
can't reliably handle multithreaded dotnet build I/O), requiring a
|
|
build-then-run two-phase workflow with no equivalent to Windows'
|
|
"run outside MO2 once" on Linux/Proton. Confirmed via a side-by-side
|
|
diff of two real installs (2026-07-17): the only fields that differ
|
|
between an MO2-Mode-enabled and MO2-Mode-disabled install are
|
|
BlockBuildingWithinMo2 (PipelineSettings.json) and HasSeenMo2Prompt
|
|
(GuiSettings.json, which only suppresses the dialog and is set to true
|
|
either way once a user has seen it once).
|
|
"""
|
|
gui_settings_path = synthesis_dir / _GUI_SETTINGS_FILENAME
|
|
try:
|
|
gui_settings = json.loads(gui_settings_path.read_text(encoding="utf-8")) if gui_settings_path.is_file() else {"Version": 2}
|
|
gui_settings["HasSeenMo2Prompt"] = True
|
|
gui_settings_path.write_text(json.dumps(gui_settings, indent=4), encoding="utf-8")
|
|
except Exception as e:
|
|
log(f"Synthesis update: could not set HasSeenMo2Prompt (non-fatal): {e}")
|
|
|
|
pipeline_settings_path = synthesis_dir / _PIPELINE_SETTINGS_FILENAME
|
|
try:
|
|
pipeline_settings = json.loads(pipeline_settings_path.read_text(encoding="utf-8")) if pipeline_settings_path.is_file() else {"Version": 2, "Profiles": []}
|
|
pipeline_settings["BlockBuildingWithinMo2"] = False
|
|
pipeline_settings_path.write_text(json.dumps(pipeline_settings, indent=4), encoding="utf-8")
|
|
except Exception as e:
|
|
log(f"Synthesis update: could not set BlockBuildingWithinMo2 (non-fatal): {e}")
|
|
|
|
log("Synthesis update: MO2 Mode defaulted off (no Linux equivalent to launching outside MO2)")
|
|
|
|
|
|
def update_synthesis(
|
|
modlist_dir: str,
|
|
log: Optional[Callable[[str], None]] = None,
|
|
) -> bool:
|
|
"""
|
|
Overwrite a modlist's bundled Synthesis with the target version if it's
|
|
older, or if Jackify hasn't touched it before. Non-fatal - logs failures
|
|
but does not raise.
|
|
"""
|
|
def _log(msg: str):
|
|
logger.info(msg)
|
|
if log:
|
|
log(msg)
|
|
|
|
synthesis_dir = find_synthesis_dir(modlist_dir)
|
|
if synthesis_dir is None:
|
|
_log("Synthesis update: no Synthesis executable entry found, skipping")
|
|
return False
|
|
|
|
_set_data_path_override(synthesis_dir, modlist_dir, _log)
|
|
|
|
if get_installed_version(synthesis_dir) == _TARGET_VERSION:
|
|
_log(f"Synthesis update: already at {_TARGET_VERSION}, skipping")
|
|
return True
|
|
|
|
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)
|
|
zip_path = cache_dir / f"Synthesis-{_TARGET_VERSION}.zip"
|
|
|
|
if not zip_path.exists():
|
|
_log(f"Downloading Synthesis {_TARGET_VERSION}...")
|
|
urllib.request.urlretrieve(_SYNTHESIS_ZIP_URL, zip_path)
|
|
_log("Synthesis downloaded")
|
|
else:
|
|
_log("Synthesis already cached, skipping download")
|
|
|
|
_log(f"Updating Synthesis to {_TARGET_VERSION} in {synthesis_dir}...")
|
|
with zipfile.ZipFile(zip_path) as zf:
|
|
zf.extractall(synthesis_dir)
|
|
|
|
_disable_mo2_mode(synthesis_dir, _log)
|
|
|
|
(synthesis_dir / _VERSION_MARKER_FILENAME).write_text(_TARGET_VERSION, encoding="utf-8")
|
|
_log(f"Synthesis updated to {_TARGET_VERSION}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
_log(f"Synthesis update failed (non-fatal): {e}")
|
|
return False
|