From 586a4b35ff23c5c82a83550d97387f2e39a8e7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Sj=C3=B6blom?= Date: Tue, 6 Jan 2026 19:47:54 +0100 Subject: [PATCH 1/2] Adding built-in support and rules for games --- README.md | 19 +- src/CMakeLists.txt | 7 +- src/gamerules.cpp | 505 +++++++++++++++++++++++++++++++++++++++++++ src/gamerules.h | 161 ++++++++++++++ src/main.cpp | 109 +++++++++- src/mpq.cpp | 80 ++++--- src/mpq.h | 7 +- test/conftest.py | 4 +- test/test_add.py | 163 +++++++++++++- test/test_create.py | 275 ++++++++++++++++++++++- test/test_extract.py | 7 +- test/test_info.py | 16 +- test/test_list.py | 4 +- 13 files changed, 1282 insertions(+), 75 deletions(-) create mode 100644 src/gamerules.cpp create mode 100644 src/gamerules.h diff --git a/README.md b/README.md index 70947c1..a9ce11e 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,6 @@ A command line tool to create, add, remove, list, extract, read, and verify MPQ - Pipe the output to `grep` or other tools to search, filter, or process files - Redirect output to files or other commands for further automation -**This project is primarily for older World of Warcraft MPQ archives**. This means it has been authored for MPQ archives versions 1 and 2, which include the following World of Warcraft (WoW) versions: Vanilla (1.12.1), TBC (2.4.3), and WoTLK (3.3.5). It has only been tested on WoW MPQ archives/patches that use MPQ versions 1 or 2. No testing has been performed on other MPQ versions or archives from other games. However, the tool will most likely work on other MPQ archive versions, as the underlying Stormlib library supports all MPQ archive versions. - If you require an MPQ tool with a graphical interface (GUI) and explicit support for more MPQ archive versions - I would recommend using [Ladik's MPQ Editor](http://www.zezula.net/en/mpq/download.html). ## Download @@ -127,13 +125,13 @@ mpqcli create The default mode of operation for the `create` subcommand is to take everything from the "target" directory (and below) and recursively add it to the archive. The directory structure is retained. Windows-style backslash path separators are used (`\`), as per the observed behavior in most MPQ archives. -### Create an MPQ archive using a specific version +### Create an MPQ archive for a specific game -Support for creating an MPQ archive version 1 or version 2 by using the `-v` or `--version` argument. +Target a specific game version by using the `-g` or `--game` argument. This will automatically set the correct archive format version and settings, although they can be overridden. ``` -mpqcli create -v 1 -mpqcli create --version 2 +mpqcli create -g starcraft +mpqcli create --game wow-wotlk --sector-size 16384 --version 3 # World of WarCraft - Wrath of the Lich King, but with non-standard sector size and MPQ version ``` ### Create and sign an MPQ archive @@ -179,6 +177,15 @@ $ mpqcli add allianz.txt --locale deDE [+] Adding file for locale 1031: allianz.txt ``` +### Add a file with game-specific properties + +Target a specific game version by using the `-g` or `--game` argument. This will automatically set the correct encryption rules and MPQ flags, although they can be overridden. + +``` +$ mpqcli add khwhat1.wav archive.mpq --game wc2 # In StarCraft and WarCraft II MPQs, wav files are compressed in ADPCM form +[+] Adding file for locale 0: khwhat1.wav +``` + ### Remove a file from an existing archive diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 05c88c7..4577f55 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,11 +11,12 @@ if (MSVC) endif() # Create the main executable -add_executable(mpqcli - main.cpp - mpq.cpp +add_executable(mpqcli + main.cpp + mpq.cpp helpers.cpp locales.cpp + gamerules.cpp ) # Add dependencies diff --git a/src/gamerules.cpp b/src/gamerules.cpp new file mode 100644 index 0000000..fdbc362 --- /dev/null +++ b/src/gamerules.cpp @@ -0,0 +1,505 @@ +#include "gamerules.h" +#include +#include +#include + +// Constructor +GameRules::GameRules(GameProfile gameProfile) : profile(gameProfile) { + InitializeRules(); +} + +// Helper function to convert string to lowercase +static std::string ToLower(const std::string& str) { + std::string result = str; + std::transform(result.begin(), result.end(), result.begin(), + [](unsigned char c) { return std::tolower(c); }); + return result; +} + +// Helper function to match wildcards (* and ?) +bool GameRules::MatchFileMask(const std::string& filename, const std::string& mask) { + // Convert both to lowercase for case-insensitive matching + std::string lowerFilename = ToLower(filename); + std::string lowerMask = ToLower(mask); + + // Replace backslashes with forward slashes for consistent path handling + std::replace(lowerFilename.begin(), lowerFilename.end(), '\\', '/'); + std::replace(lowerMask.begin(), lowerMask.end(), '\\', '/'); + + // Simple wildcard matching + size_t maskPos = 0; + size_t filePos = 0; + size_t starPos = std::string::npos; + size_t matchPos = 0; + + while (filePos < lowerFilename.length()) { + if (maskPos < lowerMask.length() && (lowerMask[maskPos] == '?' || lowerMask[maskPos] == lowerFilename[filePos])) { + maskPos++; + filePos++; + } else if (maskPos < lowerMask.length() && lowerMask[maskPos] == '*') { + starPos = maskPos; + matchPos = filePos; + maskPos++; + } else if (starPos != std::string::npos) { + maskPos = starPos + 1; + matchPos++; + filePos = matchPos; + } else { + return false; + } + } + + while (maskPos < lowerMask.length() && lowerMask[maskPos] == '*') { + maskPos++; + } + + return maskPos == lowerMask.length(); +} + +void GameRules::AddRuleByFileMask(const std::string& fileMask, DWORD mpqFlags, DWORD compressionFirst, DWORD compressionNext) { + rules.emplace_back(fileMask, mpqFlags, compressionFirst, compressionNext); +} + +// Use UINT32_MAX for sizeMax to indicate "no upper limit" +// Examples: +// AddRuleByFileSize(0, 0, ...) - Match files with exactly 0 bytes +// AddRuleByFileSize(0, 0x4000, ...) - Match files from 0 to 16KB +// AddRuleByFileSize(0x4000, UINT32_MAX, ...) - Match files from 16KB onwards +// ReSharper disable all CppDFAConstantParameter +void GameRules::AddRuleByFileSize(DWORD sizeMin, DWORD sizeMax, DWORD mpqFlags, DWORD compressionFirst, DWORD compressionNext) { + rules.emplace_back(sizeMin, sizeMax, mpqFlags, compressionFirst, compressionNext); +} + +// ReSharper disable once CppDFAConstantParameter +void GameRules::AddRuleDefault(DWORD mpqFlags, DWORD compressionFirst, DWORD compressionNext) { + rules.emplace_back(mpqFlags, compressionFirst, compressionNext); +} + +// Get compression settings for a specific file +CompressionSettings GameRules::GetCompressionSettings(const std::string& filename, const DWORD fileSize) const { + // Iterate through rules in order (first match wins) + for (const auto& rule : rules) { + switch (rule.type) { + case RuleType::FILE_MASK: + if (MatchFileMask(filename, rule.fileMask)) { + return {rule.mpqFlags, rule.compressionFirst, rule.compressionNext}; + } + break; + + case RuleType::FILE_SIZE: { + // Use UINT32_MAX to indicate "no upper limit" + bool hasUpperLimit = (rule.sizeMax != UINT32_MAX); + bool inRange = fileSize >= rule.sizeMin && (!hasUpperLimit || fileSize <= rule.sizeMax); + + if (inRange) { + return {rule.mpqFlags, rule.compressionFirst, rule.compressionNext}; + } + break; + } + + case RuleType::DEFAULT: + return {rule.mpqFlags, rule.compressionFirst, rule.compressionNext}; + } + } + + // Fallback if no rules match (shouldn't happen if DEFAULT rule is present) + return {MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE, MPQ_COMPRESSION_NEXT_SAME}; +} + +// Override MPQ creation settings with user-provided values +void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides& overrides) { + // Track whether user explicitly set fileFlags2 (needed for automatic adjustment logic) + bool userSetFileFlags2 = false; + + // Step 1: Apply user overrides + // User-provided values always take priority, even if they might be incorrect. + // We only apply override if the optional has a value (i.e., user specified it) + + if (overrides.mpqVersion.has_value()) { + createSettings.mpqVersion = overrides.mpqVersion.value(); + } + + if (overrides.streamFlags.has_value()) { + createSettings.streamFlags = overrides.streamFlags.value(); + } + + if (overrides.sectorSize.has_value()) { + createSettings.sectorSize = overrides.sectorSize.value(); + } + + if (overrides.rawChunkSize.has_value()) { + createSettings.rawChunkSize = overrides.rawChunkSize.value(); + } + + if (overrides.fileFlags1.has_value()) { + createSettings.fileFlags1 = overrides.fileFlags1.value(); + } + + if (overrides.fileFlags2.has_value()) { + createSettings.fileFlags2 = overrides.fileFlags2.value(); + userSetFileFlags2 = true; // User explicitly set this value + } + + if (overrides.fileFlags3.has_value()) { + createSettings.fileFlags3 = overrides.fileFlags3.value(); + } + + if (overrides.attrFlags.has_value()) { + createSettings.attrFlags = overrides.attrFlags.value(); + } + + // Step 2: Apply automatic adjustments based on dependencies + // These only apply if the user hasn't explicitly overridden the values + + // fileFlags2 controls the (attributes) file, which is only meaningful when + // attrFlags is also set. According to StormLib's SFileCreateArchive.cpp: + // - The (attributes) file is created only when BOTH fileFlags2 AND attrFlags are non-zero + // - If attrFlags is set but fileFlags2 is still 0 (not overridden by user or profile), + // we should set fileFlags2 to MPQ_FILE_DEFAULT_INTERNAL to enable the attributes file + + if (!userSetFileFlags2 && createSettings.fileFlags2 == 0 && createSettings.attrFlags != 0) { + // User wants attributes (attrFlags is set) but hasn't specified how to store + // the (attributes) file itself. Use the default internal file flags. + createSettings.fileFlags2 = MPQ_FILE_DEFAULT_INTERNAL; + } + + // Note: If user explicitly sets fileFlags2 to 0 via override, we respect that choice + // even if attrFlags is non-zero. +} + +// Get the profile name map (single source of truth for all valid profile names) +static const std::map& GetProfileMap() { + static const std::map profileMap = { + {"generic", GameProfile::GENERIC}, + {"diablo1", GameProfile::DIABLO1}, + {"diablo", GameProfile::DIABLO1}, + {"lordsofmagic", GameProfile::LORDSOFMAGIC}, + {"lomse", GameProfile::LORDSOFMAGIC}, + {"starcraft", GameProfile::STARCRAFT1}, + {"starcraft1", GameProfile::STARCRAFT1}, + {"sc", GameProfile::STARCRAFT1}, + {"sc1", GameProfile::STARCRAFT1}, + {"warcraft2", GameProfile::WARCRAFT2}, + {"wc2", GameProfile::WARCRAFT2}, + {"war2", GameProfile::WARCRAFT2}, + {"diablo2", GameProfile::DIABLO2}, + {"d2", GameProfile::DIABLO2}, + {"warcraft3", GameProfile::WARCRAFT3}, + {"wc3", GameProfile::WARCRAFT3}, + {"war3", GameProfile::WARCRAFT3}, + {"warcraft3-map", GameProfile::WARCRAFT3_MAP}, + {"wc3-map", GameProfile::WARCRAFT3_MAP}, + {"war3-map", GameProfile::WARCRAFT3_MAP}, + {"wow1", GameProfile::WOW_1X}, + {"wow-vanilla", GameProfile::WOW_1X}, + {"wow2", GameProfile::WOW_2X}, + {"wow-tbc", GameProfile::WOW_2X}, + {"wow3", GameProfile::WOW_3X}, + {"wow-wotlk", GameProfile::WOW_3X}, + {"wow4", GameProfile::WOW_4X}, + {"wow-cataclysm", GameProfile::WOW_4X}, + {"wow5", GameProfile::WOW_5X}, + {"wow-mop", GameProfile::WOW_5X}, + {"starcraft2", GameProfile::STARCRAFT2}, + {"sc2", GameProfile::STARCRAFT2}, + {"diablo3", GameProfile::DIABLO3}, + {"d3", GameProfile::DIABLO3} + }; + return profileMap; +} + +// Convert string to GameProfile enum +GameProfile GameRules::StringToProfile(const std::string& profileName) { + const auto& profileMap = GetProfileMap(); + std::string lower = ToLower(profileName); + auto it = profileMap.find(lower); + if (it != profileMap.end()) { + return it->second; + } + return GameProfile::GENERIC; +} + +// Convert GameProfile enum to string +std::string GameRules::ProfileToString(GameProfile profile) { + switch (profile) { + case GameProfile::GENERIC: return "generic"; + case GameProfile::DIABLO1: return "diablo1"; + case GameProfile::LORDSOFMAGIC: return "lordsofmagic"; + case GameProfile::WARCRAFT2: return "warcraft2"; + case GameProfile::STARCRAFT1: return "starcraft1"; + case GameProfile::DIABLO2: return "diablo2"; + case GameProfile::WARCRAFT3: return "warcraft3"; + case GameProfile::WARCRAFT3_MAP: return "warcraft3-map"; + case GameProfile::WOW_1X: return "wow-vanilla"; + case GameProfile::WOW_2X: return "wow-tbc"; + case GameProfile::WOW_3X: return "wow-wotlk"; + case GameProfile::WOW_4X: return "wow-cataclysm"; + case GameProfile::WOW_5X: return "wow-mop"; + case GameProfile::STARCRAFT2: return "starcraft2"; + case GameProfile::DIABLO3: return "diablo3"; + default: return "generic"; + } +} + +// Get list of canonical game profile names (for display purposes) +std::vector GameRules::GetCanonicalProfiles() { + // Iterate through all GameProfile enum values and get their canonical names + std::vector profiles; + + for (int i = static_cast(GameProfile::GENERIC); i <= static_cast(GameProfile::DIABLO3); ++i) { + profiles.push_back(ProfileToString(static_cast(i))); + } + + return profiles; +} + +// Get available profiles as a comma-separated string +std::string GameRules::GetAvailableProfiles() { + auto profiles = GetCanonicalProfiles(); + std::string result; + + for (size_t i = 0; i < profiles.size(); ++i) { + result += profiles[i]; + if (i < profiles.size() - 1) { + result += ", "; + } + } + + return result; +} + +// Validator for CLI11 - accepts all profile names but only displays canonical ones +const CLI::Validator GameProfileValid = CLI::Validator( + [](const std::string &str) { + if (str == "default") return std::string(); + + // Try to convert the string to a profile + GameProfile profile = GameRules::StringToProfile(str); + + // If it's GENERIC and the input wasn't "generic", it means the profile wasn't found + if (profile == GameProfile::GENERIC && str != "generic") { + std::string validProfiles = "Game profile must be one of:"; + for (const auto& p : GameRules::GetCanonicalProfiles()) { + validProfiles += " " + p; + } + return validProfiles; + } + return std::string(); + }, + "", + "GameProfileValidator" +); + +// Initialize rules for the selected game profile +void GameRules::InitializeRules() { + rules.clear(); + + switch (profile) { + case GameProfile::DIABLO1: + case GameProfile::LORDSOFMAGIC: + // File rules when adding files to archive: + AddRuleByFileMask("*.wav", MPQ_FILE_ENCRYPTED, 0x00, 0x00); + AddRuleByFileMask("*.smk", 0x00000000, 0x00, 0x00); + AddRuleByFileMask("*.bik", 0x00000000, 0x00, 0x00); + AddRuleByFileMask("*.mpq", MPQ_FILE_ENCRYPTED, 0x00, 0x00); + AddRuleByFileMask("game", MPQ_FILE_IMPLODE, 0x00, 0x00); + AddRuleByFileMask("hero", MPQ_FILE_IMPLODE, 0x00, 0x00); + AddRuleDefault(MPQ_FILE_IMPLODE | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE); + + // Settings for archive creation: + createSettings.mpqVersion = MPQ_FORMAT_VERSION_1; + createSettings.sectorSize = 0x1000; + break; + + case GameProfile::WARCRAFT2: + case GameProfile::STARCRAFT1: + // File rules when adding files to archive: + AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_PKWARE, MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_STEREO); + AddRuleByFileMask("*.smk", 0x00000000, 0x00, 0x00); + AddRuleByFileMask("*.bik", 0x00000000, 0x00, 0x00); + AddRuleByFileMask("*.mpq", 0x00000000, 0x00, 0x00); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, MPQ_COMPRESSION_PKWARE); + + // Settings for archive creation: + createSettings.mpqVersion = MPQ_FORMAT_VERSION_1; + createSettings.fileFlags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC; + createSettings.fileFlags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC; + createSettings.sectorSize = 0x1000; + break; + + case GameProfile::DIABLO2: + // File rules when adding files to archive: + AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_PKWARE, MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_STEREO); + AddRuleByFileMask("*.d2", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.txt", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.dc6", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.tbl", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.map", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.key", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.dat", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.ds1", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.dcc", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.cof", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.dt1", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.pl2", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.dn1", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleByFileMask("*.ico", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_PKWARE); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, MPQ_COMPRESSION_PKWARE); + + // Settings for archive creation: + createSettings.mpqVersion = MPQ_FORMAT_VERSION_1; + createSettings.fileFlags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.fileFlags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.sectorSize = 0x1000; + break; + + case GameProfile::WARCRAFT3: + // File rules when adding files to archive: + AddRuleByFileMask("Abilities\\*.wav", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO); + AddRuleByFileMask("Buildings\\*.wav", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB, + MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO); + AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_ZLIB, MPQ_COMPRESSION_HUFFMANN | MPQ_COMPRESSION_ADPCM_MONO); + + AddRuleByFileMask("ReplaceableTextures\\WorldEditUI\\*.blp", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("ReplaceableTextures\\Selection\\*.blp", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("ReplaceableTextures\\Shadows\\*.blp", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("UI\\Glues\\Loading\\Backgrounds\\*.blp", 0, 0); + AddRuleByFileMask("UI\\Glues\\Loading\\Multiplayer\\*.blp", 0, 0); + AddRuleByFileMask("UI\\*.blp", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.blp", 0, 0); + + AddRuleByFileMask("Maps\\Campaign\\*.w3m", 0, 0); + AddRuleByFileMask("*.w3m", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, MPQ_COMPRESSION_PKWARE); + + AddRuleByFileMask("*.toc", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.ifl", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.mdx", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.tga", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.slk", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.ai", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.j", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.txt", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.fdf", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.pld", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.mid", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.dls", MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.mpq", 0, 0); + AddRuleByFileMask("*.mp3", 0, 0); + + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, MPQ_COMPRESSION_PKWARE); + + // Settings for archive creation: + createSettings.mpqVersion = MPQ_FORMAT_VERSION_1; + createSettings.sectorSize = 0x1000; + createSettings.fileFlags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.fileFlags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.attrFlags = MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32; + break; + + case GameProfile::WARCRAFT3_MAP: // Warcraft III Map files + // File rules when adding files to archive: + AddRuleDefault(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + createSettings.mpqVersion = MPQ_FORMAT_VERSION_1; + createSettings.sectorSize = 0x1000; + createSettings.fileFlags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.fileFlags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.attrFlags = MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32; + break; + + case GameProfile::WOW_1X: + // File rules when adding files to archive: + AddRuleByFileMask("*.mp3", 0, 0); + AddRuleDefault(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + createSettings.mpqVersion = MPQ_FORMAT_VERSION_1; + createSettings.sectorSize = 0x1000; + createSettings.fileFlags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.fileFlags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.attrFlags = MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; + break; + + case GameProfile::WOW_2X: + case GameProfile::WOW_3X: + // File rules when adding files to archive: + AddRuleByFileMask("*.mp3", 0, 0); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + createSettings.mpqVersion = MPQ_FORMAT_VERSION_2; + createSettings.sectorSize = 0x1000; + createSettings.fileFlags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.fileFlags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.attrFlags = MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; + break; + + case GameProfile::WOW_4X: + case GameProfile::WOW_5X: + // File rules when adding files to archive: + AddRuleByFileSize(0, 0, MPQ_FILE_DELETE_MARKER, 0); + AddRuleByFileMask("*.mp3", 0, 0); + AddRuleByFileMask("*.ogg", 0, 0); + AddRuleByFileMask("*.ogv", 0, 0); + AddRuleByFileSize(0, 0x4000, MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, MPQ_COMPRESSION_ZLIB); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + createSettings.mpqVersion = MPQ_FORMAT_VERSION_4; + createSettings.rawChunkSize = 0x4000; + createSettings.sectorSize = 0x4000; + createSettings.fileFlags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.fileFlags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.attrFlags = MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; + break; + + case GameProfile::STARCRAFT2: + // File rules when adding files to archive: + AddRuleByFileSize(0, 0, MPQ_FILE_DELETE_MARKER, 0); + AddRuleByFileMask("*.mp3", 0, 0); + AddRuleByFileMask("*.ogg", 0, 0); + AddRuleByFileMask("*.ogv", 0, 0); + AddRuleByFileSize(0, 0x4000, MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, MPQ_COMPRESSION_ZLIB); + AddRuleByFileMask("*.wav", MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_SECTOR_CRC, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + createSettings.mpqVersion = MPQ_FORMAT_VERSION_2; + createSettings.sectorSize = 0x4000; + createSettings.fileFlags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.fileFlags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.attrFlags = MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; + break; + + case GameProfile::DIABLO3: + // File rules when adding files to archive: + AddRuleByFileSize(0, 0, MPQ_FILE_DELETE_MARKER, 0); + AddRuleByFileMask("*.mp3", 0, 0); + AddRuleByFileMask("*.ogg", 0, 0); + AddRuleByFileMask("*.ogv", 0, 0); + AddRuleByFileSize(0, 0x4000, MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, MPQ_COMPRESSION_ZLIB); + AddRuleDefault(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); + + // Settings for archive creation: + createSettings.mpqVersion = MPQ_FORMAT_VERSION_4; + createSettings.rawChunkSize = 0x4000; + createSettings.sectorSize = 0x4000; + createSettings.fileFlags1 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.fileFlags2 = MPQ_FILE_EXISTS | MPQ_FILE_COMPRESS; + createSettings.attrFlags = MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; + break; + + case GameProfile::GENERIC: + default: + // File rules when adding files to archive: + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE); + + // For settings for archive creation, use defaults from MpqCreateSettings constructor + break; + } +} diff --git a/src/gamerules.h b/src/gamerules.h new file mode 100644 index 0000000..d4976cf --- /dev/null +++ b/src/gamerules.h @@ -0,0 +1,161 @@ +#ifndef GAMERULES_H +#define GAMERULES_H + +#include +#include +#include +#include +#include +#include + +enum class GameProfile { + GENERIC, // Default/generic MPQ with basic compression + DIABLO1, // Diablo I / Hellfire (1997) + LORDSOFMAGIC, // Lords of Magic SE (1998) + STARCRAFT1, // StarCraft / Brood War (1998) + WARCRAFT2, // Warcraft II: Battle.net Edition (1999) + DIABLO2, // Diablo II / Lords of Destruction (2000) + WARCRAFT3, // Warcraft III / The Frozen Throne (2002) + WARCRAFT3_MAP, // Warcraft III Map files (2002) + WOW_1X, // World of Warcraft 1 - Vanilla (2004) + WOW_2X, // World of Warcraft 2 - The Burning Crusade (2007) + WOW_3X, // World of Warcraft 3 - Wrath of the Lich King (2008) + WOW_4X, // World of Warcraft 4 - Cataclysm (2010) + WOW_5X, // World of Warcraft 5 - Mists of Pandaria (2012) + STARCRAFT2, // StarCraft II (2010) + DIABLO3 // Diablo III (2012) +}; + +enum class RuleType { + FILE_MASK, // Rule based on file pattern (e.g., "*.wav") + FILE_SIZE, // Rule based on file size range + DEFAULT // Default rule (fallback) +}; + +// Structure representing a single compression rule +struct CompressionRule { + RuleType type; + std::string fileMask; // For FILE_MASK rules (e.g., "*.wav", "UI\\*.blp") + DWORD sizeMin; // For FILE_SIZE rules + DWORD sizeMax; // For FILE_SIZE rules + DWORD mpqFlags; // MPQ file flags (compression, encryption, etc.) + DWORD compressionFirst; // Compression for first sector + DWORD compressionNext; // Compression for subsequent sectors + + CompressionRule(std::string mask, const DWORD flags, const DWORD compFirst, const DWORD compNext = MPQ_COMPRESSION_NEXT_SAME) + : type(RuleType::FILE_MASK), fileMask(std::move(mask)), sizeMin(0), sizeMax(0), + mpqFlags(flags), compressionFirst(compFirst), compressionNext(compNext) {} + + CompressionRule(const DWORD minSize, const DWORD maxSize, const DWORD flags, const DWORD compFirst, const DWORD compNext = MPQ_COMPRESSION_NEXT_SAME) + : type(RuleType::FILE_SIZE), fileMask(""), sizeMin(minSize), sizeMax(maxSize), + mpqFlags(flags), compressionFirst(compFirst), compressionNext(compNext) {} + + CompressionRule(const DWORD flags, const DWORD compFirst, const DWORD compNext = MPQ_COMPRESSION_NEXT_SAME) + : type(RuleType::DEFAULT), fileMask(""), sizeMin(0), sizeMax(0), + mpqFlags(flags), compressionFirst(compFirst), compressionNext(compNext) {} +}; + +// Structure to hold compression settings for a file +struct CompressionSettings { + DWORD mpqFlags; + DWORD compressionFirst; + DWORD compressionNext; +}; + +// Structure to hold optional override settings for adding files +struct CompressionSettingsOverrides { + std::optional dwFlags; + std::optional dwCompression; + std::optional dwCompressionNext; +}; + +// Structure to hold MPQ archive creation settings +struct MpqCreateSettings { + DWORD mpqVersion; // MPQ format version (1, 2, 3, or 4) + DWORD streamFlags; // Stream flags (e.g., STREAM_PROVIDER_FLAT) + DWORD fileFlags1; // File flags for (listfile) + DWORD fileFlags2; // File flags for (attributes) + DWORD fileFlags3; // File flags for (signature) + DWORD attrFlags; // Attribute flags (CRC32, FILETIME, MD5, etc.) + DWORD sectorSize; // Sector size (typically 0x1000 or 0x4000) + DWORD rawChunkSize; // Raw chunk size (for MPQ v4, typically 0x4000) + + // Constructor with defaults + MpqCreateSettings() + : mpqVersion(MPQ_FORMAT_VERSION_1), + streamFlags(STREAM_PROVIDER_FLAT | BASE_PROVIDER_FILE), + fileFlags1(MPQ_FILE_DEFAULT_INTERNAL), + fileFlags2(0), + fileFlags3(MPQ_FILE_DEFAULT_INTERNAL), + attrFlags(0), + sectorSize(0x1000), + rawChunkSize(0) {} +}; + +// Structure to hold optional override settings for MPQ archive creation +struct MpqCreateSettingsOverrides { + std::optional mpqVersion; + std::optional streamFlags; + std::optional fileFlags1; + std::optional fileFlags2; + std::optional fileFlags3; + std::optional attrFlags; + std::optional sectorSize; + std::optional rawChunkSize; +}; + +// Game rules class that manages compression rules for different games +class GameRules { +private: + GameProfile profile; + std::vector rules; + MpqCreateSettings createSettings; + + // Helper function to match file mask pattern + static bool MatchFileMask(const std::string& filename, const std::string& mask); + + // Add rule by file mask + void AddRuleByFileMask(const std::string& fileMask, DWORD mpqFlags, DWORD compressionFirst, DWORD compressionNext = MPQ_COMPRESSION_NEXT_SAME); + + // Add rule by file size + void AddRuleByFileSize(DWORD sizeMin, DWORD sizeMax, DWORD mpqFlags, DWORD compressionFirst, DWORD compressionNext = MPQ_COMPRESSION_NEXT_SAME); + + // Add default rule + void AddRuleDefault(DWORD mpqFlags, DWORD compressionFirst, DWORD compressionNext = MPQ_COMPRESSION_NEXT_SAME); + + // Initialize rules for the selected game profile + void InitializeRules(); + + // Convert GameProfile enum to string + static std::string ProfileToString(GameProfile profile); + +public: + // Constructor + explicit GameRules(GameProfile gameProfile); + + // Get compression settings for a specific file + [[nodiscard]] CompressionSettings GetCompressionSettings(const std::string& filename, DWORD fileSize) const; + + // Get MPQ creation settings + [[nodiscard]] const MpqCreateSettings& GetCreateSettings() const { return createSettings; } + + // Override MPQ creation settings + void OverrideCreateSettings(const MpqCreateSettingsOverrides& overrides); + + // Convert string to GameProfile enum + static GameProfile StringToProfile(const std::string& profileName); + + // Get list of canonical game profile names (for display purposes) + static std::vector GetCanonicalProfiles(); + + // Get available profiles as a comma-separated string + static std::string GetAvailableProfiles(); + + // Get default game profile (GENERIC) + static GameProfile GetDefaultProfile() { return GameProfile::GENERIC; } +}; + +// Validator for CLI11 - accepts all profile names but only displays canonical ones +extern const CLI::Validator GameProfileValid; + +#endif // GAMERULES_H diff --git a/src/main.cpp b/src/main.cpp index a3eb1b8..d9e07c7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,8 +9,7 @@ #include "helpers.h" #include "locales.h" #include "mpqcli.h" - -namespace fs = std::filesystem; +#include "gamerules.h" int main(int argc, char **argv) { CLI::App app{ @@ -29,6 +28,7 @@ int main(int argc, char **argv) { std::string baseLocale = "default"; // create, add, remove, extract, read std::string baseOutput = "default"; // create, extract std::string baseListfileName = "default"; // list, extract + std::string baseGameProfile = "default"; // create, add // CLI: info std::string infoProperty = "default"; // CLI: list @@ -37,7 +37,18 @@ int main(int argc, char **argv) { bool extractKeepFolderStructure = false; // CLI: create bool createSignArchive = false; - int32_t createMpqVersion = 1; + int32_t createMpqVersion = -1; + int64_t createStreamFlags = -1; + int64_t createSectorSize = -1; + int64_t createRawChunkSize = -1; + int64_t createFileFlags1 = -1; + int64_t createFileFlags2 = -1; + int64_t createFileFlags3 = -1; + int64_t createAttrFlags = -1; + // CLI: add and create (compression overrides for files being added) + int64_t fileDwFlags = -1; + int64_t fileDwCompression = -1; + int64_t fileDwCompressionNext = -1; // CLI: list bool listDetailed = false; bool listAll = false; @@ -90,10 +101,23 @@ int main(int argc, char **argv) { ->check(CLI::ExistingDirectory); create->add_option("-o,--output", baseOutput, "Output MPQ archive"); create->add_flag("-s,--sign", createSignArchive, "Sign the MPQ archive (default false)"); - create->add_option("-v,--version", createMpqVersion, "Set the MPQ archive version (default 1)") - ->check(CLI::Range(1, 2)); create->add_option("--locale", baseLocale, "Locale to use for added files") ->check(LocaleValid); + create->add_option("-g,--game", baseGameProfile, "Game profile for MPQ creation. Valid options:\n" + GameRules::GetAvailableProfiles()) + ->check(GameProfileValid); + // MPQ creation settings overrides + create->add_option("--version", createMpqVersion, "Override the MPQ archive version")->check(CLI::Range(1, 4))->group("Game setting overrides"); + create->add_option("--stream-flags", createStreamFlags, "Override stream flags")->group("Game setting overrides"); + create->add_option("--sector-size", createSectorSize, "Override sector size")->group("Game setting overrides"); + create->add_option("--raw-chunk-size", createRawChunkSize, "Override raw chunk size for MPQ v4")->group("Game setting overrides"); + create->add_option("--file-flags1", createFileFlags1, "Override file flags for (listfile)")->group("Game setting overrides"); + create->add_option("--file-flags2", createFileFlags2, "Override file flags for (attributes)")->group("Game setting overrides"); + create->add_option("--file-flags3", createFileFlags3, "Override file flags for (signature)")->group("Game setting overrides"); + create->add_option("--attr-flags", createAttrFlags, "Override attribute flags (CRC32, FILETIME, MD5)")->group("Game setting overrides"); + // Compression settings overrides for files being added + create->add_option("--flags", fileDwFlags, "Override MPQ file flags for added files")->group("Game setting overrides"); + create->add_option("--compression", fileDwCompression, "Override compression for first sector of added files")->group("Game setting overrides"); + create->add_option("--compression-next", fileDwCompressionNext, "Override compression for subsequent sectors of added files")->group("Game setting overrides"); // Subcommand: Add CLI::App *add = app.add_subcommand("add", "Add a file to an existing MPQ archive"); @@ -106,6 +130,12 @@ int main(int argc, char **argv) { add->add_option("-p,--path", basePath, "Path within MPQ archive"); add->add_option("--locale", baseLocale, "Locale to use for added file") ->check(LocaleValid); + add->add_option("-g,--game", baseGameProfile, "Game profile for compression rules. Valid options:\n" + GameRules::GetAvailableProfiles()) + ->check(GameProfileValid); + // Compression settings overrides + add->add_option("--flags", fileDwFlags, "Override MPQ file flags")->group("Game setting overrides"); + add->add_option("--compression", fileDwCompression, "Override compression for first sector")->group("Game setting overrides"); + add->add_option("--compression-next", fileDwCompressionNext, "Override compression for subsequent sectors")->group("Game setting overrides"); // Subcommand: Remove CLI::App *remove = app.add_subcommand("remove", "Remove file from an existing MPQ archive"); @@ -208,16 +238,62 @@ int main(int argc, char **argv) { } std::string outputFile = outputFilePath.u8string(); - std::cout << "[*] Output file: " << outputFile << std::endl; + GameProfile profile; + if (baseGameProfile != "default") { + profile = GameRules::StringToProfile(baseGameProfile); + } else { + profile = GameRules::GetDefaultProfile(); + } + GameRules gameRules(profile); + + std::cout << "[*] Game profile: " << baseGameProfile << ", Output file: " << outputFile << std::endl; + + if (createMpqVersion > 0) { + createMpqVersion--; // We label versions 1-4, but StormLib uses 0-3 + } + // Apply MpqCreateSettings overrides if provided + MpqCreateSettingsOverrides overrides; + if (createMpqVersion >= 0) { + overrides.mpqVersion = static_cast(createMpqVersion); + } + if (createStreamFlags >= 0) { + overrides.streamFlags = static_cast(createStreamFlags); + } + if (createFileFlags1 >= 0) { + overrides.fileFlags1 = static_cast(createFileFlags1); + } + if (createFileFlags2 >= 0) { + overrides.fileFlags2 = static_cast(createFileFlags2); + } + if (createFileFlags3 >= 0) { + overrides.fileFlags3 = static_cast(createFileFlags3); + } + if (createAttrFlags >= 0) { + overrides.attrFlags = static_cast(createAttrFlags); + } + if (createSectorSize >= 0) { + overrides.sectorSize = static_cast(createSectorSize); + } + if (createRawChunkSize >= 0) { + overrides.rawChunkSize = static_cast(createRawChunkSize); + } + gameRules.OverrideCreateSettings(overrides); // Determine the number of files we are going to add int32_t fileCount = CalculateMpqMaxFileValue(baseTarget); // Create the MPQ archive and add files - HANDLE hArchive = CreateMpqArchive(outputFile, fileCount, createMpqVersion); + HANDLE hArchive = CreateMpqArchive(outputFile, fileCount, gameRules); if (hArchive) { LCID locale = LangToLocale(baseLocale); - AddFiles(hArchive, baseTarget, locale); + + // Apply AddFileSettings overrides if provided + CompressionSettingsOverrides addOverrides; + if (fileDwFlags >= 0) addOverrides.dwFlags = static_cast(fileDwFlags); + if (fileDwCompression >= 0) addOverrides.dwCompression = static_cast(fileDwCompression); + if (fileDwCompressionNext >= 0) addOverrides.dwCompressionNext = static_cast(fileDwCompressionNext); + + AddFiles(hArchive, baseTarget, locale, gameRules, addOverrides); if (createSignArchive) { SignMpqArchive(hArchive); @@ -254,7 +330,22 @@ int main(int argc, char **argv) { LCID locale = LangToLocale(baseLocale); - AddFile(hArchive, baseFile, archivePath, locale); + GameProfile profile; + if (baseGameProfile != "default") { + profile = GameRules::StringToProfile(baseGameProfile); + std::cout << "[*] Using game profile: " << baseGameProfile << std::endl; + } else { + profile = GameRules::GetDefaultProfile(); + } + GameRules gameRules(profile); + + // Apply AddFileSettings overrides if provided + CompressionSettingsOverrides addOverrides; + if (fileDwFlags >= 0) addOverrides.dwFlags = static_cast(fileDwFlags); + if (fileDwCompression >= 0) addOverrides.dwCompression = static_cast(fileDwCompression); + if (fileDwCompressionNext >= 0) addOverrides.dwCompressionNext = static_cast(fileDwCompressionNext); + + AddFile(hArchive, baseFile, archivePath, locale, gameRules, addOverrides); CloseMpqArchive(hArchive); } diff --git a/src/mpq.cpp b/src/mpq.cpp index 287787d..088b35e 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -11,6 +11,7 @@ #include "mpq.h" #include "helpers.h" #include "locales.h" +#include "gamerules.h" namespace fs = std::filesystem; @@ -114,35 +115,38 @@ int ExtractFile(HANDLE hArchive, const std::string& output, const std::string& f return 0; } -HANDLE CreateMpqArchive(std::string outputArchiveName, int32_t fileCount, int32_t mpqVersion) { +HANDLE CreateMpqArchive( + const std::string &outputArchiveName, + const int32_t fileCount, + const GameRules& gameRules +) { // Check if file already exists if (fs::exists(outputArchiveName)) { std::cerr << "[!] File already exists: " << outputArchiveName << " Exiting..." << std::endl; return NULL; } - // Configure flags for MPQ file, this includes: - // If we store attributes such as filetime - // The MPQ archive version - int32_t dwCreateFlags = 0; - - if (mpqVersion == 1) { - dwCreateFlags += MPQ_CREATE_ARCHIVE_V1; - } else if (mpqVersion == 2) { - dwCreateFlags += MPQ_CREATE_ARCHIVE_V2; - } else { - dwCreateFlags += MPQ_CREATE_ARCHIVE_V1; - }; - - // Always include attributes - // This is needed for filetime, locale, CRC32 and MD5 - dwCreateFlags += MPQ_CREATE_ATTRIBUTES; - HANDLE hMpq; - bool result = SFileCreateArchive( + + // Use game-specific create settings + const MpqCreateSettings& settings = gameRules.GetCreateSettings(); + + SFILE_CREATE_MPQ createInfo = {}; + // All logic for defaults and dependencies is handled in GameRules::OverrideCreateSettings + createInfo.cbSize = sizeof(SFILE_CREATE_MPQ); + createInfo.dwMpqVersion = settings.mpqVersion; + createInfo.dwStreamFlags = settings.streamFlags; + createInfo.dwFileFlags1 = settings.fileFlags1; + createInfo.dwFileFlags2 = settings.fileFlags2; + createInfo.dwFileFlags3 = settings.fileFlags3; + createInfo.dwAttrFlags = settings.attrFlags; + createInfo.dwSectorSize = settings.sectorSize; + createInfo.dwRawChunkSize = settings.rawChunkSize; + createInfo.dwMaxFileCount = fileCount; + + const bool result = SFileCreateArchive2( outputArchiveName.c_str(), - dwCreateFlags, - fileCount, + &createInfo, &hMpq ); @@ -156,12 +160,12 @@ HANDLE CreateMpqArchive(std::string outputArchiveName, int32_t fileCount, int32_ return hMpq; } -int AddFiles(HANDLE hArchive, const std::string& target, LCID locale) { +int AddFiles(HANDLE hArchive, const std::string& inputPath, LCID locale, const GameRules& gameRules, const CompressionSettingsOverrides& overrides) { // We need to "clean" the target path to ensure it is a valid directory // and to strip any directory structure from the files we add - fs::path targetPath = fs::path(target); + fs::path targetPath = fs::path(inputPath); - for (const auto &entry : fs::recursive_directory_iterator(target)) { + for (const auto &entry : fs::recursive_directory_iterator(inputPath)) { if (fs::is_regular_file(entry.path())) { // Strip the target path from the file name fs::path inputFilePath = fs::relative(entry, targetPath); @@ -169,13 +173,21 @@ int AddFiles(HANDLE hArchive, const std::string& target, LCID locale) { // Normalise path for MPQ std::string archiveFilePath = WindowsifyFilePath(inputFilePath.u8string()); - AddFile(hArchive, entry.path().u8string(), archiveFilePath, locale); + AddFile(hArchive, entry.path().u8string(), archiveFilePath, locale, gameRules, overrides); } } return 0; } -int AddFile(HANDLE hArchive, const fs::path& localFile, const std::string& archiveFilePath, LCID locale) { +int AddFile( + HANDLE hArchive, + const fs::path& localFile, + const std::string& archiveFilePath, + const LCID locale, + const GameRules& gameRules, + const CompressionSettingsOverrides& overrides +) { + // Return if file doesn't exist on disk if (!fs::exists(localFile)) { std::cerr << "[!] File doesn't exist on disk: " << localFile << std::endl; @@ -210,9 +222,17 @@ int AddFile(HANDLE hArchive, const fs::path& localFile, const std::string& archi } } - // Set file attributes in the MPQ archive (compression and encryption) - DWORD dwFlags = MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED; - DWORD dwCompression = MPQ_COMPRESSION_ZLIB; + // Get file size for rule matching + const auto fileSize = static_cast(fs::file_size(localFile)); + + // Get game-specific rules + auto [flags, compressionFirst, compressionNext] = + gameRules.GetCompressionSettings(archiveFilePath, fileSize); + + // Apply overrides where specified, otherwise use game rules + DWORD dwFlags = overrides.dwFlags.value_or(flags); + DWORD dwCompression = overrides.dwCompression.value_or(compressionFirst); + DWORD dwCompressionNext = overrides.dwCompressionNext.value_or(compressionNext); bool addedFile = SFileAddFileEx( hArchive, @@ -220,7 +240,7 @@ int AddFile(HANDLE hArchive, const fs::path& localFile, const std::string& archi archiveFilePath.c_str(), dwFlags, dwCompression, - MPQ_COMPRESSION_NEXT_SAME + dwCompressionNext ); if (!addedFile) { diff --git a/src/mpq.h b/src/mpq.h index 02bb1b3..32e9cbf 100644 --- a/src/mpq.h +++ b/src/mpq.h @@ -5,6 +5,7 @@ #include #include +#include "gamerules.h" namespace fs = std::filesystem; @@ -13,9 +14,9 @@ int CloseMpqArchive(HANDLE hArchive); int SignMpqArchive(HANDLE hArchive); int ExtractFiles(HANDLE hArchive, const std::string& output, const std::string &listfileName, LCID preferredLocale); int ExtractFile(HANDLE hArchive, const std::string& output, const std::string& fileName, bool keepFolderStructure, LCID preferredLocale); -HANDLE CreateMpqArchive(std::string outputArchiveName, int32_t fileCount, int32_t mpqVersion); -int AddFiles(HANDLE hArchive, const std::string& inputPath, LCID locale); -int AddFile(HANDLE hArchive, const fs::path& localFile, const std::string& archiveFilePath, LCID locale); +HANDLE CreateMpqArchive(const std::string &outputArchiveName, int32_t fileCount, const GameRules& gameRules); +int AddFiles(HANDLE hArchive, const std::string& inputPath, LCID locale, const GameRules& gameRules, const CompressionSettingsOverrides& overrides = CompressionSettingsOverrides()); +int AddFile(HANDLE hArchive, const fs::path& localFile, const std::string& archiveFilePath, LCID locale, const GameRules& gameRules, const CompressionSettingsOverrides& overrides = CompressionSettingsOverrides()); int RemoveFile(HANDLE hArchive, const std::string& archiveFilePath, LCID locale); int ListFiles(HANDLE hHandle, const std::string &listfileName, bool listAll, bool listDetailed, std::vector& propertiesToPrint); char* ReadFile(HANDLE hArchive, const char *szFileName, unsigned int *fileSize, LCID preferredLocale); diff --git a/test/conftest.py b/test/conftest.py index e68dc47..d84a62f 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -90,7 +90,7 @@ def generate_locales_mpq_test_files(binary_path): if locale == "": # Default locale - create a new MPQ file result = subprocess.run( - [str(binary_path), "create", "-v", "1", "-o", str(mpq_many_locales_file_name), str(locales_files_dir)], + [str(binary_path), "create", "--version", "1", "-o", str(mpq_many_locales_file_name), str(locales_files_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -112,7 +112,7 @@ def generate_locales_mpq_test_files(binary_path): file_path.write_text(content, newline="\n") result = subprocess.run( - [str(binary_path), "create", "-v", "1", "-o", str(mpq_one_locale_file_name), str(locales_files_dir), "--locale", locale], + [str(binary_path), "create", "--version", "1", "-o", str(mpq_one_locale_file_name), str(locales_files_dir), "--locale", locale], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True diff --git a/test/test_add.py b/test/test_add.py index 20ad165..8a7ef06 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -186,6 +186,167 @@ def test_create_mpq_with_locale(binary_path, generate_test_files): verify_archive_file_content(binary_path, target_file, expected_content) +def test_add_file_with_game_profile(binary_path, generate_test_files): + """ + Test adding a file to an MPQ archive with different game profiles. + + This test checks: + - If files can be added with various game profiles. + - If the game profile is accepted and applied. + - If the correct compression flags are applied to added files. + """ + _ = generate_test_files + script_dir = Path(__file__).parent + target_file = script_dir / "data" / "files.mpq" + + # Test profiles with their expected flags for .txt files + test_cases = [ + ("generic", "ce"), # Generic: compressed + encrypted + ("diablo1", "ie"), # Diablo1: imploded + encrypted + ("starcraft1", "ce2"), # StarCraft: compressed + encrypted + key v2 + ("wow1", "c"), # WoW 1.x: compressed + ("wow2", "cr"), # WoW 2.x: compressed + sector CRC + ("starcraft2", "c"), # StarCraft2: compressed (small files use single unit) + ("diablo3", "c"), # Diablo3: compressed + ] + + for profile, expected_flags in test_cases: + # Create a fresh MPQ archive for each test + create_mpq_archive_for_test(binary_path, script_dir) + + # Create a test file + test_file = script_dir / "data" / f"test_{profile}.txt" + test_file.write_text(f"Test file for {profile} profile.") + + result = subprocess.run( + [str(binary_path), "add", str(test_file), str(target_file), "--game", profile], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" + assert f"Using game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" + assert f"Adding file for locale 0: test_{profile}.txt" in result.stdout + + # Verify compression flags on the added file + list_result = subprocess.run( + [str(binary_path), "list", str(target_file), "-d", "-p", "flags"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert list_result.returncode == 0, f"Failed to list files for {profile}" + + # Check that the added file has the expected flags + found_test_file = False + for line in list_result.stdout.splitlines(): + if f"test_{profile}.txt" in line: + found_test_file = True + flags = line.split()[0] # Extract flags + # Check that expected flags are present in the actual flags + for flag in expected_flags: + assert flag in flags, f"Profile {profile}: expected flag '{flag}' in '{flags}' for added file" + + assert found_test_file, f"Profile {profile}: added file not found in archive" + + +def test_add_file_with_invalid_game_profile(binary_path, generate_test_files): + """ + Test adding a file with an invalid game profile. + + This test checks: + - If the application exits correctly when an invalid game profile is provided. + """ + _ = generate_test_files + script_dir = Path(__file__).parent + target_file = script_dir / "data" / "files.mpq" + + # Create a fresh MPQ archive + create_mpq_archive_for_test(binary_path, script_dir) + + # Create a test file + test_file = script_dir / "data" / "test_invalid.txt" + test_file.write_text("Test file for invalid profile.") + + result = subprocess.run( + [str(binary_path), "add", str(test_file), str(target_file), "-g", "invalid_profile"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode != 0, "mpqcli should have failed with invalid game profile" + + +def test_add_file_with_all_game_profiles(binary_path, generate_test_files): + """ + Test adding files with all available game profiles. + + This test checks: + - If all game profiles work with the add command. + - If files are actually added to the archive with compression applied. + """ + _ = generate_test_files + script_dir = Path(__file__).parent + target_file = script_dir / "data" / "files.mpq" + + # All profiles should be accepted + all_profiles = [ + "generic", "diablo1", "lordsofmagic", "starcraft1", "warcraft2", "diablo2", + "warcraft3", "warcraft3-map", "wow1", "wow2", "wow3", "wow4", "wow5", + "starcraft2", "diablo3" + ] + + for profile in all_profiles: + # Create a fresh MPQ archive for each test + create_mpq_archive_for_test(binary_path, script_dir) + + # Create a test file + test_file = script_dir / "data" / f"test_all_{profile}.txt" + test_file.write_text(f"Test file for {profile} profile.") + + result = subprocess.run( + [str(binary_path), "add", str(test_file), str(target_file), "-g", profile], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" + assert f"Using game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" + + # Verify the file was actually added + list_result = subprocess.run( + [str(binary_path), "list", str(target_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert list_result.returncode == 0, f"Failed to list files for {profile}" + assert f"test_all_{profile}.txt" in list_result.stdout, f"Profile {profile}: added file not found in archive" + + # Verify that some compression flag is set (at least 'c' for compressed or 'i' for imploded) + flags_result = subprocess.run( + [str(binary_path), "list", str(target_file), "-d", "-p", "flags"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert flags_result.returncode == 0, f"Failed to get flags for {profile}" + + found_with_compression = False + for line in flags_result.stdout.splitlines(): + if f"test_all_{profile}.txt" in line: + flags = line.split()[0] + # Check that either compressed or imploded flag is present + if 'c' in flags or 'i' in flags: + found_with_compression = True + break + + assert found_with_compression, f"Profile {profile}: no compression flag found on added file" + + def create_mpq_archive_for_test(binary_path, script_dir): target_dir = script_dir / "data" / "files" target_file = target_dir.with_suffix(".mpq") @@ -194,7 +355,7 @@ def create_mpq_archive_for_test(binary_path, script_dir): # test_create_mpq_already_exists target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "-v", "1", str(target_dir)], + [str(binary_path), "create", "--version", "1", str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True diff --git a/test/test_create.py b/test/test_create.py index a8fe6bf..02caef8 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -1,4 +1,5 @@ import subprocess +import shutil from pathlib import Path @@ -14,7 +15,7 @@ def test_create_mpq_target_does_not_exist(binary_path, generate_test_files): target_dir = script_dir / "does" / "not" / "exist" result = subprocess.run( - [str(binary_path), "create", "-v", "1", str(target_dir)], + [str(binary_path), "create", "--version", "1", str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -43,7 +44,7 @@ def test_create_mpq_versions(binary_path, generate_test_files): # test_create_mpq_already_exists target_file.unlink(missing_ok=True) result = subprocess.run( - [str(binary_path), "create", "-v", str(version), str(target_dir)], + [str(binary_path), "create", "--version", str(version), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -75,7 +76,19 @@ def test_create_mpq_with_output(binary_path, generate_test_files): output_file.unlink() result = subprocess.run( - [str(binary_path), "create", "-v", str(version), "-o", str(output_file), str(target_dir)], + [ + str(binary_path), "create", + "--version", str(version), + "--file-flags1", "4294967295", + "--file-flags2", "4294967295", + "--file-flags3", "0", + "--attr-flags", "15", + "--flags", "66048", + "--compression", "2", + "--compression-next", "4294967295", + "-o", str(output_file), + str(target_dir), + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -107,7 +120,18 @@ def test_create_mpq_with_weak_signature(binary_path, generate_test_files): output_file.unlink() result = subprocess.run( - [str(binary_path), "create", "-s", "-o", str(output_file), str(target_dir)], + [ + str(binary_path), "create", "-s", + "--file-flags1", "4294967295", + "--file-flags2", "4294967295", + "--file-flags3", "0", + "--attr-flags", "15", + "--flags", "512", + "--compression", "2", + "--compression-next", "4294967295", + "-o", str(output_file), + str(target_dir), + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -132,7 +156,7 @@ def test_create_mpq_already_exists(binary_path, generate_test_files): output_file = script_dir / "data" / "mpq_with_output_v1.mpq" result = subprocess.run( - [str(binary_path), "create", "-v", "1", "-o", str(output_file), str(target_dir)], + [str(binary_path), "create", "--version", "1", "-o", str(output_file), str(target_dir)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -155,7 +179,7 @@ def test_create_mpq_with_illegal_locale(binary_path, generate_test_files): output_file = script_dir / "data" / "new_mpq.mpq" result = subprocess.run( - [str(binary_path), "create", "-v", "1", "-o", str(output_file), str(target_dir), "--locale", "illegal_locale"], + [str(binary_path), "create", "--version", "1", "-o", str(output_file), str(target_dir), "--locale", "illegal_locale"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True @@ -181,6 +205,245 @@ def test_create_mpq_with_locale(binary_path, generate_locales_mpq_test_files): verify_archive_file_content(binary_path, output_file, {"esES cats.txt"}) +def test_create_mpq_with_game_profile(binary_path, generate_test_files): + """ + Test MPQ archive creation with different game profiles. + + This test checks: + - If the MPQ archive is created with various game profiles. + - If the game profile is accepted and applied. + - If the correct MPQ version is used for each profile. + - If the correct compression flags are applied. + """ + _ = generate_test_files + script_dir = Path(__file__).parent + target_dir = script_dir / "data" / "files" + + # Test profiles with their expected MPQ versions and expected flags for .txt files + test_cases = [ + ("generic", "1", "ce"), # Generic: version 1, compressed + encrypted + ("diablo1", "1", "ie"), # Diablo1: version 1, imploded + encrypted + ("starcraft1", "1", "ce2"), # StarCraft: version 1, compressed + encrypted + key v2 + ("warcraft3", "1", "ce2"), # Warcraft3: version 1, compressed + encrypted + key v2 + ("diablo2", "1", "c"), # Diablo2: version 1, compressed (txt files not encrypted) + ("wow1", "1", "c"), # WoW 1.x: version 1, compressed + ("wow2", "2", "cr"), # WoW 2.x: version 2, compressed + sector CRC + ("starcraft2", "2", "c"), # StarCraft2: version 2, compressed (small files use single unit) + ("diablo3", "4", "c"), # Diablo3: version 4, compressed + ] + + for profile, expected_version, expected_flags in test_cases: + output_file = script_dir / "data" / f"mpq_with_profile_{profile}.mpq" + output_file.unlink(missing_ok=True) + + result = subprocess.run( + [str(binary_path), "create", "-g", profile, "-o", str(output_file), str(target_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error for profile {profile}: {result.stderr}" + assert output_file.exists(), f"MPQ file was not created for profile {profile}" + assert output_file.stat().st_size > 0, f"MPQ file is empty for profile {profile}" + assert f"[*] Game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" + + # Verify the MPQ version + version_result = subprocess.run( + [str(binary_path), "info", "-p", "format-version", str(output_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert version_result.returncode == 0, f"Failed to get version for {profile}" + actual_version = version_result.stdout.strip() + assert actual_version == expected_version, f"Profile {profile}: expected version {expected_version}, got {actual_version}" + + # Verify compression flags on a .txt file + list_result = subprocess.run( + [str(binary_path), "list", str(output_file), "-d", "-p", "flags"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert list_result.returncode == 0, f"Failed to list files for {profile}" + + # Check that at least one .txt file has the expected flags + found_txt_file = False + for line in list_result.stdout.splitlines(): + if "cats.txt" in line or "dogs.txt" in line: + found_txt_file = True + flags = line.split()[0] # Extract flags + # Check that expected flags are present in the actual flags + for flag in expected_flags: + assert flag in flags, f"Profile {profile}: expected flag '{flag}' in '{flags}' for .txt file" + + assert found_txt_file, f"Profile {profile}: no .txt file found in archive" + + +def test_create_mpq_with_invalid_game_profile(binary_path, generate_test_files): + """ + Test MPQ archive creation with an invalid game profile. + + This test checks: + - If the application exits correctly when an invalid game profile is provided. + """ + _ = generate_test_files + script_dir = Path(__file__).parent + target_dir = script_dir / "data" / "files" + output_file = script_dir / "data" / "mpq_with_invalid_profile.mpq" + + result = subprocess.run( + [str(binary_path), "create", "-g", "invalid_profile", "-o", str(output_file), str(target_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode != 0, "mpqcli should have failed with invalid game profile" + assert not output_file.exists(), "MPQ file should not be created with invalid profile" + + +def test_create_mpq_with_all_game_profiles(binary_path, generate_test_files): + """ + Test MPQ archive creation with all available game profiles. + + This test checks: + - If all game profiles are valid. + - If the MPQ archive is created successfully for each profile. + - If the correct MPQ version is used for each profile. + """ + _ = generate_test_files + script_dir = Path(__file__).parent + target_dir = script_dir / "data" / "files" + + # All profiles with their expected MPQ versions + profile_versions = { + "generic": "1", + "diablo1": "1", + "lordsofmagic": "1", + "starcraft1": "1", + "warcraft2": "1", + "diablo2": "1", + "warcraft3": "1", + "warcraft3-map": "1", + "wow1": "1", + "wow2": "2", + "wow3": "2", + "wow4": "4", + "wow5": "4", + "starcraft2": "2", + "diablo3": "4" + } + + for profile, expected_version in profile_versions.items(): + output_file = script_dir / "data" / f"mpq_all_profiles_{profile}.mpq" + output_file.unlink(missing_ok=True) + + result = subprocess.run( + [str(binary_path), "create", "-g", profile, "-o", str(output_file), str(target_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" + assert output_file.exists(), f"MPQ file was not created for profile {profile}" + assert f"[*] Game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" + + # Verify the MPQ version + version_result = subprocess.run( + [str(binary_path), "info", "-p", "format-version", str(output_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert version_result.returncode == 0, f"Failed to get version for {profile}" + actual_version = version_result.stdout.strip() + assert actual_version == expected_version, f"Profile {profile}: expected version {expected_version}, got {actual_version}" + + +def test_deletion_marker_only_for_zero_size_files(binary_path): + """ + Test that deletion marker is only applied to files with size 0, not all small files. + """ + script_dir = Path(__file__).parent + data_dir = script_dir / "data" + test_dir = data_dir / "deletion_marker_test" + output_file = data_dir / "test_deletion_marker.mpq" + + # Clean up from previous runs + output_file.unlink(missing_ok=True) + if test_dir.exists(): + shutil.rmtree(test_dir) + + # Create test directory with files + test_dir.mkdir(exist_ok=True) + + # Create test files: one with size 0, one with non-zero size + zero_size_file = test_dir / "zero_size.txt" + nonzero_size_file = test_dir / "nonzero_size.txt" + + # Create a truly empty file (0 bytes) + zero_size_file.touch() + nonzero_size_file.write_text("Hello") # 5 bytes + + # Verify file sizes + assert zero_size_file.stat().st_size == 0, f"zero_size.txt should be 0 bytes, got {zero_size_file.stat().st_size}" + assert nonzero_size_file.stat().st_size == 5, f"nonzero_size.txt should be 5 bytes, got {nonzero_size_file.stat().st_size}" + + try: + # Create MPQ with StarCraft2 profile (which has deletion marker rule for size 0) + result = subprocess.run( + [str(binary_path), "create", "-g", "starcraft2", "-o", str(output_file), str(test_dir)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"Failed to create MPQ: {result.stderr}\nStdout: {result.stdout}" + + # Check flags for both files + list_result = subprocess.run( + [str(binary_path), "list", str(output_file), "-d", "-p", "flags"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert list_result.returncode == 0, f"Failed to list files: {list_result.stderr}" + + # Parse the output + zero_size_flags = None + nonzero_size_flags = None + + for line in list_result.stdout.splitlines(): + parts = line.split() + if len(parts) >= 2: + flags = parts[0] + filename = parts[1] + + if filename == "zero_size.txt": + zero_size_flags = flags + elif filename == "nonzero_size.txt": + nonzero_size_flags = flags + + # Verify that zero-size file has deletion marker + assert zero_size_flags is not None, "zero_size.txt not found in archive" + assert 'd' in zero_size_flags, f"Expected deletion marker 'd' in flags for zero_size.txt, got: {zero_size_flags}" + + # Verify that non-zero size file does NOT have deletion marker but has compression + assert nonzero_size_flags is not None, "nonzero_size.txt not found in archive" + assert 'd' not in nonzero_size_flags, f"Unexpected deletion marker 'd' in flags for nonzero_size.txt, got: {nonzero_size_flags}" + assert 'c' in nonzero_size_flags, f"Expected compression 'c' in flags for nonzero_size.txt, got: {nonzero_size_flags}" + + finally: + # Clean up + if test_dir.exists(): + shutil.rmtree(test_dir) + output_file.unlink(missing_ok=True) + + def verify_archive_file_content(binary_path, test_file, expected_output): result = subprocess.run( [str(binary_path), "list", str(test_file), "-d", "-p", "locale"], diff --git a/test/test_extract.py b/test/test_extract.py index 409c114..3f6d457 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -149,15 +149,12 @@ def test_extract_file_from_mpq_output_directory_specified(binary_path, generate_ # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} - # Create output_file path without suffix (default extract behavior is MPQ without extension) - output_file = output_dir.with_suffix("") - # Create output_files set based on directory contents (not full path) - output_files = set(fi.name for fi in output_file.glob("*")) + output_files = set(fi.name for fi in output_dir.glob("*")) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" assert output_lines == expected_lines, f"Unexpected output: {output_lines}" - assert output_file.exists(), "Output directory was not created" + assert output_dir.exists(), "Output directory was not created" assert output_files == expected_output, f"Unexpected files: {output_files}" diff --git a/test/test_info.py b/test/test_info.py index 9bed4b9..e3bc78a 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -11,7 +11,7 @@ def test_info_v1(binary_path): "Format version: 1", "Header offset: 0", "Header size: 32", - "Archive size: 1380", + "Archive size: 1381", "File count: 5", "Max files: 64", "Signature type: None", @@ -43,7 +43,7 @@ def test_info_v2(binary_path): "Format version: 2", "Header offset: 0", "Header size: 44", - "Archive size: 1376", + "Archive size: 1377", "File count: 5", "Max files: 64", "Signature type: None", @@ -51,8 +51,8 @@ def test_info_v2(binary_path): # Adjust the expected output for Windows due to different line endings if platform.system() == "Windows": - expected_output.remove("Archive size: 1376") - expected_output.add("Archive size: 1378") + expected_output.remove("Archive size: 1377") + expected_output.add("Archive size: 1379") result = subprocess.run( [str(binary_path), "info", str(test_file)], @@ -75,7 +75,7 @@ def test_info_v1_properties(binary_path): ("format-version", "1"), ("header-offset", "0"), ("header-size", "32"), - ("archive-size", "1380"), + ("archive-size", "1381"), ("file-count", "5"), ("signature-type", "None"), ] @@ -83,7 +83,7 @@ def test_info_v1_properties(binary_path): # Adjust archive-size for Windows if platform.system() == "Windows": test_cases = [ - (k, "1382") if k == "archive-size" else (k, v) + (k, "1383") if k == "archive-size" else (k, v) for (k, v) in test_cases ] @@ -107,7 +107,7 @@ def test_info_v2_properties(binary_path): ("format-version", "2"), ("header-offset", "0"), ("header-size", "44"), - ("archive-size", "1376"), + ("archive-size", "1377"), ("file-count", "5"), ("signature-type", "None"), ] @@ -115,7 +115,7 @@ def test_info_v2_properties(binary_path): # Adjust archive-size for Windows if platform.system() == "Windows": test_cases = [ - (k, "1378") if k == "archive-size" else (k, v) + (k, "1379") if k == "archive-size" else (k, v) for (k, v) in test_cases ] diff --git a/test/test_list.py b/test/test_list.py index 49dfa4b..5ea85ad 100644 --- a/test/test_list.py +++ b/test/test_list.py @@ -44,7 +44,7 @@ def test_list_mpq_with_standard_details(binary_path): expected_output = { " 27 enUS (listfile)", - " 148 enUS (attributes)", + " 149 enUS (attributes)", " 27 enUS 2025-07-29 14:31:00 dogs.txt", " 8 enUS 2025-07-29 14:31:00 bytes", " 27 enUS 2025-07-29 14:31:00 cats.txt", @@ -85,7 +85,7 @@ def test_list_mpq_with_specified_details(binary_path): " 0 eb30456b 48345fbb 0000000000000000 35 cexmn a073c614 dogs.txt", " 35 147178ed c99b9ee2 0000000000000000 16 cexmn eaa753f9 bytes", " 25 fd657910 4e9b98a7 0000000000000000 35 ce2xmnf 2d2f0a94 (listfile)", - " 14 d38437cb 07dfeaec 0000000000000000 123 ce2xmnf 50e314af (attributes)", + " 14 d38437cb 07dfeaec 0000000000000000 124 ce2xmnf 50e314af (attributes)", } # Adjust filesize for Windows if platform.system() == "Windows": From e031836baf5c2dccc9143634a9a169ae4bf976b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Sj=C3=B6blom?= Date: Fri, 9 Jan 2026 19:17:01 +0100 Subject: [PATCH 2/2] Can now list unfamiliar locales; Better printing of locales; Correcting locale --- README.md | 12 +++---- src/locales.cpp | 61 ++++++++++++++++++++++++++++++--- src/locales.h | 11 ++++-- src/mpq.cpp | 10 +++--- test/conftest.py | 53 +++++++++++++++++++++++++++-- test/test_add.py | 6 ++-- test/test_info.py | 4 +-- test/test_list.py | 82 +++++++++++++++++++++++++++++++++------------ test/test_remove.py | 9 +++-- 9 files changed, 197 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index a9ce11e..9814e1d 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Add a local file to an already existing MPQ archive. ``` $ echo "For The Horde" > fth.txt $ mpqcli add fth.txt wow-patch.mpq -[+] Adding file for locale 0: fth.txt +[+] Adding file: fth.txt ``` Alternatively, you can add a file to a specific subdirectory using the `-p` or `--path` argument. @@ -165,7 +165,7 @@ Alternatively, you can add a file to a specific subdirectory using the `-p` or ` ``` $ echo "For The Alliance" > fta.txt $ mpqcli add fta.txt wow-patch.mpq --path texts -[+] Adding file for locale 0: texts\fta.txt +[+] Adding file: texts\fta.txt ``` ### Add files to an MPQ archive with a given locale @@ -174,7 +174,7 @@ Use the `--locale` argument to specify the locale that the added file will have ``` $ mpqcli add allianz.txt --locale deDE -[+] Adding file for locale 1031: allianz.txt +[+] Adding file for locale deDE: allianz.txt ``` ### Add a file with game-specific properties @@ -183,7 +183,7 @@ Target a specific game version by using the `-g` or `--game` argument. This will ``` $ mpqcli add khwhat1.wav archive.mpq --game wc2 # In StarCraft and WarCraft II MPQs, wav files are compressed in ADPCM form -[+] Adding file for locale 0: khwhat1.wav +[+] Adding file: khwhat1.wav ``` @@ -193,7 +193,7 @@ Remove a file from an existing MPQ archive. ``` $ mpqcli remove fth.txt wow-patch.mpq -[-] Removing file for locale 0: fth.txt +[-] Removing file: fth.txt ``` ### Remove a file from an MPQ archive with a given locale @@ -202,7 +202,7 @@ Use the `--locale` argument to specify the locale of the file to be removed. ``` $ mpqcli remove alianza.txt wow-patch.mpq --locale esES -[-] Removing file for locale 1034: alianza.txt +[-] Removing file for locale esES: alianza.txt ``` diff --git a/src/locales.cpp b/src/locales.cpp index ba4895e..5071b28 100644 --- a/src/locales.cpp +++ b/src/locales.cpp @@ -2,6 +2,8 @@ #include #include #include +#include +#include #include "locales.h" @@ -28,14 +30,15 @@ namespace { {0x412, "koKR"}, // Korean {0x413, "nlNL"}, // Dutch {0x415, "plPL"}, // Polish - {0x416, "ptPT"}, // Portuguese (Portugal) + {0x416, "ptBR"}, // Portuguese (Brazil) {0x419, "ruRU"}, // Russian {0x804, "zhCN"}, // Chinese (Simplified) {0x809, "enGB"}, // English (UK) - {0x80A, "esMX"} // Spanish (Mexico) + {0x80A, "esMX"}, // Spanish (Mexico) + {0x816, "ptPT"}, // Portuguese (Portugal) }; - // Create a reverse map for language to locale lookups + // Create a reverse map for language-to-locale lookups const std::map langToLocaleMap = []() { std::map reverseMap; for (const auto& [locale, lang] : localeToLangMap) { @@ -45,16 +48,56 @@ namespace { } return reverseMap; }(); + + std::string FormatLocaleAsHex(const LCID locale) { + std::stringstream ss; + ss << std::hex << std::uppercase << locale; + const std::string hexStr = ss.str(); + // Prepend 0s if needed + return std::string(4 - hexStr.length(), '0') + hexStr; + } +} + +// Check if a string is a 4-character hexadecimal number and parse it +// Returns the parsed LCID if valid, otherwise returns defaultLocale (0) +LCID ParseHexLocale(const std::string& str) { + if (str.length() != 4) { + return defaultLocale; + } + + // Check if all characters are hexadecimal + for (char c : str) { + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) { + return defaultLocale; + } + } + + // Parse the hexadecimal string + std::stringstream ss; + ss << std::hex << str; + LCID locale; + ss >> locale; + return locale; } std::string LocaleToLang(uint16_t locale) { auto it = localeToLangMap.find(locale); - return it != localeToLangMap.end() ? it->second : ""; + return it != localeToLangMap.end() ? it->second : FormatLocaleAsHex(locale); } LCID LangToLocale(const std::string& lang) { auto it = langToLocaleMap.find(lang); - return it != langToLocaleMap.end() ? it->second : defaultLocale; + if (it != langToLocaleMap.end()) { + return it->second; + } + + // Try parsing as a hexadecimal LCID + LCID hexLocale = ParseHexLocale(lang); + if (hexLocale != defaultLocale) { + return hexLocale; + } + + return defaultLocale; } @@ -69,3 +112,11 @@ std::vector GetAllLocales() { std::sort(locales.begin(), locales.end()); return locales; } + +std::string PrettyPrintLocale(const LCID locale, const std::string &prefix, bool alwaysPrint) { + if (locale == defaultLocale && !alwaysPrint) { + return ""; + } + const auto lang = LocaleToLang(locale); + return prefix + lang; +} diff --git a/src/locales.h b/src/locales.h index 393c320..9c5f180 100644 --- a/src/locales.h +++ b/src/locales.h @@ -11,14 +11,21 @@ const LCID defaultLocale = 0; std::string LocaleToLang(uint16_t locale); LCID LangToLocale(const std::string &lang); +LCID ParseHexLocale(const std::string& str); std::vector GetAllLocales(); +std::string PrettyPrintLocale(LCID locale, const std::string &prefix = "", bool alwaysPrint = false); // Validator for CLI11 const inline auto LocaleValid = CLI::Validator( [](const std::string &str) { if (str == "default") return std::string(); - LCID locale = LangToLocale(str); + // Check if it's a 4-character hexadecimal string + if (ParseHexLocale(str) != defaultLocale) { + return std::string(); + } + + const LCID locale = LangToLocale(str); if (locale == 0) { std::string validLocales = "Locale must be nothing, or one of:"; for (const auto& l : GetAllLocales()) { @@ -28,7 +35,7 @@ const inline auto LocaleValid = CLI::Validator( } return std::string(); }, - "Validates locales and outputs valid locales", + "", "LocaleValidator" ); diff --git a/src/mpq.cpp b/src/mpq.cpp index 088b35e..310db6b 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -200,13 +200,13 @@ int AddFile( if (SFileOpenFileEx(hArchive, archiveFilePath.c_str(), SFILE_OPEN_FROM_MPQ, &hFile)) { int32_t fileLocale = GetFileInfo(hFile, SFileInfoLocale); if (fileLocale == locale) { - std::cerr << "[!] File for locale " << locale << " already exists in MPQ archive: " << archiveFilePath + std::cerr << "[!] File" << PrettyPrintLocale(locale, " for locale ") << " already exists in MPQ archive: " << archiveFilePath << " - Skipping..." << std::endl; return -1; } } SFileCloseFile(hFile); - std::cout << "[+] Adding file for locale " << locale << ": " << archiveFilePath << std::endl; + std::cout << "[+] Adding file" << PrettyPrintLocale(locale, " for locale ") << ": " << archiveFilePath << std::endl; // Verify that we are not exceeding maxFile size of the archive, and if we do, increase it int32_t numberOfFiles = GetFileInfo(hArchive, SFileMpqNumberOfFiles); @@ -254,15 +254,15 @@ int AddFile( int RemoveFile(HANDLE hArchive, const std::string& archiveFilePath, LCID locale) { SFileSetLocale(locale); - std::cout << "[-] Removing file for locale " << locale <<": " << archiveFilePath << std::endl; + std::cout << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") <<": " << archiveFilePath << std::endl; if (!SFileHasFile(hArchive, archiveFilePath.c_str())) { - std::cerr << "[!] Failed: File doesn't exist for locale " << locale << ": " << archiveFilePath << std::endl; + std::cerr << "[!] Failed: File doesn't exist" << PrettyPrintLocale(locale, " for locale ") << ": " << archiveFilePath << std::endl; return -1; } if (!SFileRemoveFile(hArchive, archiveFilePath.c_str(), 0)) { - std::cerr << "[!] Failed: File cannot be removed for locale " << locale << ": " << archiveFilePath << std::endl; + std::cerr << "[!] Failed: File cannot be removed" << PrettyPrintLocale(locale, " for locale ") << ": " << archiveFilePath << std::endl; return -1; } diff --git a/test/conftest.py b/test/conftest.py index d84a62f..113a248 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,3 +1,4 @@ +import shutil from datetime import datetime import os import platform @@ -69,6 +70,7 @@ def generate_locales_mpq_test_files(binary_path): data_dir.mkdir(parents=True, exist_ok=True) locales_files_dir = data_dir / "locale_files" + shutil.rmtree(locales_files_dir, ignore_errors=True) locales_files_dir.mkdir(parents=True, exist_ok=True) mpq_many_locales_file_name = data_dir / "mpq_with_many_locales.mpq" @@ -78,9 +80,10 @@ def generate_locales_mpq_test_files(binary_path): mpq_one_locale_file_name.unlink(missing_ok=True) locale_files = { - "": "This is a file about cats.", # Default locale - "deDE": "Dies ist eine Datei über Katzen.", - "esES": "Este es un archivo sobre gatos.", + "": "This is a file about cats.", # Default locale + "041D": "Detta är en fil om katter.", # Swedish locale (not part of locales.cpp) + "deDE": "Dies ist eine Datei über Katzen.", # German locale + "esES": "Este es un archivo sobre gatos.", # Spanish locale } # Put all items into mpq_many_locales_file_name with their locale @@ -120,6 +123,50 @@ def generate_locales_mpq_test_files(binary_path): assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" +@pytest.fixture(scope="function") +def generate_mpq_without_internal_listfile(binary_path): + script_dir = Path(__file__).parent + + data_dir = script_dir / "data" + data_dir.mkdir(parents=True, exist_ok=True) + + locales_files_dir = data_dir / "locale_files" + shutil.rmtree(locales_files_dir, ignore_errors=True) + locales_files_dir.mkdir(parents=True, exist_ok=True) + + mpq_file_name = data_dir / "mpq_without_internal_listfile2.mpq" + mpq_file_name.unlink(missing_ok=True) + + content = [ + ("capybaras.txt", "", "This is a file about capybaras."), # Default locale + ("cats.txt", "deDE", "Dies ist eine Datei über Katzen."), # German locale + ("dogs.txt", "041D", "Detta är en fil om hundar."), # Swedish locale (not part of locales.cpp) + ] + + # Put all items into mpq_many_locales_file_name with their locale + for text_file_name, locale, text_content in content: + file_path = locales_files_dir / text_file_name + file_path.write_text(text_content, newline="\n") + + if locale == "": # Default locale - create a new MPQ file + result = subprocess.run( + [str(binary_path), "create", "-o", str(mpq_file_name), str(locales_files_dir), "--file-flags1", "0"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + else: # Explicit locale - add to existing MPQ file + result = subprocess.run( + [str(binary_path), "add", str(file_path), str(mpq_file_name), "--locale", locale], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + + @pytest.fixture(scope="session") def download_test_files(): script_dir = Path(__file__).parent diff --git a/test/test_add.py b/test/test_add.py index 8a7ef06..17637be 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -74,7 +74,7 @@ def test_add_file_to_mpq_archive(binary_path, generate_test_files): output_lines = set(result.stdout.splitlines()) expected_stdout_output = { - "[+] Adding file for locale 0: test.txt", + "[+] Adding file: test.txt", } assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" @@ -165,7 +165,7 @@ def test_create_mpq_with_locale(binary_path, generate_test_files): output_lines = set(result.stdout.splitlines()) expected_stdout_output = { - "[+] Adding file for locale 1034: cats.txt", + "[+] Adding file for locale esES: cats.txt", } assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" @@ -227,7 +227,7 @@ def test_add_file_with_game_profile(binary_path, generate_test_files): assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" assert f"Using game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" - assert f"Adding file for locale 0: test_{profile}.txt" in result.stdout + assert f"Adding file: test_{profile}.txt" in result.stdout # Verify compression flags on the added file list_result = subprocess.run( diff --git a/test/test_info.py b/test/test_info.py index e3bc78a..d185d6b 100644 --- a/test/test_info.py +++ b/test/test_info.py @@ -19,8 +19,8 @@ def test_info_v1(binary_path): # Adjust the expected output for Windows due to different line endings if platform.system() == "Windows": - expected_output.remove("Archive size: 1380") - expected_output.add("Archive size: 1382") + expected_output.remove("Archive size: 1381") + expected_output.add("Archive size: 1383") result = subprocess.run( [str(binary_path), "info", str(test_file)], diff --git a/test/test_list.py b/test/test_list.py index 5ea85ad..d811ca2 100644 --- a/test/test_list.py +++ b/test/test_list.py @@ -173,21 +173,23 @@ def test_list_mpq_with_weak_signature(binary_path): assert output_lines == expected_output, f"Unexpected output: {output_lines}" -def test_list_mpq_without_providing_listfile(binary_path): +def test_list_mpq_without_providing_listfile(binary_path, generate_mpq_without_internal_listfile): """ Test MPQ file listing of MPQ that contains no internal listfile. This test checks: - That handling MPQs with no internal listfile generates the expected output. """ + _ = generate_mpq_without_internal_listfile script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" + test_file = script_dir / "data" / "mpq_without_internal_listfile2.mpq" ## No flags expected_output = { "File00000000.xxx", "File00000001.xxx", "File00000002.xxx", + "File00000003.xxx", } result = subprocess.run( [str(binary_path), "list", str(test_file)], @@ -205,6 +207,7 @@ def test_list_mpq_without_providing_listfile(binary_path): "File00000000.xxx", "File00000001.xxx", "File00000002.xxx", + "File00000003.xxx", } result = subprocess.run( [str(binary_path), "list", "-a", str(test_file)], @@ -218,11 +221,21 @@ def test_list_mpq_without_providing_listfile(binary_path): assert output_lines == expected_output, f"Unexpected output: {output_lines}" ## --all, --detailed flag - expected_output = { - " 27 enUS File00000000.xxx", - " 27 enUS File00000001.xxx", - " 72 enUS File00000002.xxx", - } + if platform.system() != "Windows": + expected_output = { + " 31 enUS File00000000.xxx", + " 33 deDE File00000001.xxx", + " 27 041D File00000002.xxx", + " 72 enUS File00000003.xxx", + } + else: + expected_output = { + " 31 enUS File00000000.xxx", + " 32 deDE File00000001.xxx", + " 26 041D File00000002.xxx", + " 72 enUS File00000003.xxx", + } + result = subprocess.run( [str(binary_path), "list", "-ad", str(test_file)], stdout=subprocess.PIPE, @@ -235,7 +248,7 @@ def test_list_mpq_without_providing_listfile(binary_path): assert output_lines == expected_output, f"Unexpected output: {output_lines}" -def test_list_mpq_providing_partial_external_listfile(binary_path): +def test_list_mpq_providing_partial_external_listfile(binary_path, generate_mpq_without_internal_listfile): """ Test MPQ file listing of MPQ that contains no internal listfile, when providing a partially compete external listfile. @@ -244,14 +257,16 @@ def test_list_mpq_providing_partial_external_listfile(binary_path): - That providing a partially complete external listfile shows the files it lists. - That the files not listed in the external listfile still show up in the output. """ + _ = generate_mpq_without_internal_listfile script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" + test_file = script_dir / "data" / "mpq_without_internal_listfile2.mpq" listfile = script_dir / "data" / "listfile.txt" listfile.write_text("cats.txt") ## No flags expected_output = { "File00000000.xxx", + "File00000002.xxx", "cats.txt", } result = subprocess.run( @@ -268,6 +283,7 @@ def test_list_mpq_providing_partial_external_listfile(binary_path): ## --all flag expected_output = { "File00000000.xxx", + "File00000002.xxx", "cats.txt", "(signature)", } @@ -283,11 +299,20 @@ def test_list_mpq_providing_partial_external_listfile(binary_path): assert output_lines == expected_output, f"Unexpected output: {output_lines}" ## --all, --detailed flag - expected_output = { - " 27 enUS File00000000.xxx", - " 27 enUS cats.txt", - " 72 enUS (signature)", - } + if platform.system() != "Windows": + expected_output = { + " 31 enUS File00000000.xxx", + " 27 041D File00000002.xxx", + " 33 deDE cats.txt", + " 72 enUS (signature)", + } + else: + expected_output = { + " 31 enUS File00000000.xxx", + " 26 041D File00000002.xxx", + " 32 deDE cats.txt", + " 72 enUS (signature)", + } result = subprocess.run( [str(binary_path), "list", "-ad", str(test_file), "--listfile", str(listfile)], stdout=subprocess.PIPE, @@ -300,7 +325,7 @@ def test_list_mpq_providing_partial_external_listfile(binary_path): assert output_lines == expected_output, f"Unexpected output: {output_lines}" -def test_list_mpq_providing_complete_external_listfile(binary_path): +def test_list_mpq_providing_complete_external_listfile(binary_path, generate_mpq_without_internal_listfile): """ Test MPQ file listing of MPQ that contains no internal listfile, when providing a compete external listfile. @@ -308,15 +333,17 @@ def test_list_mpq_providing_complete_external_listfile(binary_path): - That handling MPQs with no internal listfile generates the expected output. - That providing a complete external listfile shows the files it lists. """ + _ = generate_mpq_without_internal_listfile script_dir = Path(__file__).parent - test_file = script_dir / "data" / "mpq_without_internal_listfile.mpq" + test_file = script_dir / "data" / "mpq_without_internal_listfile2.mpq" listfile = script_dir / "data" / "listfile.txt" - listfile.write_text("cats.txt\ndogs.txt") + listfile.write_text("cats.txt\ndogs.txt\ncapybaras.txt") ## No flags expected_output = { "dogs.txt", "cats.txt", + "capybaras.txt", } result = subprocess.run( [str(binary_path), "list", str(test_file), "--listfile", str(listfile)], @@ -333,6 +360,7 @@ def test_list_mpq_providing_complete_external_listfile(binary_path): expected_output = { "dogs.txt", "cats.txt", + "capybaras.txt", "(signature)", } result = subprocess.run( @@ -347,11 +375,21 @@ def test_list_mpq_providing_complete_external_listfile(binary_path): assert output_lines == expected_output, f"Unexpected output: {output_lines}" ## --all, --detailed flag - expected_output = { - " 27 enUS dogs.txt", - " 27 enUS cats.txt", - " 72 enUS (signature)", - } + if platform.system() != "Windows": + expected_output = { + " 31 enUS capybaras.txt", + " 33 deDE cats.txt", + " 27 041D dogs.txt", + " 72 enUS (signature)", + } + else: + expected_output = { + " 31 enUS capybaras.txt", + " 32 deDE cats.txt", + " 26 041D dogs.txt", + " 72 enUS (signature)", + } + result = subprocess.run( [str(binary_path), "list", "-ad", str(test_file), "--listfile", str(listfile)], stdout=subprocess.PIPE, diff --git a/test/test_remove.py b/test/test_remove.py index e801677..c4653f1 100644 --- a/test/test_remove.py +++ b/test/test_remove.py @@ -45,13 +45,13 @@ def test_remove_target_file_does_not_exist(binary_path, generate_locales_mpq_tes output_lines = set(result.stdout.splitlines()) expected_stdout_output = { - "[-] Removing file for locale 0: does-not-exist.txt", + "[-] Removing file: does-not-exist.txt", } assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) expected_stderr_output = { - "[!] Failed: File doesn't exist for locale 0: does-not-exist.txt", + "[!] Failed: File doesn't exist: does-not-exist.txt", } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" @@ -79,7 +79,7 @@ def test_remove_file_from_mpq_archive(binary_path, generate_locales_mpq_test_fil output_lines = set(result.stdout.splitlines()) expected_output = { - "[-] Removing file for locale 0: cats.txt", + "[-] Removing file: cats.txt", } assert output_lines == expected_output, f"Unexpected output: {output_lines}" @@ -106,6 +106,7 @@ def test_remove_file_with_locale_from_mpq_archive(binary_path, generate_locales_ "enUS cats.txt", "deDE cats.txt", "esES cats.txt", + "041D cats.txt", } verify_archive_content(binary_path, target_file, expected_output) @@ -121,6 +122,7 @@ def test_remove_file_with_locale_from_mpq_archive(binary_path, generate_locales_ expected_output = { "deDE cats.txt", "esES cats.txt", + "041D cats.txt", } verify_archive_content(binary_path, target_file, expected_output) @@ -135,6 +137,7 @@ def test_remove_file_with_locale_from_mpq_archive(binary_path, generate_locales_ expected_output = { "deDE cats.txt", + "041D cats.txt", } verify_archive_content(binary_path, target_file, expected_output)