summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--py_modules/lsfg_vk/config_schema.py4
-rw-r--r--py_modules/lsfg_vk/steam_service.py26
-rw-r--r--src/components/ConfigurationTab.tsx59
-rw-r--r--src/components/Content.tsx8
-rw-r--r--src/components/GameConfigurationSelector.tsx50
-rw-r--r--src/components/NowPlayingTab.tsx14
-rw-r--r--src/config/generatedConfigSchema.ts4
-rw-r--r--src/hooks/useGameConfiguration.ts53
8 files changed, 158 insertions, 60 deletions
diff --git a/py_modules/lsfg_vk/config_schema.py b/py_modules/lsfg_vk/config_schema.py
index 675ab61..ce109f3 100644
--- a/py_modules/lsfg_vk/config_schema.py
+++ b/py_modules/lsfg_vk/config_schema.py
@@ -20,7 +20,7 @@ PROFILE_DEFAULTS: Dict[str, Any] = {
"active_in": [],
"pacing_mode": "vsync",
"multiplier": 2,
- "flow_scale": 1.0,
+ "flow_scale": 0.8,
"performance_mode": False,
"override_present_mode": True,
"preserve_swapchain_image_count": False,
@@ -78,7 +78,7 @@ class ConfigurationManager:
if not path_value:
return ""
path = Path(path_value)
- if path.name.lower() in {"lossless.dll", "losslessscaling.dll"}:
+ if path.name.lower() in {"lossless.dll"}:
return str(path.with_name("lsfg-vk.dll"))
return path_value
diff --git a/py_modules/lsfg_vk/steam_service.py b/py_modules/lsfg_vk/steam_service.py
index 8c50cfd..f46a091 100644
--- a/py_modules/lsfg_vk/steam_service.py
+++ b/py_modules/lsfg_vk/steam_service.py
@@ -56,15 +56,18 @@ class SteamService(BaseService):
for candidate in self._steam_roots():
yield from self._unique_existing_root(candidate, seen)
- library_file = candidate / "steamapps/libraryfolders.vdf"
- try:
- content = library_file.read_text(encoding="utf-8")
- except OSError:
- continue
+ for library_file in (
+ candidate / "steamapps/libraryfolders.vdf",
+ candidate / "config/libraryfolders.vdf",
+ ):
+ try:
+ content = library_file.read_text(encoding="utf-8")
+ except OSError:
+ continue
- for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content):
- path = raw_path.replace(r'\"', '"').replace(r'\\', '\\')
- yield from self._unique_existing_root(Path(path), seen)
+ for raw_path in re.findall(r'(?m)^\s*"path"\s+"((?:\\.|[^"])*)"', content):
+ path = raw_path.replace(r'\"', '"').replace(r'\\', '\\')
+ yield from self._unique_existing_root(Path(path), seen)
@staticmethod
def _read_shortcuts(data: bytes) -> Dict[str, object]:
@@ -88,6 +91,11 @@ class SteamService(BaseService):
raise ValueError("truncated binary VDF integer")
value = int.from_bytes(data[offset:offset + 4], "little", signed=True)
offset += 4
+ elif value_type == 7:
+ if offset + 8 > len(data):
+ raise ValueError("truncated binary VDF 64-bit integer")
+ value = int.from_bytes(data[offset:offset + 8], "little", signed=True)
+ offset += 8
else:
raise ValueError(f"unsupported binary VDF type {value_type}")
values[key] = value
@@ -254,7 +262,7 @@ class SteamService(BaseService):
elif fields["restart_required"]:
message = "lsfg-vk is selected; restart Steam to finish the branch switch"
else:
- message = "Lossless Scaling is not using the lsfg-vk Steam branch"
+ message = "Select lsfg-vk in Lossless Scaling's Steam Properties > Betas"
return self._success_response(dict, message, **fields)
except Exception as error:
return self._error_response(
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index 0d8553c..83f46c1 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -12,6 +12,7 @@ interface ConfigurationTabProps {
onSelect: (appid: string) => void;
onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
onEnable: (appid: string) => Promise<boolean>;
+ onEnableAll: () => Promise<void>;
onReset: () => Promise<void>;
onResetAll: () => Promise<void>;
}
@@ -23,29 +24,32 @@ export function ConfigurationTab({
onSelect,
onConfigChange,
onEnable,
+ onEnableAll,
onReset,
onResetAll,
}: ConfigurationTabProps) {
const [detailAppId, setDetailAppId] = useState<string | null>(null);
const [focusFpsMultiplier, setFocusFpsMultiplier] = useState(false);
- const [focusBackToGames, setFocusBackToGames] = useState(false);
+ const [focusDetailAction, setFocusDetailAction] = useState<"enable" | "back" | null>(null);
const backToGamesRef = useRef<HTMLDivElement>(null);
+ const enableRef = useRef<HTMLDivElement>(null);
const promptedRunningAppId = useRef<string | null>(null);
const closeDetails = useCallback(() => {
setFocusFpsMultiplier(false);
- setFocusBackToGames(false);
+ setFocusDetailAction(null);
setDetailAppId(null);
}, []);
const clearFpsFocusRequest = useCallback(() => setFocusFpsMultiplier(false), []);
useEffect(() => {
- if (!focusBackToGames) return;
+ if (!focusDetailAction) return;
const frame = requestAnimationFrame(() => {
- backToGamesRef.current?.querySelector<HTMLElement>('[role="button"]')?.focus();
- setFocusBackToGames(false);
+ const ref = focusDetailAction === "enable" ? enableRef : backToGamesRef;
+ ref.current?.querySelector<HTMLElement>('[role="button"]')?.focus();
+ setFocusDetailAction(null);
});
return () => cancelAnimationFrame(frame);
- }, [focusBackToGames]);
+ }, [focusDetailAction]);
useEffect(() => {
if (!runningGame || runningGame.configured) {
@@ -54,6 +58,7 @@ export function ConfigurationTab({
}
if (promptedRunningAppId.current !== runningGame.appid && detailAppId === null) {
promptedRunningAppId.current = runningGame.appid;
+ setFocusDetailAction(runningGame.configured ? "back" : "enable");
setDetailAppId(runningGame.appid);
}
}, [detailAppId, runningGame?.appid, runningGame?.configured]);
@@ -67,10 +72,11 @@ export function ConfigurationTab({
targets={targets}
runningGame={runningGame}
onSelect={(appid) => {
- setFocusBackToGames(true);
+ setFocusDetailAction(targets.find((target) => target.appid === appid)?.configured ? "back" : "enable");
onSelect(appid);
setDetailAppId(appid);
}}
+ onEnableAll={onEnableAll}
onResetAll={onResetAll}
/>
</PanelSection>
@@ -79,8 +85,17 @@ export function ConfigurationTab({
const profileLabel = selectedTarget?.name || "Game profile";
const profileDescription = selectedTarget
- ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "Configured" : "Not configured"}`
+ ? `${selectedTarget.nonSteam ? "Non-Steam" : "Steam"} · App ID ${selectedTarget.appid} · ${selectedTarget.configured ? "LSFG-VK Enabled" : "LSFG-VK not enabled"}`
: "Game is no longer available";
+ const handleProfileAction = async () => {
+ if (selectedTarget?.configured) {
+ promptedRunningAppId.current = detailAppId;
+ await onReset();
+ closeDetails();
+ } else if (detailAppId && await onEnable(detailAppId)) {
+ setFocusFpsMultiplier(true);
+ }
+ };
return (
<Focusable onCancelButton={closeDetails}>
@@ -88,6 +103,13 @@ export function ConfigurationTab({
<PanelSectionRow>
<Field label={profileLabel} description={profileDescription} />
</PanelSectionRow>
+ {!selectedTarget?.configured && selectedTarget && (
+ <PanelSectionRow>
+ <Focusable ref={enableRef} noFocusRing>
+ <ButtonItem layout="below" onClick={handleProfileAction}>Enable for next launch</ButtonItem>
+ </Focusable>
+ </PanelSectionRow>
+ )}
<PanelSectionRow>
<Focusable ref={backToGamesRef} noFocusRing>
<ButtonItem layout="below" onClick={closeDetails}>Back to games</ButtonItem>
@@ -102,22 +124,11 @@ export function ConfigurationTab({
onFpsMultiplierFocused={clearFpsFocusRequest}
/>
)}
- <PanelSectionRow>
- <ButtonItem
- layout="below"
- onClick={async () => {
- if (selectedTarget?.configured) {
- promptedRunningAppId.current = detailAppId;
- await onReset();
- closeDetails();
- } else if (detailAppId) {
- if (await onEnable(detailAppId)) setFocusFpsMultiplier(true);
- }
- }}
- >
- {selectedTarget?.configured ? "Remove profile" : "Enable for next launch"}
- </ButtonItem>
- </PanelSectionRow>
+ {selectedTarget?.configured && (
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={handleProfileAction}>Remove profile</ButtonItem>
+ </PanelSectionRow>
+ )}
</Focusable>
);
}
diff --git a/src/components/Content.tsx b/src/components/Content.tsx
index 1bc6e95..f1c8c11 100644
--- a/src/components/Content.tsx
+++ b/src/components/Content.tsx
@@ -6,7 +6,7 @@ import { tabStyles } from "../styles";
import { useGameConfiguration } from "../hooks/useGameConfiguration";
import { useInstallationActions } from "../hooks/useInstallationActions";
import { useInstallationStatus } from "../hooks/useLsfgHooks";
-// import { ConfigFileTab } from "./ConfigFileTab";
+import { ConfigFileTab } from "./ConfigFileTab";
import { ConfigurationTab } from "./ConfigurationTab";
import { FlatpaksTab } from "./FlatpaksTab";
import { NowPlayingTab } from "./NowPlayingTab";
@@ -38,6 +38,7 @@ export function Content() {
setSelectedAppId,
save,
enable,
+ enableAll,
resetSelected,
resetAll,
reload,
@@ -115,7 +116,6 @@ export function Content() {
game={runningGame}
config={config}
onConfigChange={handleConfigChange}
- onRemove={resetSelected}
/>
),
}] : []),
@@ -130,14 +130,14 @@ export function Content() {
onSelect={setSelectedAppId}
onConfigChange={handleConfigChange}
onEnable={enable}
+ onEnableAll={enableAll}
onReset={resetSelected}
onResetAll={resetAll}
/>
),
},
{ id: "Flatpak", title: tabIcons.flatpak, content: <FlatpaksTab /> },
- // Keep the configuration-file view available for future use without exposing it in the UI.
- // { id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> },
+ { id: "ConfigFile", title: tabIcons.configFile, content: <ConfigFileTab /> }, // comment out for prod
{ id: "Setup", title: tabIcons.setup, content: setupContent },
]
: [
diff --git a/src/components/GameConfigurationSelector.tsx b/src/components/GameConfigurationSelector.tsx
index 91fdc39..a046594 100644
--- a/src/components/GameConfigurationSelector.tsx
+++ b/src/components/GameConfigurationSelector.tsx
@@ -7,10 +7,11 @@ interface Props {
targets: GameTarget[];
runningGame: GameTarget | null;
onSelect: (appid: string) => void;
+ onEnableAll: () => Promise<void>;
onResetAll: () => Promise<void>;
}
-const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed";
+const CONFIGURED_COLLAPSED_KEY = "lsfg-configured-games-collapsed-v2";
const AVAILABLE_COLLAPSED_KEY = "lsfg-available-games-collapsed";
function usePersistentCollapsed(key: string) {
@@ -73,7 +74,7 @@ function GameGroup({
);
}
-export function GameConfigurationSelector({ targets, runningGame, onSelect, onResetAll }: Props) {
+export function GameConfigurationSelector({ targets, runningGame, onSelect, onEnableAll, onResetAll }: Props) {
const sortGames = (games: GameTarget[]) => [...games].sort((a, b) => {
if (a.appid === runningGame?.appid) return -1;
if (b.appid === runningGame?.appid) return 1;
@@ -81,8 +82,14 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onRe
});
const configuredGames = sortGames(targets.filter((game) => game.configured));
const availableGames = sortGames(targets.filter((game) => !game.configured));
+ const configuredSteamGames = configuredGames.filter((game) => !game.nonSteam);
+ const configuredNonSteamGames = configuredGames.filter((game) => game.nonSteam);
+ const availableSteamGames = availableGames.filter((game) => !game.nonSteam);
+ const availableNonSteamGames = availableGames.filter((game) => game.nonSteam);
const [configuredCollapsed, toggleConfigured] = usePersistentCollapsed(CONFIGURED_COLLAPSED_KEY);
+ const [configuredNonSteamCollapsed, toggleConfiguredNonSteam] = usePersistentCollapsed(`${CONFIGURED_COLLAPSED_KEY}-non-steam`);
const [availableCollapsed, toggleAvailable] = usePersistentCollapsed(AVAILABLE_COLLAPSED_KEY);
+ const [availableNonSteamCollapsed, toggleAvailableNonSteam] = usePersistentCollapsed(`${AVAILABLE_COLLAPSED_KEY}-non-steam`);
const confirmResetAll = () => {
showModal(
<ConfirmModal
@@ -94,6 +101,18 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onRe
/>,
);
};
+ const confirmEnableAll = () => {
+ showModal(
+ <ConfirmModal
+ strTitle="Enable all available games?"
+ strDescription="Create individual LSFG-VK profiles for every available game using the plugin defaults."
+ strOKButtonText="Enable all"
+ strCancelButtonText="Cancel"
+ onOK={() => void onEnableAll()}
+ onCancel={() => {}}
+ />,
+ );
+ };
return (
<>
@@ -102,20 +121,41 @@ export function GameConfigurationSelector({ targets, runningGame, onSelect, onRe
<Field label="No installed games" description="Steam has not reported any eligible games" />
</PanelSectionRow>
)}
+ {availableGames.length > 0 && (
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={confirmEnableAll}>
+ Enable all available games
+ </ButtonItem>
+ </PanelSectionRow>
+ )}
<GameGroup
- title="Configured"
- games={configuredGames}
+ title="LSFG-VK Enabled"
+ games={configuredSteamGames}
collapsed={configuredCollapsed}
onToggle={toggleConfigured}
onSelect={onSelect}
/>
<GameGroup
+ title="LSFG-VK Enabled (Non-Steam)"
+ games={configuredNonSteamGames}
+ collapsed={configuredNonSteamCollapsed}
+ onToggle={toggleConfiguredNonSteam}
+ onSelect={onSelect}
+ />
+ <GameGroup
title="Available games"
- games={availableGames}
+ games={availableSteamGames}
collapsed={availableCollapsed}
onToggle={toggleAvailable}
onSelect={onSelect}
/>
+ <GameGroup
+ title="Available games (Non-Steam)"
+ games={availableNonSteamGames}
+ collapsed={availableNonSteamCollapsed}
+ onToggle={toggleAvailableNonSteam}
+ onSelect={onSelect}
+ />
<PanelSectionRow>
<ButtonItem
layout="below"
diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx
index b4f0557..8d2d779 100644
--- a/src/components/NowPlayingTab.tsx
+++ b/src/components/NowPlayingTab.tsx
@@ -1,4 +1,4 @@
-import { ButtonItem, Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
+import { Field, Focusable, PanelSection, PanelSectionRow } from "@decky/ui";
import { ConfigurationData } from "../config/configSchema";
import { GameTarget } from "../hooks/useGameConfiguration";
import { GameConfigurationControls } from "./GameConfigurationControls";
@@ -7,26 +7,20 @@ interface Props {
game: GameTarget;
config: ConfigurationData;
onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
- onRemove: () => Promise<void>;
}
-export function NowPlayingTab({ game, config, onConfigChange, onRemove }: Props) {
+export function NowPlayingTab({ game, config, onConfigChange }: Props) {
return (
<Focusable>
- <PanelSection title="Now Playing">
+ <PanelSection>
<PanelSectionRow>
<Field
label={game.name}
- description={`${game.nonSteam ? "Non-Steam" : "Steam"} · App ID ${game.appid} · Configured`}
+ description={`${game.nonSteam ? "Non-Steam" : "Steam"} | App ID ${game.appid}`}
/>
</PanelSectionRow>
</PanelSection>
<GameConfigurationControls config={config} onConfigChange={onConfigChange} />
- <PanelSectionRow>
- <ButtonItem layout="below" onClick={() => void onRemove()}>
- Remove profile
- </ButtonItem>
- </PanelSectionRow>
</Focusable>
);
}
diff --git a/src/config/generatedConfigSchema.ts b/src/config/generatedConfigSchema.ts
index 71459eb..3668b5b 100644
--- a/src/config/generatedConfigSchema.ts
+++ b/src/config/generatedConfigSchema.ts
@@ -21,11 +21,11 @@ export const CONFIG_SCHEMA: Record<string, ConfigField> = {
active_in: { name: "active_in", fieldType: ConfigFieldType.ARRAY, default: [], description: "Steam AppID or executable identifiers" },
pacing_mode: { name: "pacing_mode", fieldType: ConfigFieldType.STRING, default: "vsync", description: "Frame pacing mode" },
multiplier: { name: "multiplier", fieldType: ConfigFieldType.INTEGER, default: 2, description: "Frame generation multiplier" },
- flow_scale: { name: "flow_scale", fieldType: ConfigFieldType.FLOAT, default: 1, description: "Motion estimation resolution scale" },
+ flow_scale: { name: "flow_scale", fieldType: ConfigFieldType.FLOAT, default: 0.8, description: "Motion estimation resolution scale" },
performance_mode: { name: "performance_mode", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Use the lighter frame generation model" },
override_present_mode: { name: "override_present_mode", fieldType: ConfigFieldType.BOOLEAN, default: true, description: "Override present mode" },
preserve_swapchain_image_count: { name: "preserve_swapchain_image_count", fieldType: ConfigFieldType.BOOLEAN, default: false, description: "Preserve the swapchain image count" },
};
export function getFieldNames(): string[] { return Object.keys(CONFIG_SCHEMA); }
-export function getDefaults(): ConfigurationData { return { dll: "", no_fp16: false, active_in: [], pacing_mode: "vsync", multiplier: 2, flow_scale: 1, performance_mode: false, override_present_mode: true, preserve_swapchain_image_count: false }; }
+export function getDefaults(): ConfigurationData { return { dll: "", no_fp16: false, active_in: [], pacing_mode: "vsync", multiplier: 2, flow_scale: 0.8, performance_mode: false, override_present_mode: true, preserve_swapchain_image_count: false }; }
export function getFieldTypes(): Record<string, ConfigFieldType> { return Object.fromEntries(Object.entries(CONFIG_SCHEMA).map(([key, value]) => [key, value.fieldType])); }
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index d279a8c..597edca 100644
--- a/src/hooks/useGameConfiguration.ts
+++ b/src/hooks/useGameConfiguration.ts
@@ -1,10 +1,36 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useQuickAccessVisible } from "@decky/api";
import { Router } from "@decky/ui";
import { getGameConfigs, getInstalledGames, updateGameConfig, resetGameConfig, resetAllGameConfigs, type GameConfigEntry, type GlobalConfig, type InstalledGame } from "../api/lsfgApi";
import { ConfigurationData, getDefaults } from "../config/configSchema";
+import { showErrorToast } from "../utils/toastUtils";
export interface GameTarget extends InstalledGame { configured: boolean; }
+async function getSteamShortcuts(): Promise<InstalledGame[]> {
+ const apps = (globalThis as any).SteamClient?.Apps;
+ if (typeof apps?.GetAllShortcuts !== "function") return [];
+
+ try {
+ const shortcuts = await apps.GetAllShortcuts();
+ if (!Array.isArray(shortcuts)) return [];
+ return shortcuts.flatMap((shortcut: any) => {
+ const appid = Number(shortcut?.appid);
+ const name = shortcut?.data?.strAppName;
+ if (!Number.isInteger(appid) || appid === 0 || typeof name !== "string" || !name) return [];
+ return [{ appid: String(appid >>> 0), name, nonSteam: true }];
+ });
+ } catch {
+ return [];
+ }
+}
+
+function mergeInstalledGames(backendGames: InstalledGame[], shortcutGames: InstalledGame[]) {
+ const games = new Map(backendGames.map((game) => [game.appid, game]));
+ for (const game of shortcutGames) games.set(game.appid, game);
+ return Array.from(games.values());
+}
+
export function useGameConfiguration() {
const [games, setGames] = useState<GameConfigEntry[]>([]);
const [globalConfig, setGlobalConfig] = useState<GlobalConfig>({ dll: "", no_fp16: false });
@@ -13,18 +39,25 @@ export function useGameConfiguration() {
const [selectedAppId, setSelectedAppId] = useState("");
const [runningGame, setRunningGame] = useState<GameTarget | null>(null);
const previousRunningAppId = useRef<string | null>(null);
+ const previousQuickAccessVisible = useRef<boolean | null>(null);
+ const quickAccessVisible = useQuickAccessVisible();
const load = useCallback(async () => {
- const [result, installed] = await Promise.all([getGameConfigs(), getInstalledGames()]);
+ const [result, installed, shortcuts] = await Promise.all([getGameConfigs(), getInstalledGames(), getSteamShortcuts()]);
if (result.success) {
setGlobalConfig(result.global_config || { dll: "", no_fp16: false });
setGames(result.games || []);
}
- if (installed.success) setInstalledGames(installed.games || []);
+ setInstalledGames(mergeInstalledGames(installed.success ? installed.games || [] : [], shortcuts));
setConfigsLoaded(true);
}, []);
- useEffect(() => { load(); }, [load]);
+ useEffect(() => {
+ const initialLoad = previousQuickAccessVisible.current === null;
+ const becameVisible = quickAccessVisible && previousQuickAccessVisible.current === false;
+ previousQuickAccessVisible.current = quickAccessVisible;
+ if (initialLoad || becameVisible) void load();
+ }, [load, quickAccessVisible]);
useEffect(() => {
const poll = () => {
if (!configsLoaded) return;
@@ -75,6 +108,18 @@ export function useGameConfiguration() {
if (result.success) await load();
return result.success;
}, [load, targets, template]);
+ const enableAll = useCallback(async (): Promise<void> => {
+ const available = targets.filter((target) => !target.configured && target.name);
+ if (available.length === 0) return;
+ for (const target of available) {
+ const result = await updateGameConfig(target.appid, target.name, template);
+ if (!result.success) {
+ showErrorToast("Could not enable all games", result.error || "A game profile could not be created");
+ return;
+ }
+ }
+ await load();
+ }, [load, targets, template]);
const resetSelected = useCallback(async () => {
if (selectedAppId) {
@@ -95,5 +140,5 @@ export function useGameConfiguration() {
}
}, [load]);
- return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, resetSelected, resetAll, reload: load };
+ return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load };
}