From 1ccc88a0592d8a0cbf655a90fc93fd8de5665482 Mon Sep 17 00:00:00 2001 From: slush97 Date: Sun, 4 Oct 2026 03:17:05 -0700 Subject: [PATCH 1/3] feat(installed): flag mods that ship outdated vdata A mod that ships a game .vdata_c (scripts/heroes.vdata_c, ...) replaces the whole file, so anything Valve added after it was built is deleted in game. After each get-mods, a background pass runs `vpkmerge vdata-check` on mods whose VPK contains a .vdata_c and shows an "Outdated data" tag on flagged cards, with the removed fields in the tooltip. Results are keyed on file size and mtime and reset when pak01 changes. --- electron/main/ipc/mods.ts | 5 +- electron/main/services/vdataCheck.ts | 99 ++++++++++++++++++++++++++++ electron/preload/index.ts | 5 ++ src/components/Layout.tsx | 7 ++ src/components/installed/ModCard.tsx | 22 ++++++- src/locales/en/translation.json | 4 ++ src/locales/manifest.json | 6 +- src/pages/Installed.tsx | 1 + src/types/electron.ts | 2 + src/types/mod.ts | 15 +++++ 10 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 electron/main/services/vdataCheck.ts diff --git a/electron/main/ipc/mods.ts b/electron/main/ipc/mods.ts index 9a8e2ccc..3378d8e3 100644 --- a/electron/main/ipc/mods.ts +++ b/electron/main/ipc/mods.ts @@ -1,5 +1,6 @@ import { ipcMain, shell } from 'electron'; import { modSafetySnapshot } from '../services/modSafety'; +import { checkOutdatedVdata, outdatedVdataSnapshot } from '../services/vdataCheck'; import { importDisabledVpk } from '../services/importDisabledVpk'; import { randomUUID } from 'node:crypto'; import { promises as fs, existsSync } from 'fs'; @@ -231,6 +232,7 @@ function enrichMod(mod: Mod): WireMod { return { ...mod, safety: modSafetySnapshot(mod.path), + outdatedVdata: outdatedVdataSnapshot(mod.path), // Use the stored mod name from GameBanana if available name: metadata.modName || mod.name, thumbnailUrl: metadata.thumbnailUrl, @@ -268,7 +270,7 @@ function enrichMod(mod: Mod): WireMod { // No metadata row (a VPK dropped straight into addons): still file-tree tag // the hero so unknown skins get their Locker chip like downloaded mods. const { lockerHero, lockerHeroSource } = resolveUnknownLockerHero(mod, metadata, isUnknown, globalType); - return { ...mod, safety: modSafetySnapshot(mod.path), isUnknown, globalType: globalType ?? undefined, lockerHero, lockerHeroSource }; + return { ...mod, safety: modSafetySnapshot(mod.path), outdatedVdata: outdatedVdataSnapshot(mod.path), isUnknown, globalType: globalType ?? undefined, lockerHero, lockerHeroSource }; } /** @@ -368,6 +370,7 @@ ipcMain.handle('get-mods', async (): Promise => { await parseVpkDirectoriesAsync(warmPaths); } const enriched = visible.map(enrichMod); + checkOutdatedVdata(deadlockPath, visible.map((m) => m.path)); if (settings.verboseModTrace) { const hidden = mods.length - visible.length; // The renderer (Installed.tsx visibleMods) also hides disabled source diff --git a/electron/main/services/vdataCheck.ts b/electron/main/services/vdataCheck.ts new file mode 100644 index 00000000..b40a2c1c --- /dev/null +++ b/electron/main/services/vdataCheck.ts @@ -0,0 +1,99 @@ +import { BrowserWindow } from 'electron'; +import { statSync } from 'node:fs'; +import { join } from 'node:path'; +import type { OutdatedVdata } from '../../../src/types/mod'; +import { getCitadelPath } from './deadlock'; +import { runVpkmergeStdout } from './modMerger'; +import { parseVpkDirectoriesAsync } from './vpk'; + +/** + * Flags mods that ship a stale copy of a game `.vdata_c` (`scripts/heroes.vdata_c` + * and friends). The shipped copy replaces the whole file in game, so anything + * Valve added after the mod was built goes missing. Results are keyed on the + * mod's size and mtime and dropped wholesale when pak01 changes (a game update). + */ + +const SAMPLE_SIZE = 8; + +interface Snapshot { size: number; mtimeMs: number; outdated: OutdatedVdata[] } +interface CliResult { + reports?: Array<{ entry: string; status: string; missing: string[]; extra: string[] }>; +} + +const snapshots = new Map(); +let baseKey = ''; +let queue: Promise = Promise.resolve(); + +export function outdatedVdataSnapshot(path: string): OutdatedVdata[] | undefined { + const entry = snapshots.get(path); + if (!entry?.outdated.length) return undefined; + const stat = statSync(path, { throwIfNoEntry: false }); + return stat?.size === entry.size && stat.mtimeMs === entry.mtimeMs ? entry.outdated : undefined; +} + +/** Checks mods with no current result in the background. Sends + * `mod-vdata-checked` when a new warning turns up. */ +export function checkOutdatedVdata(deadlockPath: string, modPaths: string[]): void { + queue = queue + .then(() => check(deadlockPath, modPaths)) + .catch((err) => console.warn('[vdata-check] Check failed:', err)); +} + +async function check(deadlockPath: string, modPaths: string[]): Promise { + const base = join(getCitadelPath(deadlockPath), 'pak01_dir.vpk'); + const baseStat = statSync(base, { throwIfNoEntry: false }); + if (!baseStat) return; + const key = `${base}:${baseStat.size}:${baseStat.mtimeMs}`; + if (key !== baseKey) { + snapshots.clear(); + baseKey = key; + } + + const pending = new Map(); + for (const path of modPaths) { + const stat = statSync(path, { throwIfNoEntry: false }); + if (!stat) continue; + const entry = snapshots.get(path); + if (entry?.size === stat.size && entry.mtimeMs === stat.mtimeMs) continue; + pending.set(path, { size: stat.size, mtimeMs: stat.mtimeMs }); + } + if (pending.size === 0) return; + + const trees = await parseVpkDirectoriesAsync([...pending.keys()]); + const candidates: string[] = []; + for (const [path, stat] of pending) { + if (trees.get(path)?.some((entry) => entry.endsWith('.vdata_c'))) candidates.push(path); + else snapshots.set(path, { ...stat, outdated: [] }); + } + if (candidates.length === 0) return; + + let results: CliResult[]; + try { + results = JSON.parse( + await runVpkmergeStdout(['vdata-check', '--base', base, '--json', ...candidates], 60000) + ); + } catch (err) { + // Includes bundled binaries that predate the subcommand. Marked as checked + // so a failure costs one spawn per file version, not one per scan. + console.warn('[vdata-check] vpkmerge failed:', err); + for (const path of candidates) snapshots.set(path, { ...pending.get(path)!, outdated: [] }); + return; + } + + let flagged = false; + candidates.forEach((path, i) => { + const outdated = (results[i]?.reports ?? []) + .filter((r) => r.status === 'outdated') + .map((r) => ({ + entry: r.entry, + missing: r.missing.length, + extra: r.extra.length, + sample: r.missing.slice(0, SAMPLE_SIZE), + })); + snapshots.set(path, { ...pending.get(path)!, outdated }); + flagged ||= outdated.length > 0; + }); + if (flagged) { + for (const win of BrowserWindow.getAllWindows()) win.webContents.send('mod-vdata-checked'); + } +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 18ffd8a1..44cdb73c 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -129,6 +129,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('mod-safety-changed', listener); return () => ipcRenderer.removeListener('mod-safety-changed', listener); }, + onModVdataChecked: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on('mod-vdata-checked', listener); + return () => ipcRenderer.removeListener('mod-vdata-checked', listener); + }, getMods: () => ipcRenderer.invoke('get-mods'), enableMod: (modId: string) => ipcRenderer.invoke('enable-mod', modId), disableMod: (modId: string) => ipcRenderer.invoke('disable-mod', modId), diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index baf447a2..0c0546d2 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -185,6 +185,13 @@ export default function Layout() { return unsubscribe; }, []); + useEffect( + () => window.electronAPI.onModVdataChecked(() => { + void useAppStore.getState().loadMods({ force: true, silent: true }); + }), + [] + ); + // Surface GameBanana rate limiting app-wide. The heavy "Fix Unknown" // auto-detect is the usual trigger, but any tab can hit it, so the warning // lives here rather than inside one page. diff --git a/src/components/installed/ModCard.tsx b/src/components/installed/ModCard.tsx index acb3b9d8..94e8e851 100644 --- a/src/components/installed/ModCard.tsx +++ b/src/components/installed/ModCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Loader2, Trash2, AlertTriangle, FolderOpen, FilePlus, Files, ImagePlus, Download, Info, Check, Wrench, Layers, Scissors, Share2, Beaker, PowerOff, Tag as TagIcon, Pencil, MoreHorizontal, Link2, Banana, Fingerprint, ExternalLink, Star, ArrowUpToLine, ImageDown, Link, Unlink } from 'lucide-react'; +import { Loader2, Trash2, AlertTriangle, FolderOpen, FilePlus, Files, ImagePlus, Download, Info, Check, Wrench, Layers, Scissors, Share2, Beaker, PowerOff, Tag as TagIcon, Pencil, MoreHorizontal, Link2, Banana, Fingerprint, ExternalLink, Star, ArrowUpToLine, ImageDown, Link, Unlink, FileWarning } from 'lucide-react'; import { MenuContent, MenuItem, MenuLabel, MenuRadioGroup, MenuRadioItem, MenuRoot, MenuSeparator, MenuSub, MenuSubContent, MenuSubTrigger, MenuTrigger } from '../common/menu'; import { showToast } from '../../stores/toastStore'; import { revealModInFolder } from '../../lib/api'; @@ -27,6 +27,7 @@ interface ModCardProps { mod: { safety?: Mod['safety']; safetyTarget?: Pick; + outdatedVdata?: Mod['outdatedVdata']; id: string; name: string; fileName: string; @@ -523,6 +524,23 @@ function EditableModTitle({ ); } +function OutdatedVdataTag({ outdated, variant }: { outdated?: Mod['outdatedVdata']; variant: 'overlay' | 'inline' }) { + const { t } = useTranslation(); + if (!outdated?.length) return null; + const title = outdated + .map((v) => + v.missing > 0 + ? t('installed.card.outdatedDataTitle', { count: v.missing, file: v.entry, sample: v.sample.join(', ') }) + : t('installed.card.outdatedDataExtraTitle', { file: v.entry }) + ) + .join('\n\n'); + return ( + + {t('installed.card.outdatedData')} + + ); +} + function ModListRowContent({ mod, taxonomy, @@ -630,6 +648,7 @@ function ModListRowContent({ />
+
{!mod.enabled && mod.priorityMod && ( @@ -1391,6 +1410,7 @@ export function ModCard({ {t('installed.card.conflict')} )} + {mod.isUnknown && ( v.outdatedVdata)?.outdatedVdata, // Group's overall enable state is "one or more files enabled", not // the primary's individual flag (matches sort + section choice). enabled: entry.enabledVariants.length > 0, diff --git a/src/types/electron.ts b/src/types/electron.ts index e26e8237..3dfd35d9 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -853,6 +853,8 @@ export interface ElectronAPI { rescanModSafety: () => Promise; reviewModSafety: (id: string, fingerprint: string) => Promise; onModSafetyChanged: (callback: () => void) => () => void; + /** Fires when the background outdated-vdata check flags a mod. */ + onModVdataChecked: (callback: () => void) => () => void; getMods: () => Promise; enableMod: (modId: string) => Promise; disableMod: (modId: string) => Promise; diff --git a/src/types/mod.ts b/src/types/mod.ts index 0d5032ea..3d493648 100644 --- a/src/types/mod.ts +++ b/src/types/mod.ts @@ -606,8 +606,23 @@ export interface SoundSwapInfo { pool: 'all' | 'collapse'; } +/** A game `.vdata_c` the mod ships that predates the game's copy (from + * `vpkmerge vdata-check`). The mod replaces the whole file, so every game + * field its copy lacks is deleted in game. */ +export interface OutdatedVdata { + /** VPK entry, e.g. `scripts/heroes.vdata_c`. */ + entry: string; + /** Game fields the mod's copy lacks. */ + missing: number; + /** Fields only the mod's copy has (the game dropped them since). */ + extra: number; + /** First few missing key paths, shallowest first. */ + sample: string[]; +} + export interface Mod { safety?: import('./modSafety').ModSafetySnapshot; + outdatedVdata?: OutdatedVdata[]; id: string; name: string; fileName: string; From 60ef559d023ef8674c868630d2b7436024eff84a Mon Sep 17 00:00:00 2001 From: slush97 Date: Sun, 4 Oct 2026 03:54:48 -0700 Subject: [PATCH 2/3] fix(installed): harden the outdated vdata check and flag enabled mods - Results follow a VPK through renames (toggle, reorder, Global) and are forgotten on delete, so the tag no longer flickers and vpkmerge is not respawned on every toggle. - Mods go to vpkmerge 50 at a time (Windows argv limit), results are matched by path, and a mod that fails to open no longer hides the rest of a batch. - The renderer reloads whenever a check changes what a card shows, so a warning that a game update clears goes away too. - Only fields a mod deletes are flagged; fields the game dropped are not. - Enabled outdated mods get a red tag, an "N outdated mods" button on Installed that lists them with Disable / Disable all, and a warning toast when one is enabled. --- electron/main/ipc/mods.ts | 2 +- electron/main/services/launch.ts | 3 + electron/main/services/mods.ts | 3 + electron/main/services/vdataCheck.test.ts | 158 ++++++++++++++++++ electron/main/services/vdataCheck.ts | 75 +++++---- src/components/installed/ModCard.tsx | 16 +- .../installed/OutdatedVdataModal.tsx | 66 ++++++++ src/locales/en/translation.json | 16 +- src/locales/manifest.json | 6 +- src/pages/Installed.tsx | 27 ++- src/stores/appStore.ts | 7 + src/types/mod.ts | 2 - 12 files changed, 334 insertions(+), 47 deletions(-) create mode 100644 electron/main/services/vdataCheck.test.ts create mode 100644 src/components/installed/OutdatedVdataModal.tsx diff --git a/electron/main/ipc/mods.ts b/electron/main/ipc/mods.ts index 3378d8e3..80d07758 100644 --- a/electron/main/ipc/mods.ts +++ b/electron/main/ipc/mods.ts @@ -370,7 +370,7 @@ ipcMain.handle('get-mods', async (): Promise => { await parseVpkDirectoriesAsync(warmPaths); } const enriched = visible.map(enrichMod); - checkOutdatedVdata(deadlockPath, visible.map((m) => m.path)); + void checkOutdatedVdata(deadlockPath, visible.map((m) => m.path)); if (settings.verboseModTrace) { const hidden = mods.length - visible.length; // The renderer (Installed.tsx visibleMods) also hides disabled source diff --git a/electron/main/services/launch.ts b/electron/main/services/launch.ts index ddc0410e..5b4be12e 100644 --- a/electron/main/services/launch.ts +++ b/electron/main/services/launch.ts @@ -3,6 +3,7 @@ import { join, basename, dirname } from 'path'; import { spawn } from 'child_process'; import { shell } from 'electron'; import { inspectVpkSafety, isModSafetyTrusted, moveSafetySnapshot } from './modSafety'; +import { moveVdataSnapshot } from './vdataCheck'; import { assertActiveModsSafety, moveToDisabledLibrary } from './modSafetyAudit'; import { runExclusiveModMutation } from './mods'; import { migrateModMetadata } from './metadata'; @@ -294,6 +295,7 @@ export async function stashEnabledMods(deadlockPath: string): Promise { throw err; } moveSafetySnapshot(from, to); + moveVdataSnapshot(from, to); } /** @@ -142,6 +144,7 @@ async function deleteVpkSet(path: string): Promise { const chunks = findChunkSiblingNames(basename(path), await fs.readdir(dirname(path))); await fs.unlink(path); forgetSafetySnapshot(path); + forgetVdataSnapshot(path); for (const chunk of chunks) await fs.unlink(join(dirname(path), chunk)); } diff --git a/electron/main/services/vdataCheck.test.ts b/electron/main/services/vdataCheck.test.ts new file mode 100644 index 00000000..aee0c641 --- /dev/null +++ b/electron/main/services/vdataCheck.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const h = vi.hoisted(() => ({ send: vi.fn(), run: vi.fn(), trees: new Map() })); +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [{ webContents: { send: h.send } }] } })); +vi.mock('./modMerger', () => ({ runVpkmergeStdout: h.run })); +vi.mock('./vpk', () => ({ + parseVpkDirectoriesAsync: async (paths: string[]) => new Map(paths.map((p) => [p, h.trees.get(p) ?? []])), +})); +vi.mock('./deadlock', () => ({ getCitadelPath: (deadlockPath: string) => deadlockPath })); + +const HEROES = 'scripts/heroes.vdata_c'; + +let dir: string; +let vdata: typeof import('./vdataCheck'); + +async function vpk(name: string, entries: string[], bytes = 'vpk'): Promise { + const path = join(dir, 'addons', name); + await fs.writeFile(path, bytes); + h.trees.set(path, entries); + return path; +} + +function report(missing: string[], extra: string[] = []) { + return { entry: HEROES, status: 'outdated', missing, extra, changed: [] }; +} + +/** Answers every spawn with these reports per mod path (none for unlisted mods). */ +function respond(reports: Record[]>) { + h.run.mockImplementation(async (args: string[]) => + JSON.stringify(args.slice(4).map((mod) => ({ mod, reports: reports[mod] ?? [] }))) + ); +} + +beforeEach(async () => { + dir = await fs.mkdtemp(join(tmpdir(), 'vdata-check-')); + await fs.mkdir(join(dir, 'addons')); + await fs.writeFile(join(dir, 'pak01_dir.vpk'), 'game'); + h.send.mockReset(); + h.run.mockReset(); + h.trees.clear(); + vi.resetModules(); + vdata = await import('./vdataCheck'); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +describe('checkOutdatedVdata', () => { + it('never spawns vpkmerge for a library without vdata', async () => { + const skin = await vpk('pak01_dir.vpk', ['models/heroes/skin.vmdl_c']); + await vdata.checkOutdatedVdata(dir, [skin]); + expect(h.run).not.toHaveBeenCalled(); + expect(h.send).not.toHaveBeenCalled(); + }); + + it('flags a mod that deletes game fields, once', async () => { + const stale = await vpk('pak01_dir.vpk', [HEROES]); + const current = await vpk('pak02_dir.vpk', [HEROES, 'panorama/image_compiler.vdata_c']); + respond({ [stale]: [report(['hero_ratking', 'hero_base/m_vecStats[=EMaxHealth]'])] }); + + await vdata.checkOutdatedVdata(dir, [stale, current]); + expect(vdata.outdatedVdataSnapshot(stale)).toEqual([ + { entry: HEROES, missing: 2, sample: ['hero_ratking', 'hero_base/m_vecStats[=EMaxHealth]'] }, + ]); + expect(vdata.outdatedVdataSnapshot(current)).toBeUndefined(); + expect(h.send).toHaveBeenCalledTimes(1); + + await vdata.checkOutdatedVdata(dir, [stale, current]); + expect(h.run).toHaveBeenCalledTimes(1); + expect(h.send).toHaveBeenCalledTimes(1); + }); + + it('does not flag a mod that only has fields the game dropped', async () => { + const mod = await vpk('pak01_dir.vpk', [HEROES]); + respond({ [mod]: [report([], ['hero_base/m_bOld'])] }); + await vdata.checkOutdatedVdata(dir, [mod]); + expect(vdata.outdatedVdataSnapshot(mod)).toBeUndefined(); + expect(h.send).not.toHaveBeenCalled(); + }); + + it('keeps other results when one mod fails to open', async () => { + const broken = await vpk('pak01_dir.vpk', [HEROES]); + const stale = await vpk('pak02_dir.vpk', [HEROES]); + h.run.mockResolvedValue(JSON.stringify([ + { mod: stale, reports: [report(['hero_ratking'])] }, + { mod: broken, error: 'opening mod: Failed to read VPK header' }, + ])); + await vdata.checkOutdatedVdata(dir, [broken, stale]); + expect(vdata.outdatedVdataSnapshot(stale)?.[0].missing).toBe(1); + expect(vdata.outdatedVdataSnapshot(broken)).toBeUndefined(); + }); + + it('does not respawn after a failed run until the file changes', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const mod = await vpk('pak01_dir.vpk', [HEROES]); + h.run.mockRejectedValue(new Error("unrecognized subcommand 'vdata-check'")); + await vdata.checkOutdatedVdata(dir, [mod]); + await vdata.checkOutdatedVdata(dir, [mod]); + expect(h.run).toHaveBeenCalledTimes(1); + + await fs.writeFile(mod, 'updated vpk'); + await vdata.checkOutdatedVdata(dir, [mod]); + expect(h.run).toHaveBeenCalledTimes(2); + }); + + it('drops a warning as soon as the mod file changes', async () => { + const mod = await vpk('pak01_dir.vpk', [HEROES]); + respond({ [mod]: [report(['hero_ratking'])] }); + await vdata.checkOutdatedVdata(dir, [mod]); + await fs.writeFile(mod, 'rebuilt vpk'); + expect(vdata.outdatedVdataSnapshot(mod)).toBeUndefined(); + }); + + it('keeps the warning through a rename without respawning', async () => { + const from = await vpk('pak01_dir.vpk', [HEROES]); + respond({ [from]: [report(['hero_ratking'])] }); + await vdata.checkOutdatedVdata(dir, [from]); + + const to = join(dir, 'addons', 'pak05_dir.vpk'); + await fs.rename(from, to); + vdata.moveVdataSnapshot(from, to); + h.trees.set(to, [HEROES]); + expect(vdata.outdatedVdataSnapshot(to)?.[0].missing).toBe(1); + + await vdata.checkOutdatedVdata(dir, [to]); + expect(h.run).toHaveBeenCalledTimes(1); + expect(h.send).toHaveBeenCalledTimes(1); + }); + + it('rechecks after a game update and clears a warning that no longer applies', async () => { + const mod = await vpk('pak01_dir.vpk', [HEROES]); + respond({ [mod]: [report(['hero_ratking'])] }); + await vdata.checkOutdatedVdata(dir, [mod]); + expect(h.send).toHaveBeenCalledTimes(1); + + await fs.writeFile(join(dir, 'pak01_dir.vpk'), 'game after update'); + respond({}); + await vdata.checkOutdatedVdata(dir, [mod]); + expect(h.run).toHaveBeenCalledTimes(2); + expect(vdata.outdatedVdataSnapshot(mod)).toBeUndefined(); + expect(h.send).toHaveBeenCalledTimes(2); + }); + + it('splits large libraries across spawns', async () => { + const mods = await Promise.all( + Array.from({ length: 60 }, (_, i) => vpk(`pak${String(i).padStart(2, '0')}_dir.vpk`, [HEROES])) + ); + respond({}); + await vdata.checkOutdatedVdata(dir, mods); + expect(h.run).toHaveBeenCalledTimes(2); + expect(h.run.mock.calls.map(([args]) => (args as string[]).length - 4)).toEqual([50, 10]); + }); +}); diff --git a/electron/main/services/vdataCheck.ts b/electron/main/services/vdataCheck.ts index b40a2c1c..81bc7872 100644 --- a/electron/main/services/vdataCheck.ts +++ b/electron/main/services/vdataCheck.ts @@ -10,14 +10,19 @@ import { parseVpkDirectoriesAsync } from './vpk'; * Flags mods that ship a stale copy of a game `.vdata_c` (`scripts/heroes.vdata_c` * and friends). The shipped copy replaces the whole file in game, so anything * Valve added after the mod was built goes missing. Results are keyed on the - * mod's size and mtime and dropped wholesale when pak01 changes (a game update). + * mod's path, size and mtime, follow the file through renames, and are dropped + * wholesale when pak01 changes (a game update). */ const SAMPLE_SIZE = 8; +// Keeps one spawn's argv far below the ~32K character Windows command line cap. +const BATCH_SIZE = 50; -interface Snapshot { size: number; mtimeMs: number; outdated: OutdatedVdata[] } +interface FileStat { size: number; mtimeMs: number } +interface Snapshot extends FileStat { outdated: OutdatedVdata[] } interface CliResult { - reports?: Array<{ entry: string; status: string; missing: string[]; extra: string[] }>; + mod: string; + reports?: Array<{ entry: string; missing: string[] }>; } const snapshots = new Map(); @@ -31,25 +36,40 @@ export function outdatedVdataSnapshot(path: string): OutdatedVdata[] | undefined return stat?.size === entry.size && stat.mtimeMs === entry.mtimeMs ? entry.outdated : undefined; } +export function moveVdataSnapshot(from: string, to: string): void { + const value = snapshots.get(from); + snapshots.delete(from); + if (value) snapshots.set(to, value); + else snapshots.delete(to); +} + +export function forgetVdataSnapshot(path: string): void { snapshots.delete(path); } + /** Checks mods with no current result in the background. Sends - * `mod-vdata-checked` when a new warning turns up. */ -export function checkOutdatedVdata(deadlockPath: string, modPaths: string[]): void { + * `mod-vdata-checked` when that changes what any of their cards show. */ +export function checkOutdatedVdata(deadlockPath: string, modPaths: string[]): Promise { queue = queue .then(() => check(deadlockPath, modPaths)) .catch((err) => console.warn('[vdata-check] Check failed:', err)); + return queue; +} + +function shown(modPaths: string[]): string { + return JSON.stringify(modPaths.map((path) => outdatedVdataSnapshot(path) ?? null)); } async function check(deadlockPath: string, modPaths: string[]): Promise { const base = join(getCitadelPath(deadlockPath), 'pak01_dir.vpk'); const baseStat = statSync(base, { throwIfNoEntry: false }); if (!baseStat) return; + const before = shown(modPaths); const key = `${base}:${baseStat.size}:${baseStat.mtimeMs}`; if (key !== baseKey) { snapshots.clear(); baseKey = key; } - const pending = new Map(); + const pending = new Map(); for (const path of modPaths) { const stat = statSync(path, { throwIfNoEntry: false }); if (!stat) continue; @@ -65,35 +85,32 @@ async function check(deadlockPath: string, modPaths: string[]): Promise { if (trees.get(path)?.some((entry) => entry.endsWith('.vdata_c'))) candidates.push(path); else snapshots.set(path, { ...stat, outdated: [] }); } - if (candidates.length === 0) return; + for (let i = 0; i < candidates.length; i += BATCH_SIZE) { + await checkBatch(base, candidates.slice(i, i + BATCH_SIZE), pending); + } - let results: CliResult[]; + if (shown(modPaths) !== before) { + for (const win of BrowserWindow.getAllWindows()) win.webContents.send('mod-vdata-checked'); + } +} + +async function checkBatch(base: string, paths: string[], stats: Map): Promise { + let results: CliResult[] = []; try { - results = JSON.parse( - await runVpkmergeStdout(['vdata-check', '--base', base, '--json', ...candidates], 60000) - ); + results = JSON.parse(await runVpkmergeStdout(['vdata-check', '--base', base, '--json', ...paths], 60000)); } catch (err) { // Includes bundled binaries that predate the subcommand. Marked as checked // so a failure costs one spawn per file version, not one per scan. console.warn('[vdata-check] vpkmerge failed:', err); - for (const path of candidates) snapshots.set(path, { ...pending.get(path)!, outdated: [] }); - return; } - - let flagged = false; - candidates.forEach((path, i) => { - const outdated = (results[i]?.reports ?? []) - .filter((r) => r.status === 'outdated') - .map((r) => ({ - entry: r.entry, - missing: r.missing.length, - extra: r.extra.length, - sample: r.missing.slice(0, SAMPLE_SIZE), - })); - snapshots.set(path, { ...pending.get(path)!, outdated }); - flagged ||= outdated.length > 0; - }); - if (flagged) { - for (const win of BrowserWindow.getAllWindows()) win.webContents.send('mod-vdata-checked'); + // A mod that failed to open comes back with an `error` and no reports. + // Only fields the mod deletes are flagged: fields it has that the game + // dropped are left alone. + const byMod = new Map(results.map((result) => [result.mod, result])); + for (const path of paths) { + const outdated = (byMod.get(path)?.reports ?? []) + .filter((r) => r.missing.length > 0) + .map((r) => ({ entry: r.entry, missing: r.missing.length, sample: r.missing.slice(0, SAMPLE_SIZE) })); + snapshots.set(path, { ...stats.get(path)!, outdated }); } } diff --git a/src/components/installed/ModCard.tsx b/src/components/installed/ModCard.tsx index 94e8e851..ff99c3ba 100644 --- a/src/components/installed/ModCard.tsx +++ b/src/components/installed/ModCard.tsx @@ -28,6 +28,8 @@ interface ModCardProps { safety?: Mod['safety']; safetyTarget?: Pick; outdatedVdata?: Mod['outdatedVdata']; + /** Group cards: whether the flagged variant is the enabled one. Defaults to `enabled`. */ + outdatedVdataEnabled?: boolean; id: string; name: string; fileName: string; @@ -524,18 +526,14 @@ function EditableModTitle({ ); } -function OutdatedVdataTag({ outdated, variant }: { outdated?: Mod['outdatedVdata']; variant: 'overlay' | 'inline' }) { +function OutdatedVdataTag({ outdated, enabled, variant }: { outdated?: Mod['outdatedVdata']; enabled: boolean; variant: 'overlay' | 'inline' }) { const { t } = useTranslation(); if (!outdated?.length) return null; const title = outdated - .map((v) => - v.missing > 0 - ? t('installed.card.outdatedDataTitle', { count: v.missing, file: v.entry, sample: v.sample.join(', ') }) - : t('installed.card.outdatedDataExtraTitle', { file: v.entry }) - ) + .map((v) => t('installed.card.outdatedDataTitle', { count: v.missing, file: v.entry, sample: v.sample.join(', ') })) .join('\n\n'); return ( - + {t('installed.card.outdatedData')} ); @@ -648,7 +646,7 @@ function ModListRowContent({ />
- +
{!mod.enabled && mod.priorityMod && ( @@ -1410,7 +1408,7 @@ export function ModCard({ {t('installed.card.conflict')} )} - + {mod.isUnknown && ( void; +} + +/** Enabled mods whose shipped `.vdata_c` deletes game fields, with a way to turn them off. */ +export function OutdatedVdataModal({ mods, open, onClose }: OutdatedVdataModalProps) { + const { t } = useTranslation(); + const toggleMod = useAppStore((s) => s.toggleMod); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (open && mods.length === 0) onClose(); + }, [open, mods.length, onClose]); + + const disable = async (ids: string[]) => { + setBusy(true); + for (const id of ids) await toggleMod(id); + setBusy(false); + }; + + return ( + 0} + title={t('installed.outdatedData.title', { count: mods.length })} + message={t('installed.outdatedData.message')} + confirmLabel={t('installed.outdatedData.disableAll', { count: mods.length })} + cancelLabel={t('common.actions.close')} + busy={busy} + onConfirm={() => void disable(mods.map((m) => m.id))} + onCancel={onClose} + > +
    + {mods.map((mod) => ( +
  • +
    +

    {mod.name}

    + {mod.outdatedVdata?.map((v) => ( +
    +

    {t('installed.outdatedData.removes', { count: v.missing, file: v.entry })}

    +

    + {v.sample.slice(0, SAMPLE_PATHS).join(', ')} +

    +
    + ))} +
    + +
  • + ))} +
+
+ ); +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 17dc20da..da2786ae 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1333,6 +1333,21 @@ "conflictCount_one": "{{count}} conflict", "conflictCount_other": "{{count}} conflicts" }, + "outdatedData": { + "button_one": "{{count}} outdated mod", + "button_other": "{{count}} outdated mods", + "buttonHint": "Enabled mods that ship an old copy of a game data file and remove what Valve added since", + "title_one": "{{count}} enabled mod ships outdated game data", + "title_other": "{{count}} enabled mods ship outdated game data", + "message": "Each of these replaces a whole game data file with an old copy, so anything Valve added after the mod was built is gone in game: new heroes, abilities, stats. Disable them until the author updates.", + "removes_one": "Removes {{count}} game field from {{file}}", + "removes_other": "Removes {{count}} game fields from {{file}}", + "disable": "Disable", + "disableAll_one": "Disable it", + "disableAll_other": "Disable all {{count}}", + "enabledToast_one": "{{name}} ships an old {{file}}. While it is enabled, {{count}} game field Valve added since is removed in game.", + "enabledToast_other": "{{name}} ships an old {{file}}. While it is enabled, {{count}} game fields Valve added since are removed in game." + }, "unknown": { "fixHiddenHint": "Fix unknown is hidden. Right-click to bring it back.", "fixButtonHint": "Find GameBanana matches or add custom metadata for unknown local mods. Right-click to hide this button.", @@ -1536,7 +1551,6 @@ "outdatedData": "Outdated data", "outdatedDataTitle_one": "Ships an old copy of {{file}}. It replaces the game's copy, so {{count}} field added to Deadlock since the mod was built is removed in game: {{sample}}", "outdatedDataTitle_other": "Ships an old copy of {{file}}. It replaces the game's copy, so {{count}} fields added to Deadlock since the mod was built are removed in game, starting with: {{sample}}", - "outdatedDataExtraTitle": "Ships an old copy of {{file}} with fields the game no longer has. It replaces the game's copy and may break things in game.", "unknown": "Unknown", "unknownTitle": "This local mod has no saved GameBanana or custom metadata", "updateAvailableTitle": "A newer version is available on GameBanana", diff --git a/src/locales/manifest.json b/src/locales/manifest.json index d2db9b30..e1e3026f 100644 --- a/src/locales/manifest.json +++ b/src/locales/manifest.json @@ -1,6 +1,6 @@ { "sourceLanguage": "en", - "totalKeys": 2474, + "totalKeys": 2486, "languages": [ { "code": "bg", @@ -11,14 +11,14 @@ { "code": "en", "name": "English", - "translatedKeys": 2474, + "translatedKeys": 2486, "pct": 100 }, { "code": "fr", "name": "français", "translatedKeys": 2116, - "pct": 86 + "pct": 85 }, { "code": "ru", diff --git a/src/pages/Installed.tsx b/src/pages/Installed.tsx index ea343e69..d8a9213c 100644 --- a/src/pages/Installed.tsx +++ b/src/pages/Installed.tsx @@ -50,6 +50,7 @@ import { GripVertical, ClipboardList, Fingerprint, + FileWarning, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { showToast } from '../stores/toastStore'; @@ -134,6 +135,7 @@ import { HeroTagLabel } from '../components/installed/chips'; import { ModCard } from '../components/installed/ModCard'; import { EMPTY_LIST_IDS } from '../components/installed/emptyIds'; import { EditLocalModModal } from '../components/installed/EditLocalModModal'; +import { OutdatedVdataModal } from '../components/installed/OutdatedVdataModal'; import { DeleteModsModal, type DeleteModsTarget } from '../components/installed/DeleteModsModal'; import { MakeCustomModModal } from '../components/installed/MakeCustomModModal'; import type { FoundUnknownMatch } from '../components/installed/unknown/foundMatch'; @@ -433,12 +435,14 @@ const InstalledEntryCard = memo(function InstalledEntryCard({ const safetyTarget = entry.variants.find(v => v.safety?.report.verdict === 'blocked' || v.safety?.report.verdict === 'incomplete') ?? entry.variants.find(v => v.safety?.report.verdict === 'requires-trust' && !v.safety.trusted) ?? entry.variants.find(v => v.safety?.report.verdict === 'requires-trust'); + const outdatedVariant = entry.enabledVariants.find(v => v.outdatedVdata) ?? entry.variants.find(v => v.outdatedVdata); return ( v.outdatedVdata)?.outdatedVdata, + outdatedVdata: outdatedVariant?.outdatedVdata, + outdatedVdataEnabled: !!outdatedVariant?.enabled, // Group's overall enable state is "one or more files enabled", not // the primary's individual flag (matches sort + section choice). enabled: entry.enabledVariants.length > 0, @@ -1214,6 +1218,7 @@ export default function Installed() { // { done, total } so the button can render "Updating 2/5…" and stay disabled // for the duration of the run. const [updateAllConfirmOpen, setUpdateAllConfirmOpen] = useState(false); + const [outdatedVdataOpen, setOutdatedVdataOpen] = useState(false); const [updateAllProgress, setUpdateAllProgress] = useState<{ done: number; total: number } | null>(null); const [updateAllError, setUpdateAllError] = useState(null); // Mods whose file the author deleted with no confident successor, found @@ -3547,6 +3552,7 @@ export default function Installed() { const unknownMods = mods .filter((mod) => mod.isUnknown) .sort((a, b) => a.priority - b.priority); + const outdatedVdataMods = mods.filter((mod) => mod.enabled && mod.outdatedVdata?.length); const unknownFilterCacheById: Record = {}; for (const mod of unknownMods) { const cached = getUnknownCache(mod); @@ -4049,9 +4055,20 @@ export default function Installed() { // action bar's right cluster (next to Fix Order). The right cluster wraps when // cramped, so there's no need to relocate them to a section header. const hasStatusButtons = - conflictCount > 0 || updatesAvailable.size > 0 || !!updateAllProgress || unknownMods.length > 0; + outdatedVdataMods.length > 0 || conflictCount > 0 || updatesAvailable.size > 0 || !!updateAllProgress || unknownMods.length > 0; const statusButtons = hasStatusButtons ? (
+ {outdatedVdataMods.length > 0 && ( + + )} {conflictCount > 0 && (