Skip to content

Commit

Permalink
Feature: NINJA patch support (#183)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmercm authored Nov 26, 2022
1 parent ce2950b commit 041eee2
Show file tree
Hide file tree
Showing 13 changed files with 253 additions and 26 deletions.
30 changes: 15 additions & 15 deletions docs/rom-patching.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,21 @@ Not all patch types are created equal. Here are some tables of some existing for

**Uncommon patch types:**

| Type | Supported | CRC32 in patch contents | Notes |
|---------------------|-----------|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------|
| `.aps` (GBA) ||| |
| `.aps` (N64) || ⚠️ only type 1 patches | |
| `.bdf` (BSDiff) ||| |
| `.bsp` ||| Binary Script Patching will probably never be supported, the implementation is [non-trivial](https://github.com/aaaaaa123456789/bsp). |
| `.dps` ||| |
| `.ebp` (EarthBound) ||| |
| `.ffp` ||| |
| `.gdiff` ||| |
| `.mod` (Star Rod) ||| |
| `.pat` (FireFlower) ||| |
| `.pds` ||| |
| `.rup` (NINJA 2.0) | | ❌ uses MD5 | |
| `.rxl` ||| |
| Type | Supported | CRC32 in patch contents | Notes |
|---------------------|--------------------------------------------------------|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------|
| `.aps` (GBA) | || |
| `.aps` (N64) | | ⚠️ only type 1 patches | |
| `.bdf` (BSDiff) | || |
| `.bsp` | || Binary Script Patching will probably never be supported, the implementation is [non-trivial](https://github.com/aaaaaa123456789/bsp). |
| `.dps` | || |
| `.ebp` (EarthBound) | || |
| `.ffp` | || |
| `.gdiff` | || |
| `.mod` (Star Rod) | || |
| `.pat` (FireFlower) | || |
| `.pds` | || |
| `.rup` (NINJA 2.0) | ⚠️ only single file patches, only raw/binary file type | ❌ uses MD5 | |
| `.rxl` | || |

If you have a choice in patch format, choose one that contains CRC32 checksums in the patch file contents.

Expand Down
140 changes: 140 additions & 0 deletions src/types/patches/ninjaPatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import FilePoly from '../../polyfill/filePoly.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,
}

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

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

async apply<T>(file: File, callback: (tempFile: string) => (Promise<T> | T)): Promise<T> {
return this.getFile().extractToFile(async (patchFilePath) => {
const patchFile = await FilePoly.fileFrom(patchFilePath, 'r');

const header = (await patchFile.readNext(5)).toString();
if (header !== 'NINJA') {
await patchFile.close();
throw new Error(`NINJA patch header is invalid: ${this.getFile().toString()}`);
}
const version = parseInt((await patchFile.readNext(1)).toString(), 10);
if (version !== 2) {
await patchFile.close();
throw new Error(`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

const result = await file.extractToFile(async (tempFile) => {
const targetFile = await FilePoly.fileFrom(tempFile, 'r+');

/* eslint-disable no-await-in-loop */
while (!patchFile.isEOF()) {
const command = (await patchFile.readNext(1)).readUint8();

if (command === NinjaCommand.TERMINATE) {
break;
} else if (command === NinjaCommand.OPEN) {
const multiFile = (await patchFile.readNext(1)).readUint8();
if (multiFile > 0) {
await targetFile.close();
throw new Error(`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) {
await targetFile.close();
throw new Error(`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);
/* eslint-disable no-bitwise */
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());
}
}
} else if (command === NinjaCommand.XOR) {
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);
/* eslint-disable no-bitwise */
for (let i = 0; i < length; i += 1) {
targetData[i] = (i < sourceData.length ? sourceData[i] : 0x00) ^ xorData[i];
}
await targetFile.writeAt(targetData, offset);
}
}

await targetFile.close();

return callback(tempFile);
});

await patchFile.close();

return result;
});
}
}
4 changes: 4 additions & 0 deletions src/types/patches/patchFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'path';
import File from '../files/file.js';
import BPSPatch from './bpsPatch.js';
import IPSPatch from './ipsPatch.js';
import NinjaPatch from './ninjaPatch.js';
import Patch from './patch.js';
import PPFPatch from './ppfPatch.js';
import UPSPatch from './upsPatch.js';
Expand All @@ -16,6 +17,8 @@ export default class PatchFactory {
return BPSPatch.patchFrom(file);
} if (IPSPatch.SUPPORTED_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext))) {
return IPSPatch.patchFrom(file);
} if (NinjaPatch.SUPPORTED_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext))) {
return NinjaPatch.patchFrom(file);
} if (PPFPatch.SUPPORTED_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext))) {
return PPFPatch.patchFrom(file);
} if (UPSPatch.SUPPORTED_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext))) {
Expand All @@ -31,6 +34,7 @@ export default class PatchFactory {
return [
...BPSPatch.SUPPORTED_EXTENSIONS,
...IPSPatch.SUPPORTED_EXTENSIONS,
...NinjaPatch.SUPPORTED_EXTENSIONS,
...PPFPatch.SUPPORTED_EXTENSIONS,
...UPSPatch.SUPPORTED_EXTENSIONS,
...VcdiffPatch.SUPPORTED_EXTENSIONS,
Expand Down
7 changes: 7 additions & 0 deletions test/fixtures/dats/patchable.dat
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@
<!-- After: size="26" crc="6ff9ef96" -->
</game>

<!-- NINJA2 -->
<game name="612644F">
<description>612644F</description>
<rom name="612644F.rom" size="1025" crc="f7591b29" md5="92c62becd006bac8175304bb80dfb3aa" sha1="2bcd0216e1de4ee670ee4945d600beaf6acf8feb" status="verified"/>
<!-- After: size="1025" crc="922f5181" -->
</game>

<!-- PPF -->
<game name="C01173E">
<description>C01173E</description>
Expand Down
Binary file added test/fixtures/patches/9A71FA5 f7591b29.rup
Binary file not shown.
1 change: 1 addition & 0 deletions test/fixtures/roms/patchable/612644F.rom
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
612644F1BA96586196EC27060CAE9D3F147729D8857899F16BAEC1C41A78FEB7E64333FECBDAB8A499328A099441899A9C30A741A55709647250264D061A06B413F3197E3AF5316D29246217C7C4792E5436AFE1F36E91C2963E31845FE4941A23DD63CD9D4E78497A33BA785FF5042CDC3A208C12CD6E3C5F9312C0411BDA969D175E380CBC3DAE9A708A3F6D885B21BBF3DCF68A1264F89741020AC332400FFB65A0927871BD766B115E26E4FD5262EA9E41CA5BF6849CC0AAFF14247646EB680FAA5E8C3299C9FAE9B0B57A02F3781C1658B8EE3BA9842AE698C0C5D166F44078B2E0F3D53614FFA593CA48C006F0471F474DA91889FD4593AD1809EE1BDC5A3764B0EE21D4264D65ACEB4C20758E5522D2AB29914B23DBD4190ECE41E4CFFCBF3578C8B740A6417BFF288224FA93B99A67C664E9D825497C40E32AB82508CC860085651056EADB232D93F7FB63A6CA67CD7C02FB466613A2FE6310F7AA7ECEFF5119DDB9F4D7022DE678E05D83C6D8A3C1B01E44E20ED6040C9938FD5E5017A6AE602DF579A5E4B91F88B3E93096742EB75664FF5155B5D90AB728578BBF700588B3678076EAD8F44AF7592AEFB6FEFF445FC07187BB2670C63C972A662C03421AB023CD8323EA6801112C3BE14C36479BCB24B000E99D0B073BC9881A03E69AF5BDA011DA3B220B3BD250B2CF5354D5DE85C0294701E878643015362B8B
7 changes: 7 additions & 0 deletions test/igir.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ describe('with explicit dats', () => {
[path.join('One', 'One Three', 'One.rom'), 'f817a89f'],
[path.join('One', 'One Three', 'Three.rom'), 'ff46c5d8'],
[path.join('Patchable', '0F09A40.rom'), '2f943e86'],
[path.join('Patchable', '612644F.rom'), 'f7591b29'],
[path.join('Patchable', '65D1206.rom'), '20323455'],
[path.join('Patchable', 'Before.rom'), '0361b321'],
[path.join('Patchable', 'Best.rom'), '1e3d78cf'],
Expand All @@ -94,6 +95,7 @@ describe('with explicit dats', () => {
[`${path.join('One', 'One Three.zip')}|One.rom`, 'f817a89f'],
[`${path.join('One', 'One Three.zip')}|Three.rom`, 'ff46c5d8'],
[`${path.join('Patchable', '0F09A40.zip')}|0F09A40.rom`, '2f943e86'],
[`${path.join('Patchable', '612644F.zip')}|612644F.rom`, 'f7591b29'],
[`${path.join('Patchable', '65D1206.zip')}|65D1206.rom`, '20323455'],
[`${path.join('Patchable', 'Before.zip')}|Before.rom`, '0361b321'],
[`${path.join('Patchable', 'Best.zip')}|Best.rom`, '1e3d78cf'],
Expand All @@ -114,6 +116,7 @@ describe('with explicit dats', () => {
[path.join('One', 'One Three', 'One.rom'), 'f817a89f'],
[path.join('One', 'One Three', 'Three.rom'), 'ff46c5d8'],
[path.join('Patchable', '0F09A40.rom'), '2f943e86'],
[path.join('Patchable', '612644F.rom'), 'f7591b29'],
[path.join('Patchable', '65D1206.rom'), '20323455'],
[path.join('Patchable', 'Before.rom'), '0361b321'],
[path.join('Patchable', 'Best.rom'), '1e3d78cf'],
Expand All @@ -136,8 +139,10 @@ describe('with explicit dats', () => {
[path.join('One', 'One Three', 'Three.rom'), 'ff46c5d8'],
[path.join('Patchable', '0F09A40.rom'), '2f943e86'],
[path.join('Patchable', '4FE952A.rom'), '1fb4f81f'],
[path.join('Patchable', '612644F.rom'), 'f7591b29'],
[path.join('Patchable', '65D1206.rom'), '20323455'],
[path.join('Patchable', '949F2B7.rom'), '95284ab4'],
[path.join('Patchable', '9A71FA5.rom'), '922f5181'],
[path.join('Patchable', '9E66269.rom'), '8bb5cc63'],
[path.join('Patchable', 'After.rom'), '4c8e44d4'],
[path.join('Patchable', 'Before.rom'), '0361b321'],
Expand Down Expand Up @@ -170,6 +175,7 @@ describe('with inferred dats', () => {
commands: ['copy', 'test'],
}, [
['0F09A40.rom', '2f943e86'],
['612644F.rom', 'f7591b29'],
['65D1206.rom', '20323455'],
['allpads.nes', '9180a163'],
['before.rom', '0361b321'],
Expand Down Expand Up @@ -201,6 +207,7 @@ describe('with inferred dats', () => {
commands: ['copy', 'zip', 'test'],
}, [
['0F09A40.zip|0F09A40.rom', '2f943e86'],
['612644F.zip|612644F.rom', 'f7591b29'],
['65D1206.zip|65D1206.rom', '20323455'],
['allpads.zip|allpads.nes', '9180a163'],
['before.zip|before.rom', '0361b321'],
Expand Down
2 changes: 1 addition & 1 deletion test/modules/datInferrer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ test.each([
[path.join('test', 'fixtures', 'roms')]: 5,
[path.join('test', 'fixtures', 'roms', '7z')]: 5,
[path.join('test', 'fixtures', 'roms', 'headered')]: 6,
[path.join('test', 'fixtures', 'roms', 'patchable')]: 6,
[path.join('test', 'fixtures', 'roms', 'patchable')]: 7,
[path.join('test', 'fixtures', 'roms', 'rar')]: 5,
[path.join('test', 'fixtures', 'roms', 'raw')]: 8,
[path.join('test', 'fixtures', 'roms', 'tar')]: 5,
Expand Down
2 changes: 1 addition & 1 deletion test/modules/patchCandidateGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ it('should create patch candidates with relevant patches', async () => {
}), new ProgressBarFake()).scan();
const parentsToCandidates = await runPatchCandidateGenerator(romFiles);

expect(parentsToCandidates.size).toEqual(6);
expect(parentsToCandidates.size).toEqual(7);
[...parentsToCandidates.values()]
.forEach((releaseCandidates) => expect(releaseCandidates).toHaveLength(2));
});
4 changes: 2 additions & 2 deletions test/modules/patchScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ it('should return empty list on no results', async () => {
});

it('should scan multiple files', async () => {
const expectedPatchFiles = 6;
const expectedPatchFiles = 7;
await expect(createPatchScanner(['test/fixtures/patches/*']).scan()).resolves.toHaveLength(expectedPatchFiles);
await expect(createPatchScanner(['test/fixtures/patches/**/*']).scan()).resolves.toHaveLength(expectedPatchFiles);
await expect(createPatchScanner(['test/fixtures/*/*.{bps,ips,ips32,ppf,ups,vcdiff,xdelta}']).scan()).resolves.toHaveLength(expectedPatchFiles);
await expect(createPatchScanner(['test/fixtures/*/*.{bps,ips,ips32,ppf,rup,ups,vcdiff,xdelta}']).scan()).resolves.toHaveLength(expectedPatchFiles);
});

it('should scan single files', async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/modules/romScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ it('should not throw on bad archives', async () => {

describe('multiple files', () => {
it('no files are excluded', async () => {
const expectedRomFiles = 54;
const expectedRomFiles = 55;
await expect(createRomScanner(['test/fixtures/roms']).scan()).resolves.toHaveLength(expectedRomFiles);
await expect(createRomScanner(['test/fixtures/roms/*', 'test/fixtures/roms/**/*']).scan()).resolves.toHaveLength(expectedRomFiles);
await expect(createRomScanner(['test/fixtures/roms/**/*']).scan()).resolves.toHaveLength(expectedRomFiles);
Expand Down
Loading

0 comments on commit 041eee2

Please sign in to comment.