empeeku
Version:
Javascript port of mpyq python library for reading MPQ archives.
456 lines (371 loc) • 14.4 kB
JavaScript
/* mpyq.js is a Javascript port of the mpyq Python library for reading MPQ (MoPaQ) archives. */
const path = require('path');
const fs = require('fs');
const bzip = require('seek-bzip');
const zlib = require('zlib');
const Long = require('long');
exports.version = require('./package.json').version;
const MPQ_FILE_IMPLODE = 0x00000100;
const MPQ_FILE_COMPRESS = 0x00000200;
const MPQ_FILE_ENCRYPTED = 0x00010000;
const MPQ_FILE_FIX_KEY = 0x00020000;
const MPQ_FILE_SINGLE_UNIT = 0x01000000;
const MPQ_FILE_DELETE_MARKER = 0x02000000;
const MPQ_FILE_SECTOR_CRC = 0x04000000;
const MPQ_FILE_EXISTS = 0x80000000;
function MPQFileHeader(data) {
this.magic = data.toString('utf8', 0, 4);
this.headerSize = data.readUInt32LE(4);
this.archiveSize = data.readUInt32LE(8);
this.formatVersion = data.readUInt16LE(12);
this.sectorSizeShift = data.readUInt16LE(14);
this.hashTableOffset = data.readUInt32LE(16);
this.blockTableOffset = data.readUInt32LE(20);
this.hashTableEntries = data.readUInt32LE(24);
this.blockTableEntries = data.readUInt32LE(28);
}
function MPQFileHeaderExt(data) {
this.extendedBlockTableOffset = data.readIntLE(0, 8);
this.hashTableOffsetHigh = data.readInt8(8);
this.blockTableOffsetHigh = data.readInt8(10);
}
function MPQUserDataHeader(data) {
this.magic = data.toString('utf8', 0, 4);
this.userDataSize = data.readUInt32LE(4);
this.mpqHeaderOffset = data.readUInt32LE(8);
this.userDataHeaderSize = data.readUInt32LE(12);
}
function MPQHashTableEntry(data) {
this.hashA = data.readUInt32BE(0);
this.hashB = data.readUInt32BE(4);
this.locale = data.readUInt16BE(8);
this.platform = data.readUInt16BE(10);
this.blockTableIndex = data.readUInt32BE(12);
}
function MPQBlockTableEntry(data) {
this.offset = data.readUInt32BE(0);
this.archivedSize = data.readUInt32BE(4);
this.size = data.readUInt32BE(8);
this.flags = data.readUInt32BE(12);
}
const MPQArchive = function(filename, listfile) {
/*
Create a MPQArchive object.
You can skip reading the listfile if you pass listfile=false
to the constructor. The 'files' attribute will be unavailable
if you do this.
*/
if (typeof listfile === 'undefined') listfile = true;
if (filename instanceof Buffer) {
this.file = filename;
} else {
this.filename = filename;
this.file = fs.readFileSync(filename);
}
this.header = this.readHeader();
this.hashTable = this.readTable('hash');
this.blockTable = this.readTable('block');
if (listfile) {
this.files = this.readFile('(listfile)').toString().trim().split('\r\n');
} else {
this.files = null;
}
};
MPQArchive.prototype.readHeader = function() {
// Read the header of a MPQ archive.
var magic = this.file.toString('utf8', 0, 4), header;
if (magic === 'MPQ\x1a') {
header = this.readMPQHeader();
header.offset = 0;
} else if (magic === 'MPQ\x1b') {
var userDataHeader = this.readMPQUserDataHeader();
header = this.readMPQHeader(userDataHeader.mpqHeaderOffset);
header.offset = userDataHeader.mpqHeaderOffset;
header.userDataHeader = userDataHeader;
} else {
throw new Error('Invalid file header');
}
return header;
};
MPQArchive.prototype.readMPQHeader = function(offset) {
offset = offset || 0;
var data = this.file.slice(offset, offset + 32);
var header = new MPQFileHeader(data);
if (header.formatVersion === 1) {
data = this.file.slice(offset + 32, offset + 32 + 12);
Object.assign(header, new MPQFileHeaderExt(data));
}
return header;
};
MPQArchive.prototype.readMPQUserDataHeader = function() {
var data = this.file.slice(0, 16);
var header = new MPQUserDataHeader(data);
header.content = this.file.slice(16, 16 + header.userDataHeaderSize);
return header;
};
MPQArchive.prototype.readTable = function(tableType) {
// Read either the hash or block table of a MPQ archive.
var Type;
if (tableType === 'hash') {
Type = MPQHashTableEntry;
} else if (tableType === 'block') {
Type = MPQBlockTableEntry;
} else {
throw new Error('Invalid table type.');
}
var tableOffset = this.header[tableType + 'TableOffset'];
var tableEntries = this.header[tableType + 'TableEntries'];
var key = this._hash('(' + tableType + ' table)', 'TABLE');
var data = this.file.slice(tableOffset + this.header.offset, tableOffset + this.header.offset + tableEntries * 16);
data = this._decrypt(data, key);
var entries = [];
for (var i = 0; i < tableEntries; i += 1) {
entries[i] = new Type(data.slice(i * 16, i * 16 + 16));
}
return entries;
}
MPQArchive.prototype.getHashTableEntry = function(filename) {
// Get the hash table entry corresponding to a given filename.
var hashA = this._hash(filename, 'HASH_A');
var hashB = this._hash(filename, 'HASH_B');
for (var entry of this.hashTable) {
if (entry.hashA === hashA && entry.hashB === hashB) return entry;
}
};
MPQArchive.prototype.readFile = function(filename, forceDecompress) {
// Read a file from the MPQ archive.
function decompress(data) {
// Read the compression type and decompress file data.
var compressionType = data.readUInt8(0);
if (compressionType === 0) return data;
else if (compressionType === 2) return zlib.unzipSync(data.slice(1));
else if (compressionType === 16) return bzip.decode(data.slice(1));
else throw new Error('Unsupported compression type.');
}
var hashEntry = this.getHashTableEntry(filename);
if (!hashEntry) return null;
var blockEntry = this.blockTable[hashEntry.blockTableIndex];
// Read the block.
if (blockEntry.flags & MPQ_FILE_EXISTS) {
if (blockEntry.archivedSize === 0) return null;
var offset = blockEntry.offset + this.header.offset;
var fileData = this.file.slice(offset, offset + blockEntry.archivedSize);
if (blockEntry.flags & MPQ_FILE_ENCRYPTED) {
throw new Error('Encryption is not yupported yet');
}
if (!(blockEntry.flags & MPQ_FILE_SINGLE_UNIT)) {
// File consists of many sectors. They all need to be
// decompressed separately and united.
var sectorSize = 512 << this.header.sectorSizeShift;
var sectors = Math.trunc(blockEntry.size / sectorSize) + 1;
var crc;
if (blockEntry.flags & MPQ_FILE_SECTOR_CRC) {
crc = true;
sectors += 1;
} else {
crc = false;
}
var positions = [], i;
for (i = 0; i < (sectors + 1); i += 1) {
positions[i] = fileData.readUInt32LE(4*i);
}
var ln = positions.length - (crc ? 2 : 1);
var result = new Buffer(0);
var sectorBytesLeft = blockEntry.size;
for (i = 0; i < ln; i+= 1) {
var sector = fileData.slice(positions[i], positions[i + 1]);
if ((blockEntry.flags & MPQ_FILE_COMPRESS) && (forceDecompress || (sectorBytesLeft > sector.length))) {
sector = decompress(sector);
}
sectorBytesLeft -= sector.length;
result = new Buffer.concat([result, sector]);
}
fileData = result;
} else {
// Single unit files only need to be decompressed, but
// compression only happens when at least one byte is gained.
if ((blockEntry.flags & MPQ_FILE_COMPRESS) && (forceDecompress || (blockEntry.size > blockEntry.archivedSize))) {
fileData = decompress(fileData);
}
}
return fileData;
}
};
MPQArchive.prototype.extract = function() {
// Extract all the files inside the MPQ archive in memory.
if (this.files) {
return this.files.map(filename => {
return [filename, this.readFile(filename)];
});
} else {
throw new Error('Can\'t extract whole archive without listfile.');
}
};
MPQArchive.prototype.extractToDisk = function() {
// Extract all files and write them to disk.
var extension = path.extname(this.filename);
var archiveName = path.basename(this.filename, extension);
var dirName = path.join(process.cwd(), archiveName);
try {
fs.statSync(dirName)
} catch (err) {
fs.mkdirSync(dirName);
}
process.chdir(archiveName);
this.extract().forEach(pair => {
fs.writeFileSync(pair[0], pair[1] || '');
});
};
MPQArchive.prototype.extractFiles = function(filenames) {
// Extract given files from the archive to disk.
for (filename of filenames) {
fs.mriteFileSync(filename, this.readFile(filename));
}
};
MPQArchive.prototype.printHeaders = function() {
console.log('MPQ archive header');
console.log('------------------');
for (var key in this.header) {
if (key === 'userDataHeader') continue;
console.log(key + ' - ' + this.header[key]);
}
if (this.header.userDataHeader) {
console.log();
console.log('MPQ user data header');
console.log('--------------------');
console.log();
for (var key in this.header.userDataHeader) {
console.log(key + ' - ' + this.header.userDataHeader[key]);
}
}
console.log();
};
function leadingChar(str, ch, ln, after) {
str = '' + str;
while (str.length < ln) {
str = after ? str + ch : ch + str;
}
return str;
}
function formatWord(data, ln) {
return leadingChar(data.toString(16).toUpperCase(), '0', ln);
}
MPQArchive.prototype.printHashTable = function() {
console.log('MPQ archive hash table');
console.log('----------------------');
console.log('Hash A\t\tHash B\t\tLocl\tPlat\tBlockIdx');
var format = [8, 8, 4, 4, 8];
this.hashTable.forEach(entry => {
console.log(Object.keys(entry).map((key, i) => {
return formatWord(entry[key], format[i]);
}).join('\t'));
});
console.log();
};
MPQArchive.prototype.printBlockTable = function() {
console.log('MPQ archive block table');
console.log('-----------------------');
console.log('Offset\t\tArchSize\tRealSize\tFlags');
this.blockTable.forEach(entry => {
console.log([
formatWord(entry.offset, 8),
leadingChar(entry.archivedSize, ' ', 8),
leadingChar(entry.size, ' ', 8),
formatWord(entry.flags, 8)
].join('\t'));
});
console.log();
};
MPQArchive.prototype.printFiles = function() {
var width = this.files.reduce((top, filename) => Math.max(top, filename.length), 0), hashEntry, blockEntry;
console.log('Files');
console.log('-----');
for (var filename of this.files) {
hashEntry = this.getHashTableEntry(filename);
blockEntry = this.blockTable[hashEntry.blockTableIndex];
console.log(leadingChar(filename, ' ', width, true) + ' ' + leadingChar(blockEntry.size, ' ', 8) + ' bytes');
}
};
const hashTypes = {
'TABLE_OFFSET': 0,
'HASH_A': 1,
'HASH_B': 2,
'TABLE': 3
};
MPQArchive.prototype._hash = function(string, hashType) {
// Hash a string using MPQ's hash function.
var seed1, seed2, ch, value;
seed1 = new Long.fromValue(0x7FED7FED, true);
seed2 = new Long.fromValue(0xEEEEEEEE, true);
for (ch of string.toUpperCase()) {
if (isNaN(parseInt(ch, 10))) ch = ch.codePointAt(0);
value = new Long.fromValue(this.encryptionTable[(hashTypes[hashType] << 8) + ch], true);
seed1 = value.xor(seed1.add(seed2)).and(0xFFFFFFFF);
seed2 = seed1.add(seed2).add(ch).add(seed2.shiftLeft(5)).add(3).and(0xFFFFFFFF);
}
return seed1.toNumber();
};
MPQArchive.prototype._decrypt = function(data, key) {
// Decrypt hash or block table or a sector.
var seed1, seed2, result = new Buffer(data.length);
var i, ln = data.length / 4, value;
seed1 = new Long.fromValue(key, true);
seed2 = new Long.fromValue(0xEEEEEEEE, true);
for (i = 0; i < ln; i += 1) {
seed2 = seed2.add(this.encryptionTable[0x400 + (seed1 & 0xFF)]);
seed2 = seed2.and(0xFFFFFFFF);
value = new Long.fromValue(data.readUInt32LE(i * 4), true);
value = value.xor(seed1.add(seed2)).and(0xFFFFFFFF);
seed1 = seed1.xor(-1).shiftLeft(0x15).add(0x11111111).or(seed1.shiftRight(0x0B));
seed1 = seed1.and(0xFFFFFFFF);
seed2 = value.add(seed2).add(seed2.shiftLeft(5)).add(3).and(0xFFFFFFFF);
result.writeUInt32BE(value.toNumber(), i * 4);
}
return result;
};
MPQArchive.prototype.encryptionTable = (function() {
// Prepare encryption table for MPQ hash function.
var seed, index, t1, t2, i, j;
var table = {};
seed = new Long.fromValue(0x00100001, true);
for (i = 0; i < 256; i += 1) {
index = i;
for (j = 0; j < 5; j += 1) {
seed = seed.mul(125).add(3).mod(0x2AAAAB);
t1 = seed.and(0xFFFF).shiftLeft(0x10);
seed = seed.mul(125).add(3).mod(0x2AAAAB);
t2 = seed.and(0xFFFF);
table[index] = t1.or(t2).toNumber();
index += 0x100;
}
}
return table;
})();
exports.MPQArchive = MPQArchive;
if (require.main === module) {
(function () {
const yargs = require('yargs')
.usage('usage: mpq.js [-h] [-I] [-H] [-b] [-s] [-t] [-x] file\n\nmpq.js reads and extracts MPQ archives')
.demand(1)
.alias('h', 'help').boolean('h').describe('h', 'show this help message and exit')
.alias('I', 'headers').boolean('I').describe('I', 'print header information from the archive')
.alias('H', 'hash-table').boolean('H').describe('H', 'print hash table')
.alias('b', 'block-table').boolean('b').describe('b', 'print block table')
.alias('s', 'skip-listfile').boolean('s').describe('s', 'skip reading(listfile)')
.alias('t', 'list-file').boolean('t').describe('t', 'list files inside the archive')
.alias('x', 'extract').boolean('x').describe('x', 'extract files from the archive');
const args = yargs.argv, filename = process.cwd() + path.sep + args._[0];
var archive = null;
if (!args.skipListfile) archive = new MPQArchive(filename);
else archive = new MPQArchive(filename, false);
if (args.help) {
yargs.showHelp();
process.exit();
}
if (args.headers) archive.printHeaders();
if (args.hashTable) archive.printHashTable();
if (args.blockTable) archive.printBlockTable();
if (args.listFile) archive.printFiles();
if (args.extract) archive.extractToDisk();
})();
}