"""REFramework setup: dinput8.dll, re2_fw_config.txt and the Proton launch option.""" from __future__ import annotations import io import json import re import urllib.request import zipfile from dataclasses import dataclass, field from pathlib import Path from typing import Callable from . import steam from .util import atomic_write_bytes, atomic_write_text # The filename keeps its RE2 origin across all RE Engine games. CONFIG_NAME = "re2_fw_config.txt" LOOSE_KEY = "LooseFileLoader_Enabled" RELEASES_API = "https://api.github.com/repos/praydog/REFramework-nightly/releases/latest" ASSET_NAME = "REFramework.zip" MAX_DOWNLOAD = 200 * 1024 * 1024 @dataclass class Check: label: str ok: bool detail: str = "" @dataclass class LoaderStatus: checks: list[Check] = field(default_factory=list) launch_options: str | None = None suggested_launch_options: str = steam.REQUIRED_LAUNCH_OPTIONS @property def all_ok(self) -> bool: return all(c.ok for c in self.checks) # -- config file ------------------------------------------------------------------ def read_config_value(config: Path, key: str) -> str | None: try: text = config.read_text(encoding="utf-8", errors="replace") except OSError: return None for line in text.splitlines(): k, sep, v = line.partition("=") if sep and k.strip().lower() == key.lower(): return v.strip() return None def set_config_value(config: Path, key: str, value: str) -> None: """Key-wise patch: keep every unrelated line, create the file if absent.""" try: text = config.read_text(encoding="utf-8") except FileNotFoundError: text = "" nl = "\r\n" if "\r\n" in text else "\n" lines = text.splitlines() for i, line in enumerate(lines): k, sep, _ = line.partition("=") if sep and k.strip().lower() == key.lower(): lines[i] = f"{k.strip()}={value}" break else: lines.append(f"{key}={value}") atomic_write_text(config, nl.join(lines) + nl) def loose_loader_enabled(game_dir: Path) -> bool: return (read_config_value(game_dir / CONFIG_NAME, LOOSE_KEY) or "").lower() == "true" def enable_loose_loader(game_dir: Path) -> None: set_config_value(game_dir / CONFIG_NAME, LOOSE_KEY, "true") # -- status ------------------------------------------------------------------------- def status(game_dir: Path, launch_options: str | None) -> LoaderStatus: st = LoaderStatus(launch_options=launch_options, suggested_launch_options=steam.suggest_launch_options(launch_options)) has_dll = (game_dir / "dinput8.dll").is_file() st.checks.append(Check("REFramework (dinput8.dll) installed", has_dll, "" if has_dll else "dinput8.dll not found in the game folder")) loose = loose_loader_enabled(game_dir) st.checks.append(Check(f"{LOOSE_KEY}=true in {CONFIG_NAME}", loose, "" if loose else "Must be set before launching the game")) lo = steam.launch_options_ok(launch_options) st.checks.append(Check("Steam launch option overrides dinput8", lo, "" if lo else "Proton needs WINEDLLOVERRIDES to load dinput8.dll")) return st # -- install ------------------------------------------------------------------------ Fetch = Callable[[str], bytes] def _default_fetch(url: str) -> bytes: req = urllib.request.Request(url, headers={"User-Agent": "mhwilds-mod-manager", "Accept": "application/vnd.github+json"}) with urllib.request.urlopen(req, timeout=60) as r: data = r.read(MAX_DOWNLOAD + 1) if len(data) > MAX_DOWNLOAD: raise RuntimeError("download is unexpectedly large") return data def install_reframework(game_dir: Path, fetch: Fetch = _default_fetch, backup_dir: Path | None = None) -> str: """Download the latest nightly MHWILDS.zip and extract dinput8.dll. Returns the release tag.""" release = json.loads(fetch(RELEASES_API)) asset = next((a for a in release.get("assets", []) if a.get("name") == ASSET_NAME), None) if not asset: raise RuntimeError(f"latest REFramework release has no {ASSET_NAME}") blob = fetch(asset["browser_download_url"]) with zipfile.ZipFile(io.BytesIO(blob)) as zf: member = next((n for n in zf.namelist() if n.lower() == "dinput8.dll"), None) if member is None: raise RuntimeError("dinput8.dll not found in the release archive") dll = zf.read(member) target = game_dir / "dinput8.dll" if target.exists() and backup_dir is not None: backup_dir.mkdir(parents=True, exist_ok=True) (backup_dir / "dinput8.dll.prev").write_bytes(target.read_bytes()) atomic_write_bytes(target, dll, 0o755) return str(release.get("tag_name", "")) def setup_all(game_dir: Path) -> None: """Everything except the Steam launch option (that is verify-only by default).""" enable_loose_loader(game_dir)