diff options
| author | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-03-21 07:57:48 -0400 |
|---|---|---|
| committer | xXJsonDeruloXx <danielhimebauch@gmail.com> | 2026-03-21 07:57:48 -0400 |
| commit | 2668d9649bf36d1e279470f81918464dd30f5e3d (patch) | |
| tree | 8fcdd23a0f2485bb109467bce6608955932c3d1c | |
| parent | d81bb130385114389728f849d0ab8cccf62b90d1 (diff) | |
| download | Decky-Framegen-2668d9649bf36d1e279470f81918464dd30f5e3d.tar.gz Decky-Framegen-2668d9649bf36d1e279470f81918464dd30f5e3d.zip | |
Add per-game config APIs and proxy persistence
| -rwxr-xr-x | defaults/assets/fgmod.sh | 27 | ||||
| -rw-r--r-- | main.py | 339 | ||||
| -rw-r--r-- | package.json | 4 | ||||
| -rw-r--r-- | plugin.json | 2 | ||||
| -rw-r--r-- | src/api/index.ts | 38 | ||||
| -rw-r--r-- | src/types/index.ts | 21 |
6 files changed, 366 insertions, 65 deletions
diff --git a/defaults/assets/fgmod.sh b/defaults/assets/fgmod.sh index 7aa62bb..9864ca1 100755 --- a/defaults/assets/fgmod.sh +++ b/defaults/assets/fgmod.sh @@ -24,10 +24,11 @@ error_exit() { bundle_root="${HOME}/fgmod" managed_dir_name="optiscaler-managed" manifest_name="manifest.env" -proxy_name="${OPTISCALER_PROXY:-${DLL:-winmm}}" +default_proxy="winmm" +proxy_name="${OPTISCALER_PROXY:-${DLL:-}}" proxy_name="${proxy_name%.dll}" -proxy_dll="${proxy_name}.dll" -backup_dll="${proxy_name}-original.dll" +proxy_dll="" +backup_dll="" support_files=( "libxess.dll" @@ -44,11 +45,6 @@ support_files=( "fakenvapi.ini" ) -case "$proxy_name" in - winmm|dxgi|version|dbghelp|winhttp|wininet|d3d12) ;; - *) error_exit "Unsupported OPTISCALER_PROXY '$proxy_name'." ;; -esac - [[ -d "$bundle_root" ]] || error_exit "OptiScaler runtime not installed at $bundle_root" [[ -n "${STEAM_COMPAT_DATA_PATH:-}" ]] || error_exit "STEAM_COMPAT_DATA_PATH is required. Use this wrapper from a Steam/Proton launch option." [[ $# -ge 1 ]] || error_exit "Usage: $0 program [program_arguments...]" @@ -85,12 +81,26 @@ cleanup_stage_files() { mkdir -p "$system32_path" "$managed_root" "$managed_plugins" existing_proxy="" +preferred_proxy="" if [[ -f "$manifest_path" ]]; then # shellcheck disable=SC1090 source "$manifest_path" existing_proxy="${MANAGED_PROXY:-}" + preferred_proxy="${PREFERRED_PROXY:-}" +fi + +if [[ -z "$proxy_name" ]]; then + proxy_name="${preferred_proxy:-$default_proxy}" fi +case "$proxy_name" in + winmm|dxgi|version|dbghelp|winhttp|wininet|d3d12) ;; + *) error_exit "Unsupported OPTISCALER_PROXY '$proxy_name'." ;; +esac + +proxy_dll="${proxy_name}.dll" +backup_dll="${proxy_name}-original.dll" + if [[ -n "$existing_proxy" && "$existing_proxy" != "$proxy_name" ]]; then log "Switching managed proxy from $existing_proxy to $proxy_name" cleanup_stage_files "$existing_proxy" @@ -149,6 +159,7 @@ fi cat > "$manifest_path" <<EOF MANAGED_PROXY="$proxy_name" +PREFERRED_PROXY="$preferred_proxy" BUNDLE_ROOT="$bundle_root" BUNDLE_VERSION="$runtime_version" SYSTEM32_PATH="$system32_path" @@ -3,6 +3,7 @@ import os import re import shutil import subprocess +from configparser import ConfigParser from pathlib import Path # Toggle to enable overwriting the upscaler DLL from the static remote binary. @@ -62,6 +63,17 @@ LEGACY_FILES = [ "OptiScaler.log", ] +QUICK_SETTING_DEFINITIONS = [ + {"id": "Dx12Upscaler", "section": None, "key": "Dx12Upscaler", "default": "auto"}, + {"id": "FrameGen.Enabled", "section": "FrameGen", "key": "Enabled", "default": "auto"}, + {"id": "FGInput", "section": None, "key": "FGInput", "default": "auto"}, + {"id": "FGOutput", "section": None, "key": "FGOutput", "default": "auto"}, + {"id": "Fsr4ForceCapable", "section": None, "key": "Fsr4ForceCapable", "default": "false"}, + {"id": "Fsr4EnableWatermark", "section": None, "key": "Fsr4EnableWatermark", "default": "false"}, + {"id": "UseHQFont", "section": None, "key": "UseHQFont", "default": "false"}, + {"id": "Menu.Scale", "section": "Menu", "key": "Scale", "default": "1.000000"}, +] + class Plugin: async def _main(self): @@ -152,6 +164,202 @@ class Plugin: return data + def _write_manifest_env(self, manifest_path: Path, updates: dict) -> dict: + data = self._parse_manifest_env(manifest_path) + for key, value in updates.items(): + if value is None: + continue + data[str(key)] = str(value) + + manifest_path.parent.mkdir(parents=True, exist_ok=True) + with open(manifest_path, "w", encoding="utf-8") as manifest: + for key in sorted(data.keys()): + manifest.write(f'{key}="{data[key]}"\n') + return data + + def _normalize_proxy(self, proxy: str | None) -> str: + normalized = (proxy or "winmm").replace(".dll", "").strip().lower() + if normalized not in SUPPORTED_PROXIES: + raise ValueError(f"Unsupported proxy '{proxy}'") + return normalized + + def _find_installed_game(self, appid: str | None = None) -> list[dict]: + games = [] + 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": "", "library_path": str(library_path)} + try: + 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)[1].strip().strip('"') + if '"name"' in line: + game_info["name"] = line.split('"name"', 1)[1].strip().strip('"') + except Exception as exc: + decky.logger.error(f"Skipping {appmanifest}: {exc}") + + 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 + if appid is None or str(game_info["appid"]) == str(appid): + games.append(game_info) + + deduped = {} + for game in games: + deduped[str(game["appid"])] = game + return list(deduped.values()) + + def _compatdata_dir_for_appid(self, appid: str, create: bool = False) -> Path | None: + compatdata_dirs = self._compatdata_dirs_for_appid(str(appid)) + if compatdata_dirs: + return compatdata_dirs[0] + + if not create: + return None + + installed_games = self._find_installed_game(appid) + if not installed_games: + return None + + library_path = Path(installed_games[0]["library_path"]) + compatdata_dir = library_path / "steamapps" / "compatdata" / str(appid) + compatdata_dir.mkdir(parents=True, exist_ok=True) + return compatdata_dir + + def _managed_paths_for_appid(self, appid: str, create: bool = False) -> dict | None: + compatdata_dir = self._compatdata_dir_for_appid(str(appid), create=create) + if not compatdata_dir: + return None + + managed_root = compatdata_dir / MANAGED_DIRNAME + system32 = compatdata_dir / "pfx" / "drive_c" / "windows" / "system32" + if create: + managed_root.mkdir(parents=True, exist_ok=True) + + return { + "compatdata_dir": compatdata_dir, + "managed_root": managed_root, + "manifest_path": managed_root / MANIFEST_FILENAME, + "managed_ini": managed_root / "OptiScaler.ini", + "system32": system32, + "live_ini": system32 / "OptiScaler.ini", + } + + def _ensure_managed_ini(self, appid: str) -> dict: + paths = self._managed_paths_for_appid(appid, create=True) + if not paths: + raise FileNotFoundError(f"Unable to resolve compatdata path for app {appid}") + + managed_ini = paths["managed_ini"] + if managed_ini.exists(): + return paths + + bundle_ini = self._bundle_path() / "OptiScaler.ini" + if not bundle_ini.exists(): + raise FileNotFoundError("OptiScaler runtime is not installed; missing bundled OptiScaler.ini") + + shutil.copy2(bundle_ini, managed_ini) + self._modify_optiscaler_ini(managed_ini) + return paths + + def _read_ini_config(self, ini_path: Path) -> ConfigParser: + config = ConfigParser(interpolation=None) + config.optionxform = str + if ini_path.exists(): + config.read(ini_path, encoding="utf-8") + return config + + def _find_sections_for_key(self, config: ConfigParser, key: str) -> list[str]: + return [section for section in config.sections() if config.has_option(section, key)] + + def _resolve_setting_definition(self, setting_id: str) -> dict | None: + for definition in QUICK_SETTING_DEFINITIONS: + if definition["id"] == setting_id: + return definition + return None + + def _resolve_setting_section(self, config: ConfigParser, definition: dict) -> str | None: + hinted_section = definition.get("section") + key = definition["key"] + + if hinted_section and config.has_option(hinted_section, key): + return hinted_section + + matching_sections = self._find_sections_for_key(config, key) + if len(matching_sections) == 1: + return matching_sections[0] + if hinted_section: + return hinted_section + if matching_sections: + return matching_sections[0] + return None + + def _extract_quick_settings(self, ini_path: Path) -> dict: + config = self._read_ini_config(ini_path) + settings = {} + for definition in QUICK_SETTING_DEFINITIONS: + resolved_section = self._resolve_setting_section(config, definition) + value = definition["default"] + if resolved_section and config.has_option(resolved_section, definition["key"]): + value = config.get(resolved_section, definition["key"]) + settings[definition["id"]] = value + return settings + + def _replace_ini_values(self, ini_path: Path, resolved_updates: list[tuple[str, str, str]]) -> None: + lines = [] + if ini_path.exists(): + with open(ini_path, "r", encoding="utf-8", errors="replace") as ini_file: + lines = ini_file.readlines() + + for section, key, value in resolved_updates: + section_pattern = re.compile(rf"^\s*\[{re.escape(section)}]\s*$") + any_section_pattern = re.compile(r"^\s*\[.*]\s*$") + key_pattern = re.compile(rf"^\s*{re.escape(key)}\s*=.*$") + + in_target_section = False + section_found = False + inserted_or_updated = False + insert_index = len(lines) + + for index, line in enumerate(lines): + if section_pattern.match(line): + in_target_section = True + section_found = True + insert_index = index + 1 + continue + + if in_target_section and any_section_pattern.match(line): + insert_index = index + break + + if in_target_section: + if key_pattern.match(line): + newline = "\n" if line.endswith("\n") else "" + lines[index] = f"{key}={value}{newline}" + inserted_or_updated = True + break + insert_index = index + 1 + + if inserted_or_updated: + continue + + if not section_found: + if lines and lines[-1].strip(): + lines.append("\n") + lines.append(f"[{section}]\n") + lines.append(f"{key}={value}\n") + continue + + lines[insert_index:insert_index] = [f"{key}={value}\n"] + + with open(ini_path, "w", encoding="utf-8") as ini_file: + ini_file.writelines(lines) + def _disable_hq_font_auto(self, ini_file: Path) -> bool: try: if not ini_file.exists(): @@ -494,40 +702,111 @@ class Plugin: return {"status": "success", "message": "\n".join(cleanup_messages)} - async def list_installed_games(self) -> dict: + async def get_game_config(self, appid: str) -> dict: try: - games = [] - for library_path in self._steam_library_paths(): - steamapps_path = library_path / "steamapps" - if not steamapps_path.exists(): - continue + paths = self._ensure_managed_ini(str(appid)) + manifest = self._parse_manifest_env(paths["manifest_path"]) + installed_game = self._find_installed_game(str(appid)) + game_name = installed_game[0]["name"] if installed_game else str(appid) - for appmanifest in steamapps_path.glob("appmanifest_*.acf"): - game_info = {"appid": "", "name": ""} - try: - 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)[1].strip().strip('"') - if '"name"' in line: - 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) - - filtered_games = [ - g - for g in games - if "Proton" not in g["name"] and "Steam Linux Runtime" not in g["name"] - ] + source_ini = paths["live_ini"] if paths["live_ini"].exists() else paths["managed_ini"] + with open(source_ini, "r", encoding="utf-8", errors="replace") as ini_file: + raw_ini = ini_file.read() - deduped = {} - for game in filtered_games: - deduped[str(game["appid"])] = game + preferred_proxy = manifest.get("PREFERRED_PROXY") or manifest.get("MANAGED_PROXY") or "winmm" + proxy = self._normalize_proxy(preferred_proxy) + settings = self._extract_quick_settings(source_ini) - return {"status": "success", "games": list(deduped.values())} + return { + "status": "success", + "appid": str(appid), + "name": game_name, + "proxy": proxy, + "settings": settings, + "raw_ini": raw_ini, + "managed_exists": paths["managed_ini"].exists(), + "live_available": paths["live_ini"].exists(), + "paths": { + "compatdata": str(paths["compatdata_dir"]), + "managed_root": str(paths["managed_root"]), + "managed_ini": str(paths["managed_ini"]), + "system32": str(paths["system32"]), + "live_ini": str(paths["live_ini"]), + }, + } + except Exception as exc: + decky.logger.error(f"get_game_config failed for {appid}: {exc}") + return {"status": "error", "message": str(exc)} + + async def save_game_config( + self, + appid: str, + settings: dict | None = None, + proxy: str | None = None, + apply_live: bool = False, + raw_ini: str | None = None, + ) -> dict: + try: + paths = self._ensure_managed_ini(str(appid)) + manifest_updates = {} + if proxy: + manifest_updates["PREFERRED_PROXY"] = self._normalize_proxy(proxy) + if manifest_updates: + self._write_manifest_env(paths["manifest_path"], manifest_updates) + + managed_ini = paths["managed_ini"] + resolved_updates = [] + settings = settings or {} + + if raw_ini is not None: + with open(managed_ini, "w", encoding="utf-8") as ini_file: + ini_file.write(raw_ini) + elif settings: + config = self._read_ini_config(managed_ini) + for setting_id, value in settings.items(): + definition = self._resolve_setting_definition(setting_id) + if not definition: + continue + section = self._resolve_setting_section(config, definition) or definition.get("section") or "OptiScaler" + resolved_updates.append((section, definition["key"], str(value))) + if resolved_updates: + self._replace_ini_values(managed_ini, resolved_updates) + + self._disable_hq_font_auto(managed_ini) + + live_applied = False + if apply_live and paths["live_ini"].exists(): + if raw_ini is not None: + with open(paths["live_ini"], "w", encoding="utf-8") as ini_file: + ini_file.write(raw_ini) + elif resolved_updates: + self._replace_ini_values(paths["live_ini"], resolved_updates) + self._disable_hq_font_auto(paths["live_ini"]) + live_applied = True + + message = "Saved OptiScaler config" + if apply_live: + message = ( + "Saved OptiScaler config and applied it to the running game's live prefix copy" + if live_applied + else "Saved OptiScaler config, but no live prefix copy was available to update" + ) + + return { + "status": "success", + "message": message, + "live_applied": live_applied, + "proxy": self._parse_manifest_env(paths["manifest_path"]).get("PREFERRED_PROXY", proxy or "winmm"), + } + except Exception as exc: + decky.logger.error(f"save_game_config failed for {appid}: {exc}") + return {"status": "error", "message": str(exc)} + + async def list_installed_games(self) -> dict: + try: + games = self._find_installed_game() + sanitized_games = [{"appid": game["appid"], "name": game["name"]} for game in games] + return {"status": "success", "games": sanitized_games} except Exception as exc: decky.logger.error(str(exc)) return {"status": "error", "message": str(exc)} diff --git a/package.json b/package.json index 7c68e90..d644a9d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "decky-framegen", - "version": "0.16.0", - "description": "Prefix-managed OptiScaler integration for Decky that stages per-game payloads inside Proton compatdata instead of the game directory.", + "version": "0.17.0", + "description": "Prefix-managed OptiScaler integration for Decky with per-game live config editing inside Proton compatdata instead of the game directory.", "type": "module", "scripts": { "build": "node ./node_modules/@rollup/wasm-node/dist/bin/rollup -c", diff --git a/plugin.json b/plugin.json index 4905e89..49a8ab9 100644 --- a/plugin.json +++ b/plugin.json @@ -5,7 +5,7 @@ "api_version": 1, "publish": { "tags": ["DLSS", "Framegen","upscaling","FSR"], - "description": "Prefix-managed OptiScaler integration for Decky that stages per-game payloads inside Proton compatdata instead of the game directory.", + "description": "Prefix-managed OptiScaler integration for Decky with per-game live config editing inside Proton compatdata instead of the game directory.", "image": "https://raw.githubusercontent.com/xXJSONDeruloXx/Decky-Framegen/refs/heads/main/assets/optiscaler_final.png" } } diff --git a/src/api/index.ts b/src/api/index.ts index f2e0ea1..2e275d2 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,29 +1,25 @@ import { callable } from "@decky/api"; +import type { ApiResponse, GameConfigResponse } from "../types/index"; -export const runInstallFGMod = callable< - [], - { status: string; message?: string; output?: string } ->("run_install_fgmod"); +export const runInstallFGMod = callable<[], ApiResponse>("run_install_fgmod"); -export const runUninstallFGMod = callable< - [], - { status: string; message?: string; output?: string } ->("run_uninstall_fgmod"); +export const runUninstallFGMod = callable<[], ApiResponse>("run_uninstall_fgmod"); -export const checkFGModPath = callable< - [], - { exists: boolean } ->("check_fgmod_path"); +export const checkFGModPath = callable<[], { exists: boolean }>("check_fgmod_path"); export const listInstalledGames = callable< [], { status: string; games: { appid: string; name: string }[] } >("list_installed_games"); -export const cleanupManagedGame = callable< - [string], - { status: string; message?: string; output?: string } ->("cleanup_managed_game"); +export const cleanupManagedGame = callable<[string], ApiResponse>("cleanup_managed_game"); + +export const getGameConfig = callable<[string], GameConfigResponse>("get_game_config"); + +export const saveGameConfig = callable< + [string, Record<string, string>, string | null, boolean, string | null], + ApiResponse +>("save_game_config"); export const logError = callable<[string], void>("log_error"); @@ -32,12 +28,6 @@ export const getPathDefaults = callable< { home: string; steam_common?: string } >("get_path_defaults"); -export const runManualPatch = callable< - [string], - { status: string; message?: string; output?: string } ->("manual_patch_directory"); +export const runManualPatch = callable<[string], ApiResponse>("manual_patch_directory"); -export const runManualUnpatch = callable< - [string], - { status: string; message?: string; output?: string } ->("manual_unpatch_directory"); +export const runManualUnpatch = callable<[string], ApiResponse>("manual_unpatch_directory"); diff --git a/src/types/index.ts b/src/types/index.ts index c810754..73e7947 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,6 +4,8 @@ export interface ApiResponse { status: string; message?: string; output?: string; + live_applied?: boolean; + proxy?: string; } export interface GameInfo { @@ -11,6 +13,25 @@ export interface GameInfo { name: string; } +export interface GameConfigPaths { + compatdata: string; + managed_root: string; + managed_ini: string; + system32: string; + live_ini: string; +} + +export interface GameConfigResponse extends ApiResponse { + appid?: string; + name?: string; + proxy?: string; + settings?: Record<string, string>; + raw_ini?: string; + managed_exists?: boolean; + live_available?: boolean; + paths?: GameConfigPaths; +} + export interface LaunchOptions { command: string; arguments?: string[]; |
