UNPKG

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
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; } }