summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--defaults/i18n/ja.json16
-rw-r--r--defaults/i18n/ko.json16
-rw-r--r--defaults/i18n/template.json16
-rw-r--r--package.json2
-rw-r--r--src/components/ConfigurationTab.tsx2
-rw-r--r--src/components/GameConfigurationControls.tsx9
-rw-r--r--src/components/NowPlayingTab.tsx2
-rw-r--r--src/components/WorkaroundsSection.tsx190
-rw-r--r--src/components/index.ts1
-rw-r--r--src/hooks/useGameConfiguration.ts27
-rw-r--r--src/hooks/usePerAppWorkarounds.ts173
-rw-r--r--src/i18n/languages.json48
-rw-r--r--src/types.d.ts24
-rw-r--r--src/utils/steamLaunchOptionParser.ts489
-rw-r--r--src/utils/steamLaunchOptions.ts211
-rw-r--r--tests/steamLaunchOptions.test.ts288
16 files changed, 1448 insertions, 66 deletions
diff --git a/defaults/i18n/ja.json b/defaults/i18n/ja.json
index 92a8701..fb4a3ad 100644
--- a/defaults/i18n/ja.json
+++ b/defaults/i18n/ja.json
@@ -9,7 +9,7 @@
"CONFIG_FLOW_SCALE_DESC": "内部モーション推定解像度を下げて、パフォーマンスをわずかに向上させます",
"CONFIG_BASE_FPS_CAP": "基本FPS上限",
"CONFIG_BASE_FPS_CAP_OFF": "オフ",
- "CONFIG_BASE_FPS_CAP_DESC": "フレーム倍率適用前のDirectXゲームの基本フレームレート上限。(ゲームの再起動が必要)",
+ "CONFIG_BASE_FPS_CAP_DESC": "フレーム生成前のDXVKゲームの基本上限。0で無効。ゲームの再起動が必要です。",
"CONFIG_PRESENT_MODE": "プレゼンテーションモード",
"CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync",
"CONFIG_PRESENT_MODE_MAILBOX": "Mailbox",
@@ -20,18 +20,14 @@
"CONFIG_HDR_MODE_DESC": "HDRモードを有効化します(HDRをサポートするゲームのみ)",
"CONFIG_ENABLE_WSI": "WSIを有効化",
"CONFIG_ENABLE_WSI_DESC": "Gamescope WSIレイヤーを再有効化します。ゲームの再起動が必要。",
- "CONFIG_ENABLE_WOW64": "32ビットゲーム用WOW64を有効化",
- "CONFIG_ENABLE_WOW64_DESC": "32ビットゲームにPROTON_USE_WOW64=1を有効化します(ProtonGEと併用してクラッシュを修正)",
+ "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSIを無効化",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDRを変更せずENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。",
"CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deckモードを無効化",
- "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Steam Deckモードを無効化します(一部ゲームの隠し設定を解放)",
- "CONFIG_MANGOHUD_WORKAROUND": "MangoHudワークアラウンド",
- "CONFIG_MANGOHUD_WORKAROUND_DESC": "透明なMangoHudオーバーレイを有効化します。ゲームモードでの2X倍率問題を修正することがあります",
+ "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "ゲーム固有のSteam Deck互換スイッチを無効化します。ゲームの再起動が必要です。",
"CONFIG_DISABLE_VKBASALT": "vkBasaltを無効化",
"CONFIG_DISABLE_VKBASALT_DESC": "LSFGと競合する可能性のあるvkBasaltレイヤーを無効化します(Reshade、一部のDeckyプラグイン)",
- "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasaltを強制有効化",
- "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "ゲームモードのフレームペーシング問題を修正するためにvkBasaltを強制有効化します",
- "CONFIG_ENABLE_ZINK": "OpenGLゲーム用Zinkを有効化",
- "CONFIG_ENABLE_ZINK_DESC": "OpenGLゲームにVulkanベースのOpenGL実装を使用します(一部のゲームでクラッシュやフリーズが発生する場合があります)",
+ "CONFIG_ENABLE_ZINK": "OpenGLゲームでZinkを強制",
+ "CONFIG_ENABLE_ZINK_DESC": "MesaのZink OpenGL-to-Vulkanドライバーを使用します。一部のゲームでクラッシュやフリーズが発生する可能性があります。ゲームの再起動が必要です。",
"INSTALL_INSTALLING": "インストール中...",
"INSTALL_UNINSTALLING": "アンインストール中...",
"INSTALL_UNINSTALL_BTN": "LSFG-VKをアンインストール",
diff --git a/defaults/i18n/ko.json b/defaults/i18n/ko.json
index 947640d..29783c4 100644
--- a/defaults/i18n/ko.json
+++ b/defaults/i18n/ko.json
@@ -9,7 +9,7 @@
"CONFIG_FLOW_SCALE_DESC": "내부 모션 추정 해상도를 낮춰 성능을 약간 향상시킵니다",
"CONFIG_BASE_FPS_CAP": "기본 FPS 상한",
"CONFIG_BASE_FPS_CAP_OFF": "끄기",
- "CONFIG_BASE_FPS_CAP_DESC": "프레임 배율 적용 전 DirectX 게임의 기본 프레임 상한. (게임 재시작 필요)",
+ "CONFIG_BASE_FPS_CAP_DESC": "프레임 생성 전 DXVK 게임의 기본 제한입니다. 0은 비활성화합니다. 게임 재시작 필요.",
"CONFIG_PRESENT_MODE": "프레젠테이션 모드",
"CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync",
"CONFIG_PRESENT_MODE_MAILBOX": "Mailbox",
@@ -20,18 +20,14 @@
"CONFIG_HDR_MODE_DESC": "HDR 모드를 활성화합니다 (HDR을 지원하는 게임에만 해당)",
"CONFIG_ENABLE_WSI": "WSI 활성화",
"CONFIG_ENABLE_WSI_DESC": "Gamescope WSI 레이어를 다시 활성화합니다. 게임 재시작 필요.",
- "CONFIG_ENABLE_WOW64": "32비트 게임용 WOW64 활성화",
- "CONFIG_ENABLE_WOW64_DESC": "32비트 게임에 PROTON_USE_WOW64=1을 활성화합니다 (크래시 수정을 위해 ProtonGE와 함께 사용)",
+ "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSI 비활성화",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDR을 변경하지 않고 ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.",
"CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deck 모드 비활성화",
- "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Steam Deck 모드를 비활성화합니다 (일부 게임의 숨겨진 설정 잠금 해제)",
- "CONFIG_MANGOHUD_WORKAROUND": "MangoHud 우회",
- "CONFIG_MANGOHUD_WORKAROUND_DESC": "투명한 MangoHud 오버레이를 활성화합니다. 게임 모드에서 2X 배율 문제를 수정하는 데 도움이 될 수 있습니다",
+ "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "게임별 Steam Deck 호환 스위치를 비활성화합니다. 게임 재시작 필요.",
"CONFIG_DISABLE_VKBASALT": "vkBasalt 비활성화",
"CONFIG_DISABLE_VKBASALT_DESC": "LSFG와 충돌할 수 있는 vkBasalt 레이어를 비활성화합니다 (Reshade, 일부 Decky 플러그인)",
- "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasalt 강제 활성화",
- "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "게임 모드에서 프레임 페이싱 문제 수정을 위해 vkBasalt를 강제 활성화합니다",
- "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 활성화",
- "CONFIG_ENABLE_ZINK_DESC": "OpenGL 게임에 Vulkan 기반 OpenGL 구현을 사용합니다 (일부 게임에서 크래시나 멈춤이 발생할 수 있습니다)",
+ "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 강제",
+ "CONFIG_ENABLE_ZINK_DESC": "Mesa의 Zink OpenGL-to-Vulkan 드라이버를 사용합니다. 일부 게임에서 충돌 또는 멈춤이 발생할 수 있으며 게임 재시작이 필요합니다.",
"INSTALL_INSTALLING": "설치 중...",
"INSTALL_UNINSTALLING": "제거 중...",
"INSTALL_UNINSTALL_BTN": "LSFG-VK 제거",
diff --git a/defaults/i18n/template.json b/defaults/i18n/template.json
index fce97b4..2a5b06e 100644
--- a/defaults/i18n/template.json
+++ b/defaults/i18n/template.json
@@ -9,7 +9,7 @@
"CONFIG_FLOW_SCALE_DESC": "Lowers internal motion estimation resolution, improving performance slightly",
"CONFIG_BASE_FPS_CAP": "Base FPS Cap",
"CONFIG_BASE_FPS_CAP_OFF": "Off",
- "CONFIG_BASE_FPS_CAP_DESC": "Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)",
+ "CONFIG_BASE_FPS_CAP_DESC": "Base cap for DXVK-backed games before frame generation; 0 disables. Requires game restart to apply.",
"CONFIG_PRESENT_MODE": "Present Mode",
"CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync",
"CONFIG_PRESENT_MODE_MAILBOX": "Mailbox",
@@ -20,18 +20,14 @@
"CONFIG_HDR_MODE_DESC": "Enables HDR mode (only for games that support HDR)",
"CONFIG_ENABLE_WSI": "Enable WSI",
"CONFIG_ENABLE_WSI_DESC": "Re-Enable Gamescope WSI Layer. Requires game restart to apply.",
- "CONFIG_ENABLE_WOW64": "Enable WOW64 for 32-bit games",
- "CONFIG_ENABLE_WOW64_DESC": "Enables PROTON_USE_WOW64=1 for 32-bit games (Use with ProtonGE to fix crashing)",
+ "CONFIG_DISABLE_GAMESCOPE_WSI": "Disable Gamescope WSI",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.",
"CONFIG_DISABLE_STEAMDECK_MODE": "Disable Steam Deck Mode",
- "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables Steam Deck mode (Unlocks hidden settings in some games)",
- "CONFIG_MANGOHUD_WORKAROUND": "MangoHud Workaround",
- "CONFIG_MANGOHUD_WORKAROUND_DESC": "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode",
+ "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.",
"CONFIG_DISABLE_VKBASALT": "Disable vkBasalt",
"CONFIG_DISABLE_VKBASALT_DESC": "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)",
- "CONFIG_FORCE_ENABLE_VKBASALT": "Force Enable vkBasalt",
- "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "Force vkBasalt to engage to fix framepacing issues in gamemode",
- "CONFIG_ENABLE_ZINK": "Enable Zink for OpenGL Games",
- "CONFIG_ENABLE_ZINK_DESC": "Use Vulkan-based OpenGL implementation for OpenGL games (may cause crashes or freezes with some games)",
+ "CONFIG_ENABLE_ZINK": "Force Zink for OpenGL Games",
+ "CONFIG_ENABLE_ZINK_DESC": "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes with some games. Requires game restart to apply.",
"INSTALL_INSTALLING": "Installing...",
"INSTALL_UNINSTALLING": "Uninstalling...",
"INSTALL_UNINSTALL_BTN": "Uninstall LSFG-VK",
diff --git a/package.json b/package.json
index 8deb8a7..b5e716c 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w",
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "node --experimental-strip-types --test tests/steamLaunchOptions.test.ts && python3 -m unittest discover -s tests -p 'test_*.py'"
},
"repository": {
"type": "git",
diff --git a/src/components/ConfigurationTab.tsx b/src/components/ConfigurationTab.tsx
index 6ba8f5d..2bb0f26 100644
--- a/src/components/ConfigurationTab.tsx
+++ b/src/components/ConfigurationTab.tsx
@@ -149,6 +149,8 @@ export function ConfigurationTab({
onConfigChange={onConfigChange}
autoFocusFpsMultiplier={focusFpsMultiplier}
onFpsMultiplierFocused={clearFpsFocusRequest}
+ showWorkarounds
+ workaroundTarget={selectedTarget || undefined}
/>
)}
{selectedTarget?.configured && (
diff --git a/src/components/GameConfigurationControls.tsx b/src/components/GameConfigurationControls.tsx
index 34b82c5..58ccd02 100644
--- a/src/components/GameConfigurationControls.tsx
+++ b/src/components/GameConfigurationControls.tsx
@@ -1,12 +1,16 @@
import { ConfigurationData } from "../config/configSchema";
+import type { GameTarget } from "../hooks/useGameConfiguration";
import { ConfigurationSection } from "./ConfigurationSection";
import { FpsMultiplierControl } from "./FpsMultiplierControl";
+import { WorkaroundsSection } from "./WorkaroundsSection";
interface Props {
config: ConfigurationData;
onConfigChange: (fieldName: keyof ConfigurationData, value: boolean | number | string | string[]) => Promise<void>;
autoFocusFpsMultiplier?: boolean;
onFpsMultiplierFocused?: () => void;
+ showWorkarounds?: boolean;
+ workaroundTarget?: Pick<GameTarget, "appid" | "nonSteam">;
}
export function GameConfigurationControls({
@@ -14,6 +18,8 @@ export function GameConfigurationControls({
onConfigChange,
autoFocusFpsMultiplier,
onFpsMultiplierFocused,
+ showWorkarounds = false,
+ workaroundTarget,
}: Props) {
return (
<>
@@ -24,6 +30,9 @@ export function GameConfigurationControls({
onAutoFocus={onFpsMultiplierFocused}
/>
<ConfigurationSection config={config} onConfigChange={onConfigChange} />
+ {showWorkarounds && workaroundTarget && (
+ <WorkaroundsSection appId={workaroundTarget.appid} nonSteam={workaroundTarget.nonSteam} />
+ )}
</>
);
}
diff --git a/src/components/NowPlayingTab.tsx b/src/components/NowPlayingTab.tsx
index 2ec12ca..188c56a 100644
--- a/src/components/NowPlayingTab.tsx
+++ b/src/components/NowPlayingTab.tsx
@@ -19,7 +19,7 @@ export function NowPlayingTab({ game, config, onConfigChange }: Props) {
/>
</PanelSectionRow>
</PanelSection>
- <GameConfigurationControls config={config} onConfigChange={onConfigChange} />
+ <GameConfigurationControls config={config} onConfigChange={onConfigChange} showWorkarounds={false} />
</Focusable>
);
}
diff --git a/src/components/WorkaroundsSection.tsx b/src/components/WorkaroundsSection.tsx
new file mode 100644
index 0000000..e990784
--- /dev/null
+++ b/src/components/WorkaroundsSection.tsx
@@ -0,0 +1,190 @@
+import { ButtonItem, Field, PanelSectionRow, SliderField, ToggleField } from "@decky/ui";
+import { useEffect, useState } from "react";
+import { RiArrowDownSFill, RiArrowUpSFill } from "react-icons/ri";
+import { usePerAppWorkarounds } from "../hooks/usePerAppWorkarounds";
+import t from "../i18n/i18n";
+import type { WorkaroundField } from "../utils/steamLaunchOptions";
+
+interface WorkaroundsSectionProps {
+ appId: string;
+ nonSteam: boolean;
+}
+
+const WORKAROUNDS_COLLAPSED_KEY = "lsfg-workarounds-collapsed";
+type ToggleWorkaroundField = Exclude<WorkaroundField, "dxvkFrameRate">;
+
+const TOGGLE_ROWS: readonly {
+ field: ToggleWorkaroundField;
+ labelKey: string;
+ label: string;
+ descriptionKey: string;
+ description: string;
+}[] = [
+ {
+ field: "disableGamescopeWsi",
+ labelKey: "CONFIG_DISABLE_GAMESCOPE_WSI",
+ label: "Disable Gamescope WSI",
+ descriptionKey: "CONFIG_DISABLE_GAMESCOPE_WSI_DESC",
+ description: "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.",
+ },
+ {
+ field: "disableSteamdeckMode",
+ labelKey: "CONFIG_DISABLE_STEAMDECK_MODE",
+ label: "Disable Steam Deck Mode",
+ descriptionKey: "CONFIG_DISABLE_STEAMDECK_MODE_DESC",
+ description: "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.",
+ },
+ {
+ field: "disableVkbasalt",
+ labelKey: "CONFIG_DISABLE_VKBASALT",
+ label: "Disable vkBasalt",
+ descriptionKey: "CONFIG_DISABLE_VKBASALT_DESC",
+ description: "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)",
+ },
+ {
+ field: "enableZink",
+ labelKey: "CONFIG_ENABLE_ZINK",
+ label: "Force Zink for OpenGL Games",
+ descriptionKey: "CONFIG_ENABLE_ZINK_DESC",
+ description: "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes with some games. Requires game restart to apply.",
+ },
+];
+
+function usePersistentCollapsed() {
+ const [collapsed, setCollapsed] = useState(() => {
+ try {
+ const saved = localStorage.getItem(WORKAROUNDS_COLLAPSED_KEY);
+ return saved !== null ? JSON.parse(saved) === true : true;
+ } catch {
+ return true;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(WORKAROUNDS_COLLAPSED_KEY, JSON.stringify(collapsed));
+ } catch {
+ // Persisting the view preference is optional.
+ }
+ }, [collapsed]);
+
+ return [collapsed, () => setCollapsed((value) => !value)] as const;
+}
+
+export function WorkaroundsSection({ appId, nonSteam }: WorkaroundsSectionProps) {
+ const [collapsed, toggleCollapsed] = usePersistentCollapsed();
+ const { status, snapshot, refresh, update, error } = usePerAppWorkarounds(appId, nonSteam);
+ const state = snapshot?.parsed.state;
+ const issues = snapshot?.parsed.issues || [];
+ const controlsDisabled = status !== "ready" || state === undefined;
+ const [fpsValue, setFpsValue] = useState<number | null>(null);
+ const effectiveFpsValue = fpsValue ?? state?.dxvkFrameRate ?? 0;
+ const fpsLabel = effectiveFpsValue > 0
+ ? `${effectiveFpsValue} FPS`
+ : t("CONFIG_BASE_FPS_CAP_OFF", "Off");
+
+ useEffect(() => {
+ setFpsValue(state?.dxvkFrameRate ?? null);
+ }, [state?.dxvkFrameRate, status]);
+
+ return (
+ <>
+ <style>
+ {`
+ .LSFG_WorkaroundsCollapseButton_Container > div > div > div > button,
+ .LSFG_WorkaroundsCollapseButton_Container > div > div > div > div > button {
+ height: 24px !important;
+ padding: 0 !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ }
+ `}
+ </style>
+ <PanelSectionRow>
+ <div
+ style={{
+ fontSize: "14px",
+ fontWeight: "bold",
+ marginTop: "8px",
+ marginBottom: "6px",
+ borderBottom: "1px solid rgba(255, 255, 255, 0.2)",
+ paddingBottom: "3px",
+ color: "white",
+ }}
+ >
+ {t("CONFIG_WORKAROUNDS_TITLE", "Workarounds")}
+ </div>
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <div className="LSFG_WorkaroundsCollapseButton_Container" style={{ marginTop: "-2px", marginBottom: "4px" }}>
+ <ButtonItem
+ layout="below"
+ bottomSeparator={collapsed ? "standard" : "none"}
+ onClick={toggleCollapsed}
+ >
+ {collapsed ? <RiArrowDownSFill /> : <RiArrowUpSFill />}
+ </ButtonItem>
+ </div>
+ </PanelSectionRow>
+
+ {!collapsed && (
+ <>
+ {status === "loading" && (
+ <PanelSectionRow>
+ <Field label="Reading launch options..." />
+ </PanelSectionRow>
+ )}
+ {status === "error" && (
+ <>
+ <PanelSectionRow>
+ <Field
+ label="Launch options unavailable"
+ description={error || "Steam did not provide readable launch options."}
+ />
+ </PanelSectionRow>
+ <PanelSectionRow>
+ <ButtonItem layout="below" onClick={() => void refresh()}>Retry</ButtonItem>
+ </PanelSectionRow>
+ </>
+ )}
+ {status === "ready" && issues.length > 0 && (
+ <PanelSectionRow>
+ <Field
+ label="Launch options need attention"
+ description={`${issues.join(" ")} Adjusting a workaround will normalize its managed values.`}
+ />
+ </PanelSectionRow>
+ )}
+
+ <PanelSectionRow>
+ <SliderField
+ label={`${t("CONFIG_BASE_FPS_CAP", "Base FPS Cap")} (${fpsLabel})`}
+ description={t("CONFIG_BASE_FPS_CAP_DESC", "Base cap for DXVK-backed games before frame generation; 0 disables. Requires game restart to apply.")}
+ value={effectiveFpsValue}
+ min={0}
+ max={60}
+ step={1}
+ onChange={(value) => {
+ setFpsValue(value);
+ void update("dxvkFrameRate", value);
+ }}
+ disabled={controlsDisabled}
+ />
+ </PanelSectionRow>
+ {TOGGLE_ROWS.map((row) => (
+ <PanelSectionRow key={row.field}>
+ <ToggleField
+ label={t(row.labelKey, row.label)}
+ description={t(row.descriptionKey, row.description)}
+ checked={Boolean(state?.[row.field])}
+ onChange={(value) => void update(row.field, value)}
+ disabled={controlsDisabled}
+ />
+ </PanelSectionRow>
+ ))}
+ </>
+ )}
+ </>
+ );
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index 424a360..37a8edb 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -10,3 +10,4 @@ export { FlatpaksTab } from "./FlatpaksTab";
export { GameConfigurationSelector } from "./GameConfigurationSelector";
export { GameConfigurationControls } from "./GameConfigurationControls";
export { NowPlayingTab } from "./NowPlayingTab";
+export { WorkaroundsSection } from "./WorkaroundsSection";
diff --git a/src/hooks/useGameConfiguration.ts b/src/hooks/useGameConfiguration.ts
index 597edca..7a572f9 100644
--- a/src/hooks/useGameConfiguration.ts
+++ b/src/hooks/useGameConfiguration.ts
@@ -3,6 +3,7 @@ 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 { cleanupSteamLaunchOptions } from "../utils/steamLaunchOptions";
import { showErrorToast } from "../utils/toastUtils";
export interface GameTarget extends InstalledGame { configured: boolean; }
@@ -94,6 +95,17 @@ export function useGameConfiguration() {
const template = useMemo(() => ({ ...getDefaults(), ...globalConfig }), [globalConfig]);
const config = games.find((game) => game.appid === selectedAppId)?.config || template;
+ const cleanupTargetLaunchOptions = useCallback(async (target: GameTarget): Promise<boolean> => {
+ if (!installedGames.some((game) => game.appid === target.appid)) return true;
+ try {
+ await cleanupSteamLaunchOptions(Number(target.appid), target.nonSteam);
+ return true;
+ } catch (error) {
+ showErrorToast("Could not update Steam launch options", error instanceof Error ? error.message : String(error));
+ return false;
+ }
+ }, [installedGames]);
+
const save = useCallback(async (next: ConfigurationData) => {
const selectedTarget = targets.find((target) => target.appid === selectedAppId);
if (!selectedTarget?.name) return;
@@ -104,14 +116,16 @@ export function useGameConfiguration() {
const enable = useCallback(async (appid: string) => {
const target = targets.find((item) => item.appid === appid);
if (!target?.name) return false;
+ if (!(await cleanupTargetLaunchOptions(target))) return false;
const result = await updateGameConfig(appid, target.name, template);
if (result.success) await load();
return result.success;
- }, [load, targets, template]);
+ }, [cleanupTargetLaunchOptions, 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) {
+ if (!(await cleanupTargetLaunchOptions(target))) return;
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");
@@ -119,10 +133,12 @@ export function useGameConfiguration() {
}
}
await load();
- }, [load, targets, template]);
+ }, [cleanupTargetLaunchOptions, load, targets, template]);
const resetSelected = useCallback(async () => {
if (selectedAppId) {
+ const selectedTarget = targets.find((target) => target.appid === selectedAppId);
+ if (selectedTarget && !(await cleanupTargetLaunchOptions(selectedTarget))) return;
const result = await resetGameConfig(selectedAppId);
if (result.success) {
setRunningGame((current) => current?.appid === selectedAppId ? { ...current, configured: false } : current);
@@ -130,15 +146,18 @@ export function useGameConfiguration() {
await load();
}
}
- }, [load, selectedAppId]);
+ }, [cleanupTargetLaunchOptions, load, selectedAppId, targets]);
const resetAll = useCallback(async () => {
+ for (const target of targets.filter((item) => item.configured)) {
+ if (!(await cleanupTargetLaunchOptions(target))) return;
+ }
const result = await resetAllGameConfigs();
if (result.success) {
setRunningGame((current) => current ? { ...current, configured: false } : current);
setSelectedAppId("");
await load();
}
- }, [load]);
+ }, [cleanupTargetLaunchOptions, load, targets]);
return { config, games, targets, runningGame, selectedAppId, setSelectedAppId, save, enable, enableAll, resetSelected, resetAll, reload: load };
}
diff --git a/src/hooks/usePerAppWorkarounds.ts b/src/hooks/usePerAppWorkarounds.ts
new file mode 100644
index 0000000..a937780
--- /dev/null
+++ b/src/hooks/usePerAppWorkarounds.ts
@@ -0,0 +1,173 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ applyWorkaroundChange,
+ parseWorkaroundOptions,
+ readSteamLaunchOptions,
+ subscribeSteamLaunchOptions,
+ updateSteamLaunchOptions,
+ type ParsedWorkaroundOptions,
+ type SteamLaunchOptionsSnapshot,
+ type WorkaroundField,
+} from "../utils/steamLaunchOptions";
+import { showErrorToast } from "../utils/toastUtils";
+
+export type WorkaroundLoadStatus = "loading" | "ready" | "error";
+
+const SLIDER_DEBOUNCE_MS = 250;
+
+interface PendingSliderUpdate {
+ timer: number;
+ value: number;
+ waiters: Array<(success: boolean) => void>;
+}
+
+interface WorkaroundSnapshot {
+ steam: SteamLaunchOptionsSnapshot;
+ parsed: ParsedWorkaroundOptions;
+}
+
+interface PerAppWorkarounds {
+ status: WorkaroundLoadStatus;
+ snapshot: WorkaroundSnapshot | null;
+ refresh: () => Promise<void>;
+ update: (field: WorkaroundField, value: boolean | number) => Promise<boolean>;
+ error: string | null;
+}
+
+function asError(error: unknown): Error {
+ return error instanceof Error ? error : new Error(String(error));
+}
+
+function makeSnapshot(steam: SteamLaunchOptionsSnapshot): WorkaroundSnapshot {
+ return { steam, parsed: parseWorkaroundOptions(steam.options) };
+}
+
+export function usePerAppWorkarounds(appId: string, nonSteam: boolean): PerAppWorkarounds {
+ const [status, setStatus] = useState<WorkaroundLoadStatus>("loading");
+ const [snapshot, setSnapshot] = useState<WorkaroundSnapshot | null>(null);
+ const [error, setError] = useState<string | null>(null);
+ const pendingSliderUpdate = useRef<PendingSliderUpdate | null>(null);
+ const numericAppId = Number(appId);
+
+ const applySnapshot = useCallback((steam: SteamLaunchOptionsSnapshot) => {
+ setSnapshot(makeSnapshot(steam));
+ setStatus("ready");
+ setError(null);
+ }, []);
+
+ const refresh = useCallback(async () => {
+ setStatus("loading");
+ setError(null);
+ try {
+ applySnapshot(await readSteamLaunchOptions(numericAppId, nonSteam));
+ } catch (refreshError) {
+ const nextError = asError(refreshError);
+ setStatus("error");
+ setError(nextError.message);
+ }
+ }, [applySnapshot, nonSteam, numericAppId]);
+
+ useEffect(() => {
+ let active = true;
+ setStatus("loading");
+ setSnapshot(null);
+ setError(null);
+
+ const handleSnapshot = (nextSnapshot: SteamLaunchOptionsSnapshot) => {
+ if (!active) return;
+ applySnapshot(nextSnapshot);
+ };
+ const handleSubscriptionError = (subscriptionError: Error) => {
+ if (!active) return;
+ setStatus("error");
+ setError(subscriptionError.message);
+ };
+
+ let unsubscribe = () => {};
+ try {
+ unsubscribe = subscribeSteamLaunchOptions(
+ numericAppId,
+ nonSteam,
+ handleSnapshot,
+ handleSubscriptionError,
+ );
+ } catch (subscriptionError) {
+ handleSubscriptionError(asError(subscriptionError));
+ }
+
+ void readSteamLaunchOptions(numericAppId, nonSteam)
+ .then((nextSnapshot) => {
+ if (active) applySnapshot(nextSnapshot);
+ })
+ .catch((readError) => {
+ if (active) handleSubscriptionError(asError(readError));
+ });
+
+ return () => {
+ active = false;
+ unsubscribe();
+ };
+ }, [applySnapshot, nonSteam, numericAppId]);
+
+ const persistUpdate = useCallback(async (field: WorkaroundField, value: boolean | number): Promise<boolean> => {
+ setError(null);
+ try {
+ const nextSnapshot = await updateSteamLaunchOptions(
+ numericAppId,
+ nonSteam,
+ (options) => applyWorkaroundChange(options, field, value),
+ );
+ applySnapshot(nextSnapshot);
+ return true;
+ } catch (updateError) {
+ const nextError = asError(updateError);
+ setStatus("error");
+ setError(nextError.message);
+ showErrorToast("Workaround update failed", nextError.message);
+ return false;
+ }
+ }, [applySnapshot, nonSteam, numericAppId]);
+
+ const flushSliderUpdate = useCallback(async (): Promise<boolean> => {
+ const pending = pendingSliderUpdate.current;
+ if (!pending) return true;
+
+ pendingSliderUpdate.current = null;
+ window.clearTimeout(pending.timer);
+ const success = await persistUpdate("dxvkFrameRate", pending.value);
+ pending.waiters.forEach((resolve) => resolve(success));
+ return success;
+ }, [persistUpdate]);
+
+ const update = useCallback(async (field: WorkaroundField, value: boolean | number): Promise<boolean> => {
+ if (field === "dxvkFrameRate") {
+ setError(null);
+ return new Promise<boolean>((resolve) => {
+ const pending = pendingSliderUpdate.current ?? { timer: 0, value: 0, waiters: [] };
+ window.clearTimeout(pending.timer);
+ pending.value = Number(value);
+ pending.waiters.push(resolve);
+ pending.timer = window.setTimeout(() => {
+ void flushSliderUpdate();
+ }, SLIDER_DEBOUNCE_MS);
+ pendingSliderUpdate.current = pending;
+ });
+ }
+
+ const sliderSuccess = await flushSliderUpdate();
+ if (!sliderSuccess) return false;
+ return persistUpdate(field, value);
+ }, [flushSliderUpdate, persistUpdate]);
+
+ useEffect(() => {
+ return () => {
+ const pending = pendingSliderUpdate.current;
+ if (!pending) return;
+ window.clearTimeout(pending.timer);
+ pendingSliderUpdate.current = null;
+ pending.waiters.forEach((resolve) => resolve(false));
+ };
+ }, [numericAppId, nonSteam]);
+
+ return useMemo(() => ({ status, snapshot, refresh, update, error }), [error, refresh, snapshot, status, update]);
+}
diff --git a/src/i18n/languages.json b/src/i18n/languages.json
index 3132083..3f1992c 100644
--- a/src/i18n/languages.json
+++ b/src/i18n/languages.json
@@ -10,7 +10,7 @@
"CONFIG_FLOW_SCALE_DESC": "内部モーション推定解像度を下げて、パフォーマンスをわずかに向上させます",
"CONFIG_BASE_FPS_CAP": "基本FPS上限",
"CONFIG_BASE_FPS_CAP_OFF": "オフ",
- "CONFIG_BASE_FPS_CAP_DESC": "フレーム倍率適用前のDirectXゲームの基本フレームレート上限。(ゲームの再起動が必要)",
+ "CONFIG_BASE_FPS_CAP_DESC": "フレーム生成前のDXVKゲームの基本上限。0で無効。ゲームの再起動が必要です。",
"CONFIG_PRESENT_MODE": "プレゼンテーションモード",
"CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync",
"CONFIG_PRESENT_MODE_MAILBOX": "Mailbox",
@@ -21,18 +21,14 @@
"CONFIG_HDR_MODE_DESC": "HDRモードを有効化します(HDRをサポートするゲームのみ)",
"CONFIG_ENABLE_WSI": "WSIを有効化",
"CONFIG_ENABLE_WSI_DESC": "Gamescope WSIレイヤーを再有効化します。ゲームの再起動が必要。",
- "CONFIG_ENABLE_WOW64": "32ビットゲーム用WOW64を有効化",
- "CONFIG_ENABLE_WOW64_DESC": "32ビットゲームにPROTON_USE_WOW64=1を有効化します(ProtonGEと併用してクラッシュを修正)",
+ "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSIを無効化",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDRを変更せずENABLE_GAMESCOPE_WSI=0を追加します。ゲームの再起動が必要です。",
"CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deckモードを無効化",
- "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Steam Deckモードを無効化します(一部ゲームの隠し設定を解放)",
- "CONFIG_MANGOHUD_WORKAROUND": "MangoHudワークアラウンド",
- "CONFIG_MANGOHUD_WORKAROUND_DESC": "透明なMangoHudオーバーレイを有効化します。ゲームモードでの2X倍率問題を修正することがあります",
+ "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "ゲーム固有のSteam Deck互換スイッチを無効化します。ゲームの再起動が必要です。",
"CONFIG_DISABLE_VKBASALT": "vkBasaltを無効化",
"CONFIG_DISABLE_VKBASALT_DESC": "LSFGと競合する可能性のあるvkBasaltレイヤーを無効化します(Reshade、一部のDeckyプラグイン)",
- "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasaltを強制有効化",
- "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "ゲームモードのフレームペーシング問題を修正するためにvkBasaltを強制有効化します",
- "CONFIG_ENABLE_ZINK": "OpenGLゲーム用Zinkを有効化",
- "CONFIG_ENABLE_ZINK_DESC": "OpenGLゲームにVulkanベースのOpenGL実装を使用します(一部のゲームでクラッシュやフリーズが発生する場合があります)",
+ "CONFIG_ENABLE_ZINK": "OpenGLゲームでZinkを強制",
+ "CONFIG_ENABLE_ZINK_DESC": "MesaのZink OpenGL-to-Vulkanドライバーを使用します。一部のゲームでクラッシュやフリーズが発生する可能性があります。ゲームの再起動が必要です。",
"INSTALL_INSTALLING": "インストール中...",
"INSTALL_UNINSTALLING": "アンインストール中...",
"INSTALL_UNINSTALL_BTN": "LSFG-VKをアンインストール",
@@ -101,7 +97,7 @@
"CONFIG_FLOW_SCALE_DESC": "내부 모션 추정 해상도를 낮춰 성능을 약간 향상시킵니다",
"CONFIG_BASE_FPS_CAP": "기본 FPS 상한",
"CONFIG_BASE_FPS_CAP_OFF": "끄기",
- "CONFIG_BASE_FPS_CAP_DESC": "프레임 배율 적용 전 DirectX 게임의 기본 프레임 상한. (게임 재시작 필요)",
+ "CONFIG_BASE_FPS_CAP_DESC": "프레임 생성 전 DXVK 게임의 기본 제한입니다. 0은 비활성화합니다. 게임 재시작 필요.",
"CONFIG_PRESENT_MODE": "프레젠테이션 모드",
"CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync",
"CONFIG_PRESENT_MODE_MAILBOX": "Mailbox",
@@ -112,18 +108,14 @@
"CONFIG_HDR_MODE_DESC": "HDR 모드를 활성화합니다 (HDR을 지원하는 게임에만 해당)",
"CONFIG_ENABLE_WSI": "WSI 활성화",
"CONFIG_ENABLE_WSI_DESC": "Gamescope WSI 레이어를 다시 활성화합니다. 게임 재시작 필요.",
- "CONFIG_ENABLE_WOW64": "32비트 게임용 WOW64 활성화",
- "CONFIG_ENABLE_WOW64_DESC": "32비트 게임에 PROTON_USE_WOW64=1을 활성화합니다 (크래시 수정을 위해 ProtonGE와 함께 사용)",
+ "CONFIG_DISABLE_GAMESCOPE_WSI": "Gamescope WSI 비활성화",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "HDR을 변경하지 않고 ENABLE_GAMESCOPE_WSI=0을 추가합니다. 게임 재시작 필요.",
"CONFIG_DISABLE_STEAMDECK_MODE": "Steam Deck 모드 비활성화",
- "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Steam Deck 모드를 비활성화합니다 (일부 게임의 숨겨진 설정 잠금 해제)",
- "CONFIG_MANGOHUD_WORKAROUND": "MangoHud 우회",
- "CONFIG_MANGOHUD_WORKAROUND_DESC": "투명한 MangoHud 오버레이를 활성화합니다. 게임 모드에서 2X 배율 문제를 수정하는 데 도움이 될 수 있습니다",
+ "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "게임별 Steam Deck 호환 스위치를 비활성화합니다. 게임 재시작 필요.",
"CONFIG_DISABLE_VKBASALT": "vkBasalt 비활성화",
"CONFIG_DISABLE_VKBASALT_DESC": "LSFG와 충돌할 수 있는 vkBasalt 레이어를 비활성화합니다 (Reshade, 일부 Decky 플러그인)",
- "CONFIG_FORCE_ENABLE_VKBASALT": "vkBasalt 강제 활성화",
- "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "게임 모드에서 프레임 페이싱 문제 수정을 위해 vkBasalt를 강제 활성화합니다",
- "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 활성화",
- "CONFIG_ENABLE_ZINK_DESC": "OpenGL 게임에 Vulkan 기반 OpenGL 구현을 사용합니다 (일부 게임에서 크래시나 멈춤이 발생할 수 있습니다)",
+ "CONFIG_ENABLE_ZINK": "OpenGL 게임에 Zink 강제",
+ "CONFIG_ENABLE_ZINK_DESC": "Mesa의 Zink OpenGL-to-Vulkan 드라이버를 사용합니다. 일부 게임에서 충돌 또는 멈춤이 발생할 수 있으며 게임 재시작이 필요합니다.",
"INSTALL_INSTALLING": "설치 중...",
"INSTALL_UNINSTALLING": "제거 중...",
"INSTALL_UNINSTALL_BTN": "LSFG-VK 제거",
@@ -220,7 +212,7 @@
"CONFIG_FLOW_SCALE_DESC": "Lowers internal motion estimation resolution, improving performance slightly",
"CONFIG_BASE_FPS_CAP": "Base FPS Cap",
"CONFIG_BASE_FPS_CAP_OFF": "Off",
- "CONFIG_BASE_FPS_CAP_DESC": "Base framerate cap for DirectX games, before frame multiplier. (Requires game restart to apply)",
+ "CONFIG_BASE_FPS_CAP_DESC": "Base cap for DXVK-backed games before frame generation; 0 disables. Requires game restart to apply.",
"CONFIG_PRESENT_MODE": "Present Mode",
"CONFIG_PRESENT_MODE_FIFO": "FIFO - VSync",
"CONFIG_PRESENT_MODE_MAILBOX": "Mailbox",
@@ -231,18 +223,14 @@
"CONFIG_HDR_MODE_DESC": "Enables HDR mode (only for games that support HDR)",
"CONFIG_ENABLE_WSI": "Enable WSI",
"CONFIG_ENABLE_WSI_DESC": "Re-Enable Gamescope WSI Layer. Requires game restart to apply.",
- "CONFIG_ENABLE_WOW64": "Enable WOW64 for 32-bit games",
- "CONFIG_ENABLE_WOW64_DESC": "Enables PROTON_USE_WOW64=1 for 32-bit games (Use with ProtonGE to fix crashing)",
+ "CONFIG_DISABLE_GAMESCOPE_WSI": "Disable Gamescope WSI",
+ "CONFIG_DISABLE_GAMESCOPE_WSI_DESC": "Adds ENABLE_GAMESCOPE_WSI=0 without changing HDR. Requires game restart to apply.",
"CONFIG_DISABLE_STEAMDECK_MODE": "Disable Steam Deck Mode",
- "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables Steam Deck mode (Unlocks hidden settings in some games)",
- "CONFIG_MANGOHUD_WORKAROUND": "MangoHud Workaround",
- "CONFIG_MANGOHUD_WORKAROUND_DESC": "Enables a transparent mangohud overlay, sometimes fixes issues with 2X multiplier in game mode",
+ "CONFIG_DISABLE_STEAMDECK_MODE_DESC": "Disables a game-specific Steam Deck compatibility switch. Requires game restart to apply.",
"CONFIG_DISABLE_VKBASALT": "Disable vkBasalt",
"CONFIG_DISABLE_VKBASALT_DESC": "Disables vkBasalt layer which can conflict with LSFG (Reshade, some Decky plugins)",
- "CONFIG_FORCE_ENABLE_VKBASALT": "Force Enable vkBasalt",
- "CONFIG_FORCE_ENABLE_VKBASALT_DESC": "Force vkBasalt to engage to fix framepacing issues in gamemode",
- "CONFIG_ENABLE_ZINK": "Enable Zink for OpenGL Games",
- "CONFIG_ENABLE_ZINK_DESC": "Use Vulkan-based OpenGL implementation for OpenGL games (may cause crashes or freezes with some games)",
+ "CONFIG_ENABLE_ZINK": "Force Zink for OpenGL Games",
+ "CONFIG_ENABLE_ZINK_DESC": "Uses Mesa's Zink OpenGL-to-Vulkan driver. May cause crashes or freezes with some games. Requires game restart to apply.",
"INSTALL_INSTALLING": "Installing...",
"INSTALL_UNINSTALLING": "Uninstalling...",
"INSTALL_UNINSTALL_BTN": "Uninstall LSFG-VK",
diff --git a/src/types.d.ts b/src/types.d.ts
index dfc0472..4b88d3d 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -12,3 +12,27 @@ declare module "*.jpg" {
const content: string;
export default content;
}
+
+interface SteamAppDetails {
+ strLaunchOptions?: string;
+ strShortcutLaunchOptions?: string;
+ strShortcutExe?: string;
+}
+
+interface SteamAppDetailsRegistration {
+ unregister: () => void;
+}
+
+interface SteamApps {
+ RegisterForAppDetails(
+ appId: number,
+ callback: (details: SteamAppDetails) => void,
+ ): SteamAppDetailsRegistration;
+ SetAppLaunchOptions(appId: number, options: string): void | Promise<void>;
+ SetShortcutLaunchOptions(appId: number, options: string): void | Promise<void>;
+ GetAllShortcuts?(): Promise<unknown[]>;
+}
+
+declare const SteamClient: {
+ Apps: SteamApps;
+};
diff --git a/src/utils/steamLaunchOptionParser.ts b/src/utils/steamLaunchOptionParser.ts
new file mode 100644
index 0000000..2fd7b67
--- /dev/null
+++ b/src/utils/steamLaunchOptionParser.ts
@@ -0,0 +1,489 @@
+export interface WorkaroundState {
+ dxvkFrameRate: number;
+ disableGamescopeWsi: boolean;
+ disableSteamdeckMode: boolean;
+ disableVkbasalt: boolean;
+ enableZink: boolean;
+}
+
+export type WorkaroundField = keyof WorkaroundState;
+
+export interface ParsedWorkaroundOptions {
+ state: WorkaroundState;
+ issues: string[];
+}
+
+interface LaunchToken {
+ raw: string;
+ value: string;
+}
+
+interface EnvironmentEntry {
+ value: string;
+ count: number;
+}
+
+type BooleanWorkaroundField = Exclude<WorkaroundField, "dxvkFrameRate">;
+type EnvironmentSpec = readonly [key: string, value: string];
+type DxvkFrameRateKey = "dxvk.maxFrameRate" | "dxgi.maxFrameRate" | "d3d9.maxFrameRate";
+
+interface WorkaroundDefinition {
+ spec: EnvironmentSpec;
+ clear: readonly string[];
+ label?: string;
+}
+
+const COMMAND_TOKEN = "%command%";
+const DXVK_FRAME_RATE_KEYS: readonly DxvkFrameRateKey[] = [
+ "dxvk.maxFrameRate",
+ "dxgi.maxFrameRate",
+ "d3d9.maxFrameRate",
+];
+const DXVK_MANAGED_KEYS = new Set(["DXVK_CONFIG", "DXVK_FRAME_RATE"]);
+const WORKAROUND_DEFINITIONS = {
+ disableGamescopeWsi: {
+ spec: ["ENABLE_GAMESCOPE_WSI", "0"],
+ clear: ["DISABLE_GAMESCOPE_WSI", "ENABLE_GAMESCOPE_WSI", "DXVK_HDR"],
+ },
+ disableSteamdeckMode: {
+ spec: ["SteamDeck", "0"],
+ clear: ["SteamDeck"],
+ label: "Steam Deck mode",
+ },
+ disableVkbasalt: {
+ spec: ["DISABLE_VKBASALT", "1"],
+ clear: ["DISABLE_VKBASALT"],
+ label: "Disable vkBasalt",
+ },
+ enableZink: {
+ spec: ["MESA_LOADER_DRIVER_OVERRIDE", "zink"],
+ clear: ["__GLX_VENDOR_LIBRARY_NAME", "MESA_LOADER_DRIVER_OVERRIDE", "GALLIUM_DRIVER"],
+ },
+} as const satisfies Record<BooleanWorkaroundField, WorkaroundDefinition>;
+const BOOLEAN_WORKAROUND_FIELDS: readonly BooleanWorkaroundField[] = [
+ "disableGamescopeWsi",
+ "disableSteamdeckMode",
+ "disableVkbasalt",
+ "enableZink",
+];
+const WSI_DISABLE_KEY = "DISABLE_GAMESCOPE_WSI";
+const WSI_ENABLE_KEY = "ENABLE_GAMESCOPE_WSI";
+const MANAGED_ENV_KEYS = new Set([
+ ...DXVK_MANAGED_KEYS,
+ ...BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear),
+]);
+
+const DEFAULT_WORKAROUND_STATE: WorkaroundState = {
+ dxvkFrameRate: 0,
+ disableGamescopeWsi: false,
+ disableSteamdeckMode: false,
+ disableVkbasalt: false,
+ enableZink: false,
+};
+
+export function getDefaultWorkaroundState(): WorkaroundState {
+ return { ...DEFAULT_WORKAROUND_STATE };
+}
+
+function decodeToken(raw: string): string {
+ let value = "";
+ let quote: "'" | '"' | null = null;
+
+ for (let index = 0; index < raw.length; index += 1) {
+ const character = raw[index];
+ if (character === "\\" && quote !== "'" && index + 1 < raw.length) {
+ value += raw[index + 1];
+ index += 1;
+ } else if (quote !== null) {
+ if (character === quote) quote = null;
+ else value += character;
+ } else if (character === "'" || character === '"') {
+ quote = character;
+ } else {
+ value += character;
+ }
+ }
+
+ return value;
+}
+
+// Steam stores one shell-like line. Keep each token's raw spelling beside its
+// decoded value so managed edits leave unrelated quoting and arguments alone.
+function tokenize(options: string): LaunchToken[] {
+ const tokens: LaunchToken[] = [];
+ let start = -1;
+ let quote: "'" | '"' | null = null;
+ let escaped = false;
+
+ for (let index = 0; index < options.length; index += 1) {
+ const character = options[index];
+ if (start < 0) {
+ if (/\s/.test(character)) continue;
+ start = index;
+ }
+
+ if (escaped) {
+ escaped = false;
+ } else if (character === "\\" && quote !== "'") {
+ escaped = true;
+ } else if (quote !== null) {
+ if (character === quote) quote = null;
+ } else if (character === "'" || character === '"') {
+ quote = character;
+ } else if (/\s/.test(character)) {
+ const raw = options.slice(start, index);
+ tokens.push({ raw, value: decodeToken(raw) });
+ start = -1;
+ }
+ }
+
+ if (start >= 0) {
+ const raw = options.slice(start);
+ tokens.push({ raw, value: decodeToken(raw) });
+ }
+ return tokens;
+}
+
+function serialize(tokens: readonly LaunchToken[]): string {
+ return tokens.map((token) => token.raw).join(" ");
+}
+
+export function normalizeLaunchOptions(options: string): string {
+ return serialize(tokenize(options));
+}
+
+function parseEnvironmentToken(token: LaunchToken): [string, string] | null {
+ const separator = token.value.indexOf("=");
+ if (separator < 1) return null;
+ const key = token.value.slice(0, separator);
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null;
+ return [key, token.value.slice(separator + 1)];
+}
+
+function findCommandIndex(tokens: readonly LaunchToken[]): number {
+ return tokens.findIndex((token) => token.raw.toLowerCase() === COMMAND_TOKEN);
+}
+
+function leadingEnvironmentCount(tokens: readonly LaunchToken[]): number {
+ let count = 0;
+ while (count < tokens.length && parseEnvironmentToken(tokens[count]) !== null) count += 1;
+ return count;
+}
+
+function effectivePrefixLimit(tokens: readonly LaunchToken[]): number {
+ // Only assignment words before the first command affect the launched game.
+ // Anything after a wrapper command is that command's argument, even when it
+ // happens to look like KEY=value.
+ return leadingEnvironmentCount(tokens);
+}
+
+function effectiveEnvironmentEntries(tokens: readonly LaunchToken[]): Map<string, EnvironmentEntry> {
+ const entries = new Map<string, EnvironmentEntry>();
+ for (let index = 0; index < effectivePrefixLimit(tokens); index += 1) {
+ const parsed = parseEnvironmentToken(tokens[index]);
+ if (!parsed) continue;
+ const [key, value] = parsed;
+ const previous = entries.get(key);
+ entries.set(key, { value, count: (previous?.count || 0) + 1 });
+ }
+ return entries;
+}
+
+function removePrefixAssignments(tokens: LaunchToken[], predicate: (token: LaunchToken) => boolean): boolean {
+ const limit = effectivePrefixLimit(tokens);
+ const retained = tokens.filter((token, index) => index >= limit || !predicate(token));
+ if (retained.length === tokens.length) return false;
+ tokens.splice(0, tokens.length, ...retained);
+ return true;
+}
+
+function removeAllAssignments(tokens: LaunchToken[], keys: ReadonlySet<string>): boolean {
+ return removePrefixAssignments(tokens, (token) => {
+ const parsed = parseEnvironmentToken(token);
+ return parsed !== null && keys.has(parsed[0]);
+ });
+}
+
+function encodeEnvironmentValue(value: string): string {
+ if (/^[A-Za-z0-9_./:+,%=-]+$/.test(value)) return value;
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
+}
+
+function insertEnvironmentSpecs(tokens: LaunchToken[], specs: readonly EnvironmentSpec[]): void {
+ tokens.unshift(...specs.map(([key, value]) => ({
+ raw: `${key}=${encodeEnvironmentValue(value)}`,
+ value: `${key}=${value}`,
+ })));
+}
+
+function ensureCommandToken(tokens: LaunchToken[]): void {
+ if (findCommandIndex(tokens) >= 0) return;
+ tokens.splice(leadingEnvironmentCount(tokens), 0, { raw: COMMAND_TOKEN, value: COMMAND_TOKEN });
+}
+
+export function isLegacyWrapperToken(value: string): boolean {
+ const path = decodeToken(value);
+ return path === "~/lsfg" || path === "/home/deck/lsfg";
+}
+
+function removeLegacyWrapperFromTokens(tokens: LaunchToken[]): boolean {
+ const commandIndex = findCommandIndex(tokens);
+ const prefixEnd = commandIndex >= 0 ? commandIndex : tokens.length;
+ const wrapperIndex = leadingEnvironmentCount(tokens);
+ if (wrapperIndex >= prefixEnd || !isLegacyWrapperToken(tokens[wrapperIndex].raw)) return false;
+ tokens.splice(wrapperIndex, 1);
+ return true;
+}
+
+interface DxvkConfigAssignment {
+ values: string[];
+ malformed: number;
+}
+
+interface ParsedDxvkConfig {
+ segments: string[];
+ assignments: Map<DxvkFrameRateKey, DxvkConfigAssignment>;
+}
+
+function splitDxvkConfig(value: string): string[] {
+ const segments: string[] = [];
+ let start = 0;
+ let quote: "'" | '"' | null = null;
+ let escaped = false;
+
+ for (let index = 0; index < value.length; index += 1) {
+ const character = value[index];
+ if (escaped) escaped = false;
+ else if (character === "\\" && quote !== "'") escaped = true;
+ else if (quote !== null) {
+ if (character === quote) quote = null;
+ } else if (character === "'" || character === '"') quote = character;
+ else if (character === ";") {
+ segments.push(value.slice(start, index));
+ start = index + 1;
+ }
+ }
+
+ segments.push(value.slice(start));
+ return segments;
+}
+
+function knownDxvkKey(value: string): DxvkFrameRateKey | null {
+ const key = value.match(/^([A-Za-z][A-Za-z0-9.]*)/)?.[1];
+ return key && DXVK_FRAME_RATE_KEYS.includes(key as DxvkFrameRateKey)
+ ? key as DxvkFrameRateKey
+ : null;
+}
+
+function parseDxvkConfig(value: string): ParsedDxvkConfig {
+ const segments = splitDxvkConfig(value);
+ const assignments = new Map<DxvkFrameRateKey, DxvkConfigAssignment>();
+ for (const segment of segments) {
+ const trimmed = segment.trim();
+ const key = knownDxvkKey(trimmed);
+ if (!key) continue;
+ const match = trimmed.match(/^[A-Za-z][A-Za-z0-9.]*\s*=\s*(.*?)\s*$/);
+ const entry = assignments.get(key) || { values: [], malformed: 0 };
+ if (match) entry.values.push(match[1]);
+ else entry.malformed += 1;
+ assignments.set(key, entry);
+ }
+ return { segments, assignments };
+}
+
+function isDxvkFrameRateSegment(segment: string): boolean {
+ return knownDxvkKey(segment.trim()) !== null;
+}
+
+function parseSupportedFrameRate(value: string): number | null {
+ if (!/^\d+$/.test(value)) return null;
+ const numericValue = Number(value);
+ return Number.isSafeInteger(numericValue) && numericValue <= 60 ? numericValue : null;
+}
+
+function rewriteDxvkFrameRate(tokens: LaunchToken[], frameRate: number): void {
+ const config = effectiveEnvironmentEntries(tokens).get("DXVK_CONFIG");
+ const parsed = parseDxvkConfig(config?.value || "");
+ const retained = parsed.segments
+ .filter((segment) => !isDxvkFrameRateSegment(segment))
+ .filter((segment) => segment.trim().length > 0)
+ .join(";");
+ const nextConfig = frameRate > 0
+ ? [`dxvk.maxFrameRate = ${frameRate}`, ...(retained ? [retained] : [])].join(";")
+ : retained;
+
+ removeAllAssignments(tokens, DXVK_MANAGED_KEYS);
+ if (nextConfig) {
+ ensureCommandToken(tokens);
+ insertEnvironmentSpecs(tokens, [["DXVK_CONFIG", nextConfig]]);
+ }
+}
+
+function environmentSpecsForState(state: WorkaroundState): EnvironmentSpec[] {
+ return BOOLEAN_WORKAROUND_FIELDS
+ .filter((field) => state[field])
+ .map((field) => WORKAROUND_DEFINITIONS[field].spec);
+}
+
+function validateFrameRate(frameRate: number): void {
+ if (!Number.isInteger(frameRate) || frameRate < 0 || frameRate > 60) {
+ throw new Error("Base FPS Cap must be an integer from 0 to 60");
+ }
+}
+
+function readBoolean(
+ entries: Map<string, EnvironmentEntry>,
+ key: string,
+ label: string,
+ trueValue: string,
+ issues: string[],
+): boolean {
+ const entry = entries.get(key);
+ if (!entry) return false;
+ const falseValue = trueValue === "1" ? "0" : "1";
+ if (entry.value === trueValue) return true;
+ if (entry.value === falseValue) return false;
+ issues.push(`${label} has an unsupported value.`);
+ return false;
+}
+
+export function parseWorkaroundOptions(options: string): ParsedWorkaroundOptions {
+ const tokens = tokenize(options);
+ const entries = effectiveEnvironmentEntries(tokens);
+ const state = getDefaultWorkaroundState();
+ const issues: string[] = [];
+
+ for (const [key, entry] of entries) {
+ if (MANAGED_ENV_KEYS.has(key) && entry.count > 1) {
+ issues.push(`${key} appears more than once; Steam uses the last value.`);
+ }
+ }
+
+ const dxvkConfig = parseDxvkConfig(entries.get("DXVK_CONFIG")?.value || "");
+ const effectiveDxvkValues = new Map<DxvkFrameRateKey, number | null>();
+ for (const key of DXVK_FRAME_RATE_KEYS) {
+ const assignment = dxvkConfig.assignments.get(key);
+ if (!assignment) continue;
+ if (assignment.malformed > 0) issues.push(`${key} in DXVK_CONFIG is malformed.`);
+ if (assignment.values.length > 1) {
+ issues.push(`${key} appears more than once in DXVK_CONFIG; DXVK uses the last value.`);
+ }
+ if (assignment.values.length === 0) continue;
+ const value = parseSupportedFrameRate(assignment.values[assignment.values.length - 1]);
+ effectiveDxvkValues.set(key, value);
+ if (value === null) issues.push(`${key} in DXVK_CONFIG is outside the supported 0-60 range.`);
+ }
+
+ const unifiedFrameRate = effectiveDxvkValues.get("dxvk.maxFrameRate");
+ const dxgiFrameRate = effectiveDxvkValues.get("dxgi.maxFrameRate");
+ const d3d9FrameRate = effectiveDxvkValues.get("d3d9.maxFrameRate");
+ if (unifiedFrameRate !== undefined) {
+ if (unifiedFrameRate !== null) state.dxvkFrameRate = unifiedFrameRate;
+ } else if (dxgiFrameRate !== undefined && d3d9FrameRate !== undefined) {
+ if (dxgiFrameRate !== null && dxgiFrameRate === d3d9FrameRate) state.dxvkFrameRate = dxgiFrameRate;
+ else issues.push("DXVK_CONFIG has conflicting or invalid DirectX frame caps.");
+ } else if (dxgiFrameRate !== undefined || d3d9FrameRate !== undefined) {
+ const partial = dxgiFrameRate ?? d3d9FrameRate;
+ if (partial !== null && partial !== undefined) state.dxvkFrameRate = partial;
+ issues.push("DXVK_CONFIG only caps one DirectX API; adjust the cap to normalize it.");
+ }
+
+ if (entries.has("DXVK_FRAME_RATE")) {
+ issues.push("DXVK_FRAME_RATE is obsolete on current DXVK; adjust the cap to migrate it.");
+ }
+
+ const wsiSignals: boolean[] = [];
+ const wsiDisable = entries.get(WSI_DISABLE_KEY);
+ if (wsiDisable) {
+ if (wsiDisable.value !== "0" && wsiDisable.value !== "1") issues.push("Disable Gamescope WSI has an unsupported value.");
+ else wsiSignals.push(wsiDisable.value === "1");
+ }
+ const wsiEnable = entries.get(WSI_ENABLE_KEY);
+ if (wsiEnable) {
+ if (wsiEnable.value !== "0" && wsiEnable.value !== "1") issues.push("Enable Gamescope WSI has an unsupported value.");
+ else wsiSignals.push(wsiEnable.value === "0");
+ }
+ if (wsiSignals.length > 0) {
+ if (wsiSignals.length === 2 && wsiSignals[0] !== wsiSignals[1]) {
+ issues.push("Gamescope WSI has conflicting enable and disable assignments.");
+ }
+ state.disableGamescopeWsi = wsiSignals.some(Boolean);
+ }
+
+ for (const field of ["disableSteamdeckMode", "disableVkbasalt"] as const) {
+ const { spec, label } = WORKAROUND_DEFINITIONS[field];
+ state[field] = readBoolean(entries, spec[0], label || field, spec[1], issues);
+ }
+
+ const vkBasaltEnable = entries.get("ENABLE_VKBASALT");
+ const vkBasaltDisable = entries.get("DISABLE_VKBASALT");
+ if (vkBasaltEnable?.value === "1" && vkBasaltDisable?.value === "1") {
+ issues.push("vkBasalt has conflicting enable and disable assignments.");
+ }
+
+ const zink = entries.get(WORKAROUND_DEFINITIONS.enableZink.spec[0]);
+ const glxVendor = entries.get("__GLX_VENDOR_LIBRARY_NAME");
+ const galliumDriver = entries.get("GALLIUM_DRIVER");
+ const hasLegacyZink = glxVendor !== undefined || galliumDriver !== undefined;
+ if (zink || hasLegacyZink) {
+ state.enableZink = zink?.value === WORKAROUND_DEFINITIONS.enableZink.spec[1];
+ if (hasLegacyZink && (
+ glxVendor?.value !== "mesa" ||
+ zink?.value !== WORKAROUND_DEFINITIONS.enableZink.spec[1] ||
+ galliumDriver?.value !== "zink"
+ )) {
+ issues.push("Zink workaround is only partially configured.");
+ } else if (!state.enableZink) {
+ issues.push("Zink workaround has an unsupported driver value.");
+ }
+ }
+
+ return { state, issues };
+}
+
+export function applyWorkaroundState(options: string, state: WorkaroundState): string {
+ validateFrameRate(state.dxvkFrameRate);
+ const tokens = tokenize(options);
+ removeLegacyWrapperFromTokens(tokens);
+ rewriteDxvkFrameRate(tokens, state.dxvkFrameRate);
+ const keysToClear = new Set<string>(
+ BOOLEAN_WORKAROUND_FIELDS.flatMap((field) => WORKAROUND_DEFINITIONS[field].clear),
+ );
+ if (state.disableVkbasalt) keysToClear.add("ENABLE_VKBASALT");
+ removeAllAssignments(tokens, keysToClear);
+ const specs = environmentSpecsForState(state);
+ if (specs.length > 0) {
+ ensureCommandToken(tokens);
+ insertEnvironmentSpecs(tokens, specs);
+ }
+ return serialize(tokens);
+}
+
+export function applyWorkaroundChange(options: string, field: WorkaroundField, value: boolean | number): string {
+ const tokens = tokenize(options);
+ removeLegacyWrapperFromTokens(tokens);
+
+ if (field === "dxvkFrameRate") {
+ if (typeof value !== "number") throw new Error("Base FPS Cap must be an integer from 0 to 60");
+ validateFrameRate(value);
+ rewriteDxvkFrameRate(tokens, value);
+ return serialize(tokens);
+ }
+
+ if (typeof value !== "boolean") throw new Error(`${field} must be a boolean`);
+ const definition = WORKAROUND_DEFINITIONS[field];
+ const keysToClear = new Set<string>(definition.clear);
+ if (value && field === "disableVkbasalt") keysToClear.add("ENABLE_VKBASALT");
+ removeAllAssignments(tokens, keysToClear);
+ if (value) {
+ ensureCommandToken(tokens);
+ insertEnvironmentSpecs(tokens, [definition.spec]);
+ }
+ return serialize(tokens);
+}
+
+export function cleanupLegacyWrapper(options: string): string {
+ const tokens = tokenize(options);
+ removeLegacyWrapperFromTokens(tokens);
+ return serialize(tokens);
+}
diff --git a/src/utils/steamLaunchOptions.ts b/src/utils/steamLaunchOptions.ts
new file mode 100644
index 0000000..9c7b5eb
--- /dev/null
+++ b/src/utils/steamLaunchOptions.ts
@@ -0,0 +1,211 @@
+// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests.
+import { cleanupLegacyWrapper, isLegacyWrapperToken, normalizeLaunchOptions } from "./steamLaunchOptionParser.ts";
+
+// @ts-expect-error Node's built-in TypeScript loader requires explicit source extensions in tests.
+export * from "./steamLaunchOptionParser.ts";
+
+export interface SteamLaunchOptionsSnapshot {
+ appId: number;
+ nonSteam: boolean;
+ options: string;
+ details: SteamAppDetails;
+}
+
+function validateAppId(appId: number): void {
+ if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("Invalid Steam App ID");
+}
+
+function getSteamApps(): Partial<SteamApps> | undefined {
+ return (globalThis as typeof globalThis & {
+ SteamClient?: { Apps?: Partial<SteamApps> };
+ }).SteamClient?.Apps;
+}
+
+function snapshotFromDetails(appId: number, nonSteam: boolean, details: SteamAppDetails): SteamLaunchOptionsSnapshot {
+ if (nonSteam && isLegacyWrapperToken(details.strShortcutExe || "")) {
+ throw new Error("The shortcut Target still points to the legacy ~/lsfg wrapper; restore its original executable first");
+ }
+ return {
+ appId,
+ nonSteam,
+ options: nonSteam ? details.strShortcutLaunchOptions || "" : details.strLaunchOptions || "",
+ details,
+ };
+}
+
+function asError(error: unknown): Error {
+ return error instanceof Error ? error : new Error(String(error));
+}
+
+function registerSteamAppDetails(
+ appId: number,
+ onDetails: (details: SteamAppDetails) => boolean | void,
+): () => void {
+ validateAppId(appId);
+ const apps = getSteamApps();
+ const registerForAppDetails = apps?.RegisterForAppDetails;
+ if (!registerForAppDetails) throw new Error("Steam launch options API is unavailable");
+
+ let active = true;
+ let unregisterPending = false;
+ let registration: SteamAppDetailsRegistration | undefined;
+ const unsubscribe = () => {
+ active = false;
+ if (!registration) {
+ unregisterPending = true;
+ return;
+ }
+ try {
+ registration.unregister();
+ } catch {
+ // Steam may invalidate registrations during a details refresh.
+ }
+ };
+
+ try {
+ registration = registerForAppDetails.call(apps, appId, (details) => {
+ if (!active) return;
+ if (onDetails(details || {}) === false && active) unsubscribe();
+ });
+ if (unregisterPending) {
+ try {
+ registration.unregister();
+ } catch {
+ // The registration can be invalidated before a synchronous callback returns.
+ }
+ }
+ } catch (error) {
+ throw asError(error);
+ }
+ return unsubscribe;
+}
+
+export async function readSteamLaunchOptions(appId: number, nonSteam: boolean): Promise<SteamLaunchOptionsSnapshot> {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ let timeout = 0;
+ let unsubscribe = () => {};
+ const finish = (error?: unknown, details?: SteamAppDetails) => {
+ if (settled) return;
+ settled = true;
+ window.clearTimeout(timeout);
+ unsubscribe();
+ if (error) {
+ reject(asError(error));
+ return;
+ }
+ try {
+ resolve(snapshotFromDetails(appId, nonSteam, details || {}));
+ } catch (snapshotError) {
+ reject(asError(snapshotError));
+ }
+ };
+
+ timeout = window.setTimeout(() => finish(new Error("Timed out reading Steam launch options")), 5000);
+ try {
+ unsubscribe = registerSteamAppDetails(appId, (details) => {
+ finish(undefined, details);
+ return false;
+ });
+ } catch (error) {
+ finish(error);
+ }
+ });
+}
+
+export function subscribeSteamLaunchOptions(
+ appId: number,
+ nonSteam: boolean,
+ onSnapshot: (snapshot: SteamLaunchOptionsSnapshot) => void,
+ onError: (error: Error) => void,
+): () => void {
+ return registerSteamAppDetails(appId, (details) => {
+ try {
+ onSnapshot(snapshotFromDetails(appId, nonSteam, details));
+ } catch (error) {
+ onError(asError(error));
+ }
+ });
+}
+
+async function setSteamLaunchOptions(appId: number, nonSteam: boolean, options: string): Promise<void> {
+ const apps = getSteamApps();
+ const setter = nonSteam ? apps?.SetShortcutLaunchOptions : apps?.SetAppLaunchOptions;
+ if (!setter) throw new Error(`Steam ${nonSteam ? "shortcut " : ""}launch options API is unavailable`);
+ await Promise.resolve(setter.call(apps, appId, options));
+}
+
+function delay(milliseconds: number): Promise<void> {
+ return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
+}
+
+async function waitForLaunchOptions(
+ appId: number,
+ nonSteam: boolean,
+ expected: string,
+): Promise<SteamLaunchOptionsSnapshot> {
+ const deadline = Date.now() + 5000;
+ let lastError: Error | null = null;
+ while (Date.now() <= deadline) {
+ try {
+ const snapshot = await readSteamLaunchOptions(appId, nonSteam);
+ if (normalizeLaunchOptions(snapshot.options) === normalizeLaunchOptions(expected)) return snapshot;
+ } catch (error) {
+ lastError = asError(error);
+ }
+ if (Date.now() >= deadline) break;
+ await delay(100);
+ }
+ if (lastError) throw new Error(`Steam did not accept the launch options: ${lastError.message}`);
+ throw new Error("Steam did not accept the launch options before the readback timeout");
+}
+
+const operationQueues = new Map<string, Promise<void>>();
+
+function queueKey(appId: number, nonSteam: boolean): string {
+ return `${nonSteam ? "shortcut" : "app"}:${appId}`;
+}
+
+function queueSteamAppOperation<T>(appId: number, nonSteam: boolean, operation: () => Promise<T>): Promise<T> {
+ const key = queueKey(appId, nonSteam);
+ const previous = operationQueues.get(key) || Promise.resolve();
+ const queued = previous.catch(() => undefined).then(operation);
+ let cleanup: Promise<void>;
+ cleanup = queued.then(
+ () => {
+ if (operationQueues.get(key) === cleanup) operationQueues.delete(key);
+ },
+ () => {
+ if (operationQueues.get(key) === cleanup) operationQueues.delete(key);
+ },
+ );
+ operationQueues.set(key, cleanup);
+ return queued;
+}
+
+export function updateSteamLaunchOptions(
+ appId: number,
+ nonSteam: boolean,
+ transform: (options: string) => string,
+): Promise<SteamLaunchOptionsSnapshot> {
+ return queueSteamAppOperation(appId, nonSteam, async () => {
+ const current = await readSteamLaunchOptions(appId, nonSteam);
+ const next = transform(current.options);
+ if (next === current.options) return current;
+ await setSteamLaunchOptions(appId, nonSteam, next);
+ return waitForLaunchOptions(appId, nonSteam, next);
+ });
+}
+
+export function cleanupSteamLaunchOptions(
+ appId: number,
+ nonSteam: boolean,
+): Promise<SteamLaunchOptionsSnapshot> {
+ return queueSteamAppOperation(appId, nonSteam, async () => {
+ const current = await readSteamLaunchOptions(appId, nonSteam);
+ const next = cleanupLegacyWrapper(current.options);
+ if (next === current.options) return current;
+ await setSteamLaunchOptions(appId, nonSteam, next);
+ return waitForLaunchOptions(appId, nonSteam, next);
+ });
+}
diff --git a/tests/steamLaunchOptions.test.ts b/tests/steamLaunchOptions.test.ts
new file mode 100644
index 0000000..8ecfa77
--- /dev/null
+++ b/tests/steamLaunchOptions.test.ts
@@ -0,0 +1,288 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ applyWorkaroundChange,
+ applyWorkaroundState,
+ cleanupLegacyWrapper,
+ getDefaultWorkaroundState,
+ isLegacyWrapperToken,
+ parseWorkaroundOptions,
+ readSteamLaunchOptions,
+ updateSteamLaunchOptions,
+} from "../src/utils/steamLaunchOptions.ts";
+
+test("maps the supported workarounds to current launch variables", () => {
+ const options = applyWorkaroundState('gamemoderun %command% --profile "high quality"', {
+ dxvkFrameRate: 30,
+ disableGamescopeWsi: true,
+ disableSteamdeckMode: true,
+ disableVkbasalt: true,
+ enableZink: true,
+ });
+
+ assert.equal(
+ options,
+ 'ENABLE_GAMESCOPE_WSI=0 SteamDeck=0 DISABLE_VKBASALT=1 MESA_LOADER_DRIVER_OVERRIDE=zink DXVK_CONFIG="dxvk.maxFrameRate = 30" gamemoderun %command% --profile "high quality"',
+ );
+ assert.deepEqual(parseWorkaroundOptions(options), {
+ state: {
+ dxvkFrameRate: 30,
+ disableGamescopeWsi: true,
+ disableSteamdeckMode: true,
+ disableVkbasalt: true,
+ enableZink: true,
+ },
+ issues: [],
+ });
+});
+
+test("uses SteamDeck=0 before %command% without a wrapper", () => {
+ assert.equal(
+ applyWorkaroundChange("", "disableSteamdeckMode", true),
+ "SteamDeck=0 %command%",
+ );
+});
+
+test("keeps WSI disable opt-in and does not add HDR assignments", () => {
+ const defaults = getDefaultWorkaroundState();
+ assert.equal(applyWorkaroundState("%command%", defaults), "%command%");
+ assert.equal(parseWorkaroundOptions("%command%").state.disableGamescopeWsi, false);
+ assert.equal(
+ applyWorkaroundChange("%command%", "disableGamescopeWsi", true),
+ "ENABLE_GAMESCOPE_WSI=0 %command%",
+ );
+ assert.equal(
+ applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 %command%", "disableGamescopeWsi", false),
+ "%command%",
+ );
+
+ const legacy = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%");
+ assert.equal(legacy.state.disableGamescopeWsi, true);
+ assert.deepEqual(legacy.issues, []);
+ assert.equal(
+ applyWorkaroundChange("ENABLE_GAMESCOPE_WSI=0 DXVK_HDR=0 %command%", "disableGamescopeWsi", false),
+ "%command%",
+ );
+
+ const invalid = parseWorkaroundOptions("ENABLE_GAMESCOPE_WSI=maybe %command%");
+ assert.equal(invalid.state.disableGamescopeWsi, false);
+ assert.equal(invalid.issues.length, 1);
+ const conflicting = parseWorkaroundOptions("DISABLE_GAMESCOPE_WSI=1 ENABLE_GAMESCOPE_WSI=1 %command%");
+ assert.equal(conflicting.state.disableGamescopeWsi, true);
+ assert.match(conflicting.issues.join(" "), /conflicting/);
+});
+
+test("preserves unrelated prefixes, quoted tokens, suffix arguments, and dropped variables", () => {
+ const options = applyWorkaroundChange(
+ 'PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" ENABLE_VKBASALT=1 VK_INSTANCE_LAYERS="one:two" FOO="hello world" gamemoderun %command% --flag "two words"',
+ "disableSteamdeckMode",
+ true,
+ );
+ assert.equal(
+ options,
+ 'SteamDeck=0 PROTON_USE_WOW64=1 MANGOHUD=1 MANGOHUD_CONFIG="alpha=0.01" ENABLE_VKBASALT=1 VK_INSTANCE_LAYERS="one:two" FOO="hello world" gamemoderun %command% --flag "two words"',
+ );
+ assert.deepEqual(parseWorkaroundOptions(options).issues, []);
+
+ assert.equal(
+ applyWorkaroundChange("FOO=bar --flag", "disableSteamdeckMode", true),
+ "SteamDeck=0 FOO=bar %command% --flag",
+ );
+ assert.equal(
+ applyWorkaroundChange("FOO=1 %command% MANGOHUD=1", "disableSteamdeckMode", false),
+ "FOO=1 %command% MANGOHUD=1",
+ );
+ assert.equal(
+ applyWorkaroundChange('FOO=bar --literal "%command%"', "disableSteamdeckMode", true),
+ 'SteamDeck=0 FOO=bar %command% --literal "%command%"',
+ );
+ assert.equal(
+ applyWorkaroundChange("gamemoderun SteamDeck=1 %command%", "disableSteamdeckMode", true),
+ "SteamDeck=0 gamemoderun SteamDeck=1 %command%",
+ );
+ assert.equal(parseWorkaroundOptions("gamemoderun SteamDeck=0 %command%").state.disableSteamdeckMode, false);
+});
+
+test("uses DXVK_CONFIG for the base cap and preserves other DXVK settings", () => {
+ assert.equal(
+ applyWorkaroundChange("%command%", "dxvkFrameRate", 60),
+ 'DXVK_CONFIG="dxvk.maxFrameRate = 60" %command%',
+ );
+ assert.equal(parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate = 60" %command%').state.dxvkFrameRate, 60);
+ assert.equal(
+ applyWorkaroundChange(
+ 'DXVK_CONFIG="dxgi.syncInterval = 0; dxvk.maxFrameRate = 30" %command%',
+ "dxvkFrameRate",
+ 0,
+ ),
+ 'DXVK_CONFIG="dxgi.syncInterval = 0" %command%',
+ );
+ assert.equal(
+ applyWorkaroundChange("DXVK_FRAME_RATE=30 %command%", "dxvkFrameRate", 45),
+ 'DXVK_CONFIG="dxvk.maxFrameRate = 45" %command%',
+ );
+ assert.equal(
+ applyWorkaroundChange("DXVK_FRAME_RATE=30 %command%", "dxvkFrameRate", 0),
+ "%command%",
+ );
+
+ const apiSpecific = parseWorkaroundOptions(
+ 'DXVK_CONFIG="dxgi.maxFrameRate = 30; d3d9.maxFrameRate = 30" %command%',
+ );
+ assert.equal(apiSpecific.state.dxvkFrameRate, 30);
+ assert.deepEqual(apiSpecific.issues, []);
+ const partial = parseWorkaroundOptions('DXVK_CONFIG="dxgi.maxFrameRate = 30" %command%');
+ assert.equal(partial.state.dxvkFrameRate, 30);
+ assert.match(partial.issues.join(" "), /only caps one DirectX API/);
+ const conflicting = parseWorkaroundOptions(
+ 'DXVK_CONFIG="dxgi.maxFrameRate = 30; d3d9.maxFrameRate = 60" %command%',
+ );
+ assert.equal(conflicting.state.dxvkFrameRate, 0);
+ assert.match(conflicting.issues.join(" "), /conflicting/);
+});
+
+test("reports invalid and malformed FPS values instead of treating them as off", () => {
+ const invalid = parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate = 61" %command%');
+ assert.equal(invalid.state.dxvkFrameRate, 0);
+ assert.match(invalid.issues.join(" "), /outside the supported 0-60 range/);
+ const malformed = parseWorkaroundOptions('DXVK_CONFIG="dxvk.maxFrameRate" %command%');
+ assert.equal(malformed.state.dxvkFrameRate, 0);
+ assert.match(malformed.issues.join(" "), /malformed/);
+ const obsolete = parseWorkaroundOptions("DXVK_FRAME_RATE=wat %command%");
+ assert.equal(obsolete.state.dxvkFrameRate, 0);
+ assert.match(obsolete.issues.join(" "), /obsolete/);
+ assert.throws(() => applyWorkaroundChange("%command%", "dxvkFrameRate", 61), /0 to 60/);
+ assert.throws(() => applyWorkaroundChange("%command%", "dxvkFrameRate", 1.5), /0 to 60/);
+});
+
+test("keeps vkBasalt disable mutually exclusive while preserving the dropped enable flag otherwise", () => {
+ assert.equal(
+ applyWorkaroundChange("ENABLE_VKBASALT=1 %command%", "disableSteamdeckMode", true),
+ "SteamDeck=0 ENABLE_VKBASALT=1 %command%",
+ );
+ const disabled = applyWorkaroundChange("ENABLE_VKBASALT=1 %command%", "disableVkbasalt", true);
+ assert.equal(disabled, "DISABLE_VKBASALT=1 %command%");
+ assert.equal(
+ applyWorkaroundChange(disabled, "disableVkbasalt", false),
+ "%command%",
+ );
+ const conflict = parseWorkaroundOptions("ENABLE_VKBASALT=1 DISABLE_VKBASALT=1 %command%");
+ assert.equal(conflict.state.disableVkbasalt, true);
+ assert.match(conflict.issues.join(" "), /conflicting/);
+});
+
+test("handles current and legacy Zink forms and reports partial state", () => {
+ const enabled = applyWorkaroundChange("%command%", "enableZink", true);
+ assert.equal(enabled, "MESA_LOADER_DRIVER_OVERRIDE=zink %command%");
+ assert.equal(parseWorkaroundOptions(enabled).state.enableZink, true);
+
+ const legacy = parseWorkaroundOptions(
+ "__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink GALLIUM_DRIVER=zink %command%",
+ );
+ assert.equal(legacy.state.enableZink, true);
+ assert.deepEqual(legacy.issues, []);
+
+ const partial = parseWorkaroundOptions("__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink %command%");
+ assert.equal(partial.state.enableZink, true);
+ assert.match(partial.issues.join(" "), /partially configured/);
+ assert.equal(
+ applyWorkaroundChange(
+ "__GLX_VENDOR_LIBRARY_NAME=mesa MESA_LOADER_DRIVER_OVERRIDE=zink GALLIUM_DRIVER=zink %command%",
+ "enableZink",
+ false,
+ ),
+ "%command%",
+ );
+});
+
+test("cleans only the known legacy wrapper and preserves launch options", () => {
+ assert.equal(
+ cleanupLegacyWrapper('FOO=bar ~/lsfg %command% --arg "~/lsfg"'),
+ 'FOO=bar %command% --arg "~/lsfg"',
+ );
+ assert.equal(cleanupLegacyWrapper("/home/deck/lsfg %command%"), "%command%");
+ assert.equal(
+ cleanupLegacyWrapper("DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%"),
+ "DXVK_FRAME_RATE=30 LSFG_PROCESS=decky-lsfg-vk %command%",
+ );
+ assert.equal(
+ cleanupLegacyWrapper("LSFG_PROCESS=decky-lsfg-vk %command%"),
+ "LSFG_PROCESS=decky-lsfg-vk %command%",
+ );
+ assert.equal(isLegacyWrapperToken("/home/kurt/lsfg"), false);
+});
+
+test("is idempotent", () => {
+ const first = applyWorkaroundChange("gamemoderun %command%", "enableZink", true);
+ assert.equal(applyWorkaroundState(first, parseWorkaroundOptions(first).state), first);
+ assert.equal(applyWorkaroundChange(first, "enableZink", true), first);
+ const capped = applyWorkaroundChange(first, "dxvkFrameRate", 30);
+ assert.equal(applyWorkaroundChange(capped, "dxvkFrameRate", 30), capped);
+});
+
+test("reads and writes the matching Steam app-details launch-option field", async () => {
+ const previousWindow = (globalThis as Record<string, unknown>).window;
+ const previousSteamClient = (globalThis as Record<string, unknown>).SteamClient;
+ let normalOptions = "FOO=bar %command%";
+ let shortcutOptions = "--windowed";
+ const normalWrites: string[] = [];
+ const shortcutWrites: string[] = [];
+ const unregisters: number[] = [];
+
+ const windowShim = { setTimeout, clearTimeout };
+ const apps = {
+ RegisterForAppDetails(appId: number, callback: (details: SteamAppDetails) => void) {
+ if (appId === 42) {
+ callback({ strLaunchOptions: normalOptions, strShortcutLaunchOptions: "must-not-be-read" });
+ } else {
+ callback({
+ strShortcutExe: "/usr/bin/example-game",
+ strShortcutLaunchOptions: shortcutOptions,
+ strLaunchOptions: "must-not-be-read",
+ });
+ }
+ return { unregister: () => unregisters.push(appId) };
+ },
+ SetAppLaunchOptions(appId: number, options: string) {
+ assert.equal(appId, 42);
+ normalWrites.push(options);
+ normalOptions = options.replaceAll(" ", " ");
+ },
+ SetShortcutLaunchOptions(appId: number, options: string) {
+ assert.equal(appId, 43);
+ shortcutWrites.push(options);
+ shortcutOptions = options;
+ },
+ };
+
+ (globalThis as Record<string, unknown>).window = windowShim;
+ (globalThis as Record<string, unknown>).SteamClient = { Apps: apps };
+ try {
+ const normalBefore = await readSteamLaunchOptions(42, false);
+ assert.equal(normalBefore.options, "FOO=bar %command%");
+ const normalAfter = await updateSteamLaunchOptions(
+ 42,
+ false,
+ (options) => applyWorkaroundChange(options, "disableSteamdeckMode", true),
+ );
+ assert.equal(normalWrites.length, 1);
+ assert.equal(shortcutWrites.length, 0);
+ assert.equal(normalAfter.options, "SteamDeck=0 FOO=bar %command%");
+
+ const shortcutAfter = await updateSteamLaunchOptions(
+ 43,
+ true,
+ (options) => applyWorkaroundChange(options, "disableGamescopeWsi", true),
+ );
+ assert.equal(shortcutWrites.length, 1);
+ assert.equal(shortcutWrites[0], "ENABLE_GAMESCOPE_WSI=0 %command% --windowed");
+ assert.equal(shortcutAfter.options, shortcutWrites[0]);
+ assert.ok(unregisters.includes(42));
+ assert.ok(unregisters.includes(43));
+ } finally {
+ if (previousWindow === undefined) delete (globalThis as Record<string, unknown>).window;
+ else (globalThis as Record<string, unknown>).window = previousWindow;
+ if (previousSteamClient === undefined) delete (globalThis as Record<string, unknown>).SteamClient;
+ else (globalThis as Record<string, unknown>).SteamClient = previousSteamClient;
+ }
+});