summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/__init__.py1
-rw-r--r--backend/assets.py184
-rw-r--r--backend/bundle.py522
-rw-r--r--backend/config.py175
-rw-r--r--backend/files.py36
-rw-r--r--backend/patcher.py685
-rw-r--r--backend/steam.py187
-rwxr-xr-xdefaults/assets/fgmod.sh6
-rw-r--r--main.py1908
-rw-r--r--package.json3
-rw-r--r--src/api/index.ts25
-rw-r--r--src/components/CustomPathOverride.tsx11
-rw-r--r--src/components/OptiScalerControls.tsx82
-rw-r--r--src/components/SteamGamePatcher.tsx78
-rw-r--r--src/index.tsx8
-rw-r--r--src/utils/constants.ts16
-rw-r--r--tests/test_backend.py104
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
diff --git a/main.py b/main.py
index b1a8079..c5a3e01 100644
--- a/main.py
+++ b/main.py
@@ -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()