diff options
| author | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-08-01 15:14:09 -0400 |
|---|---|---|
| committer | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-08-01 15:14:09 -0400 |
| commit | cc5eb55dbe02d65910c5278a375d0d5640374208 (patch) | |
| tree | 522764d808b097e69a2398cb2db7e0cd09167353 /py_modules/services/patcher.py | |
| parent | 02521a797e195b331af1778cd7bc854d3a396ead (diff) | |
| download | Decky-Framegen-cc5eb55dbe02d65910c5278a375d0d5640374208.tar.gz Decky-Framegen-cc5eb55dbe02d65910c5278a375d0d5640374208.zip | |
fix package layout for Decky runtime modulessafe-patch-refactor
Diffstat (limited to 'py_modules/services/patcher.py')
| -rw-r--r-- | py_modules/services/patcher.py | 685 |
1 files changed, 685 insertions, 0 deletions
diff --git a/py_modules/services/patcher.py b/py_modules/services/patcher.py new file mode 100644 index 0000000..59d08f1 --- /dev/null +++ b/py_modules/services/patcher.py @@ -0,0 +1,685 @@ +"""Patch ownership, safe cleanup, and Steam-game operations.""" + +import os +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable + +from . import config +from .assets import ( + BACKUP_DIRECTORY_NAME, + FRAMEGEN_BACKENDS, + FSR4_UPSCALER_FILENAME, + INJECTOR_FILENAMES, + MARKER_FILENAME, + MANIFEST_SCHEMA_VERSION, + RESTORABLE_BACKUP_FILES, + SUPPORT_FILES, + VALID_DLL_NAMES, + VARIANT_EXTRA_FILENAMES, +) +from .bundle import BundleManager +from .files import file_sha256, files_match, read_json, write_json +from .steam import SteamLibrary + + +Logger = Callable[[str], None] + + +class PatchManager: + def __init__(self, home: Path, bundle: BundleManager, steam: SteamLibrary, logger: Logger): + self.home = home + self.bundle = bundle + self.steam = steam + self.logger = logger + + def _log(self, message: str) -> None: + self.logger(f"[Framegen] {message}") + + @staticmethod + def _relative_path(root: Path, path: Path) -> str: + return path.resolve().relative_to(root.resolve()).as_posix() + + def _safe_path(self, root: Path, value: str | Path) -> Path: + candidate = Path(value) + if candidate.is_absolute(): + resolved = candidate.resolve() + else: + resolved = (root / candidate).resolve() + try: + resolved.relative_to(root.resolve()) + except ValueError as exc: + raise ValueError(f"Path escapes patch directory: {value}") from exc + return resolved + + def _read_marker(self, marker_path: Path) -> dict: + return read_json(marker_path) + + def _find_marker(self, install_root: Path, appid: str | None = None) -> Path | None: + if not install_root.exists(): + return None + try: + markers = sorted(path for path in install_root.rglob(MARKER_FILENAME) if path.is_file()) + except OSError: + return None + for marker in markers: + metadata = self._read_marker(marker) + if appid is None or str(metadata.get("appid")) == str(appid): + return marker + return None + + def _marker_target(self, marker_path: Path, metadata: dict) -> Path: + target_value = metadata.get("target_relative") or metadata.get("target_dir") or str(marker_path.parent) + target = self._safe_path(marker_path.parent, target_value) + if not target.is_dir(): + raise FileNotFoundError(f"Patch target directory does not exist: {target}") + return target + + def _bundle_source_for_proxy(self, variant: str, dll_name: str) -> Path: + candidates = [ + self.bundle.variant_proxy_path(variant, dll_name), + self.bundle.variant_injector_path(variant), + self.bundle.fgmod_path / "renames" / dll_name, + self.bundle.fgmod_path / "OptiScaler.dll", + ] + for candidate in candidates: + if candidate and candidate.exists(): + return candidate + raise FileNotFoundError(f"No injector source found for {dll_name}") + + def _copy_plan(self, target_dir: Path, variant: str) -> list[tuple[str, Path]]: + plan: list[tuple[str, Path]] = [] + # The proxy is the only file selected by the user; all other files are bundle-owned. + plan.append(("__proxy__", self._bundle_source_for_proxy(variant, "dxgi.dll"))) + for filename in SUPPORT_FILES: + source = self.bundle.fgmod_path / filename + if source.is_file(): + plan.append((filename, source)) + upscaler = self.bundle.variant_path(variant) + if upscaler.is_file(): + plan.append((FSR4_UPSCALER_FILENAME, upscaler)) + for extra in self.bundle.variant_extra_files(variant): + source = self.bundle.variant_extra_path(variant, extra["name"]) + if source.is_file(): + plan.append((extra["name"], source)) + + for source_root, relative_root in ( + (self.bundle.fgmod_path / "plugins", Path("plugins")), + (self.bundle.fgmod_path / "D3D12_Optiscaler", Path("D3D12_Optiscaler")), + ): + if not source_root.is_dir(): + continue + for source in sorted(path for path in source_root.rglob("*") if path.is_file()): + plan.append(((relative_root / source.relative_to(source_root)).as_posix(), source)) + return plan + + def _backup_destination(self, target_dir: Path, relative: str, source: Path, force: bool = False) -> str | None: + destination = self._safe_path(target_dir, relative) + if not destination.exists(): + return None + if destination.is_dir(): + raise IsADirectoryError(f"Patch destination is a directory: {destination}") + if not force and files_match(destination, source): + return None + + if relative in RESTORABLE_BACKUP_FILES: + legacy_backup = destination.with_name(destination.name + ".b") + if not legacy_backup.exists(): + shutil.copy2(destination, legacy_backup) + elif not files_match(legacy_backup, destination): + raise RuntimeError(f"Refusing to overwrite an existing backup: {legacy_backup}") + return self._relative_path(target_dir, legacy_backup) + + backup = target_dir / BACKUP_DIRECTORY_NAME / relative + backup.parent.mkdir(parents=True, exist_ok=True) + if backup.exists(): + if not files_match(backup, destination): + raise RuntimeError(f"Refusing to overwrite an existing backup: {backup}") + else: + shutil.copy2(destination, backup) + return self._relative_path(target_dir, backup) + + def _cleanup_empty_directories(self, target_dir: Path, managed_files: list[str]) -> None: + directories = sorted( + {self._safe_path(target_dir, path).parent for path in managed_files}, + key=lambda path: len(path.parts), + reverse=True, + ) + for directory in directories: + if directory == target_dir: + continue + try: + directory.rmdir() + except OSError: + pass + backup_dir = target_dir / BACKUP_DIRECTORY_NAME + try: + backup_dir.rmdir() + except OSError: + pass + + def _rollback( + self, + target_dir: Path, + managed: list[dict], + backups: list[dict], + ) -> None: + for entry in reversed(managed): + try: + path = self._safe_path(target_dir, entry["path"]) + backup = next((item["backup"] for item in backups if item["path"] == entry["path"]), None) + if backup: + backup_path = self._safe_path(target_dir, backup) + if backup_path.exists(): + if path.exists(): + path.unlink() + shutil.move(backup_path, path) + elif path.exists() and entry.get("sha256") == file_sha256(path): + path.unlink() + except (OSError, ValueError): + continue + self._cleanup_empty_directories(target_dir, [entry["path"] for entry in managed]) + + def _patch_directory( + self, + target_dir: Path, + dll_name: str, + variant: str, + framegen_backend: str, + *, + appid: str, + game_name: str, + original_launch_options: str, + previous_variant: str | None = None, + ) -> dict: + if dll_name not in VALID_DLL_NAMES: + return {"status": "error", "message": f"Invalid proxy DLL name: {dll_name}"} + if not self.bundle.fgmod_path.exists() or not (self.bundle.fgmod_path / "OptiScaler.dll").exists(): + return {"status": "error", "message": "OptiScaler bundle not installed. Run Install first."} + + variant = self.bundle.selected_variant(variant) + framegen_backend = self.bundle.selected_backend(framegen_backend) + previous_marker = target_dir / MARKER_FILENAME + if previous_marker.exists(): + return {"status": "error", "message": f"Patch marker already exists in {target_dir}; clean it up before patching."} + variant_overrides = self.bundle.variant_config_overrides(variant) + if previous_variant == "rdna2-valve-411-pre10" and variant != previous_variant: + variant_overrides.setdefault("FSR.Fsr4ForceModel", "auto") + variant_overrides.setdefault("Plugins.LoadCustomAmdxc64OnRdna2", "false") + + managed: list[dict] = [] + backups: list[dict] = [] + try: + plan = self._copy_plan(target_dir, variant) + proxy_source = self._bundle_source_for_proxy(variant, dll_name) + plan[0] = (dll_name, proxy_source) + config_path = target_dir / "OptiScaler.ini" + if config_path.exists(): + plan.append(("OptiScaler.ini", config_path)) + elif (self.bundle.fgmod_path / "OptiScaler.ini").exists(): + plan.append(("OptiScaler.ini", self.bundle.fgmod_path / "OptiScaler.ini")) + + for relative, source in plan: + destination = self._safe_path(target_dir, relative) + if not source.is_file(): + continue + backup = self._backup_destination( + target_dir, + relative, + source, + # Preserve every pre-existing destination. Identical bytes do + # not prove that a file belongs to this plugin. + force=destination.exists(), + ) + if backup: + backups.append({ + "path": relative, + "backup": backup, + "sha256": file_sha256(self._safe_path(target_dir, backup)), + }) + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.resolve() != source.resolve(): + shutil.copy2(source, destination) + managed.append({ + "path": relative, + "sha256": file_sha256(destination), + "source_sha256": file_sha256(source), + }) + + if config_path.exists() and not config.configure( + config_path, + framegen_backend, + variant_overrides=variant_overrides, + logger=self.logger, + ): + raise RuntimeError("Could not update OptiScaler.ini") + config_entry = next((entry for entry in managed if entry["path"] == "OptiScaler.ini"), None) + if config_entry: + config_entry["sha256"] = file_sha256(config_path) + + payload = { + "schema_version": MANIFEST_SCHEMA_VERSION, + "appid": str(appid), + "game_name": game_name, + "dll_name": dll_name, + "target_dir": str(target_dir), + "target_relative": ".", + "original_launch_options": original_launch_options, + "backed_up_files": [item["path"] for item in backups], + "backups": backups, + "managed_files": managed, + "managed_directories": sorted({str(Path(item["path"]).parent) for item in managed if Path(item["path"]).parent != Path(".")}), + "optiscaler_version": self.bundle.fgmod_version(), + "fsr4_variant": variant, + "fsr4_variant_label": self.bundle.variant_info(variant)["label"], + "fsr4_upscaler_sha256": file_sha256(target_dir / FSR4_UPSCALER_FILENAME) if (target_dir / FSR4_UPSCALER_FILENAME).exists() else None, + "framegen_backend": framegen_backend, + "framegen_backend_label": FRAMEGEN_BACKENDS[framegen_backend], + "patched_at": datetime.now(timezone.utc).isoformat(), + } + # The marker is written last, so an interrupted copy is never advertised as a complete patch. + write_json(previous_marker, payload) + return { + "status": "success", + "message": f"OptiScaler files copied to {target_dir} using {self.bundle.variant_info(variant)['label']}", + "fsr4_variant": variant, + "fsr4_variant_label": self.bundle.variant_info(variant)["label"], + "fsr4_upscaler_sha256": payload["fsr4_upscaler_sha256"], + "optiscaler_version": payload["optiscaler_version"], + "framegen_backend": framegen_backend, + } + except (OSError, RuntimeError, ValueError) as exc: + self._rollback(target_dir, managed, backups) + self._log(f"Patch failed for {target_dir}: {exc}") + return {"status": "error", "message": f"Patch failed: {exc}"} + + def _managed_entries(self, target_dir: Path, metadata: dict) -> tuple[list[tuple[dict, Path]], list[str]]: + entries: list[tuple[dict, Path]] = [] + errors: list[str] = [] + for entry in metadata.get("managed_files", []): + if not isinstance(entry, dict) or not entry.get("path"): + errors.append("Patch marker contains an invalid managed file entry.") + continue + try: + path = self._safe_path(target_dir, entry["path"]) + except ValueError as exc: + errors.append(str(exc)) + continue + expected = str(entry.get("sha256") or "") + if not expected: + errors.append(f"Patch marker has no hash for {entry['path']}.") + elif path.exists() and (not path.is_file() or file_sha256(path).lower() != expected.lower()): + errors.append(f"Refusing to remove modified managed file: {path}") + entries.append((entry, path)) + return entries, errors + + def _restore_backups(self, target_dir: Path, metadata: dict) -> list[str]: + restored: list[str] = [] + for item in metadata.get("backups", []): + if not isinstance(item, dict) or not item.get("path") or not item.get("backup"): + raise ValueError("Patch marker contains an invalid backup entry.") + path = self._safe_path(target_dir, item["path"]) + backup = self._safe_path(target_dir, item["backup"]) + if not backup.exists(): + continue + if not backup.is_file(): + raise ValueError(f"Patch backup is not a file: {backup}") + expected = str(item.get("sha256") or "") + if expected and file_sha256(backup).lower() != expected.lower(): + raise RuntimeError(f"Refusing to restore a modified backup: {backup}") + if path.exists(): + raise RuntimeError(f"Refusing to overwrite a file while restoring backup: {path}") + path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(backup, path) + restored.append(str(item["path"])) + + # Older releases used ``name.b`` backups and listed only their base names. + for raw_name in metadata.get("backed_up_files", []): + name = Path(str(raw_name)).name + if name not in RESTORABLE_BACKUP_FILES: + continue + original = self._safe_path(target_dir, name) + backup = self._safe_path(target_dir, name + ".b") + if backup.exists() and not original.exists(): + shutil.move(backup, original) + restored.append(name) + return restored + + def _validate_backups(self, target_dir: Path, metadata: dict) -> None: + for item in metadata.get("backups", []): + if not isinstance(item, dict) or not item.get("path") or not item.get("backup"): + raise ValueError("Patch marker contains an invalid backup entry.") + self._safe_path(target_dir, item["path"]) + backup = self._safe_path(target_dir, item["backup"]) + if not backup.exists(): + continue + if not backup.is_file(): + raise ValueError(f"Patch backup is not a file: {backup}") + expected = str(item.get("sha256") or "") + if expected and file_sha256(backup).lower() != expected.lower(): + raise RuntimeError(f"Refusing to restore a modified backup: {backup}") + + def _legacy_owned_paths(self, target_dir: Path, metadata: dict) -> list[Path]: + paths: list[Path] = [] + for entry in metadata.get("managed_files", []): + if isinstance(entry, dict) and entry.get("path"): + try: + paths.append(self._safe_path(target_dir, entry["path"])) + except ValueError: + continue + + variant = self.bundle.normalize_variant(metadata.get("fsr4_variant")) + proxy_sources = [ + self.bundle.fgmod_path / "OptiScaler.dll", + self.bundle.fgmod_path / "renames", + self.bundle.variant_dir(variant) / "renames", + ] + for filename in INJECTOR_FILENAMES: + target = target_dir / filename + if not target.is_file(): + continue + matches_bundle = False + for source in proxy_sources: + candidate = source if source.is_file() and source.name == filename else source / filename + if candidate.is_file() and files_match(target, candidate): + matches_bundle = True + break + if matches_bundle: + paths.append(target) + for filename in SUPPORT_FILES + VARIANT_EXTRA_FILENAMES + [FSR4_UPSCALER_FILENAME]: + target = target_dir / filename + if target.is_file() and self.bundle.is_managed_support_file(target): + paths.append(target) + for relative in (Path("plugins/OptiPatcher.asi"),): + target = target_dir / relative + source = self.bundle.fgmod_path / relative + if target.is_file() and source.is_file() and files_match(target, source): + paths.append(target) + d3d12_source = self.bundle.fgmod_path / "D3D12_Optiscaler" + d3d12_target = target_dir / "D3D12_Optiscaler" + if d3d12_source.is_dir() and d3d12_target.is_dir(): + for source in d3d12_source.rglob("*"): + if source.is_file(): + candidate = d3d12_target / source.relative_to(d3d12_source) + if candidate.is_file() and files_match(candidate, source): + paths.append(candidate) + unique: list[Path] = [] + seen: set[str] = set() + for path in paths: + if str(path) not in seen: + unique.append(path) + seen.add(str(path)) + return unique + + def _unpatch_target(self, target_dir: Path, marker_path: Path | None) -> dict: + metadata = self._read_marker(marker_path) if marker_path else { + "backed_up_files": [ + name for name in RESTORABLE_BACKUP_FILES + if (target_dir / f"{name}.b").is_file() + ], + } + try: + if marker_path and marker_path.exists() and not metadata: + return {"status": "error", "message": f"Patch marker is unreadable: {marker_path}"} + schema_version = int(metadata.get("schema_version") or 1) if metadata else 1 + if schema_version >= MANIFEST_SCHEMA_VERSION: + entries, errors = self._managed_entries(target_dir, metadata) + if errors: + return {"status": "error", "message": " ".join(errors)} + self._validate_backups(target_dir, metadata) + managed_paths = [path for _, path in entries] + for _, path in entries: + if path.exists(): + path.unlink() + restored = self._restore_backups(target_dir, metadata) + self._cleanup_empty_directories(target_dir, [str(path.relative_to(target_dir)) for path in managed_paths]) + else: + owned = self._legacy_owned_paths(target_dir, metadata) + entries, errors = self._managed_entries(target_dir, metadata) + if errors: + return {"status": "error", "message": " ".join(errors)} + owned.extend(path for _, path in entries if path not in owned) + for path in owned: + if path.is_file(): + path.unlink() + restored = self._restore_backups(target_dir, metadata) + self._cleanup_empty_directories(target_dir, [str(path.relative_to(target_dir)) for path in owned]) + if marker_path and marker_path.exists(): + marker_path.unlink() + return { + "status": "success", + "message": f"OptiScaler files removed from {target_dir}", + "restored_files": restored, + } + except (OSError, RuntimeError, ValueError) as exc: + self._log(f"Unpatch failed for {target_dir}: {exc}") + return {"status": "error", "message": f"Unpatch failed: {exc}"} + + def resolve_target_directory(self, directory: str) -> Path: + target = Path(directory).expanduser() + if not target.exists(): + raise FileNotFoundError(f"Target directory does not exist: {directory}") + if not target.is_dir(): + raise NotADirectoryError(f"Target path is not a directory: {directory}") + if not os.access(target, os.W_OK | os.X_OK): + raise PermissionError(f"Insufficient permissions for {directory}") + return target.resolve() + + def manual_patch_directory( + self, + directory: str, + dll_name: str, + fsr4_variant: str, + framegen_backend: str | None = None, + ) -> dict: + try: + target = self.resolve_target_directory(directory) + except (FileNotFoundError, NotADirectoryError, PermissionError) as exc: + return {"status": "error", "message": str(exc)} + marker = target / MARKER_FILENAME + previous_metadata = self._read_marker(marker) if marker.exists() else {} + previous_variant = previous_metadata.get("fsr4_variant") + if marker.exists(): + if str(previous_metadata.get("appid")) not in ("", "manual", "None"): + return {"status": "error", "message": "This directory is managed by an AppID patch; unpatch it there first."} + cleanup = self._unpatch_target(target, marker) + if cleanup["status"] != "success": + return cleanup + return self._patch_directory( + target, + dll_name, + self.bundle.selected_variant(fsr4_variant), + self.bundle.selected_backend(framegen_backend), + appid="manual", + game_name="Manual patch", + original_launch_options="", + previous_variant=previous_variant, + ) + + def manual_unpatch_directory(self, directory: str) -> dict: + try: + target = self.resolve_target_directory(directory) + except (FileNotFoundError, NotADirectoryError, PermissionError) as exc: + return {"status": "error", "message": str(exc)} + marker = target / MARKER_FILENAME + return self._unpatch_target(target, marker if marker.exists() else None) + + def list_installed_games(self) -> dict: + try: + return { + "status": "success", + "games": [ + { + "appid": str(game["appid"]), + "name": game["name"], + "install_found": Path(game["install_path"]).exists(), + } + for game in self.steam.find_installed_games() + ], + } + except Exception as exc: + self._log(str(exc)) + return {"status": "error", "message": str(exc)} + + def get_game_status(self, appid: str) -> dict: + try: + game_info = self.steam.game_record(str(appid)) + if not game_info: + return { + "status": "success", "appid": str(appid), "install_found": False, "patched": False, + "dll_name": None, "target_dir": None, "fsr4_variant": None, "fsr4_variant_label": None, + "framegen_backend": None, "message": "Game not found in Steam library.", + } + install_root = Path(game_info["install_path"]) + base = { + "status": "success", "appid": str(appid), "name": game_info["name"], + "install_found": install_root.exists(), "patched": False, "dll_name": None, + "target_dir": None, "fsr4_variant": None, "fsr4_variant_label": None, + "framegen_backend": None, + } + if not install_root.exists(): + return {**base, "message": "Game install directory not found."} + marker = self._find_marker(install_root, str(appid)) + if not marker: + return {**base, "message": "Not patched."} + metadata = self._read_marker(marker) + target_dir = self._marker_target(marker, metadata) + dll_name = str(metadata.get("dll_name") or "dxgi.dll") + upscaler = target_dir / FSR4_UPSCALER_FILENAME + upscaler_sha256 = file_sha256(upscaler) if upscaler.is_file() else None + variant = self.bundle.detect_variant(target_dir, upscaler_sha256) or self.bundle.normalize_variant(metadata.get("fsr4_variant")) + backend = self.bundle.normalize_backend(metadata.get("framegen_backend") or self.bundle.selected_backend()) + dll_present = (target_dir / dll_name).is_file() + return { + **base, + "patched": dll_present, + "dll_name": dll_name, + "target_dir": str(target_dir), + "patched_at": metadata.get("patched_at"), + "optiscaler_version": metadata.get("optiscaler_version"), + "fsr4_variant": variant, + "fsr4_variant_label": self.bundle.variant_info(variant)["label"], + "fsr4_upscaler_sha256": upscaler_sha256, + "framegen_backend": backend, + "framegen_backend_label": FRAMEGEN_BACKENDS[backend], + "message": f"Patched using {dll_name} with {self.bundle.variant_info(variant)['label']}." if dll_present else f"Marker found but {dll_name} is missing. Reinstall recommended.", + } + except Exception as exc: + self._log(f"get_game_status failed for {appid}: {exc}") + return {"status": "error", "message": str(exc)} + + @staticmethod + def build_managed_launch_options(dll_name: str) -> str: + if dll_name == "OptiScaler.asi": + return "SteamDeck=0 %command%" + base = dll_name.replace(".dll", "") + return f"WINEDLLOVERRIDES={base}=n,b SteamDeck=0 %command%" + + @staticmethod + def is_managed_launch_options(options: str) -> bool: + normalized = " ".join((options or "").replace('"', "").split()) + if not normalized: + return False + return any( + dll_name != "OptiScaler.asi" and f"WINEDLLOVERRIDES={dll_name.replace('.dll', '')}=n,b" in normalized + for dll_name in VALID_DLL_NAMES + ) or "fgmod/fgmod" in normalized + + def patch_game( + self, + appid: str, + dll_name: str, + current_launch_options: str, + fsr4_variant: str, + framegen_backend: str | None = None, + ) -> dict: + try: + if dll_name not in VALID_DLL_NAMES: + return {"status": "error", "message": f"Invalid proxy DLL name: {dll_name}"} + game_info = self.steam.game_record(str(appid)) + if not game_info: + return {"status": "error", "message": "Game not found in Steam library."} + install_root = Path(game_info["install_path"]) + if not install_root.exists(): + return {"status": "error", "message": "Game install directory does not exist."} + if self.steam.is_game_running(game_info): + return {"status": "error", "message": "Close the game before patching."} + if not self.bundle.fgmod_path.exists(): + return {"status": "error", "message": "OptiScaler bundle not installed. Run Install first."} + + existing_marker = self._find_marker(install_root) + existing_metadata = self._read_marker(existing_marker) if existing_marker else {} + if existing_marker and str(existing_metadata.get("appid")) not in (str(appid), "manual", "None"): + return {"status": "error", "message": "Another Framegen patch marker was found in this game install."} + original_options = current_launch_options or "" + stored_options = str(existing_metadata.get("original_launch_options") or "") + if stored_options and not self.is_managed_launch_options(stored_options): + original_options = stored_options + if self.is_managed_launch_options(original_options): + original_options = "" + + if existing_marker: + old_target = self._marker_target(existing_marker, existing_metadata) + cleanup = self._unpatch_target(old_target, existing_marker) + if cleanup["status"] != "success": + return cleanup + target_dir, _ = self.steam.guess_patch_target(game_info) + result = self._patch_directory( + target_dir, + dll_name, + self.bundle.selected_variant(fsr4_variant), + self.bundle.selected_backend(framegen_backend), + appid=str(appid), + game_name=game_info["name"], + original_launch_options=original_options, + previous_variant=existing_metadata.get("fsr4_variant"), + ) + if result["status"] != "success": + return result + backend = result.get("framegen_backend") or self.bundle.selected_backend(framegen_backend) + return { + **result, + "appid": str(appid), + "name": game_info["name"], + "dll_name": dll_name, + "target_dir": str(target_dir), + "launch_options": self.build_managed_launch_options(dll_name), + "original_launch_options": original_options, + "framegen_backend": backend, + "message": f"Patched {game_info['name']} using {dll_name} with {result.get('fsr4_variant_label') or self.bundle.variant_info(fsr4_variant)['label']}.", + } + except Exception as exc: + self._log(f"patch_game failed for {appid}: {exc}") + return {"status": "error", "message": str(exc)} + + def unpatch_game(self, appid: str) -> dict: + try: + game_info = self.steam.game_record(str(appid)) + if not game_info: + return {"status": "error", "message": "Game not found in Steam library."} + install_root = Path(game_info["install_path"]) + if not install_root.exists(): + return {"status": "success", "appid": str(appid), "name": game_info["name"], "launch_options": "", "message": "Game install directory does not exist."} + if self.steam.is_game_running(game_info): + return {"status": "error", "message": "Close the game before unpatching."} + marker = self._find_marker(install_root, str(appid)) + if not marker: + return {"status": "success", "appid": str(appid), "name": game_info["name"], "launch_options": "", "message": "No Framegen patch found for this game."} + metadata = self._read_marker(marker) + target_dir = self._marker_target(marker, metadata) + original_options = str(metadata.get("original_launch_options") or "") + cleanup = self._unpatch_target(target_dir, marker) + if cleanup["status"] != "success": + return {**cleanup, "appid": str(appid), "name": game_info["name"]} + self._log(f"unpatch_game success: appid={appid} target={target_dir}") + return { + "status": "success", + "appid": str(appid), + "name": game_info["name"], + "launch_options": original_options, + "message": f"Unpatched {game_info['name']}.", + } + except Exception as exc: + self._log(f"unpatch_game failed for {appid}: {exc}") + return {"status": "error", "message": str(exc)} |
