UNPKG

nitro-fs

Version:

NDS Filesystem reading and parsing library

1,272 lines (1,247 loc) 168 kB
class BufferReader { constructor(buffer, start, length, littleEndian = true) { this.buffer = buffer; this.start = start; this.bufferLength = length; this.view = new DataView(buffer, start, length); this.littleEndian = littleEndian; } /** * Creates a new BufferReader instance from the specified ArrayBuffer. * @param buffer - The ArrayBuffer to read from. * @param littleEndian - Whether the buffer is little endian. * @returns The BufferReader instance. */ static new(buffer, littleEndian = true) { return new BufferReader(buffer, 0, buffer.byteLength, littleEndian); } /** * Slices the buffer and returns a new BufferReader instance, without copying the underlying buffer. * @param start - The start offset. * @param end - The end offset. * @returns The new BufferReader instance. */ slice(start, end) { if (end === undefined || end > this.bufferLength) { end = this.bufferLength; } return new BufferReader(this.buffer, this.start + start, end - start); } readUint8(offset) { return this.view.getUint8(offset); } readUint16(offset) { return this.view.getUint16(offset, this.littleEndian); } readUint24(offset) { return this.view.getUint8(offset) | (this.view.getUint8(offset + 1) << 8) | (this.view.getUint8(offset + 2) << 16); } readUint32(offset) { return this.view.getUint32(offset, this.littleEndian); } readInt8(offset) { return this.view.getInt8(offset); } readInt16(offset) { return this.view.getInt16(offset, this.littleEndian); } readInt24(offset) { return this.view.getInt8(offset) | (this.view.getInt8(offset + 1) << 8) | (this.view.getInt8(offset + 2) << 16); } readInt32(offset) { return this.view.getInt32(offset, this.littleEndian); } readFloat32(offset) { return this.view.getFloat32(offset, this.littleEndian); } readFloat64(offset) { return this.view.getFloat64(offset, this.littleEndian); } /** * Reads a string of the specified length from the buffer. * @param offset - The offset to start reading from. * @param length - The length of the string to read. */ readChars(offset, length) { let result = ""; for (let i = 0; i < length; i++) { result += String.fromCharCode(this.view.getUint8(offset + i)); } return result; } /** * Reads a null-terminated string from the buffer. * @param offset - The offset to start reading from. */ readString(offset) { let result = ""; let i = 0; while (true) { let c = this.view.getUint8(offset + i); if (c === 0) { break; } result += String.fromCharCode(c); i++; } return result; } /** * Reads a variable-length integer from the buffer. Variable-length integers are encoded in groups of 7 bits, * with the 8th bit indicating whether another group of 7 bits follows. * @param offset - The offset to start reading from. * @returns An object containing the value and the length of the integer. */ readVL(offset) { let result = 0; let i = 0; while (true) { const c = this.view.getUint8(offset + i); result <<= 7; result |= (c & 0x7F); if ((c & 0x80) === 0) { break; } i++; } return { value: result, length: i + 1 }; } get length() { return this.bufferLength; } /** * Returns a copy of the underlying buffer. * @returns - A copy of the underlying buffer. */ getBuffer() { return this.buffer.slice(this.start, this.start + this.bufferLength); } } class CartridgeHeader { constructor(raw) { // Game Title (0x00, 12 bytes, Uppercase ASCII, padded with 0x00) this.gameTitle = raw.readChars(0x00, 12).replace(/\0/g, ""); // Game Code (0x0C, 4 bytes, Uppercase ASCII, padded with 0x00) this.gameCode = raw.readChars(0x0C, 4).replace(/\0/g, ""); // FNT Offset (0x40, 4 bytes) this.fntOffset = raw.readUint32(0x40); // FNT Length (0x44, 4 bytes) this.fntLength = raw.readUint32(0x44); // FAT Offset (0x48, 4 bytes) this.fatOffset = raw.readUint32(0x48); // FAT Length (0x4C, 4 bytes) this.fatLength = raw.readUint32(0x4C); // Other fields are ignored for now // TODO: Maybe CRC later? } } class NitroFAT { constructor(raw) { this.entries = []; for (let i = 0; i < raw.length; i += 8) { const startAddress = raw.readUint32(i); const endAddress = raw.readUint32(i + 4); this.entries.push({ startAddress, endAddress }); } } } class NitroFNTMainTable { constructor(raw, numEntries) { this.totalDirCount = numEntries; this.entries = []; for (let i = 0; i < numEntries; i++) { const entryOffset = i * 8; const entryBuffer = raw.slice(entryOffset, entryOffset + 8); const entry = { subTableOffset: entryBuffer.readUint32(0x00), firstFileID: entryBuffer.readUint16(0x04), parentDirectoryID: entryBuffer.readUint16(0x06) }; this.entries.push(entry); } } } class NitroFNTSubTable { constructor(raw) { this.entries = []; let i = 0; while (true) { const typeAndLength = raw.readUint8(i); i++; const { type, length } = this.seperateTypeAndLength(typeAndLength); if (type == NitroFNTSubtableEntryType.File) { const name = raw.readChars(i, length); i += length; this.entries.push({ type, length, name }); } else if (type == NitroFNTSubtableEntryType.SubDirectory) { const name = raw.readChars(i, length); i += length; // ID of the subdirectory (2 bytes, little endian) const id = raw.readUint16(i) & 0xFFF; i += 2; this.entries.push({ type, length, name, subDirectoryID: id }); } else if (type == NitroFNTSubtableEntryType.EndOfSubTable) { break; } else if (type == NitroFNTSubtableEntryType.Reserved) { throw new Error("Reserved entry type found in NitroFNTSubTable"); } } } seperateTypeAndLength(typeAndLength) { if (typeAndLength == 0x00) { return { type: NitroFNTSubtableEntryType.EndOfSubTable, length: 0 }; } else if (typeAndLength == 0x80) { return { type: NitroFNTSubtableEntryType.Reserved, length: 0 }; } else if (typeAndLength < 0x80) { return { type: NitroFNTSubtableEntryType.File, length: typeAndLength % 0x80 }; } else { return { type: NitroFNTSubtableEntryType.SubDirectory, length: typeAndLength % 0x80 }; } } } var NitroFNTSubtableEntryType; (function (NitroFNTSubtableEntryType) { NitroFNTSubtableEntryType[NitroFNTSubtableEntryType["File"] = 0] = "File"; NitroFNTSubtableEntryType[NitroFNTSubtableEntryType["SubDirectory"] = 1] = "SubDirectory"; NitroFNTSubtableEntryType[NitroFNTSubtableEntryType["EndOfSubTable"] = 2] = "EndOfSubTable"; NitroFNTSubtableEntryType[NitroFNTSubtableEntryType["Reserved"] = 3] = "Reserved"; })(NitroFNTSubtableEntryType || (NitroFNTSubtableEntryType = {})); class NitroFNT { constructor(raw) { // First, read the main table // The number of entries is always stored at 0x06 and 0x07 (2 bytes, little endian) // Each entry is 8 bytes long, so the total size of the main table is 8 * numEntries const numEntries = raw.readUint16(0x06); const mainTableBuffer = raw.slice(0, 8 * numEntries); this.mainTable = new NitroFNTMainTable(mainTableBuffer, numEntries); // Next, read the subtables this.subTables = []; for (let i = 0; i < this.mainTable.entries.length; i++) { const subTableOffset = this.mainTable.entries[i].subTableOffset; const subTable = new NitroFNTSubTable(raw.slice(subTableOffset)); this.subTables.push(subTable); } // Build the directory tree this.tree = new NitroFNTDirectory("root"); this.parseSubTable(this.subTables[0], this.tree, 0); } parseSubTable(subTable, parentDirectory, parentDirectoryID) { const mainTableEntry = this.mainTable.entries[parentDirectoryID]; for (let i = 0; i < subTable.entries.length; i++) { const subTableEntry = subTable.entries[i]; if (subTableEntry.type == NitroFNTSubtableEntryType.File) { const file = new NitroFNTFile(subTableEntry.name, mainTableEntry.firstFileID + i); parentDirectory.files.push(file); } else if (subTableEntry.type == NitroFNTSubtableEntryType.SubDirectory) { const directory = new NitroFNTDirectory(subTableEntry.name); parentDirectory.directories.push(directory); this.parseSubTable(this.subTables[subTableEntry.subDirectoryID], directory, subTableEntry.subDirectoryID); } } } } class NitroFNTDirectory { constructor(name) { this.name = name; this.files = []; this.directories = []; } } class NitroFNTFile { constructor(name, id) { this.name = name; this.id = id; } } /** * Class for reading files from the NitroFS. */ class NitroFS { /** * Creates a NitroFS instance from a ROM buffer. * @param rom - The ROM buffer. * @returns The NitroFS instance. */ static fromRom(rom) { const nitroFS = new NitroFS(); const reader = BufferReader.new(rom, true); // First, read the cartridge header const headerBuffer = reader.slice(0, 0x200); nitroFS.cartridgeHeader = new CartridgeHeader(headerBuffer); // Next, skip to the FNT and read it const fntBuffer = reader.slice(nitroFS.cartridgeHeader.fntOffset, nitroFS.cartridgeHeader.fntOffset + nitroFS.cartridgeHeader.fntLength); nitroFS.fnt = new NitroFNT(fntBuffer); // Then, skip to the FAT and read it const fatBuffer = reader.slice(nitroFS.cartridgeHeader.fatOffset, nitroFS.cartridgeHeader.fatOffset + nitroFS.cartridgeHeader.fatLength); const fat = new NitroFAT(fatBuffer); // Use file data directly instead of only addresses in order to save memory // Also, clone the file buffers so that the original buffer can be garbage collected // so that we only have to keep the file data in memory, not the entire ROM nitroFS.fileData = []; for (let i = 0; i < fat.entries.length; i++) { const entry = fat.entries[i]; nitroFS.fileData[i] = reader.slice(entry.startAddress, entry.endAddress).getBuffer(); } return nitroFS; } /** * Reads a file from the NitroFS. * @param path - The path to the file. * @returns A buffer containing the file data. */ readFile(path) { const directoryParts = path.split("/"); const fileName = directoryParts.pop(); let currentDir = this.fnt.tree; for (let i = 0; i < directoryParts.length; i++) { currentDir = currentDir.directories.find(dir => dir.name == directoryParts[i]); if (!currentDir) { throw new Error(`Directory not found: ${directoryParts[i]}`); } } const file = currentDir.files.find(file => file.name == fileName); if (!file) { throw new Error(`File not found: ${fileName}`); } return this.fileData[file.id]; } /** * Reads a directory from the NitroFS. * @param path - The path to the directory. * @returns An object containing the paths of every file and directory in the base directory. */ readDir(path) { let directoryParts = path.split("/"); // Remove every empty string from the array directoryParts = directoryParts.filter(dir => dir != ""); let currentDir = this.fnt.tree; for (let i = 0; i < directoryParts.length; i++) { currentDir = currentDir.directories.find(dir => dir.name == directoryParts[i]); if (!currentDir) { throw new Error(`Directory not found: ${directoryParts[i]}`); } } let files = []; let directories = []; for (let i = 0; i < currentDir.files.length; i++) { files.push(currentDir.files[i].name); } for (let i = 0; i < currentDir.directories.length; i++) { directories.push(currentDir.directories[i].name); } return { files, directories }; } /** * Checks if a file exists in the NitroFS. * @param path - The path to the file. * @returns Whether the file exists. */ exists(path) { try { this.readFile(path); return true; } catch (e) { return false; } } } class CompressionHeader { constructor(raw) { // Byte 0: Compression Type this.compressionType = raw.readUint8(0x00); // Byte 1-3: Decompressed size this.decompressedSize = raw.readUint24(0x01); } } var CompressionType; (function (CompressionType) { CompressionType[CompressionType["LZ10"] = 16] = "LZ10"; CompressionType[CompressionType["LZ11"] = 17] = "LZ11"; })(CompressionType || (CompressionType = {})); // https://github.com/magical/nlzss/blob/master/lzss3.py class LZ10 { static decompress(indata, decompressedSize) { let data = new Uint8Array(decompressedSize); let dataIndex = 0; let rawIndex = 0; const dispExtra = 1; function bits(byte) { return [ (byte >> 7) & 1, (byte >> 6) & 1, (byte >> 5) & 1, (byte >> 4) & 1, (byte >> 3) & 1, (byte >> 2) & 1, (byte >> 1) & 1, (byte >> 0) & 1 ]; } function writeByte(byte) { data[dataIndex++] = byte; } function readByte() { return indata.readUint8(rawIndex++); } function readShort() { // big-endian const a = indata.readUint8(rawIndex++); const b = indata.readUint8(rawIndex++); return (a << 8) | b; } function copyByte() { writeByte(readByte()); } while (dataIndex < decompressedSize) { const b = readByte(); const flags = bits(b); for (let i = 0; i < 8; i++) { if (flags[i] === 0) { copyByte(); } else if (flags[i] === 1) { const sh = readShort(); const count = (sh >> 0xC) + 3; const disp = (sh & 0xFFF) + dispExtra; for (let j = 0; j < count; j++) { const byte = data[dataIndex - disp]; writeByte(byte); } } else { throw new Error(`Invalid flag: ${flags[i]}`); } if (decompressedSize <= dataIndex) { break; } } } return data; } } class Compression { static decompress(raw) { const header = new CompressionHeader(raw.slice(0, 4)); switch (header.compressionType) { case CompressionType.LZ10: return LZ10.decompress(raw.slice(4), header.decompressedSize); default: throw new Error(`Unsupported compression type: ${header.compressionType}`); } } } class InfoSection { constructor(raw) { // Header // 0x00 (1 byte): Dummy // 0x01 (1 byte): Number of entries this.numberOfEntries = raw.readUint8(0x01); // 0x02 (2 bytes): Section Size this.sectionSize = raw.readUint16(0x02); // Unknown Block // Header is 8 bytes long // Then there are 4 bytes per entry // So the size of the unknown block is 8 + (4 * numberOfEntries) // this.unknownBlock = raw.slice(4, 8 + (4 * this.numberOfEntries) + 4); let offset = 8 + (4 * this.numberOfEntries) + 4; // Info Data Block // 0x00 (2 bytes): Header Size // 0x02 (2 bytes): Data Size this.dataSize = raw.readUint16(offset + 2); const dataSectionSize = (this.dataSize - 4) / this.numberOfEntries; offset += 4; for (let i = 0; i < this.numberOfEntries; i++) { this.parseEntry(raw.slice(offset, offset + dataSectionSize)); offset += dataSectionSize; } // Name Block // No header, each name is 16 bytes long this.names = []; for (let i = 0; i < this.numberOfEntries; i++) { this.names.push(raw.readChars(offset, offset + 16).replace(/\0/g, "")); offset += 16; } } } class PaletteInfoSection extends InfoSection { parseEntry(raw) { if (this.entries === undefined) { this.entries = []; } this.entries.push(new PaletteInfo(raw)); } } class PaletteInfo { constructor(raw) { // 0x00 (2 bytes): Palette Offset, shift << 3, relative to the start of Palette Data this.paletteOffset = (raw.readUint16(0x00)) << 3; // Rest is unknown } } class TextureInfoSection extends InfoSection { parseEntry(raw) { if (this.entries === undefined) { this.entries = []; } this.entries.push(new TextureInfo(raw)); } } class TextureInfo { constructor(raw) { // 0x00 (2 bytes): Texture Offset, shift << 3, relative to the start of Texture Data this.textureOffset = (raw.readUint16(0x00)) << 3; // 0x02 (2 bytes): Parameters // --CFFFHHHWWW---- // C = First Color Transparent // F = Format // H = Height (8 << Height) // W = Width (8 << Width) const parameters = raw.readUint16(0x02); this.firstColorTransparent = (parameters & 8192) >> 13 === 1; this.format = (parameters & 7168) >> 10; this.height = (parameters & 896) >> 7; this.width = (parameters & 112) >> 4; // Width and Height are stored as right-shifted values (8 << Width or Height), so we need to shift them back this.width = 8 << this.width; this.height = 8 << this.height; // Rest is unknown } } // http://llref.emutalk.net/docs/?file=xml/btx0.xml#xml-doc // https://github.com/scurest/apicula/blob/master/src/nitro/tex.rs class TEX0Header { constructor(raw) { // 0x00 (4 bytes): Magic "TEX0" this.magic = raw.readChars(0x00, 4); // 0x04 (4 bytes): Section size this.sectionSize = raw.readUint32(0x04); // 0x08 (4 bytes): Padding // 0x0C (2 bytes): Texture Data Size this.textureDataSize = raw.readUint16(0x0C); // 0x0E (2 bytes): Texture Info Offset this.textureInfoOffset = raw.readUint16(0x0E); // 0x10 (4 bytes): Padding // 0x14 (4 bytes): Texture Data Offset this.textureDataOffset = raw.readUint32(0x14); // 0x18 (4 bytes): Padding // 0x1C (2 bytes): Compressed Texture Data Size this.compressedTextureDataSize = raw.readUint16(0x1C); // 0x1E (2 bytes): Compressed Texture Info Offset this.compressedTextureInfoOffset = raw.readUint16(0x1E); // 0x20 (4 bytes): Padding // 0x24 (4 bytes): Compressed Texture Data Offset this.compressedTextureDataOffset = raw.readUint32(0x24); // 0x28 (4 bytes): Compressed Texture Info Data Offset this.compressedTextureInfoDataOffset = raw.readUint32(0x28); // 0x2C (4 bytes): Padding // 0x30 (4 bytes): Palette Data Size this.paletteDataSize = raw.readUint32(0x30); // 0x34 (4 bytes): Palette Info Offset this.paletteInfoOffset = raw.readUint32(0x34); // 0x38 (4 bytes): Palette Data Offset this.paletteDataOffset = raw.readUint32(0x38); } } // I don't know how this works // Thank you, MIT License // https://github.com/magcius/noclip.website/blob/master/src/SuperMario64DS/nitro_tex.ts function readTexture_CMPR_4x4(width, height, texData, palIdxData, palData) { function getPal16(offs) { //return offs < palView.byteLength ? palView.getUint16(offs, true) : 0; return offs < palView.length ? palData.readUint16(offs) : 0; } function buildColorTable(palBlock) { const palMode = palBlock >> 14; const palOffs = (palBlock & 0x3FFF) << 2; const colorTable = new Uint8Array(16); const p0 = getPal16(palOffs + 0x00); bgr5(colorTable, 0, p0); colorTable[3] = 0xFF; const p1 = getPal16(palOffs + 0x02); bgr5(colorTable, 4, p1); colorTable[7] = 0xFF; if (palMode === 0) { // PTY=0, A=0 const p2 = getPal16(palOffs + 0x04); bgr5(colorTable, 8, p2); colorTable[11] = 0xFF; // Color4 is transparent black. } else if (palMode === 1) { // PTY=1, A=0 // Color3 is a blend of Color1/Color2. colorTable[8] = (colorTable[0] + colorTable[4]) >>> 1; colorTable[9] = (colorTable[1] + colorTable[5]) >>> 1; colorTable[10] = (colorTable[2] + colorTable[6]) >>> 1; colorTable[11] = 0xFF; // Color4 is transparent black. } else if (palMode === 2) { // PTY=0, A=1 const p2 = getPal16(palOffs + 0x04); bgr5(colorTable, 8, p2); colorTable[11] = 0xFF; const p3 = getPal16(palOffs + 0x06); bgr5(colorTable, 12, p3); colorTable[15] = 0xFF; } else { colorTable[8] = s3tcblend(colorTable[4], colorTable[0]); colorTable[9] = s3tcblend(colorTable[5], colorTable[1]); colorTable[10] = s3tcblend(colorTable[6], colorTable[2]); colorTable[11] = 0xFF; colorTable[12] = s3tcblend(colorTable[0], colorTable[4]); colorTable[13] = s3tcblend(colorTable[1], colorTable[5]); colorTable[14] = s3tcblend(colorTable[2], colorTable[6]); colorTable[15] = 0xFF; } return colorTable; } const pixels = new Uint8Array(width * height * 4); const texView = texData; const palIdxView = palIdxData; const palView = palData; let srcOffs = 0; for (let yy = 0; yy < height; yy += 4) { for (let xx = 0; xx < width; xx += 4) { // let texBlock = texView.getUint32((srcOffs * 0x04), true); let texBlock = texView.readUint32(srcOffs * 0x04); //const palBlock = palIdxView.getUint16((srcOffs * 0x02), true); const palBlock = palIdxView.readUint16(srcOffs * 0x02); const colorTable = buildColorTable(palBlock); for (let y = 0; y < 4; y++) { for (let x = 0; x < 4; x++) { const colorIdx = texBlock & 0x03; const dstOffs = 4 * (((yy + y) * width) + xx + x); pixels[dstOffs + 0] = colorTable[colorIdx * 4 + 0]; pixels[dstOffs + 1] = colorTable[colorIdx * 4 + 1]; pixels[dstOffs + 2] = colorTable[colorIdx * 4 + 2]; pixels[dstOffs + 3] = colorTable[colorIdx * 4 + 3]; texBlock >>= 2; } } srcOffs++; } } return pixels; } function bgr5(pixels, dstOffs, p) { pixels[dstOffs + 0] = expand5to8(p & 0x1F); pixels[dstOffs + 1] = expand5to8((p >>> 5) & 0x1F); pixels[dstOffs + 2] = expand5to8((p >>> 10) & 0x1F); } function expand5to8(n) { return (n << (8 - 5)) | (n >>> (10 - 8)); } function s3tcblend(a, b) { return (((a << 1) + a) + ((b << 2) + b)) >>> 3; } // http://problemkaputt.de/gbatek.htm#ds3dtextureformats class TextureFormats { // Format 1 static parseA3I5(texRaw, palRaw, width, height, firstColorTransparent) { // Texture Format: 8 bits per texel // IIIIIAAA // I: Palette Index // A: Alpha // Palette Format: RGBA5551, 2 bytes per texel const tex = new Uint8Array(width * height * 4); for (let i = 0; i < texRaw.length; i++) { const texel = texRaw.readUint8(i); const index = texel & 0b00011111; const alpha = (texel & 0b11100000) >> 5; const color = palRaw.readUint16(index * 2); tex[i * 4 + 0] = this.color5to8((color >> 0) & 0x1F); tex[i * 4 + 1] = this.color5to8((color >> 5) & 0x1F); tex[i * 4 + 2] = this.color5to8((color >> 10) & 0x1F); // The DS expands the 3-bit alpha value to 5 bits like this: alpha = (alpha * 4) + (alpha / 2) // Simplified: alpha = (9/2 * alpha) // In order to convert it to 8-bit alpha, we do something similar: // alpha = (255/7 * alpha) // This isn't 100% accurate due to rounding, but there's no way to get a perfect result tex[i * 4 + 3] = Math.floor((255 / 7) * alpha); } return tex; } // Format 2 static parsePalette4(texRaw, palRaw, width, height, firstColorTransparent) { // Texture Format: 2 bits per texel // Palette Format: RGBA5551, 2 bytes per texel // Alpha values don't seem to be used const tex = new Uint8Array(width * height * 4); for (let i = 0; i < texRaw.length; i++) { const texels = texRaw.readUint8(i); const texel1 = texels & 0b00000011; const texel2 = (texels & 0b00001100) >> 2; const texel3 = (texels & 0b00110000) >> 4; const texel4 = (texels & 0b11000000) >> 6; const color1 = palRaw.readUint16(texel1 * 2); const color2 = palRaw.readUint16(texel2 * 2); const color3 = palRaw.readUint16(texel3 * 2); const color4 = palRaw.readUint16(texel4 * 2); tex[i * 16 + 0] = this.color5to8((color1 >> 0) & 0x1F); tex[i * 16 + 1] = this.color5to8((color1 >> 5) & 0x1F); tex[i * 16 + 2] = this.color5to8((color1 >> 10) & 0x1F); tex[i * 16 + 3] = 255; tex[i * 16 + 4] = this.color5to8((color2 >> 0) & 0x1F); tex[i * 16 + 5] = this.color5to8((color2 >> 5) & 0x1F); tex[i * 16 + 6] = this.color5to8((color2 >> 10) & 0x1F); tex[i * 16 + 7] = 255; tex[i * 16 + 8] = this.color5to8((color3 >> 0) & 0x1F); tex[i * 16 + 9] = this.color5to8((color3 >> 5) & 0x1F); tex[i * 16 + 10] = this.color5to8((color3 >> 10) & 0x1F); tex[i * 16 + 11] = 255; tex[i * 16 + 12] = this.color5to8((color4 >> 0) & 0x1F); tex[i * 16 + 13] = this.color5to8((color4 >> 5) & 0x1F); tex[i * 16 + 14] = this.color5to8((color4 >> 10) & 0x1F); tex[i * 16 + 15] = 255; if (firstColorTransparent) { if (texel1 === 0) { tex[i * 16 + 3] = 0; } if (texel2 === 0) { tex[i * 16 + 7] = 0; } if (texel3 === 0) { tex[i * 16 + 11] = 0; } if (texel4 === 0) { tex[i * 16 + 15] = 0; } } } return tex; } // Format 3 static parsePalette16(texRaw, palRaw, width, height, firstColorTransparent) { // Texture Format: 4 bits per texel // Palette Format: RGBA5551, 2 bytes per texel // Alpha values don't seem to be used const tex = new Uint8Array(width * height * 4); for (let i = 0; i < texRaw.length; i++) { const texels = texRaw.readUint8(i); const texel1 = texels & 0x0F; const texel2 = (texels & 0xF0) >> 4; const color1 = palRaw.readUint16(texel1 * 2); const color2 = palRaw.readUint16(texel2 * 2); tex[i * 8 + 0] = this.color5to8((color1 >> 0) & 0x1F); tex[i * 8 + 1] = this.color5to8((color1 >> 5) & 0x1F); tex[i * 8 + 2] = this.color5to8((color1 >> 10) & 0x1F); tex[i * 8 + 3] = 255; if (firstColorTransparent && texel1 === 0) { tex[i * 8 + 3] = 0; } tex[i * 8 + 4] = this.color5to8((color2 >> 0) & 0x1F); tex[i * 8 + 5] = this.color5to8((color2 >> 5) & 0x1F); tex[i * 8 + 6] = this.color5to8((color2 >> 10) & 0x1F); tex[i * 8 + 7] = 255; if (firstColorTransparent && texel2 === 0) { tex[i * 8 + 7] = 0; } } return tex; } // Format 4 static parsePalette256(texRaw, palRaw, width, height, firstColorTransparent) { // Texture Format: 8 bits per texel // Palette Format: RGBA5551, 2 bytes per texel // Alpha values don't seem to be used const tex = new Uint8Array(width * height * 4); for (let i = 0; i < texRaw.length; i++) { const texel = texRaw.readUint8(i); const color = palRaw.readUint16(texel * 2); tex[i * 4 + 0] = this.color5to8((color >> 0) & 0x1F); tex[i * 4 + 1] = this.color5to8((color >> 5) & 0x1F); tex[i * 4 + 2] = this.color5to8((color >> 10) & 0x1F); tex[i * 4 + 3] = 255; if (firstColorTransparent && texel === 0) { tex[i * 4 + 3] = 0; } } return tex; } // Format 5 static parseCompressed4x4(texRaw, palRaw, palIdxData, width, height) { return readTexture_CMPR_4x4(width, height, texRaw, palIdxData, palRaw); } // Format 6 static parseA5I3(texRaw, palRaw, width, height, firstColorTransparent) { // Texture Format: 8 bits per texel // IIIAAAAA // I: Palette Index // A: Alpha // Palette Format: RGBA5551, 2 bytes per texel const tex = new Uint8Array(width * height * 4); for (let i = 0; i < texRaw.length; i++) { const texel = texRaw.readUint8(i); const index = texel & 0b00000111; const alpha = (texel & 0b11111000) >> 3; const color = palRaw.readInt16(index * 2); tex[i * 4 + 0] = this.color5to8((color >> 0) & 0x1F); tex[i * 4 + 1] = this.color5to8((color >> 5) & 0x1F); tex[i * 4 + 2] = this.color5to8((color >> 10) & 0x1F); // Expand 5-bit alpha to 8-bit tex[i * 4 + 3] = Math.floor(255 / 31 * alpha); } return tex; } // Format 7 static parseDirectColor(texRaw, width, height) { // Texture Format: RGBA5551, 2 bytes per texel const tex = new Uint8Array(width * height * 4); for (let i = 0; i < texRaw.length; i += 2) { const color = texRaw.readUint16(i); tex[i * 2 + 0] = this.color5to8((color >> 0) & 0x1F); tex[i * 2 + 1] = this.color5to8((color >> 5) & 0x1F); tex[i * 2 + 2] = this.color5to8((color >> 10) & 0x1F); tex[i * 2 + 3] = ((color >> 15) & 0x01) << 7; } return tex; } static color5to8(value) { return Math.floor(255 / 31 * value); } } class TEX0 { constructor(raw) { this.raw = raw; this.header = new TEX0Header(raw.slice(0, 0x40)); if (this.header.magic !== "TEX0") { throw new Error("Invalid TEX0 magic"); } this.textureInfo = new TextureInfoSection(raw.slice(this.header.textureInfoOffset)); this.paletteInfo = new PaletteInfoSection(raw.slice(this.header.paletteInfoOffset)); } parseTexture(texIndex, palIndex = texIndex) { const textureInfo = this.textureInfo.entries[texIndex]; switch (textureInfo.format) { case 1: { // A3I5 const texOffset = this.header.textureDataOffset + textureInfo.textureOffset; const texSize = textureInfo.width * textureInfo.height; const texRaw = this.raw.slice(texOffset, texOffset + texSize); const paletteInfo = this.paletteInfo.entries[palIndex]; const paletteOffset = this.header.paletteDataOffset + paletteInfo.paletteOffset; const palRaw = this.raw.slice(paletteOffset, paletteOffset + 0x40); return TextureFormats.parseA3I5(texRaw, palRaw, textureInfo.width, textureInfo.height, textureInfo.firstColorTransparent); } case 2: { // 4-Color Palette const texOffset = this.header.textureDataOffset + textureInfo.textureOffset; const texSize = textureInfo.width * textureInfo.height / 4; const texRaw = this.raw.slice(texOffset, texOffset + texSize); const paletteInfo = this.paletteInfo.entries[palIndex]; const paletteOffset = this.header.paletteDataOffset + paletteInfo.paletteOffset; const palRaw = this.raw.slice(paletteOffset, paletteOffset + 0x08); return TextureFormats.parsePalette4(texRaw, palRaw, textureInfo.width, textureInfo.height, textureInfo.firstColorTransparent); } case 3: { // 16-Color Palette const texOffset = this.header.textureDataOffset + textureInfo.textureOffset; const texSize = textureInfo.width * textureInfo.height / 2; const texRaw = this.raw.slice(texOffset, texOffset + texSize); const paletteInfo = this.paletteInfo.entries[palIndex]; const paletteOffset = this.header.paletteDataOffset + paletteInfo.paletteOffset; const palRaw = this.raw.slice(paletteOffset, paletteOffset + 0x20); return TextureFormats.parsePalette16(texRaw, palRaw, textureInfo.width, textureInfo.height, textureInfo.firstColorTransparent); } case 4: { // 256-Color Palette const texOffset = this.header.textureDataOffset + textureInfo.textureOffset; const texSize = textureInfo.width * textureInfo.height; const texRaw = this.raw.slice(texOffset, texOffset + texSize); const paletteInfo = this.paletteInfo.entries[palIndex]; const paletteOffset = this.header.paletteDataOffset + paletteInfo.paletteOffset; const palRaw = this.raw.slice(paletteOffset, paletteOffset + 0x400); return TextureFormats.parsePalette256(texRaw, palRaw, textureInfo.width, textureInfo.height, textureInfo.firstColorTransparent); } case 5: { // Compressed 4x4 Texel // Please don't ask me how this works because I don't know either // I just changed some values until it somehow worked const texOffset = this.header.textureDataOffset + textureInfo.textureOffset; const texSize = textureInfo.width * textureInfo.height / 2; const texRaw = this.raw.slice(texOffset, texOffset + texSize); const paletteInfo = this.paletteInfo.entries[palIndex]; const paletteOffset = this.header.paletteDataOffset + paletteInfo.paletteOffset; const palRaw = this.raw.slice(paletteOffset); // Compressed 4x4 Texels have a second buffer after the texture data that contains the palette indices const palIdxOffset = this.header.compressedTextureInfoDataOffset + textureInfo.textureOffset / 2; const palIdxSize = (textureInfo.width * textureInfo.height) / 2; const palIdxData = this.raw.slice(palIdxOffset, palIdxOffset + palIdxSize); return TextureFormats.parseCompressed4x4(texRaw, palRaw, palIdxData, textureInfo.width, textureInfo.height); } case 6: { // A5I3 const texOffset = this.header.textureDataOffset + textureInfo.textureOffset; const texSize = textureInfo.width * textureInfo.height; const texRaw = this.raw.slice(texOffset, texOffset + texSize); const paletteInfo = this.paletteInfo.entries[palIndex]; const paletteOffset = this.header.paletteDataOffset + paletteInfo.paletteOffset; const palRaw = this.raw.slice(paletteOffset, paletteOffset + 0x10); return TextureFormats.parseA5I3(texRaw, palRaw, textureInfo.width, textureInfo.height, textureInfo.firstColorTransparent); } case 7: { // Direct Color const texOffset = this.header.textureDataOffset + textureInfo.textureOffset; const texSize = textureInfo.width * textureInfo.height * 2; const texRaw = this.raw.slice(texOffset, texOffset + texSize); return TextureFormats.parseDirectColor(texRaw, textureInfo.width, textureInfo.height); } default: throw new Error(`Unsupported texture format: ${textureInfo.format}`); } } } // http://llref.emutalk.net/docs/?file=xml/btx0.xml#xml-doc class BTX0Header { constructor(raw) { // 0x00 (4 bytes): Magic "BTX0" this.magic = raw.readChars(0x00, 4); // 0x04 (4 bytes): Constant 0x0001FEFF // 0x08 (4 bytes): File size this.fileSize = raw.readUint32(0x08); // 0x0C (2 bytes): Header size (0x10) // 0x0E (2 bytes): Number of textures (0x01) // 0x10 (4 bytes): TEX0 offset this.texOffset = raw.readUint32(0x10); } } class BTX0 { constructor(raw) { this.header = new BTX0Header(raw.slice(0, 0x14)); if (this.header.magic !== "BTX0") { throw new Error("Invalid BTX0 magic"); } this.tex = new TEX0(raw.slice(this.header.texOffset)); } } class NCL { constructor(raw, offset = 0) { this.raw = raw; this.offset = offset; } colorAt(index) { const color = this.raw.slice(index * 2 + this.offset, index * 2 + 2 + this.offset); const r = this.color5to8(color[0] & 0x1F); const g = this.color5to8((color[0] >> 5) | ((color[1] & 0x3) << 3)); const b = this.color5to8(color[1] >> 2); return new Uint8Array([r, g, b, 255]); } colors() { const colors = new Uint8Array((this.raw.length - this.offset) / 2 * 4); for (let i = 0; i < this.raw.length / 2; i++) { const color = this.colorAt(i); colors[i * 4 + 0] = color[0]; colors[i * 4 + 1] = color[1]; colors[i * 4 + 2] = color[2]; colors[i * 4 + 3] = color[3]; } return colors; } get length() { return (this.raw.length - this.offset) / 2; } color5to8(value) { return Math.floor(255 / 31 * value); } } class NCG { constructor(raw, offset = 0) { this.raw = raw; this.offset = offset; } parse(ncl, paletteIndex = 0) { const image = new Uint8Array(this.raw.length - this.offset); for (let i = 0; i < image.length; i++) { const colorIndex = this.raw[i + this.offset]; const color = ncl.colorAt(paletteIndex * 16 + colorIndex); image[i * 4 + 0] = color[0]; image[i * 4 + 1] = color[1]; image[i * 4 + 2] = color[2]; image[i * 4 + 3] = color[3]; } return image; } get length() { return this.raw.length - this.offset; } } // https://gota7.github.io/NitroStudio2/specs/common.html class Block { constructor(raw, assertMagic) { // 0x00 (4 bytes) - Magic this.magic = raw.readChars(0x00, 4); if (assertMagic && this.magic !== assertMagic) { throw new Error(`Invalid magic: ${this.magic} (expected ${assertMagic})`); } // 0x04 (4 bytes) - Size this.size = raw.readUint32(0x04); } } // https://gota7.github.io/NitroStudio2/specs/common.html class SoundFileHeader { constructor(raw, assertMagic) { // 0x00 (4 bytes) - Magic this.magic = raw.readChars(0x00, 4); if (assertMagic && this.magic !== assertMagic) { throw new Error(`Invalid magic: ${this.magic} (expected ${assertMagic})`); } // 0x04 (2 bytes) - Endianness (0xFEFF) this.endianness = raw.readUint16(0x04); // 0x06 (2 bytes) - Version (0x0100) // 0x08 (4 bytes) - File size this.fileSize = raw.readUint32(0x08); // 0x0C (2 bytes) - Header size this.headerSize = raw.readUint16(0x0C); // 0x0E (2 bytes) - Number of blocks this.blockCount = raw.readUint16(0x0E); } } class TableEntry { constructor(raw) { } } class Table { constructor(raw, type) { // Table header // 0x00 (4 bytes): Number of entries const entryCount = raw.readUint32(0x00); // Read entries this.entries = []; let offset = 4; for (let i = 0; i < entryCount; i++) { const entry = new type(raw.slice(offset)); this.entries.push(entry); offset += entry.length; } } } class Uint32TableEntry extends TableEntry { constructor(raw) { super(raw); this.length = 0x04; // 0x00 (4 bytes): Value this.value = raw.readUint32(0x00); } } var EncodingType; (function (EncodingType) { EncodingType[EncodingType["PCM8"] = 0] = "PCM8"; EncodingType[EncodingType["PCM16"] = 1] = "PCM16"; EncodingType[EncodingType["IMA_ADPCM"] = 2] = "IMA_ADPCM"; })(EncodingType || (EncodingType = {})); class Encoding { static toPCM(raw, encoding) { switch (encoding) { case EncodingType.PCM8: return this.PCM8toPCM(raw); case EncodingType.PCM16: return this.PCM16toPCM(raw); case EncodingType.IMA_ADPCM: return this.IMA_ADPCMtoPCM(raw); default: throw new Error("Unknown encoding type"); } } static PCM8toPCM(raw) { let buffer = new Float32Array(raw.length); for (let i = 0; i < raw.length; i++) { buffer[i] = raw.readInt8(i) / 128; } return buffer; } static PCM16toPCM(raw) { let buffer = new Float32Array(raw.length / 2); for (let i = 0; i < raw.length / 2; i++) { buffer[i] = raw.readInt16(i * 2) / 32768; } return buffer; } static IMA_ADPCMtoPCM(raw) { let buffer = new Float32Array(raw.length * 2 - 8); let destOff = 0; let decompSample = raw.readInt16(0); let stepIndex = raw.readUint16(2) & 0x7F; let currentOffset = 4; buffer[destOff++] = decompSample / 32768; let compByte; while (currentOffset < raw.length) { compByte = raw.readUint8(currentOffset++); const result1 = this.processNibble(compByte & 0x0F, stepIndex, decompSample); decompSample = result1.decompSample; stepIndex = result1.stepIndex; buffer[destOff++] = decompSample / 32768; const result2 = this.processNibble((compByte & 0xF0) >> 4, stepIndex, decompSample); decompSample = result2.decompSample; stepIndex = result2.stepIndex; buffer[destOff++] = decompSample / 32768; } return buffer; } static processNibble(nibble, stepIndex, decompSample) { function min(sample) { return (sample > 0x7FFF) ? 0x7FFF : sample; } function max(sample) { return (sample < -0x7FFF) ? -0x7FFF : sample; } function minmax(index, min, max) { return (index > max) ? max : ((index < min) ? min : index); } let diff = Math.floor(this.adpcmStepTable[stepIndex] / 8); if (nibble & 1) diff += Math.floor(this.adpcmStepTable[stepIndex] / 4); if (nibble & 2) diff += Math.floor(this.adpcmStepTable[stepIndex] / 2); if (nibble & 4) diff += Math.floor(this.adpcmStepTable[stepIndex]); if ((nibble & 8) == 0) { decompSample = max(decompSample + diff); } if ((nibble & 8) == 8) { decompSample = min(decompSample - diff); } stepIndex = minmax(stepIndex + this.adpcmIndexTable[nibble & 7], 0, 88); return { decompSample, stepIndex }; } } Encoding.adpcmIndexTable = [ -1, -1, -1, -1, 2, 4, 6, 8 ]; Encoding.adpcmStepTable = [ 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449, 494, 544, 598, 658, 724, 796, 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767 ]; // https://gota7.github.io/NitroStudio2/specs/soundData.html#info-block class SequenceInfo { constructor(raw) { this.fileId = raw.readUint32(0x00); this.bankId = raw.readUint16(0x04); this.volume = raw.readUint8(0x06); this.channelPriority = raw.readUint8(0x07); this.playerPriority = raw.readUint8(0x08); this.playerId = raw.readUint8(0x09); } } class SequenceArchiveInfo { constructor(raw) { this.fileId = raw.readUint32(0x00); } } class BankInfo { constructor(raw) { this.fileId = raw.readUint32(0x00); this.waveArchives = new Array(4); this.waveArchives[0] = raw.readInt16(0x04); this.waveArchives[1] = raw.readInt16(0x06); this.waveArchives[2] = raw.readInt16(0x08); this.waveArchives[3] = raw.readInt16(0x0A); } } class WaveArchiveInfo { constructor(raw) { // File ID in form 0xLLFFFFFF where F is the file ID and L is a bool to indicate that the archive should be loaded individually const value = raw.readUint32(0x00); this.fileId = value & 0x00FFFFFF; this.loadIndividually = (value & 0xFF000000) !== 0; } } class PlayerInfo { constructor(raw) { this.maxVoices = raw.readUint16(0x00); this.channels = raw.readUint16(0x02); this.heapSize = raw.readUint32(0x04); } } class GroupInfo { constructor(raw) { const table = new Table(raw, GroupEntry); this.entries = table.entries; } } class GroupEntry extends TableEntry { constructor(raw) { super(raw); this.length = 0x08; this.type = raw.readUint8(0x00); this.load = raw.readUint8(0x01); // Padding (2 bytes) this.entryId = raw.readUint32(0x04); } } var GroupEntryType; (function (GroupEntryType) { GroupEntryType[GroupEntryType["Sequence"] = 0] = "Sequence"; GroupEntryType[GroupEntryType["Bank"] = 1] = "Bank"; GroupEntryType[GroupEntryType["WaveArchive"] = 2] = "WaveArchive"; GroupEntryType[GroupEntryType["SequenceArchive"] = 3] = "SequenceArchive"; })(GroupEntryType || (GroupEntryType = {})); class StreamPlayerInfo { constructor(raw) { this.channelCount = raw.readUint8(0x00); this.leftOrMonoChannel = raw.readUint8(0x01); this.rightChannel = raw.readUint8(0x02); } } class StreamInfo { constructor(raw) { // File ID in form 0xLLFFFFFF where F is the file ID and L is a bool to indicate that the stream should be converted to stereo const value = raw.readUint32(0x00); this.fileId = value & 0x00FFFFFF; this.convertToStereo = (value & 0xFF000000) !== 0; this.volume = raw.readUint8(0x04); this.priority = raw.readUint8(0x05); this.playerId = raw.readUint8(0x06); } } // https://gota7.github.io/NitroStudio2/specs/soundData.html#info-block class InfoBlock extends Block { constructor(raw) { super(raw, "INFO"); function readTable(offset, fileInfoType) { const fileInfos = []; const tableOffset = raw.readUint32(offset); const table = new Table(raw.slice(tableOffset), Uint32TableEntry); for (let i = 0; i < table.entries.length; i++) { const entry = table.entries[i]; if (entry.value === 0) { continue; } const infoOffset = entry.value; const info = new fileInfoType(raw.slice(infoOffset)); fileInfos[i] = info; } return fileInfos; } this.sequenceInfo = readTable(0x08, Sequ