diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/ConfigurationTab.tsx | 2 | ||||
| -rw-r--r-- | src/components/GameConfigurationControls.tsx | 9 | ||||
| -rw-r--r-- | src/components/NowPlayingTab.tsx | 2 | ||||
| -rw-r--r-- | src/components/WorkaroundsSection.tsx | 190 | ||||
| -rw-r--r-- | src/components/index.ts | 1 | ||||
| -rw-r--r-- | src/hooks/useGameConfiguration.ts | 27 | ||||
| -rw-r--r-- | src/hooks/usePerAppWorkarounds.ts | 173 | ||||
| -rw-r--r-- | src/i18n/languages.json | 48 | ||||
| -rw-r--r-- | src/types.d.ts | 24 | ||||
| -rw-r--r-- | src/utils/steamLaunchOptionParser.ts | 489 | ||||
| -rw-r--r-- | src/utils/steamLaunchOptions.ts | 211 |
11 files changed, 1141 insertions, 35 deletions
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); + }); +} |
