Skip to content

Commit 5ed4fbe

Browse files
fix: plugin load crash, debug toggle, and harden compat detection
Fix ModuleNotFoundError on load by adding the plugin dir (and py_modules) to sys.path before importing compat_logic; the sandboxed loader doesn't put the plugin root on the path, which crashed the whole backend and made all UI options disappear. Fix the verbose debug logging toggle never sticking: the imported setDebugLogging API callable was shadowed by the useState setter of the same name, so the handler called the setter (returning undefined) instead of the backend. Alias the import to apiSetDebugLogging. Resolve TLS CERTIFICATE_VERIFY_FAILED properly by building a verified SSL context from the system CA bundle (certifi -> SSL_CERT_FILE -> common paths) instead of silently falling back to an unverified context. Ship a snapshot of the curated Compatibility-List with the plugin and fall back to it when the live fetch fails (offline / HTTP 429), reporting curated_source to the UI. List only games that are actually installed on disk (skip leftover Steam appmanifests), and replace the single "show compatible only" toggle with independent Verified / Compatible filters. Add an always-present "— Select a game —" placeholder as the default, unfiltered first dropdown entry instead of auto-selecting the first game, so the Verified/Compatible filters no longer keep an unrelated game pinned. Bump to 0.16.2. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 44d6ff1 commit 5ed4fbe

7 files changed

Lines changed: 897 additions & 33 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ This plugin uses OptiScaler to replace DLSS calls with FSR3/FSR3.1, giving you:
1919
- **Smart Installation**: Handles all required dependencies and library files
2020
- **Game Patching**: Easy copy-paste launch commands for enabling/disabling the mod per game
2121
- **GPU-Aware Defaults**: Detects your GPU and recommends the matching FSR4 runtime (RDNA4 native vs the RDNA2/3/3.5 INT8 override)
22-
- **Compatibility Marking**: Flags which installed games are OptiScaler compatible, with a "show compatible only" filter and a per-game manual override
22+
- **Compatibility Marking**: Flags which installed games are OptiScaler compatible, with independent **Verified** / **Compatible** filters and a per-game manual override
2323
- **OptiScaler Wiki**: Direct access to OptiScaler documentation and settings via a webpage launch button right inside the plugin.
2424

2525
### Supported Devices
@@ -41,7 +41,9 @@ OptiScaler works by hooking a game's existing upscaler, so it mainly benefits ti
4141
- **Unknown**: no signal either way. Not shown as a badge in the game list to keep it readable; visible in the compatibility detail field for the selected game.
4242
- **Not compatible**: you manually marked it as such via the override. Also hidden from the list/badges.
4343

44-
Manually marking a game **Compatible** or **Not compatible** always takes priority over the automatic detection. Use the **Show compatible only** toggle to filter the game dropdown to Verified/Compatible titles, and the **Compatibility override** dropdown to force a per-game result. Detection is best-effort and cached; the curated list is fetched once a day and everything still works offline via the local scan. If the curated list can't be fetched (for example due to an outdated CA certificate bundle on some SteamOS images), games can still be marked **Compatible** by the local scan, and the toggle description will say so.
44+
Only games that are actually installed on disk are listed (Steam's leftover `appmanifest` entries for removed games are skipped). Manually marking a game **Compatible** or **Not compatible** always takes priority over the automatic detection. Use the **Filter: Verified** and **Filter: Compatible** toggles to narrow the game dropdown (with neither enabled, every installed game is shown), and the **Compatibility override** dropdown to force a per-game result.
45+
46+
Detection is best-effort and cached. The curated list is fetched over a verified TLS connection (using the system CA bundle) at most once a day; a snapshot of the list also **ships with the plugin**, so Verified marking keeps working offline or when GitHub rate-limits the request (HTTP 429). If the live list can't be fetched, the plugin transparently falls back to the bundled snapshot and the filter description notes it. Even with no curated list at all, games can still be marked **Compatible** by the local DLL scan.
4547

4648
> **Note:** The game list and compatibility marking cover your **Steam library** only (they read Steam's `appmanifest` files). Games installed through Heroic (Epic/GOG/Amazon) or Lutris won't appear in the dropdown, but you can still patch them manually with the `~/fgmod/fgmod %command%` wrapper (the launcher script includes Lutris resolution).
4749

defaults/Compatibility-List.md

Lines changed: 742 additions & 0 deletions
Large diffs are not rendered by default.

main.py

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import decky
22
import os
3+
import sys
34
import subprocess
45
import json
56
import shutil
@@ -14,6 +15,16 @@
1415
from datetime import datetime, timezone
1516
from pathlib import Path
1617

18+
# Decky's sandboxed plugin loader does not put the plugin root directory on
19+
# sys.path (only py_modules), so a bare `from compat_logic import ...` fails at
20+
# load time with ModuleNotFoundError. Add both this file's directory and its
21+
# py_modules subdir to sys.path so the helper module resolves no matter where it
22+
# ends up in the bundle.
23+
_PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
24+
for _candidate in (_PLUGIN_DIR, os.path.join(_PLUGIN_DIR, "py_modules")):
25+
if _candidate not in sys.path:
26+
sys.path.insert(0, _candidate)
27+
1728
from compat_logic import (
1829
classify_amd_gpu,
1930
classify_game_compat,
@@ -264,6 +275,7 @@
264275
"https://raw.githubusercontent.com/wiki/optiscaler/OptiScaler/Compatibility-List.md"
265276
)
266277
CURATED_CACHE_FILENAME = "compat-curated-cache.json"
278+
BUNDLED_CURATED_FILENAME = "Compatibility-List.md"
267279
SCAN_CACHE_FILENAME = "compat-scan-cache.json"
268280
OVERRIDES_FILENAME = "compat-overrides.json"
269281
CURATED_CACHE_TTL_SECONDS = 24 * 60 * 60 # refresh at most once a day
@@ -1620,10 +1632,15 @@ async def list_installed_games(self) -> dict:
16201632
games = []
16211633
for game in self._find_installed_games():
16221634
install_root = Path(game["install_path"])
1635+
# Steam keeps appmanifest files around for games that are no
1636+
# longer on disk; skip those so the list only shows games that
1637+
# are actually installed and patchable.
1638+
if not install_root.exists():
1639+
continue
16231640
games.append({
16241641
"appid": str(game["appid"]),
16251642
"name": game["name"],
1626-
"install_found": install_root.exists(),
1643+
"install_found": True,
16271644
})
16281645
return {"status": "success", "games": games}
16291646
except Exception as e:
@@ -1954,29 +1971,88 @@ def _save_scan_cache(self, cache: dict) -> None:
19541971
def _parse_curated_markdown(self, raw: str) -> set[str]:
19551972
return parse_curated_markdown(raw)
19561973

1974+
def _resolve_ca_bundle(self) -> str | None:
1975+
"""Find a usable CA bundle. Decky's Python often can't locate one on
1976+
SteamOS (its compiled-in OpenSSL path doesn't match the distro), which
1977+
is what causes CERTIFICATE_VERIFY_FAILED. Prefer certifi if bundled,
1978+
then the well-known system locations (SteamOS/Arch, Fedora, BSD)."""
1979+
try:
1980+
import certifi # optional; only present if bundled in py_modules
1981+
1982+
path = certifi.where()
1983+
if path and os.path.exists(path):
1984+
return path
1985+
except Exception:
1986+
pass
1987+
for candidate in (
1988+
os.environ.get("SSL_CERT_FILE"),
1989+
"/etc/ssl/certs/ca-certificates.crt", # SteamOS / Arch / Debian
1990+
"/etc/pki/tls/certs/ca-bundle.crt", # Fedora / RHEL
1991+
"/etc/ssl/cert.pem", # BSD / some minimal images
1992+
):
1993+
if candidate and os.path.exists(candidate):
1994+
return candidate
1995+
return None
1996+
1997+
def _make_ssl_context(self) -> ssl.SSLContext:
1998+
ca_bundle = self._resolve_ca_bundle()
1999+
if ca_bundle:
2000+
try:
2001+
return ssl.create_default_context(cafile=ca_bundle)
2002+
except Exception as exc:
2003+
decky.logger.debug(f"[Framegen] failed to load CA bundle {ca_bundle}: {exc}")
2004+
return ssl.create_default_context()
2005+
19572006
def _fetch_curated_markdown(self) -> str:
1958-
"""Fetch the curated compat markdown, retrying with a relaxed SSL
1959-
context if the default one fails to verify. SteamOS's bundled Python
1960-
can ship an incomplete CA bundle, which otherwise fails every call
1961-
silently and leaves every game looking "Unknown"."""
2007+
"""Fetch the curated compat markdown. Uses a verified TLS context backed
2008+
by the system CA bundle; only if verification still fails does it retry
2009+
with an unverified context as a last resort (SteamOS's bundled Python
2010+
can otherwise fail every call and leave every game looking "Unknown")."""
19622011
request = urllib.request.Request(
19632012
CURATED_COMPAT_URL, headers={"User-Agent": "Decky-Framegen"}
19642013
)
19652014
try:
1966-
with urllib.request.urlopen(request, timeout=10) as response:
2015+
context = self._make_ssl_context()
2016+
with urllib.request.urlopen(request, timeout=10, context=context) as response:
19672017
return response.read().decode("utf-8", errors="replace")
19682018
except urllib.error.URLError as exc:
19692019
reason = getattr(exc, "reason", exc)
19702020
if not isinstance(reason, ssl.SSLCertVerificationError) and "CERTIFICATE_VERIFY_FAILED" not in str(reason):
19712021
raise
19722022
decky.logger.warning(
19732023
f"[Framegen] curated compat TLS verification failed ({reason}); "
1974-
"retrying with an unverified context. Consider updating system CA certificates."
2024+
"no usable CA bundle found, retrying with an unverified context. "
2025+
"Consider updating system CA certificates."
19752026
)
19762027
unverified_context = ssl._create_unverified_context()
19772028
with urllib.request.urlopen(request, timeout=10, context=unverified_context) as response:
19782029
return response.read().decode("utf-8", errors="replace")
19792030

2031+
def _bundled_curated_path(self) -> Path | None:
2032+
"""Locate the curated list snapshot bundled with the plugin. On device
2033+
the contents of defaults/ are extracted next to main.py; in the repo it
2034+
lives under defaults/."""
2035+
for candidate in (
2036+
Path(_PLUGIN_DIR) / BUNDLED_CURATED_FILENAME,
2037+
Path(_PLUGIN_DIR) / "defaults" / BUNDLED_CURATED_FILENAME,
2038+
):
2039+
if candidate.exists():
2040+
return candidate
2041+
return None
2042+
2043+
def _load_bundled_curated(self) -> dict:
2044+
path = self._bundled_curated_path()
2045+
if not path:
2046+
return {}
2047+
try:
2048+
raw = path.read_text(encoding="utf-8", errors="replace")
2049+
names = self._parse_curated_markdown(raw)
2050+
if names:
2051+
return {"fetched_at": 0, "names": sorted(names), "count": len(names), "bundled": True}
2052+
except Exception as exc:
2053+
decky.logger.warning(f"[Framegen] bundled curated list read failed: {exc}")
2054+
return {}
2055+
19802056
def _load_curated_compat(self, force_refresh: bool = False) -> dict:
19812057
cache_path = self._fgmod_data_dir() / CURATED_CACHE_FILENAME
19822058
cached = self._read_json_file(cache_path)
@@ -1999,7 +2075,20 @@ def _load_curated_compat(self, force_refresh: bool = False) -> dict:
19992075
decky.logger.warning(f"[Framegen] curated compat fetch failed: {exc}")
20002076
cached = dict(cached)
20012077
cached["last_error"] = str(exc)
2002-
return cached if cached.get("names") else {"fetched_at": 0, "names": [], "count": 0, "last_error": cached.get("last_error")}
2078+
if cached.get("names"):
2079+
return cached
2080+
# Network fetch failed and there's no usable cache: fall back to the
2081+
# snapshot bundled with the plugin so games can still be Verified while
2082+
# offline or rate-limited (HTTP 429).
2083+
bundled = self._load_bundled_curated()
2084+
if bundled.get("names"):
2085+
decky.logger.info(
2086+
f"[Framegen] using bundled curated list ({bundled['count']} entries) as fallback"
2087+
)
2088+
if cached.get("last_error"):
2089+
bundled["last_error"] = cached["last_error"]
2090+
return bundled
2091+
return {"fetched_at": 0, "names": [], "count": 0, "last_error": cached.get("last_error")}
20032092

20042093
def _load_overrides(self) -> dict:
20052094
return self._read_json_file(self._fgmod_data_dir() / OVERRIDES_FILENAME)
@@ -2040,6 +2129,8 @@ async def list_games_compatibility(self, force_refresh: bool = False) -> dict:
20402129
name = game["name"]
20412130
install_path = Path(game["install_path"]) if game.get("install_path") else None
20422131
install_found = bool(install_path and install_path.exists())
2132+
if not install_found:
2133+
continue
20432134

20442135
upscalers: list[str] = []
20452136
if install_found:
@@ -2099,6 +2190,7 @@ async def list_games_compatibility(self, force_refresh: bool = False) -> dict:
20992190
"games": results,
21002191
"curated_count": curated.get("count", len(curated_names)),
21012192
"curated_available": bool(curated_names),
2193+
"curated_source": "bundled" if curated.get("bundled") else ("live" if curated_names else "none"),
21022194
"curated_error": curated.get("last_error"),
21032195
}
21042196
except Exception as exc:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "decky-framegen",
3-
"version": "0.16.1",
3+
"version": "0.16.2",
44
"description": "This plugin installs and manages OptiScaler, a tool that enhances upscaling and enables frame generation in a range of DirectX 12 games.",
55
"type": "module",
66
"scripts": {

src/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export const listGamesCompatibility = callable<
7676
games: GameCompat[];
7777
curated_count?: number;
7878
curated_available?: boolean;
79+
curated_source?: "live" | "bundled" | "none";
7980
curated_error?: string | null;
8081
}
8182
>("list_games_compatibility");

src/components/OptiScalerControls.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useEffect } from "react";
22
import { DropdownItem, Field, PanelSection, PanelSectionRow, ToggleField } from "@decky/ui";
3-
import { runInstallFGMod, runUninstallFGMod, setDefaultFsr4Variant, detectGpu, getDebugLogging, setDebugLogging } from "../api";
3+
import { runInstallFGMod, runUninstallFGMod, setDefaultFsr4Variant, detectGpu, getDebugLogging, setDebugLogging as apiSetDebugLogging } from "../api";
44
import { OperationResult } from "./ResultDisplay";
55
import { createAutoCleanupTimer } from "../utils";
66
import { TIMEOUTS, PROXY_DLL_OPTIONS, DEFAULT_PROXY_DLL, FSR4_VARIANT_OPTIONS, DEFAULT_FSR4_VARIANT } from "../utils/constants";
@@ -174,7 +174,7 @@ export function OptiScalerControls({ pathExists, setPathExists, fgmodInfo }: Opt
174174
setDebugLogging(enabled);
175175
setDebugLoggingBusy(true);
176176
try {
177-
const result = await setDebugLogging(enabled);
177+
const result = await apiSetDebugLogging(enabled);
178178
if (result.status !== "success") throw new Error(result.message || "Failed to update debug logging.");
179179
setDebugLogging(Boolean(result.enabled));
180180
if (result.log_path) setDebugLogPath(result.log_path);

src/components/SteamGamePatcher.tsx

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps
109109
const [compatMap, setCompatMap] = useState<Record<string, GameCompat>>({});
110110
const [compatLoading, setCompatLoading] = useState(true);
111111
const [curatedError, setCuratedError] = useState<string | null>(null);
112-
const [showCompatibleOnly, setShowCompatibleOnly] = useState(false);
112+
const [curatedBundled, setCuratedBundled] = useState(false);
113+
const [filterVerified, setFilterVerified] = useState(false);
114+
const [filterCompatible, setFilterCompatible] = useState(false);
113115
const [overrideBusy, setOverrideBusy] = useState(false);
114116

115117
// ── Data loaders ───────────────────────────────────────────────────────────
@@ -123,6 +125,7 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps
123125
for (const entry of result.games) map[entry.appid] = entry;
124126
setCompatMap(map);
125127
setCuratedError(result.curated_available ? null : result.curated_error || null);
128+
setCuratedBundled(result.curated_source === "bundled");
126129
}
127130
} catch (err) {
128131
// non-fatal: compatibility hints are best-effort
@@ -144,9 +147,10 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps
144147
setSelectedAppId("");
145148
return;
146149
}
150+
// Default to the "— Select a game —" placeholder rather than auto-picking
151+
// the first game, so nothing is forced into the filtered list.
147152
setSelectedAppId((current) => {
148-
const valid =
149-
current && gameList.some((g) => g.appid === current) ? current : gameList[0].appid;
153+
const valid = current && gameList.some((g) => g.appid === current) ? current : "";
150154
lastSelectedAppId = valid;
151155
return valid;
152156
});
@@ -203,13 +207,17 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps
203207
);
204208

205209
const visibleGames = useMemo(() => {
206-
if (!showCompatibleOnly) return games;
210+
// No filter selected → show every installed game.
211+
if (!filterVerified && !filterCompatible) return games;
207212
return games.filter((g) => {
208213
const compat = compatMap[g.appid]?.compat;
209214
// Always keep the current selection visible so it doesn't vanish.
210-
return g.appid === selectedAppId || (compat && BADGE_WORTHY_COMPAT.has(compat));
215+
if (g.appid === selectedAppId) return true;
216+
if (filterVerified && compat === "verified") return true;
217+
if (filterCompatible && compat === "compatible") return true;
218+
return false;
211219
});
212-
}, [games, compatMap, showCompatibleOnly, selectedAppId]);
220+
}, [games, compatMap, filterVerified, filterCompatible, selectedAppId]);
213221

214222
const selectedVariantLabel = useMemo(
215223
() => FSR4_VARIANT_OPTIONS.find((option) => option.value === fsr4Variant)?.label ?? fsr4Variant,
@@ -322,16 +330,31 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps
322330
<>
323331
<PanelSectionRow>
324332
<ToggleField
325-
label="Show compatible only"
333+
label="Filter: Verified"
326334
description={
327335
compatLoading
328-
? "Scanning games for DLSS / FSR / XeSS..."
336+
? "Loading curated compatibility list..."
329337
: curatedError
330-
? `Filter to Verified/Compatible games. Curated list unavailable: ${curatedError}`
331-
: "Filter to games Verified via the curated list or detected as Compatible."
338+
? `On the OptiScaler curated list. Curated list unavailable: ${curatedError}`
339+
: curatedBundled
340+
? "On the OptiScaler curated list (using bundled snapshot; live list unavailable)."
341+
: "Show only games on the OptiScaler curated list."
342+
}
343+
checked={filterVerified}
344+
onChange={setFilterVerified}
345+
/>
346+
</PanelSectionRow>
347+
348+
<PanelSectionRow>
349+
<ToggleField
350+
label="Filter: Compatible"
351+
description={
352+
compatLoading
353+
? "Scanning games for DLSS / FSR / XeSS..."
354+
: "Show only games with DLSS / FSR / XeSS files detected on disk."
332355
}
333-
checked={showCompatibleOnly}
334-
onChange={setShowCompatibleOnly}
356+
checked={filterCompatible}
357+
onChange={setFilterCompatible}
335358
/>
336359
</PanelSectionRow>
337360

@@ -340,15 +363,19 @@ export function SteamGamePatcher({ dllName, fsr4Variant }: SteamGamePatcherProps
340363
layout="below"
341364
label="Steam game"
342365
menuLabel="Select a Steam game"
343-
strDefaultLabel={gamesLoading ? "Loading games..." : "Choose a game"}
344-
disabled={gamesLoading || visibleGames.length === 0}
366+
strDefaultLabel={gamesLoading ? "Loading games..." : "— Select a game"}
367+
disabled={gamesLoading}
345368
selectedOption={selectedAppId}
346-
rgOptions={visibleGames.map((g) => {
347-
const compat = compatMap[g.appid]?.compat;
348-
const badge = compat && BADGE_WORTHY_COMPAT.has(compat) ? ` - ${COMPAT_META[compat].short}` : "";
349-
const base = g.install_found === false ? `${g.name} (not installed)` : g.name;
350-
return { data: g.appid, label: `${base}${badge}` };
351-
})}
369+
rgOptions={[
370+
// Always-present placeholder so the dropdown has a stable, unfiltered
371+
// first entry even when the Verified/Compatible filters hide games.
372+
{ data: "", label: "— Select a game —" },
373+
...visibleGames.map((g) => {
374+
const compat = compatMap[g.appid]?.compat;
375+
const badge = compat && BADGE_WORTHY_COMPAT.has(compat) ? ` - ${COMPAT_META[compat].short}` : "";
376+
return { data: g.appid, label: `${g.name}${badge}` };
377+
}),
378+
]}
352379
onChange={(option) => {
353380
const next = String(option.data);
354381
lastSelectedAppId = next;

0 commit comments

Comments
 (0)