diff options
| author | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-03-20 17:32:05 -0400 |
|---|---|---|
| committer | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-03-20 17:32:05 -0400 |
| commit | ca5db2231b8554d1377dd449f6fb9c736e3d6386 (patch) | |
| tree | 0c3a52bebfa5602a6499b1fcaa1fea5539632c1e /main.py | |
| parent | ef469a8036e3b3f129a753dad4cf04fad3ca92f7 (diff) | |
| download | Decky-Framegen-ca5db2231b8554d1377dd449f6fb9c736e3d6386.tar.gz Decky-Framegen-ca5db2231b8554d1377dd449f6fb9c736e3d6386.zip | |
Implement prefix-managed OptiScaler runtime
Diffstat (limited to 'main.py')
| -rw-r--r-- | main.py | 904 |
1 files changed, 365 insertions, 539 deletions
@@ -1,31 +1,25 @@ import decky import os -import subprocess -import json -import shutil import re +import shutil +import subprocess from pathlib import Path # Toggle to enable overwriting the upscaler DLL from the static remote binary. -# Set to False or comment out this constant to skip the overwrite by default. UPSCALER_OVERWRITE_ENABLED = True -INJECTOR_FILENAMES = [ - "dxgi.dll", - "winmm.dll", - "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", - "amd_fidelityfx_upscaler_dx12.dll", - "amd_fidelityfx_vk.dll", +BUNDLE_DIRNAME = "fgmod" +MANAGED_DIRNAME = "optiscaler-managed" +MANIFEST_FILENAME = "manifest.env" + +SUPPORTED_PROXIES = [ + "dxgi", + "winmm", + "dbghelp", + "version", + "wininet", + "winhttp", + "d3d12", ] SUPPORT_FILES = [ @@ -43,6 +37,15 @@ SUPPORT_FILES = [ "fakenvapi.ini", ] +REQUIRED_BUNDLE_FILES = [ + "OptiScaler.dll", + "OptiScaler.ini", + *SUPPORT_FILES, + "fgmod", + "fgmod-uninstaller.sh", + "update-optiscaler-config.py", +] + LEGACY_FILES = [ "dlssg_to_fsr3.ini", "dlssg_to_fsr3.log", @@ -56,274 +59,334 @@ LEGACY_FILES = [ "_nvngx.dll", "dlssg_to_fsr3_amd_is_better-3.0.dll", "OptiScaler.asi", - "OptiScaler.ini", "OptiScaler.log", ] + class Plugin: 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""" + + def _home_path(self) -> Path: 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""" + return Path(decky.HOME) + except TypeError: + return Path(str(decky.HOME)) + + def _bundle_path(self) -> Path: + return self._home_path() / BUNDLE_DIRNAME + + def _steam_root_candidates(self) -> list[Path]: + home = self._home_path() + candidates = [ + home / ".local" / "share" / "Steam", + home / ".steam" / "steam", + ] + + unique = [] + seen = 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() + + 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 file: + for line in file: + 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"Failed to parse {library_file}: {exc}") + + return library_paths + + def _compatdata_dirs_for_appid(self, appid: str) -> list[Path]: + matches = [] + for library in self._steam_library_paths(): + compatdata_dir = library / "steamapps" / "compatdata" / str(appid) + if compatdata_dir.exists(): + matches.append(compatdata_dir) + return matches + + def _parse_manifest_env(self, manifest_path: Path) -> dict: + data = {} + if not manifest_path.exists(): + return data + 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 _disable_hq_font_auto(self, ini_file): - """Disable the new HQ font auto mode to avoid missing font assertions on Wine/Proton.""" + with open(manifest_path, "r", encoding="utf-8", errors="replace") as manifest: + for raw_line in manifest: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + data[key.strip()] = value.strip().strip('"') + except Exception as exc: + decky.logger.error(f"Failed to parse manifest {manifest_path}: {exc}") + + return data + + def _disable_hq_font_auto(self, ini_file: Path) -> bool: 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: + with open(ini_file, "r", encoding="utf-8", errors="replace") as f: content = f.read() - updated_content = re.sub(r'UseHQFont\s*=\s*auto', 'UseHQFont=false', content) + updated_content = re.sub(r"UseHQFont\s*=\s*auto", "UseHQFont=false", content) if updated_content != content: - with open(ini_file, 'w') as f: + with open(ini_file, "w", encoding="utf-8") 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}") + except Exception as exc: + decky.logger.error(f"Failed to update HQ font setting in OptiScaler.ini: {exc}") return False - def _modify_optiscaler_ini(self, ini_file): + def _modify_optiscaler_ini(self, ini_file: Path) -> bool: """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 FGType=auto with FGType=nukems - updated_content = re.sub(r'FGType\s*=\s*auto', 'FGType=nukems', 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) - - # Replace Path=auto with Path=plugins - updated_content = re.sub(r'Path\s*=\s*auto', 'Path=plugins', 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 FGType=nukems, Fsr4Update=true, LoadAsiPlugins=true, Path=plugins, UseHQFont=false") - return True - else: + if not ini_file.exists(): 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}") + + with open(ini_file, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + + updated_content = re.sub(r"FGType\s*=\s*auto", "FGType=nukems", content) + updated_content = re.sub(r"Fsr4Update\s*=\s*auto", "Fsr4Update=true", updated_content) + updated_content = re.sub(r"LoadAsiPlugins\s*=\s*auto", "LoadAsiPlugins=true", updated_content) + updated_content = re.sub(r"Path\s*=\s*auto", "Path=plugins", updated_content) + updated_content = re.sub(r"UseHQFont\s*=\s*auto", "UseHQFont=false", updated_content) + + with open(ini_file, "w", encoding="utf-8") as f: + f.write(updated_content) + + decky.logger.info( + "Modified OptiScaler.ini to set FGType=nukems, Fsr4Update=true, LoadAsiPlugins=true, Path=plugins, UseHQFont=false" + ) + return True + except Exception as exc: + decky.logger.error(f"Failed to modify OptiScaler.ini: {exc}") + return False + + def _create_renamed_copies(self, source_file: Path, renames_dir: Path) -> bool: + try: + renames_dir.mkdir(exist_ok=True) + + rename_files = [f"{proxy}.dll" for proxy in SUPPORTED_PROXIES] + ["OptiScaler.asi"] + if not source_file.exists(): + decky.logger.error(f"Source file {source_file} does not exist") + return False + + 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 + except Exception as exc: + decky.logger.error(f"Failed to create renamed copies: {exc}") return False + def _copy_launcher_scripts(self, assets_dir: Path, extract_path: Path) -> bool: + try: + launcher_assets = { + "fgmod.sh": "fgmod", + "fgmod-uninstaller.sh": "fgmod-uninstaller.sh", + "update-optiscaler-config.py": "update-optiscaler-config.py", + } + + for asset_name, dest_name in launcher_assets.items(): + source = assets_dir / asset_name + dest = extract_path / dest_name + if not source.exists(): + decky.logger.error(f"Launcher asset missing: {source}") + return False + shutil.copy2(source, dest) + dest.chmod(0o755) + decky.logger.info(f"Copied launcher asset {source} to {dest}") + + return True + except Exception as exc: + decky.logger.error(f"Failed to copy launcher scripts: {exc}") + return False + + def _cleanup_prefix(self, compatdata_dir: Path, proxy: str | None = None, remove_managed_root: bool = True) -> dict: + managed_root = compatdata_dir / MANAGED_DIRNAME + manifest_path = managed_root / MANIFEST_FILENAME + manifest = self._parse_manifest_env(manifest_path) + selected_proxy = (proxy or manifest.get("MANAGED_PROXY") or "winmm").replace(".dll", "") + + system32 = compatdata_dir / "pfx" / "drive_c" / "windows" / "system32" + if not system32.exists() and not managed_root.exists(): + return {"status": "success", "message": f"No managed OptiScaler state found for {compatdata_dir.name}"} + + removed = [] + + for filename in ["OptiScaler.ini", *SUPPORT_FILES, *LEGACY_FILES]: + target = system32 / filename + if target.exists(): + try: + if target.is_dir(): + shutil.rmtree(target, ignore_errors=True) + else: + target.unlink() + removed.append(filename) + except Exception as exc: + decky.logger.error(f"Failed removing {target}: {exc}") + + plugins_dir = system32 / "plugins" + if plugins_dir.exists(): + shutil.rmtree(plugins_dir, ignore_errors=True) + removed.append("plugins/") + + proxy_path = system32 / f"{selected_proxy}.dll" + backup_path = system32 / f"{selected_proxy}-original.dll" + if proxy_path.exists(): + try: + proxy_path.unlink() + removed.append(proxy_path.name) + except Exception as exc: + decky.logger.error(f"Failed removing proxy {proxy_path}: {exc}") + + if backup_path.exists(): + try: + shutil.move(backup_path, proxy_path) + removed.append(backup_path.name) + decky.logger.info(f"Restored original proxy {proxy_path.name} in {system32}") + except Exception as exc: + decky.logger.error(f"Failed restoring backup {backup_path}: {exc}") + + if remove_managed_root and managed_root.exists(): + shutil.rmtree(managed_root, ignore_errors=True) + removed.append(str(managed_root)) + + message = f"Cleaned prefix-managed OptiScaler for app {compatdata_dir.name}" + decky.logger.info(f"{message}; removed entries: {removed}") + return {"status": "success", "message": message, "removed": removed} + + def _cleanup_all_managed_prefixes(self) -> list[dict]: + cleanup_results = [] + seen = set() + + for library in self._steam_library_paths(): + compatdata_root = library / "steamapps" / "compatdata" + if not compatdata_root.exists(): + continue + + for managed_root in compatdata_root.glob(f"*/{MANAGED_DIRNAME}"): + compatdata_dir = managed_root.parent + key = str(compatdata_dir) + if key in seen: + continue + seen.add(key) + cleanup_results.append(self._cleanup_prefix(compatdata_dir)) + + return cleanup_results + async def extract_static_optiscaler(self) -> dict: - """Extract OptiScaler from the plugin's bin directory and copy additional files.""" + """Extract OptiScaler from the plugin's bin directory and copy runtime assets.""" try: decky.logger.info("Starting extract_static_optiscaler method") - - # Set up paths + bin_path = Path(decky.DECKY_PLUGIN_DIR) / "bin" - extract_path = Path(decky.HOME) / "fgmod" - - decky.logger.info(f"Bin path: {bin_path}") - decky.logger.info(f"Extract path: {extract_path}") - - # Check if bin directory exists + extract_path = self._bundle_path() + if not bin_path.exists(): decky.logger.error(f"Bin directory does not exist: {bin_path}") return {"status": "error", "message": f"Bin directory not found: {bin_path}"} - - # List files in bin directory for debugging - bin_files = list(bin_path.glob("*")) - decky.logger.info(f"Files in bin directory: {[f.name for f in bin_files]}") - - # Find the OptiScaler archive in the bin directory + optiscaler_archive = None for file in bin_path.glob("*.7z"): - decky.logger.info(f"Checking 7z file: {file.name}") - # Check for both "OptiScaler" and "Optiscaler" (case variations) and exclude BUNDLE files if ("OptiScaler" in file.name or "Optiscaler" in file.name) and "BUNDLE" not in file.name: optiscaler_archive = file - decky.logger.info(f"Found OptiScaler archive: {file.name}") break - + if not optiscaler_archive: decky.logger.error("OptiScaler archive not found in plugin bin directory") return {"status": "error", "message": "OptiScaler archive not found in plugin bin directory"} - - decky.logger.info(f"Using archive: {optiscaler_archive}") - - # Clean up existing directory + if extract_path.exists(): - decky.logger.info(f"Removing existing directory: {extract_path}") shutil.rmtree(extract_path) - - extract_path.mkdir(exist_ok=True) - decky.logger.info(f"Created extract directory: {extract_path}") - - decky.logger.info(f"Extracting {optiscaler_archive.name} to {extract_path}") - - # Extract the 7z file - extract_cmd = [ - "7z", - "x", - "-y", - "-o" + str(extract_path), - str(optiscaler_archive) - ] - - decky.logger.info(f"Running extraction command: {' '.join(extract_cmd)}") - - # Create a clean environment to avoid PyInstaller issues + extract_path.mkdir(parents=True, exist_ok=True) + + extract_cmd = ["7z", "x", "-y", "-o" + str(extract_path), str(optiscaler_archive)] clean_env = os.environ.copy() clean_env["LD_LIBRARY_PATH"] = "" - - decky.logger.info("Starting subprocess.run for extraction") + extract_result = subprocess.run( extract_cmd, capture_output=True, text=True, check=False, - env=clean_env + env=clean_env, ) - - decky.logger.info(f"Extraction completed with return code: {extract_result.returncode}") - decky.logger.info(f"Extraction stdout: {extract_result.stdout}") - if extract_result.stderr: - decky.logger.info(f"Extraction stderr: {extract_result.stderr}") - + if extract_result.returncode != 0: decky.logger.error(f"Extraction failed: {extract_result.stderr}") return { "status": "error", - "message": f"Failed to extract OptiScaler archive: {extract_result.stderr}" + "message": f"Failed to extract OptiScaler archive: {extract_result.stderr}", } - - # Copy additional individual files from bin directory - # Note: v0.9.0-pre3+ includes dlssg_to_fsr3_amd_is_better.dll, fakenvapi.dll, and fakenvapi.ini in the 7z - # Only copy files that aren't already in the archive (separate remote binaries) + additional_files = [ - "nvngx.dll", # nvidia dll from streamline sdk, not bundled in opti - "OptiPatcher_v0.30.asi" # ASI plugin for OptiScaler spoofing + "nvngx.dll", + "OptiPatcher_v0.30.asi", ] - - decky.logger.info("Starting additional files copy") + for file_name in additional_files: src_file = bin_path / file_name dest_file = extract_path / file_name - - decky.logger.info(f"Checking for additional file: {file_name} at {src_file}") - if src_file.exists(): - shutil.copy2(src_file, dest_file) - decky.logger.info(f"Copied additional file: {file_name}") - else: - decky.logger.warning(f"Additional file not found: {file_name}") + if not src_file.exists(): return { "status": "error", - "message": f"Required file {file_name} not found in plugin bin directory" + "message": f"Required file {file_name} not found in plugin bin directory", } - - decky.logger.info("Creating renamed copies of OptiScaler.dll") - # Create renamed copies of OptiScaler.dll + shutil.copy2(src_file, dest_file) + source_file = extract_path / "OptiScaler.dll" renames_dir = extract_path / "renames" self._create_renamed_copies(source_file, renames_dir) - - decky.logger.info("Copying launcher scripts") - # Copy launcher scripts from assets + assets_dir = Path(decky.DECKY_PLUGIN_DIR) / "assets" - self._copy_launcher_scripts(assets_dir, extract_path) + if not self._copy_launcher_scripts(assets_dir, extract_path): + return {"status": "error", "message": "Failed to install runtime launcher scripts"} + + plugins_dir = extract_path / "plugins" + plugins_dir.mkdir(exist_ok=True) + asi_src = bin_path / "OptiPatcher_v0.30.asi" + if asi_src.exists(): + shutil.copy2(asi_src, plugins_dir / "OptiPatcher.asi") - decky.logger.info("Setting up ASI plugins directory") - # Create plugins directory and copy OptiPatcher ASI file - try: - plugins_dir = extract_path / "plugins" - plugins_dir.mkdir(exist_ok=True) - decky.logger.info(f"Created plugins directory: {plugins_dir}") - - # Copy OptiPatcher ASI file to plugins directory - asi_src = bin_path / "OptiPatcher_v0.30.asi" - asi_dst = plugins_dir / "OptiPatcher.asi" # Rename to generic name - - if asi_src.exists(): - shutil.copy2(asi_src, asi_dst) - decky.logger.info(f"Copied OptiPatcher ASI to plugins directory: {asi_dst}") - else: - decky.logger.warning("OptiPatcher ASI file not found in bin directory") - except Exception as e: - decky.logger.error(f"Failed to setup ASI plugins directory: {e}") - - decky.logger.info("Starting upscaler DLL overwrite check") - # Optionally overwrite amd_fidelityfx_upscaler_dx12.dll with the separately bundled - # RDNA2-optimized static binary used for Steam Deck compatibility. - # Toggle via env DECKY_SKIP_UPSCALER_OVERWRITE=true to skip. try: skip_overwrite = os.environ.get("DECKY_SKIP_UPSCALER_OVERWRITE", "false").lower() in ("1", "true", "yes") if UPSCALER_OVERWRITE_ENABLED and not skip_overwrite: @@ -332,377 +395,146 @@ class Plugin: if upscaler_src.exists(): shutil.copy2(upscaler_src, upscaler_dst) decky.logger.info("Overwrote amd_fidelityfx_upscaler_dx12.dll with static remote binary") - else: - decky.logger.warning("amd_fidelityfx_upscaler_dx12.dll not found in bin; skipping overwrite") else: decky.logger.info("Skipping upscaler DLL overwrite due to DECKY_SKIP_UPSCALER_OVERWRITE") - except Exception as e: - decky.logger.error(f"Failed upscaler overwrite step: {e}") - - # Extract version from filename (e.g., OptiScaler_0.7.9.7z -> v0.7.9) - version_match = optiscaler_archive.name.replace('.7z', '') - if 'OptiScaler_' in version_match: - version = 'v' + version_match.split('OptiScaler_')[1] - elif 'Optiscaler_' in version_match: - version = 'v' + version_match.split('Optiscaler_')[1] + except Exception as exc: + decky.logger.error(f"Failed upscaler overwrite step: {exc}") + + version_match = optiscaler_archive.name.replace(".7z", "") + if "OptiScaler_" in version_match: + version = "v" + version_match.split("OptiScaler_")[1] + elif "Optiscaler_" in version_match: + version = "v" + version_match.split("Optiscaler_")[1] else: version = version_match - - # Create version file - version_file = extract_path / "version.txt" - try: - with open(version_file, 'w') as f: - f.write(version) - decky.logger.info(f"Created version file: {version}") - except Exception as e: - decky.logger.error(f"Failed to create version file: {e}") - - # Modify OptiScaler.ini to set FGType=nukems and Fsr4Update=true - decky.logger.info("Modifying OptiScaler.ini") + + with open(extract_path / "version.txt", "w", encoding="utf-8") as f: + f.write(version) + ini_file = extract_path / "OptiScaler.ini" self._modify_optiscaler_ini(ini_file) - - decky.logger.info(f"Successfully completed extraction to ~/fgmod with version {version}") + return { "status": "success", - "message": f"Successfully extracted OptiScaler {version} to ~/fgmod", - "version": version + "message": f"Installed prefix-managed OptiScaler runtime {version} to {extract_path}", + "version": version, } - - except Exception as e: - decky.logger.error(f"Extract failed with exception: {str(e)}") - decky.logger.error(f"Exception type: {type(e).__name__}") + + except Exception as exc: + decky.logger.error(f"Extract failed with exception: {str(exc)}") import traceback + decky.logger.error(f"Traceback: {traceback.format_exc()}") - return {"status": "error", "message": f"Extract failed: {str(e)}"} + return {"status": "error", "message": f"Extract failed: {str(exc)}"} 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)}") + cleanup_results = self._cleanup_all_managed_prefixes() + bundle_path = self._bundle_path() + + if bundle_path.exists(): + shutil.rmtree(bundle_path) + decky.logger.info(f"Removed directory: {bundle_path}") + + cleaned_prefixes = len([result for result in cleanup_results if result.get("status") == "success"]) return { - "status": "error", - "message": f"Uninstall failed: {str(e)}", - "output": str(e) + "status": "success", + "output": f"Removed OptiScaler runtime and cleaned {cleaned_prefixes} managed compatdata prefixes.", + } + except Exception as exc: + decky.logger.error(f"Uninstall error: {str(exc)}") + return { + "status": "error", + "message": f"Uninstall failed: {str(exc)}", + "output": str(exc), } async def run_install_fgmod(self) -> dict: try: decky.logger.info("Starting OptiScaler installation from static bundle") - - # Extract the static OptiScaler bundle extract_result = await self.extract_static_optiscaler() - if extract_result["status"] != "success": return { "status": "error", - "message": f"OptiScaler extraction failed: {extract_result.get('message', 'Unknown error')}" + "message": f"OptiScaler extraction failed: {extract_result.get('message', 'Unknown error')}", } - - return { - "status": "success", - "output": "Successfully installed OptiScaler with all necessary components! You can now replace DLSS with FSR Frame Gen!" - } - except Exception as e: - decky.logger.error(f"Unexpected error during installation: {str(e)}") return { - "status": "error", - "message": f"Installation failed: {str(e)}" + "status": "success", + "output": "Installed the prefix-managed OptiScaler runtime. Use the game selector or launch command to stage it inside a Proton prefix at launch time.", } + except Exception as exc: + decky.logger.error(f"Unexpected error during installation: {str(exc)}") + return {"status": "error", "message": f"Installation failed: {str(exc)}"} 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", # v0.9.0-pre3+ includes fakenvapi.dll in archive - "fakenvapi.ini", - "nvngx.dll", - "amd_fidelityfx_dx12.dll", - "amd_fidelityfx_framegeneration_dx12.dll", - "amd_fidelityfx_upscaler_dx12.dll", - "amd_fidelityfx_vk.dll", - "libxess.dll", - "libxess_dx11.dll", - "libxess_fg.dll", # New in v0.9.0-pre4 - "libxell.dll", # New in v0.9.0-pre4 - "fgmod", - "fgmod-uninstaller.sh", - "update-optiscaler-config.py" - ] - - if path.exists(): - # Check required files - for file_name in required_files: - if not path.joinpath(file_name).exists(): - return {"exists": False} + path = self._bundle_path() + if not path.exists(): + return {"exists": False} - # Check plugins directory and OptiPatcher ASI - plugins_dir = path / "plugins" - if not plugins_dir.exists() or not (plugins_dir / "OptiPatcher.asi").exists(): + for file_name in REQUIRED_BUNDLE_FILES: + if not path.joinpath(file_name).exists(): return {"exists": False} - return {"exists": True} - else: + plugins_dir = path / "plugins" + if not plugins_dir.exists() or not (plugins_dir / "OptiPatcher.asi").exists(): return {"exists": False} - 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) -> 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.", - } - - dll_name = "dxgi.dll" - preserve_ini = True - - try: - decky.logger.info(f"Manual patch started for {directory}") - - removed_injectors = [] - for filename in INJECTOR_FILENAMES: - path = directory / filename - if path.exists(): - path.unlink() - removed_injectors.append(filename) - decky.logger.info(f"Removed injector DLLs: {removed_injectors}" if removed_injectors else "No injector DLLs found to remove") - - backed_up_originals = [] - for dll in ORIGINAL_DLL_BACKUPS: - source = directory / dll - backup = directory / f"{dll}.b" - if source.exists() and not backup.exists(): - shutil.move(source, backup) - backed_up_originals.append(dll) - decky.logger.info(f"Backed up original DLLs: {backed_up_originals}" if backed_up_originals else "No original DLLs required backup") - - removed_legacy = [] - for legacy in ["nvapi64.dll", "nvapi64.dll.b"]: - legacy_path = directory / legacy - if legacy_path.exists(): - legacy_path.unlink() - removed_legacy.append(legacy) - decky.logger.info(f"Removed legacy files: {removed_legacy}" if removed_legacy else "No legacy files to remove") - - renamed = fgmod_path / "renames" / dll_name - destination_dll = directory / dll_name - source_for_copy = renamed if renamed.exists() else 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._disable_hq_font_auto(target_ini) - - 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") - - 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) - 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}") + return {"exists": True} - decky.logger.info(f"Manual patch complete for {directory}") - return { - "status": "success", - "message": f"OptiScaler files copied to {directory}", - } + async def cleanup_managed_game(self, appid: str) -> dict: + compatdata_dirs = self._compatdata_dirs_for_appid(str(appid)) + if not compatdata_dirs: + return {"status": "success", "message": f"No compatdata prefix found for app {appid}; launch options can still be cleared."} - 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}", - } + cleanup_messages = [] + for compatdata_dir in compatdata_dirs: + result = self._cleanup_prefix(compatdata_dir) + cleanup_messages.append(result.get("message", f"Cleaned {compatdata_dir}")) - 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): - 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}") - - restored_backups = [] - for dll in ORIGINAL_DLL_BACKUPS: - 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}", - } + return {"status": "success", "message": "\n".join(cleanup_messages)} async def list_installed_games(self) -> dict: try: - steam_root = Path(decky.HOME) / ".steam" / "steam" - library_file = Path(steam_root) / "steamapps" / "libraryfolders.vdf" - - - if not library_file.exists(): - return {"status": "error", "message": "libraryfolders.vdf not found"} - - library_paths = [] - with open(library_file, "r", encoding="utf-8", errors="replace") as file: - for line in file: - if '"path"' in line: - path = line.split('"path"')[1].strip().strip('"').replace("\\\\", "/") - library_paths.append(path) - games = [] - for library_path in library_paths: - steamapps_path = Path(library_path) / "steamapps" + 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 = {"appid": "", "name": ""} - try: - with open(appmanifest, "r", encoding="utf-8") as file: + with open(appmanifest, "r", encoding="utf-8", errors="replace") as file: for line in file: if '"appid"' in line: - game_info["appid"] = line.split('"appid"')[1].strip().strip('"') + game_info["appid"] = line.split('"appid"', 1)[1].strip().strip('"') if '"name"' in line: - game_info["name"] = line.split('"name"')[1].strip().strip('"') - except UnicodeDecodeError as e: - decky.logger.error(f"Skipping {appmanifest} due to encoding issue: {e}") - finally: - pass # Ensures loop continues even if an error occurs + game_info["name"] = line.split('"name"', 1)[1].strip().strip('"') + except Exception as exc: + decky.logger.error(f"Skipping {appmanifest}: {exc}") if game_info["appid"] and game_info["name"]: games.append(game_info) - # Filter out games whose name contains "Proton" or "Steam Linux Runtime" - filtered_games = [g for g in games if "Proton" not in g["name"] and "Steam Linux Runtime" not in g["name"]] + filtered_games = [ + g + for g in games + if "Proton" not in g["name"] and "Steam Linux Runtime" not in g["name"] + ] - return {"status": "success", "games": filtered_games} + deduped = {} + for game in filtered_games: + deduped[str(game["appid"])] = game - except Exception as e: - decky.logger.error(str(e)) - return {"status": "error", "message": str(e)} + return {"status": "success", "games": list(deduped.values())} + except Exception as exc: + decky.logger.error(str(exc)) + return {"status": "error", "message": str(exc)} async def get_path_defaults(self) -> dict: - try: - home_path = Path(decky.HOME) - except TypeError: - home_path = Path(str(decky.HOME)) - + home_path = self._home_path() steam_common = home_path / ".local" / "share" / "Steam" / "steamapps" / "common" - return { "home": str(home_path), "steam_common": str(steam_common), @@ -712,19 +544,13 @@ class Plugin: decky.logger.error(f"FRONTEND: {error}") async def manual_patch_directory(self, directory: str) -> dict: - 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)} - - return self._manual_patch_directory_impl(target_dir) + return { + "status": "error", + "message": "Direct game-directory patching has been removed. Use the prefix-managed launch command instead.", + } 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) + return { + "status": "error", + "message": "Direct game-directory patching has been removed. Use the prefix-managed launch command or per-game cleanup instead.", + } |
