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
44 changes: 43 additions & 1 deletion electron/main/services/cursorFiles.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles';
import { bmpWithExplicitAlpha, groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles';

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

function rgbBmp(pixels: number[]): Buffer {
const bmp = Buffer.alloc(54 + pixels.length);
bmp.write('BM', 0, 'latin1');
bmp.writeUInt32LE(bmp.length, 2);
bmp.writeUInt32LE(54, 10);
bmp.writeUInt32LE(40, 14);
bmp.writeInt32LE(2, 18);
bmp.writeInt32LE(1, 22);
bmp.writeUInt16LE(1, 26);
bmp.writeUInt16LE(32, 28);
Buffer.from(pixels).copy(bmp, 54);
return bmp;
}

describe('bmpWithExplicitAlpha', () => {
it('re-headers a 32-bit BI_RGB image with alpha as BITMAPV5 with an alpha mask', () => {
const pixels = [0xff, 0xff, 0xff, 0x00, 0x10, 0x20, 0x30, 0xff];
const out = bmpWithExplicitAlpha(rgbBmp(pixels));
expect(out.readUInt32LE(2)).toBe(out.length);
expect(out.readUInt32LE(10)).toBe(138);
expect(out.readUInt32LE(14)).toBe(124);
expect(out.readInt32LE(18)).toBe(2);
expect(out.readInt32LE(22)).toBe(1);
expect(out.readUInt32LE(30)).toBe(3);
expect(out.readUInt32LE(66)).toBe(0xff000000);
expect([...out.subarray(138)]).toEqual(pixels);
});

it('leaves an all-zero alpha channel alone, since SDL loads it opaque', () => {
const bmp = rgbBmp([1, 2, 3, 0, 4, 5, 6, 0]);
expect(bmpWithExplicitAlpha(bmp)).toBe(bmp);
});

it('passes through images that already carry masks, and junk', () => {
const v5 = rgbBmp([1, 2, 3, 0x80, 4, 5, 6, 0]);
v5.writeUInt32LE(3, 30);
expect(bmpWithExplicitAlpha(v5)).toBe(v5);
const junk = Buffer.from('BM-stock');
expect(bmpWithExplicitAlpha(junk)).toBe(junk);
});
});

describe('groupCursorFiles', () => {
it('treats one folder of cursors as a single set', () => {
const groups = groupCursorFiles([
Expand Down
39 changes: 39 additions & 0 deletions electron/main/services/cursorFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,45 @@ export function isUsableCursorFile(fileName: string, bytes: Uint8Array): boolean
return bytes[0] === 0x42 && bytes[1] === 0x4d;
}

const FILE_HEADER = 14;
const V5_HEADER = 124;

/**
* The stock cursors are 32-bit BI_RGB BMPs whose reserved fourth byte is alpha.
* SDL reads it, but Chromium treats it as padding and paints the transparent
* pixels (stored white) opaque. Re-header those as BITMAPV5 with an explicit
* alpha mask so a preview matches the game. Anything else passes through.
*/
export function bmpWithExplicitAlpha(bytes: Buffer): Buffer {
if (bytes.length < FILE_HEADER + 40) return bytes;
if (bytes.readUInt32LE(14) !== 40 || bytes.readUInt16LE(28) !== 32 || bytes.readUInt32LE(30) !== 0) return bytes;
const pixels = bytes.subarray(bytes.readUInt32LE(10));
// SDL loads an all-zero alpha channel as opaque, which the file already previews as.
let hasAlpha = false;
for (let i = 3; i < pixels.length; i += 4) {
if (pixels[i] !== 0) {
hasAlpha = true;
break;
}
}
if (!hasAlpha) return bytes;

const offset = FILE_HEADER + V5_HEADER;
const out = Buffer.alloc(offset + pixels.length);
bytes.copy(out, 0, 0, FILE_HEADER + 40);
out.writeUInt32LE(out.length, 2);
out.writeUInt32LE(offset, 10);
out.writeUInt32LE(V5_HEADER, 14);
out.writeUInt32LE(3, 30); // BI_BITFIELDS
out.writeUInt32LE(0x00ff0000, 54);
out.writeUInt32LE(0x0000ff00, 58);
out.writeUInt32LE(0x000000ff, 62);
out.writeUInt32LE(0xff000000, 66);
out.write('BGRs', 70, 'latin1'); // LCS_sRGB, little-endian
pixels.copy(out, offset);
return out;
}

export interface CursorSourceFile {
path: string;
fileName: string;
Expand Down
4 changes: 2 additions & 2 deletions electron/main/services/cursorPacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { existsSync, promises as fs } from 'fs';
import { basename, join } from 'path';
import type { CursorPack, CursorPacksState, CursorPreview } from '../../../src/types/electron';
import { getUserDataPath } from '../utils/paths';
import { groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles';
import { bmpWithExplicitAlpha, groupCursorFiles, isCursorFileName, isUsableCursorFile } from './cursorFiles';
import { getCitadelPath } from './deadlock';
import { extractArchive, type ExtractedVpk } from './extract';

Expand Down Expand Up @@ -173,7 +173,7 @@ async function readPreviewDir(dir: string, names: readonly string[]): Promise<Cu
if (!name.endsWith('.bmp')) continue;
const path = join(dir, name);
if (!existsSync(path)) continue;
preview[name] = `data:image/bmp;base64,${(await fs.readFile(path)).toString('base64')}`;
preview[name] = `data:image/bmp;base64,${bmpWithExplicitAlpha(await fs.readFile(path)).toString('base64')}`;
}
return preview;
}
Expand Down
22 changes: 13 additions & 9 deletions src/pages/Locker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1938,19 +1938,23 @@ function LockerGlobalView({ groups, hideNsfw, onBack, onToggle, onSetGlobalType,
const [selectedType, setSelectedType] = useState<GeneralTabId>(
() => firstPopulated[0] ?? 'soul-container'
);
// Tab order: the seven classification types, then Global (the precedence
// axis). Global is deliberately last and visually separated: it answers a
// different question ("does this mod win?") than the types above it ("what
// kind of mod is this?"). The user's own categories follow, behind a second
// separator, as a third axis again ("which pile did I put it in?").
// Tab order: the classification types, Cursors, then Global (the precedence
// axis), with populated tabs pulled above empty ones so a mostly-empty rail
// leads with what is installed. The user's own categories follow, behind a
// separator, in their own order ("which pile did I put it in?").
const cursorPackCount = useCursorPackStore((s) => s.packs.length);
const fixedTabIds: readonly GeneralTabId[] = [...GLOBAL_MOD_TYPE_ORDER, CURSOR_TAB, PRIORITY_TAB];
const countForTab = (tab: GeneralTabId) => {
if (tab === PRIORITY_TAB) return priorityMods.length;
if (tab === CURSOR_TAB) return cursorPackCount;
if (isCustomTab(tab)) return categoryGroups.get(customTabCategoryId(tab))?.length ?? 0;
return groups[tab].length;
};
const builtinTabIds: readonly GeneralTabId[] = [...GLOBAL_MOD_TYPE_ORDER, CURSOR_TAB, PRIORITY_TAB];
const fixedTabIds = [
...builtinTabIds.filter((tab) => countForTab(tab) > 0),
...builtinTabIds.filter((tab) => countForTab(tab) === 0),
];
const fixedTabOrder = fixedTabIds.join();
// Sliding active-tab highlight, mirroring the main sidebar's glide: one
// indicator element animates between the tab rows rather than each row
// toggling its own background (which snaps). Refs feed its measured position.
Expand Down Expand Up @@ -2006,12 +2010,12 @@ function LockerGlobalView({ groups, hideNsfw, onBack, onToggle, onSetGlobalType,
}, [selectedType, categories, landingTab]);
// Track the active row's box so the highlight can glide to it. Measured in a
// layout effect (pre-paint) to avoid a one-frame jump on first mount. Also
// re-measured when the category count changes: adding or deleting one moves
// the rows below it without the active tab changing.
// re-measured when the rows move without the active tab changing: a category
// added or deleted, or a tab filling up or emptying out.
useLayoutEffect(() => {
const el = tabRefs.current.get(activeType);
if (el) setTabIndicator({ top: el.offsetTop, height: el.offsetHeight });
}, [activeType, categories.length]);
}, [activeType, categories.length, fixedTabOrder]);
// Soul containers and spirit urns share the single-select + live-3D-tile
// treatment (frosted glass, content-stable key, active badge, import button).
// Never true for the Global tab: priority mods are ordinary multi-toggle
Expand Down
Loading