diff --git a/electron/main/ipc/launch.ts b/electron/main/ipc/launch.ts index 2b03a5a6..bdf0799c 100644 --- a/electron/main/ipc/launch.ts +++ b/electron/main/ipc/launch.ts @@ -16,6 +16,7 @@ import { healLockerVpks } from '../services/lockerVpk'; import { ensureReplayFolderLink } from '../services/replayFolder'; import { getMainWindow } from '../index'; import { scanMods } from '../services/mods'; +import { applyCustomConvarsWhenIdle } from '../services/customConvars'; import { captureEmptyGameMods, captureLoadedGameMods, @@ -40,6 +41,7 @@ ipcMain.handle('launch-modded', async (): Promise => { deadlockPath, onRestoreComplete: emitRestore, beforeLaunch: async () => { + await applyCustomConvarsWhenIdle(deadlockPath, isDeadlockRunning, true); captureLoadedGameMods(await scanMods(deadlockPath)); markLaunchGrace(); }, @@ -154,6 +156,11 @@ ipcMain.handle('restore-vanilla-stash', async (): Promise => { export async function runStartupRecovery(): Promise { const deadlockPath = getActiveDeadlockPath(); if (!deadlockPath) return; + try { + await applyCustomConvarsWhenIdle(deadlockPath, isDeadlockRunning, true); + } catch (error) { + console.warn('[CustomConvars] Startup restore deferred:', error); + } try { const result = await recoverFromStashOnStartup(deadlockPath); if (result) { diff --git a/electron/main/ipc/performanceConfig.ts b/electron/main/ipc/performanceConfig.ts index 22ba057b..7bb4bd8b 100644 --- a/electron/main/ipc/performanceConfig.ts +++ b/electron/main/ipc/performanceConfig.ts @@ -2,6 +2,8 @@ import { ipcMain } from 'electron'; import { existsSync } from 'fs'; import { getActiveDeadlockPath, loadSettings } from '../services/settings'; import { getGameinfoPath } from '../services/deadlock'; +import { getCustomConvarStatus, saveCustomConvarSettings, applyCustomConvarsWhenIdle, withIdleGameinfo } from '../services/customConvars'; +import { isDeadlockRunning } from '../services/launch'; import { listEditorCandidates, openInEditor } from '../services/externalEditor'; import { applyPerformanceConfig, @@ -50,6 +52,20 @@ function selection(presetId?: string, optIns?: string[], version?: string | null } // get-performance-config-status +function requireGamePath(): string { + const path = getActiveDeadlockPath(); + if (!path) throw new Error('Configure your Deadlock path first.'); + return path; +} + +ipcMain.handle('get-custom-convar-status', () => getCustomConvarStatus(requireGamePath())); +ipcMain.handle('save-custom-convars', (_event, input: unknown) => { + const path = requireGamePath(); + saveCustomConvarSettings(path, input); + return getCustomConvarStatus(path); +}); +ipcMain.handle('apply-custom-convars', () => applyCustomConvarsWhenIdle(requireGamePath(), isDeadlockRunning)); + ipcMain.handle('get-performance-config-status', (): PerformanceConfigStatus => { return getPerformanceConfigStatus(getActiveDeadlockPath()); }); @@ -67,17 +83,19 @@ ipcMain.handle( presetId?: string, optIns?: string[], version?: string | null - ): PerformanceConfigStatus => { - return applyPerformanceConfig( - getActiveDeadlockPath(), + ): Promise => { + const path = requireGamePath(); + return withIdleGameinfo(path, isDeadlockRunning, () => applyPerformanceConfig( + path, selection(presetId, optIns, version) - ); + )); } ); // remove-performance-config -ipcMain.handle('remove-performance-config', (): PerformanceConfigStatus => { - return removePerformanceConfig(getActiveDeadlockPath()); +ipcMain.handle('remove-performance-config', (): Promise => { + const path = requireGamePath(); + return withIdleGameinfo(path, isDeadlockRunning, () => removePerformanceConfig(path)); }); // reset-performance-config-overrides (reapply the pure preset, dropping the @@ -89,11 +107,12 @@ ipcMain.handle( presetId?: string, optIns?: string[], version?: string | null - ): PerformanceConfigStatus => { - return resetPerformanceConfigOverrides( - getActiveDeadlockPath(), + ): Promise => { + const path = requireGamePath(); + return withIdleGameinfo(path, isDeadlockRunning, () => resetPerformanceConfigOverrides( + path, selection(presetId, optIns, version) - ); + )); } ); diff --git a/electron/main/services/customConvars.test.ts b/electron/main/services/customConvars.test.ts new file mode 100644 index 00000000..b7785bfa --- /dev/null +++ b/electron/main/services/customConvars.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + applyCustomConvars, applyCustomConvarsWhenIdle, getCustomConvarStatus, layerCustomConvars, + readCustomConvarSettings, saveCustomConvarSettings, stripCustomConvars, validateCustomConvars, +} from './customConvars'; +import { applyPerformanceConfig, getPerformanceConfigStatus, removePerformanceConfig, resetPerformanceConfigOverrides } from './performanceConfig'; + +const STOCK = readFileSync(join(__dirname, '__fixtures__/stock-gameinfo.gi'), 'utf-8').replace(/\r\n/g, '\n'); +const entries = [{ key: 'r_aspectratio', value: '1.9' }, { key: 'cl_updaterate', value: '42' }]; +let root: string; +let file: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'grimoire-personal-')); + mkdirSync(join(root, 'game', 'citadel'), { recursive: true }); + file = join(root, 'game', 'citadel', 'gameinfo.gi'); + writeFileSync(file, STOCK); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); +const read = () => readFileSync(file, 'utf-8'); +const save = (autoRestore = false) => saveCustomConvarSettings(root, { entries, autoRestore }); + +describe('personal convar preferences', () => { + it('saves independently of the game file, survives reset, and reports saved-but-unapplied state', () => { + save(); + expect(read()).toBe(STOCK); + expect(readCustomConvarSettings(root).entries[0]).toEqual({ ...entries[0], enabled: true }); + expect(getCustomConvarStatus(root).applied).toBe(false); + applyCustomConvars(root); + writeFileSync(file, STOCK); + expect(readCustomConvarSettings(root).entries).toHaveLength(2); + expect(applyCustomConvars(root).applied).toBe(true); + }); + + it.each(['x\nGameInfo', 'x" y', 'x} {', 'x\\y', '//oops', ''])('rejects unsafe value %j without changing saved preferences', (value) => { + save(); + const before = readCustomConvarSettings(root); + expect(() => saveCustomConvarSettings(root, { entries: [{ key: 'r_test', value }], autoRestore: false })).toThrow(); + expect(readCustomConvarSettings(root)).toEqual(before); + expect(read()).toBe(STOCK); + }); + + it('rejects duplicate keys case-insensitively and reports failed storage', () => { + expect(() => validateCustomConvars({ entries: [{ key: 'R_TEST', value: '1' }, { key: 'r_test', value: '2' }], autoRestore: false })).toThrow(/Duplicate/); + mkdirSync(join(root, 'game', 'citadel', 'grimoire-custom-convars.json')); + expect(() => save()).toThrow(); + expect(read()).toBe(STOCK); + }); +}); + +describe('reversible personal layer', () => { + it.each([STOCK, STOCK.replace(/\n/g, '\r\n')])('round-trips exact bytes and applying repeatedly is idempotent', (original) => { + const applied = layerCustomConvars(original, entries); + expect(applied).toContain('r_aspectratio "1.9"'); + expect(layerCustomConvars(applied, entries)).toBe(applied); + expect(stripCustomConvars(applied)).toBe(original); + expect(layerCustomConvars(applied, [])).toBe(original); + }); + + it('overrides an existing key once and restores its original comment and value', () => { + const original = '"GameInfo"\n{\n "ConVars"\n {\n "R_ASPECTRATIO" "2.5" // player comment\n }\n}\n'; + const result = layerCustomConvars(original, [entries[0]]); + expect(result).toContain('"R_ASPECTRATIO" "1.9"'); + expect(stripCustomConvars(result)).toBe(original); + expect(layerCustomConvars(result, [{ ...entries[0], enabled: false }])).toBe(original); + }); + + it('fails closed on duplicate target keys or incomplete game files', () => { + const duplicate = 'GameInfo\n{\nConVars\n{\nr_aspectratio "1"\nR_ASPECTRATIO "2"\n}\n}\n'; + expect(() => layerCustomConvars(duplicate, entries)).toThrow(/ambiguous/); + expect(() => layerCustomConvars('GameInfo { ConVars {', entries)).toThrow(); + expect(() => layerCustomConvars('GameInfo {}', entries)).toThrow(); + }); + + it('personal values survive preset switching/reset/removal without being harvested', () => { + save(); + applyCustomConvars(root); + for (const presetId of ['sqooky-default', 'optilock-max']) { + expect(applyPerformanceConfig(root, { presetId }).state).toBe('applied'); + expect(getCustomConvarStatus(root).applied).toBe(true); + expect(getPerformanceConfigStatus(root).handEdited).toBe(false); + expect(getPerformanceConfigStatus(root).overrideCount).toBe(0); + } + expect(resetPerformanceConfigOverrides(root, { presetId: 'optilock-max' }).state).toBe('applied'); + expect(getCustomConvarStatus(root).applied).toBe(true); + expect(removePerformanceConfig(root).state).toBe('not-applied'); + expect(getCustomConvarStatus(root).applied).toBe(true); + saveCustomConvarSettings(root, { entries: [], autoRestore: false }); + applyCustomConvars(root); + expect(read()).toBe(STOCK); + }); +}); + +describe('startup and modded-launch recovery', () => { + const idle = async () => false; + const immediate = async () => {}; + it('requires opt-in and recovers valid reset files when enabled', async () => { + save(); + expect(await applyCustomConvarsWhenIdle(root, idle, true, immediate)).toBeNull(); + expect(read()).toBe(STOCK); + save(true); + expect((await applyCustomConvarsWhenIdle(root, idle, true, immediate))?.applied).toBe(true); + writeFileSync(file, STOCK); + await applyCustomConvarsWhenIdle(root, idle, true, immediate); + expect(getCustomConvarStatus(root).applied).toBe(true); + }); + it('applies saved removal of the final row on recovery', async () => { + save(true); + applyCustomConvars(root); + saveCustomConvarSettings(root, { entries: [], autoRestore: true }); + await applyCustomConvarsWhenIdle(root, idle, true, immediate); + expect(read()).toBe(STOCK); + }); + it('does not block launch or warn about a running game when values already match', async () => { + save(true); + applyCustomConvars(root); + const before = read(); + const result = await applyCustomConvarsWhenIdle(root, async () => true, true, immediate); + expect(result?.applied).toBe(true); + expect(result?.error).toBeNull(); + expect(read()).toBe(before); + }); + it('refuses a running game, a game starting during the wait, and a changing file', async () => { + save(true); + await expect(applyCustomConvarsWhenIdle(root, async () => true, true, immediate)).rejects.toThrow(/Close Deadlock/); + let checks = 0; + await expect(applyCustomConvarsWhenIdle(root, async () => ++checks > 1, true, immediate)).rejects.toThrow(/Close Deadlock/); + await expect(applyCustomConvarsWhenIdle(root, idle, true, async () => { writeFileSync(file, STOCK + '\n'); })).rejects.toThrow(/changing/); + expect(read()).toBe(STOCK + '\n'); + }); + it('does not replace an empty or malformed file with an old backup', async () => { + save(true); + writeFileSync(file + '.grimoire-bak', STOCK); + writeFileSync(file, ''); + await expect(applyCustomConvarsWhenIdle(root, idle, true, immediate)).rejects.toThrow(); + expect(read()).toBe(''); + }); + it('refuses Steam update states before touching the file', async () => { + const steamRoot = join(root, 'steamapps'); + const gameRoot = join(steamRoot, 'common', 'Deadlock'); + mkdirSync(join(gameRoot, 'game', 'citadel'), { recursive: true }); + const gameFile = join(gameRoot, 'game', 'citadel', 'gameinfo.gi'); + writeFileSync(gameFile, STOCK); + saveCustomConvarSettings(gameRoot, { entries, autoRestore: true }); + const manifest = join(steamRoot, 'appmanifest_1422450.acf'); + writeFileSync(manifest, '"AppState" { "StateFlags" "1026" }'); + await expect(applyCustomConvarsWhenIdle(gameRoot, idle, true, immediate)).rejects.toThrow(/Steam/); + expect(readFileSync(gameFile, 'utf-8')).toBe(STOCK); + writeFileSync(manifest, '"AppState" { "StateFlags" "4" }'); + expect((await applyCustomConvarsWhenIdle(gameRoot, idle, true, immediate))?.applied).toBe(true); + }); +}); diff --git a/electron/main/services/customConvars.ts b/electron/main/services/customConvars.ts new file mode 100644 index 00000000..9e3ae9bb --- /dev/null +++ b/electron/main/services/customConvars.ts @@ -0,0 +1,235 @@ +import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs'; +import { dirname, join } from 'path'; +import { randomUUID } from 'crypto'; +import { getGameinfoPath } from './deadlock'; +import type { CustomConvar, CustomConvarSettings, CustomConvarStatus } from '../../../src/types/electron'; + +const MARKER = 'grimoire-custom-convar'; +const EMPTY: CustomConvarSettings = { entries: [], autoRestore: false }; +const recoveryErrors = new Map(); + +export function validateCustomConvars(input: unknown): CustomConvarSettings { + if (!input || typeof input !== 'object') throw new Error('Invalid custom settings.'); + const value = input as CustomConvarSettings; + if (!Array.isArray(value.entries) || value.entries.length > 100 || typeof value.autoRestore !== 'boolean') { + throw new Error('Use at most 100 custom convars and a valid restore preference.'); + } + const seen = new Set(); + const entries = value.entries.map((entry) => { + if (!entry || typeof entry.key !== 'string' || typeof entry.value !== 'string') throw new Error('Each convar needs a name and value.'); + const key = entry.key.trim().toLowerCase(); + const val = entry.value.trim(); + if (!/^[a-z_][a-z0-9_.]{0,127}$/.test(key)) throw new Error(`Invalid convar name: ${entry.key}`); + const hasControl = [...val].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127); + if (!val || val.length > 256 || hasControl || /["{}\\]/.test(val) || val.includes('//')) { + throw new Error(`Use a single value without quotes, braces, comments or backslashes for ${key}.`); + } + if (seen.has(key)) throw new Error(`Duplicate convar: ${key}`); + seen.add(key); + if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') throw new Error(`Invalid enabled setting for ${key}.`); + return { key, value: val, enabled: entry.enabled !== false }; + }); + return { entries, autoRestore: value.autoRestore }; +} + +function settingsPath(deadlockPath: string): string { + return join(dirname(getGameinfoPath(deadlockPath)), 'grimoire-custom-convars.json'); +} + +export function readCustomConvarSettings(deadlockPath: string): CustomConvarSettings { + const file = settingsPath(deadlockPath); + if (!existsSync(file)) return { ...EMPTY, entries: [] }; + return validateCustomConvars(JSON.parse(readFileSync(file, 'utf-8'))); +} + +function atomicWrite(file: string, content: string, expected?: string): void { + const temporary = `${file}.${randomUUID()}.tmp`; + try { + writeFileSync(temporary, content, { encoding: 'utf-8', flag: 'wx' }); + if (expected !== undefined && readFileSync(file, 'utf-8') !== expected) { + throw new Error('The game file changed while applying. Try again when Steam has finished updating.'); + } + renameSync(temporary, file); + } finally { + if (existsSync(temporary)) unlinkSync(temporary); + } +} + +export function saveCustomConvarSettings(deadlockPath: string, input: unknown): void { + const settings = validateCustomConvars(input); + atomicWrite(settingsPath(deadlockPath), JSON.stringify(settings, null, 2)); +} + +interface Token { value: string; start: number; end: number } +interface Entry { key: Token; value?: Token; children?: Entry[]; close?: Token } + +function parse(text: string): Entry[] { + const tokens: Token[] = []; + const re = /\s+|\/\/[^\r\n]*|"(?:\\.|[^"\\])*"|[{}]|[^\s{}"]+/gy; + let at = 0; + while (at < text.length) { + re.lastIndex = at; + const match = re.exec(text); + if (!match) throw new Error('gameinfo.gi contains invalid syntax.'); + at = re.lastIndex; + const raw = match[0]; + if (/^\s|^\/\//.test(raw)) continue; + tokens.push({ value: raw.startsWith('"') ? raw.slice(1, -1) : raw, start: match.index, end: at }); + } + let cursor = 0; + const block = (nested: boolean): { entries: Entry[]; close?: Token } => { + const entries: Entry[] = []; + while (cursor < tokens.length) { + const key = tokens[cursor++]; + if (key.value === '}') { + if (!nested) throw new Error('gameinfo.gi has an unexpected closing brace.'); + return { entries, close: key }; + } + const value = tokens[cursor++]; + if (key.value === '{' || !value || value.value === '}') throw new Error('gameinfo.gi is incomplete.'); + if (value.value === '{') { + const child = block(true); + entries.push({ key, children: child.entries, close: child.close }); + } else entries.push({ key, value }); + } + if (nested) throw new Error('gameinfo.gi is incomplete.'); + return { entries }; + }; + return block(false).entries; +} + +function convarSection(text: string): Entry { + const roots = parse(text); + if (roots.length !== 1 || roots[0].key.value.toLowerCase() !== 'gameinfo') throw new Error('Expected a complete GameInfo file.'); + const blocks = roots[0].children?.filter((entry) => entry.key.value.toLowerCase() === 'convars'); + if (blocks?.length !== 1 || !blocks[0].children || !blocks[0].close) throw new Error('gameinfo.gi has no unambiguous ConVars section.'); + return blocks[0]; +} + +export function stripCustomConvars(text: string): string { + const clean = text.replace(/^([^\r\n]*?) \/\/ grimoire-custom-convar (added|original:([A-Za-z0-9+/=]+))(\r?\n|$)/gm, (_all, _line, kind: string, encoded: string, eol: string) => { + if (kind === 'added') return ''; + const original = Buffer.from(encoded, 'base64').toString('utf-8'); + if (/[\r\n]/.test(original) || original.includes(MARKER)) throw new Error('A custom convar recovery marker is damaged.'); + return original + eol; + }); + if (clean.includes(MARKER)) throw new Error('A custom convar recovery marker is damaged.'); + return clean; +} + +/** Personal values are the outermost reversible layer, above any preset. */ +export function layerCustomConvars(text: string, entries: CustomConvar[]): string { + const clean = stripCustomConvars(text); + if (!entries.length) return clean; + const validated = validateCustomConvars({ entries, autoRestore: false }).entries.filter((entry) => entry.enabled !== false); + if (!validated.length) return clean; + const section = convarSection(clean); + const edits: Array<{ start: number; end: number; text: string }> = []; + const additions: string[] = []; + const eol = clean.includes('\r\n') ? '\r\n' : '\n'; + for (const entry of validated) { + const matches = section.children!.filter((item) => item.key.value.toLowerCase() === entry.key); + if (matches.length > 1 || matches[0]?.children) throw new Error(`The existing ${entry.key} entry is ambiguous; no changes were written.`); + if (matches.length) { + const match = matches[0]; + const start = clean.lastIndexOf('\n', match.key.start) + 1; + let end = clean.indexOf('\n', match.value!.end); + if (end < 0) end = clean.length; + if (clean[end - 1] === '\r') end--; + const original = clean.slice(start, end); + const suffix = clean.slice(match.value!.end, end); + if (clean.slice(start, match.key.start).trim() || !/^\s*(?:\/\/.*)?$/.test(suffix) || /[\r\n]/.test(original)) { + throw new Error(`The ${entry.key} entry uses an unsupported layout; no changes were written.`); + } + edits.push({ start, end, text: `${clean.slice(start, match.value!.start)}"${entry.value}" // ${MARKER} original:${Buffer.from(original).toString('base64')}` }); + } else additions.push(` ${entry.key} "${entry.value}" // ${MARKER} added${eol}`); + } + const insertion = clean.lastIndexOf('\n', section.close!.start) + 1; + if (additions.length) { + if (clean.slice(insertion, section.close!.start).trim()) throw new Error('The ConVars closing brace must be on its own line.'); + edits.push({ start: insertion, end: insertion, text: additions.join('') }); + } + return edits.sort((a, b) => b.start - a.start).reduce((result, edit) => result.slice(0, edit.start) + edit.text + result.slice(edit.end), clean); +} + +export function getCustomConvarStatus(deadlockPath: string): CustomConvarStatus { + const settings = readCustomConvarSettings(deadlockPath); + try { + const content = readFileSync(getGameinfoPath(deadlockPath), 'utf-8'); + return { settings, applied: layerCustomConvars(content, settings.entries) === content, error: recoveryErrors.get(deadlockPath) ?? null }; + } catch (error) { + return { settings, applied: false, error: String(error) }; + } +} + +export function applyCustomConvars(deadlockPath: string): CustomConvarStatus { + const settings = readCustomConvarSettings(deadlockPath); + const file = getGameinfoPath(deadlockPath); + const original = readFileSync(file, 'utf-8'); + convarSection(original); + const next = layerCustomConvars(original, settings.entries); + if (next !== original) atomicWrite(file, next, original); + recoveryErrors.delete(deadlockPath); + return getCustomConvarStatus(deadlockPath); +} + +/** Refuse known update states; custom/non-Steam installs have no manifest. */ +function steamUpdateActive(deadlockPath: string): boolean { + const manifest = join(dirname(dirname(deadlockPath)), 'appmanifest_1422450.acf'); + if (!existsSync(manifest)) return false; + const content = readFileSync(manifest, 'utf-8'); + const flags = /"StateFlags"\s+"(\d+)"/.exec(content); + return !flags || Number(flags[1]) !== 4; +} + +export async function applyCustomConvarsWhenIdle( + deadlockPath: string, + isGameRunning: () => Promise, + automatic = false, + wait: () => Promise = () => new Promise((resolve) => setTimeout(resolve, 500)), +): Promise { + try { + return await applyWhenIdle(deadlockPath, isGameRunning, automatic, wait); + } catch (error) { + if (automatic) recoveryErrors.set(deadlockPath, `Automatic restore was deferred: ${String(error)}`); + throw error; + } +} + +async function applyWhenIdle( + deadlockPath: string, + isGameRunning: () => Promise, + automatic: boolean, + wait: () => Promise, +): Promise { + const settings = readCustomConvarSettings(deadlockPath); + if (automatic && !settings.autoRestore) return null; + if (automatic) { + const current = getCustomConvarStatus(deadlockPath); + if (current.applied) { + recoveryErrors.delete(deadlockPath); + return { ...current, error: null }; + } + } + return withIdleGameinfo(deadlockPath, isGameRunning, () => applyCustomConvars(deadlockPath), wait); +} + +export async function withIdleGameinfo( + deadlockPath: string, + isGameRunning: () => Promise, + operation: () => T, + wait: () => Promise = () => new Promise((resolve) => setTimeout(resolve, 500)), +): Promise { + const file = getGameinfoPath(deadlockPath); + if (await isGameRunning()) throw new Error('Close Deadlock before changing gameinfo.gi.'); + if (steamUpdateActive(deadlockPath)) throw new Error('Wait for Steam to finish updating Deadlock.'); + const before = readFileSync(file, 'utf-8'); + convarSection(before); + const modified = statSync(file).mtimeMs; + await wait(); + if (await isGameRunning()) throw new Error('Close Deadlock before changing gameinfo.gi.'); + if (steamUpdateActive(deadlockPath) || statSync(file).mtimeMs !== modified || readFileSync(file, 'utf-8') !== before) { + throw new Error('The game file is changing. Wait for Steam to finish, then apply again.'); + } + return operation(); +} diff --git a/electron/main/services/performanceConfig.ts b/electron/main/services/performanceConfig.ts index 11113ece..87ad6d88 100644 --- a/electron/main/services/performanceConfig.ts +++ b/electron/main/services/performanceConfig.ts @@ -23,6 +23,7 @@ import { createHash } from 'crypto'; import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; import { dirname, join } from 'path'; import { getGameinfoPath } from './deadlock'; +import { layerCustomConvars, stripCustomConvars, readCustomConvarSettings } from './customConvars'; import type { PerformanceConfigStatus, PerformancePresetSummary, @@ -463,7 +464,7 @@ export function applyPerformanceConfig( const crlf = original.includes('\r\n'); // Work in LF-space (see header comment), restore the EOL style on write. - let content = crlf ? original.split('\r\n').join('\n') : original; + let content = stripCustomConvars(crlf ? original.split('\r\n').join('\n') : original); const sidecar = readAppliedState(gameinfoPath); const saved = allOverrides(sidecar); @@ -516,7 +517,7 @@ export function applyPerformanceConfig( const backupPath = backupPathFor(gameinfoPath); if (!existsSync(backupPath) && hasConVars(original)) { try { - writeFileSync(backupPath, original, 'utf-8'); + writeFileSync(backupPath, stripCustomConvars(original), 'utf-8'); } catch { // Best-effort: a failed backup must not block the apply. } @@ -612,13 +613,14 @@ export function applyPerformanceConfig( return status('error', 'Patch produced an unbalanced gameinfo.gi; no changes were written.'); } - const finalText = crlf ? content.split('\n').join('\r\n') : content; + const presetText = crlf ? content.split('\n').join('\r\n') : content; + const finalText = layerCustomConvars(presetText, readCustomConvarSettings(deadlockPath).entries); writeFileSync(gameinfoPath, finalText, 'utf-8'); saved[preset.id] = overrides; writeAppliedState(gameinfoPath, { presetId: preset.id, version: preset.version, - contentHash: sha256(finalText), + contentHash: sha256(presetText), optIns, overridesByPreset: saved, }); @@ -710,8 +712,9 @@ export function removePerformanceConfig(deadlockPath: string | null): Performanc return status('not-applied', 'No performance config to remove.'); } const crlf = content.includes('\r\n'); - const restored = removeMarkers(crlf ? content.split('\r\n').join('\n') : content); - writeFileSync(gameinfoPath, crlf ? restored.split('\n').join('\r\n') : restored, 'utf-8'); + const restored = removeMarkers(stripCustomConvars(crlf ? content.split('\r\n').join('\n') : content)); + const presetText = crlf ? restored.split('\n').join('\r\n') : restored; + writeFileSync(gameinfoPath, layerCustomConvars(presetText, readCustomConvarSettings(deadlockPath).entries), 'utf-8'); clearAppliedState(gameinfoPath); return status('not-applied', 'Performance config removed; stock values restored.'); } catch (err) { @@ -827,7 +830,7 @@ export function getPerformanceConfigStatus(deadlockPath: string | null): Perform // sidecars without a hash just never set the flag. const sidecar = readAppliedState(gameinfoPath); const handEdited = - typeof sidecar?.contentHash === 'string' && sidecar.contentHash !== sha256(content); + typeof sidecar?.contentHash === 'string' && sidecar.contentHash !== sha256(stripCustomConvars(content)); // The marker is authoritative for which preset is in the file: the // sidecar can be stale or absent (hand-installed, restored backup). const appliedId = begin[1]; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index b977552f..e4b18963 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -398,6 +398,9 @@ contextBridge.exposeInMainWorld('electronAPI', { getGameinfoStatus: () => ipcRenderer.invoke('get-gameinfo-status'), fixGameinfo: () => ipcRenderer.invoke('fix-gameinfo'), getPerformanceConfigStatus: () => ipcRenderer.invoke('get-performance-config-status'), + getCustomConvarStatus: () => ipcRenderer.invoke('get-custom-convar-status'), + saveCustomConvars: (settings) => ipcRenderer.invoke('save-custom-convars', settings), + applyCustomConvars: () => ipcRenderer.invoke('apply-custom-convars'), listPerformancePresets: () => ipcRenderer.invoke('list-performance-presets'), applyPerformanceConfig: (presetId?: string, optIns?: string[], version?: string | null) => ipcRenderer.invoke('apply-performance-config', presetId, optIns, version), diff --git a/src/components/performance/CustomConvars.test.tsx b/src/components/performance/CustomConvars.test.tsx new file mode 100644 index 00000000..93f16fcc --- /dev/null +++ b/src/components/performance/CustomConvars.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +import { act, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CustomConvarSettings, CustomConvarStatus } from '../../types/electron'; +import CustomConvars from './CustomConvars'; + +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('../common/ui', () => ({ + Button: ({ children, disabled, onClick }: { children: ReactNode; disabled?: boolean; onClick: () => void }) => , + Toggle: ({ checked, disabled, label, onChange }: { checked: boolean; disabled?: boolean; label: string; onChange: (checked: boolean) => void }) => , +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +describe('personal convar editor', () => { + let host: HTMLDivElement; + let root: Root; + let stored: CustomConvarSettings; + let getStatus: ReturnType Promise>>; + let save: ReturnType Promise>>; + let apply: ReturnType Promise>>; + let onApplied: ReturnType void>>; + const status = (): CustomConvarStatus => ({ settings: stored, applied: false, error: null }); + + beforeEach(() => { + stored = { entries: [{ key: 'r_aspectratio', value: '2.15', enabled: true }], autoRestore: false }; + getStatus = vi.fn<() => Promise>().mockImplementation(async () => status()); + save = vi.fn<(settings: CustomConvarSettings) => Promise>().mockImplementation(async (settings) => { + stored = structuredClone(settings); + return status(); + }); + apply = vi.fn<() => Promise>().mockImplementation(async () => ({ ...status(), applied: true })); + onApplied = vi.fn<() => void>(); + window.electronAPI = { getCustomConvarStatus: getStatus, saveCustomConvars: save, applyCustomConvars: apply } as unknown as Window['electronAPI']; + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + }); + afterEach(() => { act(() => root.unmount()); host.remove(); }); + const button = (key: string) => [...host.querySelectorAll('button')].find((element) => element.textContent === `performance.custom.${key}`)!; + const inputs = () => [...host.querySelectorAll('input:not([type="checkbox"])')]; + const render = () => act(async () => root.render()); + const click = (element: HTMLElement) => act(async () => element.click()); + async function edit(input: HTMLInputElement, value: string) { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + + it('saves edits without applying or reporting them applied when Save only is chosen', async () => { + await render(); + await edit(inputs()[1], '1.8'); + expect(button('save').disabled).toBe(false); + await click(button('save')); + expect(save).toHaveBeenCalledWith({ entries: [{ key: 'r_aspectratio', value: '1.8', enabled: true }], autoRestore: false }); + expect(apply).not.toHaveBeenCalled(); + expect(onApplied).not.toHaveBeenCalled(); + expect(button('save').disabled).toBe(true); + expect(host.textContent).toContain('performance.custom.saved'); + expect(host.textContent).toContain('performance.custom.pending'); + }); + + it('waits for validated saved preferences before applying and retains them after the running-game rejection', async () => { + await render(); + await edit(inputs()[0], ' R_ASPECTRATIO '); + await edit(inputs()[1], '1.7'); + const saving = deferred(); + save.mockReturnValueOnce(saving.promise); + apply.mockRejectedValueOnce(new Error('Close Deadlock before changing gameinfo.gi.')); + await click(button('saveApply')); + expect(apply).not.toHaveBeenCalled(); + expect(button('saveApply').disabled).toBe(true); + stored = { entries: [{ key: 'r_aspectratio', value: '1.7', enabled: true }], autoRestore: false }; + await act(async () => saving.resolve(status())); + expect(apply).toHaveBeenCalledTimes(1); + expect(inputs()[0].value).toBe('r_aspectratio'); + expect(inputs()[1].value).toBe('1.7'); + expect(button('save').disabled).toBe(true); + expect(host.querySelector('[role="alert"]')?.textContent).toContain('Close Deadlock'); + expect(host.textContent).not.toContain('performance.custom.applied'); + expect(onApplied).not.toHaveBeenCalled(); + }); + + it('never applies a rejected save and leaves the draft available to fix', async () => { + await render(); + await edit(inputs()[1], 'bad"value'); + save.mockRejectedValueOnce(new Error('Invalid convar value')); + await click(button('saveApply')); + expect(apply).not.toHaveBeenCalled(); + expect(host.querySelector('[role="alert"]')?.textContent).toContain('Invalid convar value'); + expect(inputs()[1].value).toBe('bad"value'); + expect(button('save').disabled).toBe(false); + }); + + it('saves a disabled row without deleting it, and removes it only after the remove action', async () => { + await render(); + await click(host.querySelector('[aria-label="performance.custom.enable"]')!); + await click(button('save')); + expect(stored.entries).toEqual([{ key: 'r_aspectratio', value: '2.15', enabled: false }]); + expect(inputs()).toHaveLength(2); + await click(host.querySelector('[aria-label="performance.custom.remove"]')!); + expect(inputs()).toHaveLength(0); + await click(button('saveApply')); + expect(stored.entries).toEqual([]); + expect(apply).toHaveBeenCalledTimes(1); + expect(onApplied).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/performance/CustomConvars.tsx b/src/components/performance/CustomConvars.tsx new file mode 100644 index 00000000..716a54cd --- /dev/null +++ b/src/components/performance/CustomConvars.tsx @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Plus, Trash2 } from 'lucide-react'; +import { Button, Toggle } from '../common/ui'; +import type { CustomConvarSettings, CustomConvarStatus } from '../../types/electron'; + +export default function CustomConvars({ revision, onApplied }: { revision: unknown; onApplied: () => void }) { + const { t } = useTranslation(); + const [status, setStatus] = useState(null); + const [draft, setDraft] = useState({ entries: [], autoRestore: false }); + const [dirty, setDirty] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [loadError, setLoadError] = useState(null); + const [notice, setNotice] = useState(null); + const refresh = useCallback(async () => { + try { + const next = await window.electronAPI.getCustomConvarStatus(); + setStatus(next); + if (!dirty) setDraft(next.settings); + setLoadError(null); + } catch (err) { setLoadError(String(err)); } + }, [dirty]); + useEffect(() => { void refresh(); }, [refresh, revision]); + useEffect(() => { + const focus = () => { void refresh(); }; + window.addEventListener('focus', focus); + return () => window.removeEventListener('focus', focus); + }, [refresh]); + + const change = (next: CustomConvarSettings) => { setDraft(next); setDirty(true); setNotice(null); }; + const save = async (apply: boolean) => { + setBusy(true); + setError(null); + setNotice(null); + try { + const saved = await window.electronAPI.saveCustomConvars(draft); + setStatus(saved); + setDraft(saved.settings); + setDirty(false); + if (apply) { + setNotice(t('performance.custom.savedBeforeApply')); + const applied = await window.electronAPI.applyCustomConvars(); + setStatus(applied); + setError(applied.error); + setNotice(t('performance.custom.applied')); + onApplied(); + } else setNotice(t('performance.custom.saved')); + } catch (err) { setError(String(err)); } + finally { setBusy(false); } + }; + const displayedError = error ?? loadError ?? status?.error; + const inputClass = 'w-full min-w-0 rounded-sm border border-white/10 bg-bg-primary px-3 py-2 text-sm font-mono text-text-primary focus:outline-none focus:border-accent'; + return ( +
+

{t('performance.custom.title')}

+ {draft.entries.map((entry, index) => ( +
+ + + + +
+ ))} + + change({ ...draft, autoRestore })} + label={t('performance.custom.autoRestore')} /> + {dirty &&

{t('performance.custom.unsaved')}

} + {!dirty && status && !status.applied && !displayedError &&

{t('performance.custom.pending')}

} + {notice &&

{notice}

} + {displayedError &&

{displayedError}

} +
+ + +
+
+ ); +} diff --git a/src/components/performance/PerformanceConfigCard.tsx b/src/components/performance/PerformanceConfigCard.tsx index 375a9d52..d5939462 100644 --- a/src/components/performance/PerformanceConfigCard.tsx +++ b/src/components/performance/PerformanceConfigCard.tsx @@ -25,6 +25,7 @@ import PresetSummary from './PresetSummary'; import VersionPicker from './VersionPicker'; import VersionHistoryModal from './VersionHistoryModal'; import GameplayOptIns from './GameplayOptIns'; +import CustomConvars from './CustomConvars'; import { useAppStore, type BrowseArtistRef } from '../../stores/appStore'; import { applyPerformanceConfig, @@ -547,6 +548,7 @@ export default function PerformanceConfigCard() { contentClassName="p-0" >
+ void refresh()} />
{presets.length > 0 && ( diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 4f3e1cc3..699be3ba 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -2954,7 +2954,23 @@ "downloaded": "downloaded", "downloadedHint": "Already fetched and cached; applies without a network connection." }, - "moreActions": "More actions" + "moreActions": "More actions", + "custom": { + "title": "Personal convars", + "name": "Convar name", + "value": "Value", + "remove": "Remove {{name}}", + "add": "Add convar", + "autoRestore": "Restore on startup and before launch", + "unsaved": "You have unsaved changes.", + "pending": "Saved values differ from gameinfo.gi. Apply to update the file.", + "saved": "Saved. The game file has not been changed.", + "savedBeforeApply": "Settings saved. Applying to the game file…", + "applied": "Personal values applied.", + "saveApply": "Save & apply", + "save": "Save only", + "enable": "Enable {{name}}" + } }, "lockerOverrides": { "volume": "Volume", diff --git a/src/locales/manifest.json b/src/locales/manifest.json index 5d77cdfe..d03492da 100644 --- a/src/locales/manifest.json +++ b/src/locales/manifest.json @@ -1,6 +1,6 @@ { "sourceLanguage": "en", - "totalKeys": 2347, + "totalKeys": 2361, "languages": [ { "code": "bg", @@ -11,7 +11,7 @@ { "code": "en", "name": "English", - "translatedKeys": 2347, + "translatedKeys": 2361, "pct": 100 }, { diff --git a/src/types/electron.ts b/src/types/electron.ts index f38571d1..aeb01359 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -122,6 +122,10 @@ export interface EditorCandidate { path: string; } +export interface CustomConvar { key: string; value: string; enabled?: boolean } +export interface CustomConvarSettings { entries: CustomConvar[]; autoRestore: boolean } +export interface CustomConvarStatus { settings: CustomConvarSettings; applied: boolean; error: string | null } + /** A gameplay/visibility convar a preset's author set, which Grimoire holds * back from the preset body and writes only when the user opts in. */ export interface PerformanceOptIn { @@ -1081,6 +1085,9 @@ export interface ElectronAPI { getGameinfoStatus: () => Promise; fixGameinfo: () => Promise; getPerformanceConfigStatus: () => Promise; + getCustomConvarStatus: () => Promise; + saveCustomConvars: (settings: CustomConvarSettings) => Promise; + applyCustomConvars: () => Promise; listPerformancePresets: () => Promise; applyPerformanceConfig: ( presetId?: string,