diff --git a/src/console/progressBar.ts b/src/console/progressBar.ts index 1227ff916..4c0c374e6 100644 --- a/src/console/progressBar.ts +++ b/src/console/progressBar.ts @@ -23,6 +23,7 @@ export const ProgressBarSymbol = { // Candidates GENERATING: chalk.cyan('Σ'), FILTERING: chalk.cyan('∆'), + EXTENSION_CORRECTION: chalk.cyan('.'), HASHING: chalk.cyan('#'), VALIDATING: chalk.cyan(process.platform === 'win32' ? '?' : '≟'), COMBINING_ALL: chalk.cyan(process.platform === 'win32' ? 'U' : '∪'), diff --git a/src/modules/candidateExtensionCorrector.ts b/src/modules/candidateExtensionCorrector.ts index 570bc5df0..e31193dd5 100644 --- a/src/modules/candidateExtensionCorrector.ts +++ b/src/modules/candidateExtensionCorrector.ts @@ -7,7 +7,7 @@ import DAT from '../types/dats/dat.js'; import Parent from '../types/dats/parent.js'; import ROM from '../types/dats/rom.js'; import ArchiveEntry from '../types/files/archives/archiveEntry.js'; -import FileSignature from '../types/files/fileSignature.js'; +import FileCache from '../types/files/fileCache.js'; import Options, { FixExtension } from '../types/options.js'; import OutputFactory from '../types/outputFactory.js'; import ReleaseCandidate from '../types/releaseCandidate.js'; @@ -52,7 +52,7 @@ export default class CandidateExtensionCorrector extends Module { .filter((romWithFiles) => this.romNeedsCorrecting(romWithFiles)) .length; this.progressBar.logTrace(`${dat.getNameShort()}: correcting ${romsThatNeedCorrecting.toLocaleString()} output file extension${romsThatNeedCorrecting !== 1 ? 's' : ''}`); - await this.progressBar.setSymbol(ProgressBarSymbol.HASHING); + await this.progressBar.setSymbol(ProgressBarSymbol.EXTENSION_CORRECTION); await this.progressBar.reset(romsThatNeedCorrecting); const correctedParentsToCandidates = await this.correctExtensions(dat, parentsToCandidates); @@ -148,13 +148,8 @@ export default class CandidateExtensionCorrector extends Module { this.progressBar.logTrace(`${dat.getNameShort()}: ${parent.getName()}: correcting extension for: ${romWithFiles.getInputFile() .toString()}`); - await romWithFiles.getInputFile().createReadStream(async (stream) => { - const romSignature = await FileSignature.signatureFromFileStream(stream); - if (!romSignature) { - // No signature was found, so we can't perform any correction - return; - } - + const romSignature = await FileCache.getOrComputeFileSignature(romWithFiles.getInputFile()); + if (romSignature) { // ROM file signature found, use the appropriate extension const { dir, name } = path.parse(correctedRom.getName()); const correctedRomName = path.format({ @@ -162,7 +157,7 @@ export default class CandidateExtensionCorrector extends Module { name: name + romSignature.getExtension(), }); correctedRom = correctedRom.withName(correctedRomName); - }); + } this.progressBar.removeWaitingMessage(waitingMessage); await this.progressBar.incrementDone(); diff --git a/src/types/cache.ts b/src/types/cache.ts index 38270dec5..dccc88f4e 100644 --- a/src/types/cache.ts +++ b/src/types/cache.ts @@ -135,16 +135,17 @@ export default class Cache { * Delete a key in the cache. */ public async delete(key: string | RegExp): Promise { - let keys: string[]; + let keysToDelete: string[]; if (key instanceof RegExp) { - keys = [...this.keys().keys()].filter((k) => k.match(key)); + keysToDelete = [...this.keys().keys()].filter((k) => k.match(key)); } else { - keys = [key]; + keysToDelete = [key]; } - await Promise.all(keys.map(async (k) => { - await this.lockKey(k, () => this.deleteUnsafe(k)); - })); + // Note: avoiding lockKey() because it could get expensive with many keys to delete + await this.keyMutexesMutex.runExclusive(() => { + keysToDelete.forEach((k) => this.deleteUnsafe(k)); + }); } private deleteUnsafe(key: string): void { diff --git a/src/types/files/fileCache.ts b/src/types/files/fileCache.ts index 1ff231586..b0021d764 100644 --- a/src/types/files/fileCache.ts +++ b/src/types/files/fileCache.ts @@ -6,6 +6,7 @@ import Archive from './archives/archive.js'; import ArchiveEntry, { ArchiveEntryProps } from './archives/archiveEntry.js'; import File, { FileProps } from './file.js'; import { ChecksumBitmask } from './fileChecksums.js'; +import FileSignature from './fileSignature.js'; import ROMHeader from './romHeader.js'; interface CacheValue { @@ -14,11 +15,15 @@ interface CacheValue { value: FileProps | ArchiveEntryProps[] | string | undefined, } -enum ValueType { - FILE_CHECKSUMS = 'F', - ARCHIVE_CHECKSUMS = 'A', - FILE_HEADER = 'H', -} +const ValueType = { + FILE_CHECKSUMS: 'F', + ARCHIVE_CHECKSUMS: 'A', + // ROM headers and file signatures may not be found for files, and that is a valid result that + // gets cached. But when the list of known headers or signatures changes, we may be able to find + // a non-undefined result. So these dynamic values help with cache busting. + ROM_HEADER: `H${ROMHeader.getKnownHeaderCount()}`, + FILE_SIGNATURE: `S${FileSignature.getKnownSignatureCount()}`, +}; export default class FileCache { private static readonly VERSION = 3; @@ -45,7 +50,8 @@ export default class FileCache { const keyRegex = new RegExp(`^V${prevVersion}\\|`); return this.cache.delete(keyRegex); })); - // await this.cache.delete(new RegExp(`\\|[^${Object.values(ValueType).join()}]$`)); + // Delete keys from old value types + await this.cache.delete(new RegExp(`\\|(?!(${Object.values(ValueType).join('|')}))[^|]+$`)); // Delete keys for deleted files const disks = FsPoly.disksSync(); @@ -198,7 +204,7 @@ export default class FileCache { static async getOrComputeFileHeader(file: File): Promise { // NOTE(cemmer): we're explicitly not catching ENOENT errors here, we want it to bubble up const stats = await FsPoly.stat(file.getFilePath()); - const cacheKey = this.getCacheKey(file.toString(), ValueType.FILE_HEADER); + const cacheKey = this.getCacheKey(file.toString(), ValueType.ROM_HEADER); const cachedValue = await this.cache.getOrCompute( cacheKey, @@ -214,10 +220,11 @@ export default class FileCache { }, (cached) => { if (cached.fileSize !== stats.size || cached.modifiedTimeMillis !== stats.mtimeMs) { - // File has changed since being cached + // Recompute if the file has changed since being cached return true; } - return false; + // Recompute if the cached value isn't known + return typeof cached.value === 'string' && !ROMHeader.headerFromName(cached.value); }, ); @@ -228,7 +235,41 @@ export default class FileCache { return ROMHeader.headerFromName(cachedHeaderName); } - private static getCacheKey(filePath: string, valueType: ValueType): string { + static async getOrComputeFileSignature(file: File): Promise { + // NOTE(cemmer): we're explicitly not catching ENOENT errors here, we want it to bubble up + const stats = await FsPoly.stat(file.getFilePath()); + const cacheKey = this.getCacheKey(file.toString(), ValueType.FILE_SIGNATURE); + + const cachedValue = await this.cache.getOrCompute( + cacheKey, + async () => { + const signature = await file.createReadStream( + async (stream) => FileSignature.signatureFromFileStream(stream), + ); + return { + fileSize: stats.size, + modifiedTimeMillis: stats.mtimeMs, + value: signature?.getName(), + }; + }, + (cached) => { + if (cached.fileSize !== stats.size || cached.modifiedTimeMillis !== stats.mtimeMs) { + // File has changed since being cached + return true; + } + // Recompute if the cached value isn't known + return typeof cached.value === 'string' && !FileSignature.signatureFromName(cached.value); + }, + ); + + const cachedSignatureName = cachedValue.value as string | undefined; + if (!cachedSignatureName) { + return undefined; + } + return FileSignature.signatureFromName(cachedSignatureName); + } + + private static getCacheKey(filePath: string, valueType: string): string { return `V${FileCache.VERSION}|${filePath}|${valueType}`; } } diff --git a/src/types/files/fileFactory.ts b/src/types/files/fileFactory.ts index f5cc86ae6..450dba971 100644 --- a/src/types/files/fileFactory.ts +++ b/src/types/files/fileFactory.ts @@ -110,9 +110,7 @@ export default class FileFactory { let signature: FileSignature | undefined; try { const file = await File.fileOf({ filePath }); - signature = await file.createReadStream( - async (stream) => FileSignature.signatureFromFileStream(stream), - ); + signature = await FileCache.getOrComputeFileSignature(file); } catch { // Fail silently on assumed I/O errors return undefined; diff --git a/src/types/files/fileSignature.ts b/src/types/files/fileSignature.ts index 5922e05c9..116a3aab3 100644 --- a/src/types/files/fileSignature.ts +++ b/src/types/files/fileSignature.ts @@ -1,5 +1,7 @@ import { Readable } from 'node:stream'; +import { Memoize } from 'typescript-memoize'; + type SignaturePiece = { offset?: number, value: Buffer, @@ -10,197 +12,200 @@ export default class FileSignature { // @see https://www.garykessler.net/library/file_sigs.html // @see https://file-extension.net/seeker/ // @see https://gbatemp.net/threads/help-with-rom-iso-console-identification.611378/ - private static readonly SIGNATURES = [ + private static readonly SIGNATURES: { [key: string]: FileSignature } = { // ********** ARCHIVES ********** // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.7z', [{ value: Buffer.from('377ABCAF271C', 'hex') }]), + '7z': new FileSignature('.7z', [{ value: Buffer.from('377ABCAF271C', 'hex') }]), // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.bz2', [{ value: Buffer.from('BZh') }]), + bz2: new FileSignature('.bz2', [{ value: Buffer.from('BZh') }]), // @see https://docs.fileformat.com/compression/gz/ - new FileSignature('.gz', [{ value: Buffer.from('1F8B08', 'hex') }]), // deflate + gz: new FileSignature('.gz', [{ value: Buffer.from('1F8B08', 'hex') }]), // deflate // .tar.gz has the same file signature // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.lz', [{ value: Buffer.from('LZIP') }]), + lz: new FileSignature('.lz', [{ value: Buffer.from('LZIP') }]), // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.lz4', [{ value: Buffer.from('04224D18', 'hex') }]), + lz4: new FileSignature('.lz4', [{ value: Buffer.from('04224D18', 'hex') }]), // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.lzh', [ + lzh: new FileSignature('.lzh', [ { value: Buffer.from('-lh') }, { offset: 4, value: Buffer.from('-') }, ]), // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.oar', [{ value: Buffer.from('OAR') }]), + oar: new FileSignature('.oar', [{ value: Buffer.from('OAR') }]), // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.rar', [{ value: Buffer.from('Rar!\x1A\x07\x00') }]), // v1.50+ - new FileSignature('.rar', [{ value: Buffer.from('Rar!\x1A\x07\x01\x00') }]), // v5.00+ + rar1: new FileSignature('.rar', [{ value: Buffer.from('Rar!\x1A\x07\x00') }]), // v1.50+ + rar5: new FileSignature('.rar', [{ value: Buffer.from('Rar!\x1A\x07\x01\x00') }]), // v5.00+ // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.rs', [{ value: Buffer.from('RSVKDATA') }]), + rs: new FileSignature('.rs', [{ value: Buffer.from('RSVKDATA') }]), // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.tar', [{ offset: 257, value: Buffer.from('ustar\x0000') }]), - new FileSignature('.tar', [{ offset: 257, value: Buffer.from('ustar\x20\x20\x00') }]), + tar1: new FileSignature('.tar', [{ offset: 257, value: Buffer.from('ustar\x0000') }]), + tar2: new FileSignature('.tar', [{ offset: 257, value: Buffer.from('ustar\x20\x20\x00') }]), // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.xar', [{ value: Buffer.from('xar!') }]), + xar: new FileSignature('.xar', [{ value: Buffer.from('xar!') }]), // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.xz', [{ value: Buffer.from('\xFD7zXZ\x00') }]), + xz: new FileSignature('.xz', [{ value: Buffer.from('\xFD7zXZ\x00') }]), // .tar.xz has the same file signature // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.z', [{ value: Buffer.from('1F9D', 'hex') }]), // LZW compression - new FileSignature('.z', [{ value: Buffer.from('1FA0', 'hex') }]), // LZH compression + z_lzw: new FileSignature('.z', [{ value: Buffer.from('1F9D', 'hex') }]), // LZW compression + z_lzh: new FileSignature('.z', [{ value: Buffer.from('1FA0', 'hex') }]), // LZH compression // .tar.z has the same file signature // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.zip', [{ value: Buffer.from('PK\x03\x04') }]), - new FileSignature('.zip', [{ value: Buffer.from('PK\x05\x06') }]), // empty archive + zip: new FileSignature('.zip', [{ value: Buffer.from('PK\x03\x04') }]), + zip_empty: new FileSignature('.zip', [{ value: Buffer.from('PK\x05\x06') }]), // empty archive // .zipx has the same file signature? // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.zst', [{ value: Buffer.from('28B52FFD', 'hex') }]), + zst: new FileSignature('.zst', [{ value: Buffer.from('28B52FFD', 'hex') }]), // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.z01', [{ value: Buffer.from('PK\x07\x08') }]), + z01: new FileSignature('.z01', [{ value: Buffer.from('PK\x07\x08') }]), // ********** ROMs - GENERAL ********** // @see https://docs.fileformat.com/disc-and-media/cso/ - new FileSignature('.cso', [{ value: Buffer.from('CISO') }]), + cso: new FileSignature('.cso', [{ value: Buffer.from('CISO') }]), // @see https://en.wikipedia.org/wiki/List_of_file_signatures - new FileSignature('.isz', [{ value: Buffer.from('IsZ!') }]), + isz: new FileSignature('.isz', [{ value: Buffer.from('IsZ!') }]), // @see https://docs.fileformat.com/disc-and-media/cso/ - new FileSignature('.zso', [{ value: Buffer.from('ZISO') }]), + zso: new FileSignature('.zso', [{ value: Buffer.from('ZISO') }]), // ********** ROMs - SPECIFIC ********** // Atari - 7800 - new FileSignature('.a78', [{ offset: 1, value: Buffer.from('ATARI7800') }]), + a78: new FileSignature('.a78', [{ offset: 1, value: Buffer.from('ATARI7800') }]), // Atari - Lynx - new FileSignature('.lnx', [{ value: Buffer.from('LYNX') }]), + lnx: new FileSignature('.lnx', [{ value: Buffer.from('LYNX') }]), // Nintendo - Nintendo 3DS - new FileSignature('.3dsx', [{ value: Buffer.from('3DSX') }]), + '3dsx': new FileSignature('.3dsx', [{ value: Buffer.from('3DSX') }]), // Nintendo - Nintendo 64 // @see http://n64dev.org/romformats.html - new FileSignature('.n64', [{ value: Buffer.from('40123780', 'hex') }]), // little endian - new FileSignature('.v64', [{ value: Buffer.from('37804012', 'hex') }]), // byte-swapped - new FileSignature('.z64', [{ value: Buffer.from('80371240', 'hex') }]), // native + n64: new FileSignature('.n64', [{ value: Buffer.from('40123780', 'hex') }]), // little endian + v64: new FileSignature('.v64', [{ value: Buffer.from('37804012', 'hex') }]), // byte-swapped + z64: new FileSignature('.z64', [{ value: Buffer.from('80371240', 'hex') }]), // native // Nintendo - Nintendo 64 Disk Drive - new FileSignature('.ndd', [{ value: Buffer.from('E848D31610', 'hex') }]), + ndd: new FileSignature('.ndd', [{ value: Buffer.from('E848D31610', 'hex') }]), // Nintendo - Famicom Disk System - new FileSignature('.fds', [{ value: Buffer.from('\x01*NINTENDO-HVC*') }]), - new FileSignature('.fds', [{ value: Buffer.from('FDS') }]), + fds_hvc: new FileSignature('.fds', [{ value: Buffer.from('\x01*NINTENDO-HVC*') }]), + fds: new FileSignature('.fds', [{ value: Buffer.from('FDS') }]), // Nintendo - Game & Watch - new FileSignature('.bin', [{ value: Buffer.from('main.bs') }]), + gw: new FileSignature('.bin', [{ value: Buffer.from('main.bs') }]), // Nintendo - Game Boy // @see https://gbdev.io/pandocs/The_Cartridge_Header.html - new FileSignature('.gb', [ + gb: new FileSignature('.gb', [ { offset: 0x01_04, value: Buffer.from('CEED6666CC0D000B03730083000C000D0008111F8889000EDCCC6EE6DDDDD999BBBB67636E0EECCCDDDC999FBBB9333E', 'hex') }, // logo { offset: 0x01_43, value: Buffer.from('00', 'hex') }, // non-color ]), // Nintendo - Game Boy Advance // @see http://problemkaputt.de/gbatek.htm#gbacartridges - new FileSignature('.gba', [{ offset: 0x04, value: Buffer.from('24FFAE51699AA2213D84820A84E409AD11248B98C0817F21A352BE199309CE2010464A4AF82731EC58C7E83382E3CEBF85F4DF94CE4B09C194568AC01372A7FC9F844D73A3CA9A615897A327FC039876231DC7610304AE56BF38840040A70EFDFF52FE036F9530F197FBC08560D68025A963BE03014E38E2F9A234FFBB3E0344780090CB88113A9465C07C6387F03CAFD625E48B380AAC7221D4F807', 'hex') }]), // logo + gba: new FileSignature('.gba', [{ offset: 0x04, value: Buffer.from('24FFAE51699AA2213D84820A84E409AD11248B98C0817F21A352BE199309CE2010464A4AF82731EC58C7E83382E3CEBF85F4DF94CE4B09C194568AC01372A7FC9F844D73A3CA9A615897A327FC039876231DC7610304AE56BF38840040A70EFDFF52FE036F9530F197FBC08560D68025A963BE03014E38E2F9A234FFBB3E0344780090CB88113A9465C07C6387F03CAFD625E48B380AAC7221D4F807', 'hex') }]), // logo // Nintendo - Game Boy Color // @see https://gbdev.io/pandocs/The_Cartridge_Header.html - new FileSignature('.gbc', [ + gb_dx: new FileSignature('.gbc', [ { offset: 0x01_04, value: Buffer.from('CEED6666CC0D000B03730083000C000D0008111F8889000EDCCC6EE6DDDDD999BBBB67636E0EECCCDDDC999FBBB9333E', 'hex') }, // logo { offset: 0x01_43, value: Buffer.from('80', 'hex') }, // backwards compatible ]), - new FileSignature('.gbc', [ + gbc: new FileSignature('.gbc', [ { offset: 0x01_04, value: Buffer.from('CEED6666CC0D000B03730083000C000D0008111F8889000EDCCC6EE6DDDDD999BBBB67636E0EECCCDDDC999FBBB9333E', 'hex') }, // logo { offset: 0x01_43, value: Buffer.from('C0', 'hex') }, // color only ]), // Nintendo - Nintendo DS (Decrypted) // @see http://dsibrew.org/wiki/DSi_cartridge_header - new FileSignature('.nds', [ + nds: new FileSignature('.nds', [ { offset: 0xC0, value: Buffer.from('24FFAE51699AA2213D84820A84E409AD11248B98C0817F21A352BE199309CE2010464A4AF82731EC58C7E83382E3CEBF85F4DF94CE4B09C194568AC01372A7FC9F844D73A3CA9A615897A327FC039876231DC7610304AE56BF38840040A70EFDFF52FE036F9530F197FBC08560D68025A963BE03014E38E2F9A234FFBB3E0344780090CB88113A9465C07C6387F03CAFD625E48B380AAC7221D4F807') }, // logo { offset: 0x1_5C, value: Buffer.from('56CF', 'hex') }, // logo checksum ]), // Nintendo - Nintendo Entertainment System - new FileSignature('.nes', [{ value: Buffer.from('NES') }]), + nes: new FileSignature('.nes', [{ value: Buffer.from('NES') }]), // Nintendo - Super Nintendo Entertainment System // @see https://snes.nesdev.org/wiki/ROM_header // @see https://en.wikibooks.org/wiki/Super_NES_Programming/SNES_memory_map // TODO(cemmer): add checks from LoROM, HiROM, etc. - new FileSignature('.smc', [{ offset: 3, value: Buffer.from('00'.repeat(509), 'hex') }]), + smc: new FileSignature('.smc', [{ offset: 3, value: Buffer.from('00'.repeat(509), 'hex') }]), // @see https://file-extension.net/seeker/file_extension_smc // @see https://wiki.superfamicom.org/game-doctor - new FileSignature('.smc', [{ value: Buffer.from('\x00\x01ME DOCTOR SF 3') }]), // Game Doctor SF3? - new FileSignature('.smc', [{ value: Buffer.from('GAME DOCTOR SF 3') }]), // Game Doctor SF3/SF6/SF7 + smc_gd3_1: new FileSignature('.smc', [{ value: Buffer.from('\x00\x01ME DOCTOR SF 3') }]), // Game Doctor SF3? + smc_gd3_2: new FileSignature('.smc', [{ value: Buffer.from('GAME DOCTOR SF 3') }]), // Game Doctor SF3/SF6/SF7 // Sega - 32X // @see https://github.com/jcfieldsdev/genesis-rom-utility/blob/31826bca66c8c6c467c37c1b711943eb5464e7e8/genesis_rom.chm // @see https://plutiedev.com/rom-header - new FileSignature('.32x', [{ offset: 0x1_00, value: Buffer.from('SEGA 32X') }]), + '32x': new FileSignature('.32x', [{ offset: 0x1_00, value: Buffer.from('SEGA 32X') }]), // Sega - Game Gear // @see https://gbatemp.net/threads/help-with-rom-iso-console-identification.611378/ - new FileSignature('.gg', [{ offset: 0x7F_F0, value: Buffer.from('TMR SEGA') }]), + gg: new FileSignature('.gg', [{ offset: 0x7F_F0, value: Buffer.from('TMR SEGA') }]), // Sega - Mega Drive / Genesis // @see https://github.com/jcfieldsdev/genesis-rom-utility/blob/31826bca66c8c6c467c37c1b711943eb5464e7e8/genesis_rom.chm // @see https://plutiedev.com/rom-header - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA ') }]), - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA IS A REGISTERED') }]), - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA IS A TRADEMARK ') }]), - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA GENESIS') }]), - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from(' SEGA GENESIS') }]), - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA_GENESIS') }]), - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA MEGADRIVE') }]), - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA MEGA DRIVE') }]), - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from(' SEGA MEGA DRIVE') }]), - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA_MEGA_DRIVE') }]), - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from(' SEGA_MEGA_DRIVE') }]), - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGASEGASEGA') }]), + md_1: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA ') }]), + md_2: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA IS A REGISTERED') }]), + md_3: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA IS A TRADEMARK ') }]), + md_4: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA GENESIS') }]), + md_5: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from(' SEGA GENESIS') }]), + md_6: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA_GENESIS') }]), + md_7: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA MEGADRIVE') }]), + md_8: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA MEGA DRIVE') }]), + md_9: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from(' SEGA MEGA DRIVE') }]), + md_10: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA_MEGA_DRIVE') }]), + md_11: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from(' SEGA_MEGA_DRIVE') }]), + md_12: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGASEGASEGA') }]), // @see https://www.romhacking.net/forum/index.php?topic=32880.msg415017#msg415017 - new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAGNSS ') }]), - new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAMG RV') }]), - new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAMG_RV') }]), - new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAMGDIE') }]), - new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('SG EEI ') }]), + smd_1: new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAGNSS ') }]), + smd_2: new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAMG RV') }]), + smd_3: new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAMG_RV') }]), + smd_4: new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('EAMGDIE') }]), + smd_5: new FileSignature('.smd', [{ offset: 0x2_80, value: Buffer.from('SG EEI ') }]), // Sega - PICO // @see https://github.com/jcfieldsdev/genesis-rom-utility/blob/31826bca66c8c6c467c37c1b711943eb5464e7e8/genesis_rom.chm // @see https://plutiedev.com/rom-header - new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA PICO') }]), + pico: new FileSignature('.md', [{ offset: 0x1_00, value: Buffer.from('SEGA PICO') }]), // Sony - PlayStation Portable - new FileSignature('.pbp', [{ value: Buffer.from('\x00PBP\x00\x00\x01\x00') }]), - ].sort((a, b) => { - // 1. Prefer files that check multiple signatures - const sigsCountDiff = b.fileSignatures.length - a.fileSignatures.length; - if (sigsCountDiff !== 0) { - return sigsCountDiff; - } + pbp: new FileSignature('.pbp', [{ value: Buffer.from('\x00PBP\x00\x00\x01\x00') }]), + }; + + private static readonly SIGNATURES_SORTED = Object.values(FileSignature.SIGNATURES) + .sort((a, b) => { + // 1. Prefer files that check multiple signatures + const sigsCountDiff = b.fileSignatures.length - a.fileSignatures.length; + if (sigsCountDiff !== 0) { + return sigsCountDiff; + } - // 2. Prefer signatures of longer length - return b.fileSignatures.reduce((sum, sig) => sum + sig.value.length, 0) - - a.fileSignatures.reduce((sum, sig) => sum + sig.value.length, 0); - }); + // 2. Prefer signatures of longer length + return b.fileSignatures.reduce((sum, sig) => sum + sig.value.length, 0) + - a.fileSignatures.reduce((sum, sig) => sum + sig.value.length, 0); + }); private static readonly MAX_HEADER_LENGTH_BYTES = Object.values(FileSignature.SIGNATURES) .flatMap((romSignature) => romSignature.fileSignatures) @@ -221,6 +226,10 @@ export default class FileSignature { this.fileSignatures = fileSignatures; } + static getKnownSignatureCount(): number { + return this.SIGNATURES_SORTED.length; + } + private static async readHeaderBuffer( stream: Readable, start: number, @@ -251,11 +260,15 @@ export default class FileSignature { }); } + static signatureFromName(name: string): FileSignature | undefined { + return this.SIGNATURES[name]; + } + static async signatureFromFileStream(stream: Readable): Promise { const fileHeader = await FileSignature .readHeaderBuffer(stream, 0, this.MAX_HEADER_LENGTH_BYTES); - for (const romSignature of this.SIGNATURES) { + for (const romSignature of this.SIGNATURES_SORTED) { const signatureMatch = romSignature.fileSignatures.every((fileSignature) => { const signatureValue = fileHeader.subarray( fileSignature.offset ?? 0, @@ -271,6 +284,12 @@ export default class FileSignature { return undefined; } + @Memoize() + getName(): string { + return Object.keys(FileSignature.SIGNATURES) + .find((name) => FileSignature.SIGNATURES[name] === this) as string; + } + getExtension(): string { return this.extension; } diff --git a/src/types/files/romHeader.ts b/src/types/files/romHeader.ts index 1f4d04681..7fda49b5d 100644 --- a/src/types/files/romHeader.ts +++ b/src/types/files/romHeader.ts @@ -6,7 +6,7 @@ import { Memoize } from 'typescript-memoize'; import ArrayPoly from '../../polyfill/arrayPoly.js'; export default class ROMHeader { - private static readonly HEADERS: { [key: string]:ROMHeader } = { + private static readonly HEADERS: { [key: string]: ROMHeader } = { // http://7800.8bitdev.org/index.php/A78_Header_Specification 'No-Intro_A7800.xml': new ROMHeader(1, '415441524937383030', 128, '.a78'), @@ -64,6 +64,10 @@ export default class ROMHeader { .sort(); } + static getKnownHeaderCount(): number { + return Object.keys(this.HEADERS).length; + } + static headerFromName(name: string): ROMHeader | undefined { return this.HEADERS[name]; }