unreal.js
Version:
A pak reader for games like VALORANT & Fortnite written in Node.JS
432 lines (431 loc) • 19.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PakFileReader = void 0;
const FPakInfo_1 = require("./objects/FPakInfo");
const Aes_1 = require("../../encryption/aes/Aes");
const GameFile_1 = require("./GameFile");
const Exceptions_1 = require("../../exceptions/Exceptions");
const FByteArchive_1 = require("../reader/FByteArchive");
const FPakEntry_1 = require("./objects/FPakEntry");
const sprintf_js_1 = require("sprintf-js");
const FFileArchive_1 = require("../reader/FFileArchive");
const Utils_1 = require("../../util/Utils");
const Compression_1 = require("../../compression/Compression");
const PakVersion_1 = require("./enums/PakVersion");
const FPakCompressedBlock_1 = require("./objects/FPakCompressedBlock");
const Config_1 = require("../../Config");
const VersionContainer_1 = require("../versions/VersionContainer");
const AbstractAesVfsReader_1 = require("../vfs/AbstractAesVfsReader");
/**
* UE4 Pak File Reader
*/
class PakFileReader extends AbstractAesVfsReader_1.AbstractAesVfsReader {
/**
* Creates an instance
* @param {string} path Path to file
* @param {?VersionContainer} versions Versions
* @param {?Buffer} source Source buffer if it's not an existing file
* @constructor
* @public
*/
constructor(path, versions = VersionContainer_1.VersionContainer.DEFAULT, source) {
super(path, versions);
/**
* Aes key for pak
* @type {?Buffer}
* @public
*/
this.aesKey = null;
this.path = path;
this.Ar = source != null ? new FByteArchive_1.FByteArchive(source, versions) : new FFileArchive_1.FFileArchive(path, versions);
this.pakInfo = FPakInfo_1.FPakInfo.readPakInfo(this.Ar);
}
get hasDirectoryIndex() {
return true;
}
get encryptionKeyGuid() {
return this.pakInfo.encryptionKeyGuid;
}
/**
* Whether if it is encrypted or not
* @returns {boolean}
* @public
*/
get isEncrypted() {
return this.pakInfo.encryptedIndex;
}
/**
* Extracts a file
* @param {GameFile} gameFile File to extract
* @returns {Buffer}
* @public
*/
extract(gameFile) {
if (gameFile.pakFileName !== this.path)
throw new Error(`Wrong pak file reader, required ${gameFile.pakFileName}, this is ${this.path}`);
// If this reader is used as a concurrent reader create a clone of the main reader to
// provide thread safety
const exAr = this.Ar;
exAr.pos = gameFile.pos;
// Pak Entry is written before the file data,
// but its the same as the one from the index, just without a name
const tempEntry = new FPakEntry_1.FPakEntry(exAr, this.pakInfo, false);
for (const it of tempEntry.compressionBlocks) {
it.compressedStart += gameFile.pos;
it.compressedEnd += gameFile.pos;
}
if (gameFile.isCompressed()) {
if (Config_1.Config.GDebug)
console.debug(`${gameFile.getName()} is compressed with ${gameFile.compressionMethod}`);
const uncompressedBuffer = Buffer.alloc(gameFile.uncompressedSize);
let uncompressedBufferOff = 0;
for (const block of tempEntry.compressionBlocks) {
exAr.pos = block.compressedStart;
let srcSize = block.compressedEnd - block.compressedStart;
// Read the compressed block
let compressedBuffer;
if (gameFile.isEncrypted) {
// The compressed block is encrypted, align it and then decrypt
if (!this.aesKey) {
throw new Exceptions_1.ParserException("Decrypting a encrypted file requires an encryption key to be set");
}
srcSize = Utils_1.Utils.align(srcSize, Aes_1.Aes.BLOCK_SIZE);
const buf = exAr.read(srcSize);
compressedBuffer = Aes_1.Aes.decrypt(buf, this.aesKey);
}
else {
// Read the block data
compressedBuffer = exAr.read(srcSize);
}
// Calculate the uncompressed size,
// its either just the compression block size
// or if its the last block its the remaining data size
const uncompressedSize = Math.min(gameFile.compressionBlockSize, gameFile.uncompressedSize - uncompressedBufferOff);
Compression_1.Compression.uncompress(gameFile.compressionMethod, uncompressedBuffer, uncompressedBufferOff, uncompressedSize, compressedBuffer, 0, srcSize);
uncompressedBufferOff += gameFile.compressionBlockSize;
}
return uncompressedBuffer;
}
else if (gameFile.isEncrypted) {
if (Config_1.Config.GDebug)
console.debug(`${gameFile.getName()} is encrypted, decrypting`);
if (this.aesKey) {
throw new Exceptions_1.ParserException("Decrypting a encrypted file requires an encryption key to be set");
}
// AES is block encryption, all encrypted blocks need to be 16 bytes long,
// fix the game file length by growing it to the next multiple of 16 bytes
const newLength = Utils_1.Utils.align(gameFile.size, Aes_1.Aes.BLOCK_SIZE);
const buffer = Aes_1.Aes.decrypt(exAr.read(newLength), this.aesKey);
return buffer.subarray(0, gameFile.size);
}
else {
return exAr.read(gameFile.size);
}
}
/**
* Reads index of pak
* @returns {Array<GameFile>} Files
* @public
*/
readIndex() {
this.encryptedFileCount = 0;
this.Ar.pos = this.pakInfo.indexOffset;
// this.readAndDecrypt()
let buf = this.Ar.read(this.pakInfo.indexSize);
if (this.isEncrypted) {
const key = this.aesKey;
if (!key)
throw new Exceptions_1.ParserException("Reading this pak requires an encryption key");
buf = Aes_1.Aes.decrypt(buf, key);
}
const indexAr = new FByteArchive_1.FByteArchive(buf);
let mountPoint;
try {
mountPoint = indexAr.readString();
}
catch (e) {
throw new Exceptions_1.InvalidAesKeyException(`Given encryption key '0x${this.aesKey.toString("hex")}' is not working with '${this.path}'`);
}
this.mountPoint = PakFileReader.fixMountPoint(mountPoint);
const files = this.pakInfo.version >= PakVersion_1.EPakVersion.PakVersion_PathHashIndex ? this.readIndexUpdated(indexAr) : this.readIndexLegacy(indexAr);
// Print statistics
let stats = sprintf_js_1.sprintf("Pak \"%s\": %d files", this.path, this.files.length);
if (this.encryptedFileCount)
stats += sprintf_js_1.sprintf(" (%d encrypted)", this.encryptedFileCount);
if (this.mountPoint.includes("/"))
stats += sprintf_js_1.sprintf(", mount point: \"%s\"", this.mountPoint);
console.info(stats + ", version %d", this.pakInfo.version);
return files;
}
/**
* Reads index of old pak
* @returns {Array<GameFile>} Files
* @public
*/
readIndexLegacy(indexAr) {
const fileCount = indexAr.readInt32();
const tempMap = new Map();
for (let i = 0; i < fileCount; i++) {
const entry = new FPakEntry_1.FPakEntry(indexAr, this.pakInfo, true);
const gameFile = new GameFile_1.GameFile(entry, this.mountPoint, this.path);
if (gameFile.isEncrypted)
this.encryptedFileCount++;
tempMap.set(gameFile.path.toLowerCase(), gameFile);
}
const files = [];
for (const [_, it] of tempMap) {
if (it.isUE4Package()) {
const uexp = tempMap.get(PakFileReader.extension(_, ".uexp"));
if (uexp != null)
it.uexp = uexp;
const ubulk = tempMap.get(PakFileReader.extension(_, ".ubulk"));
if (ubulk != null)
it.uexp = ubulk;
files.push(it);
}
else {
if (!it.path.endsWith(".uexp") && !it.path.endsWith(".ubulk"))
files.push(it);
}
}
return this.files = files;
}
/**
* Reads index of new pak
* @returns {Array<GameFile>} Files
* @public
*/
readIndexUpdated(primaryIndexAr) {
const fileCount = primaryIndexAr.readInt32();
primaryIndexAr.pos += 8; // PathHashSeed
if (!primaryIndexAr.readBoolean())
throw new Exceptions_1.ParserException("No path hash index", primaryIndexAr);
primaryIndexAr.pos += 36; // PathHashIndexOffset (long) + PathHashIndexSize (long) + PathHashIndexHash (20 bytes)
if (!primaryIndexAr.readBoolean())
throw new Exceptions_1.ParserException("No directory index", primaryIndexAr);
const directoryIndexOffset = Number(primaryIndexAr.readInt64());
const directoryIndexSize = Number(primaryIndexAr.readInt64());
primaryIndexAr.pos += 20; // Directory Index hash
const encodedPakEntriesSize = primaryIndexAr.readInt32();
const encodedPakEntries = primaryIndexAr.read(encodedPakEntriesSize);
const encodedPakEntriesAr = new FByteArchive_1.FByteArchive(encodedPakEntries);
if (primaryIndexAr.readInt32() < 0)
throw new Exceptions_1.ParserException("Corrupt pak PrimaryIndex detected!", primaryIndexAr);
this.Ar.pos = directoryIndexOffset;
// this.readAndDecrypt()
let buf = this.Ar.read(directoryIndexSize);
if (this.isEncrypted) {
const key = this.aesKey;
if (!key)
throw new Exceptions_1.ParserException("Reading this pak requires an encryption key");
buf = Aes_1.Aes.decrypt(buf, key);
}
const directoryIndexAr = new FByteArchive_1.FByteArchive(buf);
const directoryIndexNum = directoryIndexAr.readInt32();
const tempMap = new Map();
for (let i = 0; i < directoryIndexNum; i++) {
const directory = directoryIndexAr.readString();
const filesNum = directoryIndexAr.readInt32();
for (let j = 0; j < filesNum; j++) {
const file = directoryIndexAr.readString();
const path = directory + file;
encodedPakEntriesAr.pos = directoryIndexAr.readInt32();
const entry = this.readBitEntry(encodedPakEntriesAr);
entry.name = path;
if (entry.isEncrypted)
this.encryptedFileCount++;
tempMap.set(path.toLowerCase(), new GameFile_1.GameFile(entry, this.mountPoint, this.path));
}
}
const files = [];
for (const [_, it] of tempMap) {
if (it.isUE4Package()) {
const uexp = tempMap.get(PakFileReader.extension(_, ".uexp"));
if (uexp != null)
it.uexp = uexp;
const ubulk = tempMap.get(PakFileReader.extension(_, ".ubulk"));
if (ubulk != null)
it.uexp = ubulk;
files.push(it);
}
else {
if (!it.path.endsWith(".uexp") && !it.path.endsWith(".ubulk"))
files.push(it);
}
}
return this.files = files;
}
/**
* Reads bit entry
* @param {FByteArchive} Ar Reader to use
* @returns {FPakEntry} Entry
* @private
*/
readBitEntry(Ar) {
// Grab the big bitfield value:
// Bit 31 = Offset 32-bit safe?
// Bit 30 = Uncompressed size 32-bit safe?
// Bit 29 = Size 32-bit safe?
// Bits 28-23 = Compression method
// Bit 22 = Encrypted
// Bits 21-6 = Compression blocks count
// Bits 5-0 = Compression block size
let compressionMethodIndex = null;
let compressionBlockSize = null;
let offset = null;
let uncompressedSize = null;
let size = null;
let encrypted = null;
let compressionBlocks = null;
const value = Ar.readUInt32();
// Filter out the CompressionMethod.
compressionMethodIndex = (value >> 23) & 0x3f;
// Test for 32-bit safe values. Grab it, or memcpy the 64-bit value
// to avoid alignment exceptions on platforms requiring 64-bit alignment
// for 64-bit variables.
//
// Read the Offset.
const isOffset32BitSafe = (value & (1 << 31)) !== 0;
offset = isOffset32BitSafe ?
Ar.readUInt32() :
Number(Ar.readInt64());
// Read the UncompressedSize.
const isUncompressedSize32BitSafe = (value & (1 << 30)) !== 0;
uncompressedSize = isUncompressedSize32BitSafe ?
Ar.readUInt32() :
Number(Ar.readInt64());
// Fill in the Size.
if (compressionMethodIndex !== 0) {
// Size is only present if compression is applied.
const isSize32BitSafe = (value & (1 << 29)) !== 0;
if (isSize32BitSafe) {
size = Ar.readUInt32();
}
else {
size = Number(Ar.readInt64());
}
}
else {
// The Size is the same thing as the UncompressedSize when
// CompressionMethod == COMPRESS_None.
size = uncompressedSize;
}
// Filter the encrypted flag.
encrypted = (value & (1 << 22)) !== 0;
// This should clear out any excess CompressionBlocks that may be valid in the user's
// passed in entry.
const compressionBlocksCount = (value >> 6) & 0xffff;
compressionBlocks = new Array(compressionBlocksCount);
for (let i = 0; i < compressionBlocksCount; ++i) {
compressionBlocks[i] = new FPakCompressedBlock_1.FPakCompressedBlock(0, 0);
}
// Filter the compression block size or use the UncompressedSize if less that 64k.
compressionBlockSize = 0;
if (compressionBlocksCount > 0) {
compressionBlockSize = uncompressedSize < 65536 ? uncompressedSize : ((value & 0x3f) << 11);
}
// Set bDeleteRecord to false, because it obviously isn't deleted if we are here.
//deleted = false Not needed
// Base offset to the compressed data
const baseOffset = this.pakInfo.version >= PakVersion_1.EPakVersion.PakVersion_RelativeChunkOffsets ? 0 : offset;
// Handle building of the CompressionBlocks array.
if (compressionBlocks.length === 1 && !encrypted) {
// If the number of CompressionBlocks is 1, we didn't store any extra information.
// Derive what we can from the entry's file offset and size.
const compressedBlock = compressionBlocks[0];
compressedBlock.compressedStart = baseOffset + FPakEntry_1.FPakEntry.getSerializedSize(this.pakInfo.version, compressionMethodIndex, compressionBlocksCount);
compressedBlock.compressedEnd = compressedBlock.compressedStart + size;
}
else if (compressionBlocks.length) {
// Get the right pointer to start copying the CompressionBlocks information from.
// Alignment of the compressed blocks
const compressedBlockAlignment = encrypted ? Aes_1.Aes.BLOCK_SIZE : 1;
// CompressedBlockOffset is the starting offset. Everything else can be derived from there.
let compressedBlockOffset = baseOffset + FPakEntry_1.FPakEntry.getSerializedSize(this.pakInfo.version, compressionMethodIndex, compressionBlocksCount);
for (const compressedBlock of compressionBlocks) {
compressedBlock.compressedStart = compressedBlockOffset;
compressedBlock.compressedEnd = compressedBlockOffset + Ar.readUInt32();
const align = compressedBlock.compressedEnd - compressedBlock.compressedStart;
compressedBlockOffset += align + compressedBlockAlignment - (align % compressedBlockAlignment);
}
}
//TODO There is some kind of issue here, compression blocks are sometimes going to far by one byte
for (const it of compressionBlocks) {
it.compressedStart = it.compressedStart + offset;
it.compressedEnd = it.compressedEnd + offset;
}
const entry = new FPakEntry_1.FPakEntry();
entry.pos = offset;
entry.size = size;
entry.uncompressedSize = uncompressedSize;
entry.compressionMethod = this.pakInfo.compressionMethods[compressionMethodIndex];
entry.compressionBlocks = compressionBlocks;
entry.isEncrypted = encrypted;
entry.compressionBlockSize = compressionBlockSize;
return entry;
}
/**
* Replaces a file extension
* @param {string} k Source
* @param {string} v Replacement
* @returns {string}
* @private
* @static
*/
static extension(k, v) {
return k.endsWith(".uasset")
? k.replace(".uasset", v)
: k.replace(".umap", v);
}
/**
* Reads and decrypts data
* - DEPRECATED: Inline this method
* @param {number} num Amount of bytes to read
* @param {boolean} isEncrypted Whether if those are encrypted
* @returns {Buffer} Bytes
* @private
* @deprecated
*/
readAndDecrypt(num, isEncrypted = this.isEncrypted) {
let data = this.Ar.read(num);
if (isEncrypted) {
const key = this.aesKey;
if (!key)
throw new Exceptions_1.ParserException("Reading this pak requires an encryption key");
data = Aes_1.Aes.decrypt(data, key);
}
return data;
}
/**
* Fixes a mount point
* @param {string} mountPoint Current mount point
* @returns {string}
* @private
*/
static fixMountPoint(mountPoint) {
let badMountPoint = false;
if (!mountPoint.startsWith("../../.."))
badMountPoint = true;
else
mountPoint = mountPoint.replace("../../..", "");
if (mountPoint[0] !== '/' || (mountPoint.length > 1 && mountPoint[1] === '.'))
badMountPoint = true;
if (badMountPoint) {
//console.warn(`Pak \"${this.path}\" has strange mount point \"${mountPoint}\", mounting to root`)
mountPoint = "/";
}
if (mountPoint.startsWith('/'))
mountPoint = mountPoint.substring(1);
return mountPoint;
}
/**
* Checks index bytes
* @returns {Buffer} Index bytes
* @public
*/
indexCheckBytes() {
this.Ar.pos = this.pakInfo.indexOffset;
return this.Ar.read(128);
}
}
exports.PakFileReader = PakFileReader;