diff --git a/package.json b/package.json index b359a1c..e2fbd24 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,16 @@ "require": "./dist/cia.js", "types": "./dist/cia.d.ts" }, + "./compression/cmp": { + "import": "./dist/compression/cmp.js", + "require": "./dist/compression/cmp.js", + "types": "./dist/compression/cmp.d.ts" + }, + "./compression/yaz0": { + "import": "./dist/compression/yaz0.js", + "require": "./dist/compression/yaz0.js", + "types": "./dist/compression/yaz0.d.ts" + }, "./msbt": { "import": "./dist/msbt.js", "require": "./dist/msbt.js", diff --git a/src/compression/cmp.ts b/src/compression/cmp.ts new file mode 100644 index 0000000..c81796b --- /dev/null +++ b/src/compression/cmp.ts @@ -0,0 +1,114 @@ +import zlib from 'node:zlib'; +import FileStream from '@/file-stream'; + +// * Unsure what the real name of this is. Switch Toolbox uses the ZCMP class for +// * files that end with `*.cmp`, however `main.sgarc.cmp` found in Mii Maker has +// * a different format? This is designed for the file(s) in Mii Maker + +/** + * CMP handles the decompression of files using ZLIB. Files of this type usually end + * with `*.cmp` + */ +export default class CMP { + private stream: FileStream; + + /** + * Decompresses the CMP-compressed data from the provided `fdOrPath` + * + * @param fdOrPath - Either an open `fd` or a path to a file on disk + */ + public decompressFromFile(fdOrPath: number | string): Buffer { + this.stream = new FileStream(fdOrPath); + return this.decompress(); + } + + /** + * Decompresses the CMP-compressed data from the provided `buffer` + * + * @param buffer - CMP-compressed data buffer + */ + public decompressFromBuffer(buffer: Buffer): Buffer { + this.stream = new FileStream(buffer); + return this.decompress(); + } + + /** + * Decompresses the CMP-compressed data from the provided string + * + * Calls `decompressFromBuffer` internally + * + * @param base64 - Base64 encoded CMP-compressed data + */ + public decompressFromString(base64: string): Buffer { + return this.decompressFromBuffer(Buffer.from(base64, 'base64')); + } + + /** + * Decompresses the CMP-compressed data from an existing file stream + * + * @param stream - An existing file stream + */ + public decompressFromFileStream(stream: FileStream): Buffer { + this.stream = stream; + return this.decompress(); + } + + /** + * Creates a new instance of `CMP` and + * parses the CMP-compressed data from the provided `fdOrPath` + * + * @param fdOrPath - Either an open `fd` or a path to a file on disk + */ + public static fromFile(fdOrPath: number | string): Buffer { + const cmp = new CMP(); + return cmp.decompressFromFile(fdOrPath); + } + + /** + * Creates a new instance of `CMP` and + * parses the CMP-compressed data from the provided `buffer` + * + * @param buffer - CMP-compressed data buffer + */ + public static fromBuffer(buffer: Buffer): Buffer { + const cmp = new CMP(); + return cmp.decompressFromBuffer(buffer); + } + + /** + * Creates a new instance of `CMP` and + * parses the CMP-compressed data from the provided string + * + * Calls `decompressFromBuffer` internally + * + * @param base64 - Base64 encoded CMP-compressed data + */ + public static fromString(base64: string): Buffer { + const cmp = new CMP(); + return cmp.decompressFromString(base64); + } + + /** + * Creates a new instance of `CMP` and + * parses the CMP-compressed data from an existing file stream + * + * @param stream - An existing file stream + */ + public static fromFileStream(stream: FileStream): Buffer { + const cmp = new CMP(); + return cmp.decompressFromFileStream(stream); + } + + private decompress(): Buffer { + const decompressedSize = this.stream.readUInt32BE(); + const compressedSize = this.stream.remaining(); + const compressed = this.stream.readBytes(compressedSize); + const decompressed = zlib.inflateSync(compressed); + + if (decompressed.length !== decompressedSize) { + throw new Error(`Invalid decompressed size. Expected ${decompressedSize}, got ${decompressed.length}`); + } + + return decompressed; + } +} \ No newline at end of file diff --git a/src/compression/index.ts b/src/compression/index.ts new file mode 100644 index 0000000..60c2cd0 --- /dev/null +++ b/src/compression/index.ts @@ -0,0 +1,5 @@ +export { default as CMP } from '@/compression/cmp'; +export * from '@/compression/cmp'; + +export { default as Yaz0 } from '@/compression/yaz0'; +export * from '@/compression/yaz0'; \ No newline at end of file diff --git a/src/compression/yaz0.ts b/src/compression/yaz0.ts new file mode 100644 index 0000000..57392ab --- /dev/null +++ b/src/compression/yaz0.ts @@ -0,0 +1,153 @@ +import FileStream from '@/file-stream'; +import Stream from '@/stream'; + +const YAZ0_MAGIC = Buffer.from('Yaz0'); + +/** + * Yaz0 handles the decompression of files using Yaz0 + */ +export default class Yaz0 { + private stream: FileStream; + + /** + * Decompresses the Yaz0-compressed data from the provided `fdOrPath` + * + * @param fdOrPath - Either an open `fd` or a path to a file on disk + */ + public decompressFromFile(fdOrPath: number | string): Buffer { + this.stream = new FileStream(fdOrPath); + return this.decompress(); + } + + /** + * Decompresses the Yaz0-compressed data from the provided `buffer` + * + * @param buffer - Yaz0-compressed data buffer + */ + public decompressFromBuffer(buffer: Buffer): Buffer { + this.stream = new FileStream(buffer); + return this.decompress(); + } + + /** + * Decompresses the Yaz0-compressed data from the provided string + * + * Calls `decompressFromBuffer` internally + * + * @param base64 - Base64 encoded Yaz0-compressed data + */ + public decompressFromString(base64: string): Buffer { + return this.decompressFromBuffer(Buffer.from(base64, 'base64')); + } + + /** + * Decompresses the Yaz0-compressed data from an existing file stream + * + * @param stream - An existing file stream + */ + public decompressFromFileStream(stream: FileStream): Buffer { + this.stream = stream; + return this.decompress(); + } + + /** + * Creates a new instance of `Yaz0` and + * parses the Yaz0-compressed data from the provided `fdOrPath` + * + * @param fdOrPath - Either an open `fd` or a path to a file on disk + */ + public static fromFile(fdOrPath: number | string): Buffer { + const yaz0 = new Yaz0(); + return yaz0.decompressFromFile(fdOrPath); + } + + /** + * Creates a new instance of `Yaz0` and + * parses the Yaz0-compressed data from the provided `buffer` + * + * @param buffer - Yaz0-compressed data buffer + */ + public static fromBuffer(buffer: Buffer): Buffer { + const yaz0 = new Yaz0(); + return yaz0.decompressFromBuffer(buffer); + } + + /** + * Creates a new instance of `Yaz0` and + * parses the Yaz0-compressed data from the provided string + * + * Calls `decompressFromBuffer` internally + * + * @param base64 - Base64 encoded Yaz0-compressed data + */ + public static fromString(base64: string): Buffer { + const yaz0 = new Yaz0(); + return yaz0.decompressFromString(base64); + } + + /** + * Creates a new instance of `Yaz0` and + * parses the Yaz0-compressed data from an existing file stream + * + * @param stream - An existing file stream + */ + public static fromFileStream(stream: FileStream): Buffer { + const yaz0 = new Yaz0(); + return yaz0.decompressFromFileStream(stream); + } + + private decompress(): Buffer { + const magic = this.stream.readBytes(4); + + if (!YAZ0_MAGIC.equals(magic)) { + throw new Error('Invalid Yaz0 magic'); + } + + const decompressedSize = this.stream.readUInt32BE(); + + this.stream.skip(4); // * Reserved, ignore for now + this.stream.skip(4); // * Reserved, ignore for now + + const compressedSize = this.stream.remaining(); + const compressed = this.stream.readBytes(compressedSize); + const compressedStream = new Stream(compressed); + const decompressed = Buffer.alloc(decompressedSize); + let outputBytePosition = 0; + + while (compressedStream.remaining()) { + const groupHeader = compressedStream.readUInt8(); + + for (let chunk = 0; chunk < 8 && compressedStream.remaining(); chunk++) { + const bitMask = 0x80 >> chunk; + const isLiteral = (groupHeader & bitMask) !== 0; + const firstByte = compressedStream.readUInt8(); + + if (isLiteral) { + decompressed[outputBytePosition++] = firstByte; + } else { + const secondByte = compressedStream.readUInt8(); + + const distance = ((firstByte & 0x0F) << 8) | secondByte; + let length: number; + + if ((firstByte & 0xF0) !== 0) { + const n = (firstByte & 0xF0) >> 4; + length = n + 2; + } else { + const thirdByte = compressedStream.readUInt8(); + length = thirdByte + 0x12; + } + + const backReferenceStart = outputBytePosition - distance - 1; + + for (let i = 0; i < length && outputBytePosition < decompressedSize; i++) { + decompressed[outputBytePosition] = decompressed[backReferenceStart + i]; + outputBytePosition++; + } + } + } + } + + return decompressed; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 27e6fb4..2a84a3e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ export * from '@/certificate'; export { default as CIA } from '@/cia'; export * from '@/cia'; +export * from '@/compression'; + export { default as MSBT } from '@/msbt'; export * from '@/msbt';