nitro-fs
Version:
NDS Filesystem reading and parsing library
1,272 lines (1,247 loc) • 168 kB
JavaScript
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