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.
Files changed (213) hide show
  1. package/.claude/settings.local.json +77 -0
  2. package/.node-version +1 -0
  3. package/CLAUDE.md +435 -0
  4. package/README.md +404 -0
  5. package/TODO.md +655 -0
  6. package/dist/index.cjs +25108 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +25085 -0
  9. package/docs/building-libretro-cores-arm-mac.md +237 -0
  10. package/docs/config-file-format.md +488 -0
  11. package/docs/cores-trd.md +425 -0
  12. package/docs/headless-hardware-rendering-trd.md +676 -0
  13. package/docs/libretro-cores-trd.md +997 -0
  14. package/docs/mupen64-software-rendering-trd.md +751 -0
  15. package/docs/n64-support-trd.md +306 -0
  16. package/docs/native-rendering-trd.md +540 -0
  17. package/docs/native-ui-rendering-trd.md +1195 -0
  18. package/docs/netplay-trd.md +665 -0
  19. package/docs/retroarch-netplay-docs.md +277 -0
  20. package/docs/save-state-format.md +666 -0
  21. package/eslint.config.js +111 -0
  22. package/icon/icon.png +0 -0
  23. package/icon/icon.pxd +0 -0
  24. package/package.json +63 -0
  25. package/pnpm-workspace.yaml +10 -0
  26. package/src/Emulator/consts.ts +14 -0
  27. package/src/Emulator/index.ts +2496 -0
  28. package/src/Emulator/saveState/index.ts +155 -0
  29. package/src/Emulator/screenshot/index.ts +160 -0
  30. package/src/Emulator/terminalDimensions/index.ts +79 -0
  31. package/src/Emulator/types.ts +83 -0
  32. package/src/cli/commands/consts.ts +10 -0
  33. package/src/cli/commands/index.ts +462 -0
  34. package/src/cli/parseArgs/consts.ts +17 -0
  35. package/src/cli/parseArgs/index.ts +457 -0
  36. package/src/cli/parseArgs/types.ts +61 -0
  37. package/src/cli/runEmulator/index.ts +406 -0
  38. package/src/cli/runEmulator/types.ts +7 -0
  39. package/src/consts.ts +19 -0
  40. package/src/core/button/consts.ts +35 -0
  41. package/src/core/button/index.ts +123 -0
  42. package/src/core/core.ts +300 -0
  43. package/src/core/index.ts +19 -0
  44. package/src/cores/libretro/api/index.ts +334 -0
  45. package/src/cores/libretro/api/types.ts +148 -0
  46. package/src/cores/libretro/callbacks/consts.ts +41 -0
  47. package/src/cores/libretro/callbacks/index.ts +456 -0
  48. package/src/cores/libretro/consts.ts +45 -0
  49. package/src/cores/libretro/coreOptions/consts.ts +36 -0
  50. package/src/cores/libretro/coreOptions/index.ts +222 -0
  51. package/src/cores/libretro/environment/consts.ts +118 -0
  52. package/src/cores/libretro/environment/index.ts +1095 -0
  53. package/src/cores/libretro/index.ts +937 -0
  54. package/src/cores/libretro/loader/index.ts +496 -0
  55. package/src/cores/libretro/pixelFormat/consts.ts +43 -0
  56. package/src/cores/libretro/pixelFormat/index.ts +397 -0
  57. package/src/cores/libretro/types.ts +339 -0
  58. package/src/frontend/AudioManager/index.ts +420 -0
  59. package/src/frontend/SettingsManager/index.ts +250 -0
  60. package/src/frontend/config/index.ts +608 -0
  61. package/src/frontend/config/tests.ts +354 -0
  62. package/src/frontend/config/types.ts +36 -0
  63. package/src/frontend/consts.ts +114 -0
  64. package/src/frontend/coreBuilder/index.ts +644 -0
  65. package/src/frontend/coreBuilder/types.ts +15 -0
  66. package/src/frontend/coreDownloader/index.ts +620 -0
  67. package/src/frontend/coreDownloader/types.ts +17 -0
  68. package/src/frontend/corePreferences/index.ts +69 -0
  69. package/src/frontend/coreRegistry/index.ts +276 -0
  70. package/src/frontend/directoryCache/index.ts +75 -0
  71. package/src/frontend/index.ts +79 -0
  72. package/src/frontend/notifications/consts.ts +14 -0
  73. package/src/frontend/notifications/index.ts +250 -0
  74. package/src/frontend/playlist/consts.ts +168 -0
  75. package/src/frontend/playlist/index.ts +899 -0
  76. package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
  77. package/src/frontend/playlist/labelFormatter/index.ts +155 -0
  78. package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
  79. package/src/frontend/playlist/reader/index.ts +559 -0
  80. package/src/frontend/playlist/sync/index.ts +511 -0
  81. package/src/frontend/playlist/systemLookup/index.ts +233 -0
  82. package/src/frontend/playlist/utils/index.ts +50 -0
  83. package/src/frontend/romScanner/consts.ts +348 -0
  84. package/src/frontend/romScanner/index.ts +1957 -0
  85. package/src/frontend/saveServices/consts.ts +2 -0
  86. package/src/frontend/saveServices/index.ts +313 -0
  87. package/src/frontend/serviceProvider/index.ts +108 -0
  88. package/src/frontend/serviceProvider/types.ts +13 -0
  89. package/src/index.ts +428 -0
  90. package/src/input/Controller/consts.ts +50 -0
  91. package/src/input/Controller/index.ts +81 -0
  92. package/src/input/GamepadManager/consts.ts +22 -0
  93. package/src/input/GamepadManager/index.ts +418 -0
  94. package/src/input/InputManager/consts.ts +86 -0
  95. package/src/input/InputManager/index.ts +593 -0
  96. package/src/input/InputMapper/consts.ts +33 -0
  97. package/src/input/InputMapper/index.ts +436 -0
  98. package/src/input/consts.ts +410 -0
  99. package/src/input/gamepadProfiles/index.ts +1100 -0
  100. package/src/input/index.ts +38 -0
  101. package/src/input/inputUtils/index.ts +31 -0
  102. package/src/netplay/FrameBuffer/consts.ts +2 -0
  103. package/src/netplay/FrameBuffer/index.ts +364 -0
  104. package/src/netplay/FrameBuffer/tests.ts +286 -0
  105. package/src/netplay/InputBuffer/consts.ts +7 -0
  106. package/src/netplay/InputBuffer/index.ts +347 -0
  107. package/src/netplay/InputBuffer/tests.ts +166 -0
  108. package/src/netplay/NetplayClient/index.ts +976 -0
  109. package/src/netplay/NetplayConnection/index.ts +536 -0
  110. package/src/netplay/NetplayDiscovery/consts.ts +41 -0
  111. package/src/netplay/NetplayDiscovery/index.ts +525 -0
  112. package/src/netplay/NetplayServer/index.ts +1407 -0
  113. package/src/netplay/SyncManager/index.ts +984 -0
  114. package/src/netplay/SyncManager/tests.ts +419 -0
  115. package/src/netplay/consts.ts +371 -0
  116. package/src/netplay/crc32/consts.ts +14 -0
  117. package/src/netplay/crc32/index.ts +97 -0
  118. package/src/netplay/crc32/tests.ts +40 -0
  119. package/src/netplay/index.ts +41 -0
  120. package/src/netplay/netplayLogger/consts.ts +30 -0
  121. package/src/netplay/netplayLogger/index.ts +345 -0
  122. package/src/netplay/protocol/consts.ts +86 -0
  123. package/src/netplay/protocol/index.ts +1280 -0
  124. package/src/netplay/protocol/tests.ts +606 -0
  125. package/src/netplay/protocol/types.ts +20 -0
  126. package/src/netplay/types.ts +395 -0
  127. package/src/rendering/KittyRenderer/index.ts +616 -0
  128. package/src/rendering/NativeRenderer/index.ts +279 -0
  129. package/src/rendering/NativeRenderer/tests.ts +133 -0
  130. package/src/rendering/TerminalRenderer/index.ts +770 -0
  131. package/src/rendering/consts.ts +401 -0
  132. package/src/rendering/fonts/CozetteVector.ttf +0 -0
  133. package/src/rendering/index.ts +26 -0
  134. package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
  135. package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
  136. package/src/rendering/nativeUi/consts.ts +6 -0
  137. package/src/rendering/nativeUi/index.ts +20 -0
  138. package/src/rendering/postProcessing/consts.ts +38 -0
  139. package/src/rendering/postProcessing/index.ts +923 -0
  140. package/src/rendering/shared/ansi/consts.ts +12 -0
  141. package/src/rendering/shared/ansi/index.ts +104 -0
  142. package/src/rendering/shared/consts.ts +2 -0
  143. package/src/rendering/shared/fitToTerminal/index.ts +67 -0
  144. package/src/ui/AddRomsPrompt/consts.ts +13 -0
  145. package/src/ui/AddRomsPrompt/index.tsx +781 -0
  146. package/src/ui/App/consts.ts +2 -0
  147. package/src/ui/App/index.tsx +456 -0
  148. package/src/ui/AppCapabilities/index.tsx +67 -0
  149. package/src/ui/ConfigContext/index.tsx +56 -0
  150. package/src/ui/CoreManager/consts.ts +11 -0
  151. package/src/ui/CoreManager/index.tsx +779 -0
  152. package/src/ui/CoreSelector/consts.ts +2 -0
  153. package/src/ui/CoreSelector/index.tsx +251 -0
  154. package/src/ui/DialogContainer/index.tsx +42 -0
  155. package/src/ui/DialogOptionsList/index.tsx +61 -0
  156. package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
  157. package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
  158. package/src/ui/GamepadContext/consts.ts +15 -0
  159. package/src/ui/GamepadContext/index.tsx +295 -0
  160. package/src/ui/NativeDialog/index.tsx +120 -0
  161. package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
  162. package/src/ui/NetplayPauseMenu/consts.ts +2 -0
  163. package/src/ui/NetplayPauseMenu/index.tsx +97 -0
  164. package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
  165. package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
  166. package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
  167. package/src/ui/RomBrowser/consts.ts +61 -0
  168. package/src/ui/RomBrowser/index.tsx +1164 -0
  169. package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
  170. package/src/ui/RomBrowser/types.ts +67 -0
  171. package/src/ui/SaveStateDialog/consts.ts +2 -0
  172. package/src/ui/SaveStateDialog/index.tsx +225 -0
  173. package/src/ui/WarningDialog/index.tsx +113 -0
  174. package/src/ui/consts.ts +27 -0
  175. package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
  176. package/src/ui/hooks/useClearTerminal/index.ts +37 -0
  177. package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
  178. package/src/ui/hooks/useGamepad/consts.ts +21 -0
  179. package/src/ui/hooks/useGamepad/index.ts +194 -0
  180. package/src/ui/index.ts +27 -0
  181. package/src/utils/buffer/consts.ts +17 -0
  182. package/src/utils/buffer/index.ts +129 -0
  183. package/src/utils/color/consts.ts +58 -0
  184. package/src/utils/color/index.ts +183 -0
  185. package/src/utils/compression/consts.ts +50 -0
  186. package/src/utils/compression/index.ts +101 -0
  187. package/src/utils/consts.ts +2 -0
  188. package/src/utils/crc32/consts.ts +22 -0
  189. package/src/utils/crc32/index.ts +83 -0
  190. package/src/utils/ensureDirectory/index.ts +10 -0
  191. package/src/utils/format/consts.ts +8 -0
  192. package/src/utils/format/index.ts +53 -0
  193. package/src/utils/getErrorMessage/index.ts +10 -0
  194. package/src/utils/index.ts +113 -0
  195. package/src/utils/ini/index.ts +200 -0
  196. package/src/utils/kitty/consts.ts +13 -0
  197. package/src/utils/kitty/index.ts +181 -0
  198. package/src/utils/logger/consts.ts +35 -0
  199. package/src/utils/logger/index.ts +217 -0
  200. package/src/utils/paths/consts.ts +18 -0
  201. package/src/utils/paths/index.ts +151 -0
  202. package/src/utils/png/consts.ts +34 -0
  203. package/src/utils/png/index.ts +131 -0
  204. package/src/utils/readJsonFile/index.ts +16 -0
  205. package/src/utils/rotateLogFile/index.ts +44 -0
  206. package/src/utils/safeClose/index.ts +10 -0
  207. package/src/utils/terminal/consts.ts +8 -0
  208. package/src/utils/terminal/index.ts +102 -0
  209. package/src/utils/thumbnailRenderer/consts.ts +2 -0
  210. package/src/utils/thumbnailRenderer/index.ts +147 -0
  211. package/src/utils/typedError/index.ts +26 -0
  212. package/tsconfig.json +31 -0
  213. 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
+ };