mpqjs
Version:
Library for reading MPQ (MoPaQ) archives
412 lines (361 loc) • 12.6 kB
JavaScript
const fs = require('fs')
const path = require('path')
const { UInt64 } = require('int64_t')
const { _, not } = require('./utils')
const encryptionTable = require('./encryptionTable')
const decompress = require('./decompress')
const hashTypes = {
TABLE_OFFSET: 0,
HASH_A: 1,
HASH_B: 2,
TABLE: 3
}
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
class MPQArchive {
/**
* Create a MPQArchive object
*
* Skip reading listfile by pass listfile=false argument,
* and then the `files` attribute will be `undefined`.
*/
constructor (filename, listfile = true) {
this.filename = filename
try {
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(/\s+/)
} else {
this.files = []
}
} catch (err) {
console.error(`[mpqjs] ${err.message}`)
console.error(err)
}
}
/**
* Read the header of a MPQ archive
*/
readHeader () {
let header
this.readOffset = 0
let magic = this.file.slice(this.readOffset, 4)
if (magic == 'MPQ\x1a') {
header = this._readMPQHeader()
header.offset = 0
} else if (magic == 'MPQ\x1b') {
let userDataHeader = this._readMPQUserDataHeader()
this.readOffset = userDataHeader.mpqHeaderOffset
header = this._readMPQHeader()
header.offset = userDataHeader.mpqHeaderOffset
header.userDataHeader = userDataHeader
} else {
throw new TypeError('Invalid file header.', this.filename)
}
return header
}
/**
* Read hash/block table of a MPQ archive
*/
readTable (type) {
if (type !== 'hash' && type !== 'block') {
throw new TypeError(`Invalid table type "${type}"`)
}
const tableOffset = this.header[`${type}TableOffset`]
const tableEntries = this.header[`${type}TableEntries`]
const key = this._hash(`(${type} table)`, 'TABLE')
this.readOffset = tableOffset + this.header.offset
let data = this.file.slice(this.readOffset, this.readOffset + tableEntries * 16)
this.readOffset += tableEntries * 16
data = this._decrypt(data, key)
return Array(tableEntries).fill(0).map((z, i) => {
return this._unpackEntry(data.slice(i * 16, i * 16 + 16), type)
})
}
/**
* Get the hash table entry corresponding to a given filename
*/
getHashTableEntry (filename) {
let hashA = this._hash(filename, 'HASH_A').toBuffer().readUInt32BE(4)
let hashB = this._hash(filename, 'HASH_B').toBuffer().readUInt32BE(4)
return this.hashTable.find(entry =>
entry.hashA === hashA && entry.hashB === entry.hashB)
}
/**
* Read a file from the MPQ archive
*/
readFile (filename, forceDecompress = false) {
let hashEntry = this.getHashTableEntry(filename)
if (!hashEntry) return Buffer.alloc(0)
let blockEntry = this.blockTable[hashEntry.blockTableIndex]
// Read the block
if (blockEntry.flags & MPQ_FILE_EXISTS) {
if (blockEntry.archivedSize === 0) return Buffer.alloc(0)
this.readOffset = blockEntry.offset + this.header.offset
let fileData = this.file.slice(this.readOffset,
this.readOffset + blockEntry.archivedSize)
this.readOffset += blockEntry.archivedSize
if (blockEntry.flags & MPQ_FILE_ENCRYPTED) {
// TODO: decrypt file
throw new Error('Encryption file is not supported yet.')
}
if (blockEntry.flags & MPQ_FILE_SINGLE_UNIT) {
// 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)
}
} else {
// TODO: Test case didn't cover
// File consists of many sectors.
// They all need to be decompressed separately and united.
let sectorSize = 512 << this.header.sectorSizeShift
let sectors = Math.ceil(blockEntry.size / sectorSize)
let crc
if (blockEntry.flags & MPQ_FILE_SECTOR_CRC) {
crc = true
++sectors
} else {
crc = false
}
let positions = Array(sectors + 1).fill(0).map((_, i) =>
fileData.readUInt32LE(i * 4))
let result = []
let sectorBytesLeft = blockEntry.size
let len = positions.length - (crc ? 2 : 1)
for (let i = 0; i < len; ++i) {
let 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.push(sector)
}
fileData = Buffer.concat(result)
}
return fileData
}
return Buffer.alloc(0)
}
/**
* Extract all the files inside the MPQ archive in memory
*/
extract () {
if (this.files && this.files.length > 0) {
if (this._extractedFilesObject) return this._extractedFilesObject
this._extractedFilesObject = this.files.reduce((result, filename) => {
return Object.assign(result, {
[filename]: this.readFile(filename)
})
}, {})
return this._extractedFilesObject
} else {
throw new Error('Cannot extract file without listfile')
}
}
/**
* Extract all files and write to disk
*/
extractToDisk (index = 1) {
const { name } = path.parse(this.filename)
let dirname = path.join(process.cwd(), name)
if (index > 1) {
dirname += _ + index
}
if (fs.existsSync(dirname)) {
return extractToDisk(index + 1)
} else {
fs.mkdirSync(dirname)
let files = this.extract()
Object.keys(files).forEach(key =>
fs.writeFileSync(path.join(dirname, key), files[key]))
}
}
/**
* Extract given files from the archive to disk
*/
extractFiles (filenames) {
filenames.forEach(name =>
fs.writeFileSync(path.join(process.cwd(), name), this.readFile(name)))
}
printHeaders () {
console.log('MPQ archive header')
console.log('------------------')
Object.keys(this.header).forEach(key => {
if (key === 'userDataHeader') return
let content = this.header[key]
if (key === 'magic') {
content = JSON.stringify(content)
.replace('\\u00', '\\x')
.replace(/"/g, '')
}
console.log(`${key.padEnd(30, ' ')} ${content}`)
})
console.log('')
}
printHashTable () {
console.log('MPQ archive hash table')
console.log('----------------------')
console.log(' Hash A Hash B Locl Plat BlockIdx')
this.hashTable.forEach(({
hashA, hashB, locale, platform, blockTableIndex
}) => {
console.log(
hashA.toString(16).toUpperCase().padStart(8, 0) + ' ' +
hashB.toString(16).toUpperCase().padStart(8, 0) + ' ' +
locale.toString(16).toUpperCase().padStart(4, 0) + ' ' +
platform.toString(16).toUpperCase().padStart(4, 0) + ' ' +
blockTableIndex.toString(16).toUpperCase().padStart(8, 0)
)
})
console.log('')
}
printBlockTable () {
console.log('MPQ archive block table')
console.log('-----------------------')
console.log(' Offset ArchSize RealSize Flags')
this.blockTable.forEach(({ offset, archivedSize, size, flags }) => {
console.log(
offset.toString(16).toUpperCase().padStart(8, 0) + ' ' +
archivedSize.toString().padStart(8, ' ') + ' ' +
size.toString().padStart(8, ' ') + ' ' +
flags.toString(16).toUpperCase().padStart(8, 0)
)
})
console.log('')
}
printFiles () {
if (this.files) {
console.log('Files')
console.log('-----')
let width = Math.max.apply(null, this.files.map(f => f.length))
this.files.forEach(filename => {
let hashEntry = this.getHashTableEntry(filename)
let blockEntry = this.blockTable[hashEntry.blockTableIndex]
console.log(
filename.padEnd(width, ' ') + ' ' +
blockEntry.size.toString().padStart(8, ' ') + ' bytes'
)
})
console.log('')
}
}
/**
* Unpack entry data from buffer, used by `readTable`
*/
_unpackEntry (data, type) {
switch (type) {
case 'hash':
return {
hashA: data.readUInt32LE(0),
hashB: data.readUInt32LE(4),
locale: data.readUInt16LE(8),
platform: data.readUInt16LE(10),
blockTableIndex: data.readUInt32LE(12)
}
break
case 'block':
return {
offset: data.readUInt32LE(0),
archivedSize: data.readUInt32LE(4),
size: data.readUInt32LE(8),
flags: data.readUInt32LE(12)
}
break
default:
throw new TypeError(`Invalid table type "${type}"`)
}
}
/**
* Read the MPQ header, used by `readHeader()`
*/
_readMPQHeader () {
let header = {
magic: this.file.slice(this.readOffset, this.readOffset + 4).toString(),
headerSize: this.file.readUInt32LE(this.readOffset + 4),
archivedSize: this.file.readUInt32LE(this.readOffset + 8),
formatVersion: this.file.readUInt16LE(this.readOffset + 12),
sectorSizeShift: this.file.readUInt16LE(this.readOffset + 14),
hashTableOffset: this.file.readUInt32LE(this.readOffset + 16),
blockTableOffset: this.file.readUInt32LE(this.readOffset + 20),
hashTableEntries: this.file.readUInt32LE(this.readOffset + 24),
blockTableEntries: this.file.readUInt32LE(this.readOffset + 28)
}
this.readOffset += 32
if (header.formatVersion === 1) {
// TODO: test case didn't cover
Object.assign(header, {
extendedBlockTableOffset: _(this.file.slice(this.readOffset, this.readOffset + 8)),
hashTableOffsetHigh: this.file.readInt16LE(this.readOffset + 8),
blockTableOffsetHigh: this.file.readInt16LE(this.readOffset + 10)
})
this.readOffset += 12
}
return header
}
/**
* Read the MPQ user data header, used by `readHeader()`
*/
_readMPQUserDataHeader () {
let header = {
magic: this.file.slice(this.readOffset, 4).toString(),
userDataSize: this.file.readUInt32LE(this.readOffset + 4),
mpqHeaderOffset: this.file.readUInt32LE(this.readOffset + 8),
userDataHeaderSize: this.file.readUInt32LE(this.readOffset + 12)
}
this.readOffset += 16
header.content = this.file.slice(this.readOffset, this.readOffset + header.userDataHeaderSize)
return header
}
/**
* Hash a string using MPQ's hash function
*/
_hash (string, type) {
let seed1 = _(0x7FED7FED)
let seed2 = _(0xEEEEEEEE)
string = string.toUpperCase()
for (let i = 0, len = string.length; i < len; ++i) {
let ch = string.charCodeAt(i)
let value = encryptionTable[(hashTypes[type] << 8) + ch]
seed1 = value.xor(seed1.add(seed2)).and(_(0xFFFFFFFF))
seed2 = _(ch).add(seed1).add(seed2).add(seed2.shiftLeft(5)).add(_(3)).and(_(0xFFFFFFFF))
}
return seed1
}
/**
* Decrypt hash, block table or a sector
*/
_decrypt (data, key) {
let seed1 = _(key)
let seed2 = _(0xEEEEEEEE)
let length = data.length
let result = Buffer.alloc(length)
for (let i = 0, len = Math.floor(length / 4); i < len; ++i) {
seed2 = seed2.add(
encryptionTable[seed1.and(_(0xFF)).add(_(0x400)).toString(10)]
)
seed2 = seed2.and(_(0xFFFFFFFF))
let value = _(data.slice(i * 4, i * 4 + 4).readUInt32LE())
value = value.xor(seed1.add(seed2)).and(_(0xFFFFFFFF))
seed1 = not(seed1).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.writeUInt32LE(value.toBuffer().readUInt32BE(4), i * 4)
}
return result
}
}
module.exports = MPQArchive