Skip to content
Closed
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
15 changes: 14 additions & 1 deletion electron/main/ipc/cursorPacks.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { ipcMain, app } from 'electron';
import { promises as fs } from 'fs';
import { basename, dirname, extname, join } from 'path';
import type { CursorPacksState, CursorPreview } from '../../../src/types/electron';
import type { CursorImageFile, CursorPacksState, CursorPreview } from '../../../src/types/electron';
import {
createCursorPack,
deleteCursorPack,
exportCursorZip,
getCursorPacks,
getCursorPreview,
installCursorArchive,
Expand Down Expand Up @@ -56,3 +58,14 @@ ipcMain.handle('cursors:import-files', async (_, paths: string[]): Promise<Curso
}
return setActiveCursorPack(deadlockPath, installed[0].id);
});

ipcMain.handle('cursors:create', async (_, name: string, files: CursorImageFile[]): Promise<CursorPacksState> => {
const deadlockPath = requireDeadlockPath();
const [pack] = await createCursorPack(deadlockPath, name, files);
if (!pack) throw new Error('None of the cursor images could be used.');
return setActiveCursorPack(deadlockPath, pack.id);
});

ipcMain.handle('cursors:export', (_, destPath: string, files: CursorImageFile[]): Promise<void> =>
exportCursorZip(getActiveDeadlockPath(), destPath, files)
);
48 changes: 47 additions & 1 deletion electron/main/services/cursorFiles.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { bmpWithExplicitAlpha, groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles';
import {
bmpWithExplicitAlpha,
formatCursorRes,
groupCursorFiles,
isCursorFileName,
isUsableCursorFile,
parseCursorRes,
} from './cursorFiles';

describe('isCursorFileName', () => {
it('accepts the stock cursor set, size variants and cursor.res', () => {
Expand All @@ -24,6 +31,45 @@ describe('isUsableCursorFile', () => {
});
});

describe('cursor.res', () => {
const stock = `// This file is only needed when using SDL, since it opens the cursors
// as BMP files instead of loading .ani files.
"resource/cursor/cursor.res"
{
cursor_default
{
"hotx" "0"
"hoty" "0"
}

// Aim with the tip of the thumb.
cursor_commend
{
"hotx" "22"
"hoty" "2"
}

}
`;

it('reads the stock file', () => {
expect(parseCursorRes(stock)).toEqual(
new Map([
['cursor_default', { x: 0, y: 0 }],
['cursor_commend', { x: 22, y: 2 }],
])
);
});

it('writes a file that reads back the same', () => {
const hotspots = new Map([
['cursor', { x: 9, y: 4 }],
['cursor_commend', { x: 22, y: 2 }],
]);
expect(parseCursorRes(formatCursorRes(hotspots))).toEqual(hotspots);
});
});

function rgbBmp(pixels: number[]): Buffer {
const bmp = Buffer.alloc(54 + pixels.length);
bmp.write('BM', 0, 'latin1');
Expand Down
25 changes: 25 additions & 0 deletions electron/main/services/cursorFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* optional cursor.res holding the hotspots.
*/

import type { CursorHotspot } from '../../../src/types/electron';

const CURSOR_IMAGE_RE = /^cursor(?:_[a-z0-9]+)*\.bmp$/;

export const MAX_CURSOR_FILE_BYTES = 4 * 1024 * 1024;
Expand All @@ -21,6 +23,29 @@ export function isUsableCursorFile(fileName: string, bytes: Uint8Array): boolean
return bytes[0] === 0x42 && bytes[1] === 0x4d;
}

/**
* The hotspots in a cursor.res, keyed by cursor name. The game looks a BMP up
* by its file stem (cursor_ping.bmp reads `cursor_ping`) and uses 0, 0 for a
* cursor the file does not list.
*/
export function parseCursorRes(text: string): Map<string, CursorHotspot> {
const hotspots = new Map<string, CursorHotspot>();
const body = text.replace(/\/\/[^\n]*/g, '');
for (const [, name, entry] of body.matchAll(/"?([\w.-]+)"?\s*\{([^{}]*)\}/g)) {
const x = /"hotx"\s+"(-?\d+)"/i.exec(entry);
const y = /"hoty"\s+"(-?\d+)"/i.exec(entry);
hotspots.set(name.toLowerCase(), { x: x ? Number(x[1]) : 0, y: y ? Number(y[1]) : 0 });
}
return hotspots;
}

export function formatCursorRes(hotspots: ReadonlyMap<string, CursorHotspot>): string {
const entries = [...hotspots].map(
([name, { x, y }]) => `\t${name}\n\t{\n\t\t"hotx"\t\t"${x}"\n\t\t"hoty"\t\t"${y}"\n\t}\n`
);
return `"resource/cursor/cursor.res"\n{\n${entries.join('\n')}}\n`;
}

const FILE_HEADER = 14;
const V5_HEADER = 124;

Expand Down
56 changes: 56 additions & 0 deletions electron/main/services/cursorPacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ vi.mock('../utils/paths', () => ({
}));

import {
createCursorPack,
deleteCursorPack,
exportCursorZip,
gameCursorsDir,
getCursorPacks,
getCursorPreview,
installCursorArchive,
reconcileCursorPack,
setActiveCursorPack,
} from './cursorPacks';
import { parseCursorRes } from './cursorFiles';

let root: string;
let deadlockPath: string;
Expand Down Expand Up @@ -166,6 +169,59 @@ describe('cursor packs', () => {
expect(await deleteCursorPack(deadlockPath, pack.id)).toEqual({ packs: [], activeId: null });
});

const built = (fileName: string, content: string, hotspot = { x: 0, y: 0 }) => ({
fileName,
bytes: new Uint8Array(Buffer.from(content)),
hotspot,
});

it('creates a pack from built images, keeping only valid cursor files', async () => {
const [pack] = await createCursorPack(deadlockPath, 'Mine', [
built('Cursor.bmp', 'BM-mine'),
built('cursor_ping.bmp', 'not a bitmap'),
built('../cursor_shop.bmp', 'BM-escape'),
built('cursor.res', '"evil" {}'),
]);
expect(pack.name).toBe('Mine');
expect(pack.files).toEqual(['cursor.bmp', 'cursor.res']);

await setActiveCursorPack(deadlockPath, pack.id);
expect(read('cursor.bmp')).toBe('BM-mine');
expect(read('cursor_ping.bmp')).toBe('BM-stock-ping');
});

it('writes the built hotspots into the stock cursor.res', async () => {
writeFileSync(
join(cursors, 'cursor.res'),
'"resource/cursor/cursor.res"\n{\n\tcursor_commend\n\t{\n\t\t"hotx" "22"\n\t\t"hoty" "2"\n\t}\n}\n'
);
const [pack] = await createCursorPack(deadlockPath, 'Mine', [built('cursor.bmp', 'BM-mine', { x: 9, y: 4 })]);
await setActiveCursorPack(deadlockPath, pack.id);

expect(parseCursorRes(read('cursor.res'))).toEqual(
new Map([
['cursor_commend', { x: 22, y: 2 }],
['cursor', { x: 9, y: 4 }],
])
);
await setActiveCursorPack(deadlockPath, null);
expect(parseCursorRes(read('cursor.res')).has('cursor')).toBe(false);
});

it('exports built images as a zip that imports back as the same pack', async () => {
const dest = join(root, 'Mine.zip');
await exportCursorZip(deadlockPath, dest, [built('cursor.bmp', 'BM-mine'), built('notes.txt', 'BM-nope')]);
expect(new AdmZip(dest).getEntries().map((e) => e.entryName)).toEqual(['cursor.bmp', 'cursor.res']);

const [pack] = await installCursorArchive(dest, mkdtempSync(join(root, 'work-')), { name: 'Mine' });
expect(pack.files).toEqual(['cursor.bmp', 'cursor.res']);
});

it('creates nothing when no image is usable', async () => {
expect(await createCursorPack(deadlockPath, 'Mine', [built('cursor.bmp', '')])).toEqual([]);
expect((await getCursorPacks()).packs).toEqual([]);
});

it('installs sibling folders as separate variants', async () => {
const packs = await install({ 'Large/cursor.bmp': 'BM-l', 'Small/cursor.bmp': 'BM-s' });
expect(packs.map((p) => p.name)).toEqual(['Kitty Paw (Large)', 'Kitty Paw (Small)']);
Expand Down
114 changes: 91 additions & 23 deletions electron/main/services/cursorPacks.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { createHash, randomUUID } from 'crypto';
import { existsSync, promises as fs } from 'fs';
import { basename, join } from 'path';
import type { CursorPack, CursorPacksState, CursorPreview } from '../../../src/types/electron';
import AdmZip from 'adm-zip';
import type { CursorImageFile, CursorPack, CursorPacksState, CursorPreview } from '../../../src/types/electron';
import { getUserDataPath } from '../utils/paths';
import { bmpWithExplicitAlpha, groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles';
import {
bmpWithExplicitAlpha,
formatCursorRes,
groupCursorFiles,
isCursorFileName,
isUsableCursorFile,
parseCursorRes,
} from './cursorFiles';
import { getCitadelPath } from './deadlock';
import { extractArchive, type ExtractedVpk } from './extract';

Expand Down Expand Up @@ -178,16 +186,19 @@ async function readPreviewDir(dir: string, names: readonly string[]): Promise<Cu
return preview;
}

// With no pack applied the game folder holds the stock set; otherwise the
// backup taken before the first apply does.
const stockSourceDir = (state: StoredState, deadlockPath: string | null) =>
state.activeId === null && deadlockPath ? gameCursorsDir(deadlockPath) : stockDir();

/** Data URLs of a pack's cursor images, or of the stock set when `id` is null. */
export async function getCursorPreview(deadlockPath: string | null, id: string | null): Promise<CursorPreview> {
const state = await loadState();
if (id !== null) {
const pack = state.packs.find((p) => p.id === id);
return pack ? readPreviewDir(packDir(id), pack.files) : {};
}
// With no pack applied the game folder holds the stock set; otherwise the
// backup taken before the first apply does.
const dir = state.activeId === null && deadlockPath ? gameCursorsDir(deadlockPath) : stockDir();
const dir = stockSourceDir(state, deadlockPath);
if (!existsSync(dir)) return {};
const names = (await fs.readdir(dir)).map((n) => n.toLowerCase()).filter(isCursorFileName).sort();
return readPreviewDir(dir, names);
Expand All @@ -199,34 +210,31 @@ export interface CursorPackSource {
gameBananaFileId?: number;
}

async function installGroups(
extracted: readonly ExtractedVpk[],
source: CursorPackSource
): Promise<CursorPack[]> {
const groups = groupCursorFiles(extracted);
if (groups.length === 0) return [];
interface CursorSet {
variant?: string;
/** Lowercased destination name -> usable file bytes. */
files: Map<string, Buffer>;
}

async function installSets(sets: readonly CursorSet[], source: CursorPackSource): Promise<CursorPack[]> {
// A lone cursor.res changes nothing visible.
const usable = sets.filter(({ files }) => [...files.keys()].some((name) => name.endsWith('.bmp')));
if (usable.length === 0) return [];
return exclusive(async () => {
const state = await loadState();
const installed: CursorPack[] = [];
for (const group of groups) {
const files = new Map<string, Buffer>();
for (const [name, path] of group.files) {
const bytes = await fs.readFile(path);
if (isUsableCursorFile(name, bytes)) files.set(name, bytes);
}
if (![...files.keys()].some((name) => name.endsWith('.bmp'))) continue;

const variant = group.variant?.replace(/[_-]+/g, ' ').trim();
const name = variant ? `${source.name} (${variant})` : source.name;
for (const { variant, files } of usable) {
const label = variant?.replace(/[_-]+/g, ' ').trim();
const name = label ? `${source.name} (${label})` : source.name;
// Reinstalling the same GameBanana file replaces its pack in place.
const existing = source.gameBananaFileId === undefined
? undefined
: state.packs.find((p) =>
p.gameBananaFileId === source.gameBananaFileId && p.variant === group.variant);
p.gameBananaFileId === source.gameBananaFileId && p.variant === variant);
const pack: CursorPack = {
id: existing?.id ?? randomUUID(),
name,
variant: group.variant,
variant,
files: [...files.keys()].sort(),
installedAt: new Date().toISOString(),
gameBananaId: source.gameBananaId,
Expand All @@ -243,6 +251,66 @@ async function installGroups(
});
}

async function installGroups(
extracted: readonly ExtractedVpk[],
source: CursorPackSource
): Promise<CursorPack[]> {
const sets: CursorSet[] = [];
for (const group of groupCursorFiles(extracted)) {
const files = new Map<string, Buffer>();
for (const [name, path] of group.files) {
const bytes = await fs.readFile(path);
if (isUsableCursorFile(name, bytes)) files.set(name, bytes);
}
sets.push({ variant: group.variant, files });
}
return installSets(sets, source);
}

/**
* BMPs built in the renderer plus a cursor.res with their hotspots. The game
* reads every cursor's hotspot from that one file, so it starts from the stock
* copy: cursors left alone keep theirs (the commend one is not at 0, 0).
*/
async function builtCursorFiles(
deadlockPath: string | null,
images: readonly CursorImageFile[]
): Promise<Map<string, Buffer>> {
const files = new Map<string, Buffer>();
const stockRes = join(stockSourceDir(await loadState(), deadlockPath), 'cursor.res');
const hotspots = parseCursorRes(existsSync(stockRes) ? await fs.readFile(stockRes, 'utf-8') : '');
for (const { fileName, bytes, hotspot } of images) {
const lower = fileName.toLowerCase();
if (!lower.endsWith('.bmp') || !isCursorFileName(lower) || !isUsableCursorFile(lower, bytes)) continue;
files.set(lower, Buffer.from(bytes));
hotspots.set(lower.slice(0, -'.bmp'.length), hotspot);
}
if (files.size > 0) files.set('cursor.res', Buffer.from(formatCursorRes(hotspots)));
return files;
}

/** Install BMPs built in the renderer (the Create cursor dialog) as one pack. */
export async function createCursorPack(
deadlockPath: string | null,
name: string,
images: readonly CursorImageFile[]
): Promise<CursorPack[]> {
return installSets([{ files: await builtCursorFiles(deadlockPath, images) }], { name });
}

/** Zip BMPs built in the renderer so they can be shared and imported elsewhere. */
export async function exportCursorZip(
deadlockPath: string | null,
destPath: string,
images: readonly CursorImageFile[]
): Promise<void> {
const files = await builtCursorFiles(deadlockPath, images);
if (files.size === 0) throw new Error('None of the cursor images could be used.');
const zip = new AdmZip();
for (const [name, bytes] of files) zip.addFile(name, bytes);
await fs.writeFile(destPath, zip.toBuffer());
}

/**
* Install the cursor sets inside an archive that shipped no VPK. Resolves to
* the installed packs, empty when the archive holds no cursor images either.
Expand Down
4 changes: 4 additions & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import type {
UpdateStatus,
LockerImageVariant,
CropRect,
CursorImageFile,
} from '../../src/types/electron';
import type { AppearanceSurface } from '../../src/types/mod';
import type { DeadworksConnectProgress } from '../../src/types/deadworks';
Expand Down Expand Up @@ -652,6 +653,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
deleteCursorPack: (id: string) => ipcRenderer.invoke('cursors:delete', id),
getCursorPreview: (id: string | null) => ipcRenderer.invoke('cursors:preview', id),
importCursorPack: (paths: string[]) => ipcRenderer.invoke('cursors:import-files', paths),
createCursorPack: (name: string, files: CursorImageFile[]) => ipcRenderer.invoke('cursors:create', name, files),
exportCursorImages: (destPath: string, files: CursorImageFile[]) =>
ipcRenderer.invoke('cursors:export', destPath, files),

// Updater
updater: {
Expand Down
Loading
Loading