emoemu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,1957 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ROM Scanner
|
|
3
|
+
*
|
|
4
|
+
* Scans directories for ROM files and extracts metadata.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readdirSync, statSync, existsSync, openSync, readSync, closeSync, readFileSync } from 'fs';
|
|
8
|
+
import { readdir, stat } from 'fs/promises';
|
|
9
|
+
import { join, extname, basename } from 'path';
|
|
10
|
+
import { groupBy, flatMap } from 'remeda';
|
|
11
|
+
import { calculateFileCrc32 } from '../../utils/crc32';
|
|
12
|
+
import { getThumbnailPath, THUMBNAIL_TYPES, type ThumbnailType } from '../../utils/paths';
|
|
13
|
+
import { HEX_RADIX } from '../../utils';
|
|
14
|
+
import { getSupportedExtensions, findMatchingCoresByExtension } from '../coreRegistry';
|
|
15
|
+
import { normalizePath } from '../playlist/utils';
|
|
16
|
+
import { getSystemName as getPlaylistSystemName } from '../playlist';
|
|
17
|
+
import { getSaveStateService, getBatterySaveService, buildCrcCache } from '../serviceProvider';
|
|
18
|
+
import type { CrcCache } from '../playlist';
|
|
19
|
+
import {
|
|
20
|
+
MIN_ROM_SIZE,
|
|
21
|
+
BINARY_CHECK_SIZE,
|
|
22
|
+
HEADER_READ_SIZE,
|
|
23
|
+
NES_REQUIRED_HEADER_SIZE,
|
|
24
|
+
GB_REQUIRED_HEADER_SIZE,
|
|
25
|
+
SNES_REQUIRED_HEADER_SIZE,
|
|
26
|
+
GENESIS_REQUIRED_HEADER_SIZE,
|
|
27
|
+
GBA_REQUIRED_HEADER_SIZE,
|
|
28
|
+
BINARY_DETECTION_THRESHOLD,
|
|
29
|
+
BYTES_PER_KB,
|
|
30
|
+
BYTES_PER_MB,
|
|
31
|
+
INES_HEADER_SIZE,
|
|
32
|
+
INES_MAGIC_N,
|
|
33
|
+
INES_MAGIC_E,
|
|
34
|
+
INES_MAGIC_S,
|
|
35
|
+
INES_MAGIC_EOF,
|
|
36
|
+
INES_PRG_BANKS_BYTE,
|
|
37
|
+
INES_CHR_BANKS_BYTE,
|
|
38
|
+
INES_FLAGS6_BYTE,
|
|
39
|
+
INES_FLAGS7_BYTE,
|
|
40
|
+
INES_MAPPER_HIGH_SHIFT,
|
|
41
|
+
INES_MAPPER_LOW_MASK,
|
|
42
|
+
INES_MAPPER_HIGH_MASK,
|
|
43
|
+
INES_PRG_BANK_SIZE_KB,
|
|
44
|
+
INES_CHR_BANK_SIZE_KB,
|
|
45
|
+
INES_BATTERY_BIT,
|
|
46
|
+
INES_PAL_BIT,
|
|
47
|
+
NES2_FORMAT_MASK,
|
|
48
|
+
NES2_FORMAT_VALUE,
|
|
49
|
+
NES2_PRG_RAM_BYTE,
|
|
50
|
+
NES2_PRG_RAM_MASK,
|
|
51
|
+
NES2_PRG_RAM_MAX_SHIFT,
|
|
52
|
+
NES2_PRG_RAM_BASE,
|
|
53
|
+
DEFAULT_NES_SRAM_KB,
|
|
54
|
+
GB_MIN_HEADER_SIZE,
|
|
55
|
+
GB_TITLE_START,
|
|
56
|
+
GB_TITLE_END,
|
|
57
|
+
GB_CGB_FLAG,
|
|
58
|
+
GB_SGB_FLAG,
|
|
59
|
+
GB_CART_TYPE,
|
|
60
|
+
GB_ROM_SIZE,
|
|
61
|
+
GB_RAM_SIZE,
|
|
62
|
+
GB_OLD_LICENSEE,
|
|
63
|
+
GB_NEW_LICENSEE_START,
|
|
64
|
+
GB_NEW_LICENSEE_END,
|
|
65
|
+
GB_HEADER_CHECKSUM,
|
|
66
|
+
GB_CHECKSUM_START,
|
|
67
|
+
GB_CHECKSUM_END,
|
|
68
|
+
GB_NEW_LICENSEE_MARKER,
|
|
69
|
+
GB_SGB_SUPPORT_VALUE,
|
|
70
|
+
GB_CGB_ENHANCED_VALUE,
|
|
71
|
+
GB_CGB_ONLY_VALUE,
|
|
72
|
+
GB_BATTERY_CART_TYPES,
|
|
73
|
+
GB_CARTRIDGE_TYPES,
|
|
74
|
+
GB_ROM_SIZES,
|
|
75
|
+
GB_RAM_SIZES,
|
|
76
|
+
SNES_LOROM_OFFSET,
|
|
77
|
+
SNES_HIROM_OFFSET,
|
|
78
|
+
SNES_LOROM_COPIER_OFFSET,
|
|
79
|
+
SNES_HIROM_COPIER_OFFSET,
|
|
80
|
+
SNES_HEADER_MIN_SIZE,
|
|
81
|
+
SNES_TITLE_LENGTH,
|
|
82
|
+
SNES_MAKEUP_OFFSET,
|
|
83
|
+
SNES_ROM_TYPE_OFFSET,
|
|
84
|
+
SNES_ROM_SIZE_OFFSET,
|
|
85
|
+
SNES_SRAM_SIZE_OFFSET,
|
|
86
|
+
SNES_COUNTRY_OFFSET,
|
|
87
|
+
SNES_PUBLISHER_OFFSET,
|
|
88
|
+
SNES_CHECKSUM_OFFSET,
|
|
89
|
+
SNES_CHECKSUM_HIGH_OFFSET,
|
|
90
|
+
SNES_COMPLEMENT_OFFSET,
|
|
91
|
+
SNES_COMPLEMENT_HIGH_OFFSET,
|
|
92
|
+
SNES_CHECKSUM_XOR,
|
|
93
|
+
SNES_HIROM_BIT,
|
|
94
|
+
SNES_FASTROM_BIT,
|
|
95
|
+
SNES_ROM_ONLY,
|
|
96
|
+
SNES_ROM_RAM,
|
|
97
|
+
SNES_ROM_RAM_BATTERY,
|
|
98
|
+
SNES_BATTERY_ROM_TYPES,
|
|
99
|
+
SNES_CHIP_TYPES,
|
|
100
|
+
SNES_SIZE_CODE_MAX,
|
|
101
|
+
SNES_PAL_THRESHOLD,
|
|
102
|
+
GENESIS_MIN_HEADER_SIZE,
|
|
103
|
+
GENESIS_SYSTEM_TYPE_START,
|
|
104
|
+
GENESIS_SYSTEM_TYPE_END,
|
|
105
|
+
GENESIS_DOMESTIC_NAME_START,
|
|
106
|
+
GENESIS_DOMESTIC_NAME_END,
|
|
107
|
+
GENESIS_OVERSEAS_NAME_START,
|
|
108
|
+
GENESIS_OVERSEAS_NAME_END,
|
|
109
|
+
GENESIS_SERIAL_START,
|
|
110
|
+
GENESIS_SERIAL_END,
|
|
111
|
+
GENESIS_IO_SUPPORT_START,
|
|
112
|
+
GENESIS_IO_SUPPORT_END,
|
|
113
|
+
GENESIS_ROM_START_ADDR,
|
|
114
|
+
GENESIS_ROM_END_ADDR,
|
|
115
|
+
GENESIS_RAM_START_ADDR,
|
|
116
|
+
GENESIS_RAM_END_ADDR,
|
|
117
|
+
GENESIS_REGION_START,
|
|
118
|
+
GENESIS_REGION_END,
|
|
119
|
+
GENESIS_COPYRIGHT_START,
|
|
120
|
+
GENESIS_COPYRIGHT_END,
|
|
121
|
+
GENESIS_MAX_ROM_SIZE,
|
|
122
|
+
GENESIS_MAX_RAM_SIZE,
|
|
123
|
+
GENESIS_INVALID_RAM_MARKER,
|
|
124
|
+
GBA_MIN_HEADER_SIZE,
|
|
125
|
+
GBA_TITLE_START,
|
|
126
|
+
GBA_TITLE_END,
|
|
127
|
+
GBA_GAME_CODE_START,
|
|
128
|
+
GBA_GAME_CODE_END,
|
|
129
|
+
GBA_MAKER_CODE_START,
|
|
130
|
+
GBA_MAKER_CODE_END,
|
|
131
|
+
GBA_UNIT_CODE,
|
|
132
|
+
GBA_HEADER_CHECKSUM,
|
|
133
|
+
GBA_CHECKSUM_START,
|
|
134
|
+
GBA_CHECKSUM_END,
|
|
135
|
+
GBA_VALID_UNIT_CODE,
|
|
136
|
+
GBA_CHECKSUM_ADJUSTMENT,
|
|
137
|
+
GBA_REGION_CHAR_INDEX,
|
|
138
|
+
GBA_GAME_CODE_LENGTH,
|
|
139
|
+
GBA_MAKER_CODE_LENGTH,
|
|
140
|
+
CHECKSUM_BYTE_MASK,
|
|
141
|
+
} from './consts';
|
|
142
|
+
|
|
143
|
+
export * from './consts';
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check if a buffer contains binary ROM data (not text).
|
|
147
|
+
* Analyzes the first BINARY_CHECK_SIZE bytes for binary indicators.
|
|
148
|
+
*/
|
|
149
|
+
const isBinaryFromBuffer = (buffer: Buffer): boolean => {
|
|
150
|
+
const bytesToCheck = Math.min(BINARY_CHECK_SIZE, buffer.length);
|
|
151
|
+
|
|
152
|
+
let nullBytes = 0;
|
|
153
|
+
let nonPrintable = 0;
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < bytesToCheck; i++) {
|
|
156
|
+
const byte = buffer[i];
|
|
157
|
+
|
|
158
|
+
// Null bytes are very common in binary files, rare in text
|
|
159
|
+
if (byte === 0x00) {
|
|
160
|
+
nullBytes++;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Count non-printable characters (excluding common whitespace)
|
|
164
|
+
// Printable ASCII: 0x09 (tab), 0x0A (LF), 0x0D (CR), 0x20-0x7E
|
|
165
|
+
const ASCII_TAB = 0x09;
|
|
166
|
+
const ASCII_LF = 0x0a;
|
|
167
|
+
const ASCII_CR = 0x0d;
|
|
168
|
+
const ASCII_SPACE = 0x20;
|
|
169
|
+
const ASCII_TILDE = 0x7e;
|
|
170
|
+
if (byte !== ASCII_TAB && byte !== ASCII_LF && byte !== ASCII_CR &&
|
|
171
|
+
(byte < ASCII_SPACE || byte > ASCII_TILDE)) {
|
|
172
|
+
nonPrintable++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// If file contains null bytes, it's almost certainly binary
|
|
177
|
+
if (nullBytes > 0) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// If more than 10% non-printable characters, likely binary
|
|
182
|
+
const nonPrintableRatio = nonPrintable / bytesToCheck;
|
|
183
|
+
return nonPrintableRatio > BINARY_DETECTION_THRESHOLD;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get the required header size for a given file extension.
|
|
188
|
+
* Returns the minimum bytes needed for binary detection + metadata extraction.
|
|
189
|
+
*/
|
|
190
|
+
const getRequiredHeaderSize = (extension: string): number => {
|
|
191
|
+
switch (extension) {
|
|
192
|
+
case '.nes':
|
|
193
|
+
return NES_REQUIRED_HEADER_SIZE;
|
|
194
|
+
case '.gb':
|
|
195
|
+
case '.gbc':
|
|
196
|
+
return GB_REQUIRED_HEADER_SIZE;
|
|
197
|
+
case '.sfc':
|
|
198
|
+
case '.smc':
|
|
199
|
+
return SNES_REQUIRED_HEADER_SIZE;
|
|
200
|
+
case '.md':
|
|
201
|
+
case '.smd':
|
|
202
|
+
case '.gen':
|
|
203
|
+
case '.bin':
|
|
204
|
+
return GENESIS_REQUIRED_HEADER_SIZE;
|
|
205
|
+
case '.gba':
|
|
206
|
+
return GBA_REQUIRED_HEADER_SIZE;
|
|
207
|
+
default:
|
|
208
|
+
// Unknown format: use default size for safety
|
|
209
|
+
return HEADER_READ_SIZE;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Read ROM header from file (single file open for both binary check and metadata).
|
|
215
|
+
* Uses smart sizing based on file extension to minimize I/O.
|
|
216
|
+
* Returns null if file is too small or can't be read.
|
|
217
|
+
*/
|
|
218
|
+
const readRomHeader = (filePath: string, fileSize: number, extension?: string): Buffer | null => {
|
|
219
|
+
if (fileSize < MIN_ROM_SIZE) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
// Use format-specific size if extension provided, otherwise use default
|
|
225
|
+
const maxSize = extension ? getRequiredHeaderSize(extension) : HEADER_READ_SIZE;
|
|
226
|
+
const bytesToRead = Math.min(maxSize, fileSize);
|
|
227
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
228
|
+
const fd = openSync(filePath, 'r');
|
|
229
|
+
let bytesRead: number;
|
|
230
|
+
try {
|
|
231
|
+
bytesRead = readSync(fd, buffer, 0, bytesToRead, 0);
|
|
232
|
+
} finally {
|
|
233
|
+
closeSync(fd);
|
|
234
|
+
}
|
|
235
|
+
return buffer.subarray(0, bytesRead);
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Normalize a title string by trimming and collapsing multiple spaces
|
|
243
|
+
*/
|
|
244
|
+
const normalizeTitle = (title: string): string => title.trim().replace(/\s+/g, ' ');
|
|
245
|
+
|
|
246
|
+
export interface RomInfo {
|
|
247
|
+
/** Full path to the ROM file */
|
|
248
|
+
path: string;
|
|
249
|
+
/** File name without directory */
|
|
250
|
+
filename: string;
|
|
251
|
+
/** File extension (lowercase, with dot) */
|
|
252
|
+
extension: string;
|
|
253
|
+
/** File size in bytes */
|
|
254
|
+
size: number;
|
|
255
|
+
/** Human-readable file size */
|
|
256
|
+
sizeFormatted: string;
|
|
257
|
+
/** Last modified date */
|
|
258
|
+
modified: Date;
|
|
259
|
+
/** System name (e.g., "Nintendo Entertainment System") */
|
|
260
|
+
system: string;
|
|
261
|
+
/** System ID for core selection */
|
|
262
|
+
systemId: string;
|
|
263
|
+
/** Number of cores that can play this ROM */
|
|
264
|
+
coreCount: number;
|
|
265
|
+
/** IDs of cores that can play this ROM (cached for playlist generation) */
|
|
266
|
+
coreIds: string[];
|
|
267
|
+
/** Additional metadata extracted from ROM header */
|
|
268
|
+
metadata: RomMetadata;
|
|
269
|
+
/** Whether a save state file exists for this ROM */
|
|
270
|
+
hasSaveState: boolean;
|
|
271
|
+
/** Modified date of the save state file (if exists) */
|
|
272
|
+
saveStateDate?: Date;
|
|
273
|
+
/** Screenshot from save state (base64-encoded PNG) */
|
|
274
|
+
saveStateScreenshot?: string;
|
|
275
|
+
/** Frame count from save state (for estimated playtime) */
|
|
276
|
+
saveStateFrameCount?: number;
|
|
277
|
+
/** Whether a battery save (.srm) file exists for this ROM */
|
|
278
|
+
hasBatterySave: boolean;
|
|
279
|
+
/** Modified date of the battery save file (if exists) */
|
|
280
|
+
batterySaveDate?: Date;
|
|
281
|
+
/** CRC32 checksum of the ROM file (uppercase hex, calculated during scan) */
|
|
282
|
+
crc32?: string;
|
|
283
|
+
|
|
284
|
+
// Runtime tracking (from playlist, RetroArch compatible)
|
|
285
|
+
/** Total runtime in seconds (from playlist) */
|
|
286
|
+
runtimeSeconds?: number;
|
|
287
|
+
/** Last played date (from playlist) */
|
|
288
|
+
lastPlayed?: Date;
|
|
289
|
+
|
|
290
|
+
// Playlist label (display name from playlist file, separate from ROM header title)
|
|
291
|
+
/** Display label from playlist file (user-editable game title) */
|
|
292
|
+
label?: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface RomMetadata {
|
|
296
|
+
/** Game title (if extractable from header) */
|
|
297
|
+
title?: string;
|
|
298
|
+
/** Mapper number (NES) */
|
|
299
|
+
mapper?: number;
|
|
300
|
+
/** PRG ROM size */
|
|
301
|
+
prgSize?: string;
|
|
302
|
+
/** CHR ROM size */
|
|
303
|
+
chrSize?: string;
|
|
304
|
+
/** RAM/SRAM size */
|
|
305
|
+
ramSize?: string;
|
|
306
|
+
/** Region (NTSC/PAL/etc) */
|
|
307
|
+
region?: string;
|
|
308
|
+
/** Cartridge type (GBC) */
|
|
309
|
+
cartridgeType?: string;
|
|
310
|
+
/** ROM type (LoROM/HiROM for SNES) */
|
|
311
|
+
romType?: string;
|
|
312
|
+
/** Has battery-backed save */
|
|
313
|
+
hasBattery?: boolean;
|
|
314
|
+
/** Publisher/developer name */
|
|
315
|
+
publisher?: string;
|
|
316
|
+
/** Special chip (SNES: DSP, SuperFX, SA-1, etc.) */
|
|
317
|
+
specialChip?: string;
|
|
318
|
+
/** Super Game Boy support */
|
|
319
|
+
sgbSupport?: boolean;
|
|
320
|
+
/** Game serial/product code */
|
|
321
|
+
serial?: string;
|
|
322
|
+
/** Supported input devices (Genesis) */
|
|
323
|
+
inputDevices?: string;
|
|
324
|
+
/** Header checksum valid */
|
|
325
|
+
checksumValid?: boolean;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Format bytes to human-readable size
|
|
330
|
+
*/
|
|
331
|
+
const formatSize = (bytes: number): string => {
|
|
332
|
+
if (bytes < BYTES_PER_KB) {return `${bytes} B`;}
|
|
333
|
+
if (bytes < BYTES_PER_MB) {return `${(bytes / BYTES_PER_KB).toFixed(1)} KB`;}
|
|
334
|
+
return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Extract metadata from NES ROM header (iNES format)
|
|
339
|
+
*/
|
|
340
|
+
const extractNesMetadata = (data: Buffer): RomMetadata => {
|
|
341
|
+
const metadata: RomMetadata = {};
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
// Need at least 16 bytes for iNES header
|
|
345
|
+
if (data.length < INES_HEADER_SIZE) {return metadata;}
|
|
346
|
+
|
|
347
|
+
// Check for iNES header (NES\x1A)
|
|
348
|
+
if (data[0] === INES_MAGIC_N && data[1] === INES_MAGIC_E && data[2] === INES_MAGIC_S && data[3] === INES_MAGIC_EOF) {
|
|
349
|
+
const prgBanks = data[INES_PRG_BANKS_BYTE];
|
|
350
|
+
const chrBanks = data[INES_CHR_BANKS_BYTE];
|
|
351
|
+
const flags6 = data[INES_FLAGS6_BYTE];
|
|
352
|
+
const flags7 = data[INES_FLAGS7_BYTE];
|
|
353
|
+
|
|
354
|
+
metadata.mapper = ((flags6 >> INES_MAPPER_HIGH_SHIFT) & INES_MAPPER_LOW_MASK) | (flags7 & INES_MAPPER_HIGH_MASK);
|
|
355
|
+
metadata.prgSize = `${prgBanks * INES_PRG_BANK_SIZE_KB} KB`;
|
|
356
|
+
metadata.chrSize = chrBanks > 0 ? `${chrBanks * INES_CHR_BANK_SIZE_KB} KB` : 'CHR RAM';
|
|
357
|
+
metadata.region = (flags7 & INES_PAL_BIT) ? 'PAL' : 'NTSC';
|
|
358
|
+
|
|
359
|
+
// Battery-backed RAM (bit 1 of flags6)
|
|
360
|
+
metadata.hasBattery = (flags6 & INES_BATTERY_BIT) !== 0;
|
|
361
|
+
|
|
362
|
+
// Check for NES 2.0 format (bits 2-3 of flags7 == 2)
|
|
363
|
+
const isNes2 = (flags7 & NES2_FORMAT_MASK) === NES2_FORMAT_VALUE;
|
|
364
|
+
if (isNes2 && data.length > NES2_PRG_RAM_BYTE) {
|
|
365
|
+
// NES 2.0: PRG-RAM size at byte 10
|
|
366
|
+
const prgRamShift = data[NES2_PRG_RAM_BYTE] & NES2_PRG_RAM_MASK;
|
|
367
|
+
if (prgRamShift > 0 && prgRamShift < NES2_PRG_RAM_MAX_SHIFT) {
|
|
368
|
+
const prgRamSize = NES2_PRG_RAM_BASE << prgRamShift;
|
|
369
|
+
metadata.ramSize = prgRamSize >= BYTES_PER_KB ? `${prgRamSize / BYTES_PER_KB} KB` : `${prgRamSize} B`;
|
|
370
|
+
}
|
|
371
|
+
} else if (metadata.hasBattery) {
|
|
372
|
+
// iNES 1.0: assume 8KB if battery present
|
|
373
|
+
metadata.ramSize = `${DEFAULT_NES_SRAM_KB} KB`;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch {
|
|
377
|
+
// Best effort - return whatever we extracted
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return metadata;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Game Boy licensee codes (old format at 0x14B)
|
|
385
|
+
*/
|
|
386
|
+
const oldLicenseeCodes: Record<number, string> = {
|
|
387
|
+
0x00: 'None',
|
|
388
|
+
0x01: 'Nintendo',
|
|
389
|
+
0x08: 'Capcom',
|
|
390
|
+
0x09: 'Hot-B',
|
|
391
|
+
0x0A: 'Jaleco',
|
|
392
|
+
0x0B: 'Coconuts',
|
|
393
|
+
0x0C: 'Elite Systems',
|
|
394
|
+
0x13: 'Electronic Arts',
|
|
395
|
+
0x18: 'Hudson Soft',
|
|
396
|
+
0x19: 'ITC Entertainment',
|
|
397
|
+
0x1A: 'Yanoman',
|
|
398
|
+
0x1D: 'Clary',
|
|
399
|
+
0x1F: 'Virgin',
|
|
400
|
+
0x24: 'PCM Complete',
|
|
401
|
+
0x25: 'San-X',
|
|
402
|
+
0x28: 'Kotobuki Systems',
|
|
403
|
+
0x29: 'Seta',
|
|
404
|
+
0x30: 'Infogrames',
|
|
405
|
+
0x31: 'Nintendo',
|
|
406
|
+
0x32: 'Bandai',
|
|
407
|
+
0x33: 'New licensee (see 0x144-0x145)',
|
|
408
|
+
0x34: 'Konami',
|
|
409
|
+
0x35: 'Hector',
|
|
410
|
+
0x38: 'Capcom',
|
|
411
|
+
0x39: 'Banpresto',
|
|
412
|
+
0x3C: 'Entertainment i',
|
|
413
|
+
0x3E: 'Gremlin',
|
|
414
|
+
0x41: 'Ubisoft',
|
|
415
|
+
0x42: 'Atlus',
|
|
416
|
+
0x44: 'Malibu',
|
|
417
|
+
0x46: 'Angel',
|
|
418
|
+
0x47: 'Spectrum Holoby',
|
|
419
|
+
0x49: 'Irem',
|
|
420
|
+
0x4A: 'Virgin',
|
|
421
|
+
0x4D: 'Malibu',
|
|
422
|
+
0x4F: 'U.S. Gold',
|
|
423
|
+
0x50: 'Absolute',
|
|
424
|
+
0x51: 'Acclaim',
|
|
425
|
+
0x52: 'Activision',
|
|
426
|
+
0x53: 'American Sammy',
|
|
427
|
+
0x54: 'GameTek',
|
|
428
|
+
0x55: 'Park Place',
|
|
429
|
+
0x56: 'LJN',
|
|
430
|
+
0x57: 'Matchbox',
|
|
431
|
+
0x59: 'Milton Bradley',
|
|
432
|
+
0x5A: 'Mindscape',
|
|
433
|
+
0x5B: 'Romstar',
|
|
434
|
+
0x5C: 'Naxat Soft',
|
|
435
|
+
0x5D: 'Tradewest',
|
|
436
|
+
0x60: 'Titus',
|
|
437
|
+
0x61: 'Virgin',
|
|
438
|
+
0x67: 'Ocean',
|
|
439
|
+
0x69: 'Electronic Arts',
|
|
440
|
+
0x6E: 'Elite Systems',
|
|
441
|
+
0x6F: 'Electro Brain',
|
|
442
|
+
0x70: 'Infogrames',
|
|
443
|
+
0x71: 'Interplay',
|
|
444
|
+
0x72: 'Broderbund',
|
|
445
|
+
0x73: 'Sculptered Soft',
|
|
446
|
+
0x75: 'The Sales Curve',
|
|
447
|
+
0x78: 'THQ',
|
|
448
|
+
0x79: 'Accolade',
|
|
449
|
+
0x7A: 'Triffix Entertainment',
|
|
450
|
+
0x7C: 'Microprose',
|
|
451
|
+
0x7F: 'Kemco',
|
|
452
|
+
0x80: 'Misawa Entertainment',
|
|
453
|
+
0x83: 'LOZC',
|
|
454
|
+
0x86: 'Tokuma Shoten',
|
|
455
|
+
0x8B: 'Bullet-Proof Software',
|
|
456
|
+
0x8C: 'Vic Tokai',
|
|
457
|
+
0x8E: 'Ape',
|
|
458
|
+
0x8F: 'I\'Max',
|
|
459
|
+
0x91: 'Chunsoft',
|
|
460
|
+
0x92: 'Video System',
|
|
461
|
+
0x93: 'Tsuburava',
|
|
462
|
+
0x95: 'Varie',
|
|
463
|
+
0x96: 'Yonezawa/S\'Pal',
|
|
464
|
+
0x97: 'Kaneko',
|
|
465
|
+
0x99: 'Arc',
|
|
466
|
+
0x9A: 'Nihon Bussan',
|
|
467
|
+
0x9B: 'Tecmo',
|
|
468
|
+
0x9C: 'Imagineer',
|
|
469
|
+
0x9D: 'Banpresto',
|
|
470
|
+
0x9F: 'Nova',
|
|
471
|
+
0xA1: 'Hori Electric',
|
|
472
|
+
0xA2: 'Bandai',
|
|
473
|
+
0xA4: 'Konami',
|
|
474
|
+
0xA6: 'Kawada',
|
|
475
|
+
0xA7: 'Takara',
|
|
476
|
+
0xA9: 'Technos Japan',
|
|
477
|
+
0xAA: 'Broderbund',
|
|
478
|
+
0xAC: 'Toei Animation',
|
|
479
|
+
0xAD: 'Toho',
|
|
480
|
+
0xAF: 'Namco',
|
|
481
|
+
0xB0: 'Acclaim',
|
|
482
|
+
0xB1: 'Nexoft',
|
|
483
|
+
0xB2: 'Bandai',
|
|
484
|
+
0xB4: 'Enix',
|
|
485
|
+
0xB6: 'HAL',
|
|
486
|
+
0xB7: 'SNK',
|
|
487
|
+
0xB9: 'Pony Canyon',
|
|
488
|
+
0xBA: 'Culture Brain',
|
|
489
|
+
0xBB: 'Sunsoft',
|
|
490
|
+
0xBD: 'Sony Imagesoft',
|
|
491
|
+
0xBF: 'Sammy',
|
|
492
|
+
0xC0: 'Taito',
|
|
493
|
+
0xC2: 'Kemco',
|
|
494
|
+
0xC3: 'Squaresoft',
|
|
495
|
+
0xC4: 'Tokuma Shoten',
|
|
496
|
+
0xC5: 'Data East',
|
|
497
|
+
0xC6: 'Tonkin House',
|
|
498
|
+
0xC8: 'Koei',
|
|
499
|
+
0xC9: 'UFL',
|
|
500
|
+
0xCA: 'Ultra',
|
|
501
|
+
0xCB: 'Vap',
|
|
502
|
+
0xCC: 'Use',
|
|
503
|
+
0xCD: 'Meldac',
|
|
504
|
+
0xCE: 'Pony Canyon',
|
|
505
|
+
0xCF: 'Angel',
|
|
506
|
+
0xD0: 'Taito',
|
|
507
|
+
0xD1: 'Sofel',
|
|
508
|
+
0xD2: 'Quest',
|
|
509
|
+
0xD3: 'Sigma Enterprises',
|
|
510
|
+
0xD4: 'Ask Kodansha',
|
|
511
|
+
0xD6: 'Naxat Soft',
|
|
512
|
+
0xD7: 'Copya Systems',
|
|
513
|
+
0xD9: 'Banpresto',
|
|
514
|
+
0xDA: 'Tomy',
|
|
515
|
+
0xDB: 'LJN',
|
|
516
|
+
0xDD: 'NCS',
|
|
517
|
+
0xDE: 'Human',
|
|
518
|
+
0xDF: 'Altron',
|
|
519
|
+
0xE0: 'Jaleco',
|
|
520
|
+
0xE1: 'Towachiki',
|
|
521
|
+
0xE2: 'Uutaka',
|
|
522
|
+
0xE3: 'Varie',
|
|
523
|
+
0xE5: 'Epoch',
|
|
524
|
+
0xE7: 'Athena',
|
|
525
|
+
0xE8: 'Asmik',
|
|
526
|
+
0xE9: 'Natsume',
|
|
527
|
+
0xEA: 'King Records',
|
|
528
|
+
0xEB: 'Atlus',
|
|
529
|
+
0xEC: 'Epic/Sony Records',
|
|
530
|
+
0xEE: 'IGS',
|
|
531
|
+
0xF0: 'A Wave',
|
|
532
|
+
0xF3: 'Extreme Entertainment',
|
|
533
|
+
0xFF: 'LJN',
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Game Boy new licensee codes (at 0x144-0x145, when old licensee is 0x33)
|
|
538
|
+
*/
|
|
539
|
+
const newLicenseeCodes: Record<string, string> = {
|
|
540
|
+
'00': 'None',
|
|
541
|
+
'01': 'Nintendo R&D1',
|
|
542
|
+
'08': 'Capcom',
|
|
543
|
+
'13': 'Electronic Arts',
|
|
544
|
+
'18': 'Hudson Soft',
|
|
545
|
+
'19': 'b-ai',
|
|
546
|
+
'20': 'kss',
|
|
547
|
+
'22': 'pow',
|
|
548
|
+
'24': 'PCM Complete',
|
|
549
|
+
'25': 'san-x',
|
|
550
|
+
'28': 'Kemco Japan',
|
|
551
|
+
'29': 'seta',
|
|
552
|
+
'30': 'Viacom',
|
|
553
|
+
'31': 'Nintendo',
|
|
554
|
+
'32': 'Bandai',
|
|
555
|
+
'33': 'Ocean/Acclaim',
|
|
556
|
+
'34': 'Konami',
|
|
557
|
+
'35': 'Hector',
|
|
558
|
+
'37': 'Taito',
|
|
559
|
+
'38': 'Hudson',
|
|
560
|
+
'39': 'Banpresto',
|
|
561
|
+
'41': 'Ubi Soft',
|
|
562
|
+
'42': 'Atlus',
|
|
563
|
+
'44': 'Malibu',
|
|
564
|
+
'46': 'angel',
|
|
565
|
+
'47': 'Bullet-Proof',
|
|
566
|
+
'49': 'irem',
|
|
567
|
+
'50': 'Absolute',
|
|
568
|
+
'51': 'Acclaim',
|
|
569
|
+
'52': 'Activision',
|
|
570
|
+
'53': 'American sammy',
|
|
571
|
+
'54': 'Konami',
|
|
572
|
+
'55': 'Hi tech entertainment',
|
|
573
|
+
'56': 'LJN',
|
|
574
|
+
'57': 'Matchbox',
|
|
575
|
+
'58': 'Mattel',
|
|
576
|
+
'59': 'Milton Bradley',
|
|
577
|
+
'60': 'Titus',
|
|
578
|
+
'61': 'Virgin',
|
|
579
|
+
'64': 'LucasArts',
|
|
580
|
+
'67': 'Ocean',
|
|
581
|
+
'69': 'Electronic Arts',
|
|
582
|
+
'70': 'Infogrames',
|
|
583
|
+
'71': 'Interplay',
|
|
584
|
+
'72': 'Broderbund',
|
|
585
|
+
'73': 'sculptured',
|
|
586
|
+
'75': 'sci',
|
|
587
|
+
'78': 'THQ',
|
|
588
|
+
'79': 'Accolade',
|
|
589
|
+
'80': 'misawa',
|
|
590
|
+
'83': 'lozc',
|
|
591
|
+
'86': 'Tokuma Shoten',
|
|
592
|
+
'87': 'Tsukuda Original',
|
|
593
|
+
'91': 'Chunsoft',
|
|
594
|
+
'92': 'Video system',
|
|
595
|
+
'93': 'Ocean/Acclaim',
|
|
596
|
+
'95': 'Varie',
|
|
597
|
+
'96': 'Yonezawa/s\'pal',
|
|
598
|
+
'97': 'Kaneko',
|
|
599
|
+
'99': 'Pack in soft',
|
|
600
|
+
'A4': 'Konami (Yu-Gi-Oh!)',
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Extract metadata from Game Boy ROM header
|
|
605
|
+
*/
|
|
606
|
+
const extractGbMetadata = (data: Buffer): RomMetadata => {
|
|
607
|
+
const metadata: RomMetadata = {};
|
|
608
|
+
|
|
609
|
+
// Need at least 0x150 bytes for full header
|
|
610
|
+
if (data.length < GB_MIN_HEADER_SIZE) {return metadata;}
|
|
611
|
+
|
|
612
|
+
// Title is at 0x134-0x143
|
|
613
|
+
try {
|
|
614
|
+
const titleBytes = data.slice(GB_TITLE_START, GB_TITLE_END);
|
|
615
|
+
const nullIndex = titleBytes.indexOf(0);
|
|
616
|
+
const title = titleBytes.slice(0, nullIndex > 0 ? nullIndex : titleBytes.length).toString('ascii');
|
|
617
|
+
if (title && /^[\x20-\x7E]+$/.test(title)) {
|
|
618
|
+
metadata.title = normalizeTitle(title);
|
|
619
|
+
}
|
|
620
|
+
} catch { /* skip field */ }
|
|
621
|
+
|
|
622
|
+
// SGB flag at 0x146
|
|
623
|
+
try {
|
|
624
|
+
const sgbFlag = data[GB_SGB_FLAG];
|
|
625
|
+
metadata.sgbSupport = sgbFlag === GB_SGB_SUPPORT_VALUE;
|
|
626
|
+
} catch { /* skip field */ }
|
|
627
|
+
|
|
628
|
+
// Cartridge type at 0x147
|
|
629
|
+
try {
|
|
630
|
+
const cartType = data[GB_CART_TYPE];
|
|
631
|
+
metadata.cartridgeType = GB_CARTRIDGE_TYPES[cartType] ?? `Unknown (0x${cartType.toString(HEX_RADIX)})`;
|
|
632
|
+
|
|
633
|
+
// Check for battery based on cartridge type
|
|
634
|
+
metadata.hasBattery = GB_BATTERY_CART_TYPES.some(t => t === cartType);
|
|
635
|
+
} catch { /* skip field */ }
|
|
636
|
+
|
|
637
|
+
// ROM size at 0x148
|
|
638
|
+
try {
|
|
639
|
+
const romSizeCode = data[GB_ROM_SIZE];
|
|
640
|
+
if (GB_ROM_SIZES[romSizeCode]) {
|
|
641
|
+
metadata.prgSize = GB_ROM_SIZES[romSizeCode];
|
|
642
|
+
}
|
|
643
|
+
} catch { /* skip field */ }
|
|
644
|
+
|
|
645
|
+
// RAM size at 0x149
|
|
646
|
+
try {
|
|
647
|
+
const ramSizeCode = data[GB_RAM_SIZE];
|
|
648
|
+
if (GB_RAM_SIZES[ramSizeCode]) {
|
|
649
|
+
metadata.ramSize = GB_RAM_SIZES[ramSizeCode];
|
|
650
|
+
}
|
|
651
|
+
} catch { /* skip field */ }
|
|
652
|
+
|
|
653
|
+
// CGB flag at 0x143
|
|
654
|
+
try {
|
|
655
|
+
const cgbFlag = data[GB_CGB_FLAG];
|
|
656
|
+
if (cgbFlag === GB_CGB_ENHANCED_VALUE) {
|
|
657
|
+
metadata.region = 'CGB Enhanced';
|
|
658
|
+
} else if (cgbFlag === GB_CGB_ONLY_VALUE) {
|
|
659
|
+
metadata.region = 'CGB Only';
|
|
660
|
+
} else {
|
|
661
|
+
metadata.region = 'DMG';
|
|
662
|
+
}
|
|
663
|
+
} catch { /* skip field */ }
|
|
664
|
+
|
|
665
|
+
// Publisher/licensee code
|
|
666
|
+
try {
|
|
667
|
+
const oldLicensee = data[GB_OLD_LICENSEE];
|
|
668
|
+
if (oldLicensee === GB_NEW_LICENSEE_MARKER) {
|
|
669
|
+
// New licensee code at 0x144-0x145
|
|
670
|
+
const newCode = String.fromCharCode(data[GB_NEW_LICENSEE_START], data[GB_NEW_LICENSEE_END]);
|
|
671
|
+
if (/^[\x20-\x7E]{2}$/.test(newCode)) {
|
|
672
|
+
metadata.publisher = newLicenseeCodes[newCode] ?? `Unknown (${newCode})`;
|
|
673
|
+
}
|
|
674
|
+
} else if (oldLicenseeCodes[oldLicensee]) {
|
|
675
|
+
metadata.publisher = oldLicenseeCodes[oldLicensee];
|
|
676
|
+
}
|
|
677
|
+
} catch { /* skip field */ }
|
|
678
|
+
|
|
679
|
+
// Header checksum at 0x14D
|
|
680
|
+
try {
|
|
681
|
+
let checksum = 0;
|
|
682
|
+
for (let i = GB_CHECKSUM_START; i <= GB_CHECKSUM_END; i++) {
|
|
683
|
+
checksum = (checksum - data[i] - 1) & CHECKSUM_BYTE_MASK;
|
|
684
|
+
}
|
|
685
|
+
metadata.checksumValid = checksum === data[GB_HEADER_CHECKSUM];
|
|
686
|
+
} catch { /* skip field */ }
|
|
687
|
+
|
|
688
|
+
return metadata;
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* SNES publisher codes
|
|
693
|
+
*/
|
|
694
|
+
const snesPublishers: Record<number, string> = {
|
|
695
|
+
0x01: 'Nintendo',
|
|
696
|
+
0x02: 'Ajinomoto',
|
|
697
|
+
0x03: 'Imagineer-Zoom',
|
|
698
|
+
0x04: 'Chris Gray Enterprises',
|
|
699
|
+
0x05: 'Zamuse',
|
|
700
|
+
0x06: 'Falcom',
|
|
701
|
+
0x08: 'Capcom',
|
|
702
|
+
0x09: 'Hot B',
|
|
703
|
+
0x0A: 'Jaleco',
|
|
704
|
+
0x0B: 'Coconuts',
|
|
705
|
+
0x0C: 'Rage Software',
|
|
706
|
+
0x0E: 'Technos',
|
|
707
|
+
0x0F: 'Mebio Software',
|
|
708
|
+
0x12: 'Gremlin Graphics',
|
|
709
|
+
0x13: 'Electronic Arts',
|
|
710
|
+
0x15: 'COBRA Team',
|
|
711
|
+
0x16: 'Human/Field',
|
|
712
|
+
0x17: 'KOEI',
|
|
713
|
+
0x18: 'Hudson Soft',
|
|
714
|
+
0x1A: 'Yanoman',
|
|
715
|
+
0x1C: 'Tecmo',
|
|
716
|
+
0x1E: 'Open System',
|
|
717
|
+
0x1F: 'Virgin Games',
|
|
718
|
+
0x20: 'KSS',
|
|
719
|
+
0x21: 'Sunsoft',
|
|
720
|
+
0x22: 'POW',
|
|
721
|
+
0x23: 'Micro World',
|
|
722
|
+
0x25: 'Enix',
|
|
723
|
+
0x26: 'Loriciel/Electro Brain',
|
|
724
|
+
0x27: 'Kemco',
|
|
725
|
+
0x28: 'Seta Co., Ltd.',
|
|
726
|
+
0x29: 'Culture Brain',
|
|
727
|
+
0x2A: 'Irem Japan',
|
|
728
|
+
0x2C: 'Pal Soft',
|
|
729
|
+
0x2D: 'Visit Co., Ltd.',
|
|
730
|
+
0x2E: 'INTEC Inc.',
|
|
731
|
+
0x2F: 'System Sacom Corp.',
|
|
732
|
+
0x30: 'Viacom New Media',
|
|
733
|
+
0x31: 'Carrozzeria',
|
|
734
|
+
0x32: 'Dynamic',
|
|
735
|
+
0x33: 'Nintendo',
|
|
736
|
+
0x34: 'Magifact',
|
|
737
|
+
0x35: 'Hect',
|
|
738
|
+
0x3C: 'Empire Interactive',
|
|
739
|
+
0x3E: 'Gremlin Interactive',
|
|
740
|
+
0x41: 'Ubisoft',
|
|
741
|
+
0x42: 'Atlus',
|
|
742
|
+
0x44: 'Playmates Interactive',
|
|
743
|
+
0x46: 'BMG Interactive',
|
|
744
|
+
0x47: 'Atlas',
|
|
745
|
+
0x48: 'Sony Music Entertainment',
|
|
746
|
+
0x4B: 'Bullet-Proof Software',
|
|
747
|
+
0x4C: 'Vic Tokai',
|
|
748
|
+
0x4E: 'Character Soft',
|
|
749
|
+
0x4F: 'I\'Max',
|
|
750
|
+
0x50: 'Takara',
|
|
751
|
+
0x51: 'CHUN Soft',
|
|
752
|
+
0x52: 'Video System',
|
|
753
|
+
0x53: 'BEC',
|
|
754
|
+
0x55: 'Varie',
|
|
755
|
+
0x56: 'Yonezawa/S\'Pal Corp.',
|
|
756
|
+
0x57: 'Kaneko',
|
|
757
|
+
0x5A: 'Nihon Bussan/Nichibutsu',
|
|
758
|
+
0x5B: 'TECMO',
|
|
759
|
+
0x5C: 'Imagineer Co., Ltd.',
|
|
760
|
+
0x5D: 'Nova',
|
|
761
|
+
0x5E: 'Den\'Z',
|
|
762
|
+
0x5F: 'Bottom Up',
|
|
763
|
+
0x60: 'Titus',
|
|
764
|
+
0x61: 'Virgin Interactive',
|
|
765
|
+
0x62: 'Konami',
|
|
766
|
+
0x64: 'Gametek',
|
|
767
|
+
0x66: 'Hori Electric',
|
|
768
|
+
0x68: 'Telstar Publishing',
|
|
769
|
+
0x69: 'Electronic Arts Victor',
|
|
770
|
+
0x6B: 'Namcot/Namco Ltd.',
|
|
771
|
+
0x6C: 'Media Rings Corp.',
|
|
772
|
+
0x6E: 'ASCII Co./Nexoft',
|
|
773
|
+
0x6F: 'Bandai',
|
|
774
|
+
0x70: 'Enix America',
|
|
775
|
+
0x71: 'Loriciel/Electro Brain',
|
|
776
|
+
0x73: 'Tomy',
|
|
777
|
+
0x75: 'KOEI/Koei America',
|
|
778
|
+
0x77: 'Takara',
|
|
779
|
+
0x79: 'Chunsoft',
|
|
780
|
+
0x7A: 'Video System/McO\'River',
|
|
781
|
+
0x7B: 'Varie',
|
|
782
|
+
0x7D: 'Pack-In-Video',
|
|
783
|
+
0x7E: 'Nichibutsu',
|
|
784
|
+
0x7F: 'TECMO',
|
|
785
|
+
0x80: 'Acclaim Japan',
|
|
786
|
+
0x81: 'ASCII Co.',
|
|
787
|
+
0x82: 'Nexoft',
|
|
788
|
+
0x83: 'Bandai/Banpresto',
|
|
789
|
+
0x85: 'Enix America',
|
|
790
|
+
0x86: 'Halken',
|
|
791
|
+
0x8B: 'Square',
|
|
792
|
+
0x8C: 'Tokuma Shoten',
|
|
793
|
+
0x8E: 'Asmik',
|
|
794
|
+
0x8F: 'Naxat/Kaga Tech',
|
|
795
|
+
0x91: 'Toshiba EMI/Compile',
|
|
796
|
+
0x92: 'Konami',
|
|
797
|
+
0x93: 'Bullet-Proof Software',
|
|
798
|
+
0x95: 'Vic Tokai',
|
|
799
|
+
0x97: 'NCS/Masaya',
|
|
800
|
+
0x98: 'Takara',
|
|
801
|
+
0x99: 'A Wave Inc.',
|
|
802
|
+
0x9A: 'Tectoy',
|
|
803
|
+
0x9B: 'Capcom',
|
|
804
|
+
0x9C: 'Banpresto',
|
|
805
|
+
0x9D: 'Tomy',
|
|
806
|
+
0x9E: 'Acclaim',
|
|
807
|
+
0x9F: 'NCS',
|
|
808
|
+
0xA0: 'Human Entertainment',
|
|
809
|
+
0xA1: 'Altron',
|
|
810
|
+
0xA2: 'Jaleco',
|
|
811
|
+
0xA3: 'Paradisco',
|
|
812
|
+
0xA4: 'Epoch',
|
|
813
|
+
0xA6: 'RCM Group',
|
|
814
|
+
0xA7: 'Athena',
|
|
815
|
+
0xA8: 'Asmik',
|
|
816
|
+
0xA9: 'Natsume',
|
|
817
|
+
0xAA: 'King Records',
|
|
818
|
+
0xAB: 'Atlus',
|
|
819
|
+
0xAC: 'Sony Music',
|
|
820
|
+
0xAE: 'IGS',
|
|
821
|
+
0xB0: 'Acclaim',
|
|
822
|
+
0xB2: 'Bandai',
|
|
823
|
+
0xB4: 'Enix',
|
|
824
|
+
0xB5: 'Athena/Kaze',
|
|
825
|
+
0xB6: 'HAL Laboratory',
|
|
826
|
+
0xB7: 'SNK',
|
|
827
|
+
0xB9: 'Pony Canyon',
|
|
828
|
+
0xBA: 'Culture Brain',
|
|
829
|
+
0xBB: 'Sunsoft',
|
|
830
|
+
0xBD: 'Sony Imagesoft',
|
|
831
|
+
0xBF: 'American Sammy',
|
|
832
|
+
0xC0: 'Taito',
|
|
833
|
+
0xC1: 'Sunsoft/Ask',
|
|
834
|
+
0xC2: 'Kemco',
|
|
835
|
+
0xC3: 'Square',
|
|
836
|
+
0xC4: 'Tokuma Soft',
|
|
837
|
+
0xC5: 'Data East',
|
|
838
|
+
0xC6: 'Tonkin House',
|
|
839
|
+
0xC8: 'Koei',
|
|
840
|
+
0xCA: 'Konami USA',
|
|
841
|
+
0xCB: 'NTVIC/VAP',
|
|
842
|
+
0xCC: 'Use Co., Ltd.',
|
|
843
|
+
0xCD: 'Meldac',
|
|
844
|
+
0xCE: 'Pony Canyon',
|
|
845
|
+
0xCF: 'Angel',
|
|
846
|
+
0xD0: 'Taito',
|
|
847
|
+
0xD2: 'Acclaim',
|
|
848
|
+
0xD3: 'ASCII',
|
|
849
|
+
0xD4: 'BanDai',
|
|
850
|
+
0xD6: 'Enix',
|
|
851
|
+
0xD8: 'HAL Laboratory',
|
|
852
|
+
0xDA: 'Tomy',
|
|
853
|
+
0xDB: 'Yutaka',
|
|
854
|
+
0xDD: 'Hiro',
|
|
855
|
+
0xDE: 'Varie',
|
|
856
|
+
0xDF: 'T&E Soft',
|
|
857
|
+
0xE0: 'Yutaka',
|
|
858
|
+
0xE2: 'UFL',
|
|
859
|
+
0xE3: 'Human',
|
|
860
|
+
0xE4: 'Altus',
|
|
861
|
+
0xE5: 'Epoch',
|
|
862
|
+
0xE7: 'Athena',
|
|
863
|
+
0xE8: 'Asmik',
|
|
864
|
+
0xE9: 'Natsume',
|
|
865
|
+
0xEA: 'King Records',
|
|
866
|
+
0xEB: 'Atlus',
|
|
867
|
+
0xEC: 'Sony Music',
|
|
868
|
+
0xED: 'Psygnosis',
|
|
869
|
+
0xEE: 'IGS',
|
|
870
|
+
0xF0: 'Acclaim/A Wave',
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Extract metadata from SNES ROM header
|
|
875
|
+
*/
|
|
876
|
+
const extractSnesMetadata = (data: Buffer): RomMetadata => {
|
|
877
|
+
const metadata: RomMetadata = {};
|
|
878
|
+
|
|
879
|
+
// Try to find SNES header at common locations
|
|
880
|
+
// LoROM: 0x7FC0, HiROM: 0xFFC0
|
|
881
|
+
// With 512-byte copier header: add 0x200
|
|
882
|
+
const locations = [SNES_LOROM_OFFSET, SNES_HIROM_OFFSET, SNES_LOROM_COPIER_OFFSET, SNES_HIROM_COPIER_OFFSET];
|
|
883
|
+
const HIGH_BYTE_SHIFT = 8;
|
|
884
|
+
|
|
885
|
+
for (const offset of locations) {
|
|
886
|
+
if (offset + SNES_HEADER_MIN_SIZE > data.length) {continue;}
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
// Check for valid checksum complement
|
|
890
|
+
const checksum = data[offset + SNES_CHECKSUM_OFFSET] | (data[offset + SNES_CHECKSUM_HIGH_OFFSET] << HIGH_BYTE_SHIFT);
|
|
891
|
+
const complement = data[offset + SNES_COMPLEMENT_OFFSET] | (data[offset + SNES_COMPLEMENT_HIGH_OFFSET] << HIGH_BYTE_SHIFT);
|
|
892
|
+
|
|
893
|
+
if ((checksum ^ complement) !== SNES_CHECKSUM_XOR) {continue;}
|
|
894
|
+
|
|
895
|
+
// Found valid header - extract each field with individual error handling
|
|
896
|
+
try {
|
|
897
|
+
const titleBytes = data.slice(offset, offset + SNES_TITLE_LENGTH);
|
|
898
|
+
const title = titleBytes.toString('ascii');
|
|
899
|
+
if (title && /^[\x20-\x7E]+$/.test(title)) {
|
|
900
|
+
metadata.title = normalizeTitle(title);
|
|
901
|
+
}
|
|
902
|
+
} catch { /* skip field */ }
|
|
903
|
+
|
|
904
|
+
try {
|
|
905
|
+
// ROM makeup byte
|
|
906
|
+
const makeup = data[offset + SNES_MAKEUP_OFFSET];
|
|
907
|
+
metadata.romType = (makeup & SNES_HIROM_BIT) ? 'HiROM' : 'LoROM';
|
|
908
|
+
|
|
909
|
+
// Check for FastROM
|
|
910
|
+
const fastRom = (makeup & SNES_FASTROM_BIT) !== 0;
|
|
911
|
+
if (fastRom) {
|
|
912
|
+
metadata.romType += ' (FastROM)';
|
|
913
|
+
}
|
|
914
|
+
} catch { /* skip field */ }
|
|
915
|
+
|
|
916
|
+
try {
|
|
917
|
+
// ROM type byte (special chips)
|
|
918
|
+
const romType = data[offset + SNES_ROM_TYPE_OFFSET];
|
|
919
|
+
if (romType !== SNES_ROM_ONLY && romType !== SNES_ROM_RAM && romType !== SNES_ROM_RAM_BATTERY) {
|
|
920
|
+
metadata.specialChip = SNES_CHIP_TYPES[romType] ?? `Unknown (0x${romType.toString(HEX_RADIX)})`;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Check for battery-backed saves
|
|
924
|
+
metadata.hasBattery = SNES_BATTERY_ROM_TYPES.some(t => t === romType);
|
|
925
|
+
} catch { /* skip field */ }
|
|
926
|
+
|
|
927
|
+
try {
|
|
928
|
+
// ROM size
|
|
929
|
+
const sizeCode = data[offset + SNES_ROM_SIZE_OFFSET];
|
|
930
|
+
if (sizeCode > 0 && sizeCode < SNES_SIZE_CODE_MAX) {
|
|
931
|
+
const size = 1 << sizeCode;
|
|
932
|
+
metadata.prgSize = size >= BYTES_PER_KB ? `${size / BYTES_PER_KB} MB` : `${size} KB`;
|
|
933
|
+
}
|
|
934
|
+
} catch { /* skip field */ }
|
|
935
|
+
|
|
936
|
+
try {
|
|
937
|
+
// SRAM size
|
|
938
|
+
const sramCode = data[offset + SNES_SRAM_SIZE_OFFSET];
|
|
939
|
+
if (sramCode > 0 && sramCode < SNES_SIZE_CODE_MAX) {
|
|
940
|
+
const sramSize = 1 << sramCode;
|
|
941
|
+
metadata.ramSize = sramSize >= BYTES_PER_KB ? `${sramSize / BYTES_PER_KB} MB` : `${sramSize} KB`;
|
|
942
|
+
}
|
|
943
|
+
} catch { /* skip field */ }
|
|
944
|
+
|
|
945
|
+
try {
|
|
946
|
+
// Country code
|
|
947
|
+
const country = data[offset + SNES_COUNTRY_OFFSET];
|
|
948
|
+
metadata.region = country < SNES_PAL_THRESHOLD ? 'NTSC' : 'PAL';
|
|
949
|
+
} catch { /* skip field */ }
|
|
950
|
+
|
|
951
|
+
try {
|
|
952
|
+
// Publisher code
|
|
953
|
+
const publisherCode = data[offset + SNES_PUBLISHER_OFFSET];
|
|
954
|
+
if (snesPublishers[publisherCode]) {
|
|
955
|
+
metadata.publisher = snesPublishers[publisherCode];
|
|
956
|
+
}
|
|
957
|
+
} catch { /* skip field */ }
|
|
958
|
+
|
|
959
|
+
// Validate checksum - we already verified the complement
|
|
960
|
+
metadata.checksumValid = true;
|
|
961
|
+
|
|
962
|
+
break;
|
|
963
|
+
} catch {
|
|
964
|
+
// Try next location
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
return metadata;
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Extract metadata from Sega Genesis/Mega Drive ROM header
|
|
974
|
+
*/
|
|
975
|
+
const extractGenesisMetadata = (data: Buffer): RomMetadata => {
|
|
976
|
+
const metadata: RomMetadata = {};
|
|
977
|
+
const COPYRIGHT_MIN_LENGTH = 5;
|
|
978
|
+
|
|
979
|
+
// Genesis header starts at 0x100 for cartridges
|
|
980
|
+
// Check for "SEGA" signature at 0x100
|
|
981
|
+
if (data.length < GENESIS_MIN_HEADER_SIZE) {return metadata;}
|
|
982
|
+
|
|
983
|
+
try {
|
|
984
|
+
const systemType = data.slice(GENESIS_SYSTEM_TYPE_START, GENESIS_SYSTEM_TYPE_END).toString('ascii').trim();
|
|
985
|
+
if (!systemType.startsWith('SEGA')) {
|
|
986
|
+
return metadata;
|
|
987
|
+
}
|
|
988
|
+
} catch {
|
|
989
|
+
return metadata;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Domestic name at 0x120-0x14F (Japanese)
|
|
993
|
+
// Overseas name at 0x150-0x17F (English)
|
|
994
|
+
try {
|
|
995
|
+
const overseasName = data.slice(GENESIS_OVERSEAS_NAME_START, GENESIS_OVERSEAS_NAME_END).toString('ascii');
|
|
996
|
+
if (overseasName && /^[\x20-\x7E]+$/.test(overseasName)) {
|
|
997
|
+
metadata.title = normalizeTitle(overseasName);
|
|
998
|
+
} else {
|
|
999
|
+
// Fall back to domestic name
|
|
1000
|
+
const domesticName = data.slice(GENESIS_DOMESTIC_NAME_START, GENESIS_DOMESTIC_NAME_END).toString('ascii');
|
|
1001
|
+
if (domesticName && /^[\x20-\x7E]+$/.test(domesticName)) {
|
|
1002
|
+
metadata.title = normalizeTitle(domesticName);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
} catch { /* skip field */ }
|
|
1006
|
+
|
|
1007
|
+
// Serial number at 0x180-0x18D
|
|
1008
|
+
try {
|
|
1009
|
+
const serial = data.slice(GENESIS_SERIAL_START, GENESIS_SERIAL_END).toString('ascii').trim();
|
|
1010
|
+
if (serial && /^[\x20-\x7E]+$/.test(serial)) {
|
|
1011
|
+
metadata.serial = serial;
|
|
1012
|
+
}
|
|
1013
|
+
} catch { /* skip field */ }
|
|
1014
|
+
|
|
1015
|
+
// ROM size from header (end address - start address)
|
|
1016
|
+
try {
|
|
1017
|
+
const romStart = data.readUInt32BE(GENESIS_ROM_START_ADDR);
|
|
1018
|
+
const romEnd = data.readUInt32BE(GENESIS_ROM_END_ADDR);
|
|
1019
|
+
if (romEnd > romStart && romEnd < GENESIS_MAX_ROM_SIZE) {
|
|
1020
|
+
const size = (romEnd - romStart + 1);
|
|
1021
|
+
if (size >= BYTES_PER_MB) {
|
|
1022
|
+
metadata.prgSize = `${(size / BYTES_PER_MB).toFixed(1)} MB`;
|
|
1023
|
+
} else if (size > 0) {
|
|
1024
|
+
metadata.prgSize = `${Math.round(size / BYTES_PER_KB)} KB`;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
} catch { /* skip field */ }
|
|
1028
|
+
|
|
1029
|
+
// RAM info at 0x1B4-0x1B8
|
|
1030
|
+
try {
|
|
1031
|
+
const ramStart = data.readUInt32BE(GENESIS_RAM_START_ADDR);
|
|
1032
|
+
const ramEnd = data.readUInt32BE(GENESIS_RAM_END_ADDR);
|
|
1033
|
+
if (ramEnd >= ramStart && ramStart !== GENESIS_INVALID_RAM_MARKER && ramStart < GENESIS_MAX_ROM_SIZE) {
|
|
1034
|
+
const ramSize = ramEnd - ramStart + 1;
|
|
1035
|
+
if (ramSize > 0 && ramSize < GENESIS_MAX_RAM_SIZE) {
|
|
1036
|
+
metadata.ramSize = ramSize >= BYTES_PER_KB ? `${ramSize / BYTES_PER_KB} KB` : `${ramSize} B`;
|
|
1037
|
+
metadata.hasBattery = true; // SRAM usually battery-backed
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
} catch { /* skip field */ }
|
|
1041
|
+
|
|
1042
|
+
// Region code at 0x1F0
|
|
1043
|
+
try {
|
|
1044
|
+
const regionBytes = data.slice(GENESIS_REGION_START, GENESIS_REGION_END).toString('ascii');
|
|
1045
|
+
const regions: string[] = [];
|
|
1046
|
+
if (regionBytes.includes('J')) {regions.push('Japan');}
|
|
1047
|
+
if (regionBytes.includes('U') || regionBytes.includes('4')) {regions.push('USA');}
|
|
1048
|
+
if (regionBytes.includes('E') || regionBytes.includes('A')) {regions.push('Europe');}
|
|
1049
|
+
if (regions.length > 0) {
|
|
1050
|
+
metadata.region = regions.join('/');
|
|
1051
|
+
}
|
|
1052
|
+
} catch { /* skip field */ }
|
|
1053
|
+
|
|
1054
|
+
// I/O support at 0x190-0x19F
|
|
1055
|
+
try {
|
|
1056
|
+
const ioSupport = data.slice(GENESIS_IO_SUPPORT_START, GENESIS_IO_SUPPORT_END).toString('ascii').trim();
|
|
1057
|
+
const devices: string[] = [];
|
|
1058
|
+
if (ioSupport.includes('J')) {devices.push('3-button');}
|
|
1059
|
+
if (ioSupport.includes('6')) {devices.push('6-button');}
|
|
1060
|
+
if (ioSupport.includes('K')) {devices.push('Keyboard');}
|
|
1061
|
+
if (ioSupport.includes('M')) {devices.push('Mouse');}
|
|
1062
|
+
if (ioSupport.includes('T')) {devices.push('Trackball');}
|
|
1063
|
+
if (ioSupport.includes('B')) {devices.push('Justifier');}
|
|
1064
|
+
if (ioSupport.includes('4')) {devices.push('Team Player');}
|
|
1065
|
+
if (devices.length > 0) {
|
|
1066
|
+
metadata.inputDevices = devices.join(', ');
|
|
1067
|
+
}
|
|
1068
|
+
} catch { /* skip field */ }
|
|
1069
|
+
|
|
1070
|
+
// Publisher from copyright string at 0x110-0x11F
|
|
1071
|
+
try {
|
|
1072
|
+
const copyright = data.slice(GENESIS_COPYRIGHT_START, GENESIS_COPYRIGHT_END).toString('ascii').trim();
|
|
1073
|
+
// Extract publisher name - usually after (C) and year
|
|
1074
|
+
const pubMatch = copyright.match(/\(C\)\s*\w+\s+(\d{4})?\s*(.+)/i);
|
|
1075
|
+
if (pubMatch && pubMatch[2]) {
|
|
1076
|
+
const pub = pubMatch[2].trim();
|
|
1077
|
+
if (pub && /^[\x20-\x7E]+$/.test(pub)) {
|
|
1078
|
+
metadata.publisher = pub;
|
|
1079
|
+
}
|
|
1080
|
+
} else if (copyright.length > COPYRIGHT_MIN_LENGTH && /^[\x20-\x7E]+$/.test(copyright)) {
|
|
1081
|
+
metadata.publisher = copyright;
|
|
1082
|
+
}
|
|
1083
|
+
} catch { /* skip field */ }
|
|
1084
|
+
|
|
1085
|
+
// Note: Checksum validation requires full ROM file and is skipped during scanning
|
|
1086
|
+
// for performance. The checksum field at 0x18E can be read but not validated.
|
|
1087
|
+
|
|
1088
|
+
return metadata;
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* GBA maker codes
|
|
1093
|
+
*/
|
|
1094
|
+
const gbaMakerCodes: Record<string, string> = {
|
|
1095
|
+
'01': 'Nintendo',
|
|
1096
|
+
'08': 'Capcom',
|
|
1097
|
+
'13': 'Electronic Arts',
|
|
1098
|
+
'18': 'Hudson Soft',
|
|
1099
|
+
'20': 'Destination Software',
|
|
1100
|
+
'28': 'Kemco Japan',
|
|
1101
|
+
'31': 'Nintendo',
|
|
1102
|
+
'32': 'Bandai',
|
|
1103
|
+
'34': 'Konami',
|
|
1104
|
+
'37': 'Taito',
|
|
1105
|
+
'41': 'Ubisoft',
|
|
1106
|
+
'42': 'Atlus',
|
|
1107
|
+
'4F': 'Eidos',
|
|
1108
|
+
'52': 'Activision',
|
|
1109
|
+
'54': 'Take-Two Interactive',
|
|
1110
|
+
'5D': 'Midway',
|
|
1111
|
+
'5G': 'Majesco',
|
|
1112
|
+
'64': 'LucasArts',
|
|
1113
|
+
'69': 'Electronic Arts',
|
|
1114
|
+
'6E': 'Elite Systems',
|
|
1115
|
+
'70': 'Infogrames',
|
|
1116
|
+
'78': 'THQ',
|
|
1117
|
+
'7D': 'Sierra',
|
|
1118
|
+
'7F': 'Kemco',
|
|
1119
|
+
'8P': 'Sega',
|
|
1120
|
+
'99': 'Pack-In-Video',
|
|
1121
|
+
'A4': 'Konami',
|
|
1122
|
+
'AF': 'Namco',
|
|
1123
|
+
'B2': 'Bandai',
|
|
1124
|
+
'C3': 'Square Enix',
|
|
1125
|
+
'EB': 'Atlus',
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Extract metadata from GBA ROM header
|
|
1130
|
+
*/
|
|
1131
|
+
const extractGbaMetadata = (data: Buffer): RomMetadata => {
|
|
1132
|
+
const metadata: RomMetadata = {};
|
|
1133
|
+
|
|
1134
|
+
// GBA header starts at 0x00
|
|
1135
|
+
// Title at 0xA0-0xAB (12 chars)
|
|
1136
|
+
if (data.length < GBA_MIN_HEADER_SIZE) {return metadata;}
|
|
1137
|
+
|
|
1138
|
+
// Title
|
|
1139
|
+
try {
|
|
1140
|
+
const titleBytes = data.slice(GBA_TITLE_START, GBA_TITLE_END);
|
|
1141
|
+
const nullIndex = titleBytes.indexOf(0);
|
|
1142
|
+
const title = titleBytes.slice(0, nullIndex > 0 ? nullIndex : titleBytes.length).toString('ascii');
|
|
1143
|
+
if (title && /^[\x20-\x7E]+$/.test(title)) {
|
|
1144
|
+
metadata.title = normalizeTitle(title);
|
|
1145
|
+
}
|
|
1146
|
+
} catch { /* skip field */ }
|
|
1147
|
+
|
|
1148
|
+
// Game code at 0xAC-0xAF (4 chars)
|
|
1149
|
+
try {
|
|
1150
|
+
const gameCode = data.slice(GBA_GAME_CODE_START, GBA_GAME_CODE_END).toString('ascii');
|
|
1151
|
+
const GAME_CODE_REGEX = new RegExp(`^[A-Z0-9]{${GBA_GAME_CODE_LENGTH}}$`);
|
|
1152
|
+
if (gameCode && GAME_CODE_REGEX.test(gameCode)) {
|
|
1153
|
+
metadata.serial = gameCode;
|
|
1154
|
+
|
|
1155
|
+
// Third character often indicates region
|
|
1156
|
+
const regionChar = gameCode[GBA_REGION_CHAR_INDEX];
|
|
1157
|
+
switch (regionChar) {
|
|
1158
|
+
case 'J': metadata.region = 'Japan'; break;
|
|
1159
|
+
case 'E': metadata.region = 'USA'; break;
|
|
1160
|
+
case 'P': metadata.region = 'Europe'; break;
|
|
1161
|
+
case 'D': metadata.region = 'Germany'; break;
|
|
1162
|
+
case 'F': metadata.region = 'France'; break;
|
|
1163
|
+
case 'S': metadata.region = 'Spain'; break;
|
|
1164
|
+
case 'I': metadata.region = 'Italy'; break;
|
|
1165
|
+
default: metadata.region = 'Unknown';
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
} catch { /* skip field */ }
|
|
1169
|
+
|
|
1170
|
+
// Maker code at 0xB0-0xB1 (2 chars)
|
|
1171
|
+
try {
|
|
1172
|
+
const makerCode = data.slice(GBA_MAKER_CODE_START, GBA_MAKER_CODE_END).toString('ascii');
|
|
1173
|
+
const MAKER_CODE_REGEX = new RegExp(`^[A-Z0-9]{${GBA_MAKER_CODE_LENGTH}}$`);
|
|
1174
|
+
if (makerCode && MAKER_CODE_REGEX.test(makerCode)) {
|
|
1175
|
+
if (gbaMakerCodes[makerCode]) {
|
|
1176
|
+
metadata.publisher = gbaMakerCodes[makerCode];
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
} catch { /* skip field */ }
|
|
1180
|
+
|
|
1181
|
+
// Main unit code at 0xB3 (should be 0x96 for GBA)
|
|
1182
|
+
// Don't bail on invalid unit code - just skip checksum validation
|
|
1183
|
+
let validHeader = false;
|
|
1184
|
+
try {
|
|
1185
|
+
const unitCode = data[GBA_UNIT_CODE];
|
|
1186
|
+
validHeader = unitCode === GBA_VALID_UNIT_CODE;
|
|
1187
|
+
} catch { /* skip field */ }
|
|
1188
|
+
|
|
1189
|
+
// Header checksum at 0xBD
|
|
1190
|
+
if (validHeader) {
|
|
1191
|
+
try {
|
|
1192
|
+
let checksum = 0;
|
|
1193
|
+
for (let i = GBA_CHECKSUM_START; i < GBA_CHECKSUM_END; i++) {
|
|
1194
|
+
checksum = (checksum - data[i]) & CHECKSUM_BYTE_MASK;
|
|
1195
|
+
}
|
|
1196
|
+
checksum = (checksum - GBA_CHECKSUM_ADJUSTMENT) & CHECKSUM_BYTE_MASK;
|
|
1197
|
+
metadata.checksumValid = checksum === data[GBA_HEADER_CHECKSUM];
|
|
1198
|
+
} catch { /* skip field */ }
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
return metadata;
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Extract metadata from a pre-read ROM header buffer.
|
|
1206
|
+
*/
|
|
1207
|
+
const extractMetadataFromBuffer = (headerData: Buffer, extension: string): RomMetadata => {
|
|
1208
|
+
switch (extension) {
|
|
1209
|
+
case '.nes':
|
|
1210
|
+
return extractNesMetadata(headerData);
|
|
1211
|
+
case '.gb':
|
|
1212
|
+
case '.gbc':
|
|
1213
|
+
return extractGbMetadata(headerData);
|
|
1214
|
+
case '.sfc':
|
|
1215
|
+
case '.smc':
|
|
1216
|
+
return extractSnesMetadata(headerData);
|
|
1217
|
+
case '.md':
|
|
1218
|
+
case '.smd':
|
|
1219
|
+
case '.gen':
|
|
1220
|
+
case '.bin':
|
|
1221
|
+
return extractGenesisMetadata(headerData);
|
|
1222
|
+
case '.gba':
|
|
1223
|
+
return extractGbaMetadata(headerData);
|
|
1224
|
+
default:
|
|
1225
|
+
return {};
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Extract metadata from ROM file based on extension.
|
|
1231
|
+
* Uses smart header sizing to read only what's needed for each format.
|
|
1232
|
+
*/
|
|
1233
|
+
const extractMetadata = (path: string, extension: string): RomMetadata => {
|
|
1234
|
+
try {
|
|
1235
|
+
const fd = openSync(path, 'r');
|
|
1236
|
+
const headerSize = getRequiredHeaderSize(extension);
|
|
1237
|
+
const buffer = Buffer.alloc(headerSize);
|
|
1238
|
+
let bytesRead: number;
|
|
1239
|
+
try {
|
|
1240
|
+
bytesRead = readSync(fd, buffer, 0, headerSize, 0);
|
|
1241
|
+
} finally {
|
|
1242
|
+
closeSync(fd);
|
|
1243
|
+
}
|
|
1244
|
+
return extractMetadataFromBuffer(buffer.subarray(0, bytesRead), extension);
|
|
1245
|
+
} catch {
|
|
1246
|
+
return {};
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* System definitions with their file extensions
|
|
1252
|
+
*/
|
|
1253
|
+
const systemExtensions: Array<{ name: string; extensions: string[] }> = [
|
|
1254
|
+
{ name: 'Nintendo Entertainment System', extensions: ['.nes'] },
|
|
1255
|
+
{ name: 'Game Boy', extensions: ['.gb'] },
|
|
1256
|
+
{ name: 'Game Boy Color', extensions: ['.gbc'] },
|
|
1257
|
+
{ name: 'Super Nintendo', extensions: ['.sfc', '.smc'] },
|
|
1258
|
+
{ name: 'Sega Master System', extensions: ['.sms'] },
|
|
1259
|
+
{ name: 'Sega Game Gear', extensions: ['.gg'] },
|
|
1260
|
+
{ name: 'Sega Genesis', extensions: ['.md', '.smd', '.gen', '.bin'] },
|
|
1261
|
+
{ name: 'Sega 32X', extensions: ['.32x'] },
|
|
1262
|
+
{ name: 'PC Engine', extensions: ['.pce'] },
|
|
1263
|
+
{ name: 'Game Boy Advance', extensions: ['.gba'] },
|
|
1264
|
+
{ name: 'Nintendo 64', extensions: ['.n64', '.z64', '.v64'] },
|
|
1265
|
+
{ name: 'Nintendo DS', extensions: ['.nds'] },
|
|
1266
|
+
{ name: 'Atari 2600', extensions: ['.a26'] },
|
|
1267
|
+
{ name: 'Atari 7800', extensions: ['.a78'] },
|
|
1268
|
+
{ name: 'Atari Lynx', extensions: ['.lnx'] },
|
|
1269
|
+
{ name: 'Neo Geo Pocket', extensions: ['.ngp'] },
|
|
1270
|
+
{ name: 'Neo Geo Pocket Color', extensions: ['.ngc'] },
|
|
1271
|
+
{ name: 'WonderSwan', extensions: ['.ws'] },
|
|
1272
|
+
{ name: 'WonderSwan Color', extensions: ['.wsc'] },
|
|
1273
|
+
{ name: 'Virtual Boy', extensions: ['.vb'] },
|
|
1274
|
+
{ name: 'Vectrex', extensions: ['.vec'] },
|
|
1275
|
+
{ name: 'ColecoVision', extensions: ['.col'] },
|
|
1276
|
+
{ name: 'Intellivision', extensions: ['.int'] },
|
|
1277
|
+
];
|
|
1278
|
+
|
|
1279
|
+
// Build lookup map from extensions to system names
|
|
1280
|
+
const extensionToSystem = new Map(
|
|
1281
|
+
flatMap(systemExtensions, (system) =>
|
|
1282
|
+
system.extensions.map((ext) => [ext, system.name] as const)
|
|
1283
|
+
)
|
|
1284
|
+
);
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Get system name from extension
|
|
1288
|
+
*/
|
|
1289
|
+
const getSystemName = (extension: string, fallback: string): string => extensionToSystem.get(extension) ?? fallback;
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Result of loading a thumbnail
|
|
1294
|
+
*/
|
|
1295
|
+
export interface ThumbnailResult {
|
|
1296
|
+
/** Base64-encoded PNG data */
|
|
1297
|
+
data: string;
|
|
1298
|
+
/** Full path to the thumbnail file */
|
|
1299
|
+
path: string;
|
|
1300
|
+
/** Type of thumbnail loaded */
|
|
1301
|
+
type: ThumbnailType;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Load a specific type of thumbnail PNG for a ROM if it exists.
|
|
1306
|
+
*
|
|
1307
|
+
* RetroArch supports three thumbnail types:
|
|
1308
|
+
* - boxart: Box art / cover images (Named_Boxarts/)
|
|
1309
|
+
* - snap: In-game screenshots (Named_Snaps/)
|
|
1310
|
+
* - title: Title screen images (Named_Titles/)
|
|
1311
|
+
*
|
|
1312
|
+
* @param rom RomInfo object containing extension, systemId, and metadata
|
|
1313
|
+
* @param type Thumbnail type to load (default: 'snap')
|
|
1314
|
+
* @returns Thumbnail data and path, or undefined if thumbnail doesn't exist
|
|
1315
|
+
*/
|
|
1316
|
+
export const loadThumbnail = (rom: RomInfo, type: ThumbnailType = 'snap'): ThumbnailResult | undefined => {
|
|
1317
|
+
try {
|
|
1318
|
+
// Get system name in RetroArch format (must match how thumbnails are saved)
|
|
1319
|
+
const systemName = getPlaylistSystemName(rom.extension, rom.systemId);
|
|
1320
|
+
|
|
1321
|
+
// Build list of names to try (in priority order):
|
|
1322
|
+
// 1. Playlist label (if available) - user-friendly name from playlist
|
|
1323
|
+
// 2. ROM filename without extension - fallback for flexible matching
|
|
1324
|
+
const namesToTry: string[] = [];
|
|
1325
|
+
|
|
1326
|
+
if (rom.label) {
|
|
1327
|
+
namesToTry.push(rom.label);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Always add filename without extension as fallback
|
|
1331
|
+
const filenameWithoutExt = rom.filename.replace(/\.[^.]+$/, '');
|
|
1332
|
+
if (filenameWithoutExt !== rom.label) {
|
|
1333
|
+
namesToTry.push(filenameWithoutExt);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Try each name in order (getThumbnailPath handles special character sanitization)
|
|
1337
|
+
for (const name of namesToTry) {
|
|
1338
|
+
const thumbnailPath = getThumbnailPath(systemName, name, type);
|
|
1339
|
+
|
|
1340
|
+
if (existsSync(thumbnailPath)) {
|
|
1341
|
+
const pngData = readFileSync(thumbnailPath);
|
|
1342
|
+
return {
|
|
1343
|
+
data: pngData.toString('base64'),
|
|
1344
|
+
path: thumbnailPath,
|
|
1345
|
+
type,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
return undefined;
|
|
1351
|
+
} catch {
|
|
1352
|
+
return undefined;
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Load the first available thumbnail for a ROM, trying types in priority order.
|
|
1358
|
+
* Priority: snap (in-game) > title > boxart
|
|
1359
|
+
*
|
|
1360
|
+
* @param rom RomInfo object containing extension, systemId, and metadata
|
|
1361
|
+
* @returns Thumbnail data, path, and type, or undefined if none exist
|
|
1362
|
+
*/
|
|
1363
|
+
export const loadAnyThumbnail = (rom: RomInfo): ThumbnailResult | undefined => {
|
|
1364
|
+
for (const type of THUMBNAIL_TYPES) {
|
|
1365
|
+
const result = loadThumbnail(rom, type);
|
|
1366
|
+
if (result) {
|
|
1367
|
+
return result;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return undefined;
|
|
1371
|
+
};
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Check if a save state file exists for a ROM and get its modification time.
|
|
1375
|
+
* Uses the SaveStateService from the service provider.
|
|
1376
|
+
*/
|
|
1377
|
+
const checkForSaveState = (romPath: string): { exists: boolean; savedAt?: Date } => {
|
|
1378
|
+
const result = getSaveStateService().checkExists(romPath);
|
|
1379
|
+
return { exists: result.exists, savedAt: result.date };
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Check if a battery save (.srm) file exists for a ROM and get its modified date.
|
|
1384
|
+
* Uses the BatterySaveService from the service provider.
|
|
1385
|
+
*/
|
|
1386
|
+
const checkForBatterySave = (romPath: string): { exists: boolean; modifiedAt?: Date } => {
|
|
1387
|
+
const result = getBatterySaveService().checkExists(romPath);
|
|
1388
|
+
return { exists: result.exists, modifiedAt: result.date };
|
|
1389
|
+
};
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Scan a directory for ROM files.
|
|
1393
|
+
* Automatically uses CRC cache from existing playlists to avoid recalculation.
|
|
1394
|
+
* @param dirPath Directory to scan
|
|
1395
|
+
* @param maxDepth Maximum depth to scan (0 = only dirPath, 1 = dirPath + immediate subdirs, -1 = unlimited)
|
|
1396
|
+
*/
|
|
1397
|
+
export const scanDirectory = (
|
|
1398
|
+
dirPath: string,
|
|
1399
|
+
maxDepth: number = 1
|
|
1400
|
+
): RomInfo[] => {
|
|
1401
|
+
const roms: RomInfo[] = [];
|
|
1402
|
+
const supportedExtensions = new Set(getSupportedExtensions());
|
|
1403
|
+
const crcCache = buildCrcCache();
|
|
1404
|
+
|
|
1405
|
+
const scan = (currentPath: string, currentDepth: number): void => {
|
|
1406
|
+
try {
|
|
1407
|
+
const entries = readdirSync(currentPath);
|
|
1408
|
+
|
|
1409
|
+
for (const entry of entries) {
|
|
1410
|
+
const fullPath = join(currentPath, entry);
|
|
1411
|
+
|
|
1412
|
+
try {
|
|
1413
|
+
const stats = statSync(fullPath);
|
|
1414
|
+
|
|
1415
|
+
if (stats.isDirectory() && (maxDepth === -1 || currentDepth < maxDepth)) {
|
|
1416
|
+
scan(fullPath, currentDepth + 1);
|
|
1417
|
+
} else if (stats.isFile()) {
|
|
1418
|
+
const ext = extname(entry).toLowerCase();
|
|
1419
|
+
|
|
1420
|
+
if (supportedExtensions.has(ext)) {
|
|
1421
|
+
// Read file header once for both binary check and metadata extraction
|
|
1422
|
+
// Uses smart sizing based on format to minimize I/O
|
|
1423
|
+
const headerBuffer = readRomHeader(fullPath, stats.size, ext);
|
|
1424
|
+
if (!headerBuffer) {
|
|
1425
|
+
continue;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Quick sanity check: ensure file is plausibly a binary ROM
|
|
1429
|
+
// This filters out text files that happen to have ROM extensions (e.g., .md markdown files)
|
|
1430
|
+
if (!isBinaryFromBuffer(headerBuffer)) {
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
const matchingCores = findMatchingCoresByExtension(ext);
|
|
1435
|
+
|
|
1436
|
+
if (matchingCores.length > 0) {
|
|
1437
|
+
const primaryCore = matchingCores[0];
|
|
1438
|
+
const systemInfo = primaryCore.factory.getSystemInfo();
|
|
1439
|
+
const saveStateInfo = checkForSaveState(fullPath);
|
|
1440
|
+
const batterySaveInfo = checkForBatterySave(fullPath);
|
|
1441
|
+
|
|
1442
|
+
// Use cached CRC32 if available, otherwise calculate
|
|
1443
|
+
const normalizedPath = normalizePath(fullPath);
|
|
1444
|
+
const cachedCrc = crcCache.get(normalizedPath);
|
|
1445
|
+
const crc32 = cachedCrc && cachedCrc !== 'DETECT'
|
|
1446
|
+
? cachedCrc
|
|
1447
|
+
: calculateFileCrc32(fullPath);
|
|
1448
|
+
|
|
1449
|
+
roms.push({
|
|
1450
|
+
path: fullPath,
|
|
1451
|
+
filename: basename(entry),
|
|
1452
|
+
extension: ext,
|
|
1453
|
+
size: stats.size,
|
|
1454
|
+
sizeFormatted: formatSize(stats.size),
|
|
1455
|
+
modified: stats.mtime,
|
|
1456
|
+
system: getSystemName(ext, systemInfo.name),
|
|
1457
|
+
systemId: primaryCore.id,
|
|
1458
|
+
coreCount: matchingCores.length,
|
|
1459
|
+
coreIds: matchingCores.map(c => c.id),
|
|
1460
|
+
metadata: extractMetadataFromBuffer(headerBuffer, ext),
|
|
1461
|
+
hasSaveState: saveStateInfo.exists,
|
|
1462
|
+
saveStateDate: saveStateInfo.savedAt,
|
|
1463
|
+
// Note: screenshot and frameCount are loaded lazily when ROM is selected
|
|
1464
|
+
hasBatterySave: batterySaveInfo.exists,
|
|
1465
|
+
batterySaveDate: batterySaveInfo.modifiedAt,
|
|
1466
|
+
crc32,
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
} catch {
|
|
1472
|
+
// Skip files we can't read
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
} catch {
|
|
1476
|
+
// Skip directories we can't read
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
|
|
1480
|
+
scan(dirPath, 0);
|
|
1481
|
+
sortRoms(roms);
|
|
1482
|
+
|
|
1483
|
+
return roms;
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Group ROMs by system
|
|
1488
|
+
*/
|
|
1489
|
+
export const groupBySystem = (roms: RomInfo[]): Map<string, RomInfo[]> =>
|
|
1490
|
+
new Map(Object.entries(groupBy(roms, rom => rom.system)));
|
|
1491
|
+
|
|
1492
|
+
/**
|
|
1493
|
+
* Result of validating a ROM file
|
|
1494
|
+
*/
|
|
1495
|
+
export type ValidateRomResult =
|
|
1496
|
+
| { valid: true; rom: RomInfo }
|
|
1497
|
+
| { valid: false; error: 'not_found' | 'not_file' | 'invalid_rom' | 'no_core'; message: string };
|
|
1498
|
+
|
|
1499
|
+
/**
|
|
1500
|
+
* Validate a single ROM file and return its info if valid
|
|
1501
|
+
*
|
|
1502
|
+
* @param filePath Path to the ROM file
|
|
1503
|
+
* @returns Either a valid RomInfo or an error with reason
|
|
1504
|
+
*/
|
|
1505
|
+
export const validateRomFile = (filePath: string): ValidateRomResult => {
|
|
1506
|
+
// Check if file exists
|
|
1507
|
+
if (!existsSync(filePath)) {
|
|
1508
|
+
return { valid: false, error: 'not_found', message: 'File does not exist' };
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// Check if it's a file (not a directory)
|
|
1512
|
+
let stats: ReturnType<typeof statSync>;
|
|
1513
|
+
try {
|
|
1514
|
+
stats = statSync(filePath);
|
|
1515
|
+
if (!stats.isFile()) {
|
|
1516
|
+
return { valid: false, error: 'not_file', message: 'Path is not a file' };
|
|
1517
|
+
}
|
|
1518
|
+
} catch {
|
|
1519
|
+
return { valid: false, error: 'not_found', message: 'Cannot access file' };
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const ext = extname(filePath).toLowerCase();
|
|
1523
|
+
const filename = basename(filePath);
|
|
1524
|
+
|
|
1525
|
+
// Check if extension is supported by any core
|
|
1526
|
+
const supportedExtensions = new Set(getSupportedExtensions());
|
|
1527
|
+
if (!supportedExtensions.has(ext)) {
|
|
1528
|
+
return { valid: false, error: 'no_core', message: `No core installed for ${ext} files` };
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// Read file header once for both binary check and metadata extraction
|
|
1532
|
+
// Uses smart sizing based on format to minimize I/O
|
|
1533
|
+
const headerBuffer = readRomHeader(filePath, stats.size, ext);
|
|
1534
|
+
if (!headerBuffer) {
|
|
1535
|
+
return { valid: false, error: 'invalid_rom', message: 'File is too small to be a valid ROM' };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Check if file is plausibly a binary ROM (not a text file)
|
|
1539
|
+
if (!isBinaryFromBuffer(headerBuffer)) {
|
|
1540
|
+
return { valid: false, error: 'invalid_rom', message: 'File does not appear to be a valid ROM' };
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// Check for matching cores
|
|
1544
|
+
const matchingCores = findMatchingCoresByExtension(ext);
|
|
1545
|
+
if (matchingCores.length === 0) {
|
|
1546
|
+
return { valid: false, error: 'no_core', message: `No core installed for ${ext} files` };
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// Build RomInfo object
|
|
1550
|
+
const primaryCore = matchingCores[0];
|
|
1551
|
+
const systemInfo = primaryCore.factory.getSystemInfo();
|
|
1552
|
+
const saveStateInfo = checkForSaveState(filePath);
|
|
1553
|
+
const batterySaveInfo = checkForBatterySave(filePath);
|
|
1554
|
+
const crc32 = calculateFileCrc32(filePath);
|
|
1555
|
+
|
|
1556
|
+
const rom: RomInfo = {
|
|
1557
|
+
path: filePath,
|
|
1558
|
+
filename: filename,
|
|
1559
|
+
extension: ext,
|
|
1560
|
+
size: stats.size,
|
|
1561
|
+
sizeFormatted: formatSize(stats.size),
|
|
1562
|
+
modified: stats.mtime,
|
|
1563
|
+
system: getSystemName(ext, systemInfo.name),
|
|
1564
|
+
systemId: primaryCore.id,
|
|
1565
|
+
coreCount: matchingCores.length,
|
|
1566
|
+
coreIds: matchingCores.map(c => c.id),
|
|
1567
|
+
metadata: extractMetadataFromBuffer(headerBuffer, ext),
|
|
1568
|
+
hasSaveState: saveStateInfo.exists,
|
|
1569
|
+
saveStateDate: saveStateInfo.savedAt,
|
|
1570
|
+
// Note: screenshot and frameCount are loaded lazily when ROM is selected
|
|
1571
|
+
hasBatterySave: batterySaveInfo.exists,
|
|
1572
|
+
batterySaveDate: batterySaveInfo.modifiedAt,
|
|
1573
|
+
crc32,
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
return { valid: true, rom };
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Extract the title from a ROM file.
|
|
1581
|
+
* Returns the embedded title if available, otherwise returns undefined.
|
|
1582
|
+
* @param romPath Full path to the ROM file
|
|
1583
|
+
*/
|
|
1584
|
+
export const getRomTitle = (romPath: string): string | undefined => {
|
|
1585
|
+
try {
|
|
1586
|
+
const ext = extname(romPath).toLowerCase();
|
|
1587
|
+
const metadata = extractMetadata(romPath, ext);
|
|
1588
|
+
return metadata.title;
|
|
1589
|
+
} catch {
|
|
1590
|
+
return undefined;
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* Progress callback for directory scanning
|
|
1596
|
+
*/
|
|
1597
|
+
export interface ScanProgress {
|
|
1598
|
+
/** Current file being processed */
|
|
1599
|
+
currentFile: string;
|
|
1600
|
+
/** Number of files processed so far */
|
|
1601
|
+
processed: number;
|
|
1602
|
+
/** Total number of files to process, or undefined if unknown (async scan) */
|
|
1603
|
+
total?: number;
|
|
1604
|
+
/** Number of ROMs found so far */
|
|
1605
|
+
romsFound: number;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Count total files in a directory tree (for progress tracking)
|
|
1610
|
+
* @param dirPath Directory to count files in
|
|
1611
|
+
* @param maxDepth Maximum depth to scan (-1 = unlimited)
|
|
1612
|
+
* @param supportedExtensions Set of extensions to count (if provided, only counts matching files)
|
|
1613
|
+
*/
|
|
1614
|
+
export const countFiles = (
|
|
1615
|
+
dirPath: string,
|
|
1616
|
+
maxDepth: number = 1,
|
|
1617
|
+
supportedExtensions?: Set<string>
|
|
1618
|
+
): number => {
|
|
1619
|
+
let count = 0;
|
|
1620
|
+
|
|
1621
|
+
const countRecursive = (currentPath: string, currentDepth: number): void => {
|
|
1622
|
+
try {
|
|
1623
|
+
const entries = readdirSync(currentPath);
|
|
1624
|
+
|
|
1625
|
+
for (const entry of entries) {
|
|
1626
|
+
const fullPath = join(currentPath, entry);
|
|
1627
|
+
|
|
1628
|
+
try {
|
|
1629
|
+
const stats = statSync(fullPath);
|
|
1630
|
+
|
|
1631
|
+
if (stats.isDirectory() && (maxDepth === -1 || currentDepth < maxDepth)) {
|
|
1632
|
+
countRecursive(fullPath, currentDepth + 1);
|
|
1633
|
+
} else if (stats.isFile()) {
|
|
1634
|
+
if (supportedExtensions) {
|
|
1635
|
+
const ext = extname(entry).toLowerCase();
|
|
1636
|
+
if (supportedExtensions.has(ext)) {
|
|
1637
|
+
count++;
|
|
1638
|
+
}
|
|
1639
|
+
} else {
|
|
1640
|
+
count++;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
} catch {
|
|
1644
|
+
// Skip files we can't access
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
} catch {
|
|
1648
|
+
// Skip directories we can't read
|
|
1649
|
+
}
|
|
1650
|
+
};
|
|
1651
|
+
|
|
1652
|
+
countRecursive(dirPath, 0);
|
|
1653
|
+
return count;
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* Count total files in a directory tree asynchronously (for progress tracking).
|
|
1658
|
+
* Uses async I/O to avoid blocking the event loop.
|
|
1659
|
+
*/
|
|
1660
|
+
export const countFilesAsync = async (
|
|
1661
|
+
dirPath: string,
|
|
1662
|
+
maxDepth: number = 1,
|
|
1663
|
+
supportedExtensions?: Set<string>,
|
|
1664
|
+
signal?: AbortSignal
|
|
1665
|
+
): Promise<number> => {
|
|
1666
|
+
let count = 0;
|
|
1667
|
+
let entryCount = 0;
|
|
1668
|
+
|
|
1669
|
+
const countRecursive = async (currentPath: string, currentDepth: number): Promise<void> => {
|
|
1670
|
+
// Check for cancellation
|
|
1671
|
+
if (signal?.aborted) {
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
let entries: string[];
|
|
1676
|
+
try {
|
|
1677
|
+
entries = await readdir(currentPath);
|
|
1678
|
+
} catch {
|
|
1679
|
+
// Skip directories we can't read
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
for (const entry of entries) {
|
|
1684
|
+
// Check for cancellation
|
|
1685
|
+
if (signal?.aborted) {
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const fullPath = join(currentPath, entry);
|
|
1690
|
+
|
|
1691
|
+
try {
|
|
1692
|
+
const stats = await stat(fullPath);
|
|
1693
|
+
|
|
1694
|
+
if (stats.isDirectory() && (maxDepth === -1 || currentDepth < maxDepth)) {
|
|
1695
|
+
await countRecursive(fullPath, currentDepth + 1);
|
|
1696
|
+
} else if (stats.isFile()) {
|
|
1697
|
+
if (supportedExtensions) {
|
|
1698
|
+
const ext = extname(entry).toLowerCase();
|
|
1699
|
+
if (supportedExtensions.has(ext)) {
|
|
1700
|
+
count++;
|
|
1701
|
+
}
|
|
1702
|
+
} else {
|
|
1703
|
+
count++;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Yield control periodically
|
|
1708
|
+
entryCount++;
|
|
1709
|
+
if (entryCount % ASYNC_YIELD_INTERVAL === 0) {
|
|
1710
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
1711
|
+
}
|
|
1712
|
+
} catch {
|
|
1713
|
+
// Skip files we can't access
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
|
|
1718
|
+
await countRecursive(dirPath, 0);
|
|
1719
|
+
return count;
|
|
1720
|
+
};
|
|
1721
|
+
|
|
1722
|
+
/** Number of entries to process before yielding control */
|
|
1723
|
+
const ASYNC_YIELD_INTERVAL = 50;
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* Collect file paths from a directory tree asynchronously.
|
|
1727
|
+
* Uses async I/O and yields control periodically to prevent blocking the event loop.
|
|
1728
|
+
*/
|
|
1729
|
+
async function* collectFilePathsAsync(
|
|
1730
|
+
dirPath: string,
|
|
1731
|
+
maxDepth: number,
|
|
1732
|
+
supportedExtensions: Set<string>
|
|
1733
|
+
): AsyncGenerator<string> {
|
|
1734
|
+
let entryCount = 0;
|
|
1735
|
+
|
|
1736
|
+
/** Recursively collect files, yielding paths as found */
|
|
1737
|
+
const collect = async function* (currentPath: string, currentDepth: number): AsyncGenerator<string> {
|
|
1738
|
+
let entries: string[];
|
|
1739
|
+
try {
|
|
1740
|
+
entries = await readdir(currentPath);
|
|
1741
|
+
} catch {
|
|
1742
|
+
// Skip directories we can't read
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
for (const entry of entries) {
|
|
1747
|
+
const fullPath = join(currentPath, entry);
|
|
1748
|
+
|
|
1749
|
+
try {
|
|
1750
|
+
const stats = await stat(fullPath);
|
|
1751
|
+
|
|
1752
|
+
if (stats.isDirectory() && (maxDepth === -1 || currentDepth < maxDepth)) {
|
|
1753
|
+
// Recurse into subdirectory
|
|
1754
|
+
yield* collect(fullPath, currentDepth + 1);
|
|
1755
|
+
} else if (stats.isFile()) {
|
|
1756
|
+
const ext = extname(entry).toLowerCase();
|
|
1757
|
+
if (supportedExtensions.has(ext)) {
|
|
1758
|
+
yield fullPath;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// Yield control periodically to allow UI updates
|
|
1763
|
+
entryCount++;
|
|
1764
|
+
if (entryCount % ASYNC_YIELD_INTERVAL === 0) {
|
|
1765
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
1766
|
+
}
|
|
1767
|
+
} catch {
|
|
1768
|
+
// Skip files we can't access
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
|
|
1773
|
+
yield* collect(dirPath, 0);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* Process a single file and return RomInfo if valid
|
|
1778
|
+
*/
|
|
1779
|
+
const processFile = (
|
|
1780
|
+
fullPath: string,
|
|
1781
|
+
crcCache?: CrcCache
|
|
1782
|
+
): RomInfo | null => {
|
|
1783
|
+
try {
|
|
1784
|
+
const stats = statSync(fullPath);
|
|
1785
|
+
const entry = basename(fullPath);
|
|
1786
|
+
const ext = extname(entry).toLowerCase();
|
|
1787
|
+
|
|
1788
|
+
// Read file header once for both binary check and metadata extraction
|
|
1789
|
+
// Uses smart sizing based on format to minimize I/O
|
|
1790
|
+
const headerBuffer = readRomHeader(fullPath, stats.size, ext);
|
|
1791
|
+
if (!headerBuffer) {
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// Quick sanity check: ensure file is plausibly a binary ROM
|
|
1796
|
+
if (!isBinaryFromBuffer(headerBuffer)) {
|
|
1797
|
+
return null;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
const matchingCores = findMatchingCoresByExtension(ext);
|
|
1801
|
+
|
|
1802
|
+
if (matchingCores.length > 0) {
|
|
1803
|
+
const primaryCore = matchingCores[0];
|
|
1804
|
+
const systemInfo = primaryCore.factory.getSystemInfo();
|
|
1805
|
+
const saveStateInfo = checkForSaveState(fullPath);
|
|
1806
|
+
const batterySaveInfo = checkForBatterySave(fullPath);
|
|
1807
|
+
|
|
1808
|
+
// Use cached CRC32 if available, otherwise calculate
|
|
1809
|
+
const normalizedPath = normalizePath(fullPath);
|
|
1810
|
+
const cachedCrc = crcCache?.get(normalizedPath);
|
|
1811
|
+
const crc32 = cachedCrc && cachedCrc !== 'DETECT'
|
|
1812
|
+
? cachedCrc
|
|
1813
|
+
: calculateFileCrc32(fullPath);
|
|
1814
|
+
|
|
1815
|
+
return {
|
|
1816
|
+
path: fullPath,
|
|
1817
|
+
filename: basename(entry),
|
|
1818
|
+
extension: ext,
|
|
1819
|
+
size: stats.size,
|
|
1820
|
+
sizeFormatted: formatSize(stats.size),
|
|
1821
|
+
modified: stats.mtime,
|
|
1822
|
+
system: getSystemName(ext, systemInfo.name),
|
|
1823
|
+
systemId: primaryCore.id,
|
|
1824
|
+
coreCount: matchingCores.length,
|
|
1825
|
+
coreIds: matchingCores.map(c => c.id),
|
|
1826
|
+
metadata: extractMetadataFromBuffer(headerBuffer, ext),
|
|
1827
|
+
hasSaveState: saveStateInfo.exists,
|
|
1828
|
+
saveStateDate: saveStateInfo.savedAt,
|
|
1829
|
+
hasBatterySave: batterySaveInfo.exists,
|
|
1830
|
+
batterySaveDate: batterySaveInfo.modifiedAt,
|
|
1831
|
+
crc32,
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
} catch {
|
|
1835
|
+
// Skip files we can't process
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
return null;
|
|
1839
|
+
};
|
|
1840
|
+
|
|
1841
|
+
/**
|
|
1842
|
+
* Get a valid timestamp from a lastPlayed value, handling Date objects and potential edge cases.
|
|
1843
|
+
* Returns undefined if the value is not a valid date with a positive timestamp.
|
|
1844
|
+
*/
|
|
1845
|
+
const getValidTimestamp = (lastPlayed: Date | undefined): number | undefined => {
|
|
1846
|
+
if (!lastPlayed) {
|
|
1847
|
+
return undefined;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// Ensure it's a Date object (might be a string if serialized)
|
|
1851
|
+
const date = lastPlayed instanceof Date ? lastPlayed : new Date(lastPlayed);
|
|
1852
|
+
const time = date.getTime();
|
|
1853
|
+
|
|
1854
|
+
// Must be a valid positive number (dates after epoch)
|
|
1855
|
+
if (typeof time === 'number' && !Number.isNaN(time) && time > 0) {
|
|
1856
|
+
return time;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
return undefined;
|
|
1860
|
+
};
|
|
1861
|
+
|
|
1862
|
+
/**
|
|
1863
|
+
* Sort ROMs by last played date (most recent first), then alphabetically for unplayed ROMs
|
|
1864
|
+
*/
|
|
1865
|
+
export const sortRoms = (roms: RomInfo[]): void => {
|
|
1866
|
+
roms.sort((a, b) => {
|
|
1867
|
+
const aTime = getValidTimestamp(a.lastPlayed);
|
|
1868
|
+
const bTime = getValidTimestamp(b.lastPlayed);
|
|
1869
|
+
|
|
1870
|
+
// Both have valid lastPlayed - sort by date (most recent first)
|
|
1871
|
+
if (aTime !== undefined && bTime !== undefined) {
|
|
1872
|
+
return bTime - aTime;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// Only one has lastPlayed - it comes first
|
|
1876
|
+
if (aTime !== undefined) {
|
|
1877
|
+
return -1;
|
|
1878
|
+
}
|
|
1879
|
+
if (bTime !== undefined) {
|
|
1880
|
+
return 1;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// Neither has lastPlayed - sort alphabetically
|
|
1884
|
+
return a.filename.localeCompare(b.filename);
|
|
1885
|
+
});
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* Scan a directory for ROM files asynchronously with progress reporting.
|
|
1890
|
+
* This allows the UI to update between file processing.
|
|
1891
|
+
* Automatically uses CRC cache from existing playlists to avoid recalculation.
|
|
1892
|
+
*
|
|
1893
|
+
* @param dirPath Directory to scan
|
|
1894
|
+
* @param maxDepth Maximum depth to scan (0 = only dirPath, 1 = dirPath + immediate subdirs, -1 = unlimited)
|
|
1895
|
+
* @param onProgress Callback for progress updates
|
|
1896
|
+
* @param signal Optional abort signal for cancellation
|
|
1897
|
+
*/
|
|
1898
|
+
/** Error thrown when scan is cancelled */
|
|
1899
|
+
export class ScanCancelledError extends Error {
|
|
1900
|
+
constructor() {
|
|
1901
|
+
super('Scan cancelled');
|
|
1902
|
+
this.name = 'ScanCancelledError';
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
export const scanDirectoryAsync = async (
|
|
1907
|
+
dirPath: string,
|
|
1908
|
+
maxDepth: number = 1,
|
|
1909
|
+
onProgress?: (progress: ScanProgress) => void,
|
|
1910
|
+
signal?: AbortSignal
|
|
1911
|
+
): Promise<RomInfo[]> => {
|
|
1912
|
+
const roms: RomInfo[] = [];
|
|
1913
|
+
const supportedExtensions = new Set(getSupportedExtensions());
|
|
1914
|
+
const crcCache = buildCrcCache();
|
|
1915
|
+
|
|
1916
|
+
// Count files first for accurate progress reporting (async to avoid blocking)
|
|
1917
|
+
const totalFiles = await countFilesAsync(dirPath, maxDepth, supportedExtensions, signal);
|
|
1918
|
+
|
|
1919
|
+
// Check for cancellation after count
|
|
1920
|
+
if (signal?.aborted) {
|
|
1921
|
+
throw new ScanCancelledError();
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// Process files using async generator
|
|
1925
|
+
// This avoids blocking the event loop for large directories
|
|
1926
|
+
let processed = 0;
|
|
1927
|
+
|
|
1928
|
+
for await (const fullPath of collectFilePathsAsync(dirPath, maxDepth, supportedExtensions)) {
|
|
1929
|
+
// Check for cancellation
|
|
1930
|
+
if (signal?.aborted) {
|
|
1931
|
+
throw new ScanCancelledError();
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
const rom = processFile(fullPath, crcCache);
|
|
1935
|
+
|
|
1936
|
+
if (rom) {
|
|
1937
|
+
roms.push(rom);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
processed++;
|
|
1941
|
+
|
|
1942
|
+
// Report progress with known total
|
|
1943
|
+
if (onProgress) {
|
|
1944
|
+
onProgress({
|
|
1945
|
+
currentFile: basename(fullPath),
|
|
1946
|
+
processed,
|
|
1947
|
+
total: totalFiles,
|
|
1948
|
+
romsFound: roms.length,
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// Sort results
|
|
1954
|
+
sortRoms(roms);
|
|
1955
|
+
|
|
1956
|
+
return roms;
|
|
1957
|
+
};
|