mdx-m3-viewer
Version:
A browser WebGL model viewer. Mainly focused on models of the games Warcraft 3 and Starcraft 2.
493 lines (405 loc) • 13.5 kB
JavaScript
import {powerOfTwo} from '../../common/math';
import {stringToBuffer} from '../../common/stringtobuffer';
import {numberToUint32} from '../../common/typecast';
import {searchHeader} from './isarchive';
import MpqCrypto from './crypto';
import MpqHashTable from './hashtable';
import MpqBlockTable from './blocktable';
import MpqFile from './file';
import {MAGIC, HASH_ENTRY_DELETED, HASH_ENTRY_EMPTY} from './constants';
/**
* MoPaQ archive (MPQ) version 0.
*/
export default class MpqArchive {
/**
* @param {?ArrayBuffer} buffer If given an ArrayBuffer, load() will be called immediately
* @param {?boolean} readonly If true, disables editing and saving the archive, allowing to optimize other things
*/
constructor(buffer, readonly) {
/** @member {number} */
this.headerOffset = 0;
/** @member {number} */
this.sectorSize = 4096;
/** @member {MpqCrypto} */
this.c = new MpqCrypto();
/** @member {MpqHashTable} */
this.hashTable = new MpqHashTable(this.c);
/** @member {MpqBlockTable} */
this.blockTable = new MpqBlockTable(this.c);
/** @member {Array<MpqFile>} */
this.files = [];
/** @member {boolean} */
this.readonly = !!readonly;
if (buffer) {
this.load(buffer);
}
}
/**
* Load an existing archive.
* Note that this clears the archive from whatever it had in it before.
*
* @param {ArrayBuffer} buffer
* @return {boolean}
*/
load(buffer) {
// let fileSize = buffer.byteLength;
let typedArray = new Uint8Array(buffer);
let headerOffset = searchHeader(typedArray);
if (headerOffset === -1) {
return false;
}
// Read the header.
let uint32array = new Uint32Array(buffer, headerOffset, 8);
// let headerSize = uint32array[1];
// let archiveSize = uint32array[2];
let formatVersionSectorSize = uint32array[3];
// let formatVersion = formatVersionSectorSize & 0x0000FFFF;
let hashPos = numberToUint32(uint32array[4] + headerOffset); // Whoever thought of MoonLight, clever!
let blockPos = numberToUint32(uint32array[5] + headerOffset);
let hashSize = uint32array[6];
let blockSize = uint32array[7];
// There can only be as many or less blocks as there are hashes.
// Therefore, if the file is reporting too many blocks, cap the actual blocks read to the amount of hashes.
if (blockSize > hashSize) {
blockSize = hashSize;
}
this.headerOffset = headerOffset;
this.sectorSize = 512 * (1 << (formatVersionSectorSize >>> 16)); // Generally 4096
// Read the hash table.
// Also clears any existing entries.
// Have to copy the data, because hashPos is not guaranteed to be a multiple of 4.
this.hashTable.load(typedArray.slice(hashPos, hashPos + hashSize * 16));
// Read the block table.
// Also clears any existing entries.
// Have to copy the data, because blockPos is not guaranteed to be a multiple of 4.
this.blockTable.load(typedArray.slice(blockPos, blockPos + blockSize * 16));
// Clear any existing files.
this.files.length = 0;
// Read the files.
for (let hash of this.hashTable.entries) {
let blockIndex = hash.blockIndex;
// If the file wasn't deleted, load it.
if (blockIndex < HASH_ENTRY_DELETED) {
let file = new MpqFile(this);
file.load(hash, this.blockTable.entries[blockIndex], typedArray);
this.files[blockIndex] = file;
}
}
// Get internal files to fill the file names.
let listfile = this.get('(listfile)');
this.get('(attributes)');
this.get('(signature)');
// If there is a listfile, use all of the file names in it.
if (listfile) {
let list = listfile.text();
if (list) {
for (let name of list.split('\r\n')) {
// get() internally also sets the file's name to the given one.
this.get(name);
}
}
}
return true;
}
/**
* Save this archive.
* Returns null when...
* 1) The archive is in readonly mode.
* 2) The offset of a file encrypted with FILE_OFFSET_ADJUSTED_KEY changed, and the file name is unknown.
*
* @return {?ArrayBuffer}
*/
save() {
if (this.readonly) {
return null;
}
let headerSize = 32;
// Delete the internal attributes file.
// The attributes might (and do in the case of World Editor generated maps) contain CRC checksums for the internal files.
// If any of these files is edited in any way, the map will be considered corrupted.
// Therefore, delete the file, and nothing will be corrupted.
// As far as I can tell, there is no real reason to keep (and update) any of the file attributes.
// It's not like Warcraft 3 has some database of checksums that it checks against.
// I assume it does have a database for the Battle.net ladder maps.
// If at any point it becomes known to me that it is indeed needed, I will add support for (attributes).
this.delete('(attributes)');
// Some archives have empty blocks in them.
// That is, blocks that take up memory, but have no actual valid data in them (as far as the archive is concerned).
// I am not sure why they exist - maybe someone deleted a file's entry and was too lazy to rebuild the archive.
// This removes such blocks of memory from the archive.
this.saveMemory();
// Set the listfile.
this.setListFile();
// Reset the file offsets.
let offset = headerSize;
for (let file of this.files) {
// If the file's offset changed, and it is encrypted with a key that depends on its offset,
// it needs to be decryped with it's current key, and encryped with the new key.
if (!file.offsetChanged(offset)) {
return null;
}
// If the file needs to be encoded, do it.
file.encode();
offset += file.block.compressedSize;
}
let hashTable = this.hashTable;
let blockTable = this.blockTable;
let hashes = hashTable.entries.length;
let blocks = blockTable.entries.length;
let filesSize = offset - headerSize;
let archiveSize = headerSize + filesSize + hashes * 16 + blocks * 16;
let hashPos = headerSize + filesSize;
let blockPos = hashPos + hashes * 16;
let typedArray = new Uint8Array(archiveSize);
let uint32array = new Uint32Array(typedArray.buffer, 0, 8);
// Write the header.
uint32array[0] = MAGIC;
uint32array[1] = headerSize;
uint32array[2] = archiveSize;
uint32array[3] = Math.log2(this.sectorSize / 512) << 16; // The version is always 0, so ignore it.
uint32array[4] = hashPos;
uint32array[5] = blockPos;
uint32array[6] = hashes;
uint32array[7] = blocks;
offset = headerSize;
// Write the files.
for (let file of this.files) {
file.save(typedArray.subarray(offset, offset + file.block.compressedSize));
offset += file.block.compressedSize;
}
// Write the hash table.
hashTable.save(typedArray.subarray(offset, offset + hashTable.entries.length * 16));
offset += hashTable.entries.length * 16;
// Write the block table.
blockTable.save(typedArray.subarray(offset, offset + blockTable.entries.length * 16));
return typedArray.buffer;
}
/**
* Some MPQs have empty memory chunks in them, left over from files that were deleted.
* This function searches for such chunks, and removes them.
* Note that it is called automatically by save().
* Does nothing if the archive is in readonly mode.
*
* @return {number} Bytes saved
*/
saveMemory() {
if (this.readonly) {
return 0;
}
let blocks = this.blockTable.entries;
let hashes = this.hashTable.entries;
let i = blocks.length;
let saved = 0;
while (i--) {
let block = blocks[i];
// Remove blocks with no data.
if (block.normalSize === 0) {
this.removeBlock(i);
saved += block.compressedSize;
} else {
let used = false;
for (let hash of hashes) {
if (hash.blockIndex === i) {
used = true;
}
}
// Remove blocks that are not used.
if (!used) {
this.removeBlock(i);
saved += block.compressedSize;
}
}
}
return saved;
}
/**
* @param {number} blockIndex
*/
removeBlock(blockIndex) {
for (let hash of this.hashTable.entries) {
if (hash.blockIndex < HASH_ENTRY_DELETED && hash.blockIndex > blockIndex) {
hash.blockIndex -= 1;
}
}
this.blockTable.entries.splice(blockIndex, 1);
}
/**
* Gets a list of the file names in the archive.
* Note that files loaded from an existing archive, without resolved names, will be named FileXXXXXXXX.
*
* @return {Array<string>}
*/
getFileNames() {
let names = [];
for (let file of this.files) {
if (file && file.name !== '') {
names.push(file.name);
}
}
return names;
}
/**
* Sets the list file with all of the resolved file names.
* Does nothing if the archive is in readonly mode.
*
* @return {boolean}
*/
setListFile() {
if (this.readonly) {
return false;
}
// Add the listfile, possibly overriding an existing one.
return this.set('(listfile)', stringToBuffer(this.getFileNames().join('\r\n')));
}
/**
* Adds a file to this archive.
* If the file already exists, its buffer will be set.
* Does nothing if the archive is in readonly mode.
*
* @param {string} name
* @param {ArrayBuffer} buffer
* @return {boolean}
*/
set(name, buffer) {
if (this.readonly) {
return false;
}
let file = this.get(name);
// If the file already exists, change the data.
if (file) {
file.set(buffer);
} else {
let blockIndex = this.blockTable.entries.length;
file = new MpqFile(this);
file.name = name;
file.nameResolved = true;
file.hash = this.hashTable.add(name, blockIndex);
file.block = this.blockTable.add(buffer);
file.buffer = buffer;
this.files[blockIndex] = file;
}
return true;
}
/**
* Gets a file from this archive.
* If the file doesn't exist, null is returned.
*
* @param {string} name
* @return {?MpqFile}
*/
get(name) {
let hash = this.hashTable.get(name);
if (hash) {
let blockIndex = hash.blockIndex;
// Check if the block exists.
if (blockIndex < HASH_ENTRY_DELETED) {
let file = this.files[blockIndex];
if (file) {
// Save the name in case it wasn't already resolved.
file.name = name;
file.nameResolved = true;
return file;
}
}
}
return null;
}
/**
* Checks if a file exists.
* Prefer to use get() if you are going to use get() afterwards anyway.
*
* @param {string} name
* @return {boolean}
*/
has(name) {
return !!this.get(name);
}
/**
* Deletes a file from this archive.
* Does nothing if...
* 1) The archive is in readonly mode.
* 2) The file doesn't exist.
*
* @param {string} name
* @return {boolean}
*/
delete(name) {
if (this.readonly) {
return false;
}
let file = this.get(name);
if (!file) {
return false;
}
file.delete();
return true;
}
/**
* Renames a file.
* Note that this sets the current file's hash's status to being deleted, rather than removing it.
* This is due to the way the search algorithm works.
* Does nothing if...
* 1) The archive is in readonly mode.
* 2) The file doesn't exist.
*
* @param {string} name
* @param {string} newName
* @return {boolean}
*/
rename(name, newName) {
if (this.readonly) {
return false;
}
let file = this.get(name);
if (!file) {
return false;
}
file.rename(newName);
return true;
}
/**
* Resizes the hashtable to the nearest power of two equal to or bigger than the given size.
* Generally speaking, the bigger the hashtable is, the quicker insertions/searches are, at the cost of added memory.
* Does nothing if...
* 1) The archive is in readonly mode.
* 2) The calculated size is smaller than the amount of files in the archive.
* 3) Not all of the file names in the archive are resolved.
*
* @param {number} size
* @return {boolean}
*/
resizeHashtable(size) {
if (this.readonly) {
return false;
}
size = Math.max(4, powerOfTwo(size));
let files = this.files;
// Can't resize to a size smaller than the existing files.
if (files.length > size) {
return false;
}
// If not all file names are known, don't resize.
// The insertion algorithm depends on the names.
for (let file of files) {
if (!file.nameResolved) {
return false;
}
}
let hashTable = this.hashTable;
let entries = hashTable.entries;
let oldEntries = entries.slice();
// Clear the entries.
hashTable.clear();
// Add empty entries.
hashTable.addEmpties(size);
// Go over all of the old entries, and copy them into the new entries.
for (let hash of oldEntries) {
if (hash.blockIndex !== HASH_ENTRY_EMPTY) {
let file = files[hash.blockIndex];
let insertionIndex = hashTable.getInsertionIndex(file.name);
entries[insertionIndex].copy(hash);
}
}
return true;
}
}