mirror of
https://github.com/OpenDiablo2/OpenDiablo2
synced 2024-11-02 17:27:23 -04:00
365 lines
14 KiB
C#
365 lines
14 KiB
C#
using OpenDiablo2.Common.Enums;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace OpenDiablo2.Common.Models
|
|
{
|
|
public sealed class MPQ : IDisposable
|
|
{
|
|
private const string HEADER_SIGNATURE = "MPQ\x1A";
|
|
private const string USERDATA_SIGNATURE = "MPQ\x1B";
|
|
private const string LISTFILE_NAME = "(listfile)";
|
|
private const int MPQ_HASH_FILE_KEY = 3;
|
|
private const int MPQ_HASH_TABLE_OFFSET = 0;
|
|
private const int MPQ_HASH_NAME_A = 1;
|
|
private const int MPQ_HASH_NAME_B = 2;
|
|
private const UInt32 MPQ_HASH_ENTRY_EMPTY = 0xFFFFFFFF;
|
|
private const UInt32 MPQ_HASH_ENTRY_DELETED = 0xFFFFFFFE;
|
|
|
|
internal struct HeaderRecord
|
|
{
|
|
public UInt32 HeaderSize;
|
|
public UInt32 ArchiveSize;
|
|
public UInt16 FormatVersion;
|
|
public Byte BlockSize;
|
|
public UInt32 HashTablePos;
|
|
public UInt32 BlockTablePos;
|
|
public UInt32 HashTableSize;
|
|
public UInt32 BlockTableSize;
|
|
// Other properties are for >0 MPQ version
|
|
}
|
|
|
|
[Flags]
|
|
internal enum eBlockRecordFlags : UInt32
|
|
{
|
|
IsFile = 0x80000000, // Block is a file, and follows the file data format; otherwise, block is free space or unused. If the block is not a file, all other flags should be cleared, and FileSize should be 0.
|
|
SingleUnit = 0x01000000, // File is stored as a single unit, rather than split into sectors.
|
|
KeyAdjusted = 0x00020000, // The file's encryption key is adjusted by the block offset and file size (explained in detail in the File Data section). File must be encrypted.
|
|
IsEncrypted = 0x00010000, // File is encrypted.
|
|
IsCompressed = 0x00000200, // File is compressed. File cannot be imploded.
|
|
IsImploded = 0x00000100 // File is imploded. File cannot be compressed.
|
|
}
|
|
|
|
internal struct BlockRecord
|
|
{
|
|
public UInt32 BlockOffset;
|
|
public UInt32 BlockSize;
|
|
public UInt32 FileSize;
|
|
public UInt32 Flags;
|
|
public uint EncryptionSeed { get; set; }
|
|
public string FileName { get; internal set; }
|
|
|
|
public bool IsFile => (Flags & (UInt32)eBlockRecordFlags.IsFile) != 0;
|
|
public bool SingleUnit => (Flags & (UInt32)eBlockRecordFlags.SingleUnit) != 0;
|
|
public bool KeyAdjusted => (Flags & (UInt32)eBlockRecordFlags.KeyAdjusted) != 0;
|
|
public bool IsEncrypted => (Flags & (UInt32)eBlockRecordFlags.IsEncrypted) != 0;
|
|
public bool IsCompressed => (Flags & (UInt32)eBlockRecordFlags.IsCompressed) != 0;
|
|
public bool IsImploded => (Flags & (UInt32)eBlockRecordFlags.IsImploded) != 0;
|
|
}
|
|
|
|
internal struct HashRecord
|
|
{
|
|
public UInt32 FilePathHashA;
|
|
public UInt32 FilePathHashB;
|
|
public UInt16 Language;
|
|
public UInt16 Platform;
|
|
public UInt32 FileBlockIndex;
|
|
}
|
|
|
|
internal static UInt32[] cryptTable = new UInt32[0x500];
|
|
internal HeaderRecord Header;
|
|
private List<BlockRecord> blockTable = new List<BlockRecord>();
|
|
private List<HashRecord> hashTable = new List<HashRecord>();
|
|
internal Stream fileStream;
|
|
|
|
public UInt16 LanguageId = 0;
|
|
public const byte Platform = 0;
|
|
|
|
public string Path { get; private set; }
|
|
public eMPQFormatVersion FormatVersion => (eMPQFormatVersion)Header.FormatVersion;
|
|
public List<string> Files => GetFilePaths();
|
|
|
|
private List<string> GetFilePaths()
|
|
{
|
|
var stream = OpenFile("(listfile)");
|
|
if (stream == null)
|
|
{
|
|
return new List<string>();
|
|
}
|
|
|
|
var sr = new StreamReader(stream);
|
|
var text = sr.ReadToEnd();
|
|
|
|
return text.Split('\n').Where(x => !String.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).ToList();
|
|
}
|
|
|
|
static MPQ()
|
|
{
|
|
InitializeCryptTable();
|
|
}
|
|
|
|
public MPQ(string path)
|
|
{
|
|
this.Path = path;
|
|
|
|
fileStream = new FileStream(path, FileMode.Open);
|
|
|
|
using (var br = new BinaryReader(fileStream, Encoding.Default, true))
|
|
{
|
|
var header = Encoding.ASCII.GetString(br.ReadBytes(4));
|
|
if (header != HEADER_SIGNATURE)
|
|
throw new ApplicationException($"Unknown header signature '{header}' detected while processing '{Path}'!");
|
|
|
|
ParseMPQHeader(br);
|
|
}
|
|
|
|
}
|
|
|
|
private static void InitializeCryptTable()
|
|
{
|
|
UInt32 seed = 0x00100001;
|
|
UInt32 index1 = 0;
|
|
UInt32 index2 = 0;
|
|
int i;
|
|
|
|
for (index1 = 0; index1 < 0x100; index1++)
|
|
{
|
|
for (index2 = index1, i = 0; i < 5; i++, index2 += 0x100)
|
|
{
|
|
seed = (seed * 125 + 3) % 0x2AAAAB;
|
|
var temp = (seed & 0xFFFF) << 0x10;
|
|
|
|
seed = (seed * 125 + 3) % 0x2AAAAB;
|
|
|
|
cryptTable[index2] = (temp | (seed & 0xFFFF));
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static void DecryptBlock(uint[] data, uint seed1)
|
|
{
|
|
uint seed2 = 0xeeeeeeee;
|
|
|
|
for (int i = 0; i < data.Length; i++)
|
|
{
|
|
seed2 += cryptTable[0x400 + (seed1 & 0xff)];
|
|
uint result = data[i];
|
|
result ^= seed1 + seed2;
|
|
|
|
seed1 = ((~seed1 << 21) + 0x11111111) | (seed1 >> 11);
|
|
seed2 = result + seed2 + (seed2 << 5) + 3;
|
|
data[i] = result;
|
|
}
|
|
}
|
|
|
|
internal static void DecryptBlock(byte[] data, uint seed1)
|
|
{
|
|
uint seed2 = 0xeeeeeeee;
|
|
|
|
// NB: If the block is not an even multiple of 4,
|
|
// the remainder is not encrypted
|
|
for (int i = 0; i < data.Length - 3; i += 4)
|
|
{
|
|
seed2 += cryptTable[(int)(0x400 + (seed1 & 0xff))];
|
|
|
|
uint result = BitConverter.ToUInt32(data, i);
|
|
result ^= (seed1 + seed2);
|
|
|
|
seed1 = ((~seed1 << 21) + 0x11111111) | (seed1 >> 11);
|
|
seed2 = result + seed2 + (seed2 << 5) + 3;
|
|
|
|
data[i + 0] = ((byte)(result & 0xff));
|
|
data[i + 1] = ((byte)((result >> 8) & 0xff));
|
|
data[i + 2] = ((byte)((result >> 16) & 0xff));
|
|
data[i + 3] = ((byte)((result >> 24) & 0xff));
|
|
}
|
|
}
|
|
|
|
|
|
private void ParseMPQHeader(BinaryReader br)
|
|
{
|
|
Header = new HeaderRecord
|
|
{
|
|
HeaderSize = br.ReadUInt32(),
|
|
ArchiveSize = br.ReadUInt32(),
|
|
FormatVersion = br.ReadUInt16(),
|
|
BlockSize = (byte)br.ReadInt16(),
|
|
HashTablePos = br.ReadUInt32(),
|
|
BlockTablePos = br.ReadUInt32(),
|
|
HashTableSize = br.ReadUInt32(),
|
|
BlockTableSize = br.ReadUInt32()
|
|
};
|
|
|
|
if (FormatVersion != eMPQFormatVersion.Format1)
|
|
throw new ApplicationException($"Unsupported MPQ format version of {Header.FormatVersion} detected for '{Path}'!");
|
|
|
|
if (br.BaseStream.Position != Header.HeaderSize)
|
|
throw new ApplicationException($"Invalid header size detected for '{Path}'. Expected to be at offset {Header.HeaderSize} but we are at offset {br.BaseStream.Position} instead!");
|
|
|
|
br.BaseStream.Seek(Header.BlockTablePos, SeekOrigin.Begin);
|
|
|
|
// Process the block table
|
|
var bData = br.ReadBytes((int)(16 * Header.BlockTableSize));
|
|
DecryptBlock(bData, HashString("(block table)", MPQ_HASH_FILE_KEY));
|
|
using (var ms = new MemoryStream(bData))
|
|
using (var dr = new BinaryReader(new MemoryStream(bData)))
|
|
for (var index = 0; index < Header.BlockTableSize; index++)
|
|
{
|
|
blockTable.Add(new BlockRecord
|
|
{
|
|
BlockOffset = dr.ReadUInt32(),
|
|
BlockSize = dr.ReadUInt32(),
|
|
FileSize = dr.ReadUInt32(),
|
|
Flags = dr.ReadUInt32()
|
|
});
|
|
}
|
|
|
|
// Process the hash table
|
|
br.BaseStream.Seek(Header.HashTablePos, SeekOrigin.Begin);
|
|
bData = br.ReadBytes((int)(16 * Header.HashTableSize));
|
|
DecryptBlock(bData, HashString("(hash table)", MPQ_HASH_FILE_KEY));
|
|
using (var ms = new MemoryStream(bData))
|
|
using (var dr = new BinaryReader(new MemoryStream(bData)))
|
|
for (var index = 0; index < Header.HashTableSize; index++)
|
|
{
|
|
hashTable.Add(new HashRecord
|
|
{
|
|
FilePathHashA = dr.ReadUInt32(),
|
|
FilePathHashB = dr.ReadUInt32(),
|
|
Language = dr.ReadUInt16(),
|
|
Platform = dr.ReadUInt16(),
|
|
FileBlockIndex = dr.ReadUInt32()
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
private uint CalculateEncryptionSeed(BlockRecord record)
|
|
{
|
|
if (record.FileName == null) return 0;
|
|
|
|
uint seed = HashString(System.IO.Path.GetFileName(record.FileName), MPQ_HASH_FILE_KEY);
|
|
if (record.KeyAdjusted)
|
|
seed = (seed + record.BlockOffset) ^ record.FileSize;
|
|
return seed;
|
|
}
|
|
|
|
private static UInt32 HashString(string inputString, UInt32 hashType)
|
|
{
|
|
if (hashType > MPQ_HASH_FILE_KEY)
|
|
throw new ApplicationException($"Unknown hash type {hashType} for input string {inputString}");
|
|
|
|
UInt32 seed1 = 0x7FED7FED;
|
|
UInt32 seed2 = 0xEEEEEEEE;
|
|
|
|
foreach (var ch in inputString)
|
|
{
|
|
var chInt = (UInt32)char.ToUpper(ch);
|
|
seed1 = cryptTable[(hashType * 0x100) + chInt] ^ (seed1 + seed2);
|
|
seed2 = chInt + seed1 + seed2 + (seed2 << 5) + 3;
|
|
}
|
|
return seed1;
|
|
}
|
|
|
|
private static UInt32 ComputeFileKey(string filePath, BlockRecord blockRecord, UInt32 archiveOffset)
|
|
{
|
|
var fileName = filePath.Split('\\').Last();
|
|
|
|
// Hash the name to get the base key
|
|
var fileKey = HashString(fileName, MPQ_HASH_FILE_KEY);
|
|
|
|
// Offset-adjust the key if necessary
|
|
if (blockRecord.KeyAdjusted)
|
|
fileKey = (fileKey + blockRecord.BlockOffset) ^ blockRecord.FileSize;
|
|
|
|
return fileKey;
|
|
}
|
|
|
|
private bool FindFileInHashTable(string filePath, out UInt32 fileHashEntry)
|
|
{
|
|
fileHashEntry = 0;
|
|
|
|
// Find the home entry in the hash table for the file
|
|
UInt32 initEntry = HashString(filePath, MPQ_HASH_TABLE_OFFSET) & (UInt32)(Header.HashTableSize - 1);
|
|
|
|
// Is there anything there at all?
|
|
if (hashTable[(int)initEntry].FileBlockIndex == MPQ_HASH_ENTRY_EMPTY)
|
|
return false;
|
|
|
|
// Compute the hashes to compare the hash table entry against
|
|
var nNameHashA = HashString(filePath, MPQ_HASH_NAME_A);
|
|
var nNameHashB = HashString(filePath, MPQ_HASH_NAME_B);
|
|
var iCurEntry = initEntry;
|
|
|
|
// Check each entry in the hash table till a termination point is reached
|
|
do
|
|
{
|
|
if (hashTable[(int)iCurEntry].FileBlockIndex != MPQ_HASH_ENTRY_DELETED)
|
|
{
|
|
if (hashTable[(int)iCurEntry].FilePathHashA == nNameHashA
|
|
&& hashTable[(int)iCurEntry].FilePathHashB == nNameHashB
|
|
&& hashTable[(int)iCurEntry].Language == LanguageId
|
|
&& hashTable[(int)iCurEntry].Platform == (UInt16)PlatformID.Win32S)
|
|
{
|
|
fileHashEntry = iCurEntry;
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
iCurEntry = (iCurEntry + 1) & (UInt32)(Header.HashTableSize - 1);
|
|
} while (iCurEntry != initEntry && hashTable[(int)iCurEntry].FileBlockIndex != MPQ_HASH_ENTRY_EMPTY);
|
|
|
|
return false;
|
|
}
|
|
|
|
private bool GetHashRecord(string fileName, out HashRecord hash)
|
|
{
|
|
uint index = HashString(fileName, 0);
|
|
index &= Header.HashTableSize - 1;
|
|
uint name1 = HashString(fileName, MPQ_HASH_NAME_A);
|
|
uint name2 = HashString(fileName, MPQ_HASH_NAME_B);
|
|
|
|
for (uint i = index; i < hashTable.Count(); ++i)
|
|
{
|
|
hash = hashTable[(int)i];
|
|
if (hash.FilePathHashA == name1 && hash.FilePathHashB == name2)
|
|
return true;
|
|
}
|
|
|
|
for (uint i = 0; i < index; i++)
|
|
{
|
|
hash = hashTable[(int)i];
|
|
if (hash.FilePathHashA == name1 && hash.FilePathHashB == name2)
|
|
return true;
|
|
}
|
|
|
|
hash = new HashRecord();
|
|
return false;
|
|
}
|
|
|
|
public MPQStream OpenFile(string filename)
|
|
{
|
|
HashRecord hash;
|
|
BlockRecord block;
|
|
|
|
if (!GetHashRecord(filename, out hash))
|
|
throw new FileNotFoundException("File not found: " + filename);
|
|
|
|
block = blockTable[(int)hash.FileBlockIndex];
|
|
block.FileName = filename.ToLower();
|
|
block.EncryptionSeed = CalculateEncryptionSeed(block);
|
|
return new MPQStream(this, block);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
fileStream?.Dispose();
|
|
}
|
|
}
|
|
}
|