Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion electron/main/ipc/mods.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
}

/**
Expand Down Expand Up @@ -368,6 +370,7 @@ ipcMain.handle('get-mods', async (): Promise<Mod[]> => {
await parseVpkDirectoriesAsync(warmPaths);
}
const enriched = visible.map(enrichMod);
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
Expand Down
3 changes: 3 additions & 0 deletions electron/main/services/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -294,6 +295,7 @@ export async function stashEnabledMods(deadlockPath: string): Promise<VanillaSta
await fs.mkdir(dirname(to), { recursive: true });
await fs.rename(from, to);
moveSafetySnapshot(from, to);
moveVdataSnapshot(from, to);
}

stash.status = 'active';
Expand Down Expand Up @@ -383,6 +385,7 @@ export async function restoreFromStash(
try {
await fs.rename(stashSlotPath(disabledPath, folder, name), join(targetDir, name));
moveSafetySnapshot(stashSlotPath(disabledPath, folder, name), join(targetDir, name));
moveVdataSnapshot(stashSlotPath(disabledPath, folder, name), join(targetDir, name));
ok = true;
break;
} catch (err) {
Expand Down
3 changes: 3 additions & 0 deletions electron/main/services/mods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { resolveVpkIdentity, readEmbeddedAddonInfo, carryForwardOriginalIdentity
import { findChunkSiblingNames } from './vpk';
import { loadSettings } from './settings';
import { assertVpkSafety, moveSafetySnapshot, forgetSafetySnapshot } from './modSafety';
import { forgetVdataSnapshot, moveVdataSnapshot } from './vdataCheck';
import { isStaleModTemp, modTempPath } from './modTemps';
import {
assertCanMoveLoadedGameMod,
Expand Down Expand Up @@ -109,6 +110,7 @@ async function renameVpkSet(from: string, to: string): Promise<void> {
throw err;
}
moveSafetySnapshot(from, to);
moveVdataSnapshot(from, to);
}

/**
Expand Down Expand Up @@ -142,6 +144,7 @@ async function deleteVpkSet(path: string): Promise<void> {
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));
}

Expand Down
158 changes: 158 additions & 0 deletions electron/main/services/vdataCheck.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>() }));
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<string> {
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<string, ReturnType<typeof report>[]>) {
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]);
});
});
116 changes: 116 additions & 0 deletions electron/main/services/vdataCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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 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 FileStat { size: number; mtimeMs: number }
interface Snapshot extends FileStat { outdated: OutdatedVdata[] }
interface CliResult {
mod: string;
reports?: Array<{ entry: string; missing: string[] }>;
}

const snapshots = new Map<string, Snapshot>();
let baseKey = '';
let queue: Promise<void> = 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;
}

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 that changes what any of their cards show. */
export function checkOutdatedVdata(deadlockPath: string, modPaths: string[]): Promise<void> {
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<void> {
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<string, FileStat>();
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: [] });
}
for (let i = 0; i < candidates.length; i += BATCH_SIZE) {
await checkBatch(base, candidates.slice(i, i + BATCH_SIZE), pending);
}

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<string, FileStat>): Promise<void> {
let results: CliResult[] = [];
try {
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);
}
// 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 });
}
}
5 changes: 5 additions & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
8 changes: 4 additions & 4 deletions scripts/fetch-vpkmerge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ import { fileURLToPath } from 'node:url';
import { get as httpsGet } from 'node:https';
import { pipeline } from 'node:stream/promises';

const VPKMERGE_VERSION = 'v0.19.1';
const VPKMERGE_VERSION = 'v0.20.0';

const ASSETS = {
'linux-x64': { name: 'vpkmerge-linux-x86_64', sha256: '22c302e4a9ddf75712b8d65708ac291c9b2016ea828a7f5b4ed96e93b0b24b39' },
'darwin-arm64': { name: 'vpkmerge-macos-aarch64', sha256: '94ac1e4078a2bebe7a5445e91882bbdb97c10fcf02b8073b515f6e9e03ba66f9' },
'win32-x64': { name: 'vpkmerge-windows-x86_64.exe', sha256: '7358f2c1c1bf769fa3192a163d3fece4cfc52c736cdcf244bf802d213cba5de1' },
'linux-x64': { name: 'vpkmerge-linux-x86_64', sha256: '2687f033d55da43c4b7db418404c44688bbe90b19585b2948c78412dff7d3e1d' },
'darwin-arm64': { name: 'vpkmerge-macos-aarch64', sha256: '72d75afdafc30aa80dd3c5a4fcafce9efe0fbbb6185d59d250606a6e276e3e6e' },
'win32-x64': { name: 'vpkmerge-windows-x86_64.exe', sha256: 'b7a6e400f6bab1e876444bf2dbc411b9fc62b05e3885d2385bcb0aed7b5430cc' },
};

const here = dirname(fileURLToPath(import.meta.url));
Expand Down
7 changes: 7 additions & 0 deletions src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading