emmercm/igir

View on GitHub
src/types/files/romHeader.ts

Summary

Maintainability
A
3 hrs
Test Coverage
import path from 'node:path';
import { Readable } from 'node:stream';

import { Memoize } from 'typescript-memoize';

import ArrayPoly from '../../polyfill/arrayPoly.js';

export default class 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'),

    // https://atarigamer.com/lynx/lnxhdrgen
    'No-Intro_LNX.xml': new ROMHeader(0, '4C594E58', 64, '.lnx', '.lyx'),

    // https://www.nesdev.org/wiki/INES
    'No-Intro_NES.xml': new ROMHeader(0, '4E4553', 16, '.nes'),

    // https://www.nesdev.org/wiki/FDS_file_format
    'No-Intro_FDS.xml': new ROMHeader(0, '464453', 16, '.fds'),

    // https://en.wikibooks.org/wiki/Super_NES_Programming/SNES_memory_map#The_SNES_header
    SMC: new ROMHeader(3, '00'.repeat(509), 512, '.smc', '.sfc'),
    // https://file-extension.net/seeker/file_extension_smc
    // https://wiki.superfamicom.org/game-doctor
    SMC_GAME_DOCTOR_1: new ROMHeader(0, '00014D4520444F43544F522053462033', 512, '.smc', '.sfc'),
    SMC_GAME_DOCTOR_2: new ROMHeader(0, '47414D4520444F43544F522053462033', 512, '.smc', '.sfc'),
  };

  private static readonly MAX_HEADER_LENGTH_BYTES = Object.values(ROMHeader.HEADERS).reduce(
    (max, fileHeader) =>
      Math.max(max, fileHeader.headerOffsetBytes + fileHeader.headerValue.length / 2),
    0,
  );

  private readonly headerOffsetBytes: number;

  private readonly headerValue: string;

  private readonly dataOffsetBytes: number;

  private readonly headeredFileExtension: string;

  private readonly headerlessFileExtension: string;

  private constructor(
    headerOffsetBytes: number,
    headerValue: string,
    dataOffset: number,
    headeredFileExtension: string,
    headerlessFileExtension?: string,
  ) {
    this.headerOffsetBytes = headerOffsetBytes;
    this.headerValue = headerValue;
    this.dataOffsetBytes = dataOffset;
    this.headeredFileExtension = headeredFileExtension;
    this.headerlessFileExtension = headerlessFileExtension ?? headeredFileExtension;
  }

  static getSupportedExtensions(): string[] {
    return Object.values(this.HEADERS)
      .map((header) => header.headeredFileExtension)
      .reduce(ArrayPoly.reduceUnique(), [])
      .sort();
  }

  static getKnownHeaderCount(): number {
    return Object.keys(this.HEADERS).length;
  }

  static headerFromName(name: string): ROMHeader | undefined {
    return this.HEADERS[name];
  }

  static headerFromFilename(filePath: string): ROMHeader | undefined {
    const headers = Object.values(this.HEADERS);
    for (const header of headers) {
      if (
        header.headeredFileExtension.toLowerCase() === path.extname(filePath).toLowerCase() ||
        (header.headerlessFileExtension?.toLowerCase() ?? '') ===
          path.extname(filePath).toLowerCase()
      ) {
        return header;
      }
    }
    return undefined;
  }

  private static async readHeaderHex(
    stream: Readable,
    start: number,
    end: number,
  ): Promise<string> {
    return new Promise((resolve, reject) => {
      stream.resume();

      const chunks: Buffer[] = [];
      const resolveHeader: () => void = () => {
        const header = Buffer.concat(chunks).subarray(start, end).toString('hex').toUpperCase();
        resolve(header);
      };

      stream.on('data', (chunk) => {
        chunks.push(Buffer.from(chunk));

        // Stop reading when we get enough data, trigger a 'close' event
        if (chunks.reduce((sum, buff) => sum + buff.length, 0) >= end) {
          resolveHeader();
          // WARN(cemmer): whatever created the stream may need to drain it!
        }
      });

      stream.on('end', resolveHeader);
      stream.on('error', reject);
    });
  }

  static async headerFromFileStream(stream: Readable): Promise<ROMHeader | undefined> {
    const fileHeader = await ROMHeader.readHeaderHex(stream, 0, this.MAX_HEADER_LENGTH_BYTES);

    const headers = Object.values(this.HEADERS);
    for (const header of headers) {
      const headerValue = fileHeader.slice(
        header.headerOffsetBytes * 2,
        header.headerOffsetBytes * 2 + header.headerValue.length,
      );
      if (headerValue === header.headerValue) {
        return header;
      }
    }

    return undefined;
  }

  @Memoize()
  getName(): string {
    return Object.keys(ROMHeader.HEADERS).find(
      (name) => ROMHeader.HEADERS[name] === this,
    ) as string;
  }

  getDataOffsetBytes(): number {
    return this.dataOffsetBytes;
  }

  getHeaderedFileExtension(): string {
    return this.headeredFileExtension;
  }

  getHeaderlessFileExtension(): string {
    return this.headerlessFileExtension;
  }
}