UNPKG

mdx-m3-viewer

Version:

A browser WebGL model viewer. Mainly focused on models of the games Warcraft 3 and Starcraft 2.

397 lines 15.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const bytesof_1 = require("../../common/bytesof"); const math_1 = require("../../common/math"); const typecast_1 = require("../../common/typecast"); const blocktable_1 = require("./blocktable"); const constants_1 = require("./constants"); const crypto_1 = require("./crypto"); const file_1 = require("./file"); const hashtable_1 = require("./hashtable"); const isarchive_1 = require("./isarchive"); /** * MoPaQ archive (MPQ) version 0. */ class MpqArchive { constructor() { this.readonly = false; this.headerOffset = 0; this.sectorSize = 4096; this.c = new crypto_1.default(); this.hashTable = new hashtable_1.default(this.c); this.blockTable = new blocktable_1.default(this.c); this.files = []; } /** * Load an existing archive. * * Note that this clears the archive from whatever it had in it before. */ load(buffer, readonly = false) { const bytes = (0, bytesof_1.bytesOf)(buffer); this.readonly = readonly; // let fileSize = buffer.byteLength; const headerOffset = (0, isarchive_1.searchHeader)(bytes); if (headerOffset === -1) { throw new Error('No MPQ header'); } // Read the header. const uint32array = new Uint32Array(bytes.buffer, headerOffset, 8); // let headerSize = uint32array[1]; // let archiveSize = uint32array[2]; const formatVersionSectorSize = uint32array[3]; // let formatVersion = formatVersionSectorSize & 0x0000FFFF; const hashPos = (0, typecast_1.numberToUint32)(uint32array[4] + headerOffset); // Whoever thought of MoonLight, clever! const blockPos = (0, typecast_1.numberToUint32)(uint32array[5] + headerOffset); const 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(bytes.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(bytes.slice(blockPos, blockPos + blockSize * 16)); // Clear any existing files. this.files.length = 0; // Read the files. for (const hash of this.hashTable.entries) { const blockIndex = hash.blockIndex; // If the block index is valid, load a file. // This isn't the case when the block is marked as deleted with HASH_ENTRY_DELETED. // This also isn't the case for archives with fake block indices. if (blockIndex < this.blockTable.entries.length) { this.files[blockIndex] = new file_1.default(this, hash, this.blockTable.entries[blockIndex], bytes, null); } } // If there is a listfile, use all of the file names in it. const listfile = this.get('(listfile)'); if (listfile) { const list = listfile.text(); if (list) { for (const name of list.split('\r\n')) { // get() internally also sets the file's name to the given one. this.get(name); } } } } /** * 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. */ save() { if (this.readonly) { return null; } const 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.set('(listfile)', this.getFileNames().join('\r\n')); // Reset the file offsets. let offset = headerSize; for (const file of this.files) { // There can be holes in the files array. if (file) { // 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; } } const hashTable = this.hashTable; const blockTable = this.blockTable; const hashes = hashTable.entries.length; const blocks = blockTable.entries.length; const filesSize = offset - headerSize; const archiveSize = headerSize + filesSize + hashes * 16 + blocks * 16; const hashPos = headerSize + filesSize; const blockPos = hashPos + hashes * 16; const bytes = new Uint8Array(archiveSize); const uint32array = new Uint32Array(bytes.buffer, 0, 8); // Write the header. uint32array[0] = constants_1.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 (const file of this.files) { // There can be holes in the files array. if (file) { if (file.rawBuffer) { bytes.set(file.rawBuffer, offset); } offset += file.block.compressedSize; } } // Write the hash table. hashTable.save(bytes.subarray(offset, offset + hashTable.entries.length * 16)); offset += hashTable.entries.length * 16; // Write the block table. blockTable.save(bytes.subarray(offset, offset + blockTable.entries.length * 16)); return bytes; } /** * 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. */ saveMemory() { if (this.readonly) { return 0; } const blocks = this.blockTable.entries; const hashes = this.hashTable.entries; let i = blocks.length; let saved = 0; while (i--) { const block = blocks[i]; // Remove blocks with no data. if (block.normalSize === 0) { this.removeBlock(i); saved += block.compressedSize; } else { let used = false; for (const hash of hashes) { if (hash.blockIndex === i) { used = true; break; } } // Remove blocks that are not used. if (!used) { this.removeBlock(i); saved += block.compressedSize; } } } return saved; } removeBlock(blockIndex) { for (const hash of this.hashTable.entries) { if (hash.blockIndex < constants_1.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. */ getFileNames() { const names = []; for (const file of this.files) { if (file && file.name !== '') { names.push(file.name); } } return names; } /** * Count the files with unresolved names. */ countUnresolved() { let unresolved = 0; for (const file of this.files) { if (!file.nameResolved) { unresolved++; } } return unresolved; } /** * Given an iterable of file names, attempt to resolve the archive files with them. */ applyListfile(listfile) { for (const file of listfile) { this.get(file); } } /** * 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. */ set(name, buffer) { if (this.readonly) { return false; } const bytes = (0, bytesof_1.bytesOf)(buffer); let file = this.get(name); // If the file already exists, change the data. if (file) { file.set(bytes); } else { const blockIndex = this.blockTable.entries.length; const hash = this.hashTable.add(name, blockIndex); if (!hash) { return false; } const block = this.blockTable.add(bytes); file = new file_1.default(this, hash, block, null, bytes); file.name = name; file.nameResolved = true; this.files[blockIndex] = file; } return true; } /** * Gets a file from this archive. * If the file doesn't exist, null is returned. */ get(name) { const hash = this.hashTable.get(name); if (hash) { const blockIndex = hash.blockIndex; // Check if the block exists. if (blockIndex < constants_1.HASH_ENTRY_DELETED) { const 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. */ 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 does not exist. */ delete(name) { if (this.readonly) { return false; } const file = this.get(name); if (!file) { return false; } file.delete(); return true; } /** * Renames a file. * * Does nothing if... * * 1) The archive is in readonly mode. * 2) The file does not exist. * * 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. */ rename(name, newName) { if (this.readonly) { return false; } const 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. */ resizeHashtable(size) { if (this.readonly) { return false; } size = Math.max(4, (0, math_1.powerOfTwo)(size)); const 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 (const file of files) { if (!file.nameResolved) { return false; } } const hashTable = this.hashTable; const entries = hashTable.entries; const 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 (const hash of oldEntries) { if (hash.blockIndex !== constants_1.HASH_ENTRY_EMPTY) { const file = files[hash.blockIndex]; const insertionIndex = hashTable.getInsertionIndex(file.name); entries[insertionIndex].copy(hash); } } return true; } } exports.default = MpqArchive; //# sourceMappingURL=archive.js.map