diff --git a/electron/main/ipc/cursorPacks.ts b/electron/main/ipc/cursorPacks.ts
index e012e85d..36ee9f20 100644
--- a/electron/main/ipc/cursorPacks.ts
+++ b/electron/main/ipc/cursorPacks.ts
@@ -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,
@@ -56,3 +58,14 @@ ipcMain.handle('cursors:import-files', async (_, paths: string[]): Promise => {
+ 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 =>
+ exportCursorZip(getActiveDeadlockPath(), destPath, files)
+);
diff --git a/electron/main/services/cursorFiles.test.ts b/electron/main/services/cursorFiles.test.ts
index 5f784c65..1b10cd5c 100644
--- a/electron/main/services/cursorFiles.test.ts
+++ b/electron/main/services/cursorFiles.test.ts
@@ -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', () => {
@@ -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');
diff --git a/electron/main/services/cursorFiles.ts b/electron/main/services/cursorFiles.ts
index ebf5e858..a040c3ab 100644
--- a/electron/main/services/cursorFiles.ts
+++ b/electron/main/services/cursorFiles.ts
@@ -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;
@@ -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 {
+ const hotspots = new Map();
+ 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 {
+ 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;
diff --git a/electron/main/services/cursorPacks.test.ts b/electron/main/services/cursorPacks.test.ts
index 3ca09b35..72df40f8 100644
--- a/electron/main/services/cursorPacks.test.ts
+++ b/electron/main/services/cursorPacks.test.ts
@@ -12,7 +12,9 @@ vi.mock('../utils/paths', () => ({
}));
import {
+ createCursorPack,
deleteCursorPack,
+ exportCursorZip,
gameCursorsDir,
getCursorPacks,
getCursorPreview,
@@ -20,6 +22,7 @@ import {
reconcileCursorPack,
setActiveCursorPack,
} from './cursorPacks';
+import { parseCursorRes } from './cursorFiles';
let root: string;
let deadlockPath: string;
@@ -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)']);
diff --git a/electron/main/services/cursorPacks.ts b/electron/main/services/cursorPacks.ts
index 9b4834df..dd6bfb3b 100644
--- a/electron/main/services/cursorPacks.ts
+++ b/electron/main/services/cursorPacks.ts
@@ -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';
@@ -178,6 +186,11 @@ async function readPreviewDir(dir: string, names: readonly string[]): Promise
+ 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 {
const state = await loadState();
@@ -185,9 +198,7 @@ export async function getCursorPreview(deadlockPath: string | null, id: string |
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);
@@ -199,34 +210,31 @@ export interface CursorPackSource {
gameBananaFileId?: number;
}
-async function installGroups(
- extracted: readonly ExtractedVpk[],
- source: CursorPackSource
-): Promise {
- const groups = groupCursorFiles(extracted);
- if (groups.length === 0) return [];
+interface CursorSet {
+ variant?: string;
+ /** Lowercased destination name -> usable file bytes. */
+ files: Map;
+}
+
+async function installSets(sets: readonly CursorSet[], source: CursorPackSource): Promise {
+ // 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();
- 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,
@@ -243,6 +251,66 @@ async function installGroups(
});
}
+async function installGroups(
+ extracted: readonly ExtractedVpk[],
+ source: CursorPackSource
+): Promise {
+ const sets: CursorSet[] = [];
+ for (const group of groupCursorFiles(extracted)) {
+ const files = new Map();
+ 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
)}
- {t('locker.crop.instructions')}
+ {cursor ? t('locker.crop.fitInstructions') : t('locker.crop.instructions')}
>
)}
diff --git a/src/components/locker/CursorCreateModal.tsx b/src/components/locker/CursorCreateModal.tsx
new file mode 100644
index 00000000..381c0dae
--- /dev/null
+++ b/src/components/locker/CursorCreateModal.tsx
@@ -0,0 +1,240 @@
+import { useEffect, useId, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Check, Download, Upload, X } from 'lucide-react';
+import { Modal, ModalBody, ModalFooter } from '../common/Modal';
+import { Button, IconButton, ModalHeader } from '../common/ui';
+import { FormField, Input } from '../common/forms';
+import { readImageDataUrl, revealPath, showOpenDialog, showSaveDialog } from '../../lib/api';
+import { encodeCursorBmp } from '../../lib/cursorBmp';
+import { useCursorPackStore } from '../../stores/cursorPackStore';
+import { showToast } from '../../stores/toastStore';
+import type { CursorHotspot, CursorImageFile, CursorPreview } from '../../types/electron';
+import CardCropper from './CardCropper';
+
+interface Slot {
+ file: string;
+ width: number;
+ height: number;
+}
+
+// The stock sizes. The game draws a cursor at whatever size its BMP is, so
+// these only set the scale. A slot left empty keeps the game's own cursor.
+const SLOTS: readonly Slot[] = [
+ { file: 'cursor.bmp', width: 48, height: 58 },
+ { file: 'cursor_ping.bmp', width: 48, height: 58 },
+ { file: 'cursor_shop.bmp', width: 60, height: 60 },
+ { file: 'cursor_commend.bmp', width: 88, height: 88 },
+];
+
+async function pngToBmp(dataUrl: string): Promise {
+ const img = new Image();
+ img.src = dataUrl;
+ await img.decode();
+ const canvas = document.createElement('canvas');
+ canvas.width = img.naturalWidth;
+ canvas.height = img.naturalHeight;
+ const ctx = canvas.getContext('2d')!;
+ ctx.drawImage(img, 0, 0);
+ return encodeCursorBmp(canvas.width, canvas.height, ctx.getImageData(0, 0, canvas.width, canvas.height).data);
+}
+
+/**
+ * Build a cursor pack from the user's own images: one tile per cursor the game
+ * ships, each framed with the cropper at that cursor's size. Creating applies it.
+ */
+export default function CursorCreateModal({ onClose }: { onClose: () => void }) {
+ const { t } = useTranslation();
+ const titleId = useId();
+ const { busy, create } = useCursorPackStore();
+ const [name, setName] = useState('');
+ const [stock, setStock] = useState({});
+ // Framed PNG data URL per cursor file name, at the slot's exact size.
+ const [picks, setPicks] = useState>({});
+ const [cropping, setCropping] = useState<{ slot: Slot; sourceDataUrl: string } | null>(null);
+ const [exporting, setExporting] = useState(false);
+
+ const labels: Record = {
+ 'cursor.bmp': t('locker.cursors.create.pointer'),
+ 'cursor_ping.bmp': t('locker.cursors.create.ping'),
+ 'cursor_shop.bmp': t('locker.cursors.create.shop'),
+ 'cursor_commend.bmp': t('locker.cursors.create.commend'),
+ };
+
+ useEffect(() => {
+ let live = true;
+ window.electronAPI
+ .getCursorPreview(null)
+ .then((preview) => {
+ if (live) setStock(preview);
+ })
+ .catch(() => {});
+ return () => {
+ live = false;
+ };
+ }, []);
+
+ const pickImage = async (slot: Slot) => {
+ try {
+ const path = await showOpenDialog({
+ title: t('locker.cursors.create.chooseImage', { cursor: labels[slot.file] }),
+ filters: [{ name: t('locker.cursors.create.imageFilter'), extensions: ['png', 'webp', 'gif', 'jpg', 'jpeg'] }],
+ });
+ if (!path) return;
+ setCropping({ slot, sourceDataUrl: await readImageDataUrl(path) });
+ } catch (err) {
+ showToast(err instanceof Error ? err.message : String(err), { tone: 'error' });
+ }
+ };
+
+ const clear = (file: string) =>
+ setPicks((prev) => {
+ const next = { ...prev };
+ delete next[file];
+ return next;
+ });
+
+ const hasPicks = Object.keys(picks).length > 0;
+ const packName = name.trim() || t('locker.cursors.create.defaultName');
+ const buildFiles = (): Promise =>
+ Promise.all(
+ Object.entries(picks).map(async ([fileName, { dataUrl, hotspot }]) => ({
+ fileName,
+ bytes: await pngToBmp(dataUrl),
+ hotspot,
+ }))
+ );
+
+ const submit = async () => {
+ try {
+ await create(packName, await buildFiles());
+ showToast(t('locker.cursors.applied', { name: packName }), { tone: 'success' });
+ onClose();
+ } catch (err) {
+ showToast(err instanceof Error ? err.message : String(err), { tone: 'error' });
+ }
+ };
+
+ const exportZip = async () => {
+ try {
+ const destPath = await showSaveDialog({
+ title: t('locker.cursors.create.exportTitle', { name: packName }),
+ defaultPath: `${packName.replace(/[\\/:*?"<>|]+/g, '').trim() || 'cursor'}.zip`,
+ filters: [{ name: t('locker.cursors.create.exportFilter'), extensions: ['zip'] }],
+ });
+ if (!destPath) return;
+ setExporting(true);
+ await window.electronAPI.exportCursorImages(destPath, await buildFiles());
+ showToast(t('locker.cursors.create.exported', { path: destPath }), { tone: 'success' });
+ void revealPath(destPath);
+ } catch (err) {
+ showToast(err instanceof Error ? err.message : String(err), { tone: 'error' });
+ } finally {
+ setExporting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ setName(e.target.value)}
+ />
+
+
+ {SLOTS.map((slot) => {
+ const pick = picks[slot.file];
+ const art = pick?.dataUrl ?? stock[slot.file];
+ return (
+
+
+
+ {pick && (
+
clear(slot.file)}
+ className="absolute right-1.5 top-1.5 bg-bg-secondary"
+ />
+ )}
+
+
{labels[slot.file]}
+
+ {slot.width} x {slot.height}
+
+
+ );
+ })}
+
+ {t('locker.cursors.create.hint')}
+
+
+
+
+
+
+ {cropping && (
+ setCropping(null)}
+ onCrop={(dataUrl, hotspot) => {
+ setPicks((prev) => ({ ...prev, [cropping.slot.file]: { dataUrl, hotspot } }));
+ setCropping(null);
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/locker/CursorPackPane.tsx b/src/components/locker/CursorPackPane.tsx
index 6f72c8d3..957fca38 100644
--- a/src/components/locker/CursorPackPane.tsx
+++ b/src/components/locker/CursorPackPane.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
-import { Check, Search, Trash2, Upload } from 'lucide-react';
+import { Check, ImagePlus, Search, Trash2, Upload } from 'lucide-react';
import { Button, IconButton, Tag } from '../common/ui';
import { ConfirmModal } from '../common/PageComponents';
import { showOpenDialogMulti } from '../../lib/api';
@@ -9,6 +9,7 @@ import { useAppStore } from '../../stores/appStore';
import { useCursorPackStore } from '../../stores/cursorPackStore';
import { showToast } from '../../stores/toastStore';
import type { CursorPack, CursorPreview } from '../../types/electron';
+import CursorCreateModal from './CursorCreateModal';
// Session cache: previews are read off disk once per pack install.
const previewCache = new Map();
@@ -196,10 +197,11 @@ export default function CursorPackPane() {
);
}
-/** Header action for the Cursor tab: pick an archive or loose BMPs and apply them. */
-export function CursorImportButton() {
+/** Header actions for the Cursor tab: build a pack from images, or import an archive or loose BMPs. */
+export function CursorPaneActions() {
const { t } = useTranslation();
const { busy, importPaths } = useCursorPackStore();
+ const [creating, setCreating] = useState(false);
const pick = async () => {
const paths = await showOpenDialogMulti({
@@ -216,8 +218,14 @@ export function CursorImportButton() {
};
return (
-
+
+
+
+ {creating && setCreating(false)} />}
+
);
}
diff --git a/src/lib/cursorBmp.test.ts b/src/lib/cursorBmp.test.ts
new file mode 100644
index 00000000..673b14f4
--- /dev/null
+++ b/src/lib/cursorBmp.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+import { encodeCursorBmp } from './cursorBmp';
+
+describe('encodeCursorBmp', () => {
+ it('writes a 32-bit bottom-up BGRA bitmap', () => {
+ // 2x2: top row red, green; bottom row blue, half-transparent white.
+ const rgba = new Uint8Array([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 128]);
+ const bmp = encodeCursorBmp(2, 2, rgba);
+ const view = new DataView(bmp.buffer);
+
+ expect(String.fromCharCode(bmp[0], bmp[1])).toBe('BM');
+ expect(view.getUint32(2, true)).toBe(54 + 16);
+ expect(view.getUint32(10, true)).toBe(54);
+ expect(view.getInt32(18, true)).toBe(2);
+ expect(view.getInt32(22, true)).toBe(2);
+ expect(view.getUint16(28, true)).toBe(32);
+ expect(view.getUint32(30, true)).toBe(0);
+ expect([...bmp.subarray(54)]).toEqual([
+ 255, 0, 0, 255, 255, 255, 255, 128,
+ 0, 0, 255, 255, 0, 255, 0, 255,
+ ]);
+ });
+});
diff --git a/src/lib/cursorBmp.ts b/src/lib/cursorBmp.ts
new file mode 100644
index 00000000..5a59a8f4
--- /dev/null
+++ b/src/lib/cursorBmp.ts
@@ -0,0 +1,37 @@
+const HEADER_BYTES = 54;
+
+/**
+ * Encode RGBA pixels the way Deadlock's stock cursors are stored: a 40-byte
+ * BITMAPINFOHEADER, 32 bits, BI_RGB, bottom-up BGRA rows. SDL reads the fourth
+ * byte as alpha, so transparency survives.
+ */
+export function encodeCursorBmp(width: number, height: number, rgba: Uint8ClampedArray | Uint8Array): Uint8Array {
+ const pixelBytes = width * height * 4;
+ const out = new Uint8Array(HEADER_BYTES + pixelBytes);
+ const view = new DataView(out.buffer);
+ out[0] = 0x42;
+ out[1] = 0x4d;
+ view.setUint32(2, out.length, true);
+ view.setUint32(10, HEADER_BYTES, true);
+ view.setUint32(14, 40, true);
+ view.setInt32(18, width, true);
+ view.setInt32(22, height, true);
+ view.setUint16(26, 1, true);
+ view.setUint16(28, 32, true);
+ view.setUint32(34, pixelBytes, true);
+ view.setInt32(38, 2835, true);
+ view.setInt32(42, 2835, true);
+
+ for (let y = 0; y < height; y++) {
+ const row = HEADER_BYTES + (height - 1 - y) * width * 4;
+ for (let x = 0; x < width; x++) {
+ const src = (y * width + x) * 4;
+ const dst = row + x * 4;
+ out[dst] = rgba[src + 2];
+ out[dst + 1] = rgba[src + 1];
+ out[dst + 2] = rgba[src];
+ out[dst + 3] = rgba[src + 3];
+ }
+ }
+ return out;
+}
diff --git a/src/lib/cursorHotspot.test.ts b/src/lib/cursorHotspot.test.ts
new file mode 100644
index 00000000..668c6002
--- /dev/null
+++ b/src/lib/cursorHotspot.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+import { detectCursorHotspot } from './cursorHotspot';
+
+function paint(width: number, height: number, inside: (x: number, y: number) => boolean) {
+ const rgba = new Uint8Array(width * height * 4);
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ if (inside(x, y)) rgba[(y * width + x) * 4 + 3] = 255;
+ }
+ }
+ return detectCursorHotspot(width, height, rgba);
+}
+
+const near = (actual: { x: number; y: number }, x: number, y: number, slack = 2) => {
+ expect(Math.abs(actual.x - x), `x ${actual.x} vs ${x}`).toBeLessThanOrEqual(slack);
+ expect(Math.abs(actual.y - y), `y ${actual.y} vs ${y}`).toBeLessThanOrEqual(slack);
+};
+
+describe('detectCursorHotspot', () => {
+ it('finds the tip of an arrow wherever it sits in the frame', () => {
+ // Stock-shaped arrow: tip, straight left side, diagonal right side.
+ const arrow = (ox: number, oy: number) => (x: number, y: number) =>
+ x >= ox && y >= oy && x - ox <= (y - oy) * 0.75 && y - oy <= 40 - (x - ox) * 0.4;
+ near(paint(48, 58, arrow(0, 0)), 0, 0);
+ near(paint(48, 58, arrow(9, 6)), 9, 6);
+ });
+
+ it('finds a fingertip pointing straight up', () => {
+ const hand = (x: number, y: number) =>
+ (x >= 26 && x <= 34 && y >= 4 && y <= 30) || (x >= 12 && x <= 46 && y >= 26 && y <= 58);
+ near(paint(60, 60, hand), 30, 4, 4);
+ });
+
+ it('ignores a tail cut off by the image edge and a ping bubble', () => {
+ // A pointed wand running off the bottom-right corner.
+ const wand = (x: number, y: number) => Math.abs(x - y) <= Math.min(10, (x + y - 8) * 0.6);
+ near(paint(57, 58, wand), 4, 4, 3);
+ const withBubble = (x: number, y: number) => wand(x, y) || (x >= 38 && x <= 55 && y <= 10);
+ near(paint(57, 58, withBubble), 4, 4, 3);
+ });
+
+ it('does not mistake a straight diagonal wand for a reticle', () => {
+ near(paint(48, 48, (x, y) => Math.abs(x - y) <= 3), 0, 0, 3);
+ });
+
+ it('aims with the middle of a crosshair or ring', () => {
+ near(paint(41, 41, (x, y) => Math.abs(x - 20) <= 1 || Math.abs(y - 20) <= 1), 20, 20, 0);
+ const ring = (x: number, y: number) => Math.abs(Math.hypot(x - 30, y - 24) - 16) <= 2;
+ near(paint(64, 64, ring), 30, 24, 1);
+ });
+
+ it('aims with the middle of round art with no corner', () => {
+ const blob = (x: number, y: number) => ((x - 20) / 18) ** 2 + ((y - 30) / 12) ** 2 <= 1 || Math.hypot(x - 34, y - 22) <= 8;
+ const hit = paint(48, 48, blob);
+ expect(hit.x).toBeGreaterThan(10);
+ expect(hit.y).toBeGreaterThan(15);
+ });
+
+ it('falls back to the top-left corner for empty or fully opaque art', () => {
+ expect(paint(32, 32, () => false)).toEqual({ x: 0, y: 0 });
+ expect(paint(32, 32, () => true)).toEqual({ x: 0, y: 0 });
+ });
+});
diff --git a/src/lib/cursorHotspot.ts b/src/lib/cursorHotspot.ts
new file mode 100644
index 00000000..82a9767d
--- /dev/null
+++ b/src/lib/cursorHotspot.ts
@@ -0,0 +1,122 @@
+import type { CursorHotspot } from '../types/electron';
+
+interface Point {
+ x: number;
+ y: number;
+}
+
+const ALPHA_MIN = 128;
+// No hull corner sharper than this means round art (a paw, a blob): aim with its middle.
+const BLUNT_DEG = 130;
+// Penalty for a corner at the far bottom-right against one at the top-left.
+// Pointers aim up-left, and a sharp corner elsewhere is usually a tail cut off
+// by the image edge (a paw's arm) or an accessory (the ping bubble), so this
+// outweighs sharpness: it only decides between corners near the top-left.
+const TOP_LEFT_BIAS_DEG = 180;
+
+const cross = (o: Point, a: Point, b: Point) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
+
+function convexHull(points: Point[]): Point[] {
+ const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
+ const half = (pts: Point[]) => {
+ const out: Point[] = [];
+ for (const p of pts) {
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
+ out.push(p);
+ }
+ out.pop();
+ return out;
+ };
+ return [...half(sorted), ...half(sorted.reverse())];
+}
+
+/** The point `dist` along the hull outline from vertex `i`, walking in `dir`. */
+function alongHull(hull: Point[], i: number, dir: 1 | -1, dist: number): Point {
+ let cur = hull[i];
+ let j = i;
+ let left = dist;
+ for (let step = 0; step < hull.length; step++) {
+ j = (j + dir + hull.length) % hull.length;
+ const next = hull[j];
+ const len = Math.hypot(next.x - cur.x, next.y - cur.y);
+ if (len >= left) return { x: cur.x + ((next.x - cur.x) * left) / len, y: cur.y + ((next.y - cur.y) * left) / len };
+ left -= len;
+ cur = next;
+ }
+ return cur;
+}
+
+/**
+ * Guess where a cursor image clicks: the sharpest corner of its outline,
+ * leaning top-left like the stock pointers, or the middle of a reticle
+ * (crosshair, ring) or of art with no corner at all. Corners are
+ * measured over a stretch of outline rather than per pixel, so a rounded
+ * fingertip still reads as a tip. Art with no transparency gets the stock 0, 0.
+ */
+export function detectCursorHotspot(width: number, height: number, rgba: ArrayLike): CursorHotspot {
+ const mask = new Uint8Array(width * height);
+ const edges: Point[] = [];
+ let opaque = 0;
+ let minX = width;
+ let minY = height;
+ let maxX = -1;
+ let maxY = -1;
+ for (let y = 0; y < height; y++) {
+ let left = -1;
+ let right = -1;
+ for (let x = 0; x < width; x++) {
+ if (rgba[(y * width + x) * 4 + 3] < ALPHA_MIN) continue;
+ mask[y * width + x] = 1;
+ opaque++;
+ if (left < 0) left = x;
+ right = x;
+ }
+ if (left < 0) continue;
+ edges.push({ x: left, y }, { x: right, y });
+ minX = Math.min(minX, left);
+ maxX = Math.max(maxX, right);
+ minY = Math.min(minY, y);
+ maxY = y;
+ }
+ if (opaque === 0 || opaque === width * height) return { x: 0, y: 0 };
+
+ // Reticles look the same turned a quarter. A half turn is not enough: a
+ // straight wand or sword survives that too.
+ const cx = (minX + maxX) / 2;
+ const cy = (minY + maxY) / 2;
+ const center = { x: Math.round(cx), y: Math.round(cy) };
+ let symmetric = 0;
+ for (let y = minY; y <= maxY; y++) {
+ for (let x = minX; x <= maxX; x++) {
+ if (!mask[y * width + x]) continue;
+ const rx = Math.round(cx - (y - cy));
+ const ry = Math.round(cy + (x - cx));
+ if (rx >= 0 && rx < width && ry >= 0 && ry < height && mask[ry * width + rx]) symmetric++;
+ }
+ }
+ if (symmetric / opaque >= 0.85) return center;
+
+ const hull = convexHull(edges);
+ if (hull.length < 3) return center;
+ const reach = Math.max(2, 0.15 * Math.max(maxX - minX, maxY - minY));
+ const span = maxX - minX + (maxY - minY) || 1;
+ let best = center;
+ let bestScore = Infinity;
+ let sharpest = 180;
+ for (let i = 0; i < hull.length; i++) {
+ const v = hull[i];
+ const a = alongHull(hull, i, -1, reach);
+ const b = alongHull(hull, i, 1, reach);
+ const cos =
+ ((a.x - v.x) * (b.x - v.x) + (a.y - v.y) * (b.y - v.y)) /
+ (Math.hypot(a.x - v.x, a.y - v.y) * Math.hypot(b.x - v.x, b.y - v.y));
+ const angle = (Math.acos(Math.max(-1, Math.min(1, cos))) * 180) / Math.PI;
+ sharpest = Math.min(sharpest, angle);
+ const score = angle + (TOP_LEFT_BIAS_DEG * (v.x - minX + (v.y - minY))) / span;
+ if (score < bestScore) {
+ bestScore = score;
+ best = v;
+ }
+ }
+ return sharpest <= BLUNT_DEG ? best : center;
+}
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 8fd76b4f..6374658e 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -2052,6 +2052,8 @@
"imageLoadFailed": "That image could not be loaded. Try a different PNG or JPG.",
"noCanvasContext": "Could not render the crop (no 2D canvas context).",
"title": "Crop {{variant}} card",
+ "fitTitle": "Frame the {{variant}} image",
+ "fitInstructions": "Drag to move, scroll or use the slider to resize. Whatever the image doesn't cover stays transparent. The crosshair marks where clicks land: it finds the tip on its own, and a click moves it.",
"resetZoom": "Reset zoom",
"upscaleWarning": "Source is {{sourceWidth}} x {{sourceHeight}}, smaller than the {{targetWidth}} x {{targetHeight}} target, so it will be upscaled and may look soft.",
"instructions": "Drag to reposition, scroll or use the slider to zoom. The frame is locked to the card's aspect so the result is not stretched.",
@@ -2237,7 +2239,27 @@
"empty": "Cursor mods swap the game's mouse pointer. Download one from Browse or import its BMP files, then pick it here.",
"deleteNamed": "Delete {{name}}",
"deleteTitle": "Delete {{name}}?",
- "deleteMessage": "This removes the cursor pack from Grimoire. If it is the active one, the default cursors come back."
+ "deleteMessage": "This removes the cursor pack from Grimoire. If it is the active one, the default cursors come back.",
+ "create": {
+ "open": "Create",
+ "title": "Create a cursor",
+ "subtitle": "Pick an image for each cursor you want to change. The rest stay as they are.",
+ "nameLabel": "Name",
+ "defaultName": "My cursor",
+ "pointer": "Pointer",
+ "ping": "Ping",
+ "shop": "Shop",
+ "commend": "Commend",
+ "chooseImage": "Choose an image for {{cursor}}",
+ "imageFilter": "Images",
+ "clear": "Clear {{cursor}}",
+ "hint": "Transparent PNGs work best. The tip of your pointer is found on its own, and you can move it while framing the image.",
+ "submit": "Create and apply",
+ "export": "Export",
+ "exportTitle": "Export {{name}}",
+ "exportFilter": "Zip archive",
+ "exported": "Exported to {{path}}"
+ }
}
},
"profiles": {
diff --git a/src/locales/manifest.json b/src/locales/manifest.json
index 6f6ccef5..a6afa030 100644
--- a/src/locales/manifest.json
+++ b/src/locales/manifest.json
@@ -1,6 +1,6 @@
{
"sourceLanguage": "en",
- "totalKeys": 2539,
+ "totalKeys": 2559,
"languages": [
{
"code": "bg",
@@ -11,7 +11,7 @@
{
"code": "en",
"name": "English",
- "translatedKeys": 2539,
+ "translatedKeys": 2559,
"pct": 100
},
{
diff --git a/src/pages/Locker.tsx b/src/pages/Locker.tsx
index 3f4e99e3..04f826ef 100644
--- a/src/pages/Locker.tsx
+++ b/src/pages/Locker.tsx
@@ -15,7 +15,7 @@ import { getActiveDeadlockPath, shouldBlurNsfw } from '../lib/appSettings';
import { getAssetPath } from '../lib/assetPath';
import HeroSkinsPanel from '../components/locker/HeroSkinsPanel';
import GlobalModPicker from '../components/locker/GlobalModPicker';
-import CursorPackPane, { CursorImportButton } from '../components/locker/CursorPackPane';
+import CursorPackPane, { CursorPaneActions } from '../components/locker/CursorPackPane';
import CategoryModPicker from '../components/locker/CategoryModPicker';
import { CreateCategoryModal, ManageCategoriesModal } from '../components/locker/ManageCategoriesModal';
import {
@@ -2308,7 +2308,7 @@ function LockerGlobalView({ groups, hideNsfw, onBack, onToggle, onSetGlobalType,
{t('locker.page.modCount', { count: countForTab(activeType) })}
- {activeType === CURSOR_TAB && }
+ {activeType === CURSOR_TAB && }
{isPriorityTab && (