From 647ccf84836e6a4de692f27cc8d41e15d4d06dde Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 7 Apr 2026 12:22:46 +1200 Subject: [PATCH 01/12] Added clang-tidy and clang-format configuration --- .clang-format | 21 ++++++++++++ .clang-tidy | 17 ++++++++++ CMakeLists.txt | 1 + Makefile | 90 ++++++++++++++++++++++++++++++++++++++------------ 4 files changed, 108 insertions(+), 21 deletions(-) create mode 100644 .clang-format create mode 100644 .clang-tidy diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..e201492 --- /dev/null +++ b/.clang-format @@ -0,0 +1,21 @@ +BasedOnStyle: Google +PointerAlignment: Right +DerivePointerAlignment: false +ColumnLimit: 100 +IndentWidth: 4 +AccessModifierOffset: -4 +IncludeBlocks: Regroup +IncludeIsMainRegex: '([-_]test)?$' +IncludeCategories: + - Regex: '^<(StormLib\.h|CLI/)' + Priority: 3 + - Regex: '^<' + Priority: 2 + - Regex: '^"' + Priority: 4 +ReflowComments: true +BreakBeforeBraces: Attach +Cpp11BracedListStyle: true +AllowShortCaseLabelsOnASingleLine: true +AllowShortFunctionsOnASingleLine: InlineOnly +SortIncludes: CaseSensitive diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..c58bb6f --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,17 @@ +Checks: > + clang-analyzer-core.*, + clang-analyzer-cplusplus.*, + clang-analyzer-deadcode.*, + modernize-use-nullptr, + bugprone-*, + -bugprone-easily-swappable-parameters, + -bugprone-exception-escape, + -bugprone-narrowing-conversions, + cppcoreguidelines-no-malloc, + cppcoreguidelines-owning-memory, + misc-unused-parameters, + performance-unnecessary-value-param, + readability-inconsistent-declaration-parameter-name, + readability-container-size-empty + +WarningsAsErrors: "*" diff --git a/CMakeLists.txt b/CMakeLists.txt index 650f5c5..dae57b4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,7 @@ if(NOT CMAKE_BUILD_TYPE) endif() set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # Static linking configuration if(BUILD_STATIC) diff --git a/Makefile b/Makefile index ca0e8f1..f0c5068 100644 --- a/Makefile +++ b/Makefile @@ -4,86 +4,133 @@ VERSION := $(shell awk '/project\(MPQCLI VERSION/ {gsub(/\)/, "", $$3); print $$ README := README.md PACKAGE_URL := https://github.com/TheGrayDot/mpqcli/pkgs/container/mpqcli +GCC_INSTALL_DIR := $(shell dirname "$(shell gcc -print-libgcc-file-name)") + .PHONY: help \ - build_linux build_windows build_clean \ + build_linux build_windows build_clean build_lint_clean \ docker_musl_build docker_musl_run docker_glibc_build docker_glibc_run \ test_create_venv test_mpqcli test_clean test_lint \ + lint_format lint_format_fix lint_cpp lint \ clean \ bump_stormlib bump_cli11 bump_submodules \ fetch_downloads tag_release -help: ## Show this help menu - @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} \ - /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-22s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) +## Show this help menu +help: + @awk 'BEGIN {FS = ":"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} \ + /^## / {desc = substr($$0, 4); next} \ + /^[a-zA-Z0-9_-]+:/ {if (desc) printf " \033[36m%-22s\033[0m %s\n", $$1, desc; desc = ""; next} \ + {desc = ""}' $(MAKEFILE_LIST) # BUILD -build_linux: ## Build for Linux using cmake +## Build for Linux using cmake +build_linux: cmake -B build \ -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ -DBUILD_MPQCLI=$(BUILD_MPQCLI) cmake --build build -build_windows: ## Build for Windows using cmake +## Build for Windows using cmake +build_windows: cmake -B build \ -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ -DBUILD_MPQCLI=$(BUILD_MPQCLI) cmake --build build --config $(CMAKE_BUILD_TYPE) -build_clean: ## Remove cmake build directory +## Remove cmake build directory +build_clean: rm -rf build +## Generate compile_commands.json for clang-tidy +build_lint: CMakeLists.txt src/CMakeLists.txt + cmake -B build_lint -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DBUILD_MPQCLI=ON -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)" + @touch $@ + +## Remove cmake lint build directory +build_lint_clean: + rm -rf build_lint + # DOCKER -docker_musl_build: ## Build Docker image using musl +## Build Docker image using musl +docker_musl_build: docker build -t mpqcli:$(VERSION) -f Dockerfile.musl . -docker_musl_run: ## Run the musl Docker image +## Run the musl Docker image +docker_musl_run: @docker run -it mpqcli:$(VERSION) version -docker_glibc_build: ## Build Docker image using glibc +## Build Docker image using glibc +docker_glibc_build: docker build -t mpqcli:$(VERSION) -f Dockerfile.glibc . -docker_glibc_run: ## Run the glibc Docker image +## Run the glibc Docker image +docker_glibc_run: @docker run -it mpqcli:$(VERSION) version # TEST -test_create_venv: ## Create Python venv and install test dependencies +## Create Python venv and install test dependencies +test_create_venv: python3 -m venv ./.venv . ./.venv/bin/activate && \ pip3 install -r test/requirements.txt -test_mpqcli: ## Run pytest test suite +## Run pytest test suite +test_mpqcli: . ./.venv/bin/activate && \ python3 -m pytest test -s -test_clean: ## Remove test data directory +## Remove test data directory +test_clean: rm -rf test/data -test_lint: ## Run ruff linter on test directory +## Run ruff linter on test directory +test_lint: . ./.venv/bin/activate && \ ruff check ./test +# LINT +## Check C++ formatting with clang-format-18 +lint_format: + find src \( -name "*.cpp" -o -name "*.h" \) | xargs clang-format-18 --dry-run --Werror + +## Auto-fix C++ formatting with clang-format-18 +lint_format_fix: + find src \( -name "*.cpp" -o -name "*.h" \) | xargs clang-format-18 -i + +## Run clang-tidy-18 static analysis +lint_cpp: build_lint + clang-tidy-18 --quiet -p build_lint --header-filter="$(CURDIR)/src/.*" src/*.cpp + +## Run all C++ linters +lint: lint_format lint_cpp + # CLEAN -clean: build_clean test_clean ## Remove all build and test artifacts +## Remove all build and test artifacts +clean: build_clean build_lint_clean test_clean # SUBMODULES -bump_stormlib: ## Bump StormLib submodule to latest remote +## Bump StormLib submodule to latest remote +bump_stormlib: @read -rp "[*] Bump StormLib? (y/N) " yn; \ case $$yn in \ [yY] ) git submodule update --init --remote extern/StormLib;; \ * ) echo "[*] Skipping...";; \ esac -bump_cli11: ## Bump CLI11 submodule to latest remote +## Bump CLI11 submodule to latest remote +bump_cli11: @read -rp "[*] Bump CLI11? (y/N) " yn; \ case $$yn in \ [yY] ) git submodule update --init --remote extern/CLI11;; \ * ) echo "[*] Skipping...";; \ esac -bump_submodules: bump_stormlib bump_cli11 ## Bump all submodules to latest remote +## Bump all submodules to latest remote +bump_submodules: bump_stormlib bump_cli11 # RELEASE -fetch_downloads: ## Fetch package downloads and update README.md badge +## Fetch package downloads and update README.md badge +fetch_downloads: @DOWNLOADS=$$(curl -s "$(PACKAGE_URL)" \ | grep -A2 "Total downloads" \ | grep -o '

[0-9]*

' \ @@ -93,7 +140,8 @@ fetch_downloads: ## Fetch package downloads and update README.md badge sed -i "s/package_downloads-[0-9]*-green/package_downloads-$$DOWNLOADS-green/" $(README); \ echo "[*] Updated package downloads badge: $$DOWNLOADS" -tag_release: ## Tag and push the current project version +## Tag and push the current project version +tag_release: @echo "[*] Current version: v$(VERSION)" @read -rp "[*] Tag and Release? (y/N) " yn; \ case $$yn in \ From ba7c64ae29636767a7aa7945d2eae747b47dc294 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 7 Apr 2026 12:23:12 +1200 Subject: [PATCH 02/12] Update gitignore for lint build --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 810bb9d..2bf826c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # CUSTOM +/build_lint /test/data # C++ From b28f4fdfe32f6772ce394151499264030f325a3f Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 7 Apr 2026 12:23:32 +1200 Subject: [PATCH 03/12] Added lint workflow to pr --- .github/workflows/lint.yml | 32 ++++++++++++++++++++++++++++++++ .github/workflows/pr.yml | 2 ++ 2 files changed, 34 insertions(+) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..414e969 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,32 @@ +name: Lint + +on: + workflow_call + +jobs: + lint_cpp: + runs-on: ubuntu-24.04 + steps: + - name: Check out repository code + uses: actions/checkout@v4 + with: + submodules: true + + - name: Install clang tools + run: sudo apt-get install -y clang-format-18 clang-tidy-18 + + - name: Check formatting + run: find src \( -name "*.cpp" -o -name "*.h" \) | xargs clang-format-18 --dry-run --Werror + + - name: Generate compile_commands.json + run: | + GCC_INSTALL_DIR=$(dirname "$(gcc -print-libgcc-file-name)") + cmake -B build_lint \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DBUILD_MPQCLI=ON \ + -DCMAKE_CXX_COMPILER=clang++-18 \ + -DCMAKE_CXX_FLAGS="--gcc-install-dir=${GCC_INSTALL_DIR}" + + - name: Run clang-tidy + run: clang-tidy-18 --quiet -p build_lint --header-filter="$(pwd)/src/.*" src/*.cpp diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 404f931..5cff590 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -9,6 +9,8 @@ permissions: jobs: build: uses: ./.github/workflows/build.yml + lint: + uses: ./.github/workflows/lint.yml test: uses: ./.github/workflows/test.yml needs: build From 7646e2e5911b6a151413e49f610f45c20980ede2 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 7 Apr 2026 12:23:57 +1200 Subject: [PATCH 04/12] Tidy clang linting errors --- src/gamerules.cpp | 113 ++++++---- src/gamerules.h | 137 ++++++------ src/helpers.cpp | 23 +- src/helpers.h | 4 +- src/locales.cpp | 105 +++++----- src/locales.h | 35 +--- src/main.cpp | 234 ++++++++++++--------- src/mpq.cpp | 523 +++++++++++++++++++++++----------------------- src/mpq.h | 33 ++- src/validators.h | 33 +++ 10 files changed, 671 insertions(+), 569 deletions(-) create mode 100644 src/validators.h diff --git a/src/gamerules.cpp b/src/gamerules.cpp index 08777bb..2601619 100644 --- a/src/gamerules.cpp +++ b/src/gamerules.cpp @@ -1,15 +1,18 @@ #include "gamerules.h" + #include #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) { +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); }); @@ -17,7 +20,7 @@ static std::string ToLower(const std::string& str) { } // Helper function to match wildcards (* and ?) -bool GameRules::MatchFileMask(const std::string& filename, const std::string& mask) { +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); @@ -33,7 +36,8 @@ bool GameRules::MatchFileMask(const std::string& filename, const std::string& ma size_t matchPos = 0; while (filePos < lowerFilename.length()) { - if (maskPos < lowerMask.length() && (lowerMask[maskPos] == '?' || lowerMask[maskPos] == lowerFilename[filePos])) { + if (maskPos < lowerMask.length() && + (lowerMask[maskPos] == '?' || lowerMask[maskPos] == lowerFilename[filePos])) { maskPos++; filePos++; } else if (maskPos < lowerMask.length() && lowerMask[maskPos] == '*') { @@ -56,7 +60,8 @@ bool GameRules::MatchFileMask(const std::string& filename, const std::string& ma return maskPos == lowerMask.length(); } -void GameRules::AddRuleByFileMask(const std::string& fileMask, DWORD mpqFlags, DWORD compressionFirst, DWORD compressionNext) { +void GameRules::AddRuleByFileMask(const std::string &fileMask, DWORD mpqFlags, + DWORD compressionFirst, DWORD compressionNext) { rules.emplace_back(fileMask, mpqFlags, compressionFirst, compressionNext); } @@ -65,20 +70,20 @@ void GameRules::AddRuleByFileMask(const std::string& fileMask, DWORD mpqFlags, D // 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) { +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 { +CompressionSettings GameRules::GetCompressionSettings(const std::string &filename, + const DWORD fileSize) const { // Iterate through rules in order (first match wins) - for (const auto& rule : rules) { + for (const auto &rule : rules) { switch (rule.type) { case RuleType::FILE_MASK: if (MatchFileMask(filename, rule.fileMask)) { @@ -89,7 +94,8 @@ CompressionSettings GameRules::GetCompressionSettings(const std::string& filenam 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); + bool inRange = + fileSize >= rule.sizeMin && (!hasUpperLimit || fileSize <= rule.sizeMax); if (inRange) { return {rule.mpqFlags, rule.compressionFirst, rule.compressionNext}; @@ -103,11 +109,12 @@ CompressionSettings GameRules::GetCompressionSettings(const std::string& filenam } // 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}; + 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) { +void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides) { // Track whether user explicitly set fileFlags2 (needed for automatic adjustment logic) bool userSetFileFlags2 = false; @@ -168,7 +175,7 @@ void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides& overrid } // Get the profile name map (single source of truth for all valid profile names) -static const std::map& GetProfileMap() { +static const std::map &GetProfileMap() { static const std::map profileMap = { {"generic", GameProfile::GENERIC}, {"diablo1", GameProfile::DIABLO1}, @@ -204,14 +211,13 @@ static const std::map& GetProfileMap() { {"starcraft2", GameProfile::STARCRAFT2}, {"sc2", GameProfile::STARCRAFT2}, {"diablo3", GameProfile::DIABLO3}, - {"d3", GameProfile::DIABLO3} - }; + {"d3", GameProfile::DIABLO3}}; return profileMap; } // Convert string to GameProfile enum -GameProfile GameRules::StringToProfile(const std::string& profileName) { - const auto& profileMap = GetProfileMap(); +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()) { @@ -247,7 +253,8 @@ 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) { + for (int i = static_cast(GameProfile::GENERIC); + i <= static_cast(GameProfile::DIABLO3); ++i) { profiles.push_back(ProfileToString(static_cast(i))); } @@ -270,7 +277,7 @@ std::string GameRules::GetAvailableProfiles() { } // Validator for CLI11 - accepts all profile names but only displays canonical ones -const CLI::Validator GameProfileValid = CLI::Validator( +extern const CLI::Validator GameProfileValid = CLI::Validator( [](const std::string &str) { if (str == "default") return std::string(); @@ -280,16 +287,14 @@ const CLI::Validator GameProfileValid = CLI::Validator( // 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()) { + for (const auto &p : GameRules::GetCanonicalProfiles()) { validProfiles += " " + p; } return validProfiles; } return std::string(); }, - "", - "GameProfileValidator" -); + "", "GameProfileValidator"); // Initialize rules for the selected game profile void GameRules::InitializeRules() { @@ -316,11 +321,13 @@ void GameRules::InitializeRules() { 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); + 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); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_PKWARE); // Settings for archive creation: createSettings.mpqVersion = MPQ_FORMAT_VERSION_1; @@ -332,7 +339,8 @@ void GameRules::InitializeRules() { 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); + 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); @@ -347,7 +355,8 @@ void GameRules::InitializeRules() { 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); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_PKWARE); // Settings for archive creation: createSettings.mpqVersion = MPQ_FORMAT_VERSION_1; @@ -363,18 +372,23 @@ void GameRules::InitializeRules() { 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); + 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("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("*.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); @@ -383,15 +397,21 @@ void GameRules::InitializeRules() { 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("*.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); + AddRuleDefault(MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED | MPQ_FILE_KEY_V2, + MPQ_COMPRESSION_PKWARE); // Settings for archive creation: createSettings.mpqVersion = MPQ_FORMAT_VERSION_1; @@ -401,7 +421,7 @@ void GameRules::InitializeRules() { createSettings.attrFlags = MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32; break; - case GameProfile::WARCRAFT3_MAP: // Warcraft III Map files + case GameProfile::WARCRAFT3_MAP: // Warcraft III Map files // File rules when adding files to archive: AddRuleDefault(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); @@ -423,7 +443,8 @@ void GameRules::InitializeRules() { 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; + createSettings.attrFlags = + MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; break; case GameProfile::WOW_2X: @@ -437,7 +458,8 @@ void GameRules::InitializeRules() { 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; + createSettings.attrFlags = + MPQ_ATTRIBUTE_FILETIME | MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5; break; case GameProfile::WOW_4X: @@ -447,7 +469,8 @@ void GameRules::InitializeRules() { 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); + 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: @@ -465,7 +488,8 @@ void GameRules::InitializeRules() { 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); + 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); @@ -483,7 +507,8 @@ void GameRules::InitializeRules() { 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); + AddRuleByFileSize(0, 0x4000, MPQ_FILE_COMPRESS | MPQ_FILE_SINGLE_UNIT, + MPQ_COMPRESSION_ZLIB); AddRuleDefault(MPQ_FILE_COMPRESS, MPQ_COMPRESSION_ZLIB); // Settings for archive creation: diff --git a/src/gamerules.h b/src/gamerules.h index 55b6dc8..db6273d 100644 --- a/src/gamerules.h +++ b/src/gamerules.h @@ -1,58 +1,76 @@ #ifndef GAMERULES_H #define GAMERULES_H +#include #include -#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) + 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) + 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) {} + 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 @@ -71,14 +89,14 @@ struct CompressionSettingsOverrides { // 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) + 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() @@ -112,16 +130,19 @@ class GameRules { MpqCreateSettings createSettings; // Helper function to match file mask pattern - static bool MatchFileMask(const std::string& filename, const std::string& mask); + 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); + 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); + 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); + void AddRuleDefault(DWORD mpqFlags, DWORD compressionFirst, + DWORD compressionNext = MPQ_COMPRESSION_NEXT_SAME); // Initialize rules for the selected game profile void InitializeRules(); @@ -134,16 +155,17 @@ class GameRules { explicit GameRules(GameProfile gameProfile); // Get compression settings for a specific file - [[nodiscard]] CompressionSettings GetCompressionSettings(const std::string& filename, DWORD fileSize) const; + [[nodiscard]] CompressionSettings GetCompressionSettings(const std::string &filename, + DWORD fileSize) const; // Get MPQ creation settings - [[nodiscard]] const MpqCreateSettings& GetCreateSettings() const { return createSettings; } + [[nodiscard]] const MpqCreateSettings &GetCreateSettings() const { return createSettings; } // Override MPQ creation settings - void OverrideCreateSettings(const MpqCreateSettingsOverrides& overrides); + void OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides); // Convert string to GameProfile enum - static GameProfile StringToProfile(const std::string& profileName); + static GameProfile StringToProfile(const std::string &profileName); // Get list of canonical game profile names (for display purposes) static std::vector GetCanonicalProfiles(); @@ -155,7 +177,4 @@ class GameRules { 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 +#endif // GAMERULES_H diff --git a/src/helpers.cpp b/src/helpers.cpp index ca5c521..6b1e7c5 100644 --- a/src/helpers.cpp +++ b/src/helpers.cpp @@ -1,11 +1,11 @@ #include +#include #include #include -#include #ifdef _WIN32 -#include #include +#include #endif #include @@ -33,12 +33,12 @@ std::string FileTimeToLsTime(int64_t fileTime) { std::string NormalizeFilePath(const fs::path &path) { std::string filePath = path.u8string(); - #ifndef _WIN32 - std::replace(filePath.begin(), filePath.end(), '\\', '/'); - return filePath; - #else - return filePath; - #endif +#ifndef _WIN32 + std::replace(filePath.begin(), filePath.end(), '\\', '/'); + return filePath; +#else + return filePath; +#endif } std::string WindowsifyFilePath(const fs::path &path) { @@ -52,7 +52,7 @@ int32_t CalculateMpqMaxFileValue(const std::string &path) { // Determine the number of files in the target directory, recusively if (!fs::is_regular_file(path)) { - for (const auto &entry: fs::recursive_directory_iterator(path)) { + for (const auto &entry : fs::recursive_directory_iterator(path)) { if (fs::is_regular_file(entry.path())) { ++fileCount; } @@ -74,8 +74,7 @@ int32_t CalculateMpqMaxFileValue(const std::string &path) { return NextPowerOfTwo(fileCount); } -int32_t NextPowerOfTwo(int32_t n) -{ +int32_t NextPowerOfTwo(int32_t n) { n--; n |= n >> 1; n |= n >> 2; @@ -85,7 +84,7 @@ int32_t NextPowerOfTwo(int32_t n) return n + 1; } -void PrintAsBinary(const char* buffer, uint32_t size) { +void PrintAsBinary(const char *buffer, uint32_t size) { #ifdef _WIN32 _setmode(_fileno(stdout), _O_BINARY); #endif diff --git a/src/helpers.h b/src/helpers.h index ac5efa2..b4db7a9 100644 --- a/src/helpers.h +++ b/src/helpers.h @@ -1,8 +1,8 @@ #ifndef HELPERS_H #define HELPERS_H -#include #include +#include namespace fs = std::filesystem; @@ -11,6 +11,6 @@ std::string NormalizeFilePath(const fs::path &path); std::string WindowsifyFilePath(const fs::path &path); int32_t CalculateMpqMaxFileValue(const std::string &path); int32_t NextPowerOfTwo(int32_t n); -void PrintAsBinary(const char* buffer, uint32_t size); +void PrintAsBinary(const char *buffer, uint32_t size); #endif diff --git a/src/locales.cpp b/src/locales.cpp index 5071b28..97a699d 100644 --- a/src/locales.cpp +++ b/src/locales.cpp @@ -1,66 +1,66 @@ +#include "locales.h" + #include #include -#include +#include #include #include -#include - -#include "locales.h" +#include namespace { - // Files in MPQs have locales with which they are associated. - // Multiple files can have the same file name if they have different locales. - // This function maps locales to language names. - // - // The mappings are from the Windows Language Code Identifier (LCID). - // They can be found, for example, here: - // https://winprotocoldoc.z19.web.core.windows.net/MS-LCID/%5bMS-LCID%5d.pdf +// Files in MPQs have locales with which they are associated. +// Multiple files can have the same file name if they have different locales. +// This function maps locales to language names. +// +// The mappings are from the Windows Language Code Identifier (LCID). +// They can be found, for example, here: +// https://winprotocoldoc.z19.web.core.windows.net/MS-LCID/%5bMS-LCID%5d.pdf - // Define a bidirectional map for locale-language mappings - const std::map localeToLangMap = { - {0x000, "enUS"}, // Default - English (US) - {0x404, "zhTW"}, // Chinese (Taiwan) - {0x405, "csCZ"}, // Czech - {0x407, "deDE"}, // German - {0x409, "enUS"}, // English (US) - {0x40a, "esES"}, // Spanish (Spain) - {0x40c, "frFR"}, // French - {0x410, "itIT"}, // Italian - {0x411, "jaJP"}, // Japanese - {0x412, "koKR"}, // Korean - {0x413, "nlNL"}, // Dutch - {0x415, "plPL"}, // Polish - {0x416, "ptBR"}, // Portuguese (Brazil) - {0x419, "ruRU"}, // Russian - {0x804, "zhCN"}, // Chinese (Simplified) - {0x809, "enGB"}, // English (UK) - {0x80A, "esMX"}, // Spanish (Mexico) - {0x816, "ptPT"}, // Portuguese (Portugal) - }; +// Define a bidirectional map for locale-language mappings +const std::map localeToLangMap = { + {0x000, "enUS"}, // Default - English (US) + {0x404, "zhTW"}, // Chinese (Taiwan) + {0x405, "csCZ"}, // Czech + {0x407, "deDE"}, // German + {0x409, "enUS"}, // English (US) + {0x40a, "esES"}, // Spanish (Spain) + {0x40c, "frFR"}, // French + {0x410, "itIT"}, // Italian + {0x411, "jaJP"}, // Japanese + {0x412, "koKR"}, // Korean + {0x413, "nlNL"}, // Dutch + {0x415, "plPL"}, // Polish + {0x416, "ptBR"}, // Portuguese (Brazil) + {0x419, "ruRU"}, // Russian + {0x804, "zhCN"}, // Chinese (Simplified) + {0x809, "enGB"}, // English (UK) + {0x80A, "esMX"}, // Spanish (Mexico) + {0x816, "ptPT"}, // Portuguese (Portugal) +}; - // Create a reverse map for language-to-locale lookups - const std::map langToLocaleMap = []() { - std::map reverseMap; - for (const auto& [locale, lang] : localeToLangMap) { - if (locale != defaultLocale) { // Skip the default locale to avoid duplication - reverseMap[lang] = locale; - } +// Create a reverse map for language-to-locale lookups +const std::map langToLocaleMap = []() { + std::map reverseMap; + for (const auto &[locale, lang] : localeToLangMap) { + if (locale != defaultLocale) { // Skip the default locale to avoid duplication + reverseMap[lang] = locale; } - 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; } + 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; } +} // namespace // 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) { +LCID ParseHexLocale(const std::string &str) { if (str.length() != 4) { return defaultLocale; } @@ -85,7 +85,7 @@ std::string LocaleToLang(uint16_t locale) { return it != localeToLangMap.end() ? it->second : FormatLocaleAsHex(locale); } -LCID LangToLocale(const std::string& lang) { +LCID LangToLocale(const std::string &lang) { auto it = langToLocaleMap.find(lang); if (it != langToLocaleMap.end()) { return it->second; @@ -100,11 +100,10 @@ LCID LangToLocale(const std::string& lang) { return defaultLocale; } - std::vector GetAllLocales() { std::vector locales; - for (const auto& [locale, lang] : localeToLangMap) { - if (locale != defaultLocale) { // Skip the default locale to avoid duplication + for (const auto &[locale, lang] : localeToLangMap) { + if (locale != defaultLocale) { // Skip the default locale to avoid duplication locales.push_back(lang); } } diff --git a/src/locales.h b/src/locales.h index 9c5f180..2c6d6ea 100644 --- a/src/locales.h +++ b/src/locales.h @@ -1,42 +1,19 @@ #ifndef LOCALES_H #define LOCALES_H -#include #include +#include +#include #include -#include const LCID defaultLocale = 0; std::string LocaleToLang(uint16_t locale); LCID LangToLocale(const std::string &lang); -LCID ParseHexLocale(const std::string& str); +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(); - - // 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()) { - validLocales += " " + l; - } - return validLocales; - } - return std::string(); - }, - "", - "LocaleValidator" -); +std::string PrettyPrintLocale(LCID locale, const std::string &prefix = "", + bool alwaysPrint = false); -#endif //LOCALES_H +#endif // LOCALES_H diff --git a/src/main.cpp b/src/main.cpp index e97d72b..a4e5712 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,38 +1,38 @@ -#include #include +#include #include #include -#include "mpq.h" +#include "gamerules.h" #include "helpers.h" #include "locales.h" +#include "mpq.h" #include "mpqcli.h" -#include "gamerules.h" +#include "validators.h" int main(int argc, char **argv) { CLI::App app{ "A command line tool to create, add, remove, list, extract, read, and verify MPQ archives " - "using the StormLib library" - }; + "using the StormLib library"}; // Require at least one subcommand app.require_subcommand(1); // CLI: base // These are reused in multiple subcommands - std::string baseTarget = "default"; // all subcommands - std::string baseFile = "default"; // add, remove, extract, read - std::string basePath = "default"; // add, create - std::string baseLocale = "default"; // create, add, remove, extract, read - std::string baseNameInArchive = "default"; // add, create - std::string baseOutput = "default"; // create, extract - std::string baseListfileName = "default"; // list, extract - std::string baseGameProfile = "default"; // create, add + std::string baseTarget = "default"; // all subcommands + std::string baseFile = "default"; // add, remove, extract, read + std::string basePath = "default"; // add, create + std::string baseLocale = "default"; // create, add, remove, extract, read + std::string baseNameInArchive = "default"; // add, create + 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: add - std::string baseDirInArchive = "default"; // add + std::string baseDirInArchive = "default"; // add bool addOverwrite = false; // CLI: extract bool extractKeepFolderStructure = false; @@ -57,30 +57,32 @@ int main(int argc, char **argv) { // CLI: verify bool verifyPrintSignature = false; + // clang-format off std::set validInfoProperties = { - "format-version", - "header-offset", - "header-size", - "archive-size", - "file-count", - "max-files", - "signature-type", + "format-version", + "header-offset", + "header-size", + "archive-size", + "file-count", + "max-files", + "signature-type", }; std::set validFileListProperties = { - "hash-index", - "name-hash1", - "name-hash2", - "name-hash3", - "locale", - "file-index", - "byte-offset", - "file-time", - "file-size", - "compressed-size", - "flags", - "encryption-key", - "encryption-key-raw", + "hash-index", + "name-hash1", + "name-hash2", + "name-hash3", + "locale", + "file-index", + "byte-offset", + "file-time", + "file-size", + "compressed-size", + "flags", + "encryption-key", + "encryption-key-raw", }; + // clang-format on // Subcommand: Version CLI::App *version = app.add_subcommand("version", "Prints program version"); @@ -97,61 +99,85 @@ int main(int argc, char **argv) { ->check(CLI::IsMember(validInfoProperties)); // Subcommand: Create - CLI::App *create = app.add_subcommand("create", "Create an MPQ archive from target file or directory"); + CLI::App *create = + app.add_subcommand("create", "Create an MPQ archive from target file or directory"); create->add_option("target", baseTarget, "Directory or file to put in MPQ archive") ->required() ->check(CLI::ExistingPath); create->add_option("-n,--name-in-archive", baseNameInArchive, "Filename inside MPQ archive"); create->add_option("-o,--output", baseOutput, "Output MPQ archive"); create->add_flag("-s,--sign", createSignArchive, "Sign the MPQ archive (default false)"); - 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()) + 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"); + 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"); + 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"); - add->add_option("file", baseFile, "File to add") - ->required() - ->check(CLI::ExistingFile); + add->add_option("file", baseFile, "File to add")->required()->check(CLI::ExistingFile); add->add_option("target", baseTarget, "Target MPQ archive") ->required() ->check(CLI::ExistingFile); - add->add_option("-p,--path", basePath, "Full path (directory and filename) of the file within MPQ archive"); - add->add_option("-d,--directory-in-archive", baseDirInArchive, "Directory to put file inside within MPQ archive"); + add->add_option("-p,--path", basePath, + "Full path (directory and filename) of the file within MPQ archive"); + add->add_option("-d,--directory-in-archive", baseDirInArchive, + "Directory to put file inside within MPQ archive"); add->add_option("-f,--filename-in-archive", baseNameInArchive, "Filename inside MPQ archive"); add->add_flag("-w,--overwrite", addOverwrite, "Overwrite file if it already is in 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()) + 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"); + 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"); - remove->add_option("file", baseFile, "File to remove") - ->required(); + remove->add_option("file", baseFile, "File to remove")->required(); remove->add_option("target", baseTarget, "Target MPQ archive") ->required() ->check(CLI::ExistingFile); - remove->add_option("--locale", baseLocale, "Locale of file to remove") - ->check(LocaleValid); + remove->add_option("--locale", baseLocale, "Locale of file to remove")->check(LocaleValid); // Subcommand: List CLI::App *list = app.add_subcommand("list", "List files from the MPQ archive"); @@ -160,7 +186,8 @@ int main(int argc, char **argv) { ->check(CLI::ExistingFile); list->add_option("-l,--listfile", baseListfileName, "File listing content of an MPQ archive") ->check(CLI::ExistingFile); - list->add_flag("-d,--detailed", listDetailed, "File listing with additional columns (default false)"); + list->add_flag("-d,--detailed", listDetailed, + "File listing with additional columns (default false)"); list->add_flag("-a,--all", listAll, "File listing including hidden files (default false)"); list->add_option("-p,--property", listProperties, "Prints only specific property values") ->check(CLI::IsMember(validFileListProperties)); @@ -172,15 +199,15 @@ int main(int argc, char **argv) { ->check(CLI::ExistingFile); extract->add_option("-o,--output", baseOutput, "Output directory"); extract->add_option("-f,--file", baseFile, "Target file to extract"); - extract->add_flag("-k,--keep", extractKeepFolderStructure, "Keep folder structure (default false)"); + extract->add_flag("-k,--keep", extractKeepFolderStructure, + "Keep folder structure (default false)"); extract->add_option("-l,--listfile", baseListfileName, "File listing content of an MPQ archive") ->check(CLI::ExistingFile); extract->add_option("--locale", baseLocale, "Preferred locale for extracted file"); // Subcommand: Read - CLI::App* read = app.add_subcommand("read", "Read a file from an MPQ archive"); - read->add_option("file", baseFile, "File to read") - ->required(); + CLI::App *read = app.add_subcommand("read", "Read a file from an MPQ archive"); + read->add_option("file", baseFile, "File to read")->required(); read->add_option("target", baseTarget, "Target MPQ archive") ->required() ->check(CLI::ExistingFile); @@ -207,7 +234,7 @@ int main(int argc, char **argv) { } // Handle subcommand: Version - if (app.got_subcommand(version)){ + if (app.got_subcommand(version)) { std::cout << MPQCLI_VERSION << "-" << GIT_COMMIT_HASH << std::endl; } @@ -237,7 +264,8 @@ int main(int argc, char **argv) { // Handle subcommand: Create if (app.got_subcommand(create)) { if (!fs::is_regular_file(baseTarget) && baseNameInArchive != "default") { - std::cerr << "[!] Cannot specify --name-in-archive when adding a directory." << std::endl; + std::cerr << "[!] Cannot specify --name-in-archive when adding a directory." + << std::endl; return 1; } fs::path outputFilePath; @@ -262,10 +290,11 @@ int main(int argc, char **argv) { } GameRules gameRules(profile); - std::cout << "[*] Game profile: " << baseGameProfile << ", Output file: " << outputFile << std::endl; + std::cout << "[*] Game profile: " << baseGameProfile << ", Output file: " << outputFile + << std::endl; if (createMpqVersion > 0) { - createMpqVersion--; // We label versions 1-4, but StormLib uses 0-3 + createMpqVersion--; // We label versions 1-4, but StormLib uses 0-3 } // Apply MpqCreateSettings overrides if provided MpqCreateSettingsOverrides overrides; @@ -306,16 +335,19 @@ int main(int argc, char **argv) { // 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); + if (fileDwCompression >= 0) + addOverrides.dwCompression = static_cast(fileDwCompression); + if (fileDwCompressionNext >= 0) + addOverrides.dwCompressionNext = static_cast(fileDwCompressionNext); if (fs::is_regular_file(baseTarget)) { // Default: use the filename as path, saves file to root of MPQ fs::path filePath = fs::path(baseTarget); std::string archivePath = filePath.filename().u8string(); - if (baseNameInArchive != "default") { // Optional: specified filename inside archive + if (baseNameInArchive != + "default") { // Optional: specified filename inside archive filePath = fs::path(baseNameInArchive); - archivePath = WindowsifyFilePath(filePath); // Normalise path for MPQ + archivePath = WindowsifyFilePath(filePath); // Normalise path for MPQ } AddFile(hArchive, baseTarget, archivePath, locale, gameRules, addOverrides); @@ -344,17 +376,24 @@ int main(int argc, char **argv) { // Path to file on disk fs::path filePath = fs::path(baseFile); - std::string archivePath = filePath.filename().u8string(); // Default: use the filename as path, saves file to root of MPQ - if (basePath != "default" && baseDirInArchive != "default" || basePath != "default" && baseNameInArchive != "default") { - // Return error since providing --path together --name-in-archive or --directory-in-archive makes no sense and is a user error - std::cerr << "[!] Cannot specify --path together with --name-in-archive or --directory-in-archive." << std::endl; + std::string archivePath = + filePath.filename() + .u8string(); // Default: use the filename as path, saves file to root of MPQ + if (basePath != "default" && baseDirInArchive != "default" || + basePath != "default" && baseNameInArchive != "default") { + // Return error since providing --path together --name-in-archive or + // --directory-in-archive makes no sense and is a user error + std::cerr << "[!] Cannot specify --path together with --name-in-archive or " + "--directory-in-archive." + << std::endl; return 1; - } else if (basePath != "default") { // Optional: specified whole path inside archive + } else if (basePath != "default") { // Optional: specified whole path inside archive filePath = fs::path(basePath); - archivePath = WindowsifyFilePath(filePath); // Normalise path for MPQ + archivePath = WindowsifyFilePath(filePath); // Normalise path for MPQ - } else if (baseDirInArchive != "default" || baseNameInArchive != "default") { // Optional: specified filename inside archive + } else if (baseDirInArchive != "default" || + baseNameInArchive != "default") { // Optional: specified filename inside archive if (baseDirInArchive == "default") { baseDirInArchive = fs::path(baseFile).parent_path().u8string(); } @@ -362,7 +401,7 @@ int main(int argc, char **argv) { baseNameInArchive = archivePath; } filePath = fs::path(baseDirInArchive) / fs::path(baseNameInArchive); - archivePath = WindowsifyFilePath(filePath); // Normalise path for MPQ + archivePath = WindowsifyFilePath(filePath); // Normalise path for MPQ } LCID locale = LangToLocale(baseLocale); @@ -379,8 +418,10 @@ int main(int argc, char **argv) { // 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); + if (fileDwCompression >= 0) + addOverrides.dwCompression = static_cast(fileDwCompression); + if (fileDwCompressionNext >= 0) + addOverrides.dwCompressionNext = static_cast(fileDwCompressionNext); AddFile(hArchive, baseFile, archivePath, locale, gameRules, addOverrides, addOverwrite); CloseMpqArchive(hArchive); @@ -432,12 +473,14 @@ int main(int argc, char **argv) { LCID locale = LangToLocale(baseLocale); if (baseLocale != "default" && locale == defaultLocale) { - std::cout << "[!] Warning: The locale '" << baseLocale << "' is unknown. Will use default locale instead." << std::endl; + std::cout << "[!] Warning: The locale '" << baseLocale + << "' is unknown. Will use default locale instead." << std::endl; } int result; if (baseFile != "default") { - result = ExtractFile(hArchive, baseOutput, baseFile, extractKeepFolderStructure, locale); + result = + ExtractFile(hArchive, baseOutput, baseFile, extractKeepFolderStructure, locale); } else { result = ExtractFiles(hArchive, baseOutput, baseListfileName, locale); } @@ -459,18 +502,18 @@ int main(int argc, char **argv) { LCID locale = LangToLocale(baseLocale); if (baseLocale != "default" && locale == defaultLocale) { - std::cout << "[!] Warning: The locale '" << baseLocale << "' is unknown. Will use default locale instead." << std::endl; + std::cout << "[!] Warning: The locale '" << baseLocale + << "' is unknown. Will use default locale instead." << std::endl; } uint32_t fileSize; - char* fileContent = ReadFile(hArchive, baseFile.c_str(), &fileSize, locale); - if (fileContent == nullptr) { + auto fileContent = ReadFile(hArchive, baseFile.c_str(), &fileSize, locale); + if (!fileContent) { return 1; } - PrintAsBinary(fileContent, fileSize); + PrintAsBinary(fileContent.get(), fileSize); - delete[] fileContent; CloseMpqArchive(hArchive); return 0; } @@ -485,8 +528,7 @@ int main(int argc, char **argv) { int result = 0; uint32_t verifyResult = VerifyMpqArchive(hArchive); - if (verifyResult == ERROR_WEAK_SIGNATURE_OK || - verifyResult == ERROR_STRONG_SIGNATURE_OK || + if (verifyResult == ERROR_WEAK_SIGNATURE_OK || verifyResult == ERROR_STRONG_SIGNATURE_OK || verifyResult == ERROR_WEAK_SIGNATURE_ERROR || verifyResult == ERROR_STRONG_SIGNATURE_ERROR) { if (verifyPrintSignature) { diff --git a/src/mpq.cpp b/src/mpq.cpp index 9b69222..28e1a8d 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -1,25 +1,24 @@ -#include +#include "mpq.h" + +#include #include #include -#include -#include #include +#include +#include +#include #include #include -#include "mpq.h" +#include "gamerules.h" #include "helpers.h" #include "locales.h" -#include "gamerules.h" namespace fs = std::filesystem; -static const std::vector kSpecialMpqFiles = { - "(listfile)", - "(signature)", - "(attributes)" -}; +static const std::vector kSpecialMpqFiles = {"(listfile)", "(signature)", + "(attributes)"}; int OpenMpqArchive(const std::string &filename, HANDLE *hArchive, int32_t flags) { if (!SFileOpenArchive(filename.c_str(), 0, flags, hArchive)) { @@ -37,7 +36,8 @@ int CloseMpqArchive(HANDLE hArchive) { return 1; } -bool FileExistsInArchiveForLocale(const HANDLE hArchive, const std::string& filePath, const LCID locale) { +bool FileExistsInArchiveForLocale(const HANDLE hArchive, const std::string &filePath, + const LCID locale) { bool fileExists = false; SFileSetLocale(locale); HANDLE hFile; @@ -59,14 +59,15 @@ int SignMpqArchive(HANDLE hArchive) { return 1; } -int ExtractFiles(HANDLE hArchive, const std::string& output, const std::string& listfileName, LCID preferredLocale) { +int ExtractFiles(HANDLE hArchive, const std::string &output, const std::string &listfileName, + LCID preferredLocale) { SFileSetLocale(preferredLocale); // Check if the user provided a listfile input - const char *listfile = (listfileName == "default") ? NULL : listfileName.c_str(); + const char *listfile = (listfileName == "default") ? nullptr : listfileName.c_str(); SFILE_FIND_DATA findData; HANDLE findHandle = SFileFindFirstFile(hArchive, "*", &findData, listfile); - if (findHandle == NULL) { + if (findHandle == nullptr) { std::cerr << "[!] Failed to find first file in MPQ archive." << std::endl; SFileCloseArchive(hArchive); return 1; @@ -74,31 +75,24 @@ int ExtractFiles(HANDLE hArchive, const std::string& output, const std::string& int32_t result = 0; do { - result |= ExtractFile( - hArchive, - output, - findData.cFileName, - true, // Keep folder structure - preferredLocale - ); - } while (SFileFindNextFile( - findHandle, - &findData)); + result |= ExtractFile(hArchive, output, findData.cFileName, + true, // Keep folder structure + preferredLocale); + } while (SFileFindNextFile(findHandle, &findData)); SFileFindClose(findHandle); return result; } -int ExtractFile(HANDLE hArchive, const std::string& output, const std::string& fileName, bool keepFolderStructure, LCID preferredLocale) { +int ExtractFile(HANDLE hArchive, const std::string &output, const std::string &fileName, + bool keepFolderStructure, LCID preferredLocale) { SFileSetLocale(preferredLocale); const char *szFileName = fileName.c_str(); - if ( - !FileExistsInArchiveForLocale(hArchive, szFileName, preferredLocale) && - !FileExistsInArchiveForLocale(hArchive, szFileName, defaultLocale) - ) { + if (!FileExistsInArchiveForLocale(hArchive, szFileName, preferredLocale) && + !FileExistsInArchiveForLocale(hArchive, szFileName, defaultLocale)) { std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(preferredLocale, " for locale ", true) - << ": " << szFileName << std::endl; + << PrettyPrintLocale(preferredLocale, " for locale ", true) << ": " << szFileName + << std::endl; return 1; } @@ -123,11 +117,11 @@ int ExtractFile(HANDLE hArchive, const std::string& output, const std::string& f // Guard against path traversal attacks: resolve any ".." components and verify // the output path is a descendant of the intended base directory fs::path resolvedOutput = fs::weakly_canonical(outputFilePathName); - if (std::mismatch(outputPathBase.begin(), outputPathBase.end(), - resolvedOutput.begin(), resolvedOutput.end()).first - != outputPathBase.end()) { - std::cerr << "[!] Blocked: path traversal attempt detected: " - << fileNameString << std::endl; + if (std::mismatch(outputPathBase.begin(), outputPathBase.end(), resolvedOutput.begin(), + resolvedOutput.end()) + .first != outputPathBase.end()) { + std::cerr << "[!] Blocked: path traversal attempt detected: " << fileNameString + << std::endl; return 1; } @@ -145,21 +139,18 @@ int ExtractFile(HANDLE hArchive, const std::string& output, const std::string& f return 0; } -HANDLE CreateMpqArchive( - const std::string &outputArchiveName, - const int32_t fileCount, - const GameRules& gameRules -) { +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; + return nullptr; } HANDLE hMpq; // Use game-specific create settings - const MpqCreateSettings& settings = gameRules.GetCreateSettings(); + const MpqCreateSettings &settings = gameRules.GetCreateSettings(); SFILE_CREATE_MPQ createInfo = {}; // All logic for defaults and dependencies is handled in GameRules::OverrideCreateSettings @@ -174,23 +165,20 @@ HANDLE CreateMpqArchive( createInfo.dwRawChunkSize = settings.rawChunkSize; createInfo.dwMaxFileCount = fileCount; - const bool result = SFileCreateArchive2( - outputArchiveName.c_str(), - &createInfo, - &hMpq - ); + const bool result = SFileCreateArchive2(outputArchiveName.c_str(), &createInfo, &hMpq); if (!result) { std::cerr << "[!] Failed to create MPQ archive: " << outputArchiveName << std::endl; int32_t error = SErrGetLastError(); std::cout << "[!] Error: " << error << std::endl; - return NULL; + return nullptr; } return hMpq; } -int AddFiles(HANDLE hArchive, const std::string& inputPath, LCID locale, const GameRules& gameRules, const CompressionSettingsOverrides& overrides) { +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(inputPath); @@ -204,27 +192,22 @@ int AddFiles(HANDLE hArchive, const std::string& inputPath, LCID locale, const G std::string archiveFilePath = WindowsifyFilePath(inputFilePath.u8string()); // Skip special MPQ files that StormLib manages automatically - if (std::find(kSpecialMpqFiles.begin(), kSpecialMpqFiles.end(), archiveFilePath) != kSpecialMpqFiles.end()) { + if (std::find(kSpecialMpqFiles.begin(), kSpecialMpqFiles.end(), archiveFilePath) != + kSpecialMpqFiles.end()) { std::cout << "[*] Skipping special MPQ file: " << archiveFilePath << std::endl; continue; } - AddFile(hArchive, entry.path().u8string(), archiveFilePath, locale, gameRules, overrides, false); + AddFile(hArchive, entry.path().u8string(), archiveFilePath, locale, gameRules, + overrides, false); } } return 0; } -int AddFile( - HANDLE hArchive, - const fs::path& localFile, - const std::string& archiveFilePath, - const LCID locale, - const GameRules& gameRules, - const CompressionSettingsOverrides& overrides, - bool overwrite -) { - +int AddFile(HANDLE hArchive, const fs::path &localFile, const std::string &archiveFilePath, + const LCID locale, const GameRules &gameRules, + const CompressionSettingsOverrides &overrides, bool overwrite) { // Return if file doesn't exist on disk if (!fs::exists(localFile)) { std::cerr << "[!] File doesn't exist on disk: " << localFile << std::endl; @@ -238,15 +221,18 @@ int AddFile( int32_t fileLocale = GetFileInfo(hFile, SFileInfoLocale); SFileCloseFile(hFile); if (fileLocale == locale && !overwrite) { - std::cerr << "[!] File" << PrettyPrintLocale(locale, " for locale ") << " already exists in MPQ archive: " - << archiveFilePath << " - Skipping..." << std::endl; + std::cerr << "[!] File" << PrettyPrintLocale(locale, " for locale ") + << " already exists in MPQ archive: " << archiveFilePath << " - Skipping..." + << std::endl; return -1; } else if (fileLocale == locale) { - std::cout << "[+] File" << PrettyPrintLocale(locale, " for locale ") << " already exists in MPQ archive: " - << archiveFilePath << " - Overwriting..." << std::endl; + std::cout << "[+] File" << PrettyPrintLocale(locale, " for locale ") + << " already exists in MPQ archive: " << archiveFilePath + << " - Overwriting..." << std::endl; } } - std::cout << "[+] Adding file" << PrettyPrintLocale(locale, " for 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); @@ -257,7 +243,8 @@ int AddFile( bool setMaxFileCount = SFileSetMaxFileCount(hArchive, newMaxFiles); if (!setMaxFileCount) { int32_t error = SErrGetLastError(); - std::cerr << "[!] Error: " << error << " Failed to increase new max file count to: " << newMaxFiles << std::endl; + std::cerr << "[!] Error: " << error + << " Failed to increase new max file count to: " << newMaxFiles << std::endl; return -1; } } @@ -265,7 +252,8 @@ int AddFile( // Get file size for rule matching const std::uintmax_t rawFileSize = fs::file_size(localFile); if (rawFileSize > std::numeric_limits::max()) { - std::cerr << "[!] Warning: file exceeds 4GB, size-based compression rules may not apply correctly: " + std::cerr << "[!] Warning: file exceeds 4GB, size-based compression rules may not apply " + "correctly: " << localFile << std::endl; } const DWORD fileSize = static_cast( @@ -284,14 +272,8 @@ int AddFile( dwFlags += MPQ_FILE_REPLACEEXISTING; } - bool addedFile = SFileAddFileEx( - hArchive, - localFile.u8string().c_str(), - archiveFilePath.c_str(), - dwFlags, - dwCompression, - dwCompressionNext - ); + bool addedFile = SFileAddFileEx(hArchive, localFile.u8string().c_str(), archiveFilePath.c_str(), + dwFlags, dwCompression, dwCompressionNext); if (!addedFile) { int32_t error = SErrGetLastError(); @@ -302,21 +284,22 @@ int AddFile( return 0; } -int RemoveFile(HANDLE hArchive, const std::string& archiveFilePath, LCID locale) { +int RemoveFile(HANDLE hArchive, const std::string &archiveFilePath, LCID locale) { SFileSetLocale(locale); - std::cout << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") <<": " << archiveFilePath << std::endl; + std::cout << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") << ": " + << archiveFilePath << std::endl; if (!FileExistsInArchiveForLocale(hArchive, archiveFilePath, locale)) { std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(locale, " for locale ", true) - << ": " << archiveFilePath << std::endl; + << PrettyPrintLocale(locale, " for locale ", true) << ": " << archiveFilePath + << std::endl; return 1; } if (!SFileRemoveFile(hArchive, archiveFilePath.c_str(), 0)) { std::cerr << "[!] Failed: File cannot be removed" - << PrettyPrintLocale(locale, " for locale ", true) - << ": " << archiveFilePath << std::endl; + << PrettyPrintLocale(locale, " for locale ", true) << ": " << archiveFilePath + << std::endl; return 1; } @@ -326,6 +309,7 @@ int RemoveFile(HANDLE hArchive, const std::string& archiveFilePath, LCID locale) std::string GetFlagString(uint32_t flags) { std::string result; + // clang-format off if (flags & MPQ_FILE_IMPLODE) result += 'i'; if (flags & MPQ_FILE_COMPRESS) result += 'c'; if (flags & MPQ_FILE_ENCRYPTED) result += 'e'; @@ -339,17 +323,19 @@ std::string GetFlagString(uint32_t flags) { if (flags & MPQ_FILE_COMPRESS_MASK) result += 'm'; if (flags & MPQ_FILE_DEFAULT_INTERNAL) result += 'n'; if (flags & MPQ_FILE_FIX_KEY) result += 'f'; + // clang-format on return result; } -int ListFiles(HANDLE hArchive, const std::string& listfileName, bool listAll, bool listDetailed, std::vector& propertiesToPrint) { +int ListFiles(HANDLE hArchive, const std::string &listfileName, bool listAll, bool listDetailed, + std::vector &propertiesToPrint) { // Check if the user provided a listfile input - const char *listfile = (listfileName == "default") ? NULL : listfileName.c_str(); + const char *listfile = (listfileName == "default") ? nullptr : listfileName.c_str(); SFILE_FIND_DATA findData; HANDLE findHandle = SFileFindFirstFile(hArchive, "*", &findData, listfile); - if (findHandle == NULL) { + if (findHandle == nullptr) { std::cerr << "[!] Failed to find first file in MPQ archive." << std::endl; SFileCloseArchive(hArchive); return -1; @@ -358,17 +344,22 @@ int ListFiles(HANDLE hArchive, const std::string& listfileName, bool listAll, bo if (propertiesToPrint.empty()) { propertiesToPrint = { // Default properties, if the user didn't specify any - "file-size", "locale", "file-time", + "file-size", + "locale", + "file-time", }; } else { - listDetailed = true; // If the user specified properties, we need to print the detailed output + listDetailed = + true; // If the user specified properties, we need to print the detailed output } - std::set seenFileNames; // Used to prevent printing the same file name multiple times + std::set + seenFileNames; // Used to prevent printing the same file name multiple times // Loop through all files in the MPQ archive do { // Skip special files unless user wants to list all (like ls -a) - if (!listAll && std::find(kSpecialMpqFiles.begin(), kSpecialMpqFiles.end(), findData.cFileName) != kSpecialMpqFiles.end()) { + if (!listAll && std::find(kSpecialMpqFiles.begin(), kSpecialMpqFiles.end(), + findData.cFileName) != kSpecialMpqFiles.end()) { continue; } @@ -380,34 +371,31 @@ int ListFiles(HANDLE hArchive, const std::string& listfileName, bool listAll, bo } seenFileNames.insert(findData.cFileName); - // Multiple files can be stored with identical filenames under different locales. // Loop over all locales and print the file details for each locale. - DWORD maxLocales = 32; // This will be updated in the call to SFileEnumLocales - const auto fileLocales = static_cast(malloc(maxLocales * sizeof(LCID))); + DWORD maxLocales = 32; // This will be updated in the call to SFileEnumLocales + std::vector fileLocaleVec(maxLocales); + LCID *fileLocales = fileLocaleVec.data(); - if (fileLocales == NULL) { - std::cerr << "[!] Unable to allocate memory for locales for file: " << findData.cFileName << std::endl; - continue; - } - DWORD result = SFileEnumLocales(hArchive, findData.cFileName, fileLocales, &maxLocales, 0); + DWORD result = + SFileEnumLocales(hArchive, findData.cFileName, fileLocales, &maxLocales, 0); if (result == ERROR_INVALID_PARAMETER) { - // This ought to mean that the file name is unknown, whereupon `SFileEnumLocales` exits early - // since its check for `IsPseudoFileName` returns true. If that is the case, it will not have - // populated `fileLocales` or have updated `maxLocales`. - // Just set the maxLocales to 1 and list the file with the unknown name once. + // This ought to mean that the file name is unknown, whereupon `SFileEnumLocales` + // exits early since its check for `IsPseudoFileName` returns true. If that is the + // case, it will not have populated `fileLocales` or have updated `maxLocales`. Just + // set the maxLocales to 1 and list the file with the unknown name once. maxLocales = 1; fileLocales[0] = defaultLocale; } else if (result == ERROR_INVALID_HANDLE || result == ERROR_NOT_SUPPORTED) { std::cerr << "[!] Internal error for file: " << findData.cFileName << std::endl; - free(fileLocales); continue; } else if (result == ERROR_INSUFFICIENT_BUFFER) { - std::cerr << "[!] There are more than " << maxLocales << " locales for the file: " << findData.cFileName << - ". Will only list the " << maxLocales << " first files." << std::endl; + std::cerr << "[!] There are more than " << maxLocales + << " locales for the file: " << findData.cFileName + << ". Will only list the " << maxLocales << " first files." << std::endl; } // Loop through all found locales @@ -420,70 +408,87 @@ int ListFiles(HANDLE hArchive, const std::string& listfileName, bool listAll, bo // Use our custom GetFileInfo function if (!SFileOpenFileEx(hArchive, findData.cFileName, SFILE_OPEN_FROM_MPQ, &hFile)) { std::cerr << "[!] Failed to open file: " << findData.cFileName << std::endl; - continue; // Skip to the next file + continue; // Skip to the next file } std::vector>> propertyActions = { - {"hash-index", [&]() { - std::cout << std::setw(5) << GetFileInfo(hFile, SFileInfoHashIndex) << " " ; - }}, - {"name-hash1", [&]() { - std::cout << std::setfill('0') << std::hex << std::setw(8) << - GetFileInfo(hFile, SFileInfoNameHash1) << - std::setfill(' ') << std::dec << " "; - }}, - {"name-hash2", [&]() { - std::cout << std::setfill('0') << std::hex << std::setw(8) << - GetFileInfo(hFile, SFileInfoNameHash2) << - std::setfill(' ') << std::dec << " "; - }}, - {"name-hash3", [&]() { - std::cout << std::setfill('0') << std::hex << std::setw(16) << - GetFileInfo(hFile, SFileInfoNameHash3) << - std::setfill(' ') << std::dec << " "; - }}, - {"locale", [&]() { - int32_t fileLocale = GetFileInfo(hFile, SFileInfoLocale); - std::string fileLocaleStr = LocaleToLang(fileLocale); - std::cout << std::setw(4) << fileLocaleStr << " "; - }}, - {"file-index", [&]() { - std::cout << std::setw(5) << GetFileInfo(hFile, SFileInfoFileIndex) << " "; - }}, - {"byte-offset", [&]() { - std::cout << std::hex << std::setw(8) << - GetFileInfo(hFile, SFileInfoByteOffset) << - std::dec << " "; - }}, - {"file-time", [&]() { - int64_t fileTime = GetFileInfo(hFile, SFileInfoFileTime); - std::string fileTimeStr = FileTimeToLsTime(fileTime); - std::cout << std::setw(19) << fileTimeStr << " "; - }}, - {"file-size", [&]() { - std::cout << std::setw(8) << GetFileInfo(hFile, SFileInfoFileSize) << " "; - }}, - {"compressed-size", [&]() { - std::cout << std::setw(8) << GetFileInfo(hFile, SFileInfoCompressedSize) << " "; - }}, - {"flags", [&]() { - int32_t flags = GetFileInfo(hFile, SFileInfoFlags); - std::cout << std::setw(8) << GetFlagString(flags) << " "; - }}, - {"encryption-key", [&]() { - std::cout << std::setfill('0') << std::hex << std::setw(8) << - GetFileInfo(hFile, SFileInfoEncryptionKey) << - std::setfill(' ') << std::dec << " "; - }}, - {"encryption-key-raw", [&]() { - std::cout << std::setfill('0') << std::hex << std::setw(8) << - GetFileInfo(hFile, SFileInfoEncryptionKeyRaw) << - std::setfill(' ') << std::dec << " "; - }}, + {"hash-index", + [&]() { + std::cout << std::setw(5) + << GetFileInfo(hFile, SFileInfoHashIndex) << " "; + }}, + {"name-hash1", + [&]() { + std::cout << std::setfill('0') << std::hex << std::setw(8) + << GetFileInfo(hFile, SFileInfoNameHash1) + << std::setfill(' ') << std::dec << " "; + }}, + {"name-hash2", + [&]() { + std::cout << std::setfill('0') << std::hex << std::setw(8) + << GetFileInfo(hFile, SFileInfoNameHash2) + << std::setfill(' ') << std::dec << " "; + }}, + {"name-hash3", + [&]() { + std::cout << std::setfill('0') << std::hex << std::setw(16) + << GetFileInfo(hFile, SFileInfoNameHash3) + << std::setfill(' ') << std::dec << " "; + }}, + {"locale", + [&]() { + int32_t fileLocale = GetFileInfo(hFile, SFileInfoLocale); + std::string fileLocaleStr = LocaleToLang(fileLocale); + std::cout << std::setw(4) << fileLocaleStr << " "; + }}, + {"file-index", + [&]() { + std::cout << std::setw(5) + << GetFileInfo(hFile, SFileInfoFileIndex) << " "; + }}, + {"byte-offset", + [&]() { + std::cout << std::hex << std::setw(8) + << GetFileInfo(hFile, SFileInfoByteOffset) << std::dec + << " "; + }}, + {"file-time", + [&]() { + int64_t fileTime = GetFileInfo(hFile, SFileInfoFileTime); + std::string fileTimeStr = FileTimeToLsTime(fileTime); + std::cout << std::setw(19) << fileTimeStr << " "; + }}, + {"file-size", + [&]() { + std::cout << std::setw(8) << GetFileInfo(hFile, SFileInfoFileSize) + << " "; + }}, + {"compressed-size", + [&]() { + std::cout << std::setw(8) + << GetFileInfo(hFile, SFileInfoCompressedSize) << " "; + }}, + {"flags", + [&]() { + int32_t flags = GetFileInfo(hFile, SFileInfoFlags); + std::cout << std::setw(8) << GetFlagString(flags) << " "; + }}, + {"encryption-key", + [&]() { + std::cout << std::setfill('0') << std::hex << std::setw(8) + << GetFileInfo(hFile, SFileInfoEncryptionKey) + << std::setfill(' ') << std::dec << " "; + }}, + {"encryption-key-raw", + [&]() { + std::cout << std::setfill('0') << std::hex << std::setw(8) + << GetFileInfo(hFile, SFileInfoEncryptionKeyRaw) + << std::setfill(' ') << std::dec << " "; + }}, }; - for (const auto& prop : propertiesToPrint) { - for (const auto &[key, action]: propertyActions) { + for (const auto &prop : propertiesToPrint) { + for (const auto &[key, action] : propertyActions) { if (prop == key) { action(); // Print property } @@ -493,8 +498,7 @@ int ListFiles(HANDLE hArchive, const std::string& listfileName, bool listAll, bo std::cout << " " << findData.cFileName << std::endl; SFileCloseFile(hFile); } - SFileSetLocale(defaultLocale); // Reset locale to default after changing it - free(fileLocales); + SFileSetLocale(defaultLocale); // Reset locale to default after changing it } else { // Print just the filename (like default ls command output) std::cout << findData.cFileName << std::endl; @@ -506,109 +510,113 @@ int ListFiles(HANDLE hArchive, const std::string& listfileName, bool listAll, bo return 0; } -char* ReadFile(HANDLE hArchive, const char *szFileName, unsigned int *fileSize, LCID preferredLocale) { +std::unique_ptr ReadFile(HANDLE hArchive, const char *szFileName, unsigned int *fileSize, + LCID preferredLocale) { SFileSetLocale(preferredLocale); - if ( - !FileExistsInArchiveForLocale(hArchive, szFileName, preferredLocale) && - !FileExistsInArchiveForLocale(hArchive, szFileName, defaultLocale) - ) { + if (!FileExistsInArchiveForLocale(hArchive, szFileName, preferredLocale) && + !FileExistsInArchiveForLocale(hArchive, szFileName, defaultLocale)) { std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(preferredLocale, " for locale ", true) - << ": " << szFileName << std::endl; - return NULL; + << PrettyPrintLocale(preferredLocale, " for locale ", true) << ": " << szFileName + << std::endl; + return nullptr; } HANDLE hFile; if (!SFileOpenFileEx(hArchive, szFileName, SFILE_OPEN_FROM_MPQ, &hFile)) { std::cerr << "[!] Failed: File cannot be opened: " << szFileName << std::endl; - return NULL; + return nullptr; } - *fileSize = SFileGetFileSize(hFile, NULL); + *fileSize = SFileGetFileSize(hFile, nullptr); if (*fileSize == SFILE_INVALID_SIZE) { std::cerr << "[!] Failed: Invalid file size for: " << szFileName << std::endl; SFileCloseFile(hFile); - return NULL; + return nullptr; } - char* fileContent = new char[*fileSize]; + auto fileContent = std::make_unique(*fileSize); DWORD dwBytesRead; - if (!SFileReadFile(hFile, fileContent, *fileSize, &dwBytesRead, NULL)) { + if (!SFileReadFile(hFile, fileContent.get(), *fileSize, &dwBytesRead, nullptr)) { std::cerr << "[!] Failed: Cannot read file contents for: " << szFileName << std::endl; - delete[] fileContent; SFileCloseFile(hFile); - return NULL; + return nullptr; } SFileCloseFile(hFile); return fileContent; } -void PrintMpqInfo(HANDLE hArchive, const std::string& infoProperty) { +void PrintMpqInfo(HANDLE hArchive, const std::string &infoProperty) { // Map of property names to their corresponding actions std::map> propertyActions = { - {"format-version", [&](bool printName) { - TMPQHeader header = GetFileInfo(hArchive, SFileMpqHeader); - uint16_t formatVersion = header.wFormatVersion + 1; // Add +1 because StormLib starts at 0 - if (printName) { - std::cout << "Format version: "; - } - std::cout << formatVersion << std::endl; - }}, - {"header-offset", [&](bool printName) { - int64_t headerOffset = GetFileInfo(hArchive, SFileMpqHeaderOffset); - if (printName) { - std::cout << "Header offset: "; - } - std::cout << headerOffset << std::endl; - }}, - {"header-size", [&](bool printName) { - int64_t headerSize = GetFileInfo(hArchive, SFileMpqHeaderSize); - if (printName) { - std::cout << "Header size: "; - } - std::cout << headerSize << std::endl; - }}, - {"archive-size", [&](bool printName) { - int32_t archiveSize = GetFileInfo(hArchive, SFileMpqArchiveSize); - if (printName) { - std::cout << "Archive size: "; - } - std::cout << archiveSize << std::endl; - }}, - {"file-count", [&](bool printName) { - int32_t numberOfFiles = GetFileInfo(hArchive, SFileMpqNumberOfFiles); - if (printName) { - std::cout << "File count: "; - } - std::cout << numberOfFiles << std::endl; - }}, - {"max-files", [&](bool printName) { - int32_t maxFiles = GetFileInfo(hArchive, SFileMpqMaxFileCount); - if (printName) { - std::cout << "Max files: "; - } - std::cout << maxFiles << std::endl; - }}, + {"format-version", + [&](bool printName) { + TMPQHeader header = GetFileInfo(hArchive, SFileMpqHeader); + uint16_t formatVersion = + header.wFormatVersion + 1; // Add +1 because StormLib starts at 0 + if (printName) { + std::cout << "Format version: "; + } + std::cout << formatVersion << std::endl; + }}, + {"header-offset", + [&](bool printName) { + int64_t headerOffset = GetFileInfo(hArchive, SFileMpqHeaderOffset); + if (printName) { + std::cout << "Header offset: "; + } + std::cout << headerOffset << std::endl; + }}, + {"header-size", + [&](bool printName) { + int64_t headerSize = GetFileInfo(hArchive, SFileMpqHeaderSize); + if (printName) { + std::cout << "Header size: "; + } + std::cout << headerSize << std::endl; + }}, + {"archive-size", + [&](bool printName) { + int32_t archiveSize = GetFileInfo(hArchive, SFileMpqArchiveSize); + if (printName) { + std::cout << "Archive size: "; + } + std::cout << archiveSize << std::endl; + }}, + {"file-count", + [&](bool printName) { + int32_t numberOfFiles = GetFileInfo(hArchive, SFileMpqNumberOfFiles); + if (printName) { + std::cout << "File count: "; + } + std::cout << numberOfFiles << std::endl; + }}, + {"max-files", + [&](bool printName) { + int32_t maxFiles = GetFileInfo(hArchive, SFileMpqMaxFileCount); + if (printName) { + std::cout << "Max files: "; + } + std::cout << maxFiles << std::endl; + }}, {"signature-type", [&](bool printName) { - int32_t signatureType = GetFileInfo(hArchive, SFileMpqSignatures); - if (printName) { - std::cout << "Signature type: "; - } - if (signatureType == SIGNATURE_TYPE_NONE) { - std::cout << "None" << std::endl; - } else if (signatureType == SIGNATURE_TYPE_WEAK) { - std::cout << "Weak" << std::endl; - } else if (signatureType == SIGNATURE_TYPE_STRONG) { - std::cout << "Strong" << std::endl; - } - }} - }; + int32_t signatureType = GetFileInfo(hArchive, SFileMpqSignatures); + if (printName) { + std::cout << "Signature type: "; + } + if (signatureType == SIGNATURE_TYPE_NONE) { + std::cout << "None" << std::endl; + } else if (signatureType == SIGNATURE_TYPE_WEAK) { + std::cout << "Weak" << std::endl; + } else if (signatureType == SIGNATURE_TYPE_STRONG) { + std::cout << "Strong" << std::endl; + } + }}}; // If infoProperty is "default", print all properties with their names (key) // Otherwise, print only the property value if (infoProperty == "default") { - for (const auto& [key, action] : propertyActions) { + for (const auto &[key, action] : propertyActions) { action(true); // Print property name and value } } else { @@ -622,10 +630,10 @@ void PrintMpqInfo(HANDLE hArchive, const std::string& infoProperty) { template T GetFileInfo(HANDLE hFile, SFileInfoClass infoClass) { T value{}; - if (!SFileGetFileInfo(hFile, infoClass, &value, sizeof(T), NULL)) { + if (!SFileGetFileInfo(hFile, infoClass, &value, sizeof(T), nullptr)) { int32_t error = SErrGetLastError(); // std::cerr << "[!] GetFileInfo failed (Error: " << error << ")" << std::endl; - return T{}; // Return default value for the type + return T{}; // Return default value for the type } return value; } @@ -634,39 +642,33 @@ uint32_t VerifyMpqArchive(HANDLE hArchive) { return SFileVerifyArchive(hArchive); } -int32_t PrintMpqSignature(HANDLE hArchive, std::string target) { +int32_t PrintMpqSignature(HANDLE hArchive, const std::string &target) { // Determine if we have a strong or weak digital signature - int32_t signatureType = - GetFileInfo(hArchive, SFileMpqSignatures); + int32_t signatureType = GetFileInfo(hArchive, SFileMpqSignatures); std::vector signatureContent; if (signatureType == SIGNATURE_TYPE_NONE) { return 1; } else if (signatureType == SIGNATURE_TYPE_WEAK) { - const char* szFileName = "(signature)"; + const char *szFileName = "(signature)"; uint32_t fileSize; - char* fileContent = ReadFile(hArchive, szFileName, &fileSize, defaultLocale); + auto fileContent = ReadFile(hArchive, szFileName, &fileSize, defaultLocale); - if (fileContent == NULL) { + if (!fileContent) { std::cerr << "[!] Failed to read weak signature file." << std::endl; return -1; } signatureContent.resize(fileSize); - std::copy(fileContent, fileContent + fileSize, - signatureContent.begin()); + std::copy(fileContent.get(), fileContent.get() + fileSize, signatureContent.begin()); - PrintAsBinary(fileContent, fileSize); - delete[] fileContent; + PrintAsBinary(fileContent.get(), fileSize); } else if (signatureType == SIGNATURE_TYPE_STRONG) { - signatureContent = GetFileInfo>( - hArchive, SFileMpqStrongSignature); + signatureContent = GetFileInfo>(hArchive, SFileMpqStrongSignature); if (signatureContent.empty()) { - int64_t archiveSize = - GetFileInfo(hArchive, SFileMpqArchiveSize64); - int64_t archiveOffset = - GetFileInfo(hArchive, SFileMpqHeaderOffset); + int64_t archiveSize = GetFileInfo(hArchive, SFileMpqArchiveSize64); + int64_t archiveOffset = GetFileInfo(hArchive, SFileMpqHeaderOffset); const fs::path archivePath = fs::canonical(target); std::uintmax_t fileSize = fs::file_size(archivePath); @@ -678,12 +680,7 @@ int32_t PrintMpqSignature(HANDLE hArchive, std::string target) { file_mpq.read(signatureContent.data(), signatureContent.size()); file_mpq.close(); - char* fileContent = new char[signatureContent.size()]; - std::copy(signatureContent.begin(), signatureContent.end(), - fileContent); - - PrintAsBinary(fileContent, static_cast(signatureContent.size())); - delete[] fileContent; + PrintAsBinary(signatureContent.data(), static_cast(signatureContent.size())); } } diff --git a/src/mpq.h b/src/mpq.h index 1f367d7..3d8e311 100644 --- a/src/mpq.h +++ b/src/mpq.h @@ -1,10 +1,12 @@ #ifndef MPQ_H #define MPQ_H -#include #include +#include +#include #include + #include "gamerules.h" namespace fs = std::filesystem; @@ -12,17 +14,26 @@ namespace fs = std::filesystem; int OpenMpqArchive(const std::string &filename, HANDLE *hArchive, int32_t flags); 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(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(), bool overwrite = false); -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); -void PrintMpqInfo(HANDLE hArchive, const std::string& infoProperty); +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(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(), + bool overwrite = false); +int RemoveFile(HANDLE hArchive, const std::string &archiveFilePath, LCID locale); +int ListFiles(HANDLE hArchive, const std::string &listfileName, bool listAll, bool listDetailed, + std::vector &propertiesToPrint); +std::unique_ptr ReadFile(HANDLE hArchive, const char *szFileName, unsigned int *fileSize, + LCID preferredLocale); +void PrintMpqInfo(HANDLE hArchive, const std::string &infoProperty); uint32_t VerifyMpqArchive(HANDLE hArchive); -int32_t PrintMpqSignature(HANDLE hArchive, std::string target); +int32_t PrintMpqSignature(HANDLE hArchive, const std::string &target); template T GetFileInfo(HANDLE hFile, SFileInfoClass infoClass); diff --git a/src/validators.h b/src/validators.h new file mode 100644 index 0000000..28009b4 --- /dev/null +++ b/src/validators.h @@ -0,0 +1,33 @@ +#ifndef VALIDATORS_H +#define VALIDATORS_H + +#include + +#include "gamerules.h" +#include "locales.h" + +// Defined in gamerules.cpp +extern const CLI::Validator GameProfileValid; + +// Inline locale validator +inline const auto LocaleValid = CLI::Validator( + [](const std::string &str) { + if (str == "default") return std::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()) { + validLocales += " " + l; + } + return validLocales; + } + return std::string(); + }, + "", "LocaleValidator"); + +#endif // VALIDATORS_H From cae8024a3d6f3f581406175979c188993803ef21 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 7 Apr 2026 12:24:17 +1200 Subject: [PATCH 05/12] Updated readme badged --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cdd6736..d732e9c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![Release Version](https://img.shields.io/github/v/release/TheGrayDot/mpqcli?style=flat) -![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?label=release_downloads) ![Package downloads](https://img.shields.io/badge/package_downloads-353-green) +![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?label=release_downloads) ![Package downloads](https://img.shields.io/badge/package_downloads-469-green) A command-line tool to create, add, remove, list, extract, read, and verify MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). From bbdcdee98921b2449f1f52dc27e41e486ceaf15a Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 12 Apr 2026 15:18:57 +1200 Subject: [PATCH 06/12] Small clang format changes --- .clang-format | 9 ++++----- src/gamerules.cpp | 48 +++++++++++++++++++++++++++++++---------------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/.clang-format b/.clang-format index e201492..2d2c904 100644 --- a/.clang-format +++ b/.clang-format @@ -8,14 +8,13 @@ IncludeBlocks: Regroup IncludeIsMainRegex: '([-_]test)?$' IncludeCategories: - Regex: '^<(StormLib\.h|CLI/)' - Priority: 3 - - Regex: '^<' Priority: 2 + - Regex: '^<' + Priority: 1 - Regex: '^"' - Priority: 4 + Priority: 3 ReflowComments: true BreakBeforeBraces: Attach Cpp11BracedListStyle: true -AllowShortCaseLabelsOnASingleLine: true AllowShortFunctionsOnASingleLine: InlineOnly -SortIncludes: CaseSensitive +SortIncludes: true diff --git a/src/gamerules.cpp b/src/gamerules.cpp index 2601619..1b5c769 100644 --- a/src/gamerules.cpp +++ b/src/gamerules.cpp @@ -229,22 +229,38 @@ GameProfile GameRules::StringToProfile(const std::string &profileName) { // 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"; + 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"; } } From 724235e67d9f4fe29190fa8afd5f5e035416aeef Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 12 Apr 2026 15:22:50 +1200 Subject: [PATCH 07/12] Small clang tidy changes --- .clang-tidy | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index c58bb6f..5f27371 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -8,10 +8,10 @@ Checks: > -bugprone-exception-escape, -bugprone-narrowing-conversions, cppcoreguidelines-no-malloc, - cppcoreguidelines-owning-memory, - misc-unused-parameters, + -cppcoreguidelines-owning-memory, performance-unnecessary-value-param, readability-inconsistent-declaration-parameter-name, readability-container-size-empty - WarningsAsErrors: "*" +HeaderFilterRegex: "src/.*" +FormatStyle: file From b4a53fa765b6b7dab58f4825e1506c190f2fc846 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 12 Apr 2026 15:25:00 +1200 Subject: [PATCH 08/12] Fixed helpers import order, and clang tidy docs --- src/helpers.cpp | 4 ++-- src/main.cpp | 2 +- src/mpq.cpp | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/helpers.cpp b/src/helpers.cpp index 6b1e7c5..14c07d8 100644 --- a/src/helpers.cpp +++ b/src/helpers.cpp @@ -1,3 +1,5 @@ +#include "helpers.h" + #include #include #include @@ -10,8 +12,6 @@ #include -#include "helpers.h" - namespace fs = std::filesystem; std::string FileTimeToLsTime(int64_t fileTime) { diff --git a/src/main.cpp b/src/main.cpp index a4e5712..65a9c81 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -57,7 +57,7 @@ int main(int argc, char **argv) { // CLI: verify bool verifyPrintSignature = false; - // clang-format off + // clang-format off: preserve vertical alignment of string set initialisers std::set validInfoProperties = { "format-version", "header-offset", diff --git a/src/mpq.cpp b/src/mpq.cpp index 28e1a8d..52d640a 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -309,7 +309,7 @@ int RemoveFile(HANDLE hArchive, const std::string &archiveFilePath, LCID locale) std::string GetFlagString(uint32_t flags) { std::string result; - // clang-format off + // clang-format off: preserve column-aligned flag-to-char mappings if (flags & MPQ_FILE_IMPLODE) result += 'i'; if (flags & MPQ_FILE_COMPRESS) result += 'c'; if (flags & MPQ_FILE_ENCRYPTED) result += 'e'; From b742ac4f25680d1a3d5d001eefa87656e41e3356 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 12 Apr 2026 15:35:41 +1200 Subject: [PATCH 09/12] Updated Makefile --- .clang-tidy | 1 - Makefile | 43 +++++++++++++++++++++++-------------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index 5f27371..baa8413 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -13,5 +13,4 @@ Checks: > readability-inconsistent-declaration-parameter-name, readability-container-size-empty WarningsAsErrors: "*" -HeaderFilterRegex: "src/.*" FormatStyle: file diff --git a/Makefile b/Makefile index f0c5068..c65751a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ CMAKE_BUILD_TYPE := Release BUILD_MPQCLI := ON +CLANG_VERSION := 18 VERSION := $(shell awk '/project\(MPQCLI VERSION/ {gsub(/\)/, "", $$3); print $$3}' CMakeLists.txt) README := README.md PACKAGE_URL := https://github.com/TheGrayDot/mpqcli/pkgs/container/mpqcli @@ -7,6 +8,7 @@ PACKAGE_URL := https://github.com/TheGrayDot/mpqcli/pkgs/container/mpqcli GCC_INSTALL_DIR := $(shell dirname "$(shell gcc -print-libgcc-file-name)") .PHONY: help \ + setup \ build_linux build_windows build_clean build_lint_clean \ docker_musl_build docker_musl_run docker_glibc_build docker_glibc_run \ test_create_venv test_mpqcli test_clean test_lint \ @@ -15,6 +17,10 @@ GCC_INSTALL_DIR := $(shell dirname "$(shell gcc -print-libgcc-file-name)") bump_stormlib bump_cli11 bump_submodules \ fetch_downloads tag_release +## Install clang lint dependencies +setup: + sudo apt-get install -y clang-format-$(CLANG_VERSION) clang-tidy-$(CLANG_VERSION) + ## Show this help menu help: @awk 'BEGIN {FS = ":"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} \ @@ -42,9 +48,13 @@ build_clean: rm -rf build ## Generate compile_commands.json for clang-tidy -build_lint: CMakeLists.txt src/CMakeLists.txt - cmake -B build_lint -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DBUILD_MPQCLI=ON -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)" - @touch $@ +build_lint/compile_commands.json: CMakeLists.txt src/CMakeLists.txt + cmake -B build_lint \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DBUILD_MPQCLI=ON \ + -DCMAKE_CXX_COMPILER=clang++-$(CLANG_VERSION) \ + -DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)" ## Remove cmake lint build directory build_lint_clean: @@ -89,17 +99,20 @@ test_lint: ruff check ./test # LINT -## Check C++ formatting with clang-format-18 +## Check C++ formatting with clang-format lint_format: - find src \( -name "*.cpp" -o -name "*.h" \) | xargs clang-format-18 --dry-run --Werror + find src \( -name "*.cpp" -o -name "*.h" \) \ + | xargs clang-format-$(CLANG_VERSION) --dry-run --Werror -## Auto-fix C++ formatting with clang-format-18 +## Auto-fix C++ formatting with clang-format lint_format_fix: - find src \( -name "*.cpp" -o -name "*.h" \) | xargs clang-format-18 -i + find src \( -name "*.cpp" -o -name "*.h" \) \ + | xargs clang-format-$(CLANG_VERSION) -i -## Run clang-tidy-18 static analysis -lint_cpp: build_lint - clang-tidy-18 --quiet -p build_lint --header-filter="$(CURDIR)/src/.*" src/*.cpp +## Run clang-tidy static analysis +lint_cpp: build_lint/compile_commands.json + clang-tidy-$(CLANG_VERSION) \ + --quiet -p build_lint --header-filter="$(CURDIR)/src/.*" src/*.cpp ## Run all C++ linters lint: lint_format lint_cpp @@ -139,13 +152,3 @@ fetch_downloads: | head -1); \ sed -i "s/package_downloads-[0-9]*-green/package_downloads-$$DOWNLOADS-green/" $(README); \ echo "[*] Updated package downloads badge: $$DOWNLOADS" - -## Tag and push the current project version -tag_release: - @echo "[*] Current version: v$(VERSION)" - @read -rp "[*] Tag and Release? (y/N) " yn; \ - case $$yn in \ - [yY] ) git tag "v$(VERSION)" && git push --tags && echo "[*] Tagged and pushed v$(VERSION)";; \ - [nN] ) echo "[*] Exiting...";; \ - * ) echo "[*] Invalid response... Exiting"; exit 1;; \ - esac From dc5650093266792b66e6ea77287c94ea1b7b3efb Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 12 Apr 2026 15:42:35 +1200 Subject: [PATCH 10/12] Updated contributing docs --- CONTRIBUTING.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++--- README.md | 2 +- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b986f50..606d8d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,40 @@ If you are unsure whether a feature fits the project, or whether an existing too **mpqcli follows the Unix philosophy.** The tool is designed to do one thing well and to compose with other tools via pipes and redirection. If you find yourself wanting to add functionality that could be handled by a separate tool — for example, sorting the output of `list` — the right answer is usually to pipe the output to that tool rather than adding it here. +## Prerequisites and Setup + +Clone the repository and initialise submodules: + +``` +git clone https://github.com/TheGrayDot/mpqcli.git +cd mpqcli +git submodule update --init --recursive +``` + +Install the clang lint tools: + +``` +make setup +``` + +## Makefile Reference + +Run `make help` to list all available targets. Common ones: + +| Target | Description | +|---|---| +| `make setup` | Install clang-format and clang-tidy via apt | +| `make build_linux` | Build for Linux using cmake | +| `make build_windows` | Build for Windows using cmake | +| `make build_clean` | Remove the cmake build directory | +| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | +| `make test_mpqcli` | Run the pytest test suite | +| `make lint` | Run all C++ linters (clang-format + clang-tidy) | +| `make lint_format` | Check formatting only (dry run) | +| `make lint_format_fix` | Auto-fix formatting in-place | +| `make lint_cpp` | Run clang-tidy static analysis | +| `make clean` | Remove all build and test artifacts | + ## Requirements for a Pull Request ### 1. Builds on your platform @@ -36,12 +70,54 @@ All tests must pass without errors. If your change adds or modifies user-facing functionality — such as a new subcommand flag or a change in output format — please include a corresponding test in the `test/` directory. The existing test files (`test_list.py`, `test_add.py`, etc.) are good references for the test style and fixtures used. -### 4. Match the existing code style +### 4. Linting must pass + +All C++ code is formatted with clang-format and analysed with clang-tidy. Run the full suite before submitting: + +``` +make lint +``` -There is no enforced formatter. Write C++ that looks consistent with the surrounding code, and Python tests that follow the style of the existing test files. +If there are formatting violations, auto-fix them with: + +``` +make lint_format_fix +``` + +Then re-run `make lint` to confirm everything passes. + +### 5. Match the existing code style + +C++ formatting is enforced by `.clang-format` (Google style base). Static analysis is enforced by `.clang-tidy`. Both configs live in the repo root. Python tests should follow the style of the existing test files. + +#### Suppression policy + +Suppressions are occasionally necessary for third-party code or intentional patterns. When suppressing a clang-tidy warning: + +- Use `// NOLINT(check-name)` with the specific check name — bare `// NOLINT` is not acceptable +- Every suppression must have a comment explaining why it is justified + +```cpp +// NOLINT(bugprone-easily-swappable-parameters): parameters validated by CLI11 +``` + +#### Disabling clang-format locally + +Use `// clang-format off` / `// clang-format on` only when the default formatting genuinely hurts readability (e.g. column-aligned tables). Add a brief comment explaining the intent: + +```cpp +// clang-format off: preserve column-aligned flag-to-char mappings for readability +if (flags & MPQ_FILE_IMPLODE) result += 'i'; +if (flags & MPQ_FILE_COMPRESS) result += 'c'; +// clang-format on +``` ## Workflow Summary 1. Fork the repository and create a branch for your change -2. Make your changes and verify they build and all tests pass -3. Open a pull request with a clear description of what was changed and why +2. Run `git submodule update --init --recursive` after cloning +3. Run `make install_clang_tools` to install lint dependencies +4. Make your changes and verify they build: `make build_linux` +5. Run `make lint` and fix any issues +6. Run `make test_mpqcli` and confirm all tests pass +7. Open a pull request with a clear description of what was changed and why diff --git a/README.md b/README.md index d732e9c..778eee9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![Release Version](https://img.shields.io/github/v/release/TheGrayDot/mpqcli?style=flat) -![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?label=release_downloads) ![Package downloads](https://img.shields.io/badge/package_downloads-469-green) +![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?label=release_downloads) ![Package downloads](https://img.shields.io/badge/package_downloads-497-green) A command-line tool to create, add, remove, list, extract, read, and verify MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). From ae963196592dc2fdbae9d9b42588df2c1c0edc5b Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 12 Apr 2026 15:42:43 +1200 Subject: [PATCH 11/12] Cleaned up makefile --- Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index c65751a..2699ac6 100644 --- a/Makefile +++ b/Makefile @@ -17,10 +17,6 @@ GCC_INSTALL_DIR := $(shell dirname "$(shell gcc -print-libgcc-file-name)") bump_stormlib bump_cli11 bump_submodules \ fetch_downloads tag_release -## Install clang lint dependencies -setup: - sudo apt-get install -y clang-format-$(CLANG_VERSION) clang-tidy-$(CLANG_VERSION) - ## Show this help menu help: @awk 'BEGIN {FS = ":"; printf "\nUsage:\n make \033[36m\033[0m\n\nTargets:\n"} \ @@ -28,6 +24,10 @@ help: /^[a-zA-Z0-9_-]+:/ {if (desc) printf " \033[36m%-22s\033[0m %s\n", $$1, desc; desc = ""; next} \ {desc = ""}' $(MAKEFILE_LIST) +## Install clang lint dependencies +install_clang_tools: + sudo apt-get install -y clang-format-$(CLANG_VERSION) clang-tidy-$(CLANG_VERSION) + # BUILD ## Build for Linux using cmake build_linux: From bc4af3a5dbb05c34cbca30d5b8212c501e934873 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 12 Apr 2026 15:43:01 +1200 Subject: [PATCH 12/12] Updated workflows --- .github/workflows/lint.yml | 17 +++++------------ .github/workflows/main.yml | 4 ++++ .github/workflows/pr.yml | 1 + 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 414e969..bfc6e5d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,25 +8,18 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out repository code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: true - name: Install clang tools - run: sudo apt-get install -y clang-format-18 clang-tidy-18 + run: make install_clang_tools - name: Check formatting - run: find src \( -name "*.cpp" -o -name "*.h" \) | xargs clang-format-18 --dry-run --Werror + run: make lint - name: Generate compile_commands.json - run: | - GCC_INSTALL_DIR=$(dirname "$(gcc -print-libgcc-file-name)") - cmake -B build_lint \ - -DCMAKE_BUILD_TYPE=Debug \ - -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - -DBUILD_MPQCLI=ON \ - -DCMAKE_CXX_COMPILER=clang++-18 \ - -DCMAKE_CXX_FLAGS="--gcc-install-dir=${GCC_INSTALL_DIR}" + run: make build_lint/compile_commands.json - name: Run clang-tidy - run: clang-tidy-18 --quiet -p build_lint --header-filter="$(pwd)/src/.*" src/*.cpp + run: make lint_cpp diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cf58964..369afa6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,6 +16,9 @@ permissions: jobs: build: uses: ./.github/workflows/build.yml + lint: + uses: ./.github/workflows/lint.yml + needs: build test: uses: ./.github/workflows/test.yml needs: build @@ -23,5 +26,6 @@ jobs: uses: ./.github/workflows/prerelease.yml needs: - build + - lint - test secrets: inherit diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5cff590..820781c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -11,6 +11,7 @@ jobs: uses: ./.github/workflows/build.yml lint: uses: ./.github/workflows/lint.yml + needs: build test: uses: ./.github/workflows/test.yml needs: build