diff --git a/__tests__/assets/res/abc.res b/__tests__/assets/res/abc.res new file mode 100644 index 0000000..f117576 Binary files /dev/null and b/__tests__/assets/res/abc.res differ diff --git a/__tests__/assets/res/magic_number.res b/__tests__/assets/res/magic_number.res new file mode 100644 index 0000000..113d220 Binary files /dev/null and b/__tests__/assets/res/magic_number.res differ diff --git a/__tests__/assets/res/too_many_files.res b/__tests__/assets/res/too_many_files.res new file mode 100644 index 0000000..7c8b46b Binary files /dev/null and b/__tests__/assets/res/too_many_files.res differ diff --git a/__tests__/res.int.test.ts b/__tests__/res.int.test.ts new file mode 100644 index 0000000..08322b9 --- /dev/null +++ b/__tests__/res.int.test.ts @@ -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?'); + }); +}); diff --git a/package.json b/package.json index d39d014..d0d310c 100644 --- a/package.json +++ b/package.json @@ -57,5 +57,6 @@ ], "dependencies": { "buffer": "^6.0.1" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/index.ts b/src/index.ts index 169201a..9fc9f51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,4 @@ export * from './shared'; export * from './util'; export * from './lgr'; export * from './state'; +export * from './res'; diff --git a/src/res/Resource.ts b/src/res/Resource.ts new file mode 100644 index 0000000..49ca83c --- /dev/null +++ b/src/res/Resource.ts @@ -0,0 +1,88 @@ +import { Buffer } from 'buffer'; + +import { nullpadString, trimString, cryptPiece, CryptKey } from '../util'; +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; + } +} diff --git a/src/res/index.ts b/src/res/index.ts new file mode 100644 index 0000000..770ac9b --- /dev/null +++ b/src/res/index.ts @@ -0,0 +1 @@ +export { default as Resource } from './Resource'; diff --git a/src/state/State.ts b/src/state/State.ts index da8671b..93a8ada 100644 --- a/src/state/State.ts +++ b/src/state/State.ts @@ -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; @@ -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, @@ -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. diff --git a/src/util.ts b/src/util.ts index 7fa5e86..15129cd 100644 --- a/src/util.ts +++ b/src/util.ts @@ -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; +}