mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-08-14 03:13:44 +02:00
Release v0.7.2 - TTW Linux Installer 0.2.0
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user