diff options
| author | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-08-01 14:47:18 -0400 |
|---|---|---|
| committer | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-08-01 14:47:18 -0400 |
| commit | 02521a797e195b331af1778cd7bc854d3a396ead (patch) | |
| tree | 63c48b246d9db2f6ab0140259d7fb8b15f2e085b | |
| parent | 96eb17b0a9f2cfd2b00ad082bef893f4efc229f7 (diff) | |
| download | Decky-Framegen-02521a797e195b331af1778cd7bc854d3a396ead.tar.gz Decky-Framegen-02521a797e195b331af1778cd7bc854d3a396ead.zip | |
refactor patch management and add ownership safeguards
| -rw-r--r-- | backend/__init__.py | 1 | ||||
| -rw-r--r-- | backend/assets.py | 184 | ||||
| -rw-r--r-- | backend/bundle.py | 522 | ||||
| -rw-r--r-- | backend/config.py | 175 | ||||
| -rw-r--r-- | backend/files.py | 36 | ||||
| -rw-r--r-- | backend/patcher.py | 685 | ||||
| -rw-r--r-- | backend/steam.py | 187 | ||||
| -rwxr-xr-x | defaults/assets/fgmod.sh | 6 | ||||
| -rw-r--r-- | main.py | 1908 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | src/api/index.ts | 25 | ||||
| -rw-r--r-- | src/components/CustomPathOverride.tsx | 11 | ||||
| -rw-r--r-- | src/components/OptiScalerControls.tsx | 82 | ||||
| -rw-r--r-- | src/components/SteamGamePatcher.tsx | 78 | ||||
| -rw-r--r-- | src/index.tsx | 8 | ||||
| -rw-r--r-- | src/utils/constants.ts | 16 | ||||
| -rw-r--r-- | tests/test_backend.py | 104 |
17 files changed, 2113 insertions, 1918 deletions
diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..462b5df --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +"""Backend services for Decky Framegen.""" diff --git a/backend/assets.py b/backend/assets.py new file mode 100644 index 0000000..69f71ba --- /dev/null +++ b/backend/assets.py @@ -0,0 +1,184 @@ +"""Static asset metadata and patch file inventories.""" + +OPTISCALER_ARCHIVE_ASSET = { + "name": "Optiscaler_0.9.4-final.20260718._MM.7z", + "sha256": "575cb4df866116093df75af607e37fd70e10f5163e0f23fd5c804142e80ef0ad", + "version": "0.9.4-final.20260718", +} + +FSR4_INT8_ASSET = { + "name": "amd_fidelityfx_upscaler_dx12.dll", + "sha256": "c7720bc16bede334f59a1a32cd22edbcbbb159685ed5240e61350a5fb0bc8a94", + "version": "4.0.2c", +} + +FSR4_OFFICIAL_411_ASSET = { + "name": "amdxcffx64.dll", + "sha256": "a2b136b6affd35a49b141a936be935f7d5ddc8d8f9b8c9afbe62ff9ddb2538a0", + "version": "4.1.1-official", +} + +FSR4_VALVE_411_ASSET = { + "name": "amdxcffx64_valve_2.3.0.2913.dll", + "sha256": "4e7dc37aebea3a90e3d3cc43e24cb2b54176b2535315f20dbe63b3b7cfc56b1e", + "version": "4.1.1-valve-2.3.0.2913", +} + +AMDXC64_RDNA2_ASSET = { + "name": "amdxc64.dll", + "sha256": "a0a0af61d475e30a70966b3459f3793df772faf8f26ae3261d10554ff592cbd5", + "version": "8.18.10.0474", +} + +OPTISCALER_PRE10_ASSET = { + "name": "OptiScaler_0.10.0-pre1.20260622_135940.dll", + "sha256": "b374b19081cc066365d0c6da4808d768e16469b0cbdfc478b6e95999947d5364", + "version": "0.10.0-pre1.20260622_135940", +} + +OPTIPATCHER_ASSET = { + "name": "OptiPatcher_rolling.asi", + "sha256": "88b9e1be3559737cd205fdf5f2c8550cf1923fb1def4c603e5bf03c3e84131b1", + "version": "rolling", +} + +FSR4_UPSCALER_FILENAME = "amd_fidelityfx_upscaler_dx12.dll" +FSR4_DRIVER_OVERRIDE_FILENAME = "amdxcffx64.dll" +INSTALL_MANIFEST_FILENAME = "install-manifest.json" +VERSION_FILENAME = "version.txt" +DEFAULT_FSR4_VARIANT = "rdna23-int8" +DEFAULT_FRAMEGEN_BACKEND = "auto" +FRAMEGEN_BACKENDS = { + "auto": "OptiScaler automatic selection", + "nukems": "Nukem's DLSSG → FSR3", +} + +FSR4_VARIANTS = { + "rdna23-int8": { + "label": "4.0.2c / RDNA2-3 compatibility", + "dir_name": "fsr4-rdna2-3", + "sha256": "c7720bc16bede334f59a1a32cd22edbcbbb159685ed5240e61350a5fb0bc8a94", + "source_asset_name": FSR4_INT8_ASSET["name"], + "source_version": FSR4_INT8_ASSET["version"], + "uses_archive_native": False, + "extra_files": [], + }, + "rdna4-native": { + "label": "4.1.1 SDK / RDNA3 dGPU + RDNA4", + "dir_name": "fsr4-rdna4", + "sha256": "d0dcccc74a43c44ba435b7a369b456e0970d8a4464e4bd683119b374f2c9fb46", + "source_asset_name": OPTISCALER_ARCHIVE_ASSET["name"], + "source_version": OPTISCALER_ARCHIVE_ASSET["version"], + "uses_archive_native": True, + "extra_files": [], + }, + "rdna34-official-411": { + "label": "4.1.1 driver override / RDNA3-4", + "dir_name": "fsr4-rdna3-4-official-411", + "sha256": "d0dcccc74a43c44ba435b7a369b456e0970d8a4464e4bd683119b374f2c9fb46", + "source_asset_name": OPTISCALER_ARCHIVE_ASSET["name"], + "source_version": OPTISCALER_ARCHIVE_ASSET["version"], + "uses_archive_native": True, + "extra_files": [ + { + "name": FSR4_DRIVER_OVERRIDE_FILENAME, + "sha256": FSR4_OFFICIAL_411_ASSET["sha256"], + "source_asset_name": FSR4_OFFICIAL_411_ASSET["name"], + "source_version": FSR4_OFFICIAL_411_ASSET["version"], + } + ], + "config_overrides": {}, + }, + "rdna2-valve-411-pre10": { + "label": "4.1.1 Valve RDNA2 compatibility", + "dir_name": "fsr4-rdna2-valve-411-pre10", + "sha256": "d0dcccc74a43c44ba435b7a369b456e0970d8a4464e4bd683119b374f2c9fb46", + "source_asset_name": OPTISCALER_ARCHIVE_ASSET["name"], + "source_version": OPTISCALER_ARCHIVE_ASSET["version"], + "uses_archive_native": True, + "injector": { + "name": "OptiScaler.dll", + "sha256": OPTISCALER_PRE10_ASSET["sha256"], + "source_asset_name": OPTISCALER_PRE10_ASSET["name"], + "source_version": OPTISCALER_PRE10_ASSET["version"], + }, + "extra_files": [ + { + "name": FSR4_DRIVER_OVERRIDE_FILENAME, + "sha256": FSR4_VALVE_411_ASSET["sha256"], + "source_asset_name": FSR4_VALVE_411_ASSET["name"], + "source_version": FSR4_VALVE_411_ASSET["version"], + }, + { + "name": "amdxc64.dll", + "sha256": AMDXC64_RDNA2_ASSET["sha256"], + "source_asset_name": AMDXC64_RDNA2_ASSET["name"], + "source_version": AMDXC64_RDNA2_ASSET["version"], + }, + ], + "config_overrides": { + "FSR.Fsr4ForceModel": "2", + "Plugins.LoadCustomAmdxc64OnRdna2": "true", + }, + }, +} + +VARIANT_EXTRA_FILENAMES = sorted( + { + extra_file["name"] + for variant in FSR4_VARIANTS.values() + for extra_file in variant.get("extra_files", []) + } +) + +PROXY_DLL_BACKUPS = [ + "dxgi.dll", + "winmm.dll", + "dbghelp.dll", + "version.dll", + "wininet.dll", + "winhttp.dll", + "OptiScaler.asi", +] +VALID_DLL_NAMES = set(PROXY_DLL_BACKUPS) + +INJECTOR_FILENAMES = [ + *PROXY_DLL_BACKUPS, + "nvngx.dll", + "_nvngx.dll", + "nvngx-wrapper.dll", + "dlss-enabler.dll", + "OptiScaler.dll", +] + +ORIGINAL_DLL_BACKUPS = [ + "d3dcompiler_47.dll", + "amd_fidelityfx_dx12.dll", + "amd_fidelityfx_framegeneration_dx12.dll", + FSR4_UPSCALER_FILENAME, + FSR4_DRIVER_OVERRIDE_FILENAME, + "amdxc64.dll", + "amd_fidelityfx_vk.dll", +] + +RESTORABLE_BACKUP_FILES = [ + *PROXY_DLL_BACKUPS, + *ORIGINAL_DLL_BACKUPS, +] + +SUPPORT_FILES = [ + "libxess.dll", + "libxess_dx11.dll", + "libxess_fg.dll", + "libxell.dll", + "amd_fidelityfx_dx12.dll", + "amd_fidelityfx_framegeneration_dx12.dll", + "amd_fidelityfx_vk.dll", + "dlssg_to_fsr3_amd_is_better.dll", + "fakenvapi.dll", + "fakenvapi.ini", +] + +MARKER_FILENAME = "FRAMEGEN_PATCH" +MANIFEST_SCHEMA_VERSION = 2 +BACKUP_DIRECTORY_NAME = ".framegen-backups" diff --git a/backend/bundle.py b/backend/bundle.py new file mode 100644 index 0000000..a6ef9e4 --- /dev/null +++ b/backend/bundle.py @@ -0,0 +1,522 @@ +"""Management of the shared OptiScaler bundle in ``~/fgmod``.""" + +import os +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable + +from . import config +from .assets import ( + DEFAULT_FRAMEGEN_BACKEND, + DEFAULT_FSR4_VARIANT, + FRAMEGEN_BACKENDS, + FSR4_DRIVER_OVERRIDE_FILENAME, + FSR4_VARIANTS, + FSR4_UPSCALER_FILENAME, + INSTALL_MANIFEST_FILENAME, + MANIFEST_SCHEMA_VERSION, + OPTIPATCHER_ASSET, + OPTISCALER_ARCHIVE_ASSET, + OPTISCALER_PRE10_ASSET, + AMDXC64_RDNA2_ASSET, + FSR4_INT8_ASSET, + FSR4_OFFICIAL_411_ASSET, + FSR4_VALVE_411_ASSET, + VERSION_FILENAME, +) +from .files import file_sha256, read_json, write_json + + +Logger = Callable[[str], None] + + +class BundleManager: + def __init__(self, home: Path, plugin_dir: Path, logger: Logger): + self.home = home + self.plugin_dir = plugin_dir + self.logger = logger + + @property + def fgmod_path(self) -> Path: + return self.home / "fgmod" + + def _log(self, message: str) -> None: + self.logger(f"[Framegen] {message}") + + def _create_renamed_copies(self, source_file: Path, renames_dir: Path) -> bool: + try: + if not source_file.exists(): + self._log(f"Source file {source_file} does not exist") + return False + renames_dir.mkdir(parents=True, exist_ok=True) + for filename in ("dxgi.dll", "winmm.dll", "dbghelp.dll", "version.dll", "wininet.dll", "winhttp.dll", "OptiScaler.asi"): + shutil.copy2(source_file, renames_dir / filename) + return True + except OSError as exc: + self._log(f"Failed to create renamed copies: {exc}") + return False + + def _copy_launcher_scripts(self, assets_dir: Path, extract_path: Path) -> bool: + try: + scripts = { + "fgmod.sh": "fgmod", + "fgmod-uninstaller.sh": "fgmod-uninstaller.sh", + "update-optiscaler-config.py": "update-optiscaler-config.py", + } + for source_name, destination_name in scripts.items(): + source = assets_dir / source_name + if not source.exists(): + continue + destination = extract_path / destination_name + shutil.copy2(source, destination) + destination.chmod(0o755) + return True + except OSError as exc: + self._log(f"Failed to copy launcher scripts: {exc}") + return False + + def _extract_archive(self, archive_path: Path, output_dir: Path) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + environment = os.environ.copy() + environment["LD_LIBRARY_PATH"] = "" + result = subprocess.run( + ["7z", "x", "-y", "-o" + str(output_dir), str(archive_path)], + capture_output=True, + text=True, + check=False, + env=environment, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr or result.stdout or f"Failed to extract {archive_path.name}") + + def _verify(self, path: Path, expected_sha256: str, description: str) -> str: + actual = file_sha256(path) + if actual.lower() != expected_sha256.lower(): + raise RuntimeError(f"{description} hash mismatch: expected {expected_sha256}, got {actual}") + return actual + + def _manifest_path(self) -> Path: + return self.fgmod_path / INSTALL_MANIFEST_FILENAME + + def load_manifest(self) -> dict: + return read_json(self._manifest_path()) + + @staticmethod + def normalize_variant(value: str | None) -> str: + return value if value in FSR4_VARIANTS else DEFAULT_FSR4_VARIANT + + @staticmethod + def normalize_backend(value: str | None) -> str: + return value if value in FRAMEGEN_BACKENDS else DEFAULT_FRAMEGEN_BACKEND + + def selected_variant(self, requested: str | None = None) -> str: + if requested in FSR4_VARIANTS: + return requested + installed = self.load_manifest().get("selected_default_variant") + return self.normalize_variant(installed) + + def selected_backend(self, requested: str | None = None) -> str: + if requested in FRAMEGEN_BACKENDS: + return requested + manifest_backend = self.load_manifest().get("framegen_backend") + if manifest_backend in FRAMEGEN_BACKENDS: + return manifest_backend + return self.normalize_backend(config.detect_backend(self.fgmod_path / "OptiScaler.ini")) + + def variant_info(self, variant: str | None) -> dict: + return FSR4_VARIANTS[self.normalize_variant(variant)] + + def variant_dir(self, variant: str | None) -> Path: + return self.fgmod_path / self.variant_info(variant)["dir_name"] + + def variant_path(self, variant: str | None) -> Path: + return self.variant_dir(variant) / FSR4_UPSCALER_FILENAME + + def variant_extra_files(self, variant: str | None) -> list[dict]: + return list(self.variant_info(variant).get("extra_files") or []) + + def variant_extra_path(self, variant: str | None, filename: str) -> Path: + return self.variant_dir(variant) / filename + + def variant_injector_info(self, variant: str | None) -> dict | None: + injector = self.variant_info(variant).get("injector") + return injector if isinstance(injector, dict) else None + + def variant_injector_path(self, variant: str | None) -> Path | None: + injector = self.variant_injector_info(variant) + return self.variant_dir(variant) / injector["name"] if injector else None + + def variant_proxy_path(self, variant: str | None, dll_name: str) -> Path | None: + if not self.variant_injector_info(variant): + return None + return self.variant_dir(variant) / "renames" / dll_name + + def variant_config_overrides(self, variant: str | None) -> dict: + overrides = self.variant_info(variant).get("config_overrides") or {} + return dict(overrides) if isinstance(overrides, dict) else {} + + def fgmod_version(self) -> str | None: + manifest = self.load_manifest() + optiscaler = manifest.get("optiscaler") + if isinstance(optiscaler, dict) and optiscaler.get("version"): + return str(optiscaler["version"]) + try: + version = (self.fgmod_path / VERSION_FILENAME).read_text(encoding="utf-8").strip() + return version or None + except OSError: + return None + + def managed_support_candidates(self, filename: str) -> list[Path]: + candidates = [self.fgmod_path / filename] + if filename == FSR4_UPSCALER_FILENAME: + candidates = [self.fgmod_path / filename] + [self.variant_path(variant) for variant in FSR4_VARIANTS] + else: + for variant in FSR4_VARIANTS: + if any(extra.get("name") == filename for extra in self.variant_extra_files(variant)): + candidates.append(self.variant_extra_path(variant, filename)) + unique: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + if str(candidate) not in seen: + unique.append(candidate) + seen.add(str(candidate)) + return unique + + def is_managed_support_file(self, path: Path) -> bool: + return path.exists() and any( + candidate.is_file() and file_sha256(path) == file_sha256(candidate) + for candidate in self.managed_support_candidates(path.name) + ) + + def _sync_variant_root_extra_files(self, variant: str) -> None: + selected = {extra["name"]: extra for extra in self.variant_extra_files(variant)} + all_extra_names = { + extra["name"] + for item in FSR4_VARIANTS.values() + for extra in item.get("extra_files", []) + } + for filename in all_extra_names: + root_path = self.fgmod_path / filename + if filename not in selected: + if root_path.exists(): + root_path.unlink() + continue + source = self.variant_extra_path(variant, filename) + if not source.exists(): + raise FileNotFoundError(f"Prepared FSR4 variant extra file missing: {source}") + shutil.copy2(source, root_path) + + def activate_variant(self, variant: str | None) -> str: + variant_id = self.normalize_variant(variant) + source = self.variant_path(variant_id) + if not source.exists(): + raise FileNotFoundError(f"Prepared FSR4 variant missing: {source}") + shutil.copy2(source, self.fgmod_path / FSR4_UPSCALER_FILENAME) + self._sync_variant_root_extra_files(variant_id) + return variant_id + + def detect_variant(self, directory: Path, upscaler_sha256: str | None) -> str | None: + if not upscaler_sha256: + return None + normalized = upscaler_sha256.lower() + for variant_id, variant in FSR4_VARIANTS.items(): + if str(variant.get("sha256", "")).lower() != normalized: + continue + if all( + (directory / extra["name"]).exists() + and file_sha256(directory / extra["name"]).lower() == extra["sha256"].lower() + for extra in variant.get("extra_files", []) + ): + return variant_id + return None + + def extract_static_optiscaler( + self, + selected_default_variant: str = DEFAULT_FSR4_VARIANT, + framegen_backend: str = DEFAULT_FRAMEGEN_BACKEND, + ) -> dict: + """Prepare the shared bundle; replacement remains intentionally non-atomic for compatibility.""" + try: + selected_default_variant = self.normalize_variant(selected_default_variant) + framegen_backend = self.normalize_backend(framegen_backend) + bin_path = self.plugin_dir / "bin" + assets_dir = self.plugin_dir / "assets" + if not bin_path.exists(): + return {"status": "error", "message": f"Bin directory not found: {bin_path}"} + + asset_sources = { + "archive": (bin_path / OPTISCALER_ARCHIVE_ASSET["name"], OPTISCALER_ARCHIVE_ASSET), + "int8": (bin_path / FSR4_INT8_ASSET["name"], FSR4_INT8_ASSET), + "official": (bin_path / FSR4_OFFICIAL_411_ASSET["name"], FSR4_OFFICIAL_411_ASSET), + "valve": (bin_path / FSR4_VALVE_411_ASSET["name"], FSR4_VALVE_411_ASSET), + "amdxc64": (bin_path / AMDXC64_RDNA2_ASSET["name"], AMDXC64_RDNA2_ASSET), + "pre10": (bin_path / OPTISCALER_PRE10_ASSET["name"], OPTISCALER_PRE10_ASSET), + "optipatcher": (bin_path / OPTIPATCHER_ASSET["name"], OPTIPATCHER_ASSET), + } + for path, asset in asset_sources.values(): + if not path.exists(): + return {"status": "error", "message": f"Required bundled asset missing: {asset['name']}"} + self._verify(path, asset["sha256"], asset["name"]) + + if self.fgmod_path.exists(): + shutil.rmtree(self.fgmod_path) + self.fgmod_path.mkdir(parents=True, exist_ok=True) + self._extract_archive(asset_sources["archive"][0], self.fgmod_path) + + source = self.fgmod_path / "OptiScaler.dll" + if not self._create_renamed_copies(source, self.fgmod_path / "renames"): + return {"status": "error", "message": "Failed to prepare renamed OptiScaler proxies."} + if not self._copy_launcher_scripts(assets_dir, self.fgmod_path): + return {"status": "error", "message": "Failed to copy launcher scripts."} + + plugins_dir = self.fgmod_path / "plugins" + plugins_dir.mkdir(parents=True, exist_ok=True) + optipatcher_dst = plugins_dir / "OptiPatcher.asi" + shutil.copy2(asset_sources["optipatcher"][0], optipatcher_dst) + optipatcher_sha256 = self._verify(optipatcher_dst, OPTIPATCHER_ASSET["sha256"], "Prepared OptiPatcher plugin") + + ini_file = self.fgmod_path / "OptiScaler.ini" + config.configure(ini_file, framegen_backend, logger=self.logger) + + native_root = self.fgmod_path / FSR4_UPSCALER_FILENAME + native_sha256 = self._verify(native_root, FSR4_VARIANTS["rdna4-native"]["sha256"], "Archive-native FSR4 upscaler") + for variant_id in ("rdna4-native", "rdna34-official-411", "rdna2-valve-411-pre10"): + variant_dir = self.variant_dir(variant_id) + variant_dir.mkdir(parents=True, exist_ok=True) + prepared = variant_dir / FSR4_UPSCALER_FILENAME + shutil.copy2(native_root, prepared) + self._verify(prepared, FSR4_VARIANTS[variant_id]["sha256"], f"Prepared {variant_id} FSR4 upscaler") + + official_dir = self.variant_dir("rdna34-official-411") + shutil.copy2(asset_sources["official"][0], official_dir / FSR4_DRIVER_OVERRIDE_FILENAME) + self._verify(official_dir / FSR4_DRIVER_OVERRIDE_FILENAME, FSR4_OFFICIAL_411_ASSET["sha256"], "Prepared official driver override") + + valve_dir = self.variant_dir("rdna2-valve-411-pre10") + shutil.copy2(asset_sources["valve"][0], valve_dir / FSR4_DRIVER_OVERRIDE_FILENAME) + shutil.copy2(asset_sources["amdxc64"][0], valve_dir / "amdxc64.dll") + shutil.copy2(asset_sources["pre10"][0], valve_dir / "OptiScaler.dll") + self._verify(valve_dir / FSR4_DRIVER_OVERRIDE_FILENAME, FSR4_VALVE_411_ASSET["sha256"], "Prepared Valve driver override") + self._verify(valve_dir / "amdxc64.dll", AMDXC64_RDNA2_ASSET["sha256"], "Prepared RDNA2 driver override") + self._verify(valve_dir / "OptiScaler.dll", OPTISCALER_PRE10_ASSET["sha256"], "Prepared pre10 OptiScaler injector") + if not self._create_renamed_copies(valve_dir / "OptiScaler.dll", valve_dir / "renames"): + return {"status": "error", "message": "Failed to prepare renamed pre10 OptiScaler proxies."} + + int8_dir = self.variant_dir("rdna23-int8") + int8_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(asset_sources["int8"][0], int8_dir / FSR4_UPSCALER_FILENAME) + self._verify(int8_dir / FSR4_UPSCALER_FILENAME, FSR4_INT8_ASSET["sha256"], "Prepared RDNA2-3 FSR4 upscaler") + + selected_default_variant = self.activate_variant(selected_default_variant) + active_sha256 = file_sha256(self.fgmod_path / FSR4_UPSCALER_FILENAME) + (self.fgmod_path / VERSION_FILENAME).write_text(OPTISCALER_ARCHIVE_ASSET["version"], encoding="utf-8") + manifest = self._build_manifest( + selected_default_variant, + framegen_backend, + native_sha256, + optipatcher_sha256, + active_sha256, + ) + write_json(self._manifest_path(), manifest) + return { + "status": "success", + "message": f"Successfully extracted OptiScaler {OPTISCALER_ARCHIVE_ASSET['version']} to ~/fgmod", + "version": OPTISCALER_ARCHIVE_ASSET["version"], + "selected_default_variant": selected_default_variant, + "selected_default_variant_label": FSR4_VARIANTS[selected_default_variant]["label"], + "framegen_backend": framegen_backend, + "framegen_backend_label": FRAMEGEN_BACKENDS[framegen_backend], + } + except Exception as exc: + self._log(f"Extract failed: {exc}") + return {"status": "error", "message": f"Extract failed: {exc}"} + + def _build_manifest( + self, + selected_variant: str, + framegen_backend: str, + native_sha256: str, + optipatcher_sha256: str, + active_sha256: str, + ) -> dict: + variants = {} + for variant_id, variant in FSR4_VARIANTS.items(): + injector = variant.get("injector") + injector_manifest = None + if isinstance(injector, dict): + injector_manifest = { + key: injector[key] + for key in ("name", "sha256", "source_asset_name", "source_version") + } + injector_manifest["path"] = str(Path(variant["dir_name"]) / injector["name"]) + variants[variant_id] = { + "label": variant["label"], + "dir_name": variant["dir_name"], + "path": str(Path(variant["dir_name"]) / FSR4_UPSCALER_FILENAME), + "sha256": variant["sha256"], + "source_asset_name": variant["source_asset_name"], + "source_version": variant["source_version"], + "uses_archive_native": bool(variant["uses_archive_native"]), + "injector": injector_manifest, + "config_overrides": dict(variant.get("config_overrides") or {}), + "extra_files": [ + { + **{key: extra[key] for key in ("name", "sha256", "source_asset_name", "source_version")}, + "path": str(Path(variant["dir_name"]) / extra["name"]), + } + for extra in variant.get("extra_files", []) + ], + } + return { + "schema_version": MANIFEST_SCHEMA_VERSION, + "installed_at": datetime.now(timezone.utc).isoformat(), + "optiscaler": { + "asset_name": OPTISCALER_ARCHIVE_ASSET["name"], + "version": OPTISCALER_ARCHIVE_ASSET["version"], + "sha256": OPTISCALER_ARCHIVE_ASSET["sha256"], + "native_upscaler_sha256": native_sha256, + }, + "optipatcher": { + "asset_name": OPTIPATCHER_ASSET["name"], + "version": OPTIPATCHER_ASSET["version"], + "sha256": optipatcher_sha256, + "target_path": "plugins/OptiPatcher.asi", + }, + "fsr4_variants": variants, + "selected_default_variant": selected_variant, + "framegen_backend": framegen_backend, + "framegen_backend_label": FRAMEGEN_BACKENDS[framegen_backend], + "active_root_upscaler": { + "path": FSR4_UPSCALER_FILENAME, + "sha256": active_sha256, + "variant": selected_variant, + }, + } + + def run_install(self, selected_variant: str, framegen_backend: str) -> dict: + selected_variant = self.normalize_variant(selected_variant) + framegen_backend = self.normalize_backend(framegen_backend) + result = self.extract_static_optiscaler(selected_variant, framegen_backend) + if result["status"] != "success": + return {"status": "error", "message": f"OptiScaler extraction failed: {result.get('message', 'Unknown error')}"} + return { + "status": "success", + "output": f"Successfully installed OptiScaler {result.get('version', OPTISCALER_ARCHIVE_ASSET['version'])} with {result.get('selected_default_variant_label', FSR4_VARIANTS[selected_variant]['label'])}.", + "version": result.get("version", OPTISCALER_ARCHIVE_ASSET["version"]), + "selected_default_variant": result.get("selected_default_variant", selected_variant), + "selected_default_variant_label": result.get("selected_default_variant_label", FSR4_VARIANTS[selected_variant]["label"]), + "framegen_backend": result.get("framegen_backend", framegen_backend), + "framegen_backend_label": result.get("framegen_backend_label", FRAMEGEN_BACKENDS[framegen_backend]), + } + + def uninstall(self) -> dict: + try: + if not self.fgmod_path.exists(): + return {"status": "success", "output": "No fgmod directory found to remove"} + shutil.rmtree(self.fgmod_path) + return {"status": "success", "output": "Successfully removed fgmod directory"} + except OSError as exc: + self._log(f"Uninstall error: {exc}") + return {"status": "error", "message": f"Uninstall failed: {exc}", "output": str(exc)} + + def set_default_variant(self, selected_variant: str) -> dict: + try: + if not self.fgmod_path.exists(): + return {"status": "error", "message": "OptiScaler bundle not installed. Run Install first."} + manifest = self.load_manifest() + if not manifest: + return {"status": "error", "message": "Install manifest missing. Reinstall OptiScaler."} + selected_variant = self.activate_variant(selected_variant) + active_sha256 = file_sha256(self.fgmod_path / FSR4_UPSCALER_FILENAME) + manifest["selected_default_variant"] = selected_variant + manifest["active_root_upscaler"] = { + "path": FSR4_UPSCALER_FILENAME, + "sha256": active_sha256, + "variant": selected_variant, + } + manifest["updated_at"] = datetime.now(timezone.utc).isoformat() + write_json(self._manifest_path(), manifest) + return { + "status": "success", + "output": f"Default FSR4 runtime switched to {FSR4_VARIANTS[selected_variant]['label']}.", + "version": self.fgmod_version(), + "selected_default_variant": selected_variant, + "selected_default_variant_label": FSR4_VARIANTS[selected_variant]["label"], + } + except Exception as exc: + self._log(f"Failed to switch default FSR4 runtime: {exc}") + return {"status": "error", "message": f"Failed to switch default FSR4 runtime: {exc}"} + + def set_framegen_backend(self, framegen_backend: str) -> dict: + try: + if not self.fgmod_path.exists(): + return {"status": "error", "message": "OptiScaler bundle not installed. Run Install first."} + backend = self.normalize_backend(framegen_backend) + manifest = self.load_manifest() + if not manifest: + return {"status": "error", "message": "Install manifest missing. Reinstall OptiScaler."} + ini_file = self.fgmod_path / "OptiScaler.ini" + if not config.configure(ini_file, backend, logger=self.logger): + return {"status": "error", "message": "Could not update the shared OptiScaler.ini."} + manifest["framegen_backend"] = backend + manifest["framegen_backend_label"] = FRAMEGEN_BACKENDS[backend] + manifest["updated_at"] = datetime.now(timezone.utc).isoformat() + write_json(self._manifest_path(), manifest) + return { + "status": "success", + "framegen_backend": backend, + "framegen_backend_label": FRAMEGEN_BACKENDS[backend], + "output": f"Frame generation backend set to {FRAMEGEN_BACKENDS[backend]}.", + } + except Exception as exc: + self._log(f"Failed to set frame generation backend: {exc}") + return {"status": "error", "message": f"Failed to set frame generation backend: {exc}"} + + def check(self) -> dict: + required_files = [ + "OptiScaler.dll", + "OptiScaler.ini", + "dlssg_to_fsr3_amd_is_better.dll", + "fakenvapi.dll", + "fakenvapi.ini", + "amd_fidelityfx_dx12.dll", + "amd_fidelityfx_framegeneration_dx12.dll", + FSR4_UPSCALER_FILENAME, + "amd_fidelityfx_vk.dll", + "libxess.dll", + "libxess_dx11.dll", + "libxess_fg.dll", + "libxell.dll", + "fgmod", + "fgmod-uninstaller.sh", + "update-optiscaler-config.py", + INSTALL_MANIFEST_FILENAME, + ] + if not self.fgmod_path.exists() or any(not (self.fgmod_path / filename).exists() for filename in required_files): + return {"exists": False} + plugins_dir = self.fgmod_path / "plugins" + if not (plugins_dir / "OptiPatcher.asi").exists(): + return {"exists": False} + for variant_id, variant in FSR4_VARIANTS.items(): + variant_dir = self.variant_dir(variant_id) + if not (variant_dir / FSR4_UPSCALER_FILENAME).exists(): + return {"exists": False} + injector = variant.get("injector") + if isinstance(injector, dict) and not (variant_dir / injector.get("name", "OptiScaler.dll")).exists(): + return {"exists": False} + for extra in variant.get("extra_files", []): + if not (variant_dir / extra["name"]).exists(): + return {"exists": False} + manifest = self.load_manifest() + variant = self.selected_variant() + backend = self.selected_backend() + return { + "exists": True, + "version": self.fgmod_version(), + "selected_fsr4_variant": variant, + "selected_fsr4_variant_label": FSR4_VARIANTS[variant]["label"], + "framegen_backend": backend, + "framegen_backend_label": FRAMEGEN_BACKENDS[backend], + "install_manifest_present": bool(manifest), + } diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..87476cc --- /dev/null +++ b/backend/config.py @@ -0,0 +1,175 @@ +"""OptiScaler configuration migration and default handling.""" + +import re +from pathlib import Path +from typing import Callable + + +Logger = Callable[[str], None] + + +def _split_override_key(raw_key: str) -> tuple[str | None, str]: + key = str(raw_key).strip() + if "." in key: + section, section_key = key.split(".", 1) + section = section.strip() + section_key = section_key.strip() + if section and section_key: + return section, section_key + return None, key + + +def apply_overrides(ini_file: Path, overrides: dict, logger: Logger | None = None) -> bool: + """Upsert INI keys without disturbing unrelated game configuration.""" + if not overrides: + return True + try: + content = ini_file.read_text(encoding="utf-8", errors="replace") + newline = "\r\n" if "\r\n" in content else "\n" + lines = content.splitlines(keepends=True) + section_pattern = re.compile(r"^\s*\[(?P<section>[^\]]+)\]\s*$") + + def ensure_trailing_newline() -> None: + if lines and not lines[-1].endswith(("\n", "\r")): + lines[-1] += newline + + def upsert(section: str | None, key: str, value: str) -> None: + replacement = f"{key}={value}" + key_pattern = re.compile(rf"^(\s*{re.escape(key)}\s*)=.*$") + + if section is None: + for index, line in enumerate(lines): + if key_pattern.match(line): + ending = "\r\n" if line.endswith("\r\n") else ("\n" if line.endswith("\n") else newline) + lines[index] = f"{replacement}{ending}" + return + ensure_trailing_newline() + lines.append(f"{replacement}{newline}") + return + + in_section = False + insert_at = None + for index, line in enumerate(lines): + match = section_pattern.match(line.strip()) + if match: + if in_section: + insert_at = index + break + if match.group("section") == section: + in_section = True + continue + if in_section and key_pattern.match(line): + ending = "\r\n" if line.endswith("\r\n") else ("\n" if line.endswith("\n") else newline) + lines[index] = f"{replacement}{ending}" + return + + if in_section: + if insert_at is None: + ensure_trailing_newline() + insert_at = len(lines) + lines.insert(insert_at, f"{replacement}{newline}") + return + + ensure_trailing_newline() + if lines and lines[-1].strip(): + lines.append(newline) + lines.extend((f"[{section}]{newline}", f"{replacement}{newline}")) + + for raw_key, raw_value in overrides.items(): + section, key = _split_override_key(str(raw_key)) + if key: + upsert(section, key, str(raw_value).strip()) + ini_file.write_text("".join(lines), encoding="utf-8") + return True + except Exception as exc: + if logger: + logger(f"Failed to apply OptiScaler.ini overrides to {ini_file}: {exc}") + return False + + +def migrate(ini_file: Path, logger: Logger | None = None) -> bool: + """Migrate the pre-0.9 ``FGType`` setting to split input/output keys.""" + try: + if not ini_file.exists(): + return False + content = ini_file.read_text(encoding="utf-8", errors="replace") + match = re.search(r"^FGType\s*=\s*(\S+)", content, re.MULTILINE) + if not match: + return True + + value = match.group(1) + if re.search(r"^FGInput\s*=", content, re.MULTILINE): + updated = re.sub(r"^FGType\s*=\s*\S+\n?", "", content, flags=re.MULTILINE) + message = f"Removed stale FGType from {ini_file}" + else: + updated = re.sub( + r"^FGType\s*=\s*\S+", + f"FGInput={value}\nFGOutput={value}", + content, + flags=re.MULTILINE, + ) + message = f"Migrated FGType={value} in {ini_file}" + if updated != content: + ini_file.write_text(updated, encoding="utf-8") + if logger: + logger(message) + return True + except Exception as exc: + if logger: + logger(f"Failed to migrate OptiScaler.ini: {exc}") + return False + + +def disable_hq_font_auto(ini_file: Path, logger: Logger | None = None) -> bool: + """Avoid the HQ font auto mode, which is unreliable under Proton.""" + try: + if not ini_file.exists(): + return False + content = ini_file.read_text(encoding="utf-8", errors="replace") + updated = re.sub(r"UseHQFont\s*=\s*auto", "UseHQFont=false", content) + if updated != content: + ini_file.write_text(updated, encoding="utf-8") + return True + except Exception as exc: + if logger: + logger(f"Failed to update HQ font setting in {ini_file}: {exc}") + return False + + +def configure( + ini_file: Path, + framegen_backend: str, + variant_overrides: dict | None = None, + logger: Logger | None = None, +) -> bool: + """Apply safe defaults and the selected frame-generation backend.""" + if not ini_file.exists(): + return False + backend = framegen_backend if framegen_backend in {"auto", "nukems"} else "auto" + overrides = { + "FGInput": backend, + "FGOutput": backend, + "Fsr4Update": "true", + "LoadAsiPlugins": "true", + } + if variant_overrides: + overrides.update(variant_overrides) + migrated = migrate(ini_file, logger) + hq_updated = disable_hq_font_auto(ini_file, logger) + configured = apply_overrides(ini_file, overrides, logger) + return migrated and hq_updated and configured + + +def detect_backend(ini_file: Path) -> str: + """Return the configured backend when both FG keys agree.""" + try: + content = ini_file.read_text(encoding="utf-8", errors="replace") + except OSError: + return "auto" + values = [ + match.group(1).strip().lower() + for match in re.finditer(r"^FG(?:Input|Output)\s*=\s*(\S+)", content, re.MULTILINE) + ] + if values and all(value == "nukems" for value in values): + return "nukems" + return "auto" diff --git a/backend/files.py b/backend/files.py new file mode 100644 index 0000000..c5ec839 --- /dev/null +++ b/backend/files.py @@ -0,0 +1,36 @@ +"""Small filesystem helpers shared by backend services.""" + +import filecmp +import hashlib +import json +from pathlib import Path + + +def file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def files_match(file_a: Path, file_b: Path) -> bool: + try: + return file_a.is_file() and file_b.is_file() and filecmp.cmp(file_a, file_b, shallow=False) + except OSError: + return False + + +def read_json(path: Path) -> dict: + try: + with path.open("r", encoding="utf-8") as stream: + payload = json.load(stream) + return payload if isinstance(payload, dict) else {} + except (OSError, ValueError, TypeError): + return {} + + +def write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as stream: + json.dump(payload, stream, indent=2) diff --git a/backend/patcher.py b/backend/patcher.py new file mode 100644 index 0000000..59d08f1 --- /dev/null +++ b/backend/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)} diff --git a/backend/steam.py b/backend/steam.py new file mode 100644 index 0000000..c2e90b0 --- /dev/null +++ b/backend/steam.py @@ -0,0 +1,187 @@ +"""Steam library discovery and conservative executable selection.""" + +import re +import subprocess +from pathlib import Path +from typing import Callable + + +Logger = Callable[[str], None] + +BAD_EXE_SUBSTRINGS = [ + "crashreport", + "crashreportclient", + "eac", + "easyanticheat", + "beclient", + "eosbootstrap", + "benchmark", + "uninstall", + "setup", + "launcher", + "updater", + "bootstrap", + "_redist", + "prereq", +] + + +class SteamLibrary: + def __init__(self, home: Path, logger: Logger): + self.home = home + self.logger = logger + + def steam_common_path(self) -> Path: + return self.home / ".local" / "share" / "Steam" / "steamapps" / "common" + + def _steam_root_candidates(self) -> list[Path]: + candidates = [ + self.home / ".local" / "share" / "Steam", + self.home / ".steam" / "steam", + self.home / ".steam" / "root", + self.home / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".local" / "share" / "Steam", + self.home / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".steam" / "steam", + ] + unique: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + key = str(candidate) + if key not in seen: + unique.append(candidate) + seen.add(key) + return unique + + def _steam_library_paths(self) -> list[Path]: + library_paths: list[Path] = [] + seen: set[str] = set() + for steam_root in self._steam_root_candidates(): + if steam_root.exists() and str(steam_root) not in seen: + library_paths.append(steam_root) + seen.add(str(steam_root)) + library_file = steam_root / "steamapps" / "libraryfolders.vdf" + if not library_file.exists(): + continue + try: + for line in library_file.read_text(encoding="utf-8", errors="replace").splitlines(): + if '"path"' not in line: + continue + path = line.split('"path"', 1)[1].strip().strip('"').replace("\\\\", "/") + candidate = Path(path) + if str(candidate) not in seen: + library_paths.append(candidate) + seen.add(str(candidate)) + except OSError as exc: + self.logger(f"[Framegen] failed to parse libraryfolders: {library_file}: {exc}") + return library_paths + + def find_installed_games(self, appid: str | None = None) -> list[dict]: + games: list[dict] = [] + for library_path in self._steam_library_paths(): + steamapps_path = library_path / "steamapps" + if not steamapps_path.exists(): + continue + for appmanifest in steamapps_path.glob("appmanifest_*.acf"): + game_info: dict = { + "appid": "", + "name": "", + "library_path": str(library_path), + "install_path": "", + } + install_dir = "" + try: + for line in appmanifest.read_text(encoding="utf-8", errors="replace").splitlines(): + if '"appid"' in line: + game_info["appid"] = line.split('"appid"', 1)[1].strip().strip('"') + elif '"name"' in line: + game_info["name"] = line.split('"name"', 1)[1].strip().strip('"') + elif '"installdir"' in line: + install_dir = line.split('"installdir"', 1)[1].strip().strip('"') + except OSError as exc: + self.logger(f"[Framegen] skipping manifest {appmanifest}: {exc}") + continue + if not game_info["appid"] or not game_info["name"]: + continue + if "Proton" in game_info["name"] or "Steam Linux Runtime" in game_info["name"]: + continue + game_info["install_path"] = str(steamapps_path / "common" / install_dir) if install_dir else str(Path()) + if appid is None or str(game_info["appid"]) == str(appid): + games.append(game_info) + deduped = {str(game["appid"]): game for game in games} + return sorted(deduped.values(), key=lambda game: game["name"].lower()) + + def game_record(self, appid: str) -> dict | None: + matches = self.find_installed_games(appid) + return matches[0] if matches else None + + @staticmethod + def normalized_path_string(value: str) -> str: + normalized = value.lower().replace("\\", "/") + normalized = normalized.replace("z:/", "/") + return normalized.replace("//", "/") + + def candidate_executables(self, install_root: Path) -> list[Path]: + if not install_root.exists(): + return [] + try: + return [exe for exe in install_root.rglob("*.exe") if exe.is_file()] + except OSError as exc: + self.logger(f"[Framegen] exe scan failed for {install_root}: {exc}") + return [] + + def _exe_score(self, exe: Path, install_root: Path, game_name: str) -> int: + normalized = self.normalized_path_string(str(exe)) + name = exe.name.lower() + score = 0 + if normalized.endswith("-win64-shipping.exe"): + score += 300 + if "shipping.exe" in name: + score += 220 + if "/binaries/win64/" in normalized: + score += 200 + if "/win64/" in normalized: + score += 80 + if exe.parent == install_root: + score += 20 + sanitized_game = re.sub(r"[^a-z0-9]", "", game_name.lower()) + sanitized_name = re.sub(r"[^a-z0-9]", "", exe.stem.lower()) + sanitized_root = re.sub(r"[^a-z0-9]", "", install_root.name.lower()) + if sanitized_game and sanitized_game in sanitized_name: + score += 120 + if sanitized_root and sanitized_root in sanitized_name: + score += 90 + for bad in BAD_EXE_SUBSTRINGS: + if bad in normalized: + score -= 200 + return score - len(exe.parts) + + def best_running_executable(self, candidates: list[Path]) -> Path | None: + if not candidates: + return None + try: + result = subprocess.run(["ps", "-eo", "args="], capture_output=True, text=True, check=False) + process_lines = result.stdout.splitlines() + except OSError as exc: + self.logger(f"[Framegen] running exe scan failed: {exc}") + return None + normalized_candidates = [(exe, self.normalized_path_string(str(exe))) for exe in candidates] + matches: list[tuple[int, Path]] = [] + for line in process_lines: + normalized_line = self.normalized_path_string(line) + for exe, normalized_exe in normalized_candidates: + if normalized_exe in normalized_line: + matches.append((len(normalized_exe), exe)) + return max(matches, key=lambda item: item[0])[1] if matches else None + + def guess_patch_target(self, game_info: dict) -> tuple[Path, Path | None]: + install_root = Path(game_info["install_path"]) + candidates = self.candidate_executables(install_root) + if not candidates: + return install_root, None + running_exe = self.best_running_executable(candidates) + if running_exe: + return running_exe.parent, running_exe + best = max(candidates, key=lambda exe: self._exe_score(exe, install_root, game_info["name"])) + return best.parent, best + + def is_game_running(self, game_info: dict) -> bool: + return self.best_running_executable(self.candidate_executables(Path(game_info["install_path"]))) is not None diff --git a/defaults/assets/fgmod.sh b/defaults/assets/fgmod.sh index e0ee1d2..33207f3 100755 --- a/defaults/assets/fgmod.sh +++ b/defaults/assets/fgmod.sh @@ -305,7 +305,11 @@ fi # === OptiScaler env variables Handling === if [[ -f "$fgmod_path/update-optiscaler-config.py" ]]; then - python "$fgmod_path/update-optiscaler-config.py" "$exe_folder_path/OptiScaler.ini" + if [[ -n "$python_bin" ]]; then + "$python_bin" "$fgmod_path/update-optiscaler-config.py" "$exe_folder_path/OptiScaler.ini" + else + echo " Python is unavailable; skipping OptiScaler config migration" + fi fi # OptiScaler 0.9.0-pre11 can assert on Proton when HQ font auto mode tries to load @@ -1,1663 +1,65 @@ -import decky -import os -import subprocess -import json -import shutil -import re -import filecmp -import hashlib -from datetime import datetime, timezone -from pathlib import Path - -OPTISCALER_ARCHIVE_ASSET = { - "name": "Optiscaler_0.9.4-final.20260718._MM.7z", - "sha256": "575cb4df866116093df75af607e37fd70e10f5163e0f23fd5c804142e80ef0ad", - "version": "0.9.4-final.20260718", -} - -FSR4_INT8_ASSET = { - "name": "amd_fidelityfx_upscaler_dx12.dll", - "sha256": "c7720bc16bede334f59a1a32cd22edbcbbb159685ed5240e61350a5fb0bc8a94", - "version": "4.0.2c", -} - -FSR4_OFFICIAL_411_ASSET = { - "name": "amdxcffx64.dll", - "sha256": "a2b136b6affd35a49b141a936be935f7d5ddc8d8f9b8c9afbe62ff9ddb2538a0", - "version": "4.1.1-official", -} - -FSR4_VALVE_411_ASSET = { - "name": "amdxcffx64_valve_2.3.0.2913.dll", - "sha256": "4e7dc37aebea3a90e3d3cc43e24cb2b54176b2535315f20dbe63b3b7cfc56b1e", - "version": "4.1.1-valve-2.3.0.2913", -} - -AMDXC64_RDNA2_ASSET = { - "name": "amdxc64.dll", - "sha256": "a0a0af61d475e30a70966b3459f3793df772faf8f26ae3261d10554ff592cbd5", - "version": "8.18.10.0474", -} - -OPTISCALER_PRE10_ASSET = { - "name": "OptiScaler_0.10.0-pre1.20260622_135940.dll", - "sha256": "b374b19081cc066365d0c6da4808d768e16469b0cbdfc478b6e95999947d5364", - "version": "0.10.0-pre1.20260622_135940", -} - -OPTIPATCHER_ASSET = { - "name": "OptiPatcher_rolling.asi", - "sha256": "88b9e1be3559737cd205fdf5f2c8550cf1923fb1def4c603e5bf03c3e84131b1", - "version": "rolling", -} - -FSR4_UPSCALER_FILENAME = "amd_fidelityfx_upscaler_dx12.dll" -FSR4_DRIVER_OVERRIDE_FILENAME = "amdxcffx64.dll" -INSTALL_MANIFEST_FILENAME = "install-manifest.json" -VERSION_FILENAME = "version.txt" -DEFAULT_FSR4_VARIANT = "rdna23-int8" - -FSR4_VARIANTS = { - "rdna23-int8": { - "label": "4.0.2c / RDNA2-3 compatibility", - "dir_name": "fsr4-rdna2-3", - "sha256": "c7720bc16bede334f59a1a32cd22edbcbbb159685ed5240e61350a5fb0bc8a94", - "source_asset_name": FSR4_INT8_ASSET["name"], - "source_version": FSR4_INT8_ASSET["version"], - "uses_archive_native": False, - "extra_files": [], - }, - "rdna4-native": { - "label": "4.1.1 SDK / RDNA3 dGPU + RDNA4", - "dir_name": "fsr4-rdna4", - "sha256": "d0dcccc74a43c44ba435b7a369b456e0970d8a4464e4bd683119b374f2c9fb46", - "source_asset_name": OPTISCALER_ARCHIVE_ASSET["name"], - "source_version": OPTISCALER_ARCHIVE_ASSET["version"], - "uses_archive_native": True, - "extra_files": [], - }, - "rdna34-official-411": { - "label": "4.1.1 driver override / RDNA3-4", - "dir_name": "fsr4-rdna3-4-official-411", - "sha256": "d0dcccc74a43c44ba435b7a369b456e0970d8a4464e4bd683119b374f2c9fb46", - "source_asset_name": OPTISCALER_ARCHIVE_ASSET["name"], - "source_version": OPTISCALER_ARCHIVE_ASSET["version"], - "uses_archive_native": True, - "extra_files": [ - { - "name": FSR4_DRIVER_OVERRIDE_FILENAME, - "sha256": FSR4_OFFICIAL_411_ASSET["sha256"], - "source_asset_name": FSR4_OFFICIAL_411_ASSET["name"], - "source_version": FSR4_OFFICIAL_411_ASSET["version"], - } - ], - "config_overrides": {}, - }, - "rdna2-valve-411-pre10": { - "label": "4.1.1 Valve RDNA2 compatibility", - "dir_name": "fsr4-rdna2-valve-411-pre10", - "sha256": "d0dcccc74a43c44ba435b7a369b456e0970d8a4464e4bd683119b374f2c9fb46", - "source_asset_name": OPTISCALER_ARCHIVE_ASSET["name"], - "source_version": OPTISCALER_ARCHIVE_ASSET["version"], - "uses_archive_native": True, - "injector": { - "name": "OptiScaler.dll", - "sha256": OPTISCALER_PRE10_ASSET["sha256"], - "source_asset_name": OPTISCALER_PRE10_ASSET["name"], - "source_version": OPTISCALER_PRE10_ASSET["version"], - }, - "extra_files": [ - { - "name": FSR4_DRIVER_OVERRIDE_FILENAME, - "sha256": FSR4_VALVE_411_ASSET["sha256"], - "source_asset_name": FSR4_VALVE_411_ASSET["name"], - "source_version": FSR4_VALVE_411_ASSET["version"], - }, - { - "name": "amdxc64.dll", - "sha256": AMDXC64_RDNA2_ASSET["sha256"], - "source_asset_name": AMDXC64_RDNA2_ASSET["name"], - "source_version": AMDXC64_RDNA2_ASSET["version"], - } - ], - "config_overrides": { - "FSR.Fsr4ForceModel": "2", - "Plugins.LoadCustomAmdxc64OnRdna2": "true", - }, - }, -} -VARIANT_EXTRA_FILENAMES = sorted( - { - extra_file["name"] - for variant in FSR4_VARIANTS.values() - for extra_file in variant.get("extra_files", []) - } -) +"""Decky entry point for the Framegen plugin. -PROXY_DLL_BACKUPS = [ - "dxgi.dll", - "winmm.dll", - "dbghelp.dll", - "version.dll", - "wininet.dll", - "winhttp.dll", - "OptiScaler.asi", -] +Decky-facing methods live here; filesystem, Steam, configuration, and patch +ownership concerns are implemented in ``backend`` services. +""" -VALID_DLL_NAMES = set(PROXY_DLL_BACKUPS) - -INJECTOR_FILENAMES = [ - *PROXY_DLL_BACKUPS, - "nvngx.dll", - "_nvngx.dll", - "nvngx-wrapper.dll", - "dlss-enabler.dll", - "OptiScaler.dll", -] - -PATCH_CLEANUP_FILES = [ - *INJECTOR_FILENAMES, - *VARIANT_EXTRA_FILENAMES, - "nvapi64.dll", - "nvapi64.dll.b", - "nvngx.ini", - "dlss-enabler-upscaler.dll", - "fakenvapi.log", - "OptiScaler.log", - "dlssg_to_fsr3.log", - "dlssg_to_fsr3_amd_is_better-3.0.dll", -] - -PATCH_FINGERPRINT_FILES = [ - "FRAMEGEN_PATCH", - "OptiScaler.ini", - "fakenvapi.dll", - "fakenvapi.ini", - "dlssg_to_fsr3_amd_is_better.dll", - "D3D12_Optiscaler", -] - -ORIGINAL_DLL_BACKUPS = [ - "d3dcompiler_47.dll", - "amd_fidelityfx_dx12.dll", - "amd_fidelityfx_framegeneration_dx12.dll", - FSR4_UPSCALER_FILENAME, - FSR4_DRIVER_OVERRIDE_FILENAME, - "amdxc64.dll", - "amd_fidelityfx_vk.dll", -] - -RESTORABLE_BACKUP_FILES = [ - *PROXY_DLL_BACKUPS, - *ORIGINAL_DLL_BACKUPS, -] - -SUPPORT_FILES = [ - "libxess.dll", - "libxess_dx11.dll", - "libxess_fg.dll", - "libxell.dll", - "amd_fidelityfx_dx12.dll", - "amd_fidelityfx_framegeneration_dx12.dll", - "amd_fidelityfx_vk.dll", - "dlssg_to_fsr3_amd_is_better.dll", - "fakenvapi.dll", - "fakenvapi.ini", -] +from pathlib import Path -MARKER_FILENAME = "FRAMEGEN_PATCH" +import decky -BAD_EXE_SUBSTRINGS = [ - "crashreport", - "crashreportclient", - "eac", - "easyanticheat", - "beclient", - "eosbootstrap", - "benchmark", - "uninstall", - "setup", - "launcher", - "updater", - "bootstrap", - "_redist", - "prereq", -] +from backend.assets import DEFAULT_FRAMEGEN_BACKEND, DEFAULT_FSR4_VARIANT +from backend.bundle import BundleManager +from backend.patcher import PatchManager +from backend.steam import SteamLibrary -LEGACY_FILES = [ - "dlssg_to_fsr3.ini", - "dlssg_to_fsr3.log", - "nvapi64.dll", - "nvapi64.dll.b", - "fakenvapi.log", - "dlss-enabler.dll", - "dlss-enabler-upscaler.dll", - "dlss-enabler.log", - "nvngx.ini", - "nvngx-wrapper.dll", - "_nvngx.dll", - "dlssg_to_fsr3_amd_is_better-3.0.dll", - "OptiScaler.asi", - "OptiScaler.ini", - "OptiScaler.log", -] class Plugin: + def __init__(self): + home = Path(str(decky.HOME)) + plugin_dir = Path(str(decky.DECKY_PLUGIN_DIR)) + logger = decky.logger.error + self._bundle = BundleManager(home, plugin_dir, logger) + self._steam = SteamLibrary(home, logger) + self._patcher = PatchManager(home, self._bundle, self._steam, logger) + async def _main(self): decky.logger.info("Framegen plugin loaded") async def _unload(self): decky.logger.info("Framegen plugin unloaded.") - - def _create_renamed_copies(self, source_file, renames_dir): - """Create renamed copies of the OptiScaler.dll file""" - try: - renames_dir.mkdir(exist_ok=True) - - rename_files = [ - "dxgi.dll", - "winmm.dll", - "dbghelp.dll", - "version.dll", - "wininet.dll", - "winhttp.dll", - "OptiScaler.asi" - ] - - if source_file.exists(): - for rename_file in rename_files: - dest_file = renames_dir / rename_file - shutil.copy2(source_file, dest_file) - decky.logger.info(f"Created renamed copy: {dest_file}") - return True - else: - decky.logger.error(f"Source file {source_file} does not exist") - return False - - except Exception as e: - decky.logger.error(f"Failed to create renamed copies: {e}") - return False - - def _copy_launcher_scripts(self, assets_dir, extract_path): - """Copy launcher scripts from assets directory""" - try: - # Copy fgmod script - fgmod_script_src = assets_dir / "fgmod.sh" - fgmod_script_dest = extract_path / "fgmod" - if fgmod_script_src.exists(): - shutil.copy2(fgmod_script_src, fgmod_script_dest) - fgmod_script_dest.chmod(0o755) - decky.logger.info(f"Copied fgmod script to {fgmod_script_dest}") - - # Copy uninstaller script - uninstaller_src = assets_dir / "fgmod-uninstaller.sh" - uninstaller_dest = extract_path / "fgmod-uninstaller.sh" - if uninstaller_src.exists(): - shutil.copy2(uninstaller_src, uninstaller_dest) - uninstaller_dest.chmod(0o755) - decky.logger.info(f"Copied uninstaller script to {uninstaller_dest}") - - # Copy optiscaler config updater script - optiscaler_config_updater_src = assets_dir / "update-optiscaler-config.py" - optiscaler_config_updater_dest = extract_path / "update-optiscaler-config.py" - if optiscaler_config_updater_src.exists(): - shutil.copy2(optiscaler_config_updater_src, optiscaler_config_updater_dest) - optiscaler_config_updater_dest.chmod(0o755) - decky.logger.info(f"Copied update-optiscaler-config.py script to {optiscaler_config_updater_dest}") - - return True - except Exception as e: - decky.logger.error(f"Failed to copy launcher scripts: {e}") - return False - - def _files_match(self, file_a: Path, file_b: Path) -> bool: - try: - return file_a.exists() and file_b.exists() and filecmp.cmp(file_a, file_b, shallow=False) - except Exception: - return False - - def _is_bundled_proxy_copy(self, file_path: Path, fgmod_path: Path) -> bool: - bundled_copy = fgmod_path / "renames" / file_path.name - return self._files_match(file_path, bundled_copy) - - def _has_patch_fingerprint(self, directory: Path) -> bool: - return any((directory / filename).exists() for filename in PATCH_FINGERPRINT_FILES) - - def _backup_preexisting_proxy_files(self, directory: Path, fgmod_path: Path) -> list[str]: - backed_up: list[str] = [] - already_patched = self._has_patch_fingerprint(directory) - for filename in PROXY_DLL_BACKUPS: - source = directory / filename - backup = directory / f"{filename}.b" - if not source.exists() or backup.exists(): - continue - if already_patched or self._is_bundled_proxy_copy(source, fgmod_path): - continue - shutil.move(source, backup) - backed_up.append(filename) - return backed_up - - def _file_sha256(self, path: Path) -> str: - digest = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - def _read_json_file(self, path: Path) -> dict: - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, dict) else {} - except Exception: - return {} - - def _write_json_file(self, path: Path, payload: dict) -> None: - with open(path, "w", encoding="utf-8") as f: - json.dump(payload, f, indent=2) - - def _extract_archive(self, archive_path: Path, output_dir: Path, members: list[str] | None = None) -> None: - output_dir.mkdir(parents=True, exist_ok=True) - extract_cmd = [ - "7z", - "x", - "-y", - "-o" + str(output_dir), - str(archive_path), - ] - if members: - extract_cmd.extend(members) - - clean_env = os.environ.copy() - clean_env["LD_LIBRARY_PATH"] = "" - result = subprocess.run( - extract_cmd, - capture_output=True, - text=True, - check=False, - env=clean_env, - ) - if result.returncode != 0: - raise RuntimeError(result.stderr or result.stdout or f"Failed to extract {archive_path.name}") - - def _verify_bundled_asset(self, path: Path, expected_sha256: str, description: str) -> str: - actual_sha256 = self._file_sha256(path) - if actual_sha256.lower() != expected_sha256.lower(): - raise RuntimeError( - f"{description} hash mismatch: expected {expected_sha256}, got {actual_sha256}" - ) - return actual_sha256 - - def _install_manifest_path(self, fgmod_path: Path) -> Path: - return fgmod_path / INSTALL_MANIFEST_FILENAME - - def _load_install_manifest(self, fgmod_path: Path) -> dict: - return self._read_json_file(self._install_manifest_path(fgmod_path)) - - def _normalize_fsr4_variant(self, fsr4_variant: str | None) -> str: - variant = str(fsr4_variant or "").strip() - if variant in FSR4_VARIANTS: - return variant - return DEFAULT_FSR4_VARIANT - - def _selected_fsr4_variant(self, fgmod_path: Path, requested_variant: str | None = None) -> str: - normalized_requested = str(requested_variant or "").strip() - if normalized_requested in FSR4_VARIANTS: - return normalized_requested - manifest = self._load_install_manifest(fgmod_path) - manifest_variant = str(manifest.get("selected_default_variant") or "").strip() - if manifest_variant in FSR4_VARIANTS: - return manifest_variant - return DEFAULT_FSR4_VARIANT - - def _fsr4_variant_info(self, fsr4_variant: str | None) -> dict: - return FSR4_VARIANTS[self._normalize_fsr4_variant(fsr4_variant)] - - def _fsr4_variant_dir(self, fgmod_path: Path, fsr4_variant: str | None) -> Path: - variant_id = self._normalize_fsr4_variant(fsr4_variant) - return fgmod_path / FSR4_VARIANTS[variant_id]["dir_name"] - - def _fsr4_variant_path(self, fgmod_path: Path, fsr4_variant: str | None) -> Path: - return self._fsr4_variant_dir(fgmod_path, fsr4_variant) / FSR4_UPSCALER_FILENAME - - def _fsr4_variant_extra_files(self, fsr4_variant: str | None) -> list[dict]: - variant = self._fsr4_variant_info(fsr4_variant) - return list(variant.get("extra_files") or []) - - def _fsr4_variant_extra_file_path(self, fgmod_path: Path, fsr4_variant: str | None, filename: str) -> Path: - return self._fsr4_variant_dir(fgmod_path, fsr4_variant) / filename - - def _fsr4_variant_injector_info(self, fsr4_variant: str | None) -> dict | None: - variant = self._fsr4_variant_info(fsr4_variant) - injector = variant.get("injector") - return injector if isinstance(injector, dict) else None - - def _fsr4_variant_injector_path(self, fgmod_path: Path, fsr4_variant: str | None) -> Path | None: - injector = self._fsr4_variant_injector_info(fsr4_variant) - if not injector: - return None - return self._fsr4_variant_dir(fgmod_path, fsr4_variant) / injector.get("name", "OptiScaler.dll") - - def _fsr4_variant_renamed_proxy_path(self, fgmod_path: Path, fsr4_variant: str | None, dll_name: str) -> Path | None: - if not self._fsr4_variant_injector_info(fsr4_variant): - return None - return self._fsr4_variant_dir(fgmod_path, fsr4_variant) / "renames" / dll_name - - def _fsr4_variant_config_overrides(self, fsr4_variant: str | None) -> dict: - variant = self._fsr4_variant_info(fsr4_variant) - overrides = variant.get("config_overrides") or {} - return dict(overrides) if isinstance(overrides, dict) else {} - - def _split_ini_override_key(self, raw_key: str) -> tuple[str | None, str]: - key = str(raw_key).strip() - if "." in key: - section, section_key = key.split(".", 1) - section = section.strip() - section_key = section_key.strip() - if section and section_key: - return section, section_key - return None, key - - def _apply_optiscaler_ini_overrides(self, ini_file: Path, overrides: dict) -> bool: - if not overrides: - return True - try: - content = ini_file.read_text(encoding="utf-8", errors="replace") - newline = "\r\n" if "\r\n" in content else "\n" - lines = content.splitlines(keepends=True) - section_pattern = re.compile(r"^\s*\[(?P<section>[^\]]+)\]\s*$") - - def ensure_trailing_newline() -> None: - if lines and not lines[-1].endswith(("\n", "\r")): - lines[-1] += newline - - def upsert(section: str | None, key: str, value: str) -> None: - replacement = f"{key}={value}" - key_pattern = re.compile(rf"^(\s*{re.escape(key)}\s*)=.*$") - - if section is None: - for idx, line in enumerate(lines): - if key_pattern.match(line): - line_ending = "\r\n" if line.endswith("\r\n") else ("\n" if line.endswith("\n") else newline) - lines[idx] = f"{replacement}{line_ending}" - return - ensure_trailing_newline() - lines.append(f"{replacement}{newline}") - return - - in_section = False - insert_at = None - for idx, line in enumerate(lines): - match = section_pattern.match(line.strip()) - if match: - if in_section: - insert_at = idx - break - if match.group("section") == section: - in_section = True - continue - if in_section and key_pattern.match(line): - line_ending = "\r\n" if line.endswith("\r\n") else ("\n" if line.endswith("\n") else newline) - lines[idx] = f"{replacement}{line_ending}" - return - - if in_section: - if insert_at is None: - ensure_trailing_newline() - insert_at = len(lines) - lines.insert(insert_at, f"{replacement}{newline}") - return - - ensure_trailing_newline() - if lines and lines[-1].strip(): - lines.append(newline) - lines.append(f"[{section}]{newline}") - lines.append(f"{replacement}{newline}") - - for raw_key, raw_value in overrides.items(): - section, key = self._split_ini_override_key(str(raw_key)) - value = str(raw_value).strip() - if key: - upsert(section, key, value) - - ini_file.write_text("".join(lines), encoding="utf-8") - return True - except Exception as exc: - decky.logger.error(f"Failed to apply OptiScaler.ini overrides to {ini_file}: {exc}") - return False - - def _sync_variant_root_extra_files(self, fgmod_path: Path, fsr4_variant: str | None) -> None: - selected_extra_files = {extra_file["name"]: extra_file for extra_file in self._fsr4_variant_extra_files(fsr4_variant)} - for filename in VARIANT_EXTRA_FILENAMES: - root_path = fgmod_path / filename - if filename not in selected_extra_files: - if root_path.exists(): - root_path.unlink() - continue - source_path = self._fsr4_variant_extra_file_path(fgmod_path, fsr4_variant, filename) - if not source_path.exists(): - raise FileNotFoundError(f"Prepared FSR4 variant extra file missing: {source_path}") - shutil.copy2(source_path, root_path) - - def _activate_default_fsr4_variant(self, fgmod_path: Path, fsr4_variant: str | None) -> str: - variant_id = self._normalize_fsr4_variant(fsr4_variant) - variant_path = self._fsr4_variant_path(fgmod_path, variant_id) - if not variant_path.exists(): - raise FileNotFoundError(f"Prepared FSR4 variant missing: {variant_path}") - shutil.copy2(variant_path, fgmod_path / FSR4_UPSCALER_FILENAME) - self._sync_variant_root_extra_files(fgmod_path, variant_id) - return variant_id - - def _detect_fsr4_variant(self, directory: Path, upscaler_sha256: str | None) -> str | None: - for variant_id, variant in FSR4_VARIANTS.items(): - extra_files = list(variant.get("extra_files") or []) - if not extra_files: - continue - if not upscaler_sha256 or str(variant.get("sha256") or "").lower() != str(upscaler_sha256).lower(): - continue - all_match = True - for extra_file in extra_files: - file_path = directory / extra_file["name"] - if not file_path.exists() or self._file_sha256(file_path).lower() != extra_file["sha256"].lower(): - all_match = False - break - if all_match: - return variant_id - - if not upscaler_sha256: - return None - normalized_sha = str(upscaler_sha256).lower() - for variant_id, variant in FSR4_VARIANTS.items(): - if variant.get("extra_files"): - continue - if str(variant.get("sha256") or "").lower() == normalized_sha: - return variant_id - return None - - def _fgmod_version(self, fgmod_path: Path) -> str | None: - manifest = self._load_install_manifest(fgmod_path) - optiscaler = manifest.get("optiscaler") if isinstance(manifest, dict) else None - if isinstance(optiscaler, dict) and optiscaler.get("version"): - return str(optiscaler.get("version")) - version_file = fgmod_path / VERSION_FILENAME - try: - if version_file.exists(): - return version_file.read_text(encoding="utf-8").strip() or None - except Exception: - return None - return None - - def _managed_support_candidate_paths(self, fgmod_path: Path, filename: str) -> list[Path]: - candidates: list[Path] = [] - if filename == FSR4_UPSCALER_FILENAME: - candidates.append(fgmod_path / FSR4_UPSCALER_FILENAME) - for variant_id in FSR4_VARIANTS: - candidates.append(self._fsr4_variant_path(fgmod_path, variant_id)) - else: - candidates.append(fgmod_path / filename) - for variant_id in FSR4_VARIANTS: - for extra_file in self._fsr4_variant_extra_files(variant_id): - if extra_file["name"] == filename: - candidates.append(self._fsr4_variant_extra_file_path(fgmod_path, variant_id, filename)) - unique: list[Path] = [] - seen: set[str] = set() - for candidate in candidates: - key = str(candidate) - if key not in seen: - unique.append(candidate) - seen.add(key) - return unique - - def _is_managed_support_file(self, path: Path, fgmod_path: Path) -> bool: - if not path.exists(): - return False - for candidate in self._managed_support_candidate_paths(fgmod_path, path.name): - if self._files_match(path, candidate): - return True - return False - - def _migrate_optiscaler_ini(self, ini_file): - """Migrate pre-v0.9-final OptiScaler.ini: replace FGType with FGInput + FGOutput. - - v0.9-final split the single FGType key into separate FGInput and FGOutput keys. - Games already patched with an older build will have FGType=<value> in their - per-game INI but no FGInput/FGOutput entries, causing the new DLL to silently - fall back to nofg. This migration runs at patch-time and at every fgmod.sh - launch so users never have to manually touch their INI. - """ - try: - if not ini_file.exists(): - return False - - with open(ini_file, 'r') as f: - content = f.read() - - fg_type_match = re.search(r'^FGType\s*=\s*(\S+)', content, re.MULTILINE) - if not fg_type_match: - return True # Nothing to migrate - - fg_value = fg_type_match.group(1) - - if re.search(r'^FGInput\s*=', content, re.MULTILINE): - # FGInput already present (INI already in v0.9-final format); - # just remove the now-unknown FGType line. - content = re.sub(r'^FGType\s*=\s*\S+\n?', '', content, flags=re.MULTILINE) - decky.logger.info(f"Removed stale FGType from {ini_file} (FGInput already present)") - else: - # Replace the single FGType=X line with FGInput=X then FGOutput=X - content = re.sub( - r'^FGType\s*=\s*\S+', - f'FGInput={fg_value}\nFGOutput={fg_value}', - content, - flags=re.MULTILINE - ) - decky.logger.info(f"Migrated FGType={fg_value} → FGInput={fg_value}, FGOutput={fg_value} in {ini_file}") - - with open(ini_file, 'w') as f: - f.write(content) - return True - except Exception as e: - decky.logger.error(f"Failed to migrate OptiScaler.ini: {e}") - return False - - def _disable_hq_font_auto(self, ini_file): - """Disable the new HQ font auto mode to avoid missing font assertions on Wine/Proton.""" - try: - if not ini_file.exists(): - decky.logger.warning(f"OptiScaler.ini not found at {ini_file}") - return False - - with open(ini_file, 'r') as f: - content = f.read() - - updated_content = re.sub(r'UseHQFont\s*=\s*auto', 'UseHQFont=false', content) - if updated_content != content: - with open(ini_file, 'w') as f: - f.write(updated_content) - decky.logger.info("Set UseHQFont=false to avoid missing font assertions") - - return True - except Exception as e: - decky.logger.error(f"Failed to update HQ font setting in OptiScaler.ini: {e}") - return False - - def _modify_optiscaler_ini(self, ini_file): - """Modify OptiScaler.ini to set FG defaults, ASI plugin settings, and safe font defaults.""" - try: - if ini_file.exists(): - with open(ini_file, 'r') as f: - content = f.read() - - # Replace FGInput=auto with FGInput=nukems (final v0.9+ split FGType into FGInput/FGOutput) - updated_content = re.sub(r'FGInput\s*=\s*auto', 'FGInput=nukems', content) - - # Replace FGOutput=auto with FGOutput=nukems - updated_content = re.sub(r'FGOutput\s*=\s*auto', 'FGOutput=nukems', updated_content) - - # Replace Fsr4Update=auto with Fsr4Update=true - updated_content = re.sub(r'Fsr4Update\s*=\s*auto', 'Fsr4Update=true', updated_content) - - # Replace LoadAsiPlugins=auto with LoadAsiPlugins=true - updated_content = re.sub(r'LoadAsiPlugins\s*=\s*auto', 'LoadAsiPlugins=true', updated_content) - - # Disable new HQ font auto mode to avoid missing font assertions on Proton - updated_content = re.sub(r'UseHQFont\s*=\s*auto', 'UseHQFont=false', updated_content) - - with open(ini_file, 'w') as f: - f.write(updated_content) - - decky.logger.info("Modified OptiScaler.ini to set FGInput=nukems, FGOutput=nukems, Fsr4Update=true, LoadAsiPlugins=true, UseHQFont=false") - return True - else: - decky.logger.warning(f"OptiScaler.ini not found at {ini_file}") - return False - except Exception as e: - decky.logger.error(f"Failed to modify OptiScaler.ini: {e}") - return False - - async def extract_static_optiscaler(self, selected_default_variant: str = DEFAULT_FSR4_VARIANT) -> dict: - """Prepare the shared ~/fgmod bundle with all bundled FSR4 runtime variants.""" - try: - decky.logger.info("Starting extract_static_optiscaler method") - - bin_path = Path(decky.DECKY_PLUGIN_DIR) / "bin" - extract_path = Path(decky.HOME) / "fgmod" - assets_dir = Path(decky.DECKY_PLUGIN_DIR) / "assets" - selected_default_variant = self._normalize_fsr4_variant(selected_default_variant) - - if not bin_path.exists(): - return {"status": "error", "message": f"Bin directory not found: {bin_path}"} - - optiscaler_archive = bin_path / OPTISCALER_ARCHIVE_ASSET["name"] - fsr4_int8_src = bin_path / FSR4_INT8_ASSET["name"] - fsr4_official_411_src = bin_path / FSR4_OFFICIAL_411_ASSET["name"] - fsr4_valve_411_src = bin_path / FSR4_VALVE_411_ASSET["name"] - amdxc64_rdna2_src = bin_path / AMDXC64_RDNA2_ASSET["name"] - optiscaler_pre10_src = bin_path / OPTISCALER_PRE10_ASSET["name"] - optipatcher_src = bin_path / OPTIPATCHER_ASSET["name"] - for required_path, asset in [ - (optiscaler_archive, OPTISCALER_ARCHIVE_ASSET), - (fsr4_int8_src, FSR4_INT8_ASSET), - (fsr4_official_411_src, FSR4_OFFICIAL_411_ASSET), - (fsr4_valve_411_src, FSR4_VALVE_411_ASSET), - (amdxc64_rdna2_src, AMDXC64_RDNA2_ASSET), - (optiscaler_pre10_src, OPTISCALER_PRE10_ASSET), - (optipatcher_src, OPTIPATCHER_ASSET), - ]: - if not required_path.exists(): - return { - "status": "error", - "message": f"Required bundled asset missing: {asset['name']}", - } - self._verify_bundled_asset(required_path, asset["sha256"], asset["name"]) - - if extract_path.exists(): - shutil.rmtree(extract_path) - extract_path.mkdir(parents=True, exist_ok=True) - - self._extract_archive(optiscaler_archive, extract_path) - - source_file = extract_path / "OptiScaler.dll" - renames_dir = extract_path / "renames" - if not self._create_renamed_copies(source_file, renames_dir): - return {"status": "error", "message": "Failed to prepare renamed OptiScaler proxies."} - - if not self._copy_launcher_scripts(assets_dir, extract_path): - return {"status": "error", "message": "Failed to copy launcher scripts."} - - plugins_dir = extract_path / "plugins" - plugins_dir.mkdir(parents=True, exist_ok=True) - optipatcher_dst = plugins_dir / "OptiPatcher.asi" - shutil.copy2(optipatcher_src, optipatcher_dst) - optipatcher_sha256 = self._verify_bundled_asset( - optipatcher_dst, - OPTIPATCHER_ASSET["sha256"], - "Prepared OptiPatcher plugin", - ) - - ini_file = extract_path / "OptiScaler.ini" - self._modify_optiscaler_ini(ini_file) - - native_upscaler_root = extract_path / FSR4_UPSCALER_FILENAME - native_upscaler_sha256 = self._verify_bundled_asset( - native_upscaler_root, - FSR4_VARIANTS["rdna4-native"]["sha256"], - "Archive-native FSR4 upscaler", - ) - rdna4_dir = extract_path / FSR4_VARIANTS["rdna4-native"]["dir_name"] - rdna4_dir.mkdir(parents=True, exist_ok=True) - rdna4_upscaler = rdna4_dir / FSR4_UPSCALER_FILENAME - shutil.copy2(native_upscaler_root, rdna4_upscaler) - self._verify_bundled_asset( - rdna4_upscaler, - FSR4_VARIANTS["rdna4-native"]["sha256"], - "Prepared rdna4-native FSR4 upscaler", - ) - - official_411_dir = extract_path / FSR4_VARIANTS["rdna34-official-411"]["dir_name"] - official_411_dir.mkdir(parents=True, exist_ok=True) - official_411_upscaler = official_411_dir / FSR4_UPSCALER_FILENAME - shutil.copy2(native_upscaler_root, official_411_upscaler) - self._verify_bundled_asset( - official_411_upscaler, - FSR4_VARIANTS["rdna34-official-411"]["sha256"], - "Prepared rdna34-official-411 FSR4 upscaler", - ) - self._verify_bundled_asset( - fsr4_official_411_src, - FSR4_OFFICIAL_411_ASSET["sha256"], - "Bundled rdna34-official-411 driver override", - ) - official_411_driver = official_411_dir / FSR4_DRIVER_OVERRIDE_FILENAME - shutil.copy2(fsr4_official_411_src, official_411_driver) - self._verify_bundled_asset( - official_411_driver, - FSR4_OFFICIAL_411_ASSET["sha256"], - "Prepared rdna34-official-411 driver override", - ) - - rdna2_valve_dir = extract_path / FSR4_VARIANTS["rdna2-valve-411-pre10"]["dir_name"] - rdna2_valve_dir.mkdir(parents=True, exist_ok=True) - rdna2_valve_upscaler = rdna2_valve_dir / FSR4_UPSCALER_FILENAME - shutil.copy2(native_upscaler_root, rdna2_valve_upscaler) - self._verify_bundled_asset( - rdna2_valve_upscaler, - FSR4_VARIANTS["rdna2-valve-411-pre10"]["sha256"], - "Prepared rdna2-valve-411-pre10 FSR4 upscaler", - ) - self._verify_bundled_asset( - fsr4_valve_411_src, - FSR4_VALVE_411_ASSET["sha256"], - "Bundled rdna2-valve-411-pre10 driver override", - ) - rdna2_valve_driver = rdna2_valve_dir / FSR4_DRIVER_OVERRIDE_FILENAME - shutil.copy2(fsr4_valve_411_src, rdna2_valve_driver) - self._verify_bundled_asset( - rdna2_valve_driver, - FSR4_VALVE_411_ASSET["sha256"], - "Prepared rdna2-valve-411-pre10 driver override", - ) - self._verify_bundled_asset( - amdxc64_rdna2_src, - AMDXC64_RDNA2_ASSET["sha256"], - "Bundled rdna2-valve-411-pre10 amdxc64 override", - ) - rdna2_valve_amdxc64 = rdna2_valve_dir / "amdxc64.dll" - shutil.copy2(amdxc64_rdna2_src, rdna2_valve_amdxc64) - self._verify_bundled_asset( - rdna2_valve_amdxc64, - AMDXC64_RDNA2_ASSET["sha256"], - "Prepared rdna2-valve-411-pre10 amdxc64 override", - ) - self._verify_bundled_asset( - optiscaler_pre10_src, - OPTISCALER_PRE10_ASSET["sha256"], - "Bundled rdna2-valve-411-pre10 OptiScaler injector", - ) - rdna2_valve_injector = rdna2_valve_dir / "OptiScaler.dll" - shutil.copy2(optiscaler_pre10_src, rdna2_valve_injector) - self._verify_bundled_asset( - rdna2_valve_injector, - OPTISCALER_PRE10_ASSET["sha256"], - "Prepared rdna2-valve-411-pre10 OptiScaler injector", - ) - if not self._create_renamed_copies(rdna2_valve_injector, rdna2_valve_dir / "renames"): - return {"status": "error", "message": "Failed to prepare renamed pre10 OptiScaler proxies."} - - rdna23_dir = extract_path / FSR4_VARIANTS["rdna23-int8"]["dir_name"] - rdna23_dir.mkdir(parents=True, exist_ok=True) - self._verify_bundled_asset( - fsr4_int8_src, - FSR4_VARIANTS["rdna23-int8"]["sha256"], - "Bundled rdna23-int8 FSR4 upscaler", - ) - shutil.copy2(fsr4_int8_src, rdna23_dir / FSR4_UPSCALER_FILENAME) - self._verify_bundled_asset( - rdna23_dir / FSR4_UPSCALER_FILENAME, - FSR4_VARIANTS["rdna23-int8"]["sha256"], - "Prepared rdna23-int8 FSR4 upscaler", - ) - - selected_default_variant = self._activate_default_fsr4_variant(extract_path, selected_default_variant) - active_upscaler_sha256 = self._file_sha256(extract_path / FSR4_UPSCALER_FILENAME) - - version_file = extract_path / VERSION_FILENAME - version_file.write_text(OPTISCALER_ARCHIVE_ASSET["version"], encoding="utf-8") - - install_manifest = { - "schema_version": 1, - "installed_at": datetime.now(timezone.utc).isoformat(), - "optiscaler": { - "asset_name": OPTISCALER_ARCHIVE_ASSET["name"], - "version": OPTISCALER_ARCHIVE_ASSET["version"], - "sha256": OPTISCALER_ARCHIVE_ASSET["sha256"], - "native_upscaler_sha256": native_upscaler_sha256, - }, - "optipatcher": { - "asset_name": OPTIPATCHER_ASSET["name"], - "version": OPTIPATCHER_ASSET["version"], - "sha256": optipatcher_sha256, - "target_path": str(optipatcher_dst.relative_to(extract_path)), - }, - "fsr4_variants": { - variant_id: { - "label": variant["label"], - "dir_name": variant["dir_name"], - "path": str((Path(variant["dir_name"]) / FSR4_UPSCALER_FILENAME).as_posix()), - "sha256": variant["sha256"], - "source_asset_name": variant["source_asset_name"], - "source_version": variant["source_version"], - "uses_archive_native": bool(variant["uses_archive_native"]), - "injector": ( - { - "name": variant["injector"]["name"], - "sha256": variant["injector"]["sha256"], - "source_asset_name": variant["injector"]["source_asset_name"], - "source_version": variant["injector"]["source_version"], - "path": str((Path(variant["dir_name"]) / variant["injector"]["name"]).as_posix()), - } - if isinstance(variant.get("injector"), dict) - else None - ), - "config_overrides": dict(variant.get("config_overrides") or {}), - "extra_files": [ - { - "name": extra_file["name"], - "sha256": extra_file["sha256"], - "source_asset_name": extra_file["source_asset_name"], - "source_version": extra_file["source_version"], - "path": str((Path(variant["dir_name"]) / extra_file["name"]).as_posix()), - } - for extra_file in variant.get("extra_files", []) - ], - } - for variant_id, variant in FSR4_VARIANTS.items() - }, - "selected_default_variant": selected_default_variant, - "active_root_upscaler": { - "path": FSR4_UPSCALER_FILENAME, - "sha256": active_upscaler_sha256, - "variant": selected_default_variant, - }, - } - self._write_json_file(self._install_manifest_path(extract_path), install_manifest) + async def extract_static_optiscaler( + self, + selected_default_variant: str = DEFAULT_FSR4_VARIANT, + framegen_backend: str = DEFAULT_FRAMEGEN_BACKEND, + ) -> dict: + return self._bundle.extract_static_optiscaler(selected_default_variant, framegen_backend) - return { - "status": "success", - "message": f"Successfully extracted OptiScaler {OPTISCALER_ARCHIVE_ASSET['version']} to ~/fgmod", - "version": OPTISCALER_ARCHIVE_ASSET["version"], - "selected_default_variant": selected_default_variant, - "selected_default_variant_label": FSR4_VARIANTS[selected_default_variant]["label"], - } - except Exception as e: - decky.logger.error(f"Extract failed with exception: {str(e)}") - import traceback - decky.logger.error(f"Traceback: {traceback.format_exc()}") - return {"status": "error", "message": f"Extract failed: {str(e)}"} + async def run_install_fgmod( + self, + selected_default_variant: str = DEFAULT_FSR4_VARIANT, + framegen_backend: str = DEFAULT_FRAMEGEN_BACKEND, + ) -> dict: + return self._bundle.run_install(selected_default_variant, framegen_backend) async def run_uninstall_fgmod(self) -> dict: - try: - # Remove fgmod directory - fgmod_path = Path(decky.HOME) / "fgmod" - - if fgmod_path.exists(): - shutil.rmtree(fgmod_path) - decky.logger.info(f"Removed directory: {fgmod_path}") - return { - "status": "success", - "output": "Successfully removed fgmod directory" - } - else: - return { - "status": "success", - "output": "No fgmod directory found to remove" - } - - except Exception as e: - decky.logger.error(f"Uninstall error: {str(e)}") - return { - "status": "error", - "message": f"Uninstall failed: {str(e)}", - "output": str(e) - } + return self._bundle.uninstall() async def set_default_fsr4_variant(self, selected_default_variant: str = DEFAULT_FSR4_VARIANT) -> dict: - try: - fgmod_path = Path(decky.HOME) / "fgmod" - if not fgmod_path.exists(): - return {"status": "error", "message": "OptiScaler bundle not installed. Run Install first."} - - selected_default_variant = self._normalize_fsr4_variant(selected_default_variant) - manifest = self._load_install_manifest(fgmod_path) - if not manifest: - return {"status": "error", "message": "Install manifest missing. Reinstall OptiScaler."} + return self._bundle.set_default_variant(selected_default_variant) - selected_default_variant = self._activate_default_fsr4_variant(fgmod_path, selected_default_variant) - active_upscaler_sha256 = self._file_sha256(fgmod_path / FSR4_UPSCALER_FILENAME) - manifest["selected_default_variant"] = selected_default_variant - manifest["active_root_upscaler"] = { - "path": FSR4_UPSCALER_FILENAME, - "sha256": active_upscaler_sha256, - "variant": selected_default_variant, - } - manifest["updated_at"] = datetime.now(timezone.utc).isoformat() - self._write_json_file(self._install_manifest_path(fgmod_path), manifest) - return { - "status": "success", - "output": f"Default FSR4 runtime switched to {FSR4_VARIANTS[selected_default_variant]['label']}.", - "version": self._fgmod_version(fgmod_path), - "selected_default_variant": selected_default_variant, - "selected_default_variant_label": FSR4_VARIANTS[selected_default_variant]["label"], - } - except Exception as e: - decky.logger.error(f"Failed to switch default FSR4 runtime: {e}") - return {"status": "error", "message": f"Failed to switch default FSR4 runtime: {e}"} - - async def run_install_fgmod(self, selected_default_variant: str = DEFAULT_FSR4_VARIANT) -> dict: - try: - decky.logger.info("Starting OptiScaler installation from static bundle") - selected_default_variant = self._normalize_fsr4_variant(selected_default_variant) - - extract_result = await self.extract_static_optiscaler(selected_default_variant) - if extract_result["status"] != "success": - return { - "status": "error", - "message": f"OptiScaler extraction failed: {extract_result.get('message', 'Unknown error')}" - } - - return { - "status": "success", - "output": ( - "Successfully installed OptiScaler " - f"{extract_result.get('version', OPTISCALER_ARCHIVE_ASSET['version'])} " - f"with {extract_result.get('selected_default_variant_label', FSR4_VARIANTS[selected_default_variant]['label'])}." - ), - "version": extract_result.get("version", OPTISCALER_ARCHIVE_ASSET["version"]), - "selected_default_variant": extract_result.get("selected_default_variant", selected_default_variant), - "selected_default_variant_label": extract_result.get( - "selected_default_variant_label", - FSR4_VARIANTS[selected_default_variant]["label"], - ), - } - - except Exception as e: - decky.logger.error(f"Unexpected error during installation: {str(e)}") - return { - "status": "error", - "message": f"Installation failed: {str(e)}" - } + async def set_framegen_backend(self, framegen_backend: str = DEFAULT_FRAMEGEN_BACKEND) -> dict: + return self._bundle.set_framegen_backend(framegen_backend) async def check_fgmod_path(self) -> dict: - path = Path(decky.HOME) / "fgmod" - required_files = [ - "OptiScaler.dll", - "OptiScaler.ini", - "dlssg_to_fsr3_amd_is_better.dll", - "fakenvapi.dll", - "fakenvapi.ini", - "amd_fidelityfx_dx12.dll", - "amd_fidelityfx_framegeneration_dx12.dll", - FSR4_UPSCALER_FILENAME, - "amd_fidelityfx_vk.dll", - "libxess.dll", - "libxess_dx11.dll", - "libxess_fg.dll", - "libxell.dll", - "fgmod", - "fgmod-uninstaller.sh", - "update-optiscaler-config.py", - INSTALL_MANIFEST_FILENAME, - ] - - if not path.exists(): - return {"exists": False} - - for file_name in required_files: - if not path.joinpath(file_name).exists(): - return {"exists": False} - - plugins_dir = path / "plugins" - if not plugins_dir.exists() or not (plugins_dir / "OptiPatcher.asi").exists(): - return {"exists": False} - - for variant_id, variant in FSR4_VARIANTS.items(): - variant_dir = path / variant["dir_name"] - variant_path = variant_dir / FSR4_UPSCALER_FILENAME - if not variant_path.exists(): - return {"exists": False} - injector = variant.get("injector") - if isinstance(injector, dict): - if not (variant_dir / injector.get("name", "OptiScaler.dll")).exists(): - return {"exists": False} - if not (variant_dir / "renames" / "dxgi.dll").exists(): - return {"exists": False} - for extra_file in variant.get("extra_files", []): - if not (variant_dir / extra_file["name"]).exists(): - return {"exists": False} - - manifest = self._load_install_manifest(path) - selected_variant = self._selected_fsr4_variant(path) - return { - "exists": True, - "version": self._fgmod_version(path), - "selected_fsr4_variant": selected_variant, - "selected_fsr4_variant_label": FSR4_VARIANTS[selected_variant]["label"], - "install_manifest_present": bool(manifest), - } - - def _resolve_target_directory(self, directory: str) -> Path: - decky.logger.info(f"Resolving target directory: {directory}") - 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}") - decky.logger.info(f"Resolved directory {directory} to absolute path {target}") - return target - - def _manual_patch_directory_impl( - self, - directory: Path, - dll_name: str = "dxgi.dll", - fsr4_variant: str | None = None, - allow_managed_support_cleanup: bool = False, - ) -> dict: - fgmod_path = Path(decky.HOME) / "fgmod" - if not fgmod_path.exists(): - return { - "status": "error", - "message": "OptiScaler bundle not installed. Run Install first.", - } - - optiscaler_dll = fgmod_path / "OptiScaler.dll" - if not optiscaler_dll.exists(): - return { - "status": "error", - "message": "OptiScaler.dll not found in ~/fgmod. Reinstall OptiScaler.", - } - - preserve_ini = True - previous_marker_metadata = self._read_marker(directory / MARKER_FILENAME) - previous_variant = str(previous_marker_metadata.get("fsr4_variant") or "").strip() - selected_variant = self._selected_fsr4_variant(fgmod_path, fsr4_variant) - selected_variant_info = FSR4_VARIANTS[selected_variant] - selected_config_overrides = self._fsr4_variant_config_overrides(selected_variant) - if previous_variant == "rdna2-valve-411-pre10" and selected_variant != previous_variant: - selected_config_overrides.setdefault("FSR.Fsr4ForceModel", "auto") - selected_config_overrides.setdefault("Plugins.LoadCustomAmdxc64OnRdna2", "false") - selected_upscaler_src = self._fsr4_variant_path(fgmod_path, selected_variant) - if not selected_upscaler_src.exists(): - selected_upscaler_src = fgmod_path / FSR4_UPSCALER_FILENAME - if not selected_upscaler_src.exists(): - return { - "status": "error", - "message": f"FSR4 upscaler variant not found for {selected_variant}. Reinstall OptiScaler.", - } - selected_extra_files = self._fsr4_variant_extra_files(selected_variant) - optiscaler_version = self._fgmod_version(fgmod_path) - selected_upscaler_sha256 = self._file_sha256(selected_upscaler_src) - - try: - decky.logger.info( - f"Manual patch started for {directory} with FSR4 variant {selected_variant} ({selected_variant_info['label']})" - ) - - backed_up_proxies = self._backup_preexisting_proxy_files(directory, fgmod_path) - decky.logger.info( - f"Backed up pre-existing proxy files: {backed_up_proxies}" - if backed_up_proxies - else "No pre-existing proxy files required backup" - ) - - removed_patch_files = [] - for filename in dict.fromkeys(PATCH_CLEANUP_FILES): - path = directory / filename - if path.exists(): - path.unlink() - removed_patch_files.append(filename) - decky.logger.info( - f"Removed stale patch files: {removed_patch_files}" - if removed_patch_files - else "No stale patch files found to remove" - ) - - backed_up_originals = [] - removed_managed_support = [] - for dll in ORIGINAL_DLL_BACKUPS: - source = directory / dll - backup = directory / f"{dll}.b" - if not source.exists() or backup.exists(): - continue - if allow_managed_support_cleanup and self._is_managed_support_file(source, fgmod_path): - source.unlink() - removed_managed_support.append(dll) - continue - shutil.move(source, backup) - backed_up_originals.append(dll) - if removed_managed_support: - decky.logger.info(f"Removed managed support files before repatch: {removed_managed_support}") - decky.logger.info( - f"Backed up original game DLLs: {backed_up_originals}" - if backed_up_originals - else "No original game DLLs required backup" - ) - - variant_renamed = self._fsr4_variant_renamed_proxy_path(fgmod_path, selected_variant, dll_name) - variant_injector = self._fsr4_variant_injector_path(fgmod_path, selected_variant) - root_renamed = fgmod_path / "renames" / dll_name - destination_dll = directory / dll_name - if variant_renamed and variant_renamed.exists(): - source_for_copy = variant_renamed - elif variant_injector and variant_injector.exists(): - source_for_copy = variant_injector - elif root_renamed.exists(): - source_for_copy = root_renamed - else: - source_for_copy = optiscaler_dll - shutil.copy2(source_for_copy, destination_dll) - decky.logger.info(f"Copied injector DLL from {source_for_copy} to {destination_dll}") - - target_ini = directory / "OptiScaler.ini" - source_ini = fgmod_path / "OptiScaler.ini" - if preserve_ini and target_ini.exists(): - decky.logger.info(f"Preserving existing OptiScaler.ini at {target_ini}") - elif source_ini.exists(): - shutil.copy2(source_ini, target_ini) - decky.logger.info(f"Copied OptiScaler.ini from {source_ini} to {target_ini}") - else: - decky.logger.warning("No OptiScaler.ini found to copy") - - if target_ini.exists(): - self._migrate_optiscaler_ini(target_ini) - self._disable_hq_font_auto(target_ini) - self._apply_optiscaler_ini_overrides(target_ini, selected_config_overrides) - - plugins_src = fgmod_path / "plugins" - plugins_dest = directory / "plugins" - if plugins_src.exists(): - shutil.copytree(plugins_src, plugins_dest, dirs_exist_ok=True) - decky.logger.info(f"Synced plugins directory from {plugins_src} to {plugins_dest}") - else: - decky.logger.warning("Plugins directory missing in fgmod bundle") - - d3d12_src = fgmod_path / "D3D12_Optiscaler" - d3d12_dest = directory / "D3D12_Optiscaler" - if d3d12_src.exists(): - shutil.copytree(d3d12_src, d3d12_dest, dirs_exist_ok=True) - decky.logger.info(f"Copied D3D12_Optiscaler directory to {d3d12_dest}") - else: - decky.logger.warning("D3D12_Optiscaler directory missing in fgmod bundle") - - copied_support = [] - missing_support = [] - for filename in SUPPORT_FILES: - source = fgmod_path / filename - dest = directory / filename - if source.exists(): - shutil.copy2(source, dest) - copied_support.append(filename) - else: - missing_support.append(filename) - - upscaler_dest = directory / FSR4_UPSCALER_FILENAME - shutil.copy2(selected_upscaler_src, upscaler_dest) - copied_support.append(FSR4_UPSCALER_FILENAME) - - for extra_file in selected_extra_files: - source = self._fsr4_variant_extra_file_path(fgmod_path, selected_variant, extra_file["name"]) - dest = directory / extra_file["name"] - if source.exists(): - shutil.copy2(source, dest) - copied_support.append(extra_file["name"]) - else: - missing_support.append(extra_file["name"]) - - if copied_support: - decky.logger.info(f"Copied support files: {copied_support}") - if missing_support: - decky.logger.warning(f"Support files missing from fgmod bundle: {missing_support}") - - decky.logger.info(f"Manual patch complete for {directory}") - return { - "status": "success", - "message": ( - f"OptiScaler files copied to {directory} using " - f"{selected_variant_info['label']}" - ), - "fsr4_variant": selected_variant, - "fsr4_variant_label": selected_variant_info["label"], - "fsr4_upscaler_sha256": selected_upscaler_sha256, - "optiscaler_version": optiscaler_version, - } - - except PermissionError as exc: - decky.logger.error(f"Manual patch permission error: {exc}") - return { - "status": "error", - "message": f"Permission error while patching: {exc}", - } - except Exception as exc: - decky.logger.error(f"Manual patch failed: {exc}") - return { - "status": "error", - "message": f"Manual patch failed: {exc}", - } - - def _manual_unpatch_directory_impl(self, directory: Path) -> dict: - try: - decky.logger.info(f"Manual unpatch started for {directory}") - - removed_files = [] - for filename in set(INJECTOR_FILENAMES + SUPPORT_FILES + VARIANT_EXTRA_FILENAMES + [FSR4_UPSCALER_FILENAME]): - path = directory / filename - if path.exists(): - path.unlink() - removed_files.append(filename) - decky.logger.info(f"Removed injector/support files: {removed_files}" if removed_files else "No injector/support files found to remove") - - legacy_removed = [] - for legacy in LEGACY_FILES: - path = directory / legacy - if path.exists(): - try: - path.unlink() - except IsADirectoryError: - shutil.rmtree(path, ignore_errors=True) - legacy_removed.append(legacy) - decky.logger.info(f"Removed legacy artifacts: {legacy_removed}" if legacy_removed else "No legacy artifacts present") - - plugins_dir = directory / "plugins" - if plugins_dir.exists(): - shutil.rmtree(plugins_dir, ignore_errors=True) - decky.logger.info(f"Removed plugins directory at {plugins_dir}") - - d3d12_dir = directory / "D3D12_Optiscaler" - if d3d12_dir.exists(): - shutil.rmtree(d3d12_dir, ignore_errors=True) - decky.logger.info(f"Removed D3D12_Optiscaler directory from {d3d12_dir}") - - restored_backups = [] - for dll in dict.fromkeys(RESTORABLE_BACKUP_FILES): - backup = directory / f"{dll}.b" - original = directory / dll - if backup.exists(): - if original.exists(): - original.unlink() - shutil.move(backup, original) - restored_backups.append(dll) - decky.logger.info(f"Restored backups: {restored_backups}" if restored_backups else "No backups found to restore") - - uninstaller = directory / "fgmod-uninstaller.sh" - if uninstaller.exists(): - uninstaller.unlink() - decky.logger.info(f"Removed fgmod uninstaller at {uninstaller}") - - decky.logger.info(f"Manual unpatch complete for {directory}") - return { - "status": "success", - "message": f"OptiScaler files removed from {directory}", - } - - except PermissionError as exc: - decky.logger.error(f"Manual unpatch permission error: {exc}") - return { - "status": "error", - "message": f"Permission error while unpatching: {exc}", - } - except Exception as exc: - decky.logger.error(f"Manual unpatch failed: {exc}") - return { - "status": "error", - "message": f"Manual unpatch failed: {exc}", - } - - # ── Steam library discovery ─────────────────────────────────────────────── - - def _home_path(self) -> Path: - try: - return Path(decky.HOME) - except TypeError: - return Path(str(decky.HOME)) - - def _steam_root_candidates(self) -> list[Path]: - home = self._home_path() - candidates = [ - home / ".local" / "share" / "Steam", - home / ".steam" / "steam", - home / ".steam" / "root", - home / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".local" / "share" / "Steam", - home / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".steam" / "steam", - ] - unique: list[Path] = [] - seen: set[str] = set() - for c in candidates: - key = str(c) - if key not in seen: - unique.append(c) - seen.add(key) - return unique - - def _steam_library_paths(self) -> list[Path]: - library_paths: list[Path] = [] - seen: set[str] = set() - for steam_root in self._steam_root_candidates(): - if steam_root.exists(): - key = str(steam_root) - if key not in seen: - library_paths.append(steam_root) - seen.add(key) - library_file = steam_root / "steamapps" / "libraryfolders.vdf" - if not library_file.exists(): - continue - try: - with open(library_file, "r", encoding="utf-8", errors="replace") as f: - for line in f: - if '"path"' not in line: - continue - path = line.split('"path"', 1)[1].strip().strip('"').replace("\\\\", "/") - candidate = Path(path) - key = str(candidate) - if key not in seen: - library_paths.append(candidate) - seen.add(key) - except Exception as exc: - decky.logger.error(f"[Framegen] failed to parse libraryfolders: {library_file}: {exc}") - return library_paths - - def _find_installed_games(self, appid: str | None = None) -> list[dict]: - games: list[dict] = [] - for library_path in self._steam_library_paths(): - steamapps_path = library_path / "steamapps" - if not steamapps_path.exists(): - continue - for appmanifest in steamapps_path.glob("appmanifest_*.acf"): - game_info: dict = {"appid": "", "name": "", "library_path": str(library_path), "install_path": ""} - install_dir = "" - try: - with open(appmanifest, "r", encoding="utf-8", errors="replace") as f: - for line in f: - if '"appid"' in line: - game_info["appid"] = line.split('"appid"', 1)[1].strip().strip('"') - elif '"name"' in line: - game_info["name"] = line.split('"name"', 1)[1].strip().strip('"') - elif '"installdir"' in line: - install_dir = line.split('"installdir"', 1)[1].strip().strip('"') - except Exception as exc: - decky.logger.error(f"[Framegen] skipping manifest {appmanifest}: {exc}") - continue - if not game_info["appid"] or not game_info["name"]: - continue - if "Proton" in game_info["name"] or "Steam Linux Runtime" in game_info["name"]: - continue - install_path = steamapps_path / "common" / install_dir if install_dir else Path() - game_info["install_path"] = str(install_path) - if appid is None or str(game_info["appid"]) == str(appid): - games.append(game_info) - deduped: dict[str, dict] = {} - for game in games: - deduped[str(game["appid"])] = game - return sorted(deduped.values(), key=lambda g: g["name"].lower()) - - def _game_record(self, appid: str) -> dict | None: - matches = self._find_installed_games(appid) - return matches[0] if matches else None - - # ── Patch target auto-detection ─────────────────────────────────────────── - - def _normalized_path_string(self, value: str) -> str: - normalized = value.lower().replace("\\", "/") - normalized = normalized.replace("z:/", "/") - normalized = normalized.replace("//", "/") - return normalized - - def _candidate_executables(self, install_root: Path) -> list[Path]: - if not install_root.exists(): - return [] - candidates: list[Path] = [] - try: - for exe in install_root.rglob("*.exe"): - if exe.is_file(): - candidates.append(exe) - except Exception as exc: - decky.logger.error(f"[Framegen] exe scan failed for {install_root}: {exc}") - return candidates - - def _exe_score(self, exe: Path, install_root: Path, game_name: str) -> int: - normalized = self._normalized_path_string(str(exe)) - name = exe.name.lower() - score = 0 - if normalized.endswith("-win64-shipping.exe"): - score += 300 - if "shipping.exe" in name: - score += 220 - if "/binaries/win64/" in normalized: - score += 200 - if "/win64/" in normalized: - score += 80 - if exe.parent == install_root: - score += 20 - sanitized_game = re.sub(r"[^a-z0-9]", "", game_name.lower()) - sanitized_name = re.sub(r"[^a-z0-9]", "", exe.stem.lower()) - sanitized_root = re.sub(r"[^a-z0-9]", "", install_root.name.lower()) - if sanitized_game and sanitized_game in sanitized_name: - score += 120 - if sanitized_root and sanitized_root in sanitized_name: - score += 90 - for bad in BAD_EXE_SUBSTRINGS: - if bad in normalized: - score -= 200 - score -= len(exe.parts) - return score - - def _best_running_executable(self, candidates: list[Path]) -> Path | None: - if not candidates: - return None - try: - result = subprocess.run(["ps", "-eo", "args="], capture_output=True, text=True, check=False) - process_lines = result.stdout.splitlines() - except Exception as exc: - decky.logger.error(f"[Framegen] running exe scan failed: {exc}") - return None - normalized_candidates = [(exe, self._normalized_path_string(str(exe))) for exe in candidates] - matches: list[tuple[int, Path]] = [] - for line in process_lines: - normalized_line = self._normalized_path_string(line) - for exe, normalized_exe in normalized_candidates: - if normalized_exe in normalized_line: - matches.append((len(normalized_exe), exe)) - if not matches: - return None - matches.sort(key=lambda item: item[0], reverse=True) - return matches[0][1] - - def _guess_patch_target(self, game_info: dict) -> tuple[Path, Path | None]: - install_root = Path(game_info["install_path"]) - candidates = self._candidate_executables(install_root) - if not candidates: - return install_root, None - running_exe = self._best_running_executable(candidates) - if running_exe: - return running_exe.parent, running_exe - best = max(candidates, key=lambda exe: self._exe_score(exe, install_root, game_info["name"])) - return best.parent, best - - def _is_game_running(self, game_info: dict) -> bool: - install_root = Path(game_info["install_path"]) - candidates = self._candidate_executables(install_root) - return self._best_running_executable(candidates) is not None - - # ── Marker file tracking ────────────────────────────────────────────────── - - def _find_marker(self, install_root: Path) -> Path | None: - if not install_root.exists(): - return None - try: - for marker in install_root.rglob(MARKER_FILENAME): - if marker.is_file(): - return marker - except Exception: - pass - return None - - def _read_marker(self, marker_path: Path) -> dict: - try: - with open(marker_path, "r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, dict) else {} - except Exception: - return {} - - def _write_marker( - self, - marker_path: Path, - *, - appid: str, - game_name: str, - dll_name: str, - target_dir: Path, - original_launch_options: str, - backed_up_files: list[str], - optiscaler_version: str | None = None, - fsr4_variant: str | None = None, - fsr4_upscaler_sha256: str | None = None, - ) -> None: - normalized_variant = self._normalize_fsr4_variant(fsr4_variant) - variant_info = FSR4_VARIANTS[normalized_variant] - payload = { - "appid": str(appid), - "game_name": game_name, - "dll_name": dll_name, - "target_dir": str(target_dir), - "original_launch_options": original_launch_options, - "backed_up_files": backed_up_files, - "optiscaler_version": optiscaler_version, - "fsr4_variant": normalized_variant, - "fsr4_variant_label": variant_info["label"], - "fsr4_upscaler_sha256": fsr4_upscaler_sha256, - "managed_files": [ - { - "path": str(target_dir / FSR4_UPSCALER_FILENAME), - "sha256": fsr4_upscaler_sha256, - "kind": "fsr4-upscaler", - "variant": normalized_variant, - } - ], - "patched_at": datetime.now(timezone.utc).isoformat(), - } - self._write_json_file(marker_path, payload) - - # ── Launch options helpers ──────────────────────────────────────────────── - - def _build_managed_launch_options(self, 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%" - - def _is_managed_launch_options(self, opts: str) -> bool: - if not opts or not opts.strip(): - return False - normalized = " ".join(opts.strip().split()) - for dll_name in VALID_DLL_NAMES: - if dll_name == "OptiScaler.asi": - continue - base = dll_name.replace(".dll", "") - if f"WINEDLLOVERRIDES={base}=n,b" in normalized: - return True - if "fgmod/fgmod" in normalized: - return True - return False + return self._bundle.check() async def list_installed_games(self) -> dict: - try: - games = [] - for game in self._find_installed_games(): - install_root = Path(game["install_path"]) - games.append({ - "appid": str(game["appid"]), - "name": game["name"], - "install_found": install_root.exists(), - }) - return {"status": "success", "games": games} - except Exception as e: - decky.logger.error(str(e)) - return {"status": "error", "message": str(e)} + return self._patcher.list_installed_games() async def get_path_defaults(self) -> dict: - try: - home_path = Path(decky.HOME) - except TypeError: - home_path = Path(str(decky.HOME)) - - steam_common = home_path / ".local" / "share" / "Steam" / "steamapps" / "common" - - return { - "home": str(home_path), - "steam_common": str(steam_common), - } + return {"home": str(self._steam.home), "steam_common": str(self._steam.steam_common_path())} async def log_error(self, error: str) -> None: decky.logger.error(f"FRONTEND: {error}") @@ -1667,109 +69,15 @@ class Plugin: directory: str, dll_name: str = "dxgi.dll", fsr4_variant: str = DEFAULT_FSR4_VARIANT, + framegen_backend: str = DEFAULT_FRAMEGEN_BACKEND, ) -> dict: - if dll_name not in VALID_DLL_NAMES: - return {"status": "error", "message": f"Invalid proxy DLL name: {dll_name}"} - try: - target_dir = self._resolve_target_directory(directory) - except (FileNotFoundError, NotADirectoryError, PermissionError) as exc: - decky.logger.error(f"Manual patch validation failed: {exc}") - return {"status": "error", "message": str(exc)} - - allow_managed_support_cleanup = (target_dir / MARKER_FILENAME).exists() - return self._manual_patch_directory_impl( - target_dir, - dll_name, - fsr4_variant, - allow_managed_support_cleanup=allow_managed_support_cleanup, - ) + return self._patcher.manual_patch_directory(directory, dll_name, fsr4_variant, framegen_backend) async def manual_unpatch_directory(self, directory: str) -> dict: - try: - target_dir = self._resolve_target_directory(directory) - except (FileNotFoundError, NotADirectoryError, PermissionError) as exc: - decky.logger.error(f"Manual unpatch validation failed: {exc}") - return {"status": "error", "message": str(exc)} - - return self._manual_unpatch_directory_impl(target_dir) - - # ── AppID-based patch / unpatch / status ─────────────────────────────────────── + return self._patcher.manual_unpatch_directory(directory) async def get_game_status(self, appid: str) -> dict: - try: - game_info = self._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, - "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"], - "install_found": False, - "patched": False, - "dll_name": None, - "target_dir": None, - "fsr4_variant": None, - "fsr4_variant_label": None, - "message": "Game install directory not found.", - } - marker = self._find_marker(install_root) - if not marker: - return { - "status": "success", - "appid": str(appid), - "name": game_info["name"], - "install_found": True, - "patched": False, - "dll_name": None, - "target_dir": None, - "fsr4_variant": None, - "fsr4_variant_label": None, - "message": "Not patched.", - } - metadata = self._read_marker(marker) - dll_name = metadata.get("dll_name", "dxgi.dll") - target_dir = Path(metadata.get("target_dir", str(marker.parent))) - dll_present = (target_dir / dll_name).exists() - upscaler_path = target_dir / FSR4_UPSCALER_FILENAME - upscaler_sha256 = self._file_sha256(upscaler_path) if upscaler_path.exists() else None - detected_variant = self._detect_fsr4_variant(target_dir, upscaler_sha256) - stored_variant = str(metadata.get("fsr4_variant") or "").strip() or None - effective_variant = detected_variant or (stored_variant if stored_variant in FSR4_VARIANTS else None) - effective_label = FSR4_VARIANTS[effective_variant]["label"] if effective_variant else None - return { - "status": "success", - "appid": str(appid), - "name": game_info["name"], - "install_found": True, - "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": effective_variant, - "fsr4_variant_label": effective_label, - "fsr4_upscaler_sha256": upscaler_sha256, - "message": ( - f"Patched using {dll_name}" + (f" with {effective_label}." if effective_label else ".") - if dll_present - else f"Marker found but {dll_name} is missing. Reinstall recommended." - ), - } - except Exception as exc: - decky.logger.error(f"[Framegen] get_game_status failed for {appid}: {exc}") - return {"status": "error", "message": str(exc)} + return self._patcher.get_game_status(appid) async def patch_game( self, @@ -1777,137 +85,9 @@ class Plugin: dll_name: str = "dxgi.dll", current_launch_options: str = "", fsr4_variant: str = DEFAULT_FSR4_VARIANT, + framegen_backend: str = DEFAULT_FRAMEGEN_BACKEND, ) -> dict: - try: - if dll_name not in VALID_DLL_NAMES: - return {"status": "error", "message": f"Invalid proxy DLL name: {dll_name}"} - game_info = self._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._is_game_running(game_info): - return {"status": "error", "message": "Close the game before patching."} - fgmod_path = Path(decky.HOME) / "fgmod" - if not fgmod_path.exists(): - return {"status": "error", "message": "OptiScaler bundle not installed. Run Install first."} - - # Preserve true original launch options across re-patches - original_launch_options = current_launch_options or "" - existing_marker = self._find_marker(install_root) - existing_marker_metadata = self._read_marker(existing_marker) if existing_marker else {} - existing_marker_target_dir = Path( - existing_marker_metadata.get("target_dir", str(existing_marker.parent)) - ) if existing_marker else None - if existing_marker: - stored_opts = str(existing_marker_metadata.get("original_launch_options") or "") - if stored_opts and not self._is_managed_launch_options(stored_opts): - original_launch_options = stored_opts - if self._is_managed_launch_options(original_launch_options): - original_launch_options = "" - - # Auto-detect the right directory to patch - target_dir, target_exe = self._guess_patch_target(game_info) - decky.logger.info(f"[Framegen] patch_game: appid={appid} dll={dll_name} target={target_dir} exe={target_exe}") - - allow_managed_support_cleanup = bool( - existing_marker and existing_marker_target_dir == target_dir - ) or (target_dir / MARKER_FILENAME).exists() - result = self._manual_patch_directory_impl( - target_dir, - dll_name, - fsr4_variant, - allow_managed_support_cleanup=allow_managed_support_cleanup, - ) - if result["status"] != "success": - return result - - backed_up = [dll for dll in dict.fromkeys(RESTORABLE_BACKUP_FILES) if (target_dir / f"{dll}.b").exists()] - marker_path = target_dir / MARKER_FILENAME - self._write_marker( - marker_path, - appid=str(appid), - game_name=game_info["name"], - dll_name=dll_name, - target_dir=target_dir, - original_launch_options=original_launch_options, - backed_up_files=backed_up, - optiscaler_version=result.get("optiscaler_version"), - fsr4_variant=result.get("fsr4_variant"), - fsr4_upscaler_sha256=result.get("fsr4_upscaler_sha256"), - ) - - if existing_marker and existing_marker != marker_path: - try: - existing_marker.unlink() - except Exception: - pass - - managed_launch_options = self._build_managed_launch_options(dll_name) - decky.logger.info(f"[Framegen] patch_game success: appid={appid} launch_options={managed_launch_options}") - return { - "status": "success", - "appid": str(appid), - "name": game_info["name"], - "dll_name": dll_name, - "target_dir": str(target_dir), - "launch_options": managed_launch_options, - "original_launch_options": original_launch_options, - "optiscaler_version": result.get("optiscaler_version"), - "fsr4_variant": result.get("fsr4_variant"), - "fsr4_variant_label": result.get("fsr4_variant_label"), - "fsr4_upscaler_sha256": result.get("fsr4_upscaler_sha256"), - "message": ( - f"Patched {game_info['name']} using {dll_name} " - f"with {result.get('fsr4_variant_label', FSR4_VARIANTS[self._normalize_fsr4_variant(fsr4_variant)]['label'])}." - ), - } - except Exception as exc: - decky.logger.error(f"[Framegen] patch_game failed for {appid}: {exc}") - return {"status": "error", "message": str(exc)} + return self._patcher.patch_game(appid, dll_name, current_launch_options, fsr4_variant, framegen_backend) async def unpatch_game(self, appid: str) -> dict: - try: - game_info = self._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._is_game_running(game_info): - return {"status": "error", "message": "Close the game before unpatching."} - marker = self._find_marker(install_root) - 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 = Path(metadata.get("target_dir", str(marker.parent))) - original_launch_options = str(metadata.get("original_launch_options") or "") - self._manual_unpatch_directory_impl(target_dir) - try: - marker.unlink() - except FileNotFoundError: - pass - decky.logger.info(f"[Framegen] unpatch_game success: appid={appid} target={target_dir}") - return { - "status": "success", - "appid": str(appid), - "name": game_info["name"], - "launch_options": original_launch_options, - "message": f"Unpatched {game_info['name']}.", - } - except Exception as exc: - decky.logger.error(f"[Framegen] unpatch_game failed for {appid}: {exc}") - return {"status": "error", "message": str(exc)} + return self._patcher.unpatch_game(appid) diff --git a/package.json b/package.json index d0c04a4..8b982a0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "rollup -c", "watch": "rollup -c -w", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "python3 -m unittest discover -s tests -v" }, "repository": { "type": "git", @@ -90,4 +90,3 @@ } ] } - diff --git a/src/api/index.ts b/src/api/index.ts index b1bf0b8..4dda43c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,7 @@ import { callable } from "@decky/api"; export const runInstallFGMod = callable< - [selected_default_variant?: string], + [selected_default_variant?: string, framegen_backend?: string], { status: string; message?: string; @@ -9,6 +9,8 @@ export const runInstallFGMod = callable< version?: string; selected_default_variant?: string; selected_default_variant_label?: string; + framegen_backend?: string; + framegen_backend_label?: string; } >("run_install_fgmod"); @@ -29,6 +31,17 @@ export const setDefaultFsr4Variant = callable< } >("set_default_fsr4_variant"); +export const setFramegenBackend = callable< + [framegen_backend?: string], + { + status: string; + message?: string; + output?: string; + framegen_backend?: string; + framegen_backend_label?: string; + } +>("set_framegen_backend"); + export const checkFGModPath = callable< [], { @@ -36,6 +49,8 @@ export const checkFGModPath = callable< version?: string | null; selected_fsr4_variant?: string | null; selected_fsr4_variant_label?: string | null; + framegen_backend?: string | null; + framegen_backend_label?: string | null; install_manifest_present?: boolean; } >("check_fgmod_path"); @@ -53,7 +68,7 @@ export const getPathDefaults = callable< >("get_path_defaults"); export const runManualPatch = callable< - [string, string, string], + [string, string, string, string], { status: string; message?: string; @@ -62,6 +77,7 @@ export const runManualPatch = callable< fsr4_variant_label?: string; fsr4_upscaler_sha256?: string; optiscaler_version?: string | null; + framegen_backend?: string; } >("manual_patch_directory"); @@ -86,11 +102,13 @@ export const getGameStatus = callable< fsr4_variant?: string | null; fsr4_variant_label?: string | null; fsr4_upscaler_sha256?: string | null; + framegen_backend?: string | null; + framegen_backend_label?: string | null; } >("get_game_status"); export const patchGame = callable< - [appid: string, dll_name: string, current_launch_options: string, fsr4_variant: string], + [appid: string, dll_name: string, current_launch_options: string, fsr4_variant: string, framegen_backend: string], { status: string; message?: string; @@ -104,6 +122,7 @@ export const patchGame = callable< fsr4_variant?: string; fsr4_variant_label?: string; fsr4_upscaler_sha256?: string; + framegen_backend?: string; } >("patch_game"); diff --git a/src/components/CustomPathOverride.tsx b/src/components/CustomPathOverride.tsx index af47735..fb7cd05 100644 --- a/src/components/CustomPathOverride.tsx +++ b/src/components/CustomPathOverride.tsx @@ -38,6 +38,7 @@ interface ManualPatchControlsProps { onManualModeChange?: (enabled: boolean) => void; dllName: string; fsr4Variant: string; + framegenBackend: string; } interface PickerState { @@ -58,7 +59,7 @@ const formatResultMessage = (result: ApiResponse | null) => { return result.message || result.output || "Operation failed."; }; -export const ManualPatchControls = ({ isAvailable, onManualModeChange, dllName, fsr4Variant }: ManualPatchControlsProps) => { +export const ManualPatchControls = ({ isAvailable, onManualModeChange, dllName, fsr4Variant, framegenBackend }: ManualPatchControlsProps) => { const [isEnabled, setEnabled] = useState(false); const [defaults, setDefaults] = useState<PathDefaults>(INITIAL_DEFAULTS); const [pickerState, setPickerState] = useState<PickerState>(INITIAL_PICKER_STATE); @@ -160,14 +161,14 @@ export const ManualPatchControls = ({ isAvailable, onManualModeChange, dllName, if (!selectedPath) return; const setBusy = action === "patch" ? setIsPatching : setIsUnpatching; - setLastOperation(action); + setLastOperation(action); setBusy(true); setOperationResult(null); try { const response = action === "patch" - ? await runManualPatch(selectedPath, dllName, fsr4Variant) + ? await runManualPatch(selectedPath, dllName, fsr4Variant, framegenBackend) : await runManualUnpatch(selectedPath); setOperationResult(response ?? { status: "error", message: "No response from backend." }); } catch (err) { @@ -179,7 +180,7 @@ export const ManualPatchControls = ({ isAvailable, onManualModeChange, dllName, setBusy(false); } }, - [selectedPath, dllName, fsr4Variant] + [selectedPath, dllName, fsr4Variant, framegenBackend] ); const handleToggle = (value: boolean) => { @@ -304,4 +305,4 @@ export const ManualPatchControls = ({ isAvailable, onManualModeChange, dllName, )} </> ); -};
\ No newline at end of file +}; diff --git a/src/components/OptiScalerControls.tsx b/src/components/OptiScalerControls.tsx index a8b0863..d6c4c77 100644 --- a/src/components/OptiScalerControls.tsx +++ b/src/components/OptiScalerControls.tsx @@ -1,9 +1,17 @@ import { useState, useEffect } from "react"; import { DropdownItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui"; -import { runInstallFGMod, runUninstallFGMod, setDefaultFsr4Variant } from "../api"; -import { OperationResult } from "./ResultDisplay"; +import { runInstallFGMod, runUninstallFGMod, setDefaultFsr4Variant, setFramegenBackend } from "../api"; +import { ResultDisplay, type OperationResult } from "./ResultDisplay"; import { createAutoCleanupTimer } from "../utils"; -import { TIMEOUTS, PROXY_DLL_OPTIONS, DEFAULT_PROXY_DLL, FSR4_VARIANT_OPTIONS, DEFAULT_FSR4_VARIANT } from "../utils/constants"; +import { + TIMEOUTS, + PROXY_DLL_OPTIONS, + DEFAULT_PROXY_DLL, + FSR4_VARIANT_OPTIONS, + DEFAULT_FSR4_VARIANT, + FRAMEGEN_BACKEND_OPTIONS, + DEFAULT_FRAMEGEN_BACKEND, +} from "../utils/constants"; import { InstallationStatus } from "./InstallationStatus"; import { OptiScalerHeader } from "./OptiScalerHeader"; import { ClipboardCommands } from "./ClipboardCommands"; @@ -18,6 +26,8 @@ interface FgmodInfo { version?: string | null; selected_fsr4_variant?: string | null; selected_fsr4_variant_label?: string | null; + framegen_backend?: string | null; + framegen_backend_label?: string | null; install_manifest_present?: boolean; } @@ -37,19 +47,22 @@ export function OptiScalerControls({ pathExists, setPathExists, fgmodInfo }: Opt const [dllName, setDllName] = useState<string>(DEFAULT_PROXY_DLL); const [fsr4Variant, setFsr4Variant] = useState<string>(DEFAULT_FSR4_VARIANT); const [fsr4VariantTouched, setFsr4VariantTouched] = useState(false); + const [framegenBackend, setFramegenBackendValue] = useState<string>(DEFAULT_FRAMEGEN_BACKEND); + const [framegenBackendTouched, setFramegenBackendTouched] = useState(false); const [switchingVariant, setSwitchingVariant] = useState(false); + const [switchingBackend, setSwitchingBackend] = useState(false); useEffect(() => { if (installResult) { return createAutoCleanupTimer(() => setInstallResult(null), TIMEOUTS.resultDisplay); } - return () => {}; // Ensure a cleanup function is always returned + return undefined; }, [installResult]); useEffect(() => { if (uninstallResult) { return createAutoCleanupTimer(() => setUninstallResult(null), TIMEOUTS.resultDisplay); } - return () => {}; // Ensure a cleanup function is always returned + return undefined; }, [uninstallResult]); useEffect(() => { @@ -59,16 +72,26 @@ export function OptiScalerControls({ pathExists, setPathExists, fgmodInfo }: Opt } }, [fgmodInfo?.selected_fsr4_variant, fsr4VariantTouched]); + useEffect(() => { + const installedBackend = fgmodInfo?.framegen_backend; + if (!framegenBackendTouched && installedBackend && FRAMEGEN_BACKEND_OPTIONS.some((option) => option.value === installedBackend)) { + setFramegenBackendValue(installedBackend); + } + }, [fgmodInfo?.framegen_backend, framegenBackendTouched]); + const handleInstallClick = async () => { try { setInstalling(true); - const result = await runInstallFGMod(fsr4Variant); + const result = await runInstallFGMod(fsr4Variant, framegenBackend); setInstallResult(result); if (result.status === "success") { setPathExists(true); + setFsr4VariantTouched(false); + setFramegenBackendTouched(false); } } catch (e) { console.error(e); + setInstallResult({ status: "error", message: e instanceof Error ? e.message : "Installation failed." }); } finally { setInstalling(false); } @@ -84,11 +107,35 @@ export function OptiScalerControls({ pathExists, setPathExists, fgmodInfo }: Opt } } catch (e) { console.error(e); + setUninstallResult({ status: "error", message: e instanceof Error ? e.message : "Uninstall failed." }); } finally { setUninstalling(false); } }; + const handleFramegenBackendChange = async (nextBackend: string) => { + const previousBackend = framegenBackend; + setFramegenBackendValue(nextBackend); + setFramegenBackendTouched(true); + if (pathExists !== true) return; + + try { + setSwitchingBackend(true); + const result = await setFramegenBackend(nextBackend); + if (result.status !== "success") { + throw new Error(result.message || result.output || "Failed to update the frame-generation backend."); + } + setFramegenBackendValue(result.framegen_backend || nextBackend); + setFramegenBackendTouched(false); + } catch (error) { + console.error(error); + setFramegenBackendValue(previousBackend); + setFramegenBackendTouched(false); + } finally { + setSwitchingBackend(false); + } + }; + const handleFsr4VariantChange = async (nextVariant: string) => { const previousVariant = fsr4Variant; setFsr4Variant(nextVariant); @@ -132,13 +179,28 @@ export function OptiScalerControls({ pathExists, setPathExists, fgmodInfo }: Opt menuLabel="Default FSR4 runtime" selectedOption={fsr4Variant} rgOptions={FSR4_VARIANT_OPTIONS.map((option) => ({ data: option.value, label: option.label }))} - disabled={installing || uninstalling || switchingVariant} + disabled={installing || uninstalling || switchingVariant || switchingBackend} onChange={(option) => { void handleFsr4VariantChange(String(option.data)); }} /> </PanelSectionRow> + <PanelSectionRow> + <DropdownItem + layout="below" + label="Frame generation backend" + description={FRAMEGEN_BACKEND_OPTIONS.find((option) => option.value === framegenBackend)?.hint} + menuLabel="Frame generation backend" + selectedOption={framegenBackend} + rgOptions={FRAMEGEN_BACKEND_OPTIONS.map((option) => ({ data: option.value, label: option.label }))} + disabled={installing || uninstalling || switchingBackend} + onChange={(option) => { + void handleFramegenBackendChange(String(option.data)); + }} + /> + </PanelSectionRow> + {pathExists === true && fgmodInfo?.version && installedVariantLabel && ( <PanelSectionRow> <Field label="Installed bundle" description={`OptiScaler ${fgmodInfo.version}`}> @@ -162,7 +224,7 @@ export function OptiScalerControls({ pathExists, setPathExists, fgmodInfo }: Opt )} {pathExists === true && ( - <SteamGamePatcher dllName={dllName} fsr4Variant={fsr4Variant} /> + <SteamGamePatcher dllName={dllName} fsr4Variant={fsr4Variant} framegenBackend={framegenBackend} /> )} <ClipboardCommands pathExists={pathExists} dllName={dllName} /> @@ -192,8 +254,12 @@ export function OptiScalerControls({ pathExists, setPathExists, fgmodInfo }: Opt onManualModeChange={setAdvancedModeEnabled} dllName={dllName} fsr4Variant={fsr4Variant} + framegenBackend={framegenBackend} /> + <ResultDisplay result={installResult} /> + <ResultDisplay result={uninstallResult} /> + {!advancedModeEnabled && ( <InstructionCard pathExists={pathExists} /> )} diff --git a/src/components/SteamGamePatcher.tsx b/src/components/SteamGamePatcher.tsx index e8ac3d2..cd8fe3c 100644 --- a/src/components/SteamGamePatcher.tsx +++ b/src/components/SteamGamePatcher.tsx @@ -4,12 +4,24 @@ import { toaster } from "@decky/api"; import { listInstalledGames, getGameStatus, patchGame, unpatchGame } from "../api"; import { FSR4_VARIANT_OPTIONS } from "../utils/constants"; -// ─── SteamClient helpers ───────────────────────────────────────────────────── +type SteamAppsApi = { + RegisterForAppDetails?: ( + appId: number, + callback: (details: { strLaunchOptions?: string }) => void + ) => { unregister: () => void }; + SetAppLaunchOptions?: (appId: number, options: string) => void; +}; + +const getSteamApps = (): SteamAppsApi | undefined => { + if (typeof SteamClient === "undefined") return undefined; + return SteamClient.Apps as unknown as SteamAppsApi; +}; const getAppLaunchOptions = (appId: number): Promise<string> => new Promise((resolve, reject) => { - if (typeof SteamClient === "undefined" || !SteamClient?.Apps?.RegisterForAppDetails) { - resolve(""); + const apps = getSteamApps(); + if (!apps || typeof apps.RegisterForAppDetails !== "function" || typeof apps.SetAppLaunchOptions !== "function") { + reject(new Error("Steam launch-options API is unavailable; patching was not started.")); return; } let settled = false; @@ -20,7 +32,7 @@ const getAppLaunchOptions = (appId: number): Promise<string> => unregister(); reject(new Error("Timed out reading launch options.")); }, 5000); - const registration = SteamClient.Apps.RegisterForAppDetails( + const registration = apps.RegisterForAppDetails( appId, (details: { strLaunchOptions?: string }) => { if (settled) return; @@ -31,15 +43,25 @@ const getAppLaunchOptions = (appId: number): Promise<string> => } ); unregister = registration.unregister; + if (settled) unregister(); }); const setAppLaunchOptions = (appId: number, options: string): void => { - if (typeof SteamClient !== "undefined" && SteamClient?.Apps?.SetAppLaunchOptions) { - SteamClient.Apps.SetAppLaunchOptions(appId, options); + const apps = getSteamApps(); + if (!apps || typeof apps.SetAppLaunchOptions !== "function") { + throw new Error("Steam launch-options API is unavailable; launch options were not changed."); } + apps.SetAppLaunchOptions(appId, options); }; -// ─── Types ─────────────────────────────────────────────────────────────────── +const canManageLaunchOptions = (): boolean => { + const apps = getSteamApps(); + return Boolean( + apps && + typeof apps.RegisterForAppDetails === "function" && + typeof apps.SetAppLaunchOptions === "function" + ); +}; type GameEntry = { appid: string; name: string; install_found?: boolean }; @@ -55,20 +77,19 @@ type GameStatus = { fsr4_variant?: string | null; fsr4_variant_label?: string | null; fsr4_upscaler_sha256?: string | null; + framegen_backend?: string | null; + framegen_backend_label?: string | null; }; -// ─── Module-level state persistence ────────────────────────────────────────── - let lastSelectedAppId = ""; -// ─── Component ─────────────────────────────────────────────────────────────── - interface SteamGamePatcherProps { dllName: string; fsr4Variant: string; + framegenBackend: string; } -export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps) { +export function SteamGamePatcher({ dllName, fsr4Variant, framegenBackend }: SteamGamePatcherProps) { const [games, setGames] = useState<GameEntry[]>([]); const [gamesLoading, setGamesLoading] = useState(true); const [selectedAppId, setSelectedAppId] = useState<string>(() => lastSelectedAppId); @@ -77,8 +98,6 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps const [busyAction, setBusyAction] = useState<"patch" | "unpatch" | null>(null); const [resultMessage, setResultMessage] = useState<string>(""); - // ── Data loaders ─────────────────────────────────────────────────────────── - const loadGames = useCallback(async () => { setGamesLoading(true); try { @@ -136,8 +155,6 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps void loadStatus(selectedAppId); }, [selectedAppId, loadStatus]); - // ── Derived state ────────────────────────────────────────────────────────── - const selectedGame = useMemo( () => games.find((g) => g.appid === selectedAppId) ?? null, [games, selectedAppId] @@ -163,20 +180,16 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps return `Patch with ${dllName}`; }, [busyAction, dllName, gameStatus, isPatchedWithDifferentDll, selectedGame]); - // ── Actions ──────────────────────────────────────────────────────────────── - const handlePatch = useCallback(async () => { if (!selectedGame || !selectedAppId || busyAction) return; setBusyAction("patch"); setResultMessage(""); try { - let currentLaunchOptions = ""; - try { - currentLaunchOptions = await getAppLaunchOptions(Number(selectedAppId)); - } catch { - // non-fatal: proceed without current launch options + if (!canManageLaunchOptions()) { + throw new Error("Steam launch-options API is unavailable; patching was not started."); } - const result = await patchGame(selectedAppId, dllName, currentLaunchOptions, fsr4Variant); + const currentLaunchOptions = await getAppLaunchOptions(Number(selectedAppId)); + const result = await patchGame(selectedAppId, dllName, currentLaunchOptions, fsr4Variant, framegenBackend); if (result.status !== "success") throw new Error(result.message || "Patch failed."); setAppLaunchOptions(Number(selectedAppId), result.launch_options || ""); const msg = result.message || `Patched ${selectedGame.name}.`; @@ -190,13 +203,16 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps } finally { setBusyAction(null); } - }, [busyAction, dllName, fsr4Variant, loadStatus, selectedAppId, selectedGame]); + }, [busyAction, dllName, framegenBackend, fsr4Variant, loadStatus, selectedAppId, selectedGame]); const handleUnpatch = useCallback(async () => { if (!selectedGame || !selectedAppId || busyAction) return; setBusyAction("unpatch"); setResultMessage(""); try { + if (!canManageLaunchOptions()) { + throw new Error("Steam launch-options API is unavailable; unpatching was not started."); + } const result = await unpatchGame(selectedAppId); if (result.status !== "success") throw new Error(result.message || "Unpatch failed."); setAppLaunchOptions(Number(selectedAppId), result.launch_options || ""); @@ -213,8 +229,6 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps } }, [busyAction, loadStatus, selectedAppId, selectedGame]); - // ── Status display ───────────────────────────────────────────────────────── - const statusDisplay = useMemo(() => { if (!selectedGame) return { text: "—", color: undefined as string | undefined }; if (statusLoading) return { text: "Loading...", color: undefined }; @@ -230,8 +244,6 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps const focusableFieldProps = { focusable: true, highlightOnFocus: true } as const; - // ── Render ───────────────────────────────────────────────────────────────── - return ( <> <PanelSectionRow> @@ -278,6 +290,14 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps </PanelSectionRow> <PanelSectionRow> + <Field {...focusableFieldProps} label="Frame generation backend"> + {gameStatus?.patched + ? (gameStatus?.framegen_backend_label || gameStatus?.framegen_backend || "Unknown") + : `Will patch with ${framegenBackend}`} + </Field> + </PanelSectionRow> + + <PanelSectionRow> <ButtonItem layout="below" disabled={!canPatch} onClick={handlePatch}> {patchButtonLabel} </ButtonItem> diff --git a/src/index.tsx b/src/index.tsx index 4a9a9f6..6d598fc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,7 +2,6 @@ import { definePlugin } from "@decky/api"; import { MdOutlineAutoAwesomeMotion } from "react-icons/md"; import { useState, useEffect } from "react"; import { OptiScalerControls } from "./components"; -// import { InstalledGamesSection } from "./components/InstalledGamesSection"; import { checkFGModPath } from "./api"; import { safeAsyncOperation } from "./utils"; import { TIMEOUTS } from "./utils/constants"; @@ -12,6 +11,8 @@ type FgmodInfo = { version?: string | null; selected_fsr4_variant?: string | null; selected_fsr4_variant_label?: string | null; + framegen_backend?: string | null; + framegen_backend_label?: string | null; install_manifest_present?: boolean; }; @@ -43,11 +44,6 @@ function MainContent() { setPathExists={setPathExists} fgmodInfo={fgmodInfo} /> - {pathExists === true ? ( - <> - {/* <InstalledGamesSection /> */} - </> - ) : null} </> ); } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 3af9874..2568dd4 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -85,6 +85,22 @@ export const FSR4_VARIANT_OPTIONS = [ export type Fsr4VariantValue = typeof FSR4_VARIANT_OPTIONS[number]["value"]; export const DEFAULT_FSR4_VARIANT: Fsr4VariantValue = "rdna23-int8"; +export const FRAMEGEN_BACKEND_OPTIONS = [ + { + value: "auto", + label: "OptiScaler automatic selection", + hint: "Let OptiScaler select the compatible frame-generation path.", + }, + { + value: "nukems", + label: "Nukem's DLSSG → FSR3", + hint: "Use Nukem's DLSSG-to-FSR3 path for games that need it.", + }, +] as const; + +export type FramegenBackendValue = typeof FRAMEGEN_BACKEND_OPTIONS[number]["value"]; +export const DEFAULT_FRAMEGEN_BACKEND: FramegenBackendValue = "auto"; + // Common timeout values export const TIMEOUTS = { resultDisplay: 5000, // 5 seconds diff --git a/tests/test_backend.py b/tests/test_backend.py new file mode 100644 index 0000000..69165f1 --- /dev/null +++ b/tests/test_backend.py @@ -0,0 +1,104 @@ +import tempfile +import unittest +from pathlib import Path + +from backend import config +from backend.bundle import BundleManager +from backend.patcher import PatchManager +from backend.steam import SteamLibrary + + +class BackendSafetyTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + root = Path(self.temp_dir.name) + self.home = root / "home" + self.plugin_dir = root / "plugin" + self.home.mkdir() + self.plugin_dir.mkdir() + + bundle_path = self.home / "fgmod" + (bundle_path / "renames").mkdir(parents=True) + (bundle_path / "plugins").mkdir() + (bundle_path / "fsr4-rdna2-3").mkdir() + (bundle_path / "OptiScaler.dll").write_bytes(b"optiscaler") + (bundle_path / "renames" / "dxgi.dll").write_bytes(b"managed-proxy") + (bundle_path / "OptiScaler.ini").write_text("FGInput=auto\nFGOutput=auto\n", encoding="utf-8") + (bundle_path / "plugins" / "OptiPatcher.asi").write_bytes(b"plugin") + (bundle_path / "fsr4-rdna2-3" / "amd_fidelityfx_upscaler_dx12.dll").write_bytes(b"upscaler") + + logger = lambda _message: None + bundle = BundleManager(self.home, self.plugin_dir, logger) + self.manager = PatchManager(self.home, bundle, SteamLibrary(self.home, logger), logger) + self.target = root / "game" + (self.target / "plugins").mkdir(parents=True) + (self.target / "plugins" / "keep.txt").write_text("user-owned", encoding="utf-8") + (self.target / "dxgi.dll").write_bytes(b"original-game-proxy") + (self.target / "OptiScaler.ini").write_text("FGInput=auto\nFGOutput=auto\nUserSetting=true\n", encoding="utf-8") + + def tearDown(self): + self.temp_dir.cleanup() + + def test_patch_and_unpatch_preserve_unowned_files(self): + result = self.manager.manual_patch_directory(str(self.target), "dxgi.dll", "rdna23-int8", "auto") + self.assertEqual(result["status"], "success") + self.assertTrue((self.target / "FRAMEGEN_PATCH").exists()) + self.assertTrue((self.target / "dxgi.dll.b").exists()) + self.assertTrue((self.target / "plugins" / "keep.txt").exists()) + + cleanup = self.manager.manual_unpatch_directory(str(self.target)) + self.assertEqual(cleanup["status"], "success") + self.assertFalse((self.target / "FRAMEGEN_PATCH").exists()) + self.assertEqual((self.target / "dxgi.dll").read_bytes(), b"original-game-proxy") + self.assertEqual((self.target / "plugins" / "keep.txt").read_text(encoding="utf-8"), "user-owned") + self.assertEqual( + (self.target / "OptiScaler.ini").read_text(encoding="utf-8"), + "FGInput=auto\nFGOutput=auto\nUserSetting=true\n", + ) + self.assertFalse((self.target / "plugins" / "OptiPatcher.asi").exists()) + + def test_unpatch_refuses_modified_managed_file_and_keeps_marker(self): + result = self.manager.manual_patch_directory(str(self.target), "dxgi.dll", "rdna23-int8", "nukems") + self.assertEqual(result["status"], "success") + (self.target / "dxgi.dll").write_bytes(b"user-modified") + + cleanup = self.manager.manual_unpatch_directory(str(self.target)) + self.assertEqual(cleanup["status"], "error") + self.assertTrue((self.target / "FRAMEGEN_PATCH").exists()) + self.assertEqual((self.target / "dxgi.dll").read_bytes(), b"user-modified") + self.assertTrue((self.target / "plugins" / "keep.txt").exists()) + + def test_appid_unpatch_propagates_cleanup_failure(self): + class FakeSteam: + def game_record(self, appid): + return {"appid": appid, "name": "Test Game", "install_path": str(self_target)} + + def is_game_running(self, _game_info): + return False + + def guess_patch_target(self, _game_info): + return self_target, None + + self_target = self.target + self.manager.steam = FakeSteam() + result = self.manager.patch_game("123", "dxgi.dll", "", "rdna23-int8", "auto") + self.assertEqual(result["status"], "success") + (self.target / "dxgi.dll").write_bytes(b"modified after patch") + + cleanup = self.manager.unpatch_game("123") + self.assertEqual(cleanup["status"], "error") + self.assertTrue((self.target / "FRAMEGEN_PATCH").exists()) + + def test_backend_configuration_is_explicit(self): + ini_file = self.target / "backend.ini" + ini_file.write_text("FGInput=auto\nFGOutput=auto\n", encoding="utf-8") + self.assertTrue(config.configure(ini_file, "nukems")) + self.assertIn("FGInput=nukems", ini_file.read_text(encoding="utf-8")) + self.assertIn("FGOutput=nukems", ini_file.read_text(encoding="utf-8")) + self.assertTrue(config.configure(ini_file, "auto")) + self.assertIn("FGInput=auto", ini_file.read_text(encoding="utf-8")) + self.assertIn("FGOutput=auto", ini_file.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() |
