emmercm/igir

View on GitHub
src/types/patches/ninjaPatch.ts

Summary

Maintainability
B
4 hrs
Test Coverage
import FilePoly from '../../polyfill/filePoly.js';
import ExpectedError from '../expectedError.js';
import File from '../files/file.js';
import Patch from './patch.js';

enum NinjaCommand {
  TERMINATE = 0x00,
  OPEN = 0x01,
  XOR = 0x02,
}

enum NinjaFileType {
  RAW = 0,
  NES = 1,
  FDS = 2,
  SNES = 3,
  N64 = 4,
  GB = 5,
  SMS = 6,
  MEGA = 7,
  PCE = 8,
  LYNX = 9,
}

/**
 * @see https://www.romhacking.net/utilities/329/
 */
export default class NinjaPatch extends Patch {
  static readonly SUPPORTED_EXTENSIONS = ['.rup'];

  static readonly FILE_SIGNATURE = Buffer.from('NINJA');

  static patchFrom(file: File): NinjaPatch {
    const crcBefore = Patch.getCrcFromPath(file.getExtractedFilePath());
    return new NinjaPatch(file, crcBefore);
  }

  async createPatchedFile(inputRomFile: File, outputRomPath: string): Promise<void> {
    return this.getFile().extractToTempFilePoly('r', async (patchFile) => {
      const header = await patchFile.readNext(5);
      if (!header.equals(NinjaPatch.FILE_SIGNATURE)) {
        throw new ExpectedError(`NINJA patch header is invalid: ${this.getFile().toString()}`);
      }
      const version = Number.parseInt((await patchFile.readNext(1)).toString(), 10);
      if (version !== 2) {
        throw new ExpectedError(`NINJA v${version} isn't supported: ${this.getFile().toString()}`);
      }

      patchFile.skipNext(1); // encoding
      patchFile.skipNext(84); // author
      patchFile.skipNext(11); // version
      patchFile.skipNext(256); // title
      patchFile.skipNext(48); // genre
      patchFile.skipNext(48); // language
      patchFile.skipNext(8); // date
      patchFile.skipNext(512); // website
      patchFile.skipNext(1074); // info

      return this.writeOutputFile(inputRomFile, outputRomPath, patchFile);
    });
  }

  private async writeOutputFile(
    inputRomFile: File,
    outputRomPath: string,
    patchFile: FilePoly,
  ): Promise<void> {
    await inputRomFile.extractToFile(outputRomPath);
    const targetFile = await FilePoly.fileFrom(outputRomPath, 'r+');

    try {
      while (!patchFile.isEOF()) {
        await this.applyCommand(patchFile, targetFile);
      }
    } finally {
      await targetFile.close();
    }
  }

  private async applyCommand(patchFile: FilePoly, targetFile: FilePoly): Promise<void> {
    const command = (await patchFile.readNext(1)).readUInt8();

    if (command === NinjaCommand.TERMINATE) {
      // Nothing
    } else if (command === NinjaCommand.OPEN) {
      await this.applyCommandOpen(patchFile, targetFile);
    } else if (command === NinjaCommand.XOR) {
      await NinjaPatch.applyCommandXor(patchFile, targetFile);
    }
  }

  private async applyCommandOpen(patchFile: FilePoly, targetFile: FilePoly): Promise<void> {
    const multiFile = (await patchFile.readNext(1)).readUInt8();
    if (multiFile > 0) {
      throw new ExpectedError(
        `Multi-file NINJA patches aren't supported: ${this.getFile().toString()}`,
      );
    }

    const fileNameLength =
      multiFile > 0 ? (await patchFile.readNext(multiFile)).readUIntLE(0, multiFile) : 0;
    patchFile.skipNext(fileNameLength); // file name
    const fileType = (await patchFile.readNext(1)).readUInt8();
    if (fileType > 0) {
      throw new ExpectedError(
        `unsupported NINJA file type ${NinjaFileType[fileType]}: ${this.getFile().toString()}`,
      );
    }
    const sourceFileSizeLength = (await patchFile.readNext(1)).readUInt8();
    const sourceFileSize = (await patchFile.readNext(sourceFileSizeLength)).readUIntLE(
      0,
      sourceFileSizeLength,
    );
    const modifiedFileSizeLength = (await patchFile.readNext(1)).readUInt8();
    const modifiedFileSize = (await patchFile.readNext(modifiedFileSizeLength)).readUIntLE(
      0,
      modifiedFileSizeLength,
    );
    patchFile.skipNext(16); // source MD5
    patchFile.skipNext(16); // modified MD5

    if (sourceFileSize !== modifiedFileSize) {
      patchFile.skipNext(1); // "M" or "A"
      const overflowSizeLength = (await patchFile.readNext(1)).readUInt8();
      const overflowSize =
        overflowSizeLength > 0
          ? (await patchFile.readNext(overflowSizeLength)).readUIntLE(0, overflowSizeLength)
          : 0;
      const overflow =
        overflowSize > 0 ? await patchFile.readNext(overflowSize) : Buffer.alloc(overflowSize);
      for (let i = 0; i < overflow.length; i += 1) {
        overflow[i] ^= 255; // NOTE(cemmer): this isn't documented anywhere
      }
      if (modifiedFileSize > sourceFileSize) {
        await targetFile.writeAt(overflow, targetFile.getSize());
      }
    }
  }

  private static async applyCommandXor(patchFile: FilePoly, targetFile: FilePoly): Promise<void> {
    const offsetLength = (await patchFile.readNext(1)).readUInt8();
    const offset = (await patchFile.readNext(offsetLength)).readUIntLE(0, offsetLength);
    targetFile.seek(offset);

    const lengthLength = (await patchFile.readNext(1)).readUInt8();
    const length = (await patchFile.readNext(lengthLength)).readUIntLE(0, lengthLength);
    const sourceData = await targetFile.readNext(length);

    const xorData = await patchFile.readNext(length);
    const targetData = Buffer.allocUnsafe(length);
    for (let i = 0; i < length; i += 1) {
      targetData[i] = (i < sourceData.length ? sourceData[i] : 0x00) ^ xorData[i];
    }
    await targetFile.writeAt(targetData, offset);
  }
}