Skip to content

Commit

Permalink
Feature: APS patch support (#306)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmercm authored Mar 10, 2023
1 parent 05094a8 commit a462a39
Show file tree
Hide file tree
Showing 23 changed files with 302 additions and 48 deletions.
3 changes: 3 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf

[*.rom]
insert_final_newline = false
4 changes: 2 additions & 2 deletions docs/rom-patching.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ Not all patch types are created equal. Here are some tables of some existing for

| Type | Supported | CRC32 in patch contents | Notes |
|---------------------|--------------------------------------------------------|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------|
| `.aps` (GBA) | || |
| `.aps` (N64) | | ⚠️ only type 1 patches | |
| `.aps` (GBA) | || |
| `.aps` (N64) | ✅ simple & N64 | | |
| `.bdf` (BSDiff) ||| |
| `.bsp` ||| Binary Script Patching will probably never be supported, the implementation is [non-trivial](https://github.com/aaaaaa123456789/bsp). |
| `.dps` ||| |
Expand Down
2 changes: 1 addition & 1 deletion src/polyfill/fsPoly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default class FsPoly {
static async mktemp(prefix: string): Promise<string> {
/* eslint-disable no-constant-condition, no-await-in-loop */
while (true) {
const randomExtension = crypto.randomBytes(4).readUInt32LE(0).toString(36);
const randomExtension = crypto.randomBytes(4).readUInt32LE().toString(36);
const filePath = `${prefix.replace(/\.+$/, '')}.${randomExtension}`;
if (!await this.exists(filePath)) {
return filePath;
Expand Down
80 changes: 80 additions & 0 deletions src/types/patches/apsGbaPatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import FilePoly from '../../polyfill/filePoly.js';
import fsPoly from '../../polyfill/fsPoly.js';
import File from '../files/file.js';
import Patch from './patch.js';

/**
* @see https://github.com/btimofeev/UniPatcher/wiki/APS-(GBA)
* @see https://github.com/Gamer2020/Unofficial-A-ptch
*/
export default class APSGBAPatch extends Patch {
static readonly FILE_SIGNATURE = Buffer.from('APS1');

static async patchFrom(file: File): Promise<APSGBAPatch> {
const crcBefore = Patch.getCrcFromPath(file.getExtractedFilePath());
let targetSize = 0;

await file.extractToTempFilePoly('r', async (patchFile) => {
patchFile.seek(APSGBAPatch.FILE_SIGNATURE.length);
patchFile.skipNext(4); // original file size
targetSize = (await patchFile.readNext(4)).readUInt32LE();
});

return new APSGBAPatch(file, crcBefore, undefined, targetSize);
}

async createPatchedFile(inputRomFile: File, outputRomPath: string): Promise<void> {
return this.getFile().extractToTempFilePoly('r', async (patchFile) => {
const header = await patchFile.readNext(APSGBAPatch.FILE_SIGNATURE.length);
if (!header.equals(APSGBAPatch.FILE_SIGNATURE)) {
throw new Error(`APS (GBA) patch header is invalid: ${this.getFile().toString()}`);
}

patchFile.skipNext(4); // original size
patchFile.skipNext(4); // patched size

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

private static async writeOutputFile(
inputRomFile: File,
outputRomPath: string,
patchFile: FilePoly,
): Promise<void> {
return inputRomFile.extractToTempFile(async (tempRomFile) => {
const sourceFile = await FilePoly.fileFrom(tempRomFile, 'r');

await fsPoly.copyFile(tempRomFile, outputRomPath);
const targetFile = await FilePoly.fileFrom(outputRomPath, 'r+');

try {
await APSGBAPatch.applyPatch(patchFile, sourceFile, targetFile);
} finally {
await targetFile.close();
await sourceFile.close();
}
});
}

private static async applyPatch(
patchFile: FilePoly,
sourceFile: FilePoly,
targetFile: FilePoly,
): Promise<void> {
/* eslint-disable no-await-in-loop, no-bitwise */
while (patchFile.getPosition() < patchFile.getSize()) {
const offset = (await patchFile.readNext(4)).readUInt32LE();
patchFile.skipNext(2); // CRC16 of original 64KiB block
patchFile.skipNext(2); // CRC16 of patched 64KiB block
const xorData = await patchFile.readNext(1024 * 1024);

const sourceData = await sourceFile.readAt(offset, xorData.length);
const targetData = Buffer.allocUnsafe(xorData.length);
for (let i = 0; i < xorData.length; i += 1) {
targetData[i] = (i < sourceData.length ? sourceData[i] : 0x00) ^ xorData[i];
}
await targetFile.writeAt(targetData, offset);
}
}
}
108 changes: 108 additions & 0 deletions src/types/patches/apsN64Patch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import FilePoly from '../../polyfill/filePoly.js';
import File from '../files/file.js';
import Patch from './patch.js';

enum APSN64PatchType {
SIMPLE = 0,
N64 = 1,
}

/**
* @see https://github.com/btimofeev/UniPatcher/wiki/APS-(N64)
*/
export default class APSN64Patch extends Patch {
static readonly FILE_SIGNATURE = Buffer.from('APS10');

private readonly patchType: APSN64PatchType;

protected constructor(
patchType: APSN64PatchType,
file: File,
crcBefore: string,
sizeAfter: number,
) {
super(file, crcBefore, undefined, sizeAfter);
this.patchType = patchType;
}

static async patchFrom(file: File): Promise<APSN64Patch> {
let patchType = APSN64PatchType.SIMPLE;
const crcBefore = Patch.getCrcFromPath(file.getExtractedFilePath());
let targetSize = 0;

await file.extractToTempFilePoly('r', async (patchFile) => {
patchFile.seek(APSN64Patch.FILE_SIGNATURE.length);
patchType = (await patchFile.readNext(1)).readUInt8();
patchFile.skipNext(1); // encoding method
patchFile.skipNext(50); // description

if (patchType === APSN64PatchType.SIMPLE) {
targetSize = (await patchFile.readNext(4)).readUInt32LE();
} else if (patchType === APSN64PatchType.N64) {
patchFile.skipNext(1); // ROM format
patchFile.skipNext(2); // cart ID string (*'s from: NUS-N**X-XXX)
patchFile.skipNext(1); // country string (* from: NUS-NXX*-XXX)
patchFile.skipNext(8); // CRC within the ROM (NOT the entire ROM CRC)
patchFile.skipNext(5); // padding
targetSize = (await patchFile.readNext(4)).readUInt32LE();
} else {
throw new Error(`APS (N64) patch type ${patchType} isn't supported: ${patchFile.getPathLike()}`);
}
});

return new APSN64Patch(patchType, file, crcBefore, targetSize);
}

async createPatchedFile(inputRomFile: File, outputRomPath: string): Promise<void> {
return this.getFile().extractToTempFilePoly('r', async (patchFile) => {
const header = await patchFile.readNext(APSN64Patch.FILE_SIGNATURE.length);
if (!header.equals(APSN64Patch.FILE_SIGNATURE)) {
throw new Error(`APS (N64) patch header is invalid: ${this.getFile().toString()}`);
}

if (this.patchType === APSN64PatchType.SIMPLE) {
patchFile.seek(61);
} else if (this.patchType === APSN64PatchType.N64) {
patchFile.seek(78);
} else {
throw new Error(`APS (N64) patch type ${this.patchType} isn't supported: ${patchFile.getPathLike()}`);
}

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

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

try {
await APSN64Patch.applyPatch(patchFile, targetFile);
} finally {
await targetFile.close();
}
}

private static async applyPatch(patchFile: FilePoly, targetFile: FilePoly): Promise<void> {
/* eslint-disable no-await-in-loop, no-bitwise */
while (patchFile.getPosition() < patchFile.getSize()) {
const offset = (await patchFile.readNext(4)).readUInt32LE();
const size = (await patchFile.readNext(1)).readUInt8();
let data: Buffer;
if (size === 0) {
// Run-length encoding record
const byte = await patchFile.readNext(1);
const rleSize = (await patchFile.readNext(1)).readUInt8();
data = Buffer.from(byte.toString('hex').repeat(rleSize), 'hex');
} else {
// Standard record
data = await patchFile.readNext(size);
}
await targetFile.writeAt(data, offset);
}
}
}
27 changes: 27 additions & 0 deletions src/types/patches/apsPatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import File from '../files/file.js';
import APSGBAPatch from './apsGbaPatch.js';
import APSN64Patch from './apsN64Patch.js';
import Patch from './patch.js';

/**
* @see https://github.com/btimofeev/UniPatcher/wiki/APS-(N64)
*/
export default abstract class APSPatch extends Patch {
static readonly SUPPORTED_EXTENSIONS = ['.aps'];

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

static async patchFrom(file: File): Promise<Patch> {
return file.extractToTempFilePoly('r', async (patchFile) => {
patchFile.seek(APSPatch.FILE_SIGNATURE.length);

const byteFive = (await patchFile.readNext(1)).toString();
const byteSix = (await patchFile.readNext(1)).readUInt8();

if (byteFive === '0' && (byteSix === 0 || byteSix === 1)) {
return APSN64Patch.patchFrom(file);
}
return APSGBAPatch.patchFrom(file);
});
}
}
3 changes: 1 addition & 2 deletions src/types/patches/bpsPatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default class BPSPatch extends Patch {
let targetSize = 0;

await file.extractToTempFilePoly('r', async (patchFile) => {
patchFile.seek(4); // header
patchFile.seek(BPSPatch.FILE_SIGNATURE.length);
await Patch.readUpsUint(patchFile); // source size
targetSize = await Patch.readUpsUint(patchFile); // target size

Expand All @@ -45,7 +45,6 @@ export default class BPSPatch extends Patch {
// Skip header info
const header = await patchFile.readNext(4);
if (!header.equals(BPSPatch.FILE_SIGNATURE)) {
await patchFile.close();
throw new Error(`BPS patch header is invalid: ${this.getFile().toString()}`);
}
await Patch.readUpsUint(patchFile); // source size
Expand Down
12 changes: 5 additions & 7 deletions src/types/patches/ipsPatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export default class IPSPatch extends Patch {
return this.getFile().extractToTempFilePoly('r', async (patchFile) => {
const header = await patchFile.readNext(5);
if (IPSPatch.FILE_SIGNATURES.every((fileSignature) => !header.equals(fileSignature))) {
await patchFile.close();
throw new Error(`IPS patch header is invalid: ${this.getFile().toString()}`);
}

Expand Down Expand Up @@ -71,19 +70,18 @@ export default class IPSPatch extends Patch {
break;
}

const offset = (await patchFile.readNext(offsetSize)).readUintBE(0, offsetSize);
const offset = (await patchFile.readNext(offsetSize)).readUIntBE(0, offsetSize);
const size = (await patchFile.readNext(2)).readUInt16BE();
let data: Buffer;
if (size === 0) {
// Run-length encoding record
const rleSize = (await patchFile.readNext(2)).readUInt16BE();
const data = Buffer.from((await patchFile.readNext(1)).toString('hex')
.repeat(rleSize), 'hex');
await targetFile.writeAt(data, offset);
data = Buffer.from((await patchFile.readNext(1)).toString('hex').repeat(rleSize), 'hex');
} else {
// Standard record
const data = await patchFile.readNext(size);
await targetFile.writeAt(data, offset);
data = await patchFile.readNext(size);
}
await targetFile.writeAt(data, offset);
}
}
}
19 changes: 8 additions & 11 deletions src/types/patches/ninjaPatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,10 @@ export default class NinjaPatch extends Patch {
return this.getFile().extractToTempFilePoly('r', async (patchFile) => {
const header = await patchFile.readNext(5);
if (!header.equals(NinjaPatch.FILE_SIGNATURE)) {
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()}`);
}

Expand Down Expand Up @@ -80,7 +78,7 @@ export default class NinjaPatch extends Patch {
}

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

if (command === NinjaCommand.TERMINATE) {
// Nothing
Expand All @@ -92,7 +90,7 @@ export default class NinjaPatch extends Patch {
}

private async applyCommandOpen(patchFile: FilePoly, targetFile: FilePoly): Promise<void> {
const multiFile = (await patchFile.readNext(1)).readUint8();
const multiFile = (await patchFile.readNext(1)).readUInt8();
if (multiFile > 0) {
throw new Error(`Multi-file NINJA patches aren't supported: ${this.getFile().toString()}`);
}
Expand All @@ -101,22 +99,22 @@ export default class NinjaPatch extends Patch {
? (await patchFile.readNext(multiFile)).readUIntLE(0, multiFile)
: 0;
patchFile.skipNext(fileNameLength); // file name
const fileType = (await patchFile.readNext(1)).readUint8();
const fileType = (await patchFile.readNext(1)).readUInt8();
if (fileType > 0) {
throw new Error(`Unsupported NINJA file type ${NinjaFileType[fileType]}: ${this.getFile().toString()}`);
}
const sourceFileSizeLength = (await patchFile.readNext(1)).readUint8();
const sourceFileSizeLength = (await patchFile.readNext(1)).readUInt8();
const sourceFileSize = (await patchFile.readNext(sourceFileSizeLength))
.readUIntLE(0, sourceFileSizeLength);
const modifiedFileSizeLength = (await patchFile.readNext(1)).readUint8();
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 overflowSizeLength = (await patchFile.readNext(1)).readUInt8();
const overflowSize = overflowSizeLength > 0
? (await patchFile.readNext(overflowSizeLength)).readUIntLE(0, overflowSizeLength)
: 0;
Expand All @@ -134,17 +132,16 @@ export default class NinjaPatch extends Patch {
}

private static async applyCommandXor(patchFile: FilePoly, targetFile: FilePoly): Promise<void> {
const offsetLength = (await patchFile.readNext(1)).readUint8();
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 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];
}
Expand Down
4 changes: 2 additions & 2 deletions src/types/patches/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export default abstract class Patch {

/* eslint-disable no-await-in-loop, no-bitwise */
while (!fp.isEOF()) {
const bits = (await fp.readNext(1)).readUint8();
const bits = (await fp.readNext(1)).readUInt8();
num = (num << 7) + (bits & 0x7f);
if (!(bits & 0x80)) { // left-most bit is telling us to keep going
break;
Expand All @@ -91,7 +91,7 @@ export default abstract class Patch {

let lastOffset = offset;
while (lastOffset < buffer.length) {
const bits = buffer.readUint8(lastOffset);
const bits = buffer.readUInt8(lastOffset);
lastOffset += 1;
num = (num << 7) + (bits & 0x7f);
if (!(bits & 0x80)) { // left-most bit is telling us to keep going
Expand Down
Loading

0 comments on commit a462a39

Please sign in to comment.