Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added __tests__/assets/res/abc.res
Binary file not shown.
Binary file added __tests__/assets/res/magic_number.res
Binary file not shown.
Binary file added __tests__/assets/res/too_many_files.res
Binary file not shown.
49 changes: 49 additions & 0 deletions __tests__/res.int.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { readFile } from 'fs-extra';

import { Resource } from '../src';

describe('Res', () => {
// This test actually doesn't work with Across.res/Elma.res due to garbage bytes after the end of strings
test('toBuffer matches original buffer', async () => {
const file = await readFile(`__tests__/assets/res/abc.res`);
const res = Resource.from(file);
const resBuffer = res.toBuffer();
expect(file).toEqual(resBuffer);
});
test('Parsing matches expected output', async () => {
const file = await readFile(`__tests__/assets/res/abc.res`);
const res = Resource.from(file);
expect(res.files.length).toEqual(3);
expect(res.files[0].name).toEqual('a.a');
expect(res.files[1].name).toEqual('b.b');
expect(res.files[2].name).toEqual('cccccccC.ccc');
expect(res.files[0].data).toEqual(Buffer.from('FileA'));
expect(res.files[1].data).toEqual(Buffer.from([0, 0, 0]));
expect(res.files[2].data).toEqual(Buffer.from('FileC'));
});
test('toBuffer throws error: Filename too long', async () => {
const res = new Resource();
res.files.push({ name: 'length=13.bad', data: Buffer.from([0]) });
expect(() => res.toBuffer()).toThrowError('Filename length=13.bad is too long (max 12 characters)');
});
test('toBuffer throws error: Filename no ext', async () => {
const res = new Resource();
res.files.push({ name: 'noext', data: Buffer.from([0]) });
expect(() => res.toBuffer()).toThrowError('Filename noext needs to include a file extension!');
});
test('toBuffer throws error: Too many files', async () => {
const res = new Resource();
for (let i = 0; i < 151; i++) {
res.files.push({ name: `file{i}.txt`, data: Buffer.from([i]) });
}
expect(() => res.toBuffer()).toThrowError('Max number of files is 150, but got 151 files');
});
test('from throws error: Too many files', async () => {
const file = await readFile(`__tests__/assets/res/too_many_files.res`);
expect(() => Resource.from(file)).toThrowError('Max number of files is 150, but got 151 files');
});
test('from throws error: Magic number', async () => {
const file = await readFile(`__tests__/assets/res/magic_number.res`);
expect(() => Resource.from(file)).toThrowError('Magic Number not found, is this really a .res file?');
});
});
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,6 @@
],
"dependencies": {
"buffer": "^6.0.1"
}
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './shared';
export * from './util';
export * from './lgr';
export * from './state';
export * from './res';
88 changes: 88 additions & 0 deletions src/res/Resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Buffer } from 'buffer';

import { nullpadString, trimString, cryptPiece, CryptKey } from '../util';

Check warning on line 3 in src/res/Resource.ts

View workflow job for this annotation

GitHub Actions / build (14)

'CryptKey' is defined but never used

Check warning on line 3 in src/res/Resource.ts

View workflow job for this annotation

GitHub Actions / build (16)

'CryptKey' is defined but never used
import { BufferInput } from '../shared';

const MAX_FILES = 150;
const RESOURCE_FILE_SIZE = 24;
const ENCRYPTED_SIZE = MAX_FILES * RESOURCE_FILE_SIZE;
const MAGIC_NUMBER = 1347839;
const HEADER_SIZE = 4 + ENCRYPTED_SIZE + 4;
const CRYPT_KEY: CryptKey = [23, 9982, 3391, 31];

export interface ResourceFile {
name: string;
data: Buffer;
}

export default class Resource {
public files: ResourceFile[] = [];

/**
* Loads a resource file from a buffer.
* @param buffer
*/
public static from(buffer: BufferInput): Resource {
return this.parseBuffer(Buffer.from(buffer));
}

private static parseBuffer(buffer: Buffer): Resource {
const res = new Resource();

const magicNumber = buffer.readUInt32LE(4 + ENCRYPTED_SIZE);
if (magicNumber !== MAGIC_NUMBER) throw Error(`Magic Number not found, is this really a .res file?`);

const fileCount = buffer.readUInt32LE(0);
if (fileCount > MAX_FILES) throw Error(`Max number of files is ${MAX_FILES}, but got ${fileCount} files`);
const decryptedHeader = this.cryptHeader(buffer.slice(4, 4 + ENCRYPTED_SIZE));
for (let i = 0; i < fileCount; i++) {
const pos = i * RESOURCE_FILE_SIZE;
const name = trimString(decryptedHeader.slice(pos, pos + 16));
const length = decryptedHeader.readUInt32LE(pos + 16);
const offset = decryptedHeader.readUInt32LE(pos + 20);
const data = buffer.slice(offset, offset + length);
res.files.push({ name, data });
}

return res;
}

public toBuffer(): Buffer {
const fileCount = this.files.length;
if (fileCount > MAX_FILES) throw Error(`Max number of files is ${MAX_FILES}, but got ${fileCount} files`);

const size = HEADER_SIZE + this.files.reduce((sum, file) => sum + file.data.length, 0);
const buffer = Buffer.alloc(size);
const header = Buffer.alloc(ENCRYPTED_SIZE);

buffer.writeUInt32LE(fileCount, 0);
buffer.writeUInt32LE(MAGIC_NUMBER, 4 + ENCRYPTED_SIZE);
let offset = HEADER_SIZE;
for (let i = 0; i < fileCount; i++) {
const pos = i * RESOURCE_FILE_SIZE;
const name = this.files[i].name;
const data = this.files[i].data;
const length = data.length;
if (name.length > 12) throw Error(`Filename ${name} is too long (max 12 characters)`);
if (!name.includes('.')) throw Error(`Filename ${name} needs to include a file extension!`);
header.write(nullpadString(name, 16), pos, 16, 'ascii');
header.writeUInt32LE(length, pos + 16);
header.writeUInt32LE(offset, pos + 20);
data.copy(buffer, offset);
offset += length;
}

const encryptedHeader = Resource.cryptHeader(header);
encryptedHeader.copy(buffer, 4);

return buffer;
}

private static cryptHeader(buffer: Buffer): Buffer {
const bufCopy = Buffer.from(buffer);
if (bufCopy.length !== ENCRYPTED_SIZE)
throw Error(`Invalid resource header length, expected buffer length of ${ENCRYPTED_SIZE}, got ${bufCopy.length}`);
const cryptedHeader = cryptPiece(bufCopy, CRYPT_KEY);
return cryptedHeader;
}
}
1 change: 1 addition & 0 deletions src/res/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Resource } from './Resource';
20 changes: 3 additions & 17 deletions src/state/State.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Buffer } from 'buffer';

import { Top10 } from '../shared';
import { nullpadString, trimString, bufferToTop10, top10ToBuffer } from '../util';
import { nullpadString, trimString, bufferToTop10, top10ToBuffer, cryptPiece, CryptKey } from '../util';

const STATE_SIZE = 67910;
const PLAYER_STRUCT_SIZE = 116;
Expand All @@ -15,6 +15,7 @@ const NUM_LEVELS = 90;
const STATE_START = 200;
const STATE_END = 123432221;
const STATE_END_ALT = 123432112;
const CRYPT_KEY: CryptKey = [23, 9782, 3391, 31];

export enum PlayMode {
Single = 1,
Expand Down Expand Up @@ -212,28 +213,13 @@ export default class State {

let curr = 0;
for (const p of statePieces) {
const decryptedPart = this.cryptStatePiece(bufCopy.slice(curr, curr + p));
const decryptedPart = cryptPiece(bufCopy.slice(curr, curr + p), CRYPT_KEY);
decryptedPart.copy(bufCopy, curr);
curr += p;
}
return bufCopy;
}

private static cryptStatePiece(buffer: Buffer): Buffer {
const bufCopy = Buffer.from(buffer);
let ebp8 = 0x17;
let ebp10 = 0x2636;

for (let i = 0; i < buffer.length; i++) {
bufCopy[i] ^= ebp8 & 0xff;
ebp10 += (ebp8 % 0xd3f) * 0xd3f;
ebp8 = ebp10 * 0x1f + 0xd3f;
ebp8 = (ebp8 & 0xffff) - 2 * (ebp8 & 0x8000);
}

return bufCopy;
}

// State file version; the only supported value is 200.
public readonly version = STATE_START;
// Best times lists. state.dat has a fixed-size array of 90 of these.
Expand Down
18 changes: 18 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,21 @@ export function bufferToTop10(buffer: BufferInput): Top10 {
multi,
};
}

export type CryptKey = [number, number, number, number];
export function cryptPiece(buffer: Buffer, key: CryptKey): Buffer {
const bufCopy = Buffer.from(buffer);
let a = key[0];
let b = key[1];
const c = key[2];
const d = key[3];

for (let i = 0; i < buffer.length; i++) {
bufCopy[i] ^= a & 0xff;
b += (a % c) * c;
a = b * d + c;
a = (a & 0xffff) - 2 * (a & 0x8000);
}

return bufCopy;
}
Loading