Skip to content

Commit

Permalink
Feature: patch scanner (#145)
Browse files Browse the repository at this point in the history
  • Loading branch information
emmercm authored Oct 31, 2022
1 parent 525c06c commit e01fc18
Show file tree
Hide file tree
Showing 16 changed files with 234 additions and 50 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,14 @@ build/
*.txt
*.xml

# ROM patches
*.bps
*.ips

# Always commit fixtures
!test/fixtures/dats/*
!test/fixtures/dats/*/*
!test/fixtures/patches/*
!test/fixtures/roms/*
!test/fixtures/roms/*/*

Expand Down
9 changes: 8 additions & 1 deletion src/console/progressBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ export default abstract class ProgressBar {
abstract done(finishedMessage?: string): Promise<void>;

async doneItems(count: number, noun: string, verb: string): Promise<void> {
return this.done(`${count.toLocaleString()} ${noun.trim()}${count !== 1 ? 's' : ''} ${verb}`);
let pluralSuffix = 's';
if (noun.toLowerCase().endsWith('ch')
|| noun.toLowerCase().endsWith('s')
) {
pluralSuffix = 'es';
}

return this.done(`${count.toLocaleString()} ${noun.trim()}${count !== 1 ? pluralSuffix : ''} ${verb}`);
}

abstract log(logLevel: LogLevel, message: string): Promise<void>;
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export default class Constants {

static readonly ROM_SCANNER_THREADS = 25;

static readonly PATCH_SCANNER_THREADS = 25;

static readonly ROM_HEADER_HASHER_THREADS = 25;

static readonly DAT_THREADS = 3;
Expand Down
28 changes: 28 additions & 0 deletions src/modules/patchScanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Symbols } from '../console/progressBar.js';
import Constants from '../constants.js';
import Patch from '../types/patches/patch.js';
import PatchFactory from '../types/patches/patchFactory.js';
import Scanner from './scanner.js';

export default class PatchScanner extends Scanner {
async scan(): Promise<Patch[]> {
await this.progressBar.logInfo('Scanning patch files');

await this.progressBar.setSymbol(Symbols.SEARCHING);
await this.progressBar.reset(this.options.getPatchFileCount());

const patchFilePaths = await this.options.scanPatchFiles();
await this.progressBar.logInfo(`Found ${patchFilePaths.length} patch file${patchFilePaths.length !== 1 ? 's' : ''}`);
await this.progressBar.reset(patchFilePaths.length);

const files = await this.getFilesFromPaths(
patchFilePaths,
Constants.PATCH_SCANNER_THREADS,
);
const patches = files.map((file) => PatchFactory.patchFrom(file));

await this.progressBar.doneItems(patches.length, 'unique patch', 'found');

return patches;
}
}
25 changes: 10 additions & 15 deletions src/modules/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import async, { AsyncResultCallback } from 'async';
import path from 'path';

import ProgressBar from '../console/progressBar.js';
import ArchiveFactory from '../types/archives/archiveFactory.js';
import FileFactory from '../types/archives/fileFactory.js';
import Rar from '../types/archives/rar.js';
import SevenZip from '../types/archives/sevenZip.js';
import Tar from '../types/archives/tar.js';
Expand Down Expand Up @@ -53,21 +53,16 @@ export default abstract class Scanner {
}

private async getFilesFromPath(filePath: string): Promise<File[]> {
let files: File[];
if (ArchiveFactory.isArchive(filePath)) {
try {
files = await ArchiveFactory.archiveFrom(filePath).getArchiveEntries();
if (!files.length) {
await this.progressBar.logWarn(`Found no files in archive: ${filePath}`);
}
} catch (e) {
await this.progressBar.logError(`Failed to parse archive ${filePath} : ${e}`);
files = [];
try {
const files = await FileFactory.filesFrom(filePath);
if (!files.length) {
await this.progressBar.logWarn(`Found no files in path: ${filePath}`);
}
} else {
files = [await File.fileOf(filePath)];
return files;
} catch (e) {
await this.progressBar.logError(`Failed to parse file ${filePath} : ${e}`);
return [];
}
return files;
}

private fileComparator(one: File, two: File): number {
Expand All @@ -91,7 +86,7 @@ export default abstract class Scanner {
}

/**
* This ordering should match {@link ArchiveFactory#archiveFrom}
* This ordering should match {@link FileFactory#archiveFrom}
*/
private static archiveEntryPriority(file: File): number {
if (!(file instanceof ArchiveEntry)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import File from '../files/file.js';
import Archive from './archive.js';
import Rar from './rar.js';
import SevenZip from './sevenZip.js';
import Tar from './tar.js';
import Zip from './zip.js';

export default class ArchiveFactory {
export default class FileFactory {
static async filesFrom(filePath: string): Promise<File[]> {
if (this.isArchive(filePath)) {
return this.archiveFrom(filePath).getArchiveEntries();
}
return [await File.fileOf(filePath)];
}

/**
* This ordering should match {@link ROMScanner#archiveEntryPriority}
*/
static archiveFrom(filePath: string): Archive {
private static archiveFrom(filePath: string): Archive {
if (Zip.SUPPORTED_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext))) {
return new Zip(filePath);
} if (Tar.SUPPORTED_EXTENSIONS.some((ext) => filePath.toLowerCase().endsWith(ext))) {
Expand Down
12 changes: 12 additions & 0 deletions src/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface OptionsProps {
readonly dat?: string[],
readonly input?: string[],
readonly inputExclude?: string[],
readonly patch?: string[],
readonly output?: string,

readonly header?: string,
Expand Down Expand Up @@ -72,6 +73,8 @@ export default class Options implements OptionsProps {

readonly inputExclude: string[];

readonly patch: string[];

readonly output: string;

readonly header: string;
Expand Down Expand Up @@ -146,6 +149,7 @@ export default class Options implements OptionsProps {
this.dat = options?.dat || [];
this.input = options?.input || [];
this.inputExclude = options?.inputExclude || [];
this.patch = options?.patch || [];
this.output = options?.output || '';

this.header = options?.header || '';
Expand Down Expand Up @@ -268,6 +272,10 @@ export default class Options implements OptionsProps {
.filter((inputPath) => inputExcludeFiles.indexOf(inputPath) === -1);
}

async scanPatchFiles(): Promise<string[]> {
return Options.scanPath(this.patch);
}

private static async scanPath(inputPaths: string[]): Promise<string[]> {
// Convert directory paths to glob patterns
const globPatterns = await Promise.all(inputPaths.map(async (inputPath) => {
Expand Down Expand Up @@ -321,6 +329,10 @@ export default class Options implements OptionsProps {
.filter((inputPath, idx, arr) => arr.indexOf(inputPath) === idx);
}

getPatchFileCount(): number {
return this.patch.length;
}

getOutput(dat?: DAT, inputRomPath?: string, game?: Game, romName?: string): string {
let output = this.shouldWrite() ? this.output : Constants.GLOBAL_TEMP_DIR;
if (this.getDirMirror() && inputRomPath) {
Expand Down
27 changes: 27 additions & 0 deletions src/types/patches/ipsPatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import path from 'path';

import File from '../files/file.js';
import Patch from './patch.js';

export default class IPSPatch extends Patch {
constructor(file: File) {
const crcBefore = IPSPatch.getCrcFromPath(file.getExtractedFilePath());
super(file, crcBefore);
}

private static getCrcFromPath(filePath: string): string {
const { name } = path.parse(filePath);

const beforeMatches = name.match(/^([a-f0-9]{8})[^a-z0-9]/i);
if (beforeMatches && beforeMatches?.length >= 2) {
return beforeMatches[1].toUpperCase();
}

const afterMatches = name.match(/[^a-z0-9]([a-f0-9]{8})$/i);
if (afterMatches && afterMatches?.length >= 2) {
return afterMatches[1].toUpperCase();
}

throw new Error(`Couldn't parse base file CRC for patch: ${filePath}`);
}
}
27 changes: 27 additions & 0 deletions src/types/patches/patch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import File from '../files/file.js';

export default abstract class Patch {
private readonly file: File;

private readonly crcBefore: string;

private readonly crcAfter?: string;

protected constructor(file: File, crcBefore: string, crcAfter?: string) {
this.file = file;
this.crcBefore = crcBefore;
this.crcAfter = crcAfter;
}

getFile(): File {
return this.file;
}

getCrcBefore(): string {
return this.crcBefore;
}

getCrcAfter(): string | undefined {
return this.crcAfter;
}
}
13 changes: 13 additions & 0 deletions src/types/patches/patchFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import File from '../files/file.js';
import IPSPatch from './ipsPatch.js';
import Patch from './patch.js';

export default class PatchFactory {
static patchFrom(file: File): Patch {
if (file.getExtractedFilePath().toLowerCase().endsWith('.ips')) {
return new IPSPatch(file);
}

throw new Error(`Unknown patch type: ${file.toString()}`);
}
}
Binary file added test/fixtures/patches/after 0361b321.ips
Binary file not shown.
35 changes: 35 additions & 0 deletions test/modules/patchScanner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import os from 'os';

import PatchScanner from '../../src/modules/patchScanner.js';
import Options from '../../src/types/options.js';
import ProgressBarFake from '../console/progressBarFake.js';

function createPatchScanner(patch: string[]): PatchScanner {
return new PatchScanner(new Options({ patch }), new ProgressBarFake());
}

it('should throw on nonexistent paths', async () => {
await expect(createPatchScanner(['/completely/invalid/path']).scan()).rejects.toThrow(/path doesn't exist/i);
await expect(createPatchScanner(['/completely/invalid/path', os.devNull]).scan()).rejects.toThrow(/path doesn't exist/i);
await expect(createPatchScanner(['/completely/invalid/path', 'test/fixtures/roms']).scan()).rejects.toThrow(/path doesn't exist/i);
await expect(createPatchScanner(['test/fixtures/**/*.tmp']).scan()).rejects.toThrow(/path doesn't exist/i);
await expect(createPatchScanner(['test/fixtures/roms/*foo*/*bar*']).scan()).rejects.toThrow(/path doesn't exist/i);
});

it('should return empty list on no results', async () => {
await expect(createPatchScanner([]).scan()).resolves.toEqual([]);
await expect(createPatchScanner(['']).scan()).resolves.toEqual([]);
await expect(createPatchScanner([os.devNull]).scan()).resolves.toEqual([]);
});

it('should scan multiple files', async () => {
const expectedPatchFiles = 1;
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}']).scan()).resolves.toHaveLength(expectedPatchFiles);
});

it('should scan single files', async () => {
await expect(createPatchScanner(['test/fixtures/patches/after*.ips']).scan()).resolves.toHaveLength(1);
await expect(createPatchScanner(['test/fixtures/*/after*.ips']).scan()).resolves.toHaveLength(1);
});
20 changes: 11 additions & 9 deletions test/modules/romWriter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import HeaderProcessor from '../../src/modules/headerProcessor.js';
import ROMScanner from '../../src/modules/romScanner.js';
import ROMWriter from '../../src/modules/romWriter.js';
import fsPoly from '../../src/polyfill/fsPoly.js';
import ArchiveFactory from '../../src/types/archives/archiveFactory.js';
import Archive from '../../src/types/archives/archive.js';
import FileFactory from '../../src/types/archives/fileFactory.js';
import ArchiveEntry from '../../src/types/files/archiveEntry.js';
import File from '../../src/types/files/file.js';
import DAT from '../../src/types/logiqx/dat.js';
import Header from '../../src/types/logiqx/header.js';
Expand Down Expand Up @@ -285,11 +287,11 @@ describe('zip', () => {
const options = new Options({ commands: ['copy', 'zip', 'test'] });
const outputFiles = (await romWriter(options, inputTemp, inputGlob, outputTemp));
expect(outputFiles).toHaveLength(1);
const archive = ArchiveFactory.archiveFrom(path.join(outputTemp, outputFiles[0][0]));
const archiveEntries = await archive.getArchiveEntries();
const archiveEntries = await FileFactory.filesFrom(path.join(outputTemp, outputFiles[0][0]));
expect(archiveEntries).toHaveLength(1);
expect(archiveEntries[0].getEntryPath()).toEqual(expectedFileName);
expect(archiveEntries[0].getCrc32()).toEqual(expectedCrc);
const archiveEntry = archiveEntries[0] as ArchiveEntry<Archive>;
expect(archiveEntry.getEntryPath()).toEqual(expectedFileName);
expect(archiveEntry.getCrc32()).toEqual(expectedCrc);
});
});

Expand All @@ -314,11 +316,11 @@ describe('zip', () => {
});
const outputFiles = (await romWriter(options, inputTemp, inputGlob, outputTemp));
expect(outputFiles).toHaveLength(1);
const archive = ArchiveFactory.archiveFrom(path.join(outputTemp, outputFiles[0][0]));
const archiveEntries = await archive.getArchiveEntries();
const archiveEntries = await FileFactory.filesFrom(path.join(outputTemp, outputFiles[0][0]));
expect(archiveEntries).toHaveLength(1);
expect(archiveEntries[0].getEntryPath()).toEqual(expectedFileName);
expect(archiveEntries[0].getCrc32()).toEqual(expectedCrc);
const archiveEntry = archiveEntries[0] as ArchiveEntry<Archive>;
expect(archiveEntry.getEntryPath()).toEqual(expectedFileName);
expect(archiveEntry.getCrc32()).toEqual(expectedCrc);
});
});

Expand Down
17 changes: 8 additions & 9 deletions test/types/files/archive.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import path from 'path';

import ArchiveFactory from '../../../src/types/archives/archiveFactory.js';
import Archive from '../../../src/types/archives/archive.js';
import FileFactory from '../../../src/types/archives/fileFactory.js';
import ArchiveEntry from '../../../src/types/files/archiveEntry.js';

describe('getArchiveEntries', () => {
test.each([
Expand All @@ -25,13 +27,11 @@ describe('getArchiveEntries', () => {
['./test/fixtures/roms/tar/unknown.tar.gz', 'unknown.rom', '377a7727'],
['./test/fixtures/roms/zip/unknown.zip', 'unknown.rom', '377a7727'],
])('should enumerate the single file archive: %s', async (filePath, expectedEntryPath, expectedCrc) => {
const archive = ArchiveFactory.archiveFrom(filePath);

const entries = await archive.getArchiveEntries();
const entries = await FileFactory.filesFrom(filePath);
expect(entries).toHaveLength(1);

const entry = entries[0];
expect(entry.getEntryPath()).toEqual(expectedEntryPath);
expect((entry as ArchiveEntry<Archive>).getEntryPath()).toEqual(expectedEntryPath);
expect(entry.getCrc32()).toEqual(expectedCrc);
});

Expand All @@ -41,15 +41,14 @@ describe('getArchiveEntries', () => {
['./test/fixtures/roms/tar/onetwothree.tar.gz', [['1/one.rom', 'f817a89f'], ['2/two.rom', '96170874'], ['3/three.rom', 'ff46c5d8']]],
['./test/fixtures/roms/zip/onetwothree.zip', [['1/one.rom', 'f817a89f'], ['2/two.rom', '96170874'], ['3/three.rom', 'ff46c5d8']]],
])('should enumerate the multi file archive: %s', async (filePath, expectedEntries) => {
const archive = ArchiveFactory.archiveFrom(filePath);

const entries = await archive.getArchiveEntries();
const entries = await FileFactory.filesFrom(filePath);
expect(entries).toHaveLength(expectedEntries.length);

for (let i = 0; i < entries.length; i += 1) {
const entry = entries[i];
const expectedEntry = expectedEntries[i];
expect(entry.getEntryPath()).toEqual(path.normalize(expectedEntry[0]));
expect((entry as ArchiveEntry<Archive>).getEntryPath())
.toEqual(path.normalize(expectedEntry[0]));
expect(entry.getCrc32()).toEqual(expectedEntry[1]);
}
});
Expand Down
Loading

0 comments on commit e01fc18

Please sign in to comment.