summaryrefslogtreecommitdiff
path: root/src/utils/steamLaunchOptionParser.ts
diff options
context:
space:
mode:
authorxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-08 16:25:11 -0400
committerxXJSONDeruloXx <danielhimebauch@gmail.com>2026-09-08 16:25:11 -0400
commit8352ee74e8a437c1f4a4dadb75d63049f45b164b (patch)
treebd59691eb01f5fbf4b5a6aede5ae4d0295e6643d /src/utils/steamLaunchOptionParser.ts
parentb3749ecc4b094a46d2ab9f638ee810f70840f67a (diff)
downloaddecky-lsfg-vk-8352ee74e8a437c1f4a4dadb75d63049f45b164b.tar.gz
decky-lsfg-vk-8352ee74e8a437c1f4a4dadb75d63049f45b164b.zip
add back workarounds sections, scope out of now playing
Diffstat (limited to 'src/utils/steamLaunchOptionParser.ts')
-rw-r--r--src/utils/steamLaunchOptionParser.ts489
1 files changed, 489 insertions, 0 deletions
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);
+}