From f1c7c7a2e020eedfe376282c6990c490cb5574ed Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:54:12 +1200 Subject: [PATCH 01/59] Split src into a core library and app into the CLI binary --- .github/workflows/build.yml | 2 +- CMakeLists.txt | 85 +++++++++++++++++++++---------------- Dockerfile.glibc | 4 +- Dockerfile.musl | 4 +- Makefile | 8 ++-- app/CMakeLists.txt | 5 +++ {src => app}/main.cpp | 0 {src => app}/validators.h | 0 src/CMakeLists.txt | 35 ++++++--------- 9 files changed, 76 insertions(+), 67 deletions(-) create mode 100644 app/CMakeLists.txt rename {src => app}/main.cpp (100%) rename {src => app}/validators.h (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6ec4dc..3ca1603 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,7 +81,7 @@ jobs: cmake -B build \ -G "Visual Studio 17 2022" \ -A x64 \ - -DBUILD_MPQCLI=ON + -DMPQCLI_BUILD_APP=ON - name: Compile project run: cmake --build build --config Release diff --git a/CMakeLists.txt b/CMakeLists.txt index b4a4cc9..7d5d1fb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,9 +2,10 @@ cmake_minimum_required(VERSION 3.25) project(MPQCLI VERSION 0.10.2) -# Options -option(BUILD_MPQCLI "Build the mpqcli CLI app" ON) -option(BUILD_STATIC "Build static binary" OFF) +# An option() name is global to the CMake cache, including a consumer's, so these +# carry the project prefix; a bare BUILD_STATIC claims a name anyone might want. +option(MPQCLI_BUILD_APP "Build the mpqcli binary" ON) +option(MPQCLI_BUILD_STATIC "Build static binary" OFF) # Set project defaults set(CMAKE_CXX_STANDARD 17) @@ -16,9 +17,16 @@ endif() set(CMAKE_POSITION_INDEPENDENT_CODE ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") + +# Windows specific settings +if(MSVC) + add_compile_definitions(__STORMLIB_NO_STATIC_LINK__) + set(CMAKE_GENERATOR_PLATFORM x64) +endif() # Static linking configuration -if(BUILD_STATIC) +if(MPQCLI_BUILD_STATIC) set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -static") set(CMAKE_FIND_LIBRARY_SUFFIXES ".a") set(BUILD_SHARED_LIBS OFF) @@ -56,37 +64,40 @@ set(STORM_USE_BUNDLED_LIBRARIES ON CACHE BOOL "" FORCE) add_subdirectory(extern/StormLib) # Handle CLI11 dependency -if(BUILD_MPQCLI) - if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/CLI11/CMakeLists.txt") - message(FATAL_ERROR - "Missing dependency: CLI11 - mpqcli requires the CLI11 library. - It is provided as a submodule of this repository. - Did you forget to execute the following commands? - git submodule init - git submodule update") - endif() - - add_subdirectory(extern/CLI11) - - # Handle hash-library dependency (CRC32 + MD5 for the add --update checks) - if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/hash-library/crc32.cpp") - message(FATAL_ERROR - "Missing dependency: hash-library - mpqcli requires the hash-library library. - It is provided as a submodule of this repository. - Did you forget to execute the following commands? - git submodule init - git submodule update") - endif() - - # hash-library ships no CMakeLists; compile only the two algorithms we use - add_library(hash-library STATIC - extern/hash-library/crc32.cpp - extern/hash-library/md5.cpp - ) - target_include_directories(hash-library SYSTEM INTERFACE "${CMAKE_SOURCE_DIR}/extern") - - # Add the main application - add_subdirectory(src) +if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/CLI11/CMakeLists.txt") + message(FATAL_ERROR +"Missing dependency: CLI11 +mpqcli requires the CLI11 library. +It is provided as a submodule of this repository. +Did you forget to execute the following commands? + git submodule init + git submodule update") +endif() + +add_subdirectory(extern/CLI11) + +# Handle hash-library dependency (CRC32 + MD5 for the add --update checks) +if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/hash-library/crc32.cpp") + message(FATAL_ERROR +"Missing dependency: hash-library +mpqcli requires the hash-library library. +It is provided as a submodule of this repository. +Did you forget to execute the following commands? + git submodule init + git submodule update") +endif() + +# hash-library ships no CMakeLists; compile only the two algorithms we use +add_library(hash-library STATIC + extern/hash-library/crc32.cpp + extern/hash-library/md5.cpp +) +target_include_directories(hash-library SYSTEM INTERFACE "${CMAKE_SOURCE_DIR}/extern") + +# Unconditional: building the core without the app is what proves nothing in +# src/ has picked up a dependency on app/. +add_subdirectory(src) + +if(MPQCLI_BUILD_APP) + add_subdirectory(app) endif() diff --git a/Dockerfile.glibc b/Dockerfile.glibc index 8310658..165c65a 100644 --- a/Dockerfile.glibc +++ b/Dockerfile.glibc @@ -13,8 +13,8 @@ COPY . . RUN cmake -B build \ -DCMAKE_BUILD_TYPE=Release \ - -DBUILD_MPQCLI=ON \ - -DBUILD_STATIC=ON + -DMPQCLI_BUILD_APP=ON \ + -DMPQCLI_BUILD_STATIC=ON RUN cmake --build build diff --git a/Dockerfile.musl b/Dockerfile.musl index 012213d..741de7c 100644 --- a/Dockerfile.musl +++ b/Dockerfile.musl @@ -12,8 +12,8 @@ COPY . . RUN cmake -B build \ -DCMAKE_BUILD_TYPE=Release \ - -DBUILD_MPQCLI=ON \ - -DBUILD_STATIC=ON + -DMPQCLI_BUILD_APP=ON \ + -DMPQCLI_BUILD_STATIC=ON RUN cmake --build build diff --git a/Makefile b/Makefile index 6b21c54..170445a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ SHELL := /bin/bash CMAKE_BUILD_TYPE := Release -BUILD_MPQCLI := ON +MPQCLI_BUILD_APP := ON CLANG_VERSION := 18 VERSION := $(shell awk '/project\(MPQCLI VERSION/ {gsub(/\)/, "", $$3); print $$3}' CMakeLists.txt) README := README.md @@ -24,7 +24,7 @@ configure: ## Configure cmake build (debug, with compile_commands.json) cmake -B build \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - -DBUILD_MPQCLI=$(BUILD_MPQCLI) \ + -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) \ -DCMAKE_CXX_COMPILER=clang++-$(CLANG_VERSION) \ -DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)" @@ -36,14 +36,14 @@ build: ## Build via cmake build_linux: ## Build for Linux using cmake cmake -B build \ -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ - -DBUILD_MPQCLI=$(BUILD_MPQCLI) + -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) cmake --build build .PHONY: build_windows build_windows: ## Build for Windows using cmake cmake -B build \ -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ - -DBUILD_MPQCLI=$(BUILD_MPQCLI) + -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) cmake --build build --config $(CMAKE_BUILD_TYPE) .PHONY: build_clean diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt new file mode 100644 index 0000000..ab572f5 --- /dev/null +++ b/app/CMakeLists.txt @@ -0,0 +1,5 @@ +add_executable(mpqcli + main.cpp +) + +target_link_libraries(mpqcli PRIVATE mpqcli_core CLI11::CLI11) diff --git a/src/main.cpp b/app/main.cpp similarity index 100% rename from src/main.cpp rename to app/main.cpp diff --git a/src/validators.h b/app/validators.h similarity index 100% rename from src/validators.h rename to app/validators.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c6c5495..1b43502 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -16,18 +16,11 @@ file(READ "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.ps1" PS_COMPLETION_SCRI file(READ "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.fish" FISH_COMPLETION_SCRIPT) configure_file(completion_data.h.in "${CMAKE_BINARY_DIR}/completion_data.h") -# Set output directory for the executable -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") - -# Windows specific settings -if (MSVC) - add_compile_definitions(__STORMLIB_NO_STATIC_LINK__) - set(CMAKE_GENERATOR_PLATFORM x64) -endif() - -# Create the main executable -add_executable(mpqcli - main.cpp +# Internal to this repository, so no include/ and no alias. Keeping the logic in +# a library rather than beside main() is what lets test binaries link it; a test +# that recompiles the sources instead is a second build that drifts from the +# shipped one. +add_library(mpqcli_core STATIC commands.cpp completion.cpp mpq.cpp @@ -36,16 +29,16 @@ add_executable(mpqcli gamerules.cpp ) -# Add dependencies -add_dependencies(mpqcli storm) +add_dependencies(mpqcli_core storm) -# Include directories -target_include_directories(mpqcli SYSTEM PRIVATE - "${CMAKE_SOURCE_DIR}/extern/StormLib/src" -) -target_include_directories(mpqcli PRIVATE +# PUBLIC so app/ and the test binaries can include these headers by name +target_include_directories(mpqcli_core PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}" "${CMAKE_BINARY_DIR}" ) +target_include_directories(mpqcli_core SYSTEM PUBLIC + "${CMAKE_SOURCE_DIR}/extern/StormLib/src" +) -# Link libraries -target_link_libraries(mpqcli PRIVATE storm CLI11::CLI11 hash-library) +# CLI11 is here only for the game profile validator defined in gamerules.cpp +target_link_libraries(mpqcli_core PUBLIC storm hash-library CLI11::CLI11) From 90dbb07336c36176370cb0ce23893c4860fd96de Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:54:44 +1200 Subject: [PATCH 02/59] Moved the CLI validators into the app --- CMakeLists.txt | 12 +++++++----- app/CMakeLists.txt | 1 + app/validators.cpp | 48 ++++++++++++++++++++++++++++++++++++++++++++++ app/validators.h | 29 ++-------------------------- src/CMakeLists.txt | 3 +-- src/gamerules.cpp | 23 ---------------------- 6 files changed, 59 insertions(+), 57 deletions(-) create mode 100644 app/validators.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7d5d1fb..6af5337 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,18 +63,20 @@ set(STORM_USE_BUNDLED_LIBRARIES ON CACHE BOOL "" FORCE) add_subdirectory(extern/StormLib) -# Handle CLI11 dependency -if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/CLI11/CMakeLists.txt") - message(FATAL_ERROR +# Handle CLI11 dependency. Only app/ parses arguments, so the core never sees it. +if(MPQCLI_BUILD_APP) + if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/CLI11/CMakeLists.txt") + message(FATAL_ERROR "Missing dependency: CLI11 mpqcli requires the CLI11 library. It is provided as a submodule of this repository. Did you forget to execute the following commands? git submodule init git submodule update") -endif() + endif() -add_subdirectory(extern/CLI11) + add_subdirectory(extern/CLI11) +endif() # Handle hash-library dependency (CRC32 + MD5 for the add --update checks) if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/hash-library/crc32.cpp") diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index ab572f5..731b625 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -1,5 +1,6 @@ add_executable(mpqcli main.cpp + validators.cpp ) target_link_libraries(mpqcli PRIVATE mpqcli_core CLI11::CLI11) diff --git a/app/validators.cpp b/app/validators.cpp new file mode 100644 index 0000000..652f0c8 --- /dev/null +++ b/app/validators.cpp @@ -0,0 +1,48 @@ +#include "validators.h" + +#include + +#include "gamerules.h" +#include "locales.h" + +// Accepts every profile name but lists only the canonical ones on failure +extern const CLI::Validator game_profile_valid = CLI::Validator( + [](const std::string &str) { + if (str == "default") + return std::string(); + + const GameProfile profile = GameRules::StringToProfile(str); + + // StringToProfile returns GENERIC for an unrecognised name, so the only + // way to tell a real "generic" from a miss is to compare the input + if (profile == GameProfile::GENERIC && str != "generic") { + std::string valid_profiles = "Game profile must be one of:"; + for (const auto &p : GameRules::GetCanonicalProfiles()) { + valid_profiles += " " + p; + } + return valid_profiles; + } + return std::string(); + }, + "", "GameProfileValidator"); + +extern const CLI::Validator locale_valid = CLI::Validator( + [](const std::string &str) { + if (str == "default") + return std::string(); + + if (ParseHexLocale(str) != default_locale) { + return std::string(); + } + + const LCID locale = LangToLocale(str); + if (locale == 0) { + std::string valid_locales = "Locale must be nothing, or one of:"; + for (const auto &l : GetAllLocales()) { + valid_locales += " " + l; + } + return valid_locales; + } + return std::string(); + }, + "", "LocaleValidator"); diff --git a/app/validators.h b/app/validators.h index dbfbec5..d2b0cdf 100644 --- a/app/validators.h +++ b/app/validators.h @@ -1,34 +1,9 @@ #ifndef VALIDATORS_H #define VALIDATORS_H -#include +#include -#include "gamerules.h" -#include "locales.h" - -// Defined in gamerules.cpp extern const CLI::Validator game_profile_valid; - -// Inline locale validator -inline const auto locale_valid = CLI::Validator( - [](const std::string &str) { - if (str == "default") - return std::string(); - - if (ParseHexLocale(str) != default_locale) { - return std::string(); - } - - const LCID locale = LangToLocale(str); - if (locale == 0) { - std::string valid_locales = "Locale must be nothing, or one of:"; - for (const auto &l : GetAllLocales()) { - valid_locales += " " + l; - } - return valid_locales; - } - return std::string(); - }, - "", "LocaleValidator"); +extern const CLI::Validator locale_valid; #endif // VALIDATORS_H diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1b43502..0076c4f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -40,5 +40,4 @@ target_include_directories(mpqcli_core SYSTEM PUBLIC "${CMAKE_SOURCE_DIR}/extern/StormLib/src" ) -# CLI11 is here only for the game profile validator defined in gamerules.cpp -target_link_libraries(mpqcli_core PUBLIC storm hash-library CLI11::CLI11) +target_link_libraries(mpqcli_core PUBLIC storm hash-library) diff --git a/src/gamerules.cpp b/src/gamerules.cpp index f7b49e2..0649c85 100644 --- a/src/gamerules.cpp +++ b/src/gamerules.cpp @@ -4,8 +4,6 @@ #include #include -#include - // Constructor GameRules::GameRules(GameProfile game_profile) : profile_(game_profile) { InitializeRules(); @@ -298,27 +296,6 @@ std::string GameRules::GetAvailableProfiles() { return result; } -// Validator for CLI11 - accepts all profile names but only displays canonical ones -extern const CLI::Validator game_profile_valid = CLI::Validator( - [](const std::string &str) { - if (str == "default") - return std::string(); - - // Try to convert the string to a profile - GameProfile profile = GameRules::StringToProfile(str); - - // If it's GENERIC and the input wasn't "generic", it means the profile wasn't found - if (profile == GameProfile::GENERIC && str != "generic") { - std::string valid_profiles = "Game profile must be one of:"; - for (const auto &p : GameRules::GetCanonicalProfiles()) { - valid_profiles += " " + p; - } - return valid_profiles; - } - return std::string(); - }, - "", "GameProfileValidator"); - // Initialize rules for the selected game profile void GameRules::InitializeRules() { rules_.clear(); From 90f69ea9f771834535c02c63288cb86fbd779a51 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:55:04 +1200 Subject: [PATCH 03/59] Moved the version, about and completion handlers into the app --- app/CMakeLists.txt | 3 +++ app/about.cpp | 17 +++++++++++++++++ {src => app}/completion.cpp | 3 +-- app/main.cpp | 2 +- src/completion.h => app/subcommands.h | 9 ++++++--- app/version.cpp | 9 +++++++++ src/CMakeLists.txt | 1 - src/commands.cpp | 19 ------------------- src/commands.h | 2 -- 9 files changed, 37 insertions(+), 28 deletions(-) create mode 100644 app/about.cpp rename {src => app}/completion.cpp (92%) rename src/completion.h => app/subcommands.h (51%) create mode 100644 app/version.cpp diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 731b625..fc5de07 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -1,6 +1,9 @@ add_executable(mpqcli main.cpp + about.cpp + completion.cpp validators.cpp + version.cpp ) target_link_libraries(mpqcli PRIVATE mpqcli_core CLI11::CLI11) diff --git a/app/about.cpp b/app/about.cpp new file mode 100644 index 0000000..101a58b --- /dev/null +++ b/app/about.cpp @@ -0,0 +1,17 @@ +#include + +#include "mpqcli.h" +#include "subcommands.h" + +int HandleAbout() { + std::cout << "Name: mpqcli" << std::endl; + std::cout << "Version: " << MPQCLI_VERSION << "-" << GIT_COMMIT_HASH << std::endl; + std::cout << "Author: Thomas Laurenson" << std::endl; + std::cout << "License: MIT" << std::endl; + std::cout << "GitHub: https://github.com/thegraydot/mpqcli" << std::endl; + std::cout << "Dependencies:" << std::endl; + std::cout << " - StormLib (https://github.com/ladislav-zezula/StormLib)" << std::endl; + std::cout << " - CLI11 (https://github.com/CLIUtils/CLI11)" << std::endl; + std::cout << " - hash-library (https://github.com/stbrumme/hash-library)" << std::endl; + return 0; +} diff --git a/src/completion.cpp b/app/completion.cpp similarity index 92% rename from src/completion.cpp rename to app/completion.cpp index ce787a9..f066f1f 100644 --- a/src/completion.cpp +++ b/app/completion.cpp @@ -1,8 +1,7 @@ -#include "completion.h" - #include #include "completion_data.h" +#include "subcommands.h" void HandleCompletionBash() { std::cout << BashCompletionScript; diff --git a/app/main.cpp b/app/main.cpp index 825b873..47c82c7 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -7,8 +7,8 @@ #include #include "commands.h" -#include "completion.h" #include "gamerules.h" +#include "subcommands.h" #include "validators.h" int main(int argc, char **argv) { diff --git a/src/completion.h b/app/subcommands.h similarity index 51% rename from src/completion.h rename to app/subcommands.h index 926a176..54b4bad 100644 --- a/src/completion.h +++ b/app/subcommands.h @@ -1,9 +1,12 @@ -#ifndef COMPLETION_H -#define COMPLETION_H +#ifndef SUBCOMMANDS_H +#define SUBCOMMANDS_H + +int HandleVersion(); +int HandleAbout(); void HandleCompletionBash(); void HandleCompletionZsh(); void HandleCompletionPs(); void HandleCompletionFish(); -#endif // COMPLETION_H +#endif // SUBCOMMANDS_H diff --git a/app/version.cpp b/app/version.cpp new file mode 100644 index 0000000..e22728b --- /dev/null +++ b/app/version.cpp @@ -0,0 +1,9 @@ +#include + +#include "mpqcli.h" +#include "subcommands.h" + +int HandleVersion() { + std::cout << MPQCLI_VERSION << "-" << GIT_COMMIT_HASH << std::endl; + return 0; +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0076c4f..965a85f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,7 +22,6 @@ configure_file(completion_data.h.in "${CMAKE_BINARY_DIR}/completion_data.h") # shipped one. add_library(mpqcli_core STATIC commands.cpp - completion.cpp mpq.cpp helpers.cpp locales.cpp diff --git a/src/commands.cpp b/src/commands.cpp index e19415a..016aa83 100644 --- a/src/commands.cpp +++ b/src/commands.cpp @@ -12,7 +12,6 @@ #include "helpers.h" #include "locales.h" #include "mpq.h" -#include "mpqcli.h" namespace fs = std::filesystem; @@ -26,24 +25,6 @@ std::string ResolveArchiveName(const std::string &f, const std::optional &property) { HANDLE archive; if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { diff --git a/src/commands.h b/src/commands.h index 27bb95f..6bb1d2c 100644 --- a/src/commands.h +++ b/src/commands.h @@ -6,8 +6,6 @@ #include #include -int HandleVersion(); -int HandleAbout(); int HandleInfo(const std::string &target, const std::optional &property); int HandleCreate(const std::string &target, const std::optional &path, const std::optional &output, bool sign_archive, From d7bd96a04934a32f7be104cc28d721394a8af789 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:56:01 +1200 Subject: [PATCH 04/59] Moved build inputs out of src into cmake and completion --- app/CMakeLists.txt | 27 ++++++++++++++++++++++ app/about.cpp | 2 +- app/completion.cpp | 2 +- app/version.cpp | 2 +- {src => cmake}/completion_data.h.in | 8 +++---- src/mpqcli.h.in => cmake/version.h.in | 0 {src/completion => completion}/mpqcli.bash | 0 {src/completion => completion}/mpqcli.fish | 0 {src/completion => completion}/mpqcli.ps1 | 0 {src/completion => completion}/mpqcli.zsh | 0 src/CMakeLists.txt | 19 --------------- test/test_completion.py | 8 +++---- 12 files changed, 38 insertions(+), 30 deletions(-) rename {src => cmake}/completion_data.h.in (62%) rename src/mpqcli.h.in => cmake/version.h.in (100%) rename {src/completion => completion}/mpqcli.bash (100%) rename {src/completion => completion}/mpqcli.fish (100%) rename {src/completion => completion}/mpqcli.ps1 (100%) rename {src/completion => completion}/mpqcli.zsh (100%) diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index fc5de07..c607f57 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -1,3 +1,26 @@ +configure_file( + "${PROJECT_SOURCE_DIR}/cmake/version.h.in" + "${PROJECT_BINARY_DIR}/include/mpqcli/version.h" + @ONLY +) + +# Re-run configure when a script is edited, so the embedded copy cannot go stale +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${PROJECT_SOURCE_DIR}/completion/mpqcli.bash" + "${PROJECT_SOURCE_DIR}/completion/mpqcli.zsh" + "${PROJECT_SOURCE_DIR}/completion/mpqcli.ps1" + "${PROJECT_SOURCE_DIR}/completion/mpqcli.fish" +) +file(READ "${PROJECT_SOURCE_DIR}/completion/mpqcli.bash" BASH_COMPLETION_SCRIPT) +file(READ "${PROJECT_SOURCE_DIR}/completion/mpqcli.zsh" ZSH_COMPLETION_SCRIPT) +file(READ "${PROJECT_SOURCE_DIR}/completion/mpqcli.ps1" PS_COMPLETION_SCRIPT) +file(READ "${PROJECT_SOURCE_DIR}/completion/mpqcli.fish" FISH_COMPLETION_SCRIPT) +configure_file( + "${PROJECT_SOURCE_DIR}/cmake/completion_data.h.in" + "${PROJECT_BINARY_DIR}/include/mpqcli/completion_data.h" + @ONLY +) + add_executable(mpqcli main.cpp about.cpp @@ -6,4 +29,8 @@ add_executable(mpqcli version.cpp ) +# An include tree rather than PROJECT_BINARY_DIR itself, which would expose +# every generated file in the build tree to #include +target_include_directories(mpqcli PRIVATE "${PROJECT_BINARY_DIR}/include") + target_link_libraries(mpqcli PRIVATE mpqcli_core CLI11::CLI11) diff --git a/app/about.cpp b/app/about.cpp index 101a58b..a8105f1 100644 --- a/app/about.cpp +++ b/app/about.cpp @@ -1,6 +1,6 @@ #include +#include -#include "mpqcli.h" #include "subcommands.h" int HandleAbout() { diff --git a/app/completion.cpp b/app/completion.cpp index f066f1f..bac9951 100644 --- a/app/completion.cpp +++ b/app/completion.cpp @@ -1,6 +1,6 @@ #include +#include -#include "completion_data.h" #include "subcommands.h" void HandleCompletionBash() { diff --git a/app/version.cpp b/app/version.cpp index e22728b..17be46f 100644 --- a/app/version.cpp +++ b/app/version.cpp @@ -1,6 +1,6 @@ #include +#include -#include "mpqcli.h" #include "subcommands.h" int HandleVersion() { diff --git a/src/completion_data.h.in b/cmake/completion_data.h.in similarity index 62% rename from src/completion_data.h.in rename to cmake/completion_data.h.in index 081fc13..71123af 100644 --- a/src/completion_data.h.in +++ b/cmake/completion_data.h.in @@ -1,16 +1,16 @@ #ifndef COMPLETION_DATA_H #define COMPLETION_DATA_H -// Auto-generated from src/completion/mpqcli.bash, do not edit directly +// Auto-generated from completion/mpqcli.bash, do not edit directly static constexpr char BashCompletionScript[] = R"BASH_MPQCLI(@BASH_COMPLETION_SCRIPT@)BASH_MPQCLI"; -// Auto-generated from src/completion/mpqcli.zsh, do not edit directly +// Auto-generated from completion/mpqcli.zsh, do not edit directly static constexpr char ZshCompletionScript[] = R"ZSH_MPQCLI(@ZSH_COMPLETION_SCRIPT@)ZSH_MPQCLI"; -// Auto-generated from src/completion/mpqcli.ps1, do not edit directly +// Auto-generated from completion/mpqcli.ps1, do not edit directly static constexpr char PsCompletionScript[] = R"PS_MPQCLI(@PS_COMPLETION_SCRIPT@)PS_MPQCLI"; -// Auto-generated from src/completion/mpqcli.fish, do not edit directly +// Auto-generated from completion/mpqcli.fish, do not edit directly static constexpr char FishCompletionScript[] = R"FISH_MPQCLI(@FISH_COMPLETION_SCRIPT@)FISH_MPQCLI"; #endif // COMPLETION_DATA_H diff --git a/src/mpqcli.h.in b/cmake/version.h.in similarity index 100% rename from src/mpqcli.h.in rename to cmake/version.h.in diff --git a/src/completion/mpqcli.bash b/completion/mpqcli.bash similarity index 100% rename from src/completion/mpqcli.bash rename to completion/mpqcli.bash diff --git a/src/completion/mpqcli.fish b/completion/mpqcli.fish similarity index 100% rename from src/completion/mpqcli.fish rename to completion/mpqcli.fish diff --git a/src/completion/mpqcli.ps1 b/completion/mpqcli.ps1 similarity index 100% rename from src/completion/mpqcli.ps1 rename to completion/mpqcli.ps1 diff --git a/src/completion/mpqcli.zsh b/completion/mpqcli.zsh similarity index 100% rename from src/completion/mpqcli.zsh rename to completion/mpqcli.zsh diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 965a85f..b308719 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,21 +1,3 @@ -# Configure version header -configure_file(mpqcli.h.in "${CMAKE_BINARY_DIR}/mpqcli.h") - -# Embed completion scripts into generated headers. -# Declare the script files as configure-time dependencies so CMake re-generates -# completion_data.h whenever the bash or zsh source scripts are edited. -set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS - "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.bash" - "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.zsh" - "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.ps1" - "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.fish" -) -file(READ "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.bash" BASH_COMPLETION_SCRIPT) -file(READ "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.zsh" ZSH_COMPLETION_SCRIPT) -file(READ "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.ps1" PS_COMPLETION_SCRIPT) -file(READ "${CMAKE_CURRENT_SOURCE_DIR}/completion/mpqcli.fish" FISH_COMPLETION_SCRIPT) -configure_file(completion_data.h.in "${CMAKE_BINARY_DIR}/completion_data.h") - # Internal to this repository, so no include/ and no alias. Keeping the logic in # a library rather than beside main() is what lets test binaries link it; a test # that recompiles the sources instead is a second build that drifts from the @@ -33,7 +15,6 @@ add_dependencies(mpqcli_core storm) # PUBLIC so app/ and the test binaries can include these headers by name target_include_directories(mpqcli_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}" - "${CMAKE_BINARY_DIR}" ) target_include_directories(mpqcli_core SYSTEM PUBLIC "${CMAKE_SOURCE_DIR}/extern/StormLib/src" diff --git a/test/test_completion.py b/test/test_completion.py index 545f9c5..67a18be 100644 --- a/test/test_completion.py +++ b/test/test_completion.py @@ -37,7 +37,7 @@ def test_completion_bash_output_size(binary_path): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "src" / "completion" / "mpqcli.bash" + script_path = Path(__file__).parent.parent / "completion" / "mpqcli.bash" assert len(result.stdout) == len(script_path.read_text()) @@ -50,7 +50,7 @@ def test_completion_zsh_output_size(binary_path): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "src" / "completion" / "mpqcli.zsh" + script_path = Path(__file__).parent.parent / "completion" / "mpqcli.zsh" assert len(result.stdout) == len(script_path.read_text()) @@ -76,7 +76,7 @@ def test_completion_powershell_output_size(binary_path): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "src" / "completion" / "mpqcli.ps1" + script_path = Path(__file__).parent.parent / "completion" / "mpqcli.ps1" assert len(result.stdout) == len(script_path.read_text()) @@ -101,7 +101,7 @@ def test_completion_fish_output_size(binary_path): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - script_path = Path(__file__).parent.parent / "src" / "completion" / "mpqcli.fish" + script_path = Path(__file__).parent.parent / "completion" / "mpqcli.fish" assert len(result.stdout) == len(script_path.read_text()) From 771aeec788e82acbf9ffd8730e14976cce678fac Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:56:34 +1200 Subject: [PATCH 05/59] Split helpers into a util module --- app/validators.cpp | 2 +- src/CMakeLists.txt | 7 +- src/commands.cpp | 16 +-- src/helpers.cpp | 201 ------------------------------------- src/helpers.h | 29 ------ src/mpq.cpp | 11 +- src/util/capacity.cpp | 29 ++++++ src/util/capacity.h | 12 +++ src/util/format.cpp | 74 ++++++++++++++ src/util/format.h | 13 +++ src/util/hash.cpp | 43 ++++++++ src/util/hash.h | 19 ++++ src/{ => util}/locales.cpp | 0 src/{ => util}/locales.h | 0 src/util/path.cpp | 83 +++++++++++++++ src/util/path.h | 29 ++++++ 16 files changed, 317 insertions(+), 251 deletions(-) delete mode 100644 src/helpers.cpp delete mode 100644 src/helpers.h create mode 100644 src/util/capacity.cpp create mode 100644 src/util/capacity.h create mode 100644 src/util/format.cpp create mode 100644 src/util/format.h create mode 100644 src/util/hash.cpp create mode 100644 src/util/hash.h rename src/{ => util}/locales.cpp (100%) rename src/{ => util}/locales.h (100%) create mode 100644 src/util/path.cpp create mode 100644 src/util/path.h diff --git a/app/validators.cpp b/app/validators.cpp index 652f0c8..75566d2 100644 --- a/app/validators.cpp +++ b/app/validators.cpp @@ -3,7 +3,7 @@ #include #include "gamerules.h" -#include "locales.h" +#include "util/locales.h" // Accepts every profile name but lists only the canonical ones on failure extern const CLI::Validator game_profile_valid = CLI::Validator( diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b308719..0ef82b9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,9 +5,12 @@ add_library(mpqcli_core STATIC commands.cpp mpq.cpp - helpers.cpp - locales.cpp gamerules.cpp + util/capacity.cpp + util/format.cpp + util/hash.cpp + util/locales.cpp + util/path.cpp ) add_dependencies(mpqcli_core storm) diff --git a/src/commands.cpp b/src/commands.cpp index 016aa83..83ea671 100644 --- a/src/commands.cpp +++ b/src/commands.cpp @@ -9,22 +9,14 @@ #include #include "gamerules.h" -#include "helpers.h" -#include "locales.h" #include "mpq.h" +#include "util/capacity.h" +#include "util/format.h" +#include "util/locales.h" +#include "util/path.h" namespace fs = std::filesystem; -std::string ResolveArchiveName(const std::string &f, const std::optional &path, - const bool treat_as_directory = false) { - fs::path file_path = path.value_or(fs::path(f).filename().u8string()); - if (treat_as_directory) { - const std::string filename = fs::path(f).filename().u8string(); - file_path = path.value_or("") / fs::path(filename); - } - return WindowsifyFilePath(file_path); -} - int HandleInfo(const std::string &target, const std::optional &property) { HANDLE archive; if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { diff --git a/src/helpers.cpp b/src/helpers.cpp deleted file mode 100644 index d9ffda7..0000000 --- a/src/helpers.cpp +++ /dev/null @@ -1,201 +0,0 @@ -#include "helpers.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef _WIN32 -#include -#include -#endif - -#include - -namespace fs = std::filesystem; - -std::string FileTimeToLsTime(int64_t file_time) { - if (file_time == 0) { - return ""; - } - constexpr int64_t epoch_diff = 11644473600LL; - int64_t unix_time = (file_time / 10000000) - epoch_diff; - char buf[20]; - struct tm tm_buf; -#ifdef _WIN32 - localtime_s(&tm_buf, &unix_time); -#else - localtime_r(&unix_time, &tm_buf); -#endif - strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm_buf); - return std::string(buf); -} - -std::string NormalizeFilePath(const fs::path &path) { - std::string file_path = path.u8string(); -#ifndef _WIN32 - std::replace(file_path.begin(), file_path.end(), '\\', '/'); - return file_path; -#else - return file_path; -#endif -} - -std::string WindowsifyFilePath(const fs::path &path) { - std::string file_path = path.u8string(); - std::replace(file_path.begin(), file_path.end(), '/', '\\'); - return file_path; -} - -std::string StormErrorString(uint32_t err) { - switch (err) { - // clang-format off - case ERROR_SUCCESS: return "Success"; - case ERROR_FILE_NOT_FOUND: return "File not found"; - case ERROR_ACCESS_DENIED: return "Access denied (archive may be read-only or have open files)"; - case ERROR_INVALID_HANDLE: return "Invalid handle"; - case ERROR_NOT_ENOUGH_MEMORY: return "Not enough memory"; - case ERROR_NOT_SUPPORTED: return "Operation not supported"; - case ERROR_INVALID_PARAMETER: return "Invalid parameter"; - case ERROR_DISK_FULL: return "Disk full"; - case ERROR_ALREADY_EXISTS: return "Already exists"; - case ERROR_INSUFFICIENT_BUFFER: return "Insufficient buffer"; - case ERROR_BAD_FORMAT: return "Bad MPQ format"; - case ERROR_NO_MORE_FILES: return "No more files"; - case ERROR_HANDLE_EOF: return "End of file"; - case ERROR_CAN_NOT_COMPLETE: return "Cannot complete"; - case ERROR_FILE_CORRUPT: return "File is corrupt"; - case ERROR_BUFFER_OVERFLOW: return "Buffer overflow"; - case ERROR_INVALID_DATA: return "Invalid data"; - case ERROR_NO_UNICODE_TRANSLATION: return "No Unicode translation"; - case ERROR_AVI_FILE: return "Not an MPQ file (AVI file)"; - case ERROR_UNKNOWN_FILE_KEY: return "Unknown file encryption key"; - case ERROR_CHECKSUM_ERROR: return "Sector checksum mismatch"; - case ERROR_INTERNAL_FILE: return "Operation not allowed on internal file"; - case ERROR_BASE_FILE_MISSING: return "Base file missing for incremental patch"; - case ERROR_MARKED_FOR_DELETE: return "File is marked as deleted in the MPQ"; - case ERROR_FILE_INCOMPLETE: return "Required file part is missing"; - case ERROR_UNKNOWN_FILE_NAMES: return "At least one file name is unknown (listfile is incomplete)"; - case ERROR_CANT_FIND_PATCH_PREFIX: return "Cannot find patch prefix"; - case ERROR_FAKE_MPQ_HEADER: return "Fake MPQ header at this position"; - case ERROR_FILE_DELETED: return "File contains delete marker"; - default: return std::strerror(static_cast(err)); - // clang-format on - } -} - -std::vector ListFilesRecursive(const fs::path &directory, std::error_code &ec) { - std::vector files; - fs::recursive_directory_iterator it(directory, ec); - while (!ec && it != fs::recursive_directory_iterator()) { - // A dangling symlink reports not_found rather than a hard error, and is - // skipped like any other non-regular entry - std::error_code status_ec; - const fs::file_status status = it->status(status_ec); - if (fs::is_regular_file(status)) { - files.push_back(it->path()); - } else if (status_ec && status.type() != fs::file_type::not_found) { - ec = status_ec; - break; - } - it.increment(ec); - } - if (ec) { - return {}; - } - std::sort(files.begin(), files.end()); - return files; -} - -uint32_t CalculateMpqMaxFileValue(uint32_t file_count) { - // Always add 3 for "special" files - file_count += 3; - - // Based on file count, determine the max number of files an MPQ archive can hold - // We always have a minimum of 32 - // Anything over is rounded up to the closest power of 2 - // For example: 64, 128, 256 - // This is examples behavior of WoW MPQ archives (patches and installs) - if (file_count <= 32) { - return 32; - } - - return NextPowerOfTwo(file_count); -} - -uint32_t NextPowerOfTwo(uint32_t n) { - n--; - n |= n >> 1; - n |= n >> 2; - n |= n >> 4; - n |= n >> 8; - n |= n >> 16; - return n + 1; -} - -void PrintAsBinary(const char *buffer, uint32_t size) { -#ifdef _WIN32 - _setmode(_fileno(stdout), _O_BINARY); -#endif - std::cout.write(buffer, size); -} - -// CRC32 (ZIP/gzip polynomial) and MD5 are provided by the hash-library -// submodule, matching the values StormLib stores in (attributes). -std::optional ComputeFileCrc32(const fs::path &path) { - std::ifstream f(path, std::ios::binary); - if (!f) { - return std::nullopt; - } - CRC32 crc32; - char buf[65536]; - while (f.read(buf, sizeof(buf)) || f.gcount() > 0) { - crc32.add(buf, static_cast(f.gcount())); - } - // getHash yields the checksum as big-endian bytes; reassemble the value. - unsigned char digest[CRC32::HashBytes]; - crc32.getHash(digest); - return (static_cast(digest[0]) << 24) | (static_cast(digest[1]) << 16) | - (static_cast(digest[2]) << 8) | static_cast(digest[3]); -} - -bool ComputeFileMd5(const fs::path &path, uint8_t *md5_out) { - std::ifstream f(path, std::ios::binary); - if (!f) { - return false; - } - MD5 md5; - char buf[65536]; - while (f.read(buf, sizeof(buf)) || f.gcount() > 0) { - md5.add(buf, static_cast(f.gcount())); - } - md5.getHash(md5_out); - return true; -} - -// Returns the file's last-modification time as a Windows FILETIME value -// (100-nanosecond intervals since 1601-01-01 UTC). Returns 0 on error. -uint64_t LocalFileTimestamp(const fs::path &path) { -#ifdef _WIN32 - // _wstat64 handles paths with non-ASCII characters, which the narrow - // stat() would mangle on Windows. - struct _stat64 st {}; - if (_wstat64(path.wstring().c_str(), &st) != 0) { - return 0; - } -#else - struct stat st {}; - if (stat(path.string().c_str(), &st) != 0) { - return 0; - } -#endif - constexpr int64_t epoch_diff = 11644473600LL; - return static_cast((static_cast(st.st_mtime) + epoch_diff) * 10000000LL); -} diff --git a/src/helpers.h b/src/helpers.h deleted file mode 100644 index 617f7f1..0000000 --- a/src/helpers.h +++ /dev/null @@ -1,29 +0,0 @@ -#ifndef HELPERS_H -#define HELPERS_H - -#include -#include -#include -#include -#include -#include - -namespace fs = std::filesystem; - -std::string FileTimeToLsTime(int64_t file_time); -std::string NormalizeFilePath(const fs::path &path); -std::string WindowsifyFilePath(const fs::path &path); -std::string StormErrorString(uint32_t err); - -std::vector ListFilesRecursive(const fs::path &directory, std::error_code &ec); -uint32_t CalculateMpqMaxFileValue(uint32_t file_count); -uint32_t NextPowerOfTwo(uint32_t n); -void PrintAsBinary(const char *buffer, uint32_t size); - -// Local file checksum / timestamp helpers used by the --update logic. -// Each returns std::nullopt / false / 0 if the file cannot be read. -std::optional ComputeFileCrc32(const fs::path &path); -bool ComputeFileMd5(const fs::path &path, uint8_t *md5_out); -uint64_t LocalFileTimestamp(const fs::path &path); - -#endif diff --git a/src/mpq.cpp b/src/mpq.cpp index 053cfa5..ec49136 100644 --- a/src/mpq.cpp +++ b/src/mpq.cpp @@ -15,8 +15,11 @@ #include #include "gamerules.h" -#include "helpers.h" -#include "locales.h" +#include "util/capacity.h" +#include "util/format.h" +#include "util/hash.h" +#include "util/locales.h" +#include "util/path.h" namespace fs = std::filesystem; @@ -68,10 +71,6 @@ bool SignMpqArchive(HANDLE archive) { return true; } -static bool IsWithinDirectory(const fs::path &base, const fs::path &path) { - return std::mismatch(base.begin(), base.end(), path.begin(), path.end()).first == base.end(); -} - int ExtractFiles(HANDLE archive, const std::string &output, const std::optional &listfile_name, LCID preferred_locale) { SFileSetLocale(preferred_locale); diff --git a/src/util/capacity.cpp b/src/util/capacity.cpp new file mode 100644 index 0000000..256cfac --- /dev/null +++ b/src/util/capacity.cpp @@ -0,0 +1,29 @@ +#include "util/capacity.h" + +#include + +uint32_t NextPowerOfTwo(uint32_t n) { + n--; + n |= n >> 1; + n |= n >> 2; + n |= n >> 4; + n |= n >> 8; + n |= n >> 16; + return n + 1; +} + +uint32_t CalculateMpqMaxFileValue(uint32_t file_count) { + // Always add 3 for "special" files + file_count += 3; + + // Based on file count, determine the max number of files an MPQ archive can hold + // We always have a minimum of 32 + // Anything over is rounded up to the closest power of 2 + // For example: 64, 128, 256 + // This is examples behavior of WoW MPQ archives (patches and installs) + if (file_count <= 32) { + return 32; + } + + return NextPowerOfTwo(file_count); +} diff --git a/src/util/capacity.h b/src/util/capacity.h new file mode 100644 index 0000000..8af7aec --- /dev/null +++ b/src/util/capacity.h @@ -0,0 +1,12 @@ +#ifndef UTIL_CAPACITY_H +#define UTIL_CAPACITY_H + +#include + +/// Rounds n up to the next power of two +uint32_t NextPowerOfTwo(uint32_t n); + +/// Returns the max file count an archive holding file_count files should declare +uint32_t CalculateMpqMaxFileValue(uint32_t file_count); + +#endif diff --git a/src/util/format.cpp b/src/util/format.cpp new file mode 100644 index 0000000..2867d22 --- /dev/null +++ b/src/util/format.cpp @@ -0,0 +1,74 @@ +#include "util/format.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#endif + +#include + +std::string FileTimeToLsTime(int64_t file_time) { + if (file_time == 0) { + return ""; + } + constexpr int64_t epoch_diff = 11644473600LL; + int64_t unix_time = (file_time / 10000000) - epoch_diff; + char buf[20]; + struct tm tm_buf; +#ifdef _WIN32 + localtime_s(&tm_buf, &unix_time); +#else + localtime_r(&unix_time, &tm_buf); +#endif + strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm_buf); + return std::string(buf); +} + +std::string StormErrorString(uint32_t err) { + switch (err) { + // clang-format off + case ERROR_SUCCESS: return "Success"; + case ERROR_FILE_NOT_FOUND: return "File not found"; + case ERROR_ACCESS_DENIED: return "Access denied (archive may be read-only or have open files)"; + case ERROR_INVALID_HANDLE: return "Invalid handle"; + case ERROR_NOT_ENOUGH_MEMORY: return "Not enough memory"; + case ERROR_NOT_SUPPORTED: return "Operation not supported"; + case ERROR_INVALID_PARAMETER: return "Invalid parameter"; + case ERROR_DISK_FULL: return "Disk full"; + case ERROR_ALREADY_EXISTS: return "Already exists"; + case ERROR_INSUFFICIENT_BUFFER: return "Insufficient buffer"; + case ERROR_BAD_FORMAT: return "Bad MPQ format"; + case ERROR_NO_MORE_FILES: return "No more files"; + case ERROR_HANDLE_EOF: return "End of file"; + case ERROR_CAN_NOT_COMPLETE: return "Cannot complete"; + case ERROR_FILE_CORRUPT: return "File is corrupt"; + case ERROR_BUFFER_OVERFLOW: return "Buffer overflow"; + case ERROR_INVALID_DATA: return "Invalid data"; + case ERROR_NO_UNICODE_TRANSLATION: return "No Unicode translation"; + case ERROR_AVI_FILE: return "Not an MPQ file (AVI file)"; + case ERROR_UNKNOWN_FILE_KEY: return "Unknown file encryption key"; + case ERROR_CHECKSUM_ERROR: return "Sector checksum mismatch"; + case ERROR_INTERNAL_FILE: return "Operation not allowed on internal file"; + case ERROR_BASE_FILE_MISSING: return "Base file missing for incremental patch"; + case ERROR_MARKED_FOR_DELETE: return "File is marked as deleted in the MPQ"; + case ERROR_FILE_INCOMPLETE: return "Required file part is missing"; + case ERROR_UNKNOWN_FILE_NAMES: return "At least one file name is unknown (listfile is incomplete)"; + case ERROR_CANT_FIND_PATCH_PREFIX: return "Cannot find patch prefix"; + case ERROR_FAKE_MPQ_HEADER: return "Fake MPQ header at this position"; + case ERROR_FILE_DELETED: return "File contains delete marker"; + default: return std::strerror(static_cast(err)); + // clang-format on + } +} + +void PrintAsBinary(const char *buffer, uint32_t size) { +#ifdef _WIN32 + _setmode(_fileno(stdout), _O_BINARY); +#endif + std::cout.write(buffer, size); +} diff --git a/src/util/format.h b/src/util/format.h new file mode 100644 index 0000000..f65f934 --- /dev/null +++ b/src/util/format.h @@ -0,0 +1,13 @@ +#ifndef UTIL_FORMAT_H +#define UTIL_FORMAT_H + +#include +#include + +std::string FileTimeToLsTime(int64_t file_time); +std::string StormErrorString(uint32_t err); + +/// Writes size bytes of buffer to stdout without line-ending translation +void PrintAsBinary(const char *buffer, uint32_t size); + +#endif diff --git a/src/util/hash.cpp b/src/util/hash.cpp new file mode 100644 index 0000000..9d16b1e --- /dev/null +++ b/src/util/hash.cpp @@ -0,0 +1,43 @@ +#include "util/hash.h" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +// CRC32 (ZIP/gzip polynomial) and MD5 are provided by the hash-library +// submodule, matching the values StormLib stores in (attributes). +std::optional ComputeFileCrc32(const fs::path &path) { + std::ifstream f(path, std::ios::binary); + if (!f) { + return std::nullopt; + } + CRC32 crc32; + char buf[65536]; + while (f.read(buf, sizeof(buf)) || f.gcount() > 0) { + crc32.add(buf, static_cast(f.gcount())); + } + // getHash yields the checksum as big-endian bytes; reassemble the value. + unsigned char digest[CRC32::HashBytes]; + crc32.getHash(digest); + return (static_cast(digest[0]) << 24) | (static_cast(digest[1]) << 16) | + (static_cast(digest[2]) << 8) | static_cast(digest[3]); +} + +bool ComputeFileMd5(const fs::path &path, uint8_t *md5_out) { + std::ifstream f(path, std::ios::binary); + if (!f) { + return false; + } + MD5 md5; + char buf[65536]; + while (f.read(buf, sizeof(buf)) || f.gcount() > 0) { + md5.add(buf, static_cast(f.gcount())); + } + md5.getHash(md5_out); + return true; +} diff --git a/src/util/hash.h b/src/util/hash.h new file mode 100644 index 0000000..d9a649b --- /dev/null +++ b/src/util/hash.h @@ -0,0 +1,19 @@ +#ifndef UTIL_HASH_H +#define UTIL_HASH_H + +#include +#include +#include + +namespace fs = std::filesystem; + +// CRC32 (ZIP/gzip polynomial) and MD5 match the values StormLib stores in +// (attributes), which is what lets the --update check compare them directly. + +/// Returns the CRC32 of path, or nullopt if it cannot be read +std::optional ComputeFileCrc32(const fs::path &path); + +/// Writes the MD5 of path to md5_out, returning false if it cannot be read +bool ComputeFileMd5(const fs::path &path, uint8_t *md5_out); + +#endif diff --git a/src/locales.cpp b/src/util/locales.cpp similarity index 100% rename from src/locales.cpp rename to src/util/locales.cpp diff --git a/src/locales.h b/src/util/locales.h similarity index 100% rename from src/locales.h rename to src/util/locales.h diff --git a/src/util/path.cpp b/src/util/path.cpp new file mode 100644 index 0000000..99b0e99 --- /dev/null +++ b/src/util/path.cpp @@ -0,0 +1,83 @@ +#include "util/path.h" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +std::string NormalizeFilePath(const fs::path &path) { + std::string file_path = path.u8string(); +#ifndef _WIN32 + std::replace(file_path.begin(), file_path.end(), '\\', '/'); + return file_path; +#else + return file_path; +#endif +} + +std::string WindowsifyFilePath(const fs::path &path) { + std::string file_path = path.u8string(); + std::replace(file_path.begin(), file_path.end(), '/', '\\'); + return file_path; +} + +std::vector ListFilesRecursive(const fs::path &directory, std::error_code &ec) { + std::vector files; + fs::recursive_directory_iterator it(directory, ec); + while (!ec && it != fs::recursive_directory_iterator()) { + // A dangling symlink reports not_found rather than a hard error, and is + // skipped like any other non-regular entry + std::error_code status_ec; + const fs::file_status status = it->status(status_ec); + if (fs::is_regular_file(status)) { + files.push_back(it->path()); + } else if (status_ec && status.type() != fs::file_type::not_found) { + ec = status_ec; + break; + } + it.increment(ec); + } + if (ec) { + return {}; + } + std::sort(files.begin(), files.end()); + return files; +} + +// Returns the file's last-modification time as a Windows FILETIME value +// (100-nanosecond intervals since 1601-01-01 UTC). Returns 0 on error. +uint64_t LocalFileTimestamp(const fs::path &path) { +#ifdef _WIN32 + // _wstat64 handles paths with non-ASCII characters, which the narrow + // stat() would mangle on Windows. + struct _stat64 st {}; + if (_wstat64(path.wstring().c_str(), &st) != 0) { + return 0; + } +#else + struct stat st {}; + if (stat(path.string().c_str(), &st) != 0) { + return 0; + } +#endif + constexpr int64_t epoch_diff = 11644473600LL; + return static_cast((static_cast(st.st_mtime) + epoch_diff) * 10000000LL); +} + +bool IsWithinDirectory(const fs::path &base, const fs::path &path) { + return std::mismatch(base.begin(), base.end(), path.begin(), path.end()).first == base.end(); +} + +std::string ResolveArchiveName(const std::string &f, const std::optional &path, + const bool treat_as_directory) { + fs::path file_path = path.value_or(fs::path(f).filename().u8string()); + if (treat_as_directory) { + const std::string filename = fs::path(f).filename().u8string(); + file_path = path.value_or("") / fs::path(filename); + } + return WindowsifyFilePath(file_path); +} diff --git a/src/util/path.h b/src/util/path.h new file mode 100644 index 0000000..c312b34 --- /dev/null +++ b/src/util/path.h @@ -0,0 +1,29 @@ +#ifndef UTIL_PATH_H +#define UTIL_PATH_H + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +std::string NormalizeFilePath(const fs::path &path); +std::string WindowsifyFilePath(const fs::path &path); + +/// Reports whether path resolves to a location inside base +bool IsWithinDirectory(const fs::path &base, const fs::path &path); + +/// Builds the in-archive name for a local file +std::string ResolveArchiveName(const std::string &f, const std::optional &path, + bool treat_as_directory = false); + +std::vector ListFilesRecursive(const fs::path &directory, std::error_code &ec); + +/// Returns the file's last-modification time as a Windows FILETIME value +/// (100-nanosecond intervals since 1601-01-01 UTC), or 0 if it cannot be read +uint64_t LocalFileTimestamp(const fs::path &path); + +#endif From 6bc2c6daec260bcbcbeca7ce498eb9f2c01f76b1 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:58:17 +1200 Subject: [PATCH 06/59] Split mpq into per-operation modules --- src/CMakeLists.txt | 14 +- src/commands.cpp | 12 +- src/mpq.cpp | 851 -------------------------------------------- src/mpq.h | 52 --- src/mpq/add.cpp | 179 ++++++++++ src/mpq/add.h | 23 ++ src/mpq/archive.cpp | 37 ++ src/mpq/archive.h | 13 + src/mpq/compact.cpp | 23 ++ src/mpq/compact.h | 11 + src/mpq/create.cpp | 53 +++ src/mpq/create.h | 14 + src/mpq/extract.cpp | 118 ++++++ src/mpq/extract.h | 14 + src/mpq/flags.cpp | 29 ++ src/mpq/flags.h | 9 + src/mpq/info.cpp | 93 +++++ src/mpq/info.h | 11 + src/mpq/list.cpp | 164 +++++++++ src/mpq/list.h | 13 + src/mpq/query.cpp | 87 +++++ src/mpq/query.h | 30 ++ src/mpq/read.cpp | 46 +++ src/mpq/read.h | 11 + src/mpq/remove.cpp | 32 ++ src/mpq/remove.h | 10 + src/mpq/rename.cpp | 35 ++ src/mpq/rename.h | 11 + src/mpq/verify.cpp | 73 ++++ src/mpq/verify.h | 12 + 30 files changed, 1175 insertions(+), 905 deletions(-) delete mode 100644 src/mpq.cpp delete mode 100644 src/mpq.h create mode 100644 src/mpq/add.cpp create mode 100644 src/mpq/add.h create mode 100644 src/mpq/archive.cpp create mode 100644 src/mpq/archive.h create mode 100644 src/mpq/compact.cpp create mode 100644 src/mpq/compact.h create mode 100644 src/mpq/create.cpp create mode 100644 src/mpq/create.h create mode 100644 src/mpq/extract.cpp create mode 100644 src/mpq/extract.h create mode 100644 src/mpq/flags.cpp create mode 100644 src/mpq/flags.h create mode 100644 src/mpq/info.cpp create mode 100644 src/mpq/info.h create mode 100644 src/mpq/list.cpp create mode 100644 src/mpq/list.h create mode 100644 src/mpq/query.cpp create mode 100644 src/mpq/query.h create mode 100644 src/mpq/read.cpp create mode 100644 src/mpq/read.h create mode 100644 src/mpq/remove.cpp create mode 100644 src/mpq/remove.h create mode 100644 src/mpq/rename.cpp create mode 100644 src/mpq/rename.h create mode 100644 src/mpq/verify.cpp create mode 100644 src/mpq/verify.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0ef82b9..9525a5e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -4,7 +4,19 @@ # shipped one. add_library(mpqcli_core STATIC commands.cpp - mpq.cpp + mpq/add.cpp + mpq/archive.cpp + mpq/compact.cpp + mpq/create.cpp + mpq/extract.cpp + mpq/flags.cpp + mpq/info.cpp + mpq/list.cpp + mpq/query.cpp + mpq/read.cpp + mpq/remove.cpp + mpq/rename.cpp + mpq/verify.cpp gamerules.cpp util/capacity.cpp util/format.cpp diff --git a/src/commands.cpp b/src/commands.cpp index 83ea671..1672f83 100644 --- a/src/commands.cpp +++ b/src/commands.cpp @@ -9,7 +9,17 @@ #include #include "gamerules.h" -#include "mpq.h" +#include "mpq/add.h" +#include "mpq/archive.h" +#include "mpq/compact.h" +#include "mpq/create.h" +#include "mpq/extract.h" +#include "mpq/info.h" +#include "mpq/list.h" +#include "mpq/read.h" +#include "mpq/remove.h" +#include "mpq/rename.h" +#include "mpq/verify.h" #include "util/capacity.h" #include "util/format.h" #include "util/locales.h" diff --git a/src/mpq.cpp b/src/mpq.cpp deleted file mode 100644 index ec49136..0000000 --- a/src/mpq.cpp +++ /dev/null @@ -1,851 +0,0 @@ -#include "mpq.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "gamerules.h" -#include "util/capacity.h" -#include "util/format.h" -#include "util/hash.h" -#include "util/locales.h" -#include "util/path.h" - -namespace fs = std::filesystem; - -static const std::vector special_mpq_files = {"(listfile)", "(signature)", - "(attributes)"}; - -bool OpenMpqArchive(const std::string &filename, HANDLE *archive, int32_t flags) { - if (!SFileOpenArchive(filename.c_str(), 0, flags, archive)) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to open MPQ archive: " << filename << ": (" << error << ") " - << StormErrorString(error) << std::endl; - return false; - } - return true; -} - -bool CloseMpqArchive(HANDLE archive) { - if (!SFileCloseArchive(archive)) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to close MPQ archive: (" << error << ") " - << StormErrorString(error) << std::endl; - return false; - } - return true; -} - -bool FileExistsInArchiveForLocale(const HANDLE archive, const std::string &file_path, - const LCID locale) { - bool file_exists = false; - SFileSetLocale(locale); - HANDLE file; - if (SFileOpenFileEx(archive, file_path.c_str(), SFILE_OPEN_FROM_MPQ, &file)) { - const auto file_locale = GetFileInfo(file, SFileInfoLocale); - if (file_locale == locale) { - file_exists = true; - } - SFileCloseFile(file); - } - return file_exists; -} - -bool SignMpqArchive(HANDLE archive) { - if (!SFileSignArchive(archive, SIGNATURE_TYPE_WEAK)) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to sign MPQ archive: (" << error << ") " << StormErrorString(error) - << std::endl; - return false; - } - return true; -} - -int ExtractFiles(HANDLE archive, const std::string &output, - const std::optional &listfile_name, LCID preferred_locale) { - SFileSetLocale(preferred_locale); - // Check if the user provided a listfile input - const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; - - SFILE_FIND_DATA find_data; - HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); - if (find_handle == nullptr) { - std::cerr << "[!] Failed to find first file in MPQ archive." << std::endl; - return 1; - } - - int32_t result = 0; - do { - result |= ExtractFile(archive, output, find_data.cFileName, - true, // Keep folder structure - preferred_locale); - } while (SFileFindNextFile(find_handle, &find_data)); - - SFileFindClose(find_handle); - return result; -} - -int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, - bool keep_folder_structure, LCID preferred_locale) { - SFileSetLocale(preferred_locale); - if (!FileExistsInArchiveForLocale(archive, file_name.c_str(), preferred_locale) && - !FileExistsInArchiveForLocale(archive, file_name.c_str(), default_locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name - << std::endl; - return 1; - } - - // Change forward slashes on non-Windows systems - fs::path file_name_path(file_name); - std::string file_name_string = NormalizeFilePath(file_name_path); - - // Remove folder structure if keepFolderStructure is false - if (!keep_folder_structure) { - file_name_path = fs::path(file_name_string); - file_name_string = file_name_path.filename().u8string(); - } - - std::error_code ec; - fs::path output_path_base = fs::absolute(output, ec).lexically_normal(); - if (ec) { - std::cerr << "[!] Failed to resolve output directory: (" << ec.value() << ") " - << ec.message() << ": " << output << std::endl; - return 1; - } - if (output_path_base.filename().empty()) { - output_path_base = output_path_base.parent_path(); - } - - // Guard against path traversal attacks in two stages. First lexically, so a - // ".." entry is rejected before anything is created on disk - fs::path output_file_path_name = (output_path_base / file_name_string).lexically_normal(); - if (!IsWithinDirectory(output_path_base, output_file_path_name)) { - std::cerr << "[!] Blocked: path traversal attempt detected: " << file_name_string - << std::endl; - return 1; - } - - // Ensure sub-directories for folder-nested files exist before resolving - fs::create_directories(output_file_path_name.parent_path(), ec); - if (ec) { - std::cerr << "[!] Failed to create output directory: (" << ec.value() << ") " - << ec.message() << ": " << output_file_path_name.parent_path().u8string() - << std::endl; - return 1; - } - - // Second, through the OS to also catch symlinks. Volumes that cannot report - // real paths (RAM disks) fail here, in which case the lexical check above is - // the only guard; HandleExtract warns about this once - fs::path resolved_base = fs::canonical(output_path_base, ec); - if (!ec) { - fs::path resolved_output = fs::canonical(output_file_path_name.parent_path(), ec) / - output_file_path_name.filename(); - if (ec) { - std::cerr << "[!] Failed to resolve output path: (" << ec.value() << ") " - << ec.message() << ": " << output_file_path_name.u8string() << std::endl; - return 1; - } - if (!IsWithinDirectory(resolved_base, resolved_output)) { - std::cerr << "[!] Blocked: path traversal attempt detected: " << file_name_string - << std::endl; - return 1; - } - } - - std::string output_file_name{output_file_path_name.u8string()}; - - if (SFileExtractFile(archive, file_name.c_str(), output_file_name.c_str(), 0)) { - std::cout << "[*] Extracted: " << file_name_string << std::endl; - } else { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed: (" << error << ") " << StormErrorString(error) << ": " - << file_name << std::endl; - return 1; - } - - return 0; -} - -HANDLE CreateMpqArchive(const std::string &output_archive_name, const uint32_t file_count, - const GameRules &game_rules) { - // Check if file already exists - std::error_code ec; - if (fs::exists(output_archive_name, ec)) { - std::cerr << "[!] File already exists: " << output_archive_name << " Exiting..." - << std::endl; - return nullptr; - } - - HANDLE archive; - - // Use game-specific create settings - const MpqCreateSettings &settings = game_rules.GetCreateSettings(); - - SFILE_CREATE_MPQ create_info = {}; - // All logic for defaults and dependencies is handled in GameRules::OverrideCreateSettings - create_info.cbSize = sizeof(SFILE_CREATE_MPQ); - create_info.dwMpqVersion = settings.mpq_version; - create_info.dwStreamFlags = settings.stream_flags; - create_info.dwFileFlags1 = settings.file_flags1; - create_info.dwFileFlags2 = settings.file_flags2; - create_info.dwFileFlags3 = settings.file_flags3; - create_info.dwAttrFlags = settings.attr_flags; - create_info.dwSectorSize = settings.sector_size; - create_info.dwRawChunkSize = settings.raw_chunk_size; - create_info.dwMaxFileCount = file_count; - - const bool result = SFileCreateArchive2(output_archive_name.c_str(), &create_info, &archive); - - if (!result) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to create MPQ archive: " << output_archive_name << ": (" << error - << ") " << StormErrorString(error) << std::endl; - return nullptr; - } - - return archive; -} - -static bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file, - std::string &match_reason) { - const DWORD archived_size = SFileGetFileSize(file, nullptr); - std::error_code ec; - const uintmax_t disk_size = fs::file_size(local_file, ec); - if (ec || disk_size != static_cast(archived_size)) { - return false; - } - - const DWORD attr_flags = SFileGetAttributes(archive); - - // Step 1: Timestamp: cheapest check, no local file I/O. - if (attr_flags & MPQ_ATTRIBUTE_FILETIME) { - const uint64_t archived_time = GetFileInfo(file, SFileInfoFileTime); - const uint64_t local_time = LocalFileTimestamp(local_file); - // Compare at second resolution: stat() has only second precision. - if (archived_time != 0 && local_time != 0 && - archived_time / 10000000u == local_time / 10000000u) { - match_reason = "Timestamp matches"; - return true; - } - } - - // Step 2: MD5: if timestamp did not match or was unavailable. - if (attr_flags & MPQ_ATTRIBUTE_MD5) { - uint8_t archived_md5[MD5_DIGEST_SIZE]{}; - if (SFileGetFileInfo(file, SFileInfoMD5, archived_md5, sizeof(archived_md5), nullptr)) { - // An all-zero digest means "no MD5 stored". A file whose - // real MD5 is all zeroes is astronomically unlikely; the - // worst case is a redundant re-add. - const uint8_t zero_md5[MD5_DIGEST_SIZE]{}; - if (std::memcmp(archived_md5, zero_md5, MD5_DIGEST_SIZE) != 0) { - uint8_t local_md5[MD5_DIGEST_SIZE]{}; - if (ComputeFileMd5(local_file, local_md5) && - std::memcmp(local_md5, archived_md5, MD5_DIGEST_SIZE) == 0) { - match_reason = "MD5 matches"; - return true; - } - } - } - } - - // Step 3: CRC32: if neither timestamp nor MD5 matched or was available. - if (attr_flags & MPQ_ATTRIBUTE_CRC32) { - const DWORD archived_crc32 = GetFileInfo(file, SFileInfoCRC32); - // Zero means "no CRC32 stored"; a file whose real CRC32 is - // zero just gets a redundant re-add. - if (archived_crc32 != 0) { - if (auto local_crc32 = ComputeFileCrc32(local_file)) { - if (*local_crc32 == archived_crc32) { - match_reason = "CRC32 matches"; - return true; - } - } - } - } - - // No attributes present, or none matched: re-add the file. - return false; -} - -int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, - const std::string &path_prefix, LCID locale, const GameRules &game_rules, - const CompressionSettingsOverrides &overrides, bool overwrite, bool update, - int *skipped) { - int files_added = 0; - int files_skipped = 0; - int files_failed = 0; - - for (const auto &file : files) { - // Determine relative path lexically rather than with fs::relative, which - // resolves paths through the OS and throws on volumes that cannot report - // real paths (RAM disks, some network shares). - fs::path input_file_path = file.lexically_relative(base_path); - std::string archive_file_path; - - if (path_prefix.empty()) { - archive_file_path = WindowsifyFilePath(input_file_path.u8string()); - } else { - archive_file_path = - WindowsifyFilePath((fs::path(path_prefix) / input_file_path).u8string()); - } - - if (std::find(special_mpq_files.begin(), special_mpq_files.end(), archive_file_path) != - special_mpq_files.end()) { - std::cout << "[*] Skipping special MPQ file: " << archive_file_path << std::endl; - continue; - } - - int file_skipped = 0; - if (AddFile(archive, file, archive_file_path, locale, game_rules, overrides, overwrite, - update, &file_skipped) != 0) { - files_failed++; - } else if (file_skipped > 0) { - files_skipped++; - } else { - files_added++; - } - } - - if (update) { - std::cout << "[*] For " << base_path.u8string() << ": " << files_added << " files added, " - << files_skipped << " files skipped, " << files_failed << " files failed." - << std::endl; - } - - if (skipped != nullptr) { - *skipped += files_skipped; - } - - return files_failed > 0 ? 1 : 0; -} - -int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archive_file_path, - const LCID locale, const GameRules &game_rules, - const CompressionSettingsOverrides &overrides, bool overwrite, bool update, - int *skipped) { - // Return if file doesn't exist on disk - std::error_code ec; - if (!fs::exists(local_file, ec)) { - std::cerr << "[!] File doesn't exist on disk: " << local_file << std::endl; - return 1; - } - - // Check if file exists in MPQ archive - SFileSetLocale(locale); - HANDLE file; - if (SFileOpenFileEx(archive, archive_file_path.c_str(), SFILE_OPEN_FROM_MPQ, &file)) { - const auto file_locale = GetFileInfo(file, SFileInfoLocale); - if (file_locale == locale) { - // --update: leave the archived copy alone while it still matches the local file - std::string match_reason; - const bool unchanged = - update && ArchivedFileMatches(archive, file, local_file, match_reason); - SFileCloseFile(file); - - if (unchanged) { - std::cout << "[~] Skipping unchanged file: " << archive_file_path << " (" - << match_reason << ")" << std::endl; - if (skipped != nullptr) { - (*skipped)++; - } - return 0; - } - - // Without either flag, leaving the archived copy in place is the documented - // default, so this is a skip rather than a failure. - if (!overwrite && !update) { - std::cerr << "[!] File" << PrettyPrintLocale(locale, " for locale ") - << " already exists in MPQ archive: " << archive_file_path - << " - Skipping..." << std::endl; - if (skipped != nullptr) { - (*skipped)++; - } - return 0; - } - - std::cout << "[+] File" << PrettyPrintLocale(locale, " for locale ") - << " already exists in MPQ archive: " << archive_file_path - << " - Overwriting..." << std::endl; - } else { - SFileCloseFile(file); - } - } - std::cout << "[+] Adding file" << PrettyPrintLocale(locale, " for locale ") << ": " - << archive_file_path << std::endl; - - // Verify that we are not exceeding maxFile size of the archive, and if we do, increase it - int32_t number_of_files = GetFileInfo(archive, SFileMpqNumberOfFiles); - int32_t max_files = GetFileInfo(archive, SFileMpqMaxFileCount); - - if (number_of_files + 1 > max_files) { - uint32_t new_max_files = NextPowerOfTwo(static_cast(number_of_files + 1)); - bool set_max_file_count = SFileSetMaxFileCount(archive, new_max_files); - if (!set_max_file_count) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to increase new max file count to " << new_max_files << ": (" - << error << ") " << StormErrorString(error) << std::endl; - return 1; - } - } - - // Get file size for rule matching - const std::uintmax_t raw_file_size = fs::file_size(local_file, ec); - if (ec) { - std::cerr << "[!] Failed to read file size: (" << ec.value() << ") " << ec.message() << ": " - << local_file << std::endl; - return 1; - } - if (raw_file_size > std::numeric_limits::max()) { - std::cerr << "[!] Warning: file exceeds 4GB, size-based compression rules may not apply " - "correctly: " - << local_file << std::endl; - } - const DWORD file_size = static_cast( - std::min(raw_file_size, static_cast(std::numeric_limits::max()))); - - // Get game-specific rules - const auto settings = game_rules.GetCompressionSettings(archive_file_path, file_size); - - // Apply overrides where specified, otherwise use game rules - DWORD flags = overrides.flags.value_or(settings.mpq_flags); - DWORD compression = overrides.compression.value_or(settings.compression_first); - DWORD compression_next = overrides.compression_next.value_or(settings.compression_next); - - // Both flags mean "replace what is already there"; --update has simply decided - // beforehand that this particular file is worth replacing. - if (overwrite || update) { - flags |= MPQ_FILE_REPLACEEXISTING; - } - - bool added_file = - SFileAddFileEx(archive, local_file.u8string().c_str(), archive_file_path.c_str(), flags, - compression, compression_next); - - if (!added_file) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to add: " << archive_file_path << ": (" << error << ") " - << StormErrorString(error) << std::endl; - return 1; - } - - return 0; -} - -int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale) { - SFileSetLocale(locale); - std::cout << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") << ": " - << archive_file_path << std::endl; - - if (!FileExistsInArchiveForLocale(archive, archive_file_path, locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(locale, " for locale ", true) << ": " << archive_file_path - << std::endl; - return 1; - } - - if (!SFileRemoveFile(archive, archive_file_path.c_str(), 0)) { - std::cerr << "[!] Failed: File cannot be removed" - << PrettyPrintLocale(locale, " for locale ", true) << ": " << archive_file_path - << std::endl; - return 1; - } - - return 0; -} - -int RenameFile(HANDLE archive, const std::string &old_archive_file_path, - const std::string &new_archive_file_path, LCID locale) { - SFileSetLocale(locale); - const std::string new_stored_path = WindowsifyFilePath(new_archive_file_path); - std::cout << "[~] Renaming file" << PrettyPrintLocale(locale, " for locale ") << ": " - << old_archive_file_path << " -> " << new_stored_path << std::endl; - - if (!FileExistsInArchiveForLocale(archive, old_archive_file_path, locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(locale, " for locale ", true) << ": " - << old_archive_file_path << std::endl; - return 1; - } - - if (!SFileRenameFile(archive, old_archive_file_path.c_str(), new_stored_path.c_str())) { - std::cerr << "[!] Failed: File cannot be renamed" - << PrettyPrintLocale(locale, " for locale ", true) << ": " - << old_archive_file_path << " -> " << new_stored_path << std::endl; - return 1; - } - - return 0; -} - -std::string GetFlagString(uint32_t flags) { - std::string result; - - // Preserve column-aligned flag-to-char mappings - // clang-format off - if (flags & MPQ_FILE_IMPLODE) result += 'i'; - if (flags & MPQ_FILE_COMPRESS) result += 'c'; - if (flags & MPQ_FILE_ENCRYPTED) result += 'e'; - if (flags & MPQ_FILE_KEY_V2) result += '2'; - if (flags & MPQ_FILE_PATCH_FILE) result += 'p'; - if (flags & MPQ_FILE_SINGLE_UNIT) result += 'u'; - if (flags & MPQ_FILE_DELETE_MARKER) result += 'd'; - if (flags & MPQ_FILE_SECTOR_CRC) result += 'r'; - if (flags & MPQ_FILE_SIGNATURE) result += 's'; - if (flags & MPQ_FILE_EXISTS) result += 'x'; - 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 archive, const std::optional &listfile_name, bool list_all, - bool list_detailed, const std::vector &properties) { - // Check if the user provided a listfile input - const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; - - SFILE_FIND_DATA find_data; - HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); - if (find_handle == nullptr) { - std::cerr << "[!] Failed to find first file in MPQ archive." << std::endl; - return -1; - } - - std::vector properties_to_print = - properties.empty() ? std::vector{"file-size", "locale", "file-time"} - : properties; - if (!properties.empty()) { - list_detailed = - true; // If the user specified properties, we need to print the detailed output - } - - // Map of property name to SFileInfoClass, defined once, outside the loop - static const std::map property_info_class = { - {"hash-index", SFileInfoHashIndex}, - {"name-hash1", SFileInfoNameHash1}, - {"name-hash2", SFileInfoNameHash2}, - {"name-hash3", SFileInfoNameHash3}, - {"locale", SFileInfoLocale}, - {"file-index", SFileInfoFileIndex}, - {"byte-offset", SFileInfoByteOffset}, - {"file-time", SFileInfoFileTime}, - {"file-size", SFileInfoFileSize}, - {"compressed-size", SFileInfoCompressedSize}, - {"flags", SFileInfoFlags}, - {"encryption-key", SFileInfoEncryptionKey}, - {"encryption-key-raw", SFileInfoEncryptionKeyRaw}, - }; - - std::set - seen_file_names; // 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 (!list_all && std::find(special_mpq_files.begin(), special_mpq_files.end(), - find_data.cFileName) != special_mpq_files.end()) { - continue; - } - - // Print the detailed (long) file listing (like ls -l) - if (list_detailed) { - if (seen_file_names.find(find_data.cFileName) != seen_file_names.end()) { - // Filename has been seen before, and thus printed before. Skip over it. - continue; - } - seen_file_names.insert(find_data.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 max_locales = 32; // This will be updated in the call to SFileEnumLocales - std::vector file_locale_vec(max_locales); - LCID *file_locales = file_locale_vec.data(); - - DWORD result = - SFileEnumLocales(archive, find_data.cFileName, file_locales, &max_locales, 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. - max_locales = 1; - file_locales[0] = default_locale; - - } else if (result == ERROR_INVALID_HANDLE || result == ERROR_NOT_SUPPORTED) { - std::cerr << "[!] Internal error for file: " << find_data.cFileName << std::endl; - continue; - - } else if (result == ERROR_INSUFFICIENT_BUFFER) { - std::cerr << "[!] There are more than " << max_locales - << " locales for the file: " << find_data.cFileName - << ". Will only list the " << max_locales << " first files." << std::endl; - } - - // Loop through all found locales - for (DWORD i = 0; i < max_locales; i++) { - LCID locale = file_locales[i]; - SFileSetLocale(locale); - HANDLE file; - - // We need to open the file to get detailed information - // Use our custom GetFileInfo function - if (!SFileOpenFileEx(archive, find_data.cFileName, SFILE_OPEN_FROM_MPQ, &file)) { - std::cerr << "[!] Failed to open file: " << find_data.cFileName << std::endl; - continue; // Skip to the next file - } - - for (const auto &prop : properties_to_print) { - auto it = property_info_class.find(prop); - if (it == property_info_class.end()) - continue; - - if (prop == "hash-index" || prop == "file-index") { - std::cout << std::setw(5) << GetFileInfo(file, it->second) << " "; - } else if (prop == "name-hash1" || prop == "name-hash2") { - std::cout << std::setfill('0') << std::hex << std::setw(8) - << GetFileInfo(file, it->second) << std::setfill(' ') - << std::dec << " "; - } else if (prop == "name-hash3") { - std::cout << std::setfill('0') << std::hex << std::setw(16) - << GetFileInfo(file, it->second) << std::setfill(' ') - << std::dec << " "; - } else if (prop == "locale") { - std::cout << std::setw(4) - << LocaleToLang(GetFileInfo(file, it->second)) << " "; - } else if (prop == "byte-offset") { - std::cout << std::hex << std::setw(8) - << GetFileInfo(file, it->second) << std::dec << " "; - } else if (prop == "file-time") { - std::cout << std::setw(19) - << FileTimeToLsTime(GetFileInfo(file, it->second)) - << " "; - } else if (prop == "file-size" || prop == "compressed-size") { - std::cout << std::setw(8) << GetFileInfo(file, it->second) << " "; - } else if (prop == "flags") { - std::cout << std::setw(8) - << GetFlagString(GetFileInfo(file, it->second)) << " "; - } else if (prop == "encryption-key" || prop == "encryption-key-raw") { - std::cout << std::setfill('0') << std::hex << std::setw(8) - << GetFileInfo(file, it->second) << std::setfill(' ') - << std::dec << " "; - } - } - - std::cout << " " << find_data.cFileName << std::endl; - SFileCloseFile(file); - } - SFileSetLocale(default_locale); // Reset locale to default after changing it - } else { - // Print just the filename (like default ls command output) - std::cout << find_data.cFileName << std::endl; - } - - } while (SFileFindNextFile(find_handle, &find_data)); - - SFileFindClose(find_handle); - return 0; -} - -std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, - LCID preferred_locale) { - SFileSetLocale(preferred_locale); - if (!FileExistsInArchiveForLocale(archive, file_name, preferred_locale) && - !FileExistsInArchiveForLocale(archive, file_name, default_locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name - << std::endl; - return nullptr; - } - - HANDLE file; - if (!SFileOpenFileEx(archive, file_name, SFILE_OPEN_FROM_MPQ, &file)) { - std::cerr << "[!] Failed: File cannot be opened: " << file_name << std::endl; - return nullptr; - } - - *file_size = SFileGetFileSize(file, nullptr); - if (*file_size == SFILE_INVALID_SIZE) { - std::cerr << "[!] Failed: Invalid file size for: " << file_name << std::endl; - SFileCloseFile(file); - return nullptr; - } - - auto file_content = std::make_unique(*file_size); - DWORD bytes_read; - if (!SFileReadFile(file, file_content.get(), *file_size, &bytes_read, nullptr)) { - std::cerr << "[!] Failed: Cannot read file contents for: " << file_name << std::endl; - SFileCloseFile(file); - return nullptr; - } - - SFileCloseFile(file); - return file_content; -} - -void PrintMpqInfo(HANDLE archive, const std::optional &info_property) { - // Map of property names to their corresponding actions - std::map> property_actions = { - {"format-version", - [&](bool print_name) { - TMPQHeader header = GetFileInfo(archive, SFileMpqHeader); - uint16_t format_version = - header.wFormatVersion + 1; // Add +1 because StormLib starts at 0 - if (print_name) { - std::cout << "Format version: "; - } - std::cout << format_version << std::endl; - }}, - {"header-offset", - [&](bool print_name) { - int64_t header_offset = GetFileInfo(archive, SFileMpqHeaderOffset); - if (print_name) { - std::cout << "Header offset: "; - } - std::cout << header_offset << std::endl; - }}, - {"header-size", - [&](bool print_name) { - int64_t header_size = GetFileInfo(archive, SFileMpqHeaderSize); - if (print_name) { - std::cout << "Header size: "; - } - std::cout << header_size << std::endl; - }}, - {"archive-size", - [&](bool print_name) { - int64_t archive_size = GetFileInfo(archive, SFileMpqArchiveSize64); - if (print_name) { - std::cout << "Archive size: "; - } - std::cout << archive_size << std::endl; - }}, - {"file-count", - [&](bool print_name) { - int32_t number_of_files = GetFileInfo(archive, SFileMpqNumberOfFiles); - if (print_name) { - std::cout << "File count: "; - } - std::cout << number_of_files << std::endl; - }}, - {"max-files", - [&](bool print_name) { - int32_t max_files = GetFileInfo(archive, SFileMpqMaxFileCount); - if (print_name) { - std::cout << "Max files: "; - } - std::cout << max_files << std::endl; - }}, - {"signature-type", [&](bool print_name) { - int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); - if (print_name) { - std::cout << "Signature type: "; - } - if (signature_type == SIGNATURE_TYPE_NONE) { - std::cout << "None" << std::endl; - } else if (signature_type == SIGNATURE_TYPE_WEAK) { - std::cout << "Weak" << std::endl; - } else if (signature_type == SIGNATURE_TYPE_STRONG) { - std::cout << "Strong" << std::endl; - } - }}}; - - // If infoProperty is not set, print all properties with their names (key) - // Otherwise, print only the specified property value - if (!info_property.has_value()) { - for (const auto &[key, action] : property_actions) { - action(true); // Print property name and value - } - } else { - auto it = property_actions.find(info_property.value()); - if (it != property_actions.end()) { - it->second(false); // Print only the value - } - } -} - -uint32_t VerifyMpqArchive(HANDLE archive) { - return SFileVerifyArchive(archive); -} - -int CompactMpqArchive(HANDLE archive, const std::optional &listfile_name) { - std::cout << "[*] Compacting archive. This may take some time..." << std::endl; - // Check if the user provided a listfile input - const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; - - if (!SFileCompactArchive(archive, listfile, false)) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to compact archive: (" << error << ") " << StormErrorString(error) - << std::endl; - return 1; - } - return 0; -} - -int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { - // Determine if we have a strong or weak digital signature - int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); - - std::vector signature_content; - - if (signature_type == SIGNATURE_TYPE_NONE) { - return 1; - } else if (signature_type == SIGNATURE_TYPE_WEAK) { - const char *file_name = "(signature)"; - uint32_t file_size; - auto file_content = ReadFile(archive, file_name, &file_size, default_locale); - - if (!file_content) { - std::cerr << "[!] Failed to read weak signature file." << std::endl; - return -1; - } - signature_content.resize(file_size); - std::copy(file_content.get(), file_content.get() + file_size, signature_content.begin()); - - PrintAsBinary(file_content.get(), file_size); - - } else if (signature_type == SIGNATURE_TYPE_STRONG) { - // StormLib does not expose the strong signature via SFileGetFileInfo into a - // growable buffer (SFileMpqStrongSignature's advertised size always exceeds - // sizeof(std::vector)), so read it directly from the archive file instead. - int64_t archive_size = GetFileInfo(archive, SFileMpqArchiveSize64); - int64_t archive_offset = GetFileInfo(archive, SFileMpqHeaderOffset); - - const fs::path archive_path(target); - std::error_code ec; - const std::uintmax_t file_size = fs::file_size(archive_path, ec); - if (ec) { - std::cerr << "[!] Failed to read archive size: (" << ec.value() << ") " << ec.message() - << ": " << target << std::endl; - return -1; - } - int64_t signature_length = file_size - archive_offset - archive_size; - - if (signature_length <= 0) { - std::cerr << "[!] Invalid signature length: " << signature_length << std::endl; - return -1; - } - - std::ifstream file_mpq(archive_path, std::ios::binary); - file_mpq.seekg(archive_offset + archive_size, std::ios::beg); - signature_content.resize(static_cast(signature_length)); - file_mpq.read(signature_content.data(), signature_content.size()); - file_mpq.close(); - - PrintAsBinary(signature_content.data(), static_cast(signature_content.size())); - } - - return 0; -} diff --git a/src/mpq.h b/src/mpq.h deleted file mode 100644 index 362628d..0000000 --- a/src/mpq.h +++ /dev/null @@ -1,52 +0,0 @@ -#ifndef MPQ_H -#define MPQ_H - -#include -#include -#include -#include - -#include - -#include "gamerules.h" - -namespace fs = std::filesystem; - -bool OpenMpqArchive(const std::string &filename, HANDLE *archive, int32_t flags); -bool CloseMpqArchive(HANDLE archive); -bool SignMpqArchive(HANDLE archive); -int ExtractFiles(HANDLE archive, const std::string &output, - const std::optional &listfile_name, LCID preferred_locale); -int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, - bool keep_folder_structure, LCID preferred_locale); -HANDLE CreateMpqArchive(const std::string &output_archive_name, uint32_t file_count, - const GameRules &game_rules); -int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, - const std::string &path_prefix, LCID locale, const GameRules &game_rules, - const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), - bool overwrite = false, bool update = false, int *skipped = nullptr); -int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archive_file_path, - LCID locale, const GameRules &game_rules, - const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), - bool overwrite = false, bool update = false, int *skipped = nullptr); -int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale); -int RenameFile(HANDLE archive, const std::string &old_archive_file_path, - const std::string &new_archive_file_path, LCID locale); -int ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, - bool list_detailed, const std::vector &properties); -std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, - LCID preferred_locale); -void PrintMpqInfo(HANDLE archive, const std::optional &info_property); -uint32_t VerifyMpqArchive(HANDLE archive); -int CompactMpqArchive(HANDLE archive, const std::optional &listfile_name); -int32_t PrintMpqSignature(HANDLE archive, const std::string &target); - -template T GetFileInfo(HANDLE file, SFileInfoClass info_class) { - T value{}; - if (!SFileGetFileInfo(file, info_class, &value, sizeof(T), nullptr)) { - return T{}; - } - return value; -} - -#endif diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp new file mode 100644 index 0000000..a77591f --- /dev/null +++ b/src/mpq/add.cpp @@ -0,0 +1,179 @@ +#include "mpq/add.h" + +#include +#include +#include +#include +#include + +#include + +#include "gamerules.h" +#include "mpq/query.h" +#include "util/capacity.h" +#include "util/format.h" +#include "util/locales.h" +#include "util/path.h" + +int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, + const std::string &path_prefix, LCID locale, const GameRules &game_rules, + const CompressionSettingsOverrides &overrides, bool overwrite, bool update, + int *skipped) { + int files_added = 0; + int files_skipped = 0; + int files_failed = 0; + + for (const auto &file : files) { + // Determine relative path lexically rather than with fs::relative, which + // resolves paths through the OS and throws on volumes that cannot report + // real paths (RAM disks, some network shares). + fs::path input_file_path = file.lexically_relative(base_path); + std::string archive_file_path; + + if (path_prefix.empty()) { + archive_file_path = WindowsifyFilePath(input_file_path.u8string()); + } else { + archive_file_path = + WindowsifyFilePath((fs::path(path_prefix) / input_file_path).u8string()); + } + + if (std::find(special_mpq_files.begin(), special_mpq_files.end(), archive_file_path) != + special_mpq_files.end()) { + std::cout << "[*] Skipping special MPQ file: " << archive_file_path << std::endl; + continue; + } + + int file_skipped = 0; + if (AddFile(archive, file, archive_file_path, locale, game_rules, overrides, overwrite, + update, &file_skipped) != 0) { + files_failed++; + } else if (file_skipped > 0) { + files_skipped++; + } else { + files_added++; + } + } + + if (update) { + std::cout << "[*] For " << base_path.u8string() << ": " << files_added << " files added, " + << files_skipped << " files skipped, " << files_failed << " files failed." + << std::endl; + } + + if (skipped != nullptr) { + *skipped += files_skipped; + } + + return files_failed > 0 ? 1 : 0; +} +int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archive_file_path, + const LCID locale, const GameRules &game_rules, + const CompressionSettingsOverrides &overrides, bool overwrite, bool update, + int *skipped) { + // Return if file doesn't exist on disk + std::error_code ec; + if (!fs::exists(local_file, ec)) { + std::cerr << "[!] File doesn't exist on disk: " << local_file << std::endl; + return 1; + } + + // Check if file exists in MPQ archive + SFileSetLocale(locale); + HANDLE file; + if (SFileOpenFileEx(archive, archive_file_path.c_str(), SFILE_OPEN_FROM_MPQ, &file)) { + const auto file_locale = GetFileInfo(file, SFileInfoLocale); + if (file_locale == locale) { + // --update: leave the archived copy alone while it still matches the local file + std::string match_reason; + const bool unchanged = + update && ArchivedFileMatches(archive, file, local_file, match_reason); + SFileCloseFile(file); + + if (unchanged) { + std::cout << "[~] Skipping unchanged file: " << archive_file_path << " (" + << match_reason << ")" << std::endl; + if (skipped != nullptr) { + (*skipped)++; + } + return 0; + } + + // Without either flag, leaving the archived copy in place is the documented + // default, so this is a skip rather than a failure. + if (!overwrite && !update) { + std::cerr << "[!] File" << PrettyPrintLocale(locale, " for locale ") + << " already exists in MPQ archive: " << archive_file_path + << " - Skipping..." << std::endl; + if (skipped != nullptr) { + (*skipped)++; + } + return 0; + } + + std::cout << "[+] File" << PrettyPrintLocale(locale, " for locale ") + << " already exists in MPQ archive: " << archive_file_path + << " - Overwriting..." << std::endl; + } else { + SFileCloseFile(file); + } + } + std::cout << "[+] Adding file" << PrettyPrintLocale(locale, " for locale ") << ": " + << archive_file_path << std::endl; + + // Verify that we are not exceeding maxFile size of the archive, and if we do, increase it + int32_t number_of_files = GetFileInfo(archive, SFileMpqNumberOfFiles); + int32_t max_files = GetFileInfo(archive, SFileMpqMaxFileCount); + + if (number_of_files + 1 > max_files) { + uint32_t new_max_files = NextPowerOfTwo(static_cast(number_of_files + 1)); + bool set_max_file_count = SFileSetMaxFileCount(archive, new_max_files); + if (!set_max_file_count) { + const auto error = SErrGetLastError(); + std::cerr << "[!] Failed to increase new max file count to " << new_max_files << ": (" + << error << ") " << StormErrorString(error) << std::endl; + return 1; + } + } + + // Get file size for rule matching + const std::uintmax_t raw_file_size = fs::file_size(local_file, ec); + if (ec) { + std::cerr << "[!] Failed to read file size: (" << ec.value() << ") " << ec.message() << ": " + << local_file << std::endl; + return 1; + } + if (raw_file_size > std::numeric_limits::max()) { + std::cerr << "[!] Warning: file exceeds 4GB, size-based compression rules may not apply " + "correctly: " + << local_file << std::endl; + } + const DWORD file_size = static_cast( + std::min(raw_file_size, static_cast(std::numeric_limits::max()))); + + // Get game-specific rules + const auto settings = game_rules.GetCompressionSettings(archive_file_path, file_size); + + // Apply overrides where specified, otherwise use game rules + DWORD flags = overrides.flags.value_or(settings.mpq_flags); + DWORD compression = overrides.compression.value_or(settings.compression_first); + DWORD compression_next = overrides.compression_next.value_or(settings.compression_next); + + // Both flags mean "replace what is already there"; --update has simply decided + // beforehand that this particular file is worth replacing. + if (overwrite || update) { + flags |= MPQ_FILE_REPLACEEXISTING; + } + + bool added_file = + SFileAddFileEx(archive, local_file.u8string().c_str(), archive_file_path.c_str(), flags, + compression, compression_next); + + if (!added_file) { + const auto error = SErrGetLastError(); + std::cerr << "[!] Failed to add: " << archive_file_path << ": (" << error << ") " + << StormErrorString(error) << std::endl; + return 1; + } + + return 0; +} diff --git a/src/mpq/add.h b/src/mpq/add.h new file mode 100644 index 0000000..2c58145 --- /dev/null +++ b/src/mpq/add.h @@ -0,0 +1,23 @@ +#ifndef MPQ_ADD_H +#define MPQ_ADD_H + +#include +#include +#include + +#include + +#include "gamerules.h" + +namespace fs = std::filesystem; + +int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, + const std::string &path_prefix, LCID locale, const GameRules &game_rules, + const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), + bool overwrite = false, bool update = false, int *skipped = nullptr); +int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archive_file_path, + LCID locale, const GameRules &game_rules, + const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), + bool overwrite = false, bool update = false, int *skipped = nullptr); + +#endif diff --git a/src/mpq/archive.cpp b/src/mpq/archive.cpp new file mode 100644 index 0000000..cfde080 --- /dev/null +++ b/src/mpq/archive.cpp @@ -0,0 +1,37 @@ +#include "mpq/archive.h" + +#include +#include +#include + +#include + +#include "util/format.h" + +bool OpenMpqArchive(const std::string &filename, HANDLE *archive, int32_t flags) { + if (!SFileOpenArchive(filename.c_str(), 0, flags, archive)) { + const auto error = SErrGetLastError(); + std::cerr << "[!] Failed to open MPQ archive: " << filename << ": (" << error << ") " + << StormErrorString(error) << std::endl; + return false; + } + return true; +} +bool CloseMpqArchive(HANDLE archive) { + if (!SFileCloseArchive(archive)) { + const auto error = SErrGetLastError(); + std::cerr << "[!] Failed to close MPQ archive: (" << error << ") " + << StormErrorString(error) << std::endl; + return false; + } + return true; +} +bool SignMpqArchive(HANDLE archive) { + if (!SFileSignArchive(archive, SIGNATURE_TYPE_WEAK)) { + const auto error = SErrGetLastError(); + std::cerr << "[!] Failed to sign MPQ archive: (" << error << ") " << StormErrorString(error) + << std::endl; + return false; + } + return true; +} diff --git a/src/mpq/archive.h b/src/mpq/archive.h new file mode 100644 index 0000000..a9b5739 --- /dev/null +++ b/src/mpq/archive.h @@ -0,0 +1,13 @@ +#ifndef MPQ_ARCHIVE_H +#define MPQ_ARCHIVE_H + +#include +#include + +#include + +bool OpenMpqArchive(const std::string &filename, HANDLE *archive, int32_t flags); +bool CloseMpqArchive(HANDLE archive); +bool SignMpqArchive(HANDLE archive); + +#endif diff --git a/src/mpq/compact.cpp b/src/mpq/compact.cpp new file mode 100644 index 0000000..2e2145e --- /dev/null +++ b/src/mpq/compact.cpp @@ -0,0 +1,23 @@ +#include "mpq/compact.h" + +#include +#include +#include + +#include + +#include "util/format.h" + +int CompactMpqArchive(HANDLE archive, const std::optional &listfile_name) { + std::cout << "[*] Compacting archive. This may take some time..." << std::endl; + // Check if the user provided a listfile input + const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; + + if (!SFileCompactArchive(archive, listfile, false)) { + const auto error = SErrGetLastError(); + std::cerr << "[!] Failed to compact archive: (" << error << ") " << StormErrorString(error) + << std::endl; + return 1; + } + return 0; +} diff --git a/src/mpq/compact.h b/src/mpq/compact.h new file mode 100644 index 0000000..03383fa --- /dev/null +++ b/src/mpq/compact.h @@ -0,0 +1,11 @@ +#ifndef MPQ_COMPACT_H +#define MPQ_COMPACT_H + +#include +#include + +#include + +int CompactMpqArchive(HANDLE archive, const std::optional &listfile_name); + +#endif diff --git a/src/mpq/create.cpp b/src/mpq/create.cpp new file mode 100644 index 0000000..687938d --- /dev/null +++ b/src/mpq/create.cpp @@ -0,0 +1,53 @@ +#include "mpq/create.h" + +#include +#include +#include +#include + +#include + +#include "gamerules.h" +#include "util/format.h" + +namespace fs = std::filesystem; + +HANDLE CreateMpqArchive(const std::string &output_archive_name, const uint32_t file_count, + const GameRules &game_rules) { + // Check if file already exists + std::error_code ec; + if (fs::exists(output_archive_name, ec)) { + std::cerr << "[!] File already exists: " << output_archive_name << " Exiting..." + << std::endl; + return nullptr; + } + + HANDLE archive; + + // Use game-specific create settings + const MpqCreateSettings &settings = game_rules.GetCreateSettings(); + + SFILE_CREATE_MPQ create_info = {}; + // All logic for defaults and dependencies is handled in GameRules::OverrideCreateSettings + create_info.cbSize = sizeof(SFILE_CREATE_MPQ); + create_info.dwMpqVersion = settings.mpq_version; + create_info.dwStreamFlags = settings.stream_flags; + create_info.dwFileFlags1 = settings.file_flags1; + create_info.dwFileFlags2 = settings.file_flags2; + create_info.dwFileFlags3 = settings.file_flags3; + create_info.dwAttrFlags = settings.attr_flags; + create_info.dwSectorSize = settings.sector_size; + create_info.dwRawChunkSize = settings.raw_chunk_size; + create_info.dwMaxFileCount = file_count; + + const bool result = SFileCreateArchive2(output_archive_name.c_str(), &create_info, &archive); + + if (!result) { + const auto error = SErrGetLastError(); + std::cerr << "[!] Failed to create MPQ archive: " << output_archive_name << ": (" << error + << ") " << StormErrorString(error) << std::endl; + return nullptr; + } + + return archive; +} diff --git a/src/mpq/create.h b/src/mpq/create.h new file mode 100644 index 0000000..b512bed --- /dev/null +++ b/src/mpq/create.h @@ -0,0 +1,14 @@ +#ifndef MPQ_CREATE_H +#define MPQ_CREATE_H + +#include +#include + +#include + +#include "gamerules.h" + +HANDLE CreateMpqArchive(const std::string &output_archive_name, uint32_t file_count, + const GameRules &game_rules); + +#endif diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp new file mode 100644 index 0000000..569c1ba --- /dev/null +++ b/src/mpq/extract.cpp @@ -0,0 +1,118 @@ +#include "mpq/extract.h" + +#include +#include +#include + +#include + +#include "mpq/query.h" +#include "util/format.h" +#include "util/locales.h" +#include "util/path.h" + +int ExtractFiles(HANDLE archive, const std::string &output, + const std::optional &listfile_name, LCID preferred_locale) { + SFileSetLocale(preferred_locale); + // Check if the user provided a listfile input + const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; + + SFILE_FIND_DATA find_data; + HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); + if (find_handle == nullptr) { + std::cerr << "[!] Failed to find first file in MPQ archive." << std::endl; + return 1; + } + + int32_t result = 0; + do { + result |= ExtractFile(archive, output, find_data.cFileName, + true, // Keep folder structure + preferred_locale); + } while (SFileFindNextFile(find_handle, &find_data)); + + SFileFindClose(find_handle); + return result; +} +int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, + bool keep_folder_structure, LCID preferred_locale) { + SFileSetLocale(preferred_locale); + if (!FileExistsInArchiveForLocale(archive, file_name.c_str(), preferred_locale) && + !FileExistsInArchiveForLocale(archive, file_name.c_str(), default_locale)) { + std::cerr << "[!] Failed: File doesn't exist" + << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name + << std::endl; + return 1; + } + + // Change forward slashes on non-Windows systems + fs::path file_name_path(file_name); + std::string file_name_string = NormalizeFilePath(file_name_path); + + // Remove folder structure if keepFolderStructure is false + if (!keep_folder_structure) { + file_name_path = fs::path(file_name_string); + file_name_string = file_name_path.filename().u8string(); + } + + std::error_code ec; + fs::path output_path_base = fs::absolute(output, ec).lexically_normal(); + if (ec) { + std::cerr << "[!] Failed to resolve output directory: (" << ec.value() << ") " + << ec.message() << ": " << output << std::endl; + return 1; + } + if (output_path_base.filename().empty()) { + output_path_base = output_path_base.parent_path(); + } + + // Guard against path traversal attacks in two stages. First lexically, so a + // ".." entry is rejected before anything is created on disk + fs::path output_file_path_name = (output_path_base / file_name_string).lexically_normal(); + if (!IsWithinDirectory(output_path_base, output_file_path_name)) { + std::cerr << "[!] Blocked: path traversal attempt detected: " << file_name_string + << std::endl; + return 1; + } + + // Ensure sub-directories for folder-nested files exist before resolving + fs::create_directories(output_file_path_name.parent_path(), ec); + if (ec) { + std::cerr << "[!] Failed to create output directory: (" << ec.value() << ") " + << ec.message() << ": " << output_file_path_name.parent_path().u8string() + << std::endl; + return 1; + } + + // Second, through the OS to also catch symlinks. Volumes that cannot report + // real paths (RAM disks) fail here, in which case the lexical check above is + // the only guard; HandleExtract warns about this once + fs::path resolved_base = fs::canonical(output_path_base, ec); + if (!ec) { + fs::path resolved_output = fs::canonical(output_file_path_name.parent_path(), ec) / + output_file_path_name.filename(); + if (ec) { + std::cerr << "[!] Failed to resolve output path: (" << ec.value() << ") " + << ec.message() << ": " << output_file_path_name.u8string() << std::endl; + return 1; + } + if (!IsWithinDirectory(resolved_base, resolved_output)) { + std::cerr << "[!] Blocked: path traversal attempt detected: " << file_name_string + << std::endl; + return 1; + } + } + + std::string output_file_name{output_file_path_name.u8string()}; + + if (SFileExtractFile(archive, file_name.c_str(), output_file_name.c_str(), 0)) { + std::cout << "[*] Extracted: " << file_name_string << std::endl; + } else { + const auto error = SErrGetLastError(); + std::cerr << "[!] Failed: (" << error << ") " << StormErrorString(error) << ": " + << file_name << std::endl; + return 1; + } + + return 0; +} diff --git a/src/mpq/extract.h b/src/mpq/extract.h new file mode 100644 index 0000000..828c092 --- /dev/null +++ b/src/mpq/extract.h @@ -0,0 +1,14 @@ +#ifndef MPQ_EXTRACT_H +#define MPQ_EXTRACT_H + +#include +#include + +#include + +int ExtractFiles(HANDLE archive, const std::string &output, + const std::optional &listfile_name, LCID preferred_locale); +int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, + bool keep_folder_structure, LCID preferred_locale); + +#endif diff --git a/src/mpq/flags.cpp b/src/mpq/flags.cpp new file mode 100644 index 0000000..5371556 --- /dev/null +++ b/src/mpq/flags.cpp @@ -0,0 +1,29 @@ +#include "mpq/flags.h" + +#include +#include + +#include + +std::string GetFlagString(uint32_t flags) { + std::string result; + + // Preserve column-aligned flag-to-char mappings + // clang-format off + if (flags & MPQ_FILE_IMPLODE) result += 'i'; + if (flags & MPQ_FILE_COMPRESS) result += 'c'; + if (flags & MPQ_FILE_ENCRYPTED) result += 'e'; + if (flags & MPQ_FILE_KEY_V2) result += '2'; + if (flags & MPQ_FILE_PATCH_FILE) result += 'p'; + if (flags & MPQ_FILE_SINGLE_UNIT) result += 'u'; + if (flags & MPQ_FILE_DELETE_MARKER) result += 'd'; + if (flags & MPQ_FILE_SECTOR_CRC) result += 'r'; + if (flags & MPQ_FILE_SIGNATURE) result += 's'; + if (flags & MPQ_FILE_EXISTS) result += 'x'; + 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; +} diff --git a/src/mpq/flags.h b/src/mpq/flags.h new file mode 100644 index 0000000..83c044e --- /dev/null +++ b/src/mpq/flags.h @@ -0,0 +1,9 @@ +#ifndef MPQ_FLAGS_H +#define MPQ_FLAGS_H + +#include +#include + +std::string GetFlagString(uint32_t flags); + +#endif diff --git a/src/mpq/info.cpp b/src/mpq/info.cpp new file mode 100644 index 0000000..33faad3 --- /dev/null +++ b/src/mpq/info.cpp @@ -0,0 +1,93 @@ +#include "mpq/info.h" + +#include +#include +#include +#include +#include + +#include + +#include "mpq/query.h" +#include "util/format.h" + +void PrintMpqInfo(HANDLE archive, const std::optional &info_property) { + // Map of property names to their corresponding actions + std::map> property_actions = { + {"format-version", + [&](bool print_name) { + TMPQHeader header = GetFileInfo(archive, SFileMpqHeader); + uint16_t format_version = + header.wFormatVersion + 1; // Add +1 because StormLib starts at 0 + if (print_name) { + std::cout << "Format version: "; + } + std::cout << format_version << std::endl; + }}, + {"header-offset", + [&](bool print_name) { + int64_t header_offset = GetFileInfo(archive, SFileMpqHeaderOffset); + if (print_name) { + std::cout << "Header offset: "; + } + std::cout << header_offset << std::endl; + }}, + {"header-size", + [&](bool print_name) { + int64_t header_size = GetFileInfo(archive, SFileMpqHeaderSize); + if (print_name) { + std::cout << "Header size: "; + } + std::cout << header_size << std::endl; + }}, + {"archive-size", + [&](bool print_name) { + int64_t archive_size = GetFileInfo(archive, SFileMpqArchiveSize64); + if (print_name) { + std::cout << "Archive size: "; + } + std::cout << archive_size << std::endl; + }}, + {"file-count", + [&](bool print_name) { + int32_t number_of_files = GetFileInfo(archive, SFileMpqNumberOfFiles); + if (print_name) { + std::cout << "File count: "; + } + std::cout << number_of_files << std::endl; + }}, + {"max-files", + [&](bool print_name) { + int32_t max_files = GetFileInfo(archive, SFileMpqMaxFileCount); + if (print_name) { + std::cout << "Max files: "; + } + std::cout << max_files << std::endl; + }}, + {"signature-type", [&](bool print_name) { + int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); + if (print_name) { + std::cout << "Signature type: "; + } + if (signature_type == SIGNATURE_TYPE_NONE) { + std::cout << "None" << std::endl; + } else if (signature_type == SIGNATURE_TYPE_WEAK) { + std::cout << "Weak" << std::endl; + } else if (signature_type == SIGNATURE_TYPE_STRONG) { + std::cout << "Strong" << std::endl; + } + }}}; + + // If infoProperty is not set, print all properties with their names (key) + // Otherwise, print only the specified property value + if (!info_property.has_value()) { + for (const auto &[key, action] : property_actions) { + action(true); // Print property name and value + } + } else { + auto it = property_actions.find(info_property.value()); + if (it != property_actions.end()) { + it->second(false); // Print only the value + } + } +} diff --git a/src/mpq/info.h b/src/mpq/info.h new file mode 100644 index 0000000..b6a7bb3 --- /dev/null +++ b/src/mpq/info.h @@ -0,0 +1,11 @@ +#ifndef MPQ_INFO_H +#define MPQ_INFO_H + +#include +#include + +#include + +void PrintMpqInfo(HANDLE archive, const std::optional &info_property); + +#endif diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp new file mode 100644 index 0000000..935cca8 --- /dev/null +++ b/src/mpq/list.cpp @@ -0,0 +1,164 @@ +#include "mpq/list.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "mpq/flags.h" +#include "mpq/query.h" +#include "util/format.h" +#include "util/locales.h" + +int ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, + bool list_detailed, const std::vector &properties) { + // Check if the user provided a listfile input + const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; + + SFILE_FIND_DATA find_data; + HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); + if (find_handle == nullptr) { + std::cerr << "[!] Failed to find first file in MPQ archive." << std::endl; + return -1; + } + + std::vector properties_to_print = + properties.empty() ? std::vector{"file-size", "locale", "file-time"} + : properties; + if (!properties.empty()) { + list_detailed = + true; // If the user specified properties, we need to print the detailed output + } + + // Map of property name to SFileInfoClass, defined once, outside the loop + static const std::map property_info_class = { + {"hash-index", SFileInfoHashIndex}, + {"name-hash1", SFileInfoNameHash1}, + {"name-hash2", SFileInfoNameHash2}, + {"name-hash3", SFileInfoNameHash3}, + {"locale", SFileInfoLocale}, + {"file-index", SFileInfoFileIndex}, + {"byte-offset", SFileInfoByteOffset}, + {"file-time", SFileInfoFileTime}, + {"file-size", SFileInfoFileSize}, + {"compressed-size", SFileInfoCompressedSize}, + {"flags", SFileInfoFlags}, + {"encryption-key", SFileInfoEncryptionKey}, + {"encryption-key-raw", SFileInfoEncryptionKeyRaw}, + }; + + std::set + seen_file_names; // 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 (!list_all && std::find(special_mpq_files.begin(), special_mpq_files.end(), + find_data.cFileName) != special_mpq_files.end()) { + continue; + } + + // Print the detailed (long) file listing (like ls -l) + if (list_detailed) { + if (seen_file_names.find(find_data.cFileName) != seen_file_names.end()) { + // Filename has been seen before, and thus printed before. Skip over it. + continue; + } + seen_file_names.insert(find_data.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 max_locales = 32; // This will be updated in the call to SFileEnumLocales + std::vector file_locale_vec(max_locales); + LCID *file_locales = file_locale_vec.data(); + + DWORD result = + SFileEnumLocales(archive, find_data.cFileName, file_locales, &max_locales, 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. + max_locales = 1; + file_locales[0] = default_locale; + + } else if (result == ERROR_INVALID_HANDLE || result == ERROR_NOT_SUPPORTED) { + std::cerr << "[!] Internal error for file: " << find_data.cFileName << std::endl; + continue; + + } else if (result == ERROR_INSUFFICIENT_BUFFER) { + std::cerr << "[!] There are more than " << max_locales + << " locales for the file: " << find_data.cFileName + << ". Will only list the " << max_locales << " first files." << std::endl; + } + + // Loop through all found locales + for (DWORD i = 0; i < max_locales; i++) { + LCID locale = file_locales[i]; + SFileSetLocale(locale); + HANDLE file; + + // We need to open the file to get detailed information + // Use our custom GetFileInfo function + if (!SFileOpenFileEx(archive, find_data.cFileName, SFILE_OPEN_FROM_MPQ, &file)) { + std::cerr << "[!] Failed to open file: " << find_data.cFileName << std::endl; + continue; // Skip to the next file + } + + for (const auto &prop : properties_to_print) { + auto it = property_info_class.find(prop); + if (it == property_info_class.end()) + continue; + + if (prop == "hash-index" || prop == "file-index") { + std::cout << std::setw(5) << GetFileInfo(file, it->second) << " "; + } else if (prop == "name-hash1" || prop == "name-hash2") { + std::cout << std::setfill('0') << std::hex << std::setw(8) + << GetFileInfo(file, it->second) << std::setfill(' ') + << std::dec << " "; + } else if (prop == "name-hash3") { + std::cout << std::setfill('0') << std::hex << std::setw(16) + << GetFileInfo(file, it->second) << std::setfill(' ') + << std::dec << " "; + } else if (prop == "locale") { + std::cout << std::setw(4) + << LocaleToLang(GetFileInfo(file, it->second)) << " "; + } else if (prop == "byte-offset") { + std::cout << std::hex << std::setw(8) + << GetFileInfo(file, it->second) << std::dec << " "; + } else if (prop == "file-time") { + std::cout << std::setw(19) + << FileTimeToLsTime(GetFileInfo(file, it->second)) + << " "; + } else if (prop == "file-size" || prop == "compressed-size") { + std::cout << std::setw(8) << GetFileInfo(file, it->second) << " "; + } else if (prop == "flags") { + std::cout << std::setw(8) + << GetFlagString(GetFileInfo(file, it->second)) << " "; + } else if (prop == "encryption-key" || prop == "encryption-key-raw") { + std::cout << std::setfill('0') << std::hex << std::setw(8) + << GetFileInfo(file, it->second) << std::setfill(' ') + << std::dec << " "; + } + } + + std::cout << " " << find_data.cFileName << std::endl; + SFileCloseFile(file); + } + SFileSetLocale(default_locale); // Reset locale to default after changing it + } else { + // Print just the filename (like default ls command output) + std::cout << find_data.cFileName << std::endl; + } + + } while (SFileFindNextFile(find_handle, &find_data)); + + SFileFindClose(find_handle); + return 0; +} diff --git a/src/mpq/list.h b/src/mpq/list.h new file mode 100644 index 0000000..267160e --- /dev/null +++ b/src/mpq/list.h @@ -0,0 +1,13 @@ +#ifndef MPQ_LIST_H +#define MPQ_LIST_H + +#include +#include +#include + +#include + +int ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, + bool list_detailed, const std::vector &properties); + +#endif diff --git a/src/mpq/query.cpp b/src/mpq/query.cpp new file mode 100644 index 0000000..d655fac --- /dev/null +++ b/src/mpq/query.cpp @@ -0,0 +1,87 @@ +#include "mpq/query.h" + +#include +#include +#include +#include + +#include + +#include "util/hash.h" +#include "util/locales.h" +#include "util/path.h" + +bool FileExistsInArchiveForLocale(const HANDLE archive, const std::string &file_path, + const LCID locale) { + bool file_exists = false; + SFileSetLocale(locale); + HANDLE file; + if (SFileOpenFileEx(archive, file_path.c_str(), SFILE_OPEN_FROM_MPQ, &file)) { + const auto file_locale = GetFileInfo(file, SFileInfoLocale); + if (file_locale == locale) { + file_exists = true; + } + SFileCloseFile(file); + } + return file_exists; +} +bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file, + std::string &match_reason) { + const DWORD archived_size = SFileGetFileSize(file, nullptr); + std::error_code ec; + const uintmax_t disk_size = fs::file_size(local_file, ec); + if (ec || disk_size != static_cast(archived_size)) { + return false; + } + + const DWORD attr_flags = SFileGetAttributes(archive); + + // Step 1: Timestamp: cheapest check, no local file I/O. + if (attr_flags & MPQ_ATTRIBUTE_FILETIME) { + const uint64_t archived_time = GetFileInfo(file, SFileInfoFileTime); + const uint64_t local_time = LocalFileTimestamp(local_file); + // Compare at second resolution: stat() has only second precision. + if (archived_time != 0 && local_time != 0 && + archived_time / 10000000u == local_time / 10000000u) { + match_reason = "Timestamp matches"; + return true; + } + } + + // Step 2: MD5: if timestamp did not match or was unavailable. + if (attr_flags & MPQ_ATTRIBUTE_MD5) { + uint8_t archived_md5[MD5_DIGEST_SIZE]{}; + if (SFileGetFileInfo(file, SFileInfoMD5, archived_md5, sizeof(archived_md5), nullptr)) { + // An all-zero digest means "no MD5 stored". A file whose + // real MD5 is all zeroes is astronomically unlikely; the + // worst case is a redundant re-add. + const uint8_t zero_md5[MD5_DIGEST_SIZE]{}; + if (std::memcmp(archived_md5, zero_md5, MD5_DIGEST_SIZE) != 0) { + uint8_t local_md5[MD5_DIGEST_SIZE]{}; + if (ComputeFileMd5(local_file, local_md5) && + std::memcmp(local_md5, archived_md5, MD5_DIGEST_SIZE) == 0) { + match_reason = "MD5 matches"; + return true; + } + } + } + } + + // Step 3: CRC32: if neither timestamp nor MD5 matched or was available. + if (attr_flags & MPQ_ATTRIBUTE_CRC32) { + const DWORD archived_crc32 = GetFileInfo(file, SFileInfoCRC32); + // Zero means "no CRC32 stored"; a file whose real CRC32 is + // zero just gets a redundant re-add. + if (archived_crc32 != 0) { + if (auto local_crc32 = ComputeFileCrc32(local_file)) { + if (*local_crc32 == archived_crc32) { + match_reason = "CRC32 matches"; + return true; + } + } + } + } + + // No attributes present, or none matched: re-add the file. + return false; +} diff --git a/src/mpq/query.h b/src/mpq/query.h new file mode 100644 index 0000000..6d25a22 --- /dev/null +++ b/src/mpq/query.h @@ -0,0 +1,30 @@ +#ifndef MPQ_QUERY_H +#define MPQ_QUERY_H + +#include +#include +#include + +#include + +namespace fs = std::filesystem; + +/// Files the archive maintains for itself rather than on a user's behalf +inline const std::vector special_mpq_files = {"(listfile)", "(signature)", + "(attributes)"}; + +bool FileExistsInArchiveForLocale(HANDLE archive, const std::string &file_path, LCID locale); + +/// Reports whether the archived copy of local_file is byte-for-byte current +bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file, + std::string &match_reason); + +template T GetFileInfo(HANDLE file, SFileInfoClass info_class) { + T value{}; + if (!SFileGetFileInfo(file, info_class, &value, sizeof(T), nullptr)) { + return T{}; + } + return value; +} + +#endif diff --git a/src/mpq/read.cpp b/src/mpq/read.cpp new file mode 100644 index 0000000..e790ac1 --- /dev/null +++ b/src/mpq/read.cpp @@ -0,0 +1,46 @@ +#include "mpq/read.h" + +#include +#include + +#include + +#include "mpq/query.h" +#include "util/format.h" +#include "util/locales.h" + +std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, + LCID preferred_locale) { + SFileSetLocale(preferred_locale); + if (!FileExistsInArchiveForLocale(archive, file_name, preferred_locale) && + !FileExistsInArchiveForLocale(archive, file_name, default_locale)) { + std::cerr << "[!] Failed: File doesn't exist" + << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name + << std::endl; + return nullptr; + } + + HANDLE file; + if (!SFileOpenFileEx(archive, file_name, SFILE_OPEN_FROM_MPQ, &file)) { + std::cerr << "[!] Failed: File cannot be opened: " << file_name << std::endl; + return nullptr; + } + + *file_size = SFileGetFileSize(file, nullptr); + if (*file_size == SFILE_INVALID_SIZE) { + std::cerr << "[!] Failed: Invalid file size for: " << file_name << std::endl; + SFileCloseFile(file); + return nullptr; + } + + auto file_content = std::make_unique(*file_size); + DWORD bytes_read; + if (!SFileReadFile(file, file_content.get(), *file_size, &bytes_read, nullptr)) { + std::cerr << "[!] Failed: Cannot read file contents for: " << file_name << std::endl; + SFileCloseFile(file); + return nullptr; + } + + SFileCloseFile(file); + return file_content; +} diff --git a/src/mpq/read.h b/src/mpq/read.h new file mode 100644 index 0000000..1f29900 --- /dev/null +++ b/src/mpq/read.h @@ -0,0 +1,11 @@ +#ifndef MPQ_READ_H +#define MPQ_READ_H + +#include + +#include + +std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, + LCID preferred_locale); + +#endif diff --git a/src/mpq/remove.cpp b/src/mpq/remove.cpp new file mode 100644 index 0000000..4a4f9f3 --- /dev/null +++ b/src/mpq/remove.cpp @@ -0,0 +1,32 @@ +#include "mpq/remove.h" + +#include +#include + +#include + +#include "mpq/query.h" +#include "util/format.h" +#include "util/locales.h" + +int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale) { + SFileSetLocale(locale); + std::cout << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") << ": " + << archive_file_path << std::endl; + + if (!FileExistsInArchiveForLocale(archive, archive_file_path, locale)) { + std::cerr << "[!] Failed: File doesn't exist" + << PrettyPrintLocale(locale, " for locale ", true) << ": " << archive_file_path + << std::endl; + return 1; + } + + if (!SFileRemoveFile(archive, archive_file_path.c_str(), 0)) { + std::cerr << "[!] Failed: File cannot be removed" + << PrettyPrintLocale(locale, " for locale ", true) << ": " << archive_file_path + << std::endl; + return 1; + } + + return 0; +} diff --git a/src/mpq/remove.h b/src/mpq/remove.h new file mode 100644 index 0000000..2109e4c --- /dev/null +++ b/src/mpq/remove.h @@ -0,0 +1,10 @@ +#ifndef MPQ_REMOVE_H +#define MPQ_REMOVE_H + +#include + +#include + +int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale); + +#endif diff --git a/src/mpq/rename.cpp b/src/mpq/rename.cpp new file mode 100644 index 0000000..86b861f --- /dev/null +++ b/src/mpq/rename.cpp @@ -0,0 +1,35 @@ +#include "mpq/rename.h" + +#include +#include + +#include + +#include "mpq/query.h" +#include "util/format.h" +#include "util/locales.h" +#include "util/path.h" + +int RenameFile(HANDLE archive, const std::string &old_archive_file_path, + const std::string &new_archive_file_path, LCID locale) { + SFileSetLocale(locale); + const std::string new_stored_path = WindowsifyFilePath(new_archive_file_path); + std::cout << "[~] Renaming file" << PrettyPrintLocale(locale, " for locale ") << ": " + << old_archive_file_path << " -> " << new_stored_path << std::endl; + + if (!FileExistsInArchiveForLocale(archive, old_archive_file_path, locale)) { + std::cerr << "[!] Failed: File doesn't exist" + << PrettyPrintLocale(locale, " for locale ", true) << ": " + << old_archive_file_path << std::endl; + return 1; + } + + if (!SFileRenameFile(archive, old_archive_file_path.c_str(), new_stored_path.c_str())) { + std::cerr << "[!] Failed: File cannot be renamed" + << PrettyPrintLocale(locale, " for locale ", true) << ": " + << old_archive_file_path << " -> " << new_stored_path << std::endl; + return 1; + } + + return 0; +} diff --git a/src/mpq/rename.h b/src/mpq/rename.h new file mode 100644 index 0000000..c3fb23d --- /dev/null +++ b/src/mpq/rename.h @@ -0,0 +1,11 @@ +#ifndef MPQ_RENAME_H +#define MPQ_RENAME_H + +#include + +#include + +int RenameFile(HANDLE archive, const std::string &old_archive_file_path, + const std::string &new_archive_file_path, LCID locale); + +#endif diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp new file mode 100644 index 0000000..38fd26d --- /dev/null +++ b/src/mpq/verify.cpp @@ -0,0 +1,73 @@ +#include "mpq/verify.h" + +#include +#include +#include +#include +#include + +#include + +#include "mpq/query.h" +#include "mpq/read.h" +#include "util/format.h" +#include "util/locales.h" + +uint32_t VerifyMpqArchive(HANDLE archive) { + return SFileVerifyArchive(archive); +} +int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { + // Determine if we have a strong or weak digital signature + int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); + + std::vector signature_content; + + if (signature_type == SIGNATURE_TYPE_NONE) { + return 1; + } else if (signature_type == SIGNATURE_TYPE_WEAK) { + const char *file_name = "(signature)"; + uint32_t file_size; + auto file_content = ReadFile(archive, file_name, &file_size, default_locale); + + if (!file_content) { + std::cerr << "[!] Failed to read weak signature file." << std::endl; + return -1; + } + signature_content.resize(file_size); + std::copy(file_content.get(), file_content.get() + file_size, signature_content.begin()); + + PrintAsBinary(file_content.get(), file_size); + + } else if (signature_type == SIGNATURE_TYPE_STRONG) { + // StormLib does not expose the strong signature via SFileGetFileInfo into a + // growable buffer (SFileMpqStrongSignature's advertised size always exceeds + // sizeof(std::vector)), so read it directly from the archive file instead. + int64_t archive_size = GetFileInfo(archive, SFileMpqArchiveSize64); + int64_t archive_offset = GetFileInfo(archive, SFileMpqHeaderOffset); + + const fs::path archive_path(target); + std::error_code ec; + const std::uintmax_t file_size = fs::file_size(archive_path, ec); + if (ec) { + std::cerr << "[!] Failed to read archive size: (" << ec.value() << ") " << ec.message() + << ": " << target << std::endl; + return -1; + } + int64_t signature_length = file_size - archive_offset - archive_size; + + if (signature_length <= 0) { + std::cerr << "[!] Invalid signature length: " << signature_length << std::endl; + return -1; + } + + std::ifstream file_mpq(archive_path, std::ios::binary); + file_mpq.seekg(archive_offset + archive_size, std::ios::beg); + signature_content.resize(static_cast(signature_length)); + file_mpq.read(signature_content.data(), signature_content.size()); + file_mpq.close(); + + PrintAsBinary(signature_content.data(), static_cast(signature_content.size())); + } + + return 0; +} diff --git a/src/mpq/verify.h b/src/mpq/verify.h new file mode 100644 index 0000000..c4d4259 --- /dev/null +++ b/src/mpq/verify.h @@ -0,0 +1,12 @@ +#ifndef MPQ_VERIFY_H +#define MPQ_VERIFY_H + +#include +#include + +#include + +uint32_t VerifyMpqArchive(HANDLE archive); +int32_t PrintMpqSignature(HANDLE archive, const std::string &target); + +#endif From f4ccb0eab31be4a1078db4114075ffd8f724affd Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:58:26 +1200 Subject: [PATCH 07/59] Split commands into per-command cli modules --- app/about.cpp | 2 +- app/{subcommands.h => commands.h} | 6 +- app/completion.cpp | 2 +- app/main.cpp | 2 +- app/version.cpp | 2 +- src/CMakeLists.txt | 11 +- src/cli/add.cpp | 103 +++++++ src/{ => cli}/commands.h | 6 +- src/cli/compact.cpp | 20 ++ src/cli/create.cpp | 135 ++++++++++ src/cli/extract.cpp | 78 ++++++ src/cli/info.cpp | 19 ++ src/cli/list.cpp | 21 ++ src/cli/read.cpp | 39 +++ src/cli/remove.cpp | 35 +++ src/cli/rename.cpp | 23 ++ src/cli/verify.cpp | 54 ++++ src/commands.cpp | 428 ------------------------------ 18 files changed, 547 insertions(+), 439 deletions(-) rename app/{subcommands.h => commands.h} (69%) create mode 100644 src/cli/add.cpp rename src/{ => cli}/commands.h (96%) create mode 100644 src/cli/compact.cpp create mode 100644 src/cli/create.cpp create mode 100644 src/cli/extract.cpp create mode 100644 src/cli/info.cpp create mode 100644 src/cli/list.cpp create mode 100644 src/cli/read.cpp create mode 100644 src/cli/remove.cpp create mode 100644 src/cli/rename.cpp create mode 100644 src/cli/verify.cpp delete mode 100644 src/commands.cpp diff --git a/app/about.cpp b/app/about.cpp index a8105f1..29db4db 100644 --- a/app/about.cpp +++ b/app/about.cpp @@ -1,7 +1,7 @@ #include #include -#include "subcommands.h" +#include "commands.h" int HandleAbout() { std::cout << "Name: mpqcli" << std::endl; diff --git a/app/subcommands.h b/app/commands.h similarity index 69% rename from app/subcommands.h rename to app/commands.h index 54b4bad..d785960 100644 --- a/app/subcommands.h +++ b/app/commands.h @@ -1,5 +1,5 @@ -#ifndef SUBCOMMANDS_H -#define SUBCOMMANDS_H +#ifndef COMMANDS_H +#define COMMANDS_H int HandleVersion(); int HandleAbout(); @@ -9,4 +9,4 @@ void HandleCompletionZsh(); void HandleCompletionPs(); void HandleCompletionFish(); -#endif // SUBCOMMANDS_H +#endif // COMMANDS_H diff --git a/app/completion.cpp b/app/completion.cpp index bac9951..580d87e 100644 --- a/app/completion.cpp +++ b/app/completion.cpp @@ -1,7 +1,7 @@ #include #include -#include "subcommands.h" +#include "commands.h" void HandleCompletionBash() { std::cout << BashCompletionScript; diff --git a/app/main.cpp b/app/main.cpp index 47c82c7..f0ec605 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -6,9 +6,9 @@ #include +#include "cli/commands.h" #include "commands.h" #include "gamerules.h" -#include "subcommands.h" #include "validators.h" int main(int argc, char **argv) { diff --git a/app/version.cpp b/app/version.cpp index 17be46f..ef78a0e 100644 --- a/app/version.cpp +++ b/app/version.cpp @@ -1,7 +1,7 @@ #include #include -#include "subcommands.h" +#include "commands.h" int HandleVersion() { std::cout << MPQCLI_VERSION << "-" << GIT_COMMIT_HASH << std::endl; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9525a5e..f572b3e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,7 +3,16 @@ # that recompiles the sources instead is a second build that drifts from the # shipped one. add_library(mpqcli_core STATIC - commands.cpp + cli/add.cpp + cli/compact.cpp + cli/create.cpp + cli/extract.cpp + cli/info.cpp + cli/list.cpp + cli/read.cpp + cli/remove.cpp + cli/rename.cpp + cli/verify.cpp mpq/add.cpp mpq/archive.cpp mpq/compact.cpp diff --git a/src/cli/add.cpp b/src/cli/add.cpp new file mode 100644 index 0000000..538671f --- /dev/null +++ b/src/cli/add.cpp @@ -0,0 +1,103 @@ +#include "mpq/add.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "cli/commands.h" +#include "gamerules.h" +#include "mpq/archive.h" +#include "util/locales.h" +#include "util/path.h" + +namespace fs = std::filesystem; + +int HandleAdd(const std::vector &files, const std::string &target, + const std::optional &path, bool overwrite, bool update, + const std::optional &locale, + const std::optional &game_profile, int64_t file_flags, + int64_t file_compression, int64_t file_compression_next) { + HANDLE archive; + if (!OpenMpqArchive(target, &archive, 0)) { + return 1; + } + + LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; + + GameProfile profile; + if (game_profile.has_value()) { + profile = GameRules::StringToProfile(game_profile.value()); + std::cout << "[*] Using game profile: " << game_profile.value() << std::endl; + } else { + profile = GameRules::GetDefaultProfile(); + } + GameRules game_rules(profile); + + CompressionSettingsOverrides add_overrides; + if (file_flags >= 0) + add_overrides.flags = static_cast(file_flags); + if (file_compression >= 0) + add_overrides.compression = static_cast(file_compression); + if (file_compression_next >= 0) + add_overrides.compression_next = static_cast(file_compression_next); + + std::error_code ec; + bool has_directory = false; + for (const auto &f : files) { + if (fs::is_directory(f, ec)) { + has_directory = true; + break; + } + } + + int result = 0; + int files_skipped = 0; + for (const auto &f : files) { + if (!fs::exists(f, ec)) { + std::cerr << "[!] Path does not exist: " << f << std::endl; + result |= 1; + continue; + } + + if (fs::is_directory(f, ec)) { + std::vector directory_files = ListFilesRecursive(f, ec); + if (ec) { + std::cerr << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() + << ": " << f << std::endl; + result |= 1; + continue; + } + std::string prefix = path.value_or(""); + result |= AddFiles(archive, directory_files, f, prefix, lcid, game_rules, add_overrides, + overwrite, update, &files_skipped); + + } else if (fs::is_regular_file(f, ec)) { + const bool treat_as_directory = has_directory || files.size() > 1; + std::string archive_path = ResolveArchiveName(f, path, treat_as_directory); + result |= AddFile(archive, f, archive_path, lcid, game_rules, add_overrides, overwrite, + update, &files_skipped); + + } else { + std::cerr << "[!] Not a file or directory: " << f << std::endl; + result |= 1; + } + } + + // Skipping pre-existing files is the default, so point at the flags that change it + // rather than letting the run look like it silently did nothing. + if (!overwrite && !update && files_skipped > 0) { + std::cerr << "[*] " << files_skipped + << " file(s) already in the archive were skipped. Use --overwrite to replace " + "them, or --update to replace only the ones that changed." + << std::endl; + } + + CloseMpqArchive(archive); + return result; +} diff --git a/src/commands.h b/src/cli/commands.h similarity index 96% rename from src/commands.h rename to src/cli/commands.h index 6bb1d2c..5c1f376 100644 --- a/src/commands.h +++ b/src/cli/commands.h @@ -1,5 +1,5 @@ -#ifndef COMMANDS_H -#define COMMANDS_H +#ifndef CLI_COMMANDS_H +#define CLI_COMMANDS_H #include #include @@ -34,4 +34,4 @@ int HandleRead(const std::string &file, const std::string &target, int HandleVerify(const std::string &target, bool print_signature); int HandleCompact(const std::string &target, const std::optional &listfile_name); -#endif // COMMANDS_H +#endif // CLI_COMMANDS_H diff --git a/src/cli/compact.cpp b/src/cli/compact.cpp new file mode 100644 index 0000000..c30f46e --- /dev/null +++ b/src/cli/compact.cpp @@ -0,0 +1,20 @@ +#include "mpq/compact.h" + +#include +#include + +#include + +#include "cli/commands.h" +#include "mpq/archive.h" + +int HandleCompact(const std::string &target, const std::optional &listfile_name) { + HANDLE archive; + if (!OpenMpqArchive(target, &archive, 0)) { + return 1; + } + + const int result = CompactMpqArchive(archive, listfile_name); + CloseMpqArchive(archive); + return result; +} diff --git a/src/cli/create.cpp b/src/cli/create.cpp new file mode 100644 index 0000000..e30fb07 --- /dev/null +++ b/src/cli/create.cpp @@ -0,0 +1,135 @@ +#include "mpq/create.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "cli/commands.h" +#include "gamerules.h" +#include "mpq/add.h" +#include "mpq/archive.h" +#include "util/capacity.h" +#include "util/locales.h" +#include "util/path.h" + +namespace fs = std::filesystem; + +int HandleCreate(const std::string &target, const std::optional &path, + const std::optional &output, bool sign_archive, + const std::optional &locale, + const std::optional &game_profile, int32_t mpq_version, + int64_t stream_flags, int64_t sector_size, int64_t raw_chunk_size, + int64_t file_flags1, int64_t file_flags2, int64_t file_flags3, int64_t attr_flags, + int64_t file_flags, int64_t file_compression, int64_t file_compression_next) { + std::error_code ec; + fs::path output_file_path; + if (output.has_value()) { + output_file_path = fs::absolute(output.value(), ec); + if (ec) { + std::cerr << "[!] Failed to resolve output path: (" << ec.value() << ") " + << ec.message() << ": " << output.value() << std::endl; + return 1; + } + } else { + output_file_path = fs::path(target); + // If the path ends with a separator (e.g. "dir/"), strip the + // trailing separator first so we get "dir.mpq" + if (output_file_path.filename().empty()) { + output_file_path = output_file_path.parent_path(); + } + output_file_path.replace_extension(".mpq"); + } + std::string output_file = output_file_path.u8string(); + + GameProfile profile; + if (game_profile.has_value()) { + profile = GameRules::StringToProfile(game_profile.value()); + } else { + profile = GameRules::GetDefaultProfile(); + } + GameRules game_rules(profile); + + std::cout << "[*] Game profile: " << game_profile.value_or("default") + << ", Output file: " << output_file << std::endl; + + if (mpq_version > 0) { + mpq_version--; // We label versions 1-4, but StormLib uses 0-3 + } + + // Apply MpqCreateSettings overrides if provided + MpqCreateSettingsOverrides overrides; + if (mpq_version >= 0) + overrides.mpq_version = static_cast(mpq_version); + if (stream_flags >= 0) + overrides.stream_flags = static_cast(stream_flags); + if (file_flags1 >= 0) + overrides.file_flags1 = static_cast(file_flags1); + if (file_flags2 >= 0) + overrides.file_flags2 = static_cast(file_flags2); + if (file_flags3 >= 0) + overrides.file_flags3 = static_cast(file_flags3); + if (attr_flags >= 0) + overrides.attr_flags = static_cast(attr_flags); + if (sector_size >= 0) + overrides.sector_size = static_cast(sector_size); + if (raw_chunk_size >= 0) + overrides.raw_chunk_size = static_cast(raw_chunk_size); + game_rules.OverrideCreateSettings(overrides); + + // List the files up front: the archive's max file count is fixed at creation + std::vector files; + const bool is_directory = fs::is_directory(target, ec); + if (is_directory) { + files = ListFilesRecursive(target, ec); + if (ec) { + std::cerr << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() + << ": " << target << std::endl; + return 1; + } + } else if (!fs::is_regular_file(target, ec)) { + std::cerr << "[!] Not a file or directory: " << target << std::endl; + return 1; + } + const uint32_t file_count = + CalculateMpqMaxFileValue(is_directory ? static_cast(files.size()) : 1); + + // Create the MPQ archive and add files + int result = 0; + HANDLE archive = CreateMpqArchive(output_file, file_count, game_rules); + if (archive) { + LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; + + // Apply AddFileSettings overrides if provided + CompressionSettingsOverrides add_overrides; + if (file_flags >= 0) + add_overrides.flags = static_cast(file_flags); + if (file_compression >= 0) + add_overrides.compression = static_cast(file_compression); + if (file_compression_next >= 0) + add_overrides.compression_next = static_cast(file_compression_next); + + if (is_directory) { + const std::string prefix = path.value_or(""); + result |= AddFiles(archive, files, target, prefix, lcid, game_rules, add_overrides); + } else { + std::string archive_path = ResolveArchiveName(target, path); + result |= AddFile(archive, target, archive_path, lcid, game_rules, add_overrides); + } + + if (sign_archive) { + SignMpqArchive(archive); + } + CloseMpqArchive(archive); + } else { + std::cerr << "[!] Failed to create MPQ archive." << std::endl; + return 1; + } + + return result; +} diff --git a/src/cli/extract.cpp b/src/cli/extract.cpp new file mode 100644 index 0000000..0195cec --- /dev/null +++ b/src/cli/extract.cpp @@ -0,0 +1,78 @@ +#include "mpq/extract.h" + +#include +#include +#include +#include +#include + +#include + +#include "cli/commands.h" +#include "mpq/archive.h" +#include "util/locales.h" + +namespace fs = std::filesystem; + +int HandleExtract(const std::string &target, const std::optional &output, + const std::optional &file, bool keep_folder_structure, + const std::optional &listfile_name, + const std::optional &locale) { + // If no output directory specified, use MPQ path without extension + // If output directory specified, create it if it doesn't exist + std::error_code ec; + std::string effective_output; + if (!output.has_value()) { + fs::path target_path = fs::absolute(target, ec); + if (ec) { + std::cerr << "[!] Failed to resolve archive path: (" << ec.value() << ") " + << ec.message() << ": " << target << std::endl; + return 1; + } + effective_output = (target_path.parent_path() / target_path.stem()).u8string(); + } else { + effective_output = output.value(); + } + fs::create_directory(effective_output, ec); + if (ec) { + std::error_code query_ec; + if (!fs::is_directory(effective_output, query_ec)) { + std::cerr << "[!] Failed to create output directory: (" << ec.value() << ") " + << ec.message() << ": " << effective_output << std::endl; + return 1; + } + } + + // ExtractFile can only check for symlink traversal where the OS can resolve + // real paths; warn once up front on volumes where it cannot (RAM disks) + static_cast(fs::canonical(effective_output, ec)); + if (ec) { + std::cout << "[!] Warning: Output directory cannot be fully resolved, symlinks will not " + "be checked during extraction: " + << effective_output << std::endl; + } + + HANDLE archive; + if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { + return 1; + } + + LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; + if (locale.has_value() && lcid == default_locale) { + std::cout << "[!] Warning: The locale '" << locale.value() + << "' is unknown. Will use default locale instead." << std::endl; + } + + int result; + if (file.has_value()) { + result = ExtractFile(archive, effective_output, file.value(), keep_folder_structure, lcid); + } else { + result = ExtractFiles(archive, effective_output, listfile_name, lcid); + } + CloseMpqArchive(archive); + + if (result != 0) { + std::cerr << std::endl << "[!] Failed to extract all files." << std::endl; + } + return result; +} diff --git a/src/cli/info.cpp b/src/cli/info.cpp new file mode 100644 index 0000000..10cae83 --- /dev/null +++ b/src/cli/info.cpp @@ -0,0 +1,19 @@ +#include "mpq/info.h" + +#include +#include + +#include + +#include "cli/commands.h" +#include "mpq/archive.h" + +int HandleInfo(const std::string &target, const std::optional &property) { + HANDLE archive; + if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { + return 1; + } + PrintMpqInfo(archive, property); + CloseMpqArchive(archive); + return 0; +} diff --git a/src/cli/list.cpp b/src/cli/list.cpp new file mode 100644 index 0000000..71db165 --- /dev/null +++ b/src/cli/list.cpp @@ -0,0 +1,21 @@ +#include "mpq/list.h" + +#include +#include +#include + +#include + +#include "cli/commands.h" +#include "mpq/archive.h" + +int HandleList(const std::string &target, const std::optional &listfile_name, + bool list_all, bool list_detailed, const std::vector &properties) { + HANDLE archive; + if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { + return 1; + } + ListFiles(archive, listfile_name, list_all, list_detailed, properties); + CloseMpqArchive(archive); + return 0; +} diff --git a/src/cli/read.cpp b/src/cli/read.cpp new file mode 100644 index 0000000..cc27aec --- /dev/null +++ b/src/cli/read.cpp @@ -0,0 +1,39 @@ +#include "mpq/read.h" + +#include +#include +#include +#include + +#include + +#include "cli/commands.h" +#include "mpq/archive.h" +#include "util/format.h" +#include "util/locales.h" + +int HandleRead(const std::string &file, const std::string &target, + const std::optional &locale) { + HANDLE archive; + if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { + return 1; + } + + LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; + if (locale.has_value() && lcid == default_locale) { + std::cout << "[!] Warning: The locale '" << locale.value() + << "' is unknown. Will use default locale instead." << std::endl; + } + + uint32_t file_size; + auto file_content = ReadFile(archive, file.c_str(), &file_size, lcid); + if (!file_content) { + CloseMpqArchive(archive); + return 1; + } + + PrintAsBinary(file_content.get(), file_size); + + CloseMpqArchive(archive); + return 0; +} diff --git a/src/cli/remove.cpp b/src/cli/remove.cpp new file mode 100644 index 0000000..137af26 --- /dev/null +++ b/src/cli/remove.cpp @@ -0,0 +1,35 @@ +#include "mpq/remove.h" + +#include +#include +#include +#include + +#include + +#include "cli/commands.h" +#include "mpq/archive.h" +#include "util/locales.h" + +int HandleRemove(const std::vector &files, const std::string &target, + const std::optional &locale) { + HANDLE archive; + if (!OpenMpqArchive(target, &archive, 0)) { + return 1; + } + + LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; + std::unordered_set seen; + int overall_result = 0; + for (const auto &f : files) { + if (!seen.insert(f).second) { + continue; + } + int result = RemoveFile(archive, f, lcid); + if (result != 0) { + overall_result = result; + } + } + CloseMpqArchive(archive); + return overall_result; +} diff --git a/src/cli/rename.cpp b/src/cli/rename.cpp new file mode 100644 index 0000000..90b3bb9 --- /dev/null +++ b/src/cli/rename.cpp @@ -0,0 +1,23 @@ +#include "mpq/rename.h" + +#include +#include + +#include + +#include "cli/commands.h" +#include "mpq/archive.h" +#include "util/locales.h" + +int HandleRename(const std::string &old_file, const std::string &new_file, + const std::string &target, const std::optional &locale) { + HANDLE archive; + if (!OpenMpqArchive(target, &archive, 0)) { + return 1; + } + + LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; + int result = RenameFile(archive, old_file, new_file, lcid); + CloseMpqArchive(archive); + return result; +} diff --git a/src/cli/verify.cpp b/src/cli/verify.cpp new file mode 100644 index 0000000..b51bf76 --- /dev/null +++ b/src/cli/verify.cpp @@ -0,0 +1,54 @@ +#include "mpq/verify.h" + +#include +#include + +#include + +#include "cli/commands.h" +#include "mpq/archive.h" + +int HandleVerify(const std::string &target, bool print_signature) { + HANDLE archive; + if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { + return 1; + } + + int result; + switch (VerifyMpqArchive(archive)) { + case ERROR_WEAK_SIGNATURE_OK: + case ERROR_STRONG_SIGNATURE_OK: + if (print_signature) { + // If printing the signature, don't print success message + // because the user might want to pipe/redirect the signature data + PrintMpqSignature(archive, target); + } else { + std::cout << "[*] Verify success" << std::endl; + } + result = 0; + break; + + case ERROR_WEAK_SIGNATURE_ERROR: + case ERROR_STRONG_SIGNATURE_ERROR: + if (print_signature) { + // Print the (invalid) signature bytes for forensic inspection, + // but still fail: the archive content no longer matches it + PrintMpqSignature(archive, target); + } + std::cerr << "[!] Verify failed: signature is present but invalid" << std::endl; + result = 1; + break; + + case ERROR_NO_SIGNATURE: + std::cerr << "[!] Verify failed: archive has no signature" << std::endl; + result = 1; + break; + + default: // ERROR_VERIFY_FAILED or any other value + std::cerr << "[!] Verify failed" << std::endl; + result = 1; + break; + } + CloseMpqArchive(archive); + return result; +} diff --git a/src/commands.cpp b/src/commands.cpp deleted file mode 100644 index 1672f83..0000000 --- a/src/commands.cpp +++ /dev/null @@ -1,428 +0,0 @@ -#include "commands.h" - -#include -#include -#include -#include -#include - -#include - -#include "gamerules.h" -#include "mpq/add.h" -#include "mpq/archive.h" -#include "mpq/compact.h" -#include "mpq/create.h" -#include "mpq/extract.h" -#include "mpq/info.h" -#include "mpq/list.h" -#include "mpq/read.h" -#include "mpq/remove.h" -#include "mpq/rename.h" -#include "mpq/verify.h" -#include "util/capacity.h" -#include "util/format.h" -#include "util/locales.h" -#include "util/path.h" - -namespace fs = std::filesystem; - -int HandleInfo(const std::string &target, const std::optional &property) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - PrintMpqInfo(archive, property); - CloseMpqArchive(archive); - return 0; -} - -int HandleCreate(const std::string &target, const std::optional &path, - const std::optional &output, bool sign_archive, - const std::optional &locale, - const std::optional &game_profile, int32_t mpq_version, - int64_t stream_flags, int64_t sector_size, int64_t raw_chunk_size, - int64_t file_flags1, int64_t file_flags2, int64_t file_flags3, int64_t attr_flags, - int64_t file_flags, int64_t file_compression, int64_t file_compression_next) { - std::error_code ec; - fs::path output_file_path; - if (output.has_value()) { - output_file_path = fs::absolute(output.value(), ec); - if (ec) { - std::cerr << "[!] Failed to resolve output path: (" << ec.value() << ") " - << ec.message() << ": " << output.value() << std::endl; - return 1; - } - } else { - output_file_path = fs::path(target); - // If the path ends with a separator (e.g. "dir/"), strip the - // trailing separator first so we get "dir.mpq" - if (output_file_path.filename().empty()) { - output_file_path = output_file_path.parent_path(); - } - output_file_path.replace_extension(".mpq"); - } - std::string output_file = output_file_path.u8string(); - - GameProfile profile; - if (game_profile.has_value()) { - profile = GameRules::StringToProfile(game_profile.value()); - } else { - profile = GameRules::GetDefaultProfile(); - } - GameRules game_rules(profile); - - std::cout << "[*] Game profile: " << game_profile.value_or("default") - << ", Output file: " << output_file << std::endl; - - if (mpq_version > 0) { - mpq_version--; // We label versions 1-4, but StormLib uses 0-3 - } - - // Apply MpqCreateSettings overrides if provided - MpqCreateSettingsOverrides overrides; - if (mpq_version >= 0) - overrides.mpq_version = static_cast(mpq_version); - if (stream_flags >= 0) - overrides.stream_flags = static_cast(stream_flags); - if (file_flags1 >= 0) - overrides.file_flags1 = static_cast(file_flags1); - if (file_flags2 >= 0) - overrides.file_flags2 = static_cast(file_flags2); - if (file_flags3 >= 0) - overrides.file_flags3 = static_cast(file_flags3); - if (attr_flags >= 0) - overrides.attr_flags = static_cast(attr_flags); - if (sector_size >= 0) - overrides.sector_size = static_cast(sector_size); - if (raw_chunk_size >= 0) - overrides.raw_chunk_size = static_cast(raw_chunk_size); - game_rules.OverrideCreateSettings(overrides); - - // List the files up front: the archive's max file count is fixed at creation - std::vector files; - const bool is_directory = fs::is_directory(target, ec); - if (is_directory) { - files = ListFilesRecursive(target, ec); - if (ec) { - std::cerr << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() - << ": " << target << std::endl; - return 1; - } - } else if (!fs::is_regular_file(target, ec)) { - std::cerr << "[!] Not a file or directory: " << target << std::endl; - return 1; - } - const uint32_t file_count = - CalculateMpqMaxFileValue(is_directory ? static_cast(files.size()) : 1); - - // Create the MPQ archive and add files - int result = 0; - HANDLE archive = CreateMpqArchive(output_file, file_count, game_rules); - if (archive) { - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - - // Apply AddFileSettings overrides if provided - CompressionSettingsOverrides add_overrides; - if (file_flags >= 0) - add_overrides.flags = static_cast(file_flags); - if (file_compression >= 0) - add_overrides.compression = static_cast(file_compression); - if (file_compression_next >= 0) - add_overrides.compression_next = static_cast(file_compression_next); - - if (is_directory) { - const std::string prefix = path.value_or(""); - result |= AddFiles(archive, files, target, prefix, lcid, game_rules, add_overrides); - } else { - std::string archive_path = ResolveArchiveName(target, path); - result |= AddFile(archive, target, archive_path, lcid, game_rules, add_overrides); - } - - if (sign_archive) { - SignMpqArchive(archive); - } - CloseMpqArchive(archive); - } else { - std::cerr << "[!] Failed to create MPQ archive." << std::endl; - return 1; - } - - return result; -} - -int HandleAdd(const std::vector &files, const std::string &target, - const std::optional &path, bool overwrite, bool update, - const std::optional &locale, - const std::optional &game_profile, int64_t file_flags, - int64_t file_compression, int64_t file_compression_next) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - - GameProfile profile; - if (game_profile.has_value()) { - profile = GameRules::StringToProfile(game_profile.value()); - std::cout << "[*] Using game profile: " << game_profile.value() << std::endl; - } else { - profile = GameRules::GetDefaultProfile(); - } - GameRules game_rules(profile); - - CompressionSettingsOverrides add_overrides; - if (file_flags >= 0) - add_overrides.flags = static_cast(file_flags); - if (file_compression >= 0) - add_overrides.compression = static_cast(file_compression); - if (file_compression_next >= 0) - add_overrides.compression_next = static_cast(file_compression_next); - - std::error_code ec; - bool has_directory = false; - for (const auto &f : files) { - if (fs::is_directory(f, ec)) { - has_directory = true; - break; - } - } - - int result = 0; - int files_skipped = 0; - for (const auto &f : files) { - if (!fs::exists(f, ec)) { - std::cerr << "[!] Path does not exist: " << f << std::endl; - result |= 1; - continue; - } - - if (fs::is_directory(f, ec)) { - std::vector directory_files = ListFilesRecursive(f, ec); - if (ec) { - std::cerr << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() - << ": " << f << std::endl; - result |= 1; - continue; - } - std::string prefix = path.value_or(""); - result |= AddFiles(archive, directory_files, f, prefix, lcid, game_rules, add_overrides, - overwrite, update, &files_skipped); - - } else if (fs::is_regular_file(f, ec)) { - const bool treat_as_directory = has_directory || files.size() > 1; - std::string archive_path = ResolveArchiveName(f, path, treat_as_directory); - result |= AddFile(archive, f, archive_path, lcid, game_rules, add_overrides, overwrite, - update, &files_skipped); - - } else { - std::cerr << "[!] Not a file or directory: " << f << std::endl; - result |= 1; - } - } - - // Skipping pre-existing files is the default, so point at the flags that change it - // rather than letting the run look like it silently did nothing. - if (!overwrite && !update && files_skipped > 0) { - std::cerr << "[*] " << files_skipped - << " file(s) already in the archive were skipped. Use --overwrite to replace " - "them, or --update to replace only the ones that changed." - << std::endl; - } - - CloseMpqArchive(archive); - return result; -} - -int HandleRemove(const std::vector &files, const std::string &target, - const std::optional &locale) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - std::unordered_set seen; - int overall_result = 0; - for (const auto &f : files) { - if (!seen.insert(f).second) { - continue; - } - int result = RemoveFile(archive, f, lcid); - if (result != 0) { - overall_result = result; - } - } - CloseMpqArchive(archive); - return overall_result; -} - -int HandleRename(const std::string &old_file, const std::string &new_file, - const std::string &target, const std::optional &locale) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - int result = RenameFile(archive, old_file, new_file, lcid); - CloseMpqArchive(archive); - return result; -} - -int HandleList(const std::string &target, const std::optional &listfile_name, - bool list_all, bool list_detailed, const std::vector &properties) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - ListFiles(archive, listfile_name, list_all, list_detailed, properties); - CloseMpqArchive(archive); - return 0; -} - -int HandleExtract(const std::string &target, const std::optional &output, - const std::optional &file, bool keep_folder_structure, - const std::optional &listfile_name, - const std::optional &locale) { - // If no output directory specified, use MPQ path without extension - // If output directory specified, create it if it doesn't exist - std::error_code ec; - std::string effective_output; - if (!output.has_value()) { - fs::path target_path = fs::absolute(target, ec); - if (ec) { - std::cerr << "[!] Failed to resolve archive path: (" << ec.value() << ") " - << ec.message() << ": " << target << std::endl; - return 1; - } - effective_output = (target_path.parent_path() / target_path.stem()).u8string(); - } else { - effective_output = output.value(); - } - fs::create_directory(effective_output, ec); - if (ec) { - std::error_code query_ec; - if (!fs::is_directory(effective_output, query_ec)) { - std::cerr << "[!] Failed to create output directory: (" << ec.value() << ") " - << ec.message() << ": " << effective_output << std::endl; - return 1; - } - } - - // ExtractFile can only check for symlink traversal where the OS can resolve - // real paths; warn once up front on volumes where it cannot (RAM disks) - static_cast(fs::canonical(effective_output, ec)); - if (ec) { - std::cout << "[!] Warning: Output directory cannot be fully resolved, symlinks will not " - "be checked during extraction: " - << effective_output << std::endl; - } - - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - if (locale.has_value() && lcid == default_locale) { - std::cout << "[!] Warning: The locale '" << locale.value() - << "' is unknown. Will use default locale instead." << std::endl; - } - - int result; - if (file.has_value()) { - result = ExtractFile(archive, effective_output, file.value(), keep_folder_structure, lcid); - } else { - result = ExtractFiles(archive, effective_output, listfile_name, lcid); - } - CloseMpqArchive(archive); - - if (result != 0) { - std::cerr << std::endl << "[!] Failed to extract all files." << std::endl; - } - return result; -} - -int HandleRead(const std::string &file, const std::string &target, - const std::optional &locale) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - if (locale.has_value() && lcid == default_locale) { - std::cout << "[!] Warning: The locale '" << locale.value() - << "' is unknown. Will use default locale instead." << std::endl; - } - - uint32_t file_size; - auto file_content = ReadFile(archive, file.c_str(), &file_size, lcid); - if (!file_content) { - CloseMpqArchive(archive); - return 1; - } - - PrintAsBinary(file_content.get(), file_size); - - CloseMpqArchive(archive); - return 0; -} - -int HandleVerify(const std::string &target, bool print_signature) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - - int result; - switch (VerifyMpqArchive(archive)) { - case ERROR_WEAK_SIGNATURE_OK: - case ERROR_STRONG_SIGNATURE_OK: - if (print_signature) { - // If printing the signature, don't print success message - // because the user might want to pipe/redirect the signature data - PrintMpqSignature(archive, target); - } else { - std::cout << "[*] Verify success" << std::endl; - } - result = 0; - break; - - case ERROR_WEAK_SIGNATURE_ERROR: - case ERROR_STRONG_SIGNATURE_ERROR: - if (print_signature) { - // Print the (invalid) signature bytes for forensic inspection, - // but still fail: the archive content no longer matches it - PrintMpqSignature(archive, target); - } - std::cerr << "[!] Verify failed: signature is present but invalid" << std::endl; - result = 1; - break; - - case ERROR_NO_SIGNATURE: - std::cerr << "[!] Verify failed: archive has no signature" << std::endl; - result = 1; - break; - - default: // ERROR_VERIFY_FAILED or any other value - std::cerr << "[!] Verify failed" << std::endl; - result = 1; - break; - } - CloseMpqArchive(archive); - return result; -} - -int HandleCompact(const std::string &target, const std::optional &listfile_name) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } - - const int result = CompactMpqArchive(archive, listfile_name); - CloseMpqArchive(archive); - return result; -} From 514f1e4ca5b779d2154c8b6f61be3b9199493aef Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:58:36 +1200 Subject: [PATCH 08/59] Split gamerules into rules, profiles and profile data --- app/main.cpp | 2 +- app/validators.cpp | 2 +- src/CMakeLists.txt | 5 +- src/cli/add.cpp | 2 +- src/cli/create.cpp | 2 +- .../profile_data.cpp} | 298 +----------------- src/gamerules/profiles.cpp | 129 ++++++++ src/gamerules/profiles.h | 22 ++ src/gamerules/rules.cpp | 167 ++++++++++ src/gamerules/rules.h | 67 ++++ src/{gamerules.h => gamerules/settings.h} | 80 +---- src/mpq/add.cpp | 2 +- src/mpq/add.h | 3 +- src/mpq/create.cpp | 2 +- src/mpq/create.h | 2 +- src/util/string.cpp | 12 + src/util/string.h | 9 + 17 files changed, 423 insertions(+), 383 deletions(-) rename src/{gamerules.cpp => gamerules/profile_data.cpp} (52%) create mode 100644 src/gamerules/profiles.cpp create mode 100644 src/gamerules/profiles.h create mode 100644 src/gamerules/rules.cpp create mode 100644 src/gamerules/rules.h rename src/{gamerules.h => gamerules/settings.h} (52%) create mode 100644 src/util/string.cpp create mode 100644 src/util/string.h diff --git a/app/main.cpp b/app/main.cpp index f0ec605..cc2cf62 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -8,7 +8,7 @@ #include "cli/commands.h" #include "commands.h" -#include "gamerules.h" +#include "gamerules/rules.h" #include "validators.h" int main(int argc, char **argv) { diff --git a/app/validators.cpp b/app/validators.cpp index 75566d2..e48298e 100644 --- a/app/validators.cpp +++ b/app/validators.cpp @@ -2,7 +2,7 @@ #include -#include "gamerules.h" +#include "gamerules/rules.h" #include "util/locales.h" // Accepts every profile name but lists only the canonical ones on failure diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f572b3e..520e6b7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -26,12 +26,15 @@ add_library(mpqcli_core STATIC mpq/remove.cpp mpq/rename.cpp mpq/verify.cpp - gamerules.cpp + gamerules/profile_data.cpp + gamerules/profiles.cpp + gamerules/rules.cpp util/capacity.cpp util/format.cpp util/hash.cpp util/locales.cpp util/path.cpp + util/string.cpp ) add_dependencies(mpqcli_core storm) diff --git a/src/cli/add.cpp b/src/cli/add.cpp index 538671f..0cfa220 100644 --- a/src/cli/add.cpp +++ b/src/cli/add.cpp @@ -11,7 +11,7 @@ #include #include "cli/commands.h" -#include "gamerules.h" +#include "gamerules/rules.h" #include "mpq/archive.h" #include "util/locales.h" #include "util/path.h" diff --git a/src/cli/create.cpp b/src/cli/create.cpp index e30fb07..a7b0271 100644 --- a/src/cli/create.cpp +++ b/src/cli/create.cpp @@ -11,7 +11,7 @@ #include #include "cli/commands.h" -#include "gamerules.h" +#include "gamerules/rules.h" #include "mpq/add.h" #include "mpq/archive.h" #include "util/capacity.h" diff --git a/src/gamerules.cpp b/src/gamerules/profile_data.cpp similarity index 52% rename from src/gamerules.cpp rename to src/gamerules/profile_data.cpp index 0649c85..7a84134 100644 --- a/src/gamerules.cpp +++ b/src/gamerules/profile_data.cpp @@ -1,300 +1,4 @@ -#include "gamerules.h" - -#include -#include -#include - -// Constructor -GameRules::GameRules(GameProfile game_profile) : profile_(game_profile) { - InitializeRules(); -} - -// Helper function to convert string to lowercase -static std::string ToLower(const std::string &str) { - std::string result = str; - std::transform(result.begin(), result.end(), result.begin(), - [](unsigned char c) { return std::tolower(c); }); - return result; -} - -// Helper function to match wildcards (* and ?) -bool GameRules::MatchFileMask(const std::string &filename, const std::string &mask) { - // Convert both to lowercase for case-insensitive matching - std::string lower_filename = ToLower(filename); - std::string lower_mask = ToLower(mask); - - // Replace backslashes with forward slashes for consistent path handling - std::replace(lower_filename.begin(), lower_filename.end(), '\\', '/'); - std::replace(lower_mask.begin(), lower_mask.end(), '\\', '/'); - - // Simple wildcard matching - size_t mask_pos = 0; - size_t file_pos = 0; - size_t star_pos = std::string::npos; - size_t match_pos = 0; - - while (file_pos < lower_filename.length()) { - if (mask_pos < lower_mask.length() && - (lower_mask[mask_pos] == '?' || lower_mask[mask_pos] == lower_filename[file_pos])) { - mask_pos++; - file_pos++; - } else if (mask_pos < lower_mask.length() && lower_mask[mask_pos] == '*') { - star_pos = mask_pos; - match_pos = file_pos; - mask_pos++; - } else if (star_pos != std::string::npos) { - mask_pos = star_pos + 1; - match_pos++; - file_pos = match_pos; - } else { - return false; - } - } - - while (mask_pos < lower_mask.length() && lower_mask[mask_pos] == '*') { - mask_pos++; - } - - return mask_pos == lower_mask.length(); -} - -void GameRules::AddRuleByFileMask(const std::string &file_mask, DWORD mpq_flags, - DWORD compression_first, DWORD compression_next) { - rules_.emplace_back(file_mask, mpq_flags, compression_first, compression_next); -} - -// Use UINT32_MAX for sizeMax to indicate "no upper limit" -// Examples: -// AddRuleByFileSize(0, 0, ...) - Match files with exactly 0 bytes -// AddRuleByFileSize(0, 0x4000, ...) - Match files from 0 to 16KB -// AddRuleByFileSize(0x4000, UINT32_MAX, ...) - Match files from 16KB onwards -void GameRules::AddRuleByFileSize(DWORD size_min, DWORD size_max, DWORD mpq_flags, - DWORD compression_first, DWORD compression_next) { - rules_.emplace_back(size_min, size_max, mpq_flags, compression_first, compression_next); -} - -void GameRules::AddRuleDefault(DWORD mpq_flags, DWORD compression_first, DWORD compression_next) { - rules_.emplace_back(mpq_flags, compression_first, compression_next); -} - -// Get compression settings for a specific file -CompressionSettings GameRules::GetCompressionSettings(const std::string &filename, - const DWORD file_size) const { - // Iterate through rules in order (first match wins) - for (const auto &rule : rules_) { - switch (rule.type) { - case RuleType::FILE_MASK: - if (MatchFileMask(filename, rule.file_mask)) { - return {rule.mpq_flags, rule.compression_first, rule.compression_next}; - } - break; - - case RuleType::FILE_SIZE: { - // Use UINT32_MAX to indicate "no upper limit" - bool has_upper_limit = (rule.size_max != UINT32_MAX); - bool in_range = - file_size >= rule.size_min && (!has_upper_limit || file_size <= rule.size_max); - - if (in_range) { - return {rule.mpq_flags, rule.compression_first, rule.compression_next}; - } - break; - } - - case RuleType::DEFAULT: - return {rule.mpq_flags, rule.compression_first, rule.compression_next}; - } - } - - // Fallback if no rules match (shouldn't happen if DEFAULT rule is present) - return {MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE, - MPQ_COMPRESSION_NEXT_SAME}; -} - -// Override MPQ creation settings with user-provided values -void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides) { - // Track whether user explicitly set fileFlags2 (needed for automatic adjustment logic) - bool user_set_file_flags2 = false; - - // Step 1: Apply user overrides - // User-provided values always take priority, even if they might be incorrect. - // We only apply override if the optional has a value (i.e., user specified it) - - if (overrides.mpq_version.has_value()) { - create_settings_.mpq_version = overrides.mpq_version.value(); - } - - if (overrides.stream_flags.has_value()) { - create_settings_.stream_flags = overrides.stream_flags.value(); - } - - if (overrides.sector_size.has_value()) { - create_settings_.sector_size = overrides.sector_size.value(); - } - - if (overrides.raw_chunk_size.has_value()) { - create_settings_.raw_chunk_size = overrides.raw_chunk_size.value(); - } - - if (overrides.file_flags1.has_value()) { - create_settings_.file_flags1 = overrides.file_flags1.value(); - } - - if (overrides.file_flags2.has_value()) { - create_settings_.file_flags2 = overrides.file_flags2.value(); - user_set_file_flags2 = true; // User explicitly set this value - } - - if (overrides.file_flags3.has_value()) { - create_settings_.file_flags3 = overrides.file_flags3.value(); - } - - if (overrides.attr_flags.has_value()) { - create_settings_.attr_flags = overrides.attr_flags.value(); - } - - // Step 2: Apply automatic adjustments based on dependencies - // These only apply if the user hasn't explicitly overridden the values - - // fileFlags2 controls the (attributes) file, which is only meaningful when - // attrFlags is also set. According to StormLib's SFileCreateArchive.cpp: - // - The (attributes) file is created only when BOTH fileFlags2 AND attrFlags are non-zero - // - If attrFlags is set but fileFlags2 is still 0 (not overridden by user or profile), - // we should set fileFlags2 to MPQ_FILE_DEFAULT_INTERNAL to enable the attributes file - - if (!user_set_file_flags2 && create_settings_.file_flags2 == 0 && - create_settings_.attr_flags != 0) { - // User wants attributes (attrFlags is set) but hasn't specified how to store - // the (attributes) file itself. Use the default internal file flags. - create_settings_.file_flags2 = MPQ_FILE_DEFAULT_INTERNAL; - } - - // Note: If user explicitly sets fileFlags2 to 0 via override, we respect that choice - // even if attrFlags is non-zero. -} - -// Get the profile name map (single source of truth for all valid profile names) -static const std::map &GetProfileMap() { - static const std::map profile_map = { - {"generic", GameProfile::GENERIC}, - {"diablo1", GameProfile::DIABLO1}, - {"diablo", GameProfile::DIABLO1}, - {"d1", GameProfile::DIABLO1}, - {"lordsofmagic", GameProfile::LORDSOFMAGIC}, - {"lomse", GameProfile::LORDSOFMAGIC}, - {"starcraft", GameProfile::STARCRAFT1}, - {"starcraft1", GameProfile::STARCRAFT1}, - {"sc", GameProfile::STARCRAFT1}, - {"sc1", GameProfile::STARCRAFT1}, - {"warcraft2", GameProfile::WARCRAFT2}, - {"wc2", GameProfile::WARCRAFT2}, - {"war2", GameProfile::WARCRAFT2}, - {"diablo2", GameProfile::DIABLO2}, - {"d2", GameProfile::DIABLO2}, - {"warcraft3", GameProfile::WARCRAFT3}, - {"wc3", GameProfile::WARCRAFT3}, - {"war3", GameProfile::WARCRAFT3}, - {"warcraft3-map", GameProfile::WARCRAFT3_MAP}, - {"wc3-map", GameProfile::WARCRAFT3_MAP}, - {"war3-map", GameProfile::WARCRAFT3_MAP}, - {"wow1", GameProfile::WOW_1X}, - {"wow-vanilla", GameProfile::WOW_1X}, - {"wow2", GameProfile::WOW_2X}, - {"wow-tbc", GameProfile::WOW_2X}, - {"wow3", GameProfile::WOW_3X}, - {"wow-wotlk", GameProfile::WOW_3X}, - {"wow4", GameProfile::WOW_4X}, - {"wow-cataclysm", GameProfile::WOW_4X}, - {"wow5", GameProfile::WOW_5X}, - {"wow-mop", GameProfile::WOW_5X}, - {"starcraft2", GameProfile::STARCRAFT2}, - {"sc2", GameProfile::STARCRAFT2}, - {"diablo3", GameProfile::DIABLO3}, - {"d3", GameProfile::DIABLO3}}; - return profile_map; -} - -// Convert string to GameProfile enum -GameProfile GameRules::StringToProfile(const std::string &profile_name) { - const auto &profile_map = GetProfileMap(); - std::string lower = ToLower(profile_name); - auto it = profile_map.find(lower); - if (it != profile_map.end()) { - return it->second; - } - return GameProfile::GENERIC; -} - -// Convert GameProfile enum to string -std::string GameRules::ProfileToString(GameProfile profile) { - switch (profile) { - case GameProfile::GENERIC: - return "generic"; - case GameProfile::DIABLO1: - return "diablo1"; - case GameProfile::LORDSOFMAGIC: - return "lordsofmagic"; - case GameProfile::WARCRAFT2: - return "warcraft2"; - case GameProfile::STARCRAFT1: - return "starcraft1"; - case GameProfile::DIABLO2: - return "diablo2"; - case GameProfile::WARCRAFT3: - return "warcraft3"; - case GameProfile::WARCRAFT3_MAP: - return "warcraft3-map"; - case GameProfile::WOW_1X: - return "wow-vanilla"; - case GameProfile::WOW_2X: - return "wow-tbc"; - case GameProfile::WOW_3X: - return "wow-wotlk"; - case GameProfile::WOW_4X: - return "wow-cataclysm"; - case GameProfile::WOW_5X: - return "wow-mop"; - case GameProfile::STARCRAFT2: - return "starcraft2"; - case GameProfile::DIABLO3: - return "diablo3"; - default: - return "generic"; - } -} - -// Get list of canonical game profile names (for display purposes) -std::vector GameRules::GetCanonicalProfiles() { - static const std::vector all_profiles = { - GameProfile::GENERIC, GameProfile::DIABLO1, GameProfile::LORDSOFMAGIC, - GameProfile::STARCRAFT1, GameProfile::WARCRAFT2, GameProfile::DIABLO2, - GameProfile::WARCRAFT3, GameProfile::WARCRAFT3_MAP, GameProfile::WOW_1X, - GameProfile::WOW_2X, GameProfile::WOW_3X, GameProfile::WOW_4X, - GameProfile::WOW_5X, GameProfile::STARCRAFT2, GameProfile::DIABLO3, - }; - - std::vector profiles; - profiles.reserve(all_profiles.size()); - for (const auto &p : all_profiles) { - profiles.push_back(ProfileToString(p)); - } - return profiles; -} - -// Get available profiles as a comma-separated string -std::string GameRules::GetAvailableProfiles() { - auto profiles = GetCanonicalProfiles(); - std::string result; - - for (size_t i = 0; i < profiles.size(); ++i) { - result += profiles[i]; - if (i < profiles.size() - 1) { - result += ", "; - } - } - - return result; -} +#include "gamerules/rules.h" // Initialize rules for the selected game profile void GameRules::InitializeRules() { diff --git a/src/gamerules/profiles.cpp b/src/gamerules/profiles.cpp new file mode 100644 index 0000000..3b6fc49 --- /dev/null +++ b/src/gamerules/profiles.cpp @@ -0,0 +1,129 @@ +#include +#include +#include + +#include "gamerules/rules.h" +#include "util/string.h" + +// Get the profile name map (single source of truth for all valid profile names) +static const std::map &GetProfileMap() { + static const std::map profile_map = { + {"generic", GameProfile::GENERIC}, + {"diablo1", GameProfile::DIABLO1}, + {"diablo", GameProfile::DIABLO1}, + {"d1", GameProfile::DIABLO1}, + {"lordsofmagic", GameProfile::LORDSOFMAGIC}, + {"lomse", GameProfile::LORDSOFMAGIC}, + {"starcraft", GameProfile::STARCRAFT1}, + {"starcraft1", GameProfile::STARCRAFT1}, + {"sc", GameProfile::STARCRAFT1}, + {"sc1", GameProfile::STARCRAFT1}, + {"warcraft2", GameProfile::WARCRAFT2}, + {"wc2", GameProfile::WARCRAFT2}, + {"war2", GameProfile::WARCRAFT2}, + {"diablo2", GameProfile::DIABLO2}, + {"d2", GameProfile::DIABLO2}, + {"warcraft3", GameProfile::WARCRAFT3}, + {"wc3", GameProfile::WARCRAFT3}, + {"war3", GameProfile::WARCRAFT3}, + {"warcraft3-map", GameProfile::WARCRAFT3_MAP}, + {"wc3-map", GameProfile::WARCRAFT3_MAP}, + {"war3-map", GameProfile::WARCRAFT3_MAP}, + {"wow1", GameProfile::WOW_1X}, + {"wow-vanilla", GameProfile::WOW_1X}, + {"wow2", GameProfile::WOW_2X}, + {"wow-tbc", GameProfile::WOW_2X}, + {"wow3", GameProfile::WOW_3X}, + {"wow-wotlk", GameProfile::WOW_3X}, + {"wow4", GameProfile::WOW_4X}, + {"wow-cataclysm", GameProfile::WOW_4X}, + {"wow5", GameProfile::WOW_5X}, + {"wow-mop", GameProfile::WOW_5X}, + {"starcraft2", GameProfile::STARCRAFT2}, + {"sc2", GameProfile::STARCRAFT2}, + {"diablo3", GameProfile::DIABLO3}, + {"d3", GameProfile::DIABLO3}}; + return profile_map; +} + +// Convert string to GameProfile enum +GameProfile GameRules::StringToProfile(const std::string &profile_name) { + const auto &profile_map = GetProfileMap(); + std::string lower = ToLower(profile_name); + auto it = profile_map.find(lower); + if (it != profile_map.end()) { + return it->second; + } + return GameProfile::GENERIC; +} + +// Convert GameProfile enum to string +std::string GameRules::ProfileToString(GameProfile profile) { + switch (profile) { + case GameProfile::GENERIC: + return "generic"; + case GameProfile::DIABLO1: + return "diablo1"; + case GameProfile::LORDSOFMAGIC: + return "lordsofmagic"; + case GameProfile::WARCRAFT2: + return "warcraft2"; + case GameProfile::STARCRAFT1: + return "starcraft1"; + case GameProfile::DIABLO2: + return "diablo2"; + case GameProfile::WARCRAFT3: + return "warcraft3"; + case GameProfile::WARCRAFT3_MAP: + return "warcraft3-map"; + case GameProfile::WOW_1X: + return "wow-vanilla"; + case GameProfile::WOW_2X: + return "wow-tbc"; + case GameProfile::WOW_3X: + return "wow-wotlk"; + case GameProfile::WOW_4X: + return "wow-cataclysm"; + case GameProfile::WOW_5X: + return "wow-mop"; + case GameProfile::STARCRAFT2: + return "starcraft2"; + case GameProfile::DIABLO3: + return "diablo3"; + default: + return "generic"; + } +} + +// Get list of canonical game profile names (for display purposes) +std::vector GameRules::GetCanonicalProfiles() { + static const std::vector all_profiles = { + GameProfile::GENERIC, GameProfile::DIABLO1, GameProfile::LORDSOFMAGIC, + GameProfile::STARCRAFT1, GameProfile::WARCRAFT2, GameProfile::DIABLO2, + GameProfile::WARCRAFT3, GameProfile::WARCRAFT3_MAP, GameProfile::WOW_1X, + GameProfile::WOW_2X, GameProfile::WOW_3X, GameProfile::WOW_4X, + GameProfile::WOW_5X, GameProfile::STARCRAFT2, GameProfile::DIABLO3, + }; + + std::vector profiles; + profiles.reserve(all_profiles.size()); + for (const auto &p : all_profiles) { + profiles.push_back(ProfileToString(p)); + } + return profiles; +} + +// Get available profiles as a comma-separated string +std::string GameRules::GetAvailableProfiles() { + auto profiles = GetCanonicalProfiles(); + std::string result; + + for (size_t i = 0; i < profiles.size(); ++i) { + result += profiles[i]; + if (i < profiles.size() - 1) { + result += ", "; + } + } + + return result; +} diff --git a/src/gamerules/profiles.h b/src/gamerules/profiles.h new file mode 100644 index 0000000..8de9b84 --- /dev/null +++ b/src/gamerules/profiles.h @@ -0,0 +1,22 @@ +#ifndef GAMERULES_PROFILES_H +#define GAMERULES_PROFILES_H + +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) +}; + +#endif diff --git a/src/gamerules/rules.cpp b/src/gamerules/rules.cpp new file mode 100644 index 0000000..ec174f8 --- /dev/null +++ b/src/gamerules/rules.cpp @@ -0,0 +1,167 @@ +#include "gamerules/rules.h" + +#include +#include + +#include "util/string.h" + +// Constructor +GameRules::GameRules(GameProfile game_profile) : profile_(game_profile) { + InitializeRules(); +} + +// Helper function to match wildcards (* and ?) +bool GameRules::MatchFileMask(const std::string &filename, const std::string &mask) { + // Convert both to lowercase for case-insensitive matching + std::string lower_filename = ToLower(filename); + std::string lower_mask = ToLower(mask); + + // Replace backslashes with forward slashes for consistent path handling + std::replace(lower_filename.begin(), lower_filename.end(), '\\', '/'); + std::replace(lower_mask.begin(), lower_mask.end(), '\\', '/'); + + // Simple wildcard matching + size_t mask_pos = 0; + size_t file_pos = 0; + size_t star_pos = std::string::npos; + size_t match_pos = 0; + + while (file_pos < lower_filename.length()) { + if (mask_pos < lower_mask.length() && + (lower_mask[mask_pos] == '?' || lower_mask[mask_pos] == lower_filename[file_pos])) { + mask_pos++; + file_pos++; + } else if (mask_pos < lower_mask.length() && lower_mask[mask_pos] == '*') { + star_pos = mask_pos; + match_pos = file_pos; + mask_pos++; + } else if (star_pos != std::string::npos) { + mask_pos = star_pos + 1; + match_pos++; + file_pos = match_pos; + } else { + return false; + } + } + + while (mask_pos < lower_mask.length() && lower_mask[mask_pos] == '*') { + mask_pos++; + } + + return mask_pos == lower_mask.length(); +} + +void GameRules::AddRuleByFileMask(const std::string &file_mask, DWORD mpq_flags, + DWORD compression_first, DWORD compression_next) { + rules_.emplace_back(file_mask, mpq_flags, compression_first, compression_next); +} + +// Use UINT32_MAX for sizeMax to indicate "no upper limit" +// Examples: +// AddRuleByFileSize(0, 0, ...) - Match files with exactly 0 bytes +// AddRuleByFileSize(0, 0x4000, ...) - Match files from 0 to 16KB +// AddRuleByFileSize(0x4000, UINT32_MAX, ...) - Match files from 16KB onwards +void GameRules::AddRuleByFileSize(DWORD size_min, DWORD size_max, DWORD mpq_flags, + DWORD compression_first, DWORD compression_next) { + rules_.emplace_back(size_min, size_max, mpq_flags, compression_first, compression_next); +} + +void GameRules::AddRuleDefault(DWORD mpq_flags, DWORD compression_first, DWORD compression_next) { + rules_.emplace_back(mpq_flags, compression_first, compression_next); +} + +// Get compression settings for a specific file +CompressionSettings GameRules::GetCompressionSettings(const std::string &filename, + const DWORD file_size) const { + // Iterate through rules in order (first match wins) + for (const auto &rule : rules_) { + switch (rule.type) { + case RuleType::FILE_MASK: + if (MatchFileMask(filename, rule.file_mask)) { + return {rule.mpq_flags, rule.compression_first, rule.compression_next}; + } + break; + + case RuleType::FILE_SIZE: { + // Use UINT32_MAX to indicate "no upper limit" + bool has_upper_limit = (rule.size_max != UINT32_MAX); + bool in_range = + file_size >= rule.size_min && (!has_upper_limit || file_size <= rule.size_max); + + if (in_range) { + return {rule.mpq_flags, rule.compression_first, rule.compression_next}; + } + break; + } + + case RuleType::DEFAULT: + return {rule.mpq_flags, rule.compression_first, rule.compression_next}; + } + } + + // Fallback if no rules match (shouldn't happen if DEFAULT rule is present) + return {MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE, + MPQ_COMPRESSION_NEXT_SAME}; +} + +// Override MPQ creation settings with user-provided values +void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides) { + // Track whether user explicitly set fileFlags2 (needed for automatic adjustment logic) + bool user_set_file_flags2 = false; + + // Step 1: Apply user overrides + // User-provided values always take priority, even if they might be incorrect. + // We only apply override if the optional has a value (i.e., user specified it) + + if (overrides.mpq_version.has_value()) { + create_settings_.mpq_version = overrides.mpq_version.value(); + } + + if (overrides.stream_flags.has_value()) { + create_settings_.stream_flags = overrides.stream_flags.value(); + } + + if (overrides.sector_size.has_value()) { + create_settings_.sector_size = overrides.sector_size.value(); + } + + if (overrides.raw_chunk_size.has_value()) { + create_settings_.raw_chunk_size = overrides.raw_chunk_size.value(); + } + + if (overrides.file_flags1.has_value()) { + create_settings_.file_flags1 = overrides.file_flags1.value(); + } + + if (overrides.file_flags2.has_value()) { + create_settings_.file_flags2 = overrides.file_flags2.value(); + user_set_file_flags2 = true; // User explicitly set this value + } + + if (overrides.file_flags3.has_value()) { + create_settings_.file_flags3 = overrides.file_flags3.value(); + } + + if (overrides.attr_flags.has_value()) { + create_settings_.attr_flags = overrides.attr_flags.value(); + } + + // Step 2: Apply automatic adjustments based on dependencies + // These only apply if the user hasn't explicitly overridden the values + + // fileFlags2 controls the (attributes) file, which is only meaningful when + // attrFlags is also set. According to StormLib's SFileCreateArchive.cpp: + // - The (attributes) file is created only when BOTH fileFlags2 AND attrFlags are non-zero + // - If attrFlags is set but fileFlags2 is still 0 (not overridden by user or profile), + // we should set fileFlags2 to MPQ_FILE_DEFAULT_INTERNAL to enable the attributes file + + if (!user_set_file_flags2 && create_settings_.file_flags2 == 0 && + create_settings_.attr_flags != 0) { + // User wants attributes (attrFlags is set) but hasn't specified how to store + // the (attributes) file itself. Use the default internal file flags. + create_settings_.file_flags2 = MPQ_FILE_DEFAULT_INTERNAL; + } + + // Note: If user explicitly sets fileFlags2 to 0 via override, we respect that choice + // even if attrFlags is non-zero. +} diff --git a/src/gamerules/rules.h b/src/gamerules/rules.h new file mode 100644 index 0000000..2630cc6 --- /dev/null +++ b/src/gamerules/rules.h @@ -0,0 +1,67 @@ +#ifndef GAMERULES_RULES_H +#define GAMERULES_RULES_H + +#include +#include + +#include + +#include "gamerules/profiles.h" +#include "gamerules/settings.h" + +// Game rules class that manages compression rules for different games +class GameRules { +private: + GameProfile profile_; + std::vector rules_; + MpqCreateSettings create_settings_; + + // Helper function to match file mask pattern + static bool MatchFileMask(const std::string &filename, const std::string &mask); + + // Add rule by file mask + void AddRuleByFileMask(const std::string &file_mask, DWORD mpq_flags, DWORD compression_first, + DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); + + // Add rule by file size + void AddRuleByFileSize(DWORD size_min, DWORD size_max, DWORD mpq_flags, DWORD compression_first, + DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); + + // Add default rule + void AddRuleDefault(DWORD mpq_flags, DWORD compression_first, + DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); + + // Initialize rules for the selected game profile + void InitializeRules(); + + // Convert GameProfile enum to string + static std::string ProfileToString(GameProfile profile); + +public: + // Constructor + explicit GameRules(GameProfile game_profile); + + // Get compression settings for a specific file + [[nodiscard]] CompressionSettings GetCompressionSettings(const std::string &filename, + DWORD file_size) const; + + // Get MPQ creation settings + [[nodiscard]] const MpqCreateSettings &GetCreateSettings() const { return create_settings_; } + + // Override MPQ creation settings + void OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides); + + // Convert string to GameProfile enum + static GameProfile StringToProfile(const std::string &profile_name); + + // Get list of canonical game profile names (for display purposes) + static std::vector GetCanonicalProfiles(); + + // Get available profiles as a comma-separated string + static std::string GetAvailableProfiles(); + + // Get default game profile (GENERIC) + static GameProfile GetDefaultProfile() { return GameProfile::GENERIC; } +}; + +#endif diff --git a/src/gamerules.h b/src/gamerules/settings.h similarity index 52% rename from src/gamerules.h rename to src/gamerules/settings.h index 1a26fdb..50db4d4 100644 --- a/src/gamerules.h +++ b/src/gamerules/settings.h @@ -1,31 +1,12 @@ -#ifndef GAMERULES_H -#define GAMERULES_H +#ifndef GAMERULES_SETTINGS_H +#define GAMERULES_SETTINGS_H #include #include #include -#include #include -enum class GameProfile { - GENERIC, // Default/generic MPQ with basic compression - DIABLO1, // Diablo I / Hellfire (1997) - LORDSOFMAGIC, // Lords of Magic SE (1998) - STARCRAFT1, // StarCraft / Brood War (1998) - WARCRAFT2, // Warcraft II: Battle.net Edition (1999) - DIABLO2, // Diablo II / Lords of Destruction (2000) - WARCRAFT3, // Warcraft III / The Frozen Throne (2002) - WARCRAFT3_MAP, // Warcraft III Map files (2002) - WOW_1X, // World of Warcraft 1 - Vanilla (2004) - WOW_2X, // World of Warcraft 2 - The Burning Crusade (2007) - WOW_3X, // World of Warcraft 3 - Wrath of the Lich King (2008) - WOW_4X, // World of Warcraft 4 - Cataclysm (2010) - WOW_5X, // World of Warcraft 5 - Mists of Pandaria (2012) - STARCRAFT2, // StarCraft II (2010) - DIABLO3 // Diablo III (2012) -}; - enum class RuleType { FILE_MASK, // Rule based on file pattern (e.g., "*.wav") FILE_SIZE, // Rule based on file size range @@ -103,59 +84,4 @@ struct MpqCreateSettingsOverrides { std::optional raw_chunk_size; }; -// Game rules class that manages compression rules for different games -class GameRules { -private: - GameProfile profile_; - std::vector rules_; - MpqCreateSettings create_settings_; - - // Helper function to match file mask pattern - static bool MatchFileMask(const std::string &filename, const std::string &mask); - - // Add rule by file mask - void AddRuleByFileMask(const std::string &file_mask, DWORD mpq_flags, DWORD compression_first, - DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); - - // Add rule by file size - void AddRuleByFileSize(DWORD size_min, DWORD size_max, DWORD mpq_flags, DWORD compression_first, - DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); - - // Add default rule - void AddRuleDefault(DWORD mpq_flags, DWORD compression_first, - DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); - - // Initialize rules for the selected game profile - void InitializeRules(); - - // Convert GameProfile enum to string - static std::string ProfileToString(GameProfile profile); - -public: - // Constructor - explicit GameRules(GameProfile game_profile); - - // Get compression settings for a specific file - [[nodiscard]] CompressionSettings GetCompressionSettings(const std::string &filename, - DWORD file_size) const; - - // Get MPQ creation settings - [[nodiscard]] const MpqCreateSettings &GetCreateSettings() const { return create_settings_; } - - // Override MPQ creation settings - void OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides); - - // Convert string to GameProfile enum - static GameProfile StringToProfile(const std::string &profile_name); - - // Get list of canonical game profile names (for display purposes) - static std::vector GetCanonicalProfiles(); - - // Get available profiles as a comma-separated string - static std::string GetAvailableProfiles(); - - // Get default game profile (GENERIC) - static GameProfile GetDefaultProfile() { return GameProfile::GENERIC; } -}; - -#endif // GAMERULES_H +#endif diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index a77591f..fc25002 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -8,7 +8,7 @@ #include -#include "gamerules.h" +#include "gamerules/rules.h" #include "mpq/query.h" #include "util/capacity.h" #include "util/format.h" diff --git a/src/mpq/add.h b/src/mpq/add.h index 2c58145..f3be6d9 100644 --- a/src/mpq/add.h +++ b/src/mpq/add.h @@ -7,7 +7,8 @@ #include -#include "gamerules.h" +#include "gamerules/rules.h" +#include "gamerules/settings.h" namespace fs = std::filesystem; diff --git a/src/mpq/create.cpp b/src/mpq/create.cpp index 687938d..7622873 100644 --- a/src/mpq/create.cpp +++ b/src/mpq/create.cpp @@ -7,7 +7,7 @@ #include -#include "gamerules.h" +#include "gamerules/rules.h" #include "util/format.h" namespace fs = std::filesystem; diff --git a/src/mpq/create.h b/src/mpq/create.h index b512bed..1f24a87 100644 --- a/src/mpq/create.h +++ b/src/mpq/create.h @@ -6,7 +6,7 @@ #include -#include "gamerules.h" +#include "gamerules/rules.h" HANDLE CreateMpqArchive(const std::string &output_archive_name, uint32_t file_count, const GameRules &game_rules); diff --git a/src/util/string.cpp b/src/util/string.cpp new file mode 100644 index 0000000..4603bca --- /dev/null +++ b/src/util/string.cpp @@ -0,0 +1,12 @@ +#include "util/string.h" + +#include +#include +#include + +std::string ToLower(const std::string &str) { + std::string result = str; + std::transform(result.begin(), result.end(), result.begin(), + [](unsigned char c) { return std::tolower(c); }); + return result; +} diff --git a/src/util/string.h b/src/util/string.h new file mode 100644 index 0000000..5ce1bf4 --- /dev/null +++ b/src/util/string.h @@ -0,0 +1,9 @@ +#ifndef UTIL_STRING_H +#define UTIL_STRING_H + +#include + +/// Returns str with every ASCII letter lowercased +std::string ToLower(const std::string &str); + +#endif From 6670510a3f2813ac6b104d324f72ec49a42a9e88 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:58:49 +1200 Subject: [PATCH 09/59] Wrapped the core library in the mpqcli namespace --- app/main.cpp | 2 ++ app/validators.cpp | 2 ++ src/cli/add.cpp | 4 ++++ src/cli/commands.h | 4 ++++ src/cli/compact.cpp | 4 ++++ src/cli/create.cpp | 4 ++++ src/cli/extract.cpp | 4 ++++ src/cli/info.cpp | 4 ++++ src/cli/list.cpp | 4 ++++ src/cli/read.cpp | 4 ++++ src/cli/remove.cpp | 4 ++++ src/cli/rename.cpp | 4 ++++ src/cli/verify.cpp | 4 ++++ src/gamerules/profile_data.cpp | 4 ++++ src/gamerules/profiles.cpp | 4 ++++ src/gamerules/profiles.h | 4 ++++ src/gamerules/rules.cpp | 4 ++++ src/gamerules/rules.h | 4 ++++ src/gamerules/settings.h | 4 ++++ src/mpq/add.cpp | 6 ++++++ src/mpq/add.h | 13 ++++++++----- src/mpq/archive.cpp | 4 ++++ src/mpq/archive.h | 4 ++++ src/mpq/compact.cpp | 4 ++++ src/mpq/compact.h | 4 ++++ src/mpq/create.cpp | 4 ++++ src/mpq/create.h | 4 ++++ src/mpq/extract.cpp | 7 +++++++ src/mpq/extract.h | 4 ++++ src/mpq/flags.cpp | 4 ++++ src/mpq/flags.h | 4 ++++ src/mpq/info.cpp | 4 ++++ src/mpq/info.h | 4 ++++ src/mpq/list.cpp | 4 ++++ src/mpq/list.h | 4 ++++ src/mpq/query.cpp | 6 ++++++ src/mpq/query.h | 6 ++++-- src/mpq/read.cpp | 4 ++++ src/mpq/read.h | 4 ++++ src/mpq/remove.cpp | 4 ++++ src/mpq/remove.h | 4 ++++ src/mpq/rename.cpp | 4 ++++ src/mpq/rename.h | 4 ++++ src/mpq/verify.cpp | 6 ++++++ src/mpq/verify.h | 4 ++++ src/util/capacity.cpp | 4 ++++ src/util/capacity.h | 4 ++++ src/util/format.cpp | 4 ++++ src/util/format.h | 4 ++++ src/util/hash.cpp | 4 ++++ src/util/hash.h | 8 +++++--- src/util/locales.cpp | 4 ++++ src/util/locales.h | 4 ++++ src/util/path.cpp | 4 ++++ src/util/path.h | 15 +++++++++------ src/util/string.cpp | 4 ++++ src/util/string.h | 4 ++++ 57 files changed, 243 insertions(+), 16 deletions(-) diff --git a/app/main.cpp b/app/main.cpp index cc2cf62..5481d71 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -11,6 +11,8 @@ #include "gamerules/rules.h" #include "validators.h" +using namespace mpqcli; + int main(int argc, char **argv) { CLI::App app{ "A command line tool to create, add, remove, list, extract, read, rename, and verify MPQ " diff --git a/app/validators.cpp b/app/validators.cpp index e48298e..ae9c495 100644 --- a/app/validators.cpp +++ b/app/validators.cpp @@ -5,6 +5,8 @@ #include "gamerules/rules.h" #include "util/locales.h" +using namespace mpqcli; + // Accepts every profile name but lists only the canonical ones on failure extern const CLI::Validator game_profile_valid = CLI::Validator( [](const std::string &str) { diff --git a/src/cli/add.cpp b/src/cli/add.cpp index 0cfa220..4034e18 100644 --- a/src/cli/add.cpp +++ b/src/cli/add.cpp @@ -18,6 +18,8 @@ namespace fs = std::filesystem; +namespace mpqcli { + int HandleAdd(const std::vector &files, const std::string &target, const std::optional &path, bool overwrite, bool update, const std::optional &locale, @@ -101,3 +103,5 @@ int HandleAdd(const std::vector &files, const std::string &target, CloseMpqArchive(archive); return result; } + +} // namespace mpqcli diff --git a/src/cli/commands.h b/src/cli/commands.h index 5c1f376..2ecb80f 100644 --- a/src/cli/commands.h +++ b/src/cli/commands.h @@ -6,6 +6,8 @@ #include #include +namespace mpqcli { + int HandleInfo(const std::string &target, const std::optional &property); int HandleCreate(const std::string &target, const std::optional &path, const std::optional &output, bool sign_archive, @@ -34,4 +36,6 @@ int HandleRead(const std::string &file, const std::string &target, int HandleVerify(const std::string &target, bool print_signature); int HandleCompact(const std::string &target, const std::optional &listfile_name); +} // namespace mpqcli + #endif // CLI_COMMANDS_H diff --git a/src/cli/compact.cpp b/src/cli/compact.cpp index c30f46e..ed62cca 100644 --- a/src/cli/compact.cpp +++ b/src/cli/compact.cpp @@ -8,6 +8,8 @@ #include "cli/commands.h" #include "mpq/archive.h" +namespace mpqcli { + int HandleCompact(const std::string &target, const std::optional &listfile_name) { HANDLE archive; if (!OpenMpqArchive(target, &archive, 0)) { @@ -18,3 +20,5 @@ int HandleCompact(const std::string &target, const std::optional &l CloseMpqArchive(archive); return result; } + +} // namespace mpqcli diff --git a/src/cli/create.cpp b/src/cli/create.cpp index a7b0271..0a9c5f1 100644 --- a/src/cli/create.cpp +++ b/src/cli/create.cpp @@ -20,6 +20,8 @@ namespace fs = std::filesystem; +namespace mpqcli { + int HandleCreate(const std::string &target, const std::optional &path, const std::optional &output, bool sign_archive, const std::optional &locale, @@ -133,3 +135,5 @@ int HandleCreate(const std::string &target, const std::optional &pa return result; } + +} // namespace mpqcli diff --git a/src/cli/extract.cpp b/src/cli/extract.cpp index 0195cec..0a52967 100644 --- a/src/cli/extract.cpp +++ b/src/cli/extract.cpp @@ -14,6 +14,8 @@ namespace fs = std::filesystem; +namespace mpqcli { + int HandleExtract(const std::string &target, const std::optional &output, const std::optional &file, bool keep_folder_structure, const std::optional &listfile_name, @@ -76,3 +78,5 @@ int HandleExtract(const std::string &target, const std::optional &o } return result; } + +} // namespace mpqcli diff --git a/src/cli/info.cpp b/src/cli/info.cpp index 10cae83..ce35d55 100644 --- a/src/cli/info.cpp +++ b/src/cli/info.cpp @@ -8,6 +8,8 @@ #include "cli/commands.h" #include "mpq/archive.h" +namespace mpqcli { + int HandleInfo(const std::string &target, const std::optional &property) { HANDLE archive; if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { @@ -17,3 +19,5 @@ int HandleInfo(const std::string &target, const std::optional &prop CloseMpqArchive(archive); return 0; } + +} // namespace mpqcli diff --git a/src/cli/list.cpp b/src/cli/list.cpp index 71db165..beb8675 100644 --- a/src/cli/list.cpp +++ b/src/cli/list.cpp @@ -9,6 +9,8 @@ #include "cli/commands.h" #include "mpq/archive.h" +namespace mpqcli { + int HandleList(const std::string &target, const std::optional &listfile_name, bool list_all, bool list_detailed, const std::vector &properties) { HANDLE archive; @@ -19,3 +21,5 @@ int HandleList(const std::string &target, const std::optional &list CloseMpqArchive(archive); return 0; } + +} // namespace mpqcli diff --git a/src/cli/read.cpp b/src/cli/read.cpp index cc27aec..45f0f41 100644 --- a/src/cli/read.cpp +++ b/src/cli/read.cpp @@ -12,6 +12,8 @@ #include "util/format.h" #include "util/locales.h" +namespace mpqcli { + int HandleRead(const std::string &file, const std::string &target, const std::optional &locale) { HANDLE archive; @@ -37,3 +39,5 @@ int HandleRead(const std::string &file, const std::string &target, CloseMpqArchive(archive); return 0; } + +} // namespace mpqcli diff --git a/src/cli/remove.cpp b/src/cli/remove.cpp index 137af26..206b3c1 100644 --- a/src/cli/remove.cpp +++ b/src/cli/remove.cpp @@ -11,6 +11,8 @@ #include "mpq/archive.h" #include "util/locales.h" +namespace mpqcli { + int HandleRemove(const std::vector &files, const std::string &target, const std::optional &locale) { HANDLE archive; @@ -33,3 +35,5 @@ int HandleRemove(const std::vector &files, const std::string &targe CloseMpqArchive(archive); return overall_result; } + +} // namespace mpqcli diff --git a/src/cli/rename.cpp b/src/cli/rename.cpp index 90b3bb9..6dea488 100644 --- a/src/cli/rename.cpp +++ b/src/cli/rename.cpp @@ -9,6 +9,8 @@ #include "mpq/archive.h" #include "util/locales.h" +namespace mpqcli { + int HandleRename(const std::string &old_file, const std::string &new_file, const std::string &target, const std::optional &locale) { HANDLE archive; @@ -21,3 +23,5 @@ int HandleRename(const std::string &old_file, const std::string &new_file, CloseMpqArchive(archive); return result; } + +} // namespace mpqcli diff --git a/src/cli/verify.cpp b/src/cli/verify.cpp index b51bf76..f55a2cd 100644 --- a/src/cli/verify.cpp +++ b/src/cli/verify.cpp @@ -8,6 +8,8 @@ #include "cli/commands.h" #include "mpq/archive.h" +namespace mpqcli { + int HandleVerify(const std::string &target, bool print_signature) { HANDLE archive; if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { @@ -52,3 +54,5 @@ int HandleVerify(const std::string &target, bool print_signature) { CloseMpqArchive(archive); return result; } + +} // namespace mpqcli diff --git a/src/gamerules/profile_data.cpp b/src/gamerules/profile_data.cpp index 7a84134..23a1665 100644 --- a/src/gamerules/profile_data.cpp +++ b/src/gamerules/profile_data.cpp @@ -1,5 +1,7 @@ #include "gamerules/rules.h" +namespace mpqcli { + // Initialize rules for the selected game profile void GameRules::InitializeRules() { rules_.clear(); @@ -233,3 +235,5 @@ void GameRules::InitializeRules() { break; } } + +} // namespace mpqcli diff --git a/src/gamerules/profiles.cpp b/src/gamerules/profiles.cpp index 3b6fc49..c5c709e 100644 --- a/src/gamerules/profiles.cpp +++ b/src/gamerules/profiles.cpp @@ -5,6 +5,8 @@ #include "gamerules/rules.h" #include "util/string.h" +namespace mpqcli { + // Get the profile name map (single source of truth for all valid profile names) static const std::map &GetProfileMap() { static const std::map profile_map = { @@ -127,3 +129,5 @@ std::string GameRules::GetAvailableProfiles() { return result; } + +} // namespace mpqcli diff --git a/src/gamerules/profiles.h b/src/gamerules/profiles.h index 8de9b84..63e9ed2 100644 --- a/src/gamerules/profiles.h +++ b/src/gamerules/profiles.h @@ -1,6 +1,8 @@ #ifndef GAMERULES_PROFILES_H #define GAMERULES_PROFILES_H +namespace mpqcli { + enum class GameProfile { GENERIC, // Default/generic MPQ with basic compression DIABLO1, // Diablo I / Hellfire (1997) @@ -19,4 +21,6 @@ enum class GameProfile { DIABLO3 // Diablo III (2012) }; +} // namespace mpqcli + #endif diff --git a/src/gamerules/rules.cpp b/src/gamerules/rules.cpp index ec174f8..6bd4cf1 100644 --- a/src/gamerules/rules.cpp +++ b/src/gamerules/rules.cpp @@ -5,6 +5,8 @@ #include "util/string.h" +namespace mpqcli { + // Constructor GameRules::GameRules(GameProfile game_profile) : profile_(game_profile) { InitializeRules(); @@ -165,3 +167,5 @@ void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides &overrid // Note: If user explicitly sets fileFlags2 to 0 via override, we respect that choice // even if attrFlags is non-zero. } + +} // namespace mpqcli diff --git a/src/gamerules/rules.h b/src/gamerules/rules.h index 2630cc6..af9c68e 100644 --- a/src/gamerules/rules.h +++ b/src/gamerules/rules.h @@ -9,6 +9,8 @@ #include "gamerules/profiles.h" #include "gamerules/settings.h" +namespace mpqcli { + // Game rules class that manages compression rules for different games class GameRules { private: @@ -64,4 +66,6 @@ class GameRules { static GameProfile GetDefaultProfile() { return GameProfile::GENERIC; } }; +} // namespace mpqcli + #endif diff --git a/src/gamerules/settings.h b/src/gamerules/settings.h index 50db4d4..b1e5b5f 100644 --- a/src/gamerules/settings.h +++ b/src/gamerules/settings.h @@ -7,6 +7,8 @@ #include +namespace mpqcli { + enum class RuleType { FILE_MASK, // Rule based on file pattern (e.g., "*.wav") FILE_SIZE, // Rule based on file size range @@ -84,4 +86,6 @@ struct MpqCreateSettingsOverrides { std::optional raw_chunk_size; }; +} // namespace mpqcli + #endif diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index fc25002..3ff7747 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -15,6 +15,10 @@ #include "util/locales.h" #include "util/path.h" +namespace mpqcli { + +namespace fs = std::filesystem; + int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, const std::string &path_prefix, LCID locale, const GameRules &game_rules, const CompressionSettingsOverrides &overrides, bool overwrite, bool update, @@ -177,3 +181,5 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi return 0; } + +} // namespace mpqcli diff --git a/src/mpq/add.h b/src/mpq/add.h index f3be6d9..ce0bf4c 100644 --- a/src/mpq/add.h +++ b/src/mpq/add.h @@ -10,15 +10,18 @@ #include "gamerules/rules.h" #include "gamerules/settings.h" -namespace fs = std::filesystem; +namespace mpqcli { -int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, - const std::string &path_prefix, LCID locale, const GameRules &game_rules, +int AddFiles(HANDLE archive, const std::vector &files, + const std::filesystem::path &base_path, const std::string &path_prefix, LCID locale, + const GameRules &game_rules, const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), bool overwrite = false, bool update = false, int *skipped = nullptr); -int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archive_file_path, - LCID locale, const GameRules &game_rules, +int AddFile(HANDLE archive, const std::filesystem::path &local_file, + const std::string &archive_file_path, LCID locale, const GameRules &game_rules, const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), bool overwrite = false, bool update = false, int *skipped = nullptr); +} // namespace mpqcli + #endif diff --git a/src/mpq/archive.cpp b/src/mpq/archive.cpp index cfde080..f3e60f3 100644 --- a/src/mpq/archive.cpp +++ b/src/mpq/archive.cpp @@ -8,6 +8,8 @@ #include "util/format.h" +namespace mpqcli { + bool OpenMpqArchive(const std::string &filename, HANDLE *archive, int32_t flags) { if (!SFileOpenArchive(filename.c_str(), 0, flags, archive)) { const auto error = SErrGetLastError(); @@ -35,3 +37,5 @@ bool SignMpqArchive(HANDLE archive) { } return true; } + +} // namespace mpqcli diff --git a/src/mpq/archive.h b/src/mpq/archive.h index a9b5739..d36515f 100644 --- a/src/mpq/archive.h +++ b/src/mpq/archive.h @@ -6,8 +6,12 @@ #include +namespace mpqcli { + bool OpenMpqArchive(const std::string &filename, HANDLE *archive, int32_t flags); bool CloseMpqArchive(HANDLE archive); bool SignMpqArchive(HANDLE archive); +} // namespace mpqcli + #endif diff --git a/src/mpq/compact.cpp b/src/mpq/compact.cpp index 2e2145e..47da094 100644 --- a/src/mpq/compact.cpp +++ b/src/mpq/compact.cpp @@ -8,6 +8,8 @@ #include "util/format.h" +namespace mpqcli { + int CompactMpqArchive(HANDLE archive, const std::optional &listfile_name) { std::cout << "[*] Compacting archive. This may take some time..." << std::endl; // Check if the user provided a listfile input @@ -21,3 +23,5 @@ int CompactMpqArchive(HANDLE archive, const std::optional &listfile } return 0; } + +} // namespace mpqcli diff --git a/src/mpq/compact.h b/src/mpq/compact.h index 03383fa..a4fcaab 100644 --- a/src/mpq/compact.h +++ b/src/mpq/compact.h @@ -6,6 +6,10 @@ #include +namespace mpqcli { + int CompactMpqArchive(HANDLE archive, const std::optional &listfile_name); +} // namespace mpqcli + #endif diff --git a/src/mpq/create.cpp b/src/mpq/create.cpp index 7622873..b5cdf9a 100644 --- a/src/mpq/create.cpp +++ b/src/mpq/create.cpp @@ -12,6 +12,8 @@ namespace fs = std::filesystem; +namespace mpqcli { + HANDLE CreateMpqArchive(const std::string &output_archive_name, const uint32_t file_count, const GameRules &game_rules) { // Check if file already exists @@ -51,3 +53,5 @@ HANDLE CreateMpqArchive(const std::string &output_archive_name, const uint32_t f return archive; } + +} // namespace mpqcli diff --git a/src/mpq/create.h b/src/mpq/create.h index 1f24a87..03109a1 100644 --- a/src/mpq/create.h +++ b/src/mpq/create.h @@ -8,7 +8,11 @@ #include "gamerules/rules.h" +namespace mpqcli { + HANDLE CreateMpqArchive(const std::string &output_archive_name, uint32_t file_count, const GameRules &game_rules); +} // namespace mpqcli + #endif diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index 569c1ba..917a569 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -1,5 +1,6 @@ #include "mpq/extract.h" +#include #include #include #include @@ -11,6 +12,10 @@ #include "util/locales.h" #include "util/path.h" +namespace mpqcli { + +namespace fs = std::filesystem; + int ExtractFiles(HANDLE archive, const std::string &output, const std::optional &listfile_name, LCID preferred_locale) { SFileSetLocale(preferred_locale); @@ -116,3 +121,5 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi return 0; } + +} // namespace mpqcli diff --git a/src/mpq/extract.h b/src/mpq/extract.h index 828c092..e69ffb6 100644 --- a/src/mpq/extract.h +++ b/src/mpq/extract.h @@ -6,9 +6,13 @@ #include +namespace mpqcli { + int ExtractFiles(HANDLE archive, const std::string &output, const std::optional &listfile_name, LCID preferred_locale); int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, bool keep_folder_structure, LCID preferred_locale); +} // namespace mpqcli + #endif diff --git a/src/mpq/flags.cpp b/src/mpq/flags.cpp index 5371556..bae82ae 100644 --- a/src/mpq/flags.cpp +++ b/src/mpq/flags.cpp @@ -5,6 +5,8 @@ #include +namespace mpqcli { + std::string GetFlagString(uint32_t flags) { std::string result; @@ -27,3 +29,5 @@ std::string GetFlagString(uint32_t flags) { return result; } + +} // namespace mpqcli diff --git a/src/mpq/flags.h b/src/mpq/flags.h index 83c044e..e092df6 100644 --- a/src/mpq/flags.h +++ b/src/mpq/flags.h @@ -4,6 +4,10 @@ #include #include +namespace mpqcli { + std::string GetFlagString(uint32_t flags); +} // namespace mpqcli + #endif diff --git a/src/mpq/info.cpp b/src/mpq/info.cpp index 33faad3..6bd69a9 100644 --- a/src/mpq/info.cpp +++ b/src/mpq/info.cpp @@ -11,6 +11,8 @@ #include "mpq/query.h" #include "util/format.h" +namespace mpqcli { + void PrintMpqInfo(HANDLE archive, const std::optional &info_property) { // Map of property names to their corresponding actions std::map> property_actions = { @@ -91,3 +93,5 @@ void PrintMpqInfo(HANDLE archive, const std::optional &info_propert } } } + +} // namespace mpqcli diff --git a/src/mpq/info.h b/src/mpq/info.h index b6a7bb3..2f9292f 100644 --- a/src/mpq/info.h +++ b/src/mpq/info.h @@ -6,6 +6,10 @@ #include +namespace mpqcli { + void PrintMpqInfo(HANDLE archive, const std::optional &info_property); +} // namespace mpqcli + #endif diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp index 935cca8..34d9cdf 100644 --- a/src/mpq/list.cpp +++ b/src/mpq/list.cpp @@ -16,6 +16,8 @@ #include "util/format.h" #include "util/locales.h" +namespace mpqcli { + int ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, bool list_detailed, const std::vector &properties) { // Check if the user provided a listfile input @@ -162,3 +164,5 @@ int ListFiles(HANDLE archive, const std::optional &listfile_name, b SFileFindClose(find_handle); return 0; } + +} // namespace mpqcli diff --git a/src/mpq/list.h b/src/mpq/list.h index 267160e..191005c 100644 --- a/src/mpq/list.h +++ b/src/mpq/list.h @@ -7,7 +7,11 @@ #include +namespace mpqcli { + int ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, bool list_detailed, const std::vector &properties); +} // namespace mpqcli + #endif diff --git a/src/mpq/query.cpp b/src/mpq/query.cpp index d655fac..7f31765 100644 --- a/src/mpq/query.cpp +++ b/src/mpq/query.cpp @@ -11,6 +11,10 @@ #include "util/locales.h" #include "util/path.h" +namespace mpqcli { + +namespace fs = std::filesystem; + bool FileExistsInArchiveForLocale(const HANDLE archive, const std::string &file_path, const LCID locale) { bool file_exists = false; @@ -85,3 +89,5 @@ bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file // No attributes present, or none matched: re-add the file. return false; } + +} // namespace mpqcli diff --git a/src/mpq/query.h b/src/mpq/query.h index 6d25a22..5cc91dd 100644 --- a/src/mpq/query.h +++ b/src/mpq/query.h @@ -7,7 +7,7 @@ #include -namespace fs = std::filesystem; +namespace mpqcli { /// Files the archive maintains for itself rather than on a user's behalf inline const std::vector special_mpq_files = {"(listfile)", "(signature)", @@ -16,7 +16,7 @@ inline const std::vector special_mpq_files = {"(listfile)", "(signa bool FileExistsInArchiveForLocale(HANDLE archive, const std::string &file_path, LCID locale); /// Reports whether the archived copy of local_file is byte-for-byte current -bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file, +bool ArchivedFileMatches(HANDLE archive, HANDLE file, const std::filesystem::path &local_file, std::string &match_reason); template T GetFileInfo(HANDLE file, SFileInfoClass info_class) { @@ -27,4 +27,6 @@ template T GetFileInfo(HANDLE file, SFileInfoClass info_class) { return value; } +} // namespace mpqcli + #endif diff --git a/src/mpq/read.cpp b/src/mpq/read.cpp index e790ac1..f7f397f 100644 --- a/src/mpq/read.cpp +++ b/src/mpq/read.cpp @@ -9,6 +9,8 @@ #include "util/format.h" #include "util/locales.h" +namespace mpqcli { + std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, LCID preferred_locale) { SFileSetLocale(preferred_locale); @@ -44,3 +46,5 @@ std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned SFileCloseFile(file); return file_content; } + +} // namespace mpqcli diff --git a/src/mpq/read.h b/src/mpq/read.h index 1f29900..3360ae8 100644 --- a/src/mpq/read.h +++ b/src/mpq/read.h @@ -5,7 +5,11 @@ #include +namespace mpqcli { + std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, LCID preferred_locale); +} // namespace mpqcli + #endif diff --git a/src/mpq/remove.cpp b/src/mpq/remove.cpp index 4a4f9f3..f7c9091 100644 --- a/src/mpq/remove.cpp +++ b/src/mpq/remove.cpp @@ -9,6 +9,8 @@ #include "util/format.h" #include "util/locales.h" +namespace mpqcli { + int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale) { SFileSetLocale(locale); std::cout << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") << ": " @@ -30,3 +32,5 @@ int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale return 0; } + +} // namespace mpqcli diff --git a/src/mpq/remove.h b/src/mpq/remove.h index 2109e4c..a39685a 100644 --- a/src/mpq/remove.h +++ b/src/mpq/remove.h @@ -5,6 +5,10 @@ #include +namespace mpqcli { + int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale); +} // namespace mpqcli + #endif diff --git a/src/mpq/rename.cpp b/src/mpq/rename.cpp index 86b861f..1ba7a17 100644 --- a/src/mpq/rename.cpp +++ b/src/mpq/rename.cpp @@ -10,6 +10,8 @@ #include "util/locales.h" #include "util/path.h" +namespace mpqcli { + int RenameFile(HANDLE archive, const std::string &old_archive_file_path, const std::string &new_archive_file_path, LCID locale) { SFileSetLocale(locale); @@ -33,3 +35,5 @@ int RenameFile(HANDLE archive, const std::string &old_archive_file_path, return 0; } + +} // namespace mpqcli diff --git a/src/mpq/rename.h b/src/mpq/rename.h index c3fb23d..8d01b3f 100644 --- a/src/mpq/rename.h +++ b/src/mpq/rename.h @@ -5,7 +5,11 @@ #include +namespace mpqcli { + int RenameFile(HANDLE archive, const std::string &old_archive_file_path, const std::string &new_archive_file_path, LCID locale); +} // namespace mpqcli + #endif diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp index 38fd26d..abdbc62 100644 --- a/src/mpq/verify.cpp +++ b/src/mpq/verify.cpp @@ -13,6 +13,10 @@ #include "util/format.h" #include "util/locales.h" +namespace mpqcli { + +namespace fs = std::filesystem; + uint32_t VerifyMpqArchive(HANDLE archive) { return SFileVerifyArchive(archive); } @@ -71,3 +75,5 @@ int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { return 0; } + +} // namespace mpqcli diff --git a/src/mpq/verify.h b/src/mpq/verify.h index c4d4259..b26b9f6 100644 --- a/src/mpq/verify.h +++ b/src/mpq/verify.h @@ -6,7 +6,11 @@ #include +namespace mpqcli { + uint32_t VerifyMpqArchive(HANDLE archive); int32_t PrintMpqSignature(HANDLE archive, const std::string &target); +} // namespace mpqcli + #endif diff --git a/src/util/capacity.cpp b/src/util/capacity.cpp index 256cfac..3e1b8cd 100644 --- a/src/util/capacity.cpp +++ b/src/util/capacity.cpp @@ -2,6 +2,8 @@ #include +namespace mpqcli { + uint32_t NextPowerOfTwo(uint32_t n) { n--; n |= n >> 1; @@ -27,3 +29,5 @@ uint32_t CalculateMpqMaxFileValue(uint32_t file_count) { return NextPowerOfTwo(file_count); } + +} // namespace mpqcli diff --git a/src/util/capacity.h b/src/util/capacity.h index 8af7aec..8360174 100644 --- a/src/util/capacity.h +++ b/src/util/capacity.h @@ -3,10 +3,14 @@ #include +namespace mpqcli { + /// Rounds n up to the next power of two uint32_t NextPowerOfTwo(uint32_t n); /// Returns the max file count an archive holding file_count files should declare uint32_t CalculateMpqMaxFileValue(uint32_t file_count); +} // namespace mpqcli + #endif diff --git a/src/util/format.cpp b/src/util/format.cpp index 2867d22..a4a2812 100644 --- a/src/util/format.cpp +++ b/src/util/format.cpp @@ -12,6 +12,8 @@ #include +namespace mpqcli { + std::string FileTimeToLsTime(int64_t file_time) { if (file_time == 0) { return ""; @@ -72,3 +74,5 @@ void PrintAsBinary(const char *buffer, uint32_t size) { #endif std::cout.write(buffer, size); } + +} // namespace mpqcli diff --git a/src/util/format.h b/src/util/format.h index f65f934..d322bb0 100644 --- a/src/util/format.h +++ b/src/util/format.h @@ -4,10 +4,14 @@ #include #include +namespace mpqcli { + std::string FileTimeToLsTime(int64_t file_time); std::string StormErrorString(uint32_t err); /// Writes size bytes of buffer to stdout without line-ending translation void PrintAsBinary(const char *buffer, uint32_t size); +} // namespace mpqcli + #endif diff --git a/src/util/hash.cpp b/src/util/hash.cpp index 9d16b1e..1e14704 100644 --- a/src/util/hash.cpp +++ b/src/util/hash.cpp @@ -9,6 +9,8 @@ namespace fs = std::filesystem; +namespace mpqcli { + // CRC32 (ZIP/gzip polynomial) and MD5 are provided by the hash-library // submodule, matching the values StormLib stores in (attributes). std::optional ComputeFileCrc32(const fs::path &path) { @@ -41,3 +43,5 @@ bool ComputeFileMd5(const fs::path &path, uint8_t *md5_out) { md5.getHash(md5_out); return true; } + +} // namespace mpqcli diff --git a/src/util/hash.h b/src/util/hash.h index d9a649b..ba0b522 100644 --- a/src/util/hash.h +++ b/src/util/hash.h @@ -5,15 +5,17 @@ #include #include -namespace fs = std::filesystem; +namespace mpqcli { // CRC32 (ZIP/gzip polynomial) and MD5 match the values StormLib stores in // (attributes), which is what lets the --update check compare them directly. /// Returns the CRC32 of path, or nullopt if it cannot be read -std::optional ComputeFileCrc32(const fs::path &path); +std::optional ComputeFileCrc32(const std::filesystem::path &path); /// Writes the MD5 of path to md5_out, returning false if it cannot be read -bool ComputeFileMd5(const fs::path &path, uint8_t *md5_out); +bool ComputeFileMd5(const std::filesystem::path &path, uint8_t *md5_out); + +} // namespace mpqcli #endif diff --git a/src/util/locales.cpp b/src/util/locales.cpp index 257c221..cf15570 100644 --- a/src/util/locales.cpp +++ b/src/util/locales.cpp @@ -7,6 +7,8 @@ #include #include +namespace mpqcli { + namespace { // Files in MPQs have locales with which they are associated. // Multiple files can have the same file name if they have different locales. @@ -119,3 +121,5 @@ std::string PrettyPrintLocale(const LCID locale, const std::string &prefix, bool const auto lang = LocaleToLang(locale); return prefix + lang; } + +} // namespace mpqcli diff --git a/src/util/locales.h b/src/util/locales.h index ff07736..04c2660 100644 --- a/src/util/locales.h +++ b/src/util/locales.h @@ -7,6 +7,8 @@ #include +namespace mpqcli { + inline constexpr LCID default_locale = 0; std::string LocaleToLang(uint16_t locale); @@ -16,4 +18,6 @@ std::vector GetAllLocales(); std::string PrettyPrintLocale(LCID locale, const std::string &prefix = "", bool always_print = false); +} // namespace mpqcli + #endif // LOCALES_H diff --git a/src/util/path.cpp b/src/util/path.cpp index 99b0e99..d4a0cc0 100644 --- a/src/util/path.cpp +++ b/src/util/path.cpp @@ -9,6 +9,8 @@ namespace fs = std::filesystem; +namespace mpqcli { + std::string NormalizeFilePath(const fs::path &path) { std::string file_path = path.u8string(); #ifndef _WIN32 @@ -81,3 +83,5 @@ std::string ResolveArchiveName(const std::string &f, const std::optional #include -namespace fs = std::filesystem; +namespace mpqcli { -std::string NormalizeFilePath(const fs::path &path); -std::string WindowsifyFilePath(const fs::path &path); +std::string NormalizeFilePath(const std::filesystem::path &path); +std::string WindowsifyFilePath(const std::filesystem::path &path); /// Reports whether path resolves to a location inside base -bool IsWithinDirectory(const fs::path &base, const fs::path &path); +bool IsWithinDirectory(const std::filesystem::path &base, const std::filesystem::path &path); /// Builds the in-archive name for a local file std::string ResolveArchiveName(const std::string &f, const std::optional &path, bool treat_as_directory = false); -std::vector ListFilesRecursive(const fs::path &directory, std::error_code &ec); +std::vector ListFilesRecursive(const std::filesystem::path &directory, + std::error_code &ec); /// Returns the file's last-modification time as a Windows FILETIME value /// (100-nanosecond intervals since 1601-01-01 UTC), or 0 if it cannot be read -uint64_t LocalFileTimestamp(const fs::path &path); +uint64_t LocalFileTimestamp(const std::filesystem::path &path); + +} // namespace mpqcli #endif diff --git a/src/util/string.cpp b/src/util/string.cpp index 4603bca..1276ede 100644 --- a/src/util/string.cpp +++ b/src/util/string.cpp @@ -4,9 +4,13 @@ #include #include +namespace mpqcli { + std::string ToLower(const std::string &str) { std::string result = str; std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return std::tolower(c); }); return result; } + +} // namespace mpqcli diff --git a/src/util/string.h b/src/util/string.h index 5ce1bf4..fb76ab5 100644 --- a/src/util/string.h +++ b/src/util/string.h @@ -3,7 +3,11 @@ #include +namespace mpqcli { + /// Returns str with every ASCII letter lowercased std::string ToLower(const std::string &str); +} // namespace mpqcli + #endif From 3000524557b08cb7eaaf2c137303643daccb4a1c Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:58:57 +1200 Subject: [PATCH 10/59] Fixed lint globs and CI path filters after the restructure --- .github/workflows/main.yml | 3 +++ .github/workflows/pr.yml | 3 +++ CONTRIBUTING.md | 4 ++-- Makefile | 6 +++--- docs/contributing.md | 4 ++-- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 64e43bd..f3a233c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,6 +10,9 @@ on: - ".clang-format" - ".clang-tidy" - "src/**" + - "app/**" + - "cmake/**" + - "completion/**" - "extern/**" - "test/**" - "CMakeLists.txt" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index cdc8167..d9805d2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -7,6 +7,9 @@ on: - ".clang-format" - ".clang-tidy" - "src/**" + - "app/**" + - "cmake/**" + - "completion/**" - "extern/**" - "test/**" - "CMakeLists.txt" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8c5810..5aaa9fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,12 +119,12 @@ if (flags & MPQ_FILE_COMPRESS) result += 'c'; ### StormLib locale state is global and not thread-safe -`SFileSetLocale` sets a process-wide locale variable (`g_lcFileLocale`) inside StormLib. All locale-sensitive operations in `mpq.cpp` - file open, add, remove, read, extract, and list - call `SFileSetLocale` immediately before the relevant StormLib call. There is no locale-explicit alternative in StormLib's public API (`SFileOpenFileEx`, `SFileAddFileEx`, etc. all read `g_lcFileLocale` internally). +`SFileSetLocale` sets a process-wide locale variable (`g_lcFileLocale`) inside StormLib. All locale-sensitive operations in `src/mpq/` - file open, add, remove, read, extract, and list - call `SFileSetLocale` immediately before the relevant StormLib call. There is no locale-explicit alternative in StormLib's public API (`SFileOpenFileEx`, `SFileAddFileEx`, etc. all read `g_lcFileLocale` internally). This means: - The `SFileSetLocale` + StormLib-call sequence is **not atomic** and would be unsafe under concurrency -- mpqcli is intentionally **single-threaded**; do not introduce threads or async I/O without auditing every locale-sensitive call site in `mpq.cpp` +- mpqcli is intentionally **single-threaded**; do not introduce threads or async I/O without auditing every locale-sensitive call site in `src/mpq/` If you add a new StormLib call that is locale-sensitive, follow the existing pattern: call `SFileSetLocale` immediately before it, with no intervening calls between the two. diff --git a/Makefile b/Makefile index 170445a..fb176cf 100644 --- a/Makefile +++ b/Makefile @@ -111,18 +111,18 @@ test_lint: ## Run ruff linter on test directory # LINT .PHONY: fmt_check fmt_check: ## Check C++ formatting with clang-format - find src \( -name "*.cpp" -o -name "*.h" \) \ + find src app \( -name "*.cpp" -o -name "*.h" \) \ | xargs clang-format-$(CLANG_VERSION) --dry-run --Werror .PHONY: fmt fmt: ## Auto-fix C++ formatting with clang-format - find src \( -name "*.cpp" -o -name "*.h" \) \ + find src app \( -name "*.cpp" -o -name "*.h" \) \ | xargs clang-format-$(CLANG_VERSION) -i .PHONY: lint_cpp lint_cpp: ## Run clang-tidy static analysis (requires: make configure) clang-tidy-$(CLANG_VERSION) --quiet -p build \ - --header-filter="$(CURDIR)/src/.*" src/*.cpp 2>&1 \ + --header-filter="$(CURDIR)/(src|app)/.*" $$(find src app -name "*.cpp") 2>&1 \ | grep -v " warnings generated"; \ exit $${PIPESTATUS[0]} diff --git a/docs/contributing.md b/docs/contributing.md index e8c5810..5aaa9fd 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -119,12 +119,12 @@ if (flags & MPQ_FILE_COMPRESS) result += 'c'; ### StormLib locale state is global and not thread-safe -`SFileSetLocale` sets a process-wide locale variable (`g_lcFileLocale`) inside StormLib. All locale-sensitive operations in `mpq.cpp` - file open, add, remove, read, extract, and list - call `SFileSetLocale` immediately before the relevant StormLib call. There is no locale-explicit alternative in StormLib's public API (`SFileOpenFileEx`, `SFileAddFileEx`, etc. all read `g_lcFileLocale` internally). +`SFileSetLocale` sets a process-wide locale variable (`g_lcFileLocale`) inside StormLib. All locale-sensitive operations in `src/mpq/` - file open, add, remove, read, extract, and list - call `SFileSetLocale` immediately before the relevant StormLib call. There is no locale-explicit alternative in StormLib's public API (`SFileOpenFileEx`, `SFileAddFileEx`, etc. all read `g_lcFileLocale` internally). This means: - The `SFileSetLocale` + StormLib-call sequence is **not atomic** and would be unsafe under concurrency -- mpqcli is intentionally **single-threaded**; do not introduce threads or async I/O without auditing every locale-sensitive call site in `mpq.cpp` +- mpqcli is intentionally **single-threaded**; do not introduce threads or async I/O without auditing every locale-sensitive call site in `src/mpq/` If you add a new StormLib call that is locale-sensitive, follow the existing pattern: call `SFileSetLocale` immediately before it, with no intervening calls between the two. From 0619b9ee59c32393c7124af3844ddaa24dccd8a0 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:59:03 +1200 Subject: [PATCH 11/59] Renamed Makefile targets to name the action they perform --- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 33 ++++++++++++++++----------------- Makefile | 29 +++++++++++++---------------- docs/contributing.md | 33 ++++++++++++++++----------------- 5 files changed, 48 insertions(+), 53 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 68db4c7..a8be08e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,6 +16,6 @@ jobs: - run: make configure - - run: make fmt_check + - run: make check_format - - run: make lint_cpp + - run: make check_lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 403c985..d8088bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Resolve release version id: version - run: echo "version=$(make get_project_version)" >> "$GITHUB_OUTPUT" + run: echo "version=$(make get_version)" >> "$GITHUB_OUTPUT" - name: Download Linux GLIBC binary uses: actions/download-artifact@v8 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5aaa9fd..888e0b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,20 +28,19 @@ make install_clang_tools Run `make help` to list all available targets. Common ones: -| Target | Description | -|----------------------------|--------------------------------------------------------------------| -| `make install_clang_tools` | Install clang-format and clang-tidy via apt | -| `make configure` | Configure cmake build with clang (required before `make lint`) | -| `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 fmt_check` | Check formatting only (dry run) | -| `make fmt` | Auto-fix formatting in-place | -| `make lint_cpp` | Run clang-tidy static analysis | -| `make clean` | Remove all build and test artifacts | +| Target | Description | +|----------------------------|----------------------------------------------------------------------| +| `make install_clang_tools` | Install clang-format and clang-tidy via apt | +| `make configure` | Configure cmake build with clang (required before `make check_lint`) | +| `make build_linux` | Build for Linux using cmake | +| `make build_windows` | Build for Windows using cmake | +| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | +| `make test_mpqcli` | Run the pytest test suite | +| `make check_all` | Run every static check (clang-format + clang-tidy) | +| `make check_format` | Check formatting only (dry run) | +| `make format` | Auto-fix formatting in-place | +| `make check_lint` | Run clang-tidy static analysis | +| `make clean` | Remove all build, test and docs artifacts | ## Requirements for a Pull Request @@ -83,10 +82,10 @@ make lint If there are formatting violations, auto-fix them with: ``` -make fmt +make format ``` -Then re-run `make lint` to confirm everything passes. +Then re-run `make check_all` to confirm everything passes. ### 5. Match the existing code style @@ -134,6 +133,6 @@ If you add a new StormLib call that is locale-sensitive, follow the existing pat 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 configure` and then `make lint`, fixing any issues +5. Run `make configure` and then `make check_all`, fixing 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/Makefile b/Makefile index fb176cf..ed7d6c7 100644 --- a/Makefile +++ b/Makefile @@ -46,10 +46,6 @@ build_windows: ## Build for Windows using cmake -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) cmake --build build --config $(CMAKE_BUILD_TYPE) -.PHONY: build_clean -build_clean: ## Remove cmake build directory - rm -rf build - # DOCKER .PHONY: docker_musl_build docker_musl_build: ## Build Docker image using musl @@ -109,36 +105,37 @@ test_lint: ## Run ruff linter on test directory ruff check ./test # LINT -.PHONY: fmt_check -fmt_check: ## Check C++ formatting with clang-format +.PHONY: check_format +check_format: ## Check C++ formatting with clang-format find src app \( -name "*.cpp" -o -name "*.h" \) \ | xargs clang-format-$(CLANG_VERSION) --dry-run --Werror -.PHONY: fmt -fmt: ## Auto-fix C++ formatting with clang-format +.PHONY: format +format: ## Auto-fix C++ formatting with clang-format find src app \( -name "*.cpp" -o -name "*.h" \) \ | xargs clang-format-$(CLANG_VERSION) -i -.PHONY: lint_cpp -lint_cpp: ## Run clang-tidy static analysis (requires: make configure) +.PHONY: check_lint +check_lint: ## Run clang-tidy static analysis (requires: make configure) clang-tidy-$(CLANG_VERSION) --quiet -p build \ --header-filter="$(CURDIR)/(src|app)/.*" $$(find src app -name "*.cpp") 2>&1 \ | grep -v " warnings generated"; \ exit $${PIPESTATUS[0]} -.PHONY: lint -lint: fmt_check lint_cpp ## Run all C++ linters +.PHONY: check_all +check_all: check_format check_lint ## Run every static check .PHONY: ci -ci: configure build fmt_check lint_cpp test ## Run all CI checks locally +ci: configure build check_all test ## Run all CI checks locally # CLEAN .PHONY: clean -clean: build_clean test_clean docs_clean ## Remove all build, test, and docs artifacts +clean: test_clean docs_clean ## Remove all build, test, and docs artifacts + rm -rf build # GET -.PHONY: get_project_version -get_project_version: ## Print the project version from CMakeLists.txt +.PHONY: get_version +get_version: ## Print the project version from CMakeLists.txt @grep -oE 'VERSION [0-9]+\.[0-9]+\.[0-9]+' CMakeLists.txt | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' .PHONY: get_changelog diff --git a/docs/contributing.md b/docs/contributing.md index 5aaa9fd..888e0b9 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -28,20 +28,19 @@ make install_clang_tools Run `make help` to list all available targets. Common ones: -| Target | Description | -|----------------------------|--------------------------------------------------------------------| -| `make install_clang_tools` | Install clang-format and clang-tidy via apt | -| `make configure` | Configure cmake build with clang (required before `make lint`) | -| `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 fmt_check` | Check formatting only (dry run) | -| `make fmt` | Auto-fix formatting in-place | -| `make lint_cpp` | Run clang-tidy static analysis | -| `make clean` | Remove all build and test artifacts | +| Target | Description | +|----------------------------|----------------------------------------------------------------------| +| `make install_clang_tools` | Install clang-format and clang-tidy via apt | +| `make configure` | Configure cmake build with clang (required before `make check_lint`) | +| `make build_linux` | Build for Linux using cmake | +| `make build_windows` | Build for Windows using cmake | +| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | +| `make test_mpqcli` | Run the pytest test suite | +| `make check_all` | Run every static check (clang-format + clang-tidy) | +| `make check_format` | Check formatting only (dry run) | +| `make format` | Auto-fix formatting in-place | +| `make check_lint` | Run clang-tidy static analysis | +| `make clean` | Remove all build, test and docs artifacts | ## Requirements for a Pull Request @@ -83,10 +82,10 @@ make lint If there are formatting violations, auto-fix them with: ``` -make fmt +make format ``` -Then re-run `make lint` to confirm everything passes. +Then re-run `make check_all` to confirm everything passes. ### 5. Match the existing code style @@ -134,6 +133,6 @@ If you add a new StormLib call that is locale-sensitive, follow the existing pat 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 configure` and then `make lint`, fixing any issues +5. Run `make configure` and then `make check_all`, fixing 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 From 2773905124bff82f70f83201e53499058044da7f Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 17 Sep 2026 17:59:14 +1200 Subject: [PATCH 12/59] Added directory variables, section markers and a lint configure to the Makefile --- .github/workflows/lint.yml | 2 +- .gitignore | 1 + CONTRIBUTING.md | 3 +- Makefile | 78 +++++++++++++++++++++++++------------- docs/contributing.md | 3 +- 5 files changed, 57 insertions(+), 30 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a8be08e..148bbde 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: - run: make install_clang_tools - - run: make configure + - run: make configure_lint - run: make check_format diff --git a/.gitignore b/.gitignore index fe4e3bc..d8eec25 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ __pycache__/ # Distribution / packaging .Python build/ +build-lint/ develop-eggs/ dist/ downloads/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 888e0b9..6cf3193 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ Run `make help` to list all available targets. Common ones: | Target | Description | |----------------------------|----------------------------------------------------------------------| | `make install_clang_tools` | Install clang-format and clang-tidy via apt | -| `make configure` | Configure cmake build with clang (required before `make check_lint`) | +| `make configure` | Configure the cmake build (uses the default compiler) | | `make build_linux` | Build for Linux using cmake | | `make build_windows` | Build for Windows using cmake | | `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | @@ -39,6 +39,7 @@ Run `make help` to list all available targets. Common ones: | `make check_all` | Run every static check (clang-format + clang-tidy) | | `make check_format` | Check formatting only (dry run) | | `make format` | Auto-fix formatting in-place | +| `make configure_lint` | Configure build-lint/ with clang++ for clang-tidy | | `make check_lint` | Run clang-tidy static analysis | | `make clean` | Remove all build, test and docs artifacts | diff --git a/Makefile b/Makefile index ed7d6c7..e86e9fa 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,38 @@ SHELL := /bin/bash +# Project-owned C++ directories, derived rather than hardcoded so that adding +# one does not also require remembering to edit the format and lint targets +CPP_DIRS := $(wildcard src app test) +CPP_LINT_DIRS := $(wildcard src app) + CMAKE_BUILD_TYPE := Release -MPQCLI_BUILD_APP := ON -CLANG_VERSION := 18 +MPQCLI_BUILD_APP ?= ON +JOBS ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) + +# Prefer the versioned tool, fall back to the plain name. Falling back rather +# than failing keeps the error legible: "clang-format: command not found" +# beats a make-level complaint about an empty variable. +CLANG_VERSION ?= 18 +CLANG_FORMAT ?= $(shell command -v clang-format-$(CLANG_VERSION) 2>/dev/null || echo clang-format) +CLANG_TIDY ?= $(shell command -v clang-tidy-$(CLANG_VERSION) 2>/dev/null || echo clang-tidy) + +# clang-tidy resolves headers through the compiler that produced +# compile_commands.json. Pointed at a GCC build it cannot find libstdc++ and +# emits confident diagnostics from a broken AST, so it gets its own tree. +LINT_DIR ?= build-lint +GCC_INSTALL_DIR := $(shell dirname "$(shell gcc -print-libgcc-file-name)") + 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 -GCC_INSTALL_DIR := $(shell dirname "$(shell gcc -print-libgcc-file-name)") TAG ?= $(shell git describe --tags --abbrev=0 2>/dev/null) .PHONY: help help: ## Show this help message - @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ - | awk 'BEGIN {FS = ":.*?## "}; {printf " %-22s %s\n", $$1, $$2}' + @awk 'BEGIN {FS = ":.*?## "} /^##@ / {printf "\n%s\n", substr($$0, 5)} \ + /^[a-zA-Z_-]+:.*## / {printf " %-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) -# BUILD +##@ BUILD .PHONY: install_clang_tools install_clang_tools: ## Install clang lint dependencies sudo apt-get install -y clang-format-$(CLANG_VERSION) clang-tidy-$(CLANG_VERSION) @@ -24,29 +42,27 @@ configure: ## Configure cmake build (debug, with compile_commands.json) cmake -B build \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) \ - -DCMAKE_CXX_COMPILER=clang++-$(CLANG_VERSION) \ - -DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)" + -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) .PHONY: build build: ## Build via cmake - cmake --build build + cmake --build build --parallel $(JOBS) .PHONY: build_linux build_linux: ## Build for Linux using cmake cmake -B build \ -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) - cmake --build build + cmake --build build --parallel $(JOBS) .PHONY: build_windows build_windows: ## Build for Windows using cmake cmake -B build \ -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) - cmake --build build --config $(CMAKE_BUILD_TYPE) + cmake --build build --config $(CMAKE_BUILD_TYPE) --parallel $(JOBS) -# DOCKER +##@ DOCKER .PHONY: docker_musl_build docker_musl_build: ## Build Docker image using musl docker build -t mpqcli:$(VERSION) -f Dockerfile.musl . @@ -63,7 +79,7 @@ docker_glibc_build: ## Build Docker image using glibc docker_glibc_run: ## Run the glibc Docker image @docker run -it mpqcli:$(VERSION) version -# DOCS +##@ DOCS .PHONY: docs_mermaid docs_mermaid: ## Fetch mermaid.min.js and mermaid-init.js into the repo root (gitignored) mdbook-mermaid install . @@ -80,7 +96,7 @@ docs_serve: docs_mermaid ## Serve the docs locally with live reload docs_clean: ## Remove the generated docs site and mermaid assets rm -rf book mermaid.min.js mermaid-init.js -# TEST +##@ TEST .PHONY: test test: build test_mpqcli ## Run test suite (builds binary first) @@ -104,21 +120,29 @@ test_lint: ## Run ruff linter on test directory . ./.venv/bin/activate && \ ruff check ./test -# LINT +##@ LINT .PHONY: check_format check_format: ## Check C++ formatting with clang-format - find src app \( -name "*.cpp" -o -name "*.h" \) \ - | xargs clang-format-$(CLANG_VERSION) --dry-run --Werror + find $(CPP_DIRS) \( -name "*.cpp" -o -name "*.h" \) \ + | xargs $(CLANG_FORMAT) --dry-run --Werror .PHONY: format format: ## Auto-fix C++ formatting with clang-format - find src app \( -name "*.cpp" -o -name "*.h" \) \ - | xargs clang-format-$(CLANG_VERSION) -i + find $(CPP_DIRS) \( -name "*.cpp" -o -name "*.h" \) \ + | xargs $(CLANG_FORMAT) -i + +.PHONY: configure_lint +configure_lint: ## Configure $(LINT_DIR) with clang++, so clang-tidy can parse the sources + cmake -B $(LINT_DIR) \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DCMAKE_CXX_COMPILER=clang++-$(CLANG_VERSION) \ + -DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)" .PHONY: check_lint -check_lint: ## Run clang-tidy static analysis (requires: make configure) - clang-tidy-$(CLANG_VERSION) --quiet -p build \ - --header-filter="$(CURDIR)/(src|app)/.*" $$(find src app -name "*.cpp") 2>&1 \ +check_lint: configure_lint ## Run clang-tidy static analysis + $(CLANG_TIDY) --quiet -p $(LINT_DIR) \ + --header-filter="$(CURDIR)/(src|app)/.*" $$(find $(CPP_LINT_DIRS) -name "*.cpp") 2>&1 \ | grep -v " warnings generated"; \ exit $${PIPESTATUS[0]} @@ -128,12 +152,12 @@ check_all: check_format check_lint ## Run every static check .PHONY: ci ci: configure build check_all test ## Run all CI checks locally -# CLEAN +##@ CLEAN .PHONY: clean clean: test_clean docs_clean ## Remove all build, test, and docs artifacts - rm -rf build + rm -rf build $(LINT_DIR) -# GET +##@ GET .PHONY: get_version get_version: ## Print the project version from CMakeLists.txt @grep -oE 'VERSION [0-9]+\.[0-9]+\.[0-9]+' CMakeLists.txt | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' @@ -153,7 +177,7 @@ get_changelog: ## Print release notes for TAG to stdout (default: latest tag; ov fi; \ echo "$$notes" -# RELEASE +##@ RELEASE .PHONY: fetch_downloads fetch_downloads: ## Fetch package downloads and update README.md badge @DOWNLOADS=$$(curl -s "$(PACKAGE_URL)" \ diff --git a/docs/contributing.md b/docs/contributing.md index 888e0b9..6cf3193 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -31,7 +31,7 @@ Run `make help` to list all available targets. Common ones: | Target | Description | |----------------------------|----------------------------------------------------------------------| | `make install_clang_tools` | Install clang-format and clang-tidy via apt | -| `make configure` | Configure cmake build with clang (required before `make check_lint`) | +| `make configure` | Configure the cmake build (uses the default compiler) | | `make build_linux` | Build for Linux using cmake | | `make build_windows` | Build for Windows using cmake | | `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | @@ -39,6 +39,7 @@ Run `make help` to list all available targets. Common ones: | `make check_all` | Run every static check (clang-format + clang-tidy) | | `make check_format` | Check formatting only (dry run) | | `make format` | Auto-fix formatting in-place | +| `make configure_lint` | Configure build-lint/ with clang++ for clang-tidy | | `make check_lint` | Run clang-tidy static analysis | | `make clean` | Remove all build, test and docs artifacts | From 060caf99b479f2c053ab69165f6998ec8de821f1 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 22 Sep 2026 19:34:26 +1200 Subject: [PATCH 13/59] Made an unknown subcommand fail rather than print help and exit zero --- app/main.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/main.cpp b/app/main.cpp index 5481d71..167d634 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -272,8 +272,9 @@ int main(int argc, char **argv) { try { app.parse(argc, argv); } catch (const CLI::ParseError &e) { - // If we get a "subcommand required" error, print help message - if (e.get_exit_code() == static_cast(CLI::ExitCodes::RequiredError)) { + // An empty command line is a request for help rather than a mistake. The argc + // check is what keeps an unknown subcommand, which raises the same error, failing + if (argc == 1 && e.get_exit_code() == static_cast(CLI::ExitCodes::RequiredError)) { std::cout << app.help() << std::endl; return 0; } From d1b19541a599fa8f72d9455a95c6349dd6f9b398 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 22 Sep 2026 19:37:09 +1200 Subject: [PATCH 14/59] Moved the version header into the core and made it constants rather than macros --- app/CMakeLists.txt | 6 ------ app/about.cpp | 3 ++- app/version.cpp | 2 +- cmake/version.h.in | 22 ++++++++++++++++++++-- src/CMakeLists.txt | 8 ++++++++ 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index c607f57..f9159e2 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -1,9 +1,3 @@ -configure_file( - "${PROJECT_SOURCE_DIR}/cmake/version.h.in" - "${PROJECT_BINARY_DIR}/include/mpqcli/version.h" - @ONLY -) - # Re-run configure when a script is edited, so the embedded copy cannot go stale set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${PROJECT_SOURCE_DIR}/completion/mpqcli.bash" diff --git a/app/about.cpp b/app/about.cpp index 29db4db..3b18963 100644 --- a/app/about.cpp +++ b/app/about.cpp @@ -5,7 +5,8 @@ int HandleAbout() { std::cout << "Name: mpqcli" << std::endl; - std::cout << "Version: " << MPQCLI_VERSION << "-" << GIT_COMMIT_HASH << std::endl; + std::cout << "Version: " << mpqcli::version_string << "-" << mpqcli::git_commit_hash + << std::endl; std::cout << "Author: Thomas Laurenson" << std::endl; std::cout << "License: MIT" << std::endl; std::cout << "GitHub: https://github.com/thegraydot/mpqcli" << std::endl; diff --git a/app/version.cpp b/app/version.cpp index ef78a0e..ac471b0 100644 --- a/app/version.cpp +++ b/app/version.cpp @@ -4,6 +4,6 @@ #include "commands.h" int HandleVersion() { - std::cout << MPQCLI_VERSION << "-" << GIT_COMMIT_HASH << std::endl; + std::cout << mpqcli::version_string << "-" << mpqcli::git_commit_hash << std::endl; return 0; } diff --git a/cmake/version.h.in b/cmake/version.h.in index b824f5f..e86af37 100644 --- a/cmake/version.h.in +++ b/cmake/version.h.in @@ -1,2 +1,20 @@ -#define MPQCLI_VERSION "@MPQCLI_VERSION@" -#define GIT_COMMIT_HASH "@GIT_COMMIT_HASH@" +#pragma once + +namespace mpqcli { + +/// Major version, from project(... VERSION) in the root CMakeLists.txt +inline constexpr int version_major = @PROJECT_VERSION_MAJOR@; + +/// Minor version +inline constexpr int version_minor = @PROJECT_VERSION_MINOR@; + +/// Patch version +inline constexpr int version_patch = @PROJECT_VERSION_PATCH@; + +/// The version as a dotted string, "0.11.0" +inline constexpr const char *version_string = "@PROJECT_VERSION@"; + +/// The commit the binary was built from, or "unknown" outside a git checkout +inline constexpr const char *git_commit_hash = "@GIT_COMMIT_HASH@"; + +} // namespace mpqcli diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 520e6b7..4939cfe 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,3 +1,10 @@ +# Generated beside the core library so the core and the CLI report the same version +configure_file( + "${PROJECT_SOURCE_DIR}/cmake/version.h.in" + "${PROJECT_BINARY_DIR}/include/mpqcli/version.h" + @ONLY +) + # Internal to this repository, so no include/ and no alias. Keeping the logic in # a library rather than beside main() is what lets test binaries link it; a test # that recompiles the sources instead is a second build that drifts from the @@ -42,6 +49,7 @@ add_dependencies(mpqcli_core storm) # PUBLIC so app/ and the test binaries can include these headers by name target_include_directories(mpqcli_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}" + "${PROJECT_BINARY_DIR}/include" ) target_include_directories(mpqcli_core SYSTEM PUBLIC "${CMAKE_SOURCE_DIR}/extern/StormLib/src" From 85fd39370a7c3cb073985f0444813ee935f1ff2f Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 22 Sep 2026 19:40:52 +1200 Subject: [PATCH 15/59] Replaced the masked dev release delete with an existence check --- .github/workflows/prerelease.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 280aff3..392c4d9 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -39,9 +39,16 @@ jobs: with: name: mpqcli-windows-amd64.exe - - name: Delete existing dev release + - name: Delete any existing dev release run: | - gh release delete dev --yes --cleanup-tag || true + if err=$(gh release view dev 2>&1 >/dev/null); then + gh release delete dev --yes --cleanup-tag + elif grep -qi "release not found" <<<"$err"; then + echo "No existing dev release" + else + echo "::error::could not determine whether a dev release exists: ${err}" + exit 1 + fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 6523b444a704f133f1577c019ab7076657baace5 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 22 Sep 2026 19:44:18 +1200 Subject: [PATCH 16/59] Resolved the release version from the tag and hardened the version lookup --- .github/workflows/release.yml | 11 +++++++++-- Makefile | 9 +++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8088bc..9db5d4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,9 +16,16 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Resolve release version + - name: Resolve release version from the tag id: version - run: echo "version=$(make get_version)" >> "$GITHUB_OUTPUT" + run: | + version="${GITHUB_REF_NAME#v}" + project_version="$(make get_version)" + if [ "$version" != "$project_version" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match the project version ${project_version} in CMakeLists.txt" + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" - name: Download Linux GLIBC binary uses: actions/download-artifact@v8 diff --git a/Makefile b/Makefile index b1cf7ff..54b3c8e 100644 --- a/Makefile +++ b/Makefile @@ -167,8 +167,13 @@ gen_docs_changelog: ## Copy CHANGELOG.md into the docs site ##@ GET .PHONY: get_version -get_version: ## Print the project version from CMakeLists.txt - @grep -oE 'VERSION [0-9]+\.[0-9]+\.[0-9]+' CMakeLists.txt | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' +get_version: ## Print the project version from CMakeLists.txt (fails if absent) + @awk '\ + /cmake_minimum_required/ { next } \ + match($$0, /VERSION[ \t]+[0-9]+\.[0-9]+\.[0-9]+/) { \ + v = substr($$0, RSTART, RLENGTH); sub(/VERSION[ \t]+/, "", v); \ + print v; found = 1; exit } \ + END { if (!found) exit 1 }' CMakeLists.txt .PHONY: get_changelog get_changelog: ## Print release notes for TAG to stdout (default: latest tag; override with TAG=v1.0.0) From be40966713768679bd101eb7c28670e1c1f971f1 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 22 Sep 2026 19:52:07 +1200 Subject: [PATCH 17/59] Replaced the two Dockerfiles with one static musl image, dropping the glibc assets --- .dockerignore | 11 ++++++++- .github/workflows/build.yml | 38 ++++++++------------------------ .github/workflows/main.yml | 4 ++-- .github/workflows/pr.yml | 4 ++-- .github/workflows/prerelease.yml | 24 +++++--------------- .github/workflows/release.yml | 24 +++++--------------- .github/workflows/test.yml | 18 +++++---------- CONTRIBUTING.md | 2 +- Dockerfile | 30 +++++++++++++++++++++++++ Dockerfile.glibc | 30 ------------------------- Dockerfile.musl | 27 ----------------------- Makefile | 18 +++++---------- docs/contributing.md | 2 +- 13 files changed, 77 insertions(+), 155 deletions(-) create mode 100644 Dockerfile delete mode 100644 Dockerfile.glibc delete mode 100644 Dockerfile.musl diff --git a/.dockerignore b/.dockerignore index 796b96d..d7a31ac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,10 @@ -/build +# Local build, docs and test output: large, stale and never read by the image build +build/ +build-lint/ +book/ +.venv/ +test/data/ + +# .git is deliberately not ignored. The root CMakeLists runs git rev-parse to +# stamp the commit into the version header, and without the repository the +# released binary reports "unknown" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ca1603..f74b422 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,30 +10,12 @@ jobs: include: # AMD64 builds on ubuntu-24.04 - arch: amd64 - libc: glibc runner: ubuntu-24.04 - dockerfile: Dockerfile.glibc platform: linux/amd64 - binary_path: /usr/local/bin/mpqcli - - arch: amd64 - libc: musl - runner: ubuntu-24.04 - dockerfile: Dockerfile.musl - platform: linux/amd64 - binary_path: /mpqcli # ARM64 builds on ubuntu-24.04-arm - arch: arm64 - libc: glibc - runner: ubuntu-24.04-arm - dockerfile: Dockerfile.glibc - platform: linux/arm64 - binary_path: /usr/local/bin/mpqcli - - arch: arm64 - libc: musl runner: ubuntu-24.04-arm - dockerfile: Dockerfile.musl platform: linux/arm64 - binary_path: /mpqcli runs-on: ${{ matrix.runner }} steps: @@ -42,29 +24,29 @@ jobs: with: submodules: true - - name: Build Docker image (${{ matrix.arch }} ${{ matrix.libc }}) + - name: Build Docker image (${{ matrix.arch }}) run: | - docker build --platform ${{ matrix.platform }} -t mpqcli-${{ matrix.arch }}-${{ matrix.libc }} -f ${{ matrix.dockerfile }} . + docker build --platform ${{ matrix.platform }} -t mpqcli-${{ matrix.arch }} . - name: Extract binary from Docker image run: | # Create a temporary container from the image - CONTAINER_ID=$(docker create mpqcli-${{ matrix.arch }}-${{ matrix.libc }}) + CONTAINER_ID=$(docker create mpqcli-${{ matrix.arch }}) # Copy the binary from the container - docker cp $CONTAINER_ID:${{ matrix.binary_path }} ./mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} + docker cp $CONTAINER_ID:/mpqcli ./mpqcli-linux-${{ matrix.arch }} # Clean up the container docker rm $CONTAINER_ID - name: Verify binary run: | - file ./mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} - echo "Binary created: mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }}" + file ./mpqcli-linux-${{ matrix.arch }} + echo "Binary created: mpqcli-linux-${{ matrix.arch }}" - name: Upload binary as artifact uses: actions/upload-artifact@v7 with: - name: mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} - path: mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} + name: mpqcli-linux-${{ matrix.arch }} + path: mpqcli-linux-${{ matrix.arch }} retention-days: 1 build_windows: @@ -107,11 +89,9 @@ jobs: with: submodules: true - # For the distributable Docker image we use musl - # This results in a smaller image size - name: Build Docker image (AMD64) run: | - docker build --platform linux/amd64 -t mpqcli -f Dockerfile.musl . + docker build --platform linux/amd64 -t mpqcli . - name: Save Docker image as a tar file run: | diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f3a233c..c43f6cd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,8 +16,8 @@ on: - "extern/**" - "test/**" - "CMakeLists.txt" - - "Dockerfile.glibc" - - "Dockerfile.musl" + - "Dockerfile" + - ".dockerignore" concurrency: group: prerelease diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d9805d2..9fae0f8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -13,8 +13,8 @@ on: - "extern/**" - "test/**" - "CMakeLists.txt" - - "Dockerfile.glibc" - - "Dockerfile.musl" + - "Dockerfile" + - ".dockerignore" permissions: contents: read diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 392c4d9..3ce6025 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -14,25 +14,15 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Download Linux GLIBC binary + - name: Download Linux AMD64 binary uses: actions/download-artifact@v8 with: - name: mpqcli-linux-amd64-glibc + name: mpqcli-linux-amd64 - - name: Download Linux MUSL binary + - name: Download Linux ARM64 binary uses: actions/download-artifact@v8 with: - name: mpqcli-linux-amd64-musl - - - name: Download Linux ARM64 GLIBC binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-arm64-glibc - - - name: Download Linux ARM64 MUSL binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-arm64-musl + name: mpqcli-linux-arm64 - name: Download Windows binary uses: actions/download-artifact@v8 @@ -58,10 +48,8 @@ jobs: --title "Dev (Pre-release)" \ --prerelease \ --notes "**Commit:** ${{ github.sha }}" \ - mpqcli-linux-amd64-glibc \ - mpqcli-linux-amd64-musl \ - mpqcli-linux-arm64-glibc \ - mpqcli-linux-arm64-musl \ + mpqcli-linux-amd64 \ + mpqcli-linux-arm64 \ mpqcli-windows-amd64.exe env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9db5d4a..ea054e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,25 +27,15 @@ jobs: fi echo "version=${version}" >> "$GITHUB_OUTPUT" - - name: Download Linux GLIBC binary + - name: Download Linux AMD64 binary uses: actions/download-artifact@v8 with: - name: mpqcli-linux-amd64-glibc + name: mpqcli-linux-amd64 - - name: Download Linux MUSL binary + - name: Download Linux ARM64 binary uses: actions/download-artifact@v8 with: - name: mpqcli-linux-amd64-musl - - - name: Download Linux ARM64 GLIBC binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-arm64-glibc - - - name: Download Linux ARM64 MUSL binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-arm64-musl + name: mpqcli-linux-arm64 - name: Download Windows binary uses: actions/download-artifact@v8 @@ -66,10 +56,8 @@ jobs: gh release create ${{ github.ref_name }} \ --title "${{ github.ref_name }}" \ --notes "$CHANGELOG_CONTENT" \ - mpqcli-linux-amd64-glibc \ - mpqcli-linux-amd64-musl \ - mpqcli-linux-arm64-glibc \ - mpqcli-linux-arm64-musl \ + mpqcli-linux-amd64 \ + mpqcli-linux-arm64 \ mpqcli-windows-amd64.exe env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ea49e8b..bcd9cf0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,17 +13,9 @@ jobs: include: # AMD64 tests on ubuntu-24.04 - arch: amd64 - libc: glibc - runner: ubuntu-24.04 - - arch: amd64 - libc: musl runner: ubuntu-24.04 # ARM64 tests on ubuntu-24.04-arm - arch: arm64 - libc: glibc - runner: ubuntu-24.04-arm - - arch: arm64 - libc: musl runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} @@ -34,19 +26,19 @@ jobs: with: submodules: false - - name: Download Linux binary (${{ matrix.arch }} ${{ matrix.libc }}) + - name: Download Linux binary (${{ matrix.arch }}) uses: actions/download-artifact@v8 with: - name: mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} + name: mpqcli-linux-${{ matrix.arch }} path: build/bin - name: Rename binary to "mpqcli" - run: mv build/bin/mpqcli-linux-${{ matrix.arch }}-${{ matrix.libc }} build/bin/mpqcli + run: mv build/bin/mpqcli-linux-${{ matrix.arch }} build/bin/mpqcli - name: Make binary executable run: chmod +x ./build/bin/mpqcli - - name: Check mpqcli version (${{ matrix.arch }} ${{ matrix.libc }}) + - name: Check mpqcli version (${{ matrix.arch }}) run: ./build/bin/mpqcli version - name: Setup python @@ -59,7 +51,7 @@ jobs: python -m pip install --upgrade pip pip install -r test/requirements.txt - - name: Run tests (${{ matrix.arch }} ${{ matrix.libc }}) + - name: Run tests (${{ matrix.arch }}) run: | python3 -m pytest test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6cf3193..042ea70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,7 @@ make build_linux # Linux make build_windows # Windows ``` -A PR automatically triggers the CI build workflow, which compiles and tests across all supported Linux targets (AMD64 and ARM64, glibc and musl). You are not expected to reproduce all of those locally. +A PR automatically triggers the CI build workflow, which compiles and tests across all supported Linux targets (AMD64 and ARM64). You are not expected to reproduce all of those locally. ### 2. Tests pass diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..81e7800 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Stage 1: Build +FROM alpine:3.23 AS build + +RUN apk add --no-cache \ + build-base \ + cmake \ + git + +WORKDIR /src +COPY . . + +# MPQCLI_BUILD_STATIC is what makes the scratch stage viable: the binary carries +# musl and libstdc++ with it and needs no loader at runtime +RUN cmake -B build/release \ + -DCMAKE_BUILD_TYPE=Release \ + -DMPQCLI_BUILD_APP=ON \ + -DMPQCLI_BUILD_STATIC=ON \ + && cmake --build build/release --parallel "$(nproc)" \ + && strip build/release/bin/mpqcli + +# Stage 2: Runtime +FROM scratch + +LABEL org.opencontainers.image.source="https://github.com/thegraydot/mpqcli" +LABEL org.opencontainers.image.description="A command-line tool to create, add, remove, list, extract, read, rename, and verify MPQ archives using the StormLib library" +LABEL org.opencontainers.image.licenses="MIT" + +COPY --from=build /src/build/release/bin/mpqcli /mpqcli +USER 1001:1001 +ENTRYPOINT ["/mpqcli"] diff --git a/Dockerfile.glibc b/Dockerfile.glibc deleted file mode 100644 index 165c65a..0000000 --- a/Dockerfile.glibc +++ /dev/null @@ -1,30 +0,0 @@ -# Stage 1: Build -FROM ubuntu:24.04 AS builder - -RUN apt-get update && apt-get install -y \ - build-essential \ - cmake \ - git \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /mpqcli - -COPY . . - -RUN cmake -B build \ - -DCMAKE_BUILD_TYPE=Release \ - -DMPQCLI_BUILD_APP=ON \ - -DMPQCLI_BUILD_STATIC=ON - -RUN cmake --build build - -RUN strip build/bin/mpqcli - -# Stage 2: Create a minimal runtime image -FROM ubuntu:24.04 AS runtime - -COPY --from=builder /mpqcli/build/bin/mpqcli /usr/local/bin/mpqcli - -RUN chmod +x /usr/local/bin/mpqcli - -ENTRYPOINT ["mpqcli"] diff --git a/Dockerfile.musl b/Dockerfile.musl deleted file mode 100644 index 741de7c..0000000 --- a/Dockerfile.musl +++ /dev/null @@ -1,27 +0,0 @@ -# Stage 1: Build -FROM alpine:3.23 AS builder - -RUN apk add --no-cache \ - build-base \ - cmake \ - git - -WORKDIR /mpqcli - -COPY . . - -RUN cmake -B build \ - -DCMAKE_BUILD_TYPE=Release \ - -DMPQCLI_BUILD_APP=ON \ - -DMPQCLI_BUILD_STATIC=ON - -RUN cmake --build build - -RUN strip build/bin/mpqcli - -# Stage 2: Create a minimal runtime image -FROM scratch AS runtime - -COPY --from=builder /mpqcli/build/bin/mpqcli /mpqcli - -ENTRYPOINT ["/mpqcli"] diff --git a/Makefile b/Makefile index 54b3c8e..d60665a 100644 --- a/Makefile +++ b/Makefile @@ -63,20 +63,12 @@ build_windows: ## Build for Windows using cmake cmake --build build --config $(CMAKE_BUILD_TYPE) --parallel $(JOBS) ##@ DOCKER -.PHONY: docker_musl_build -docker_musl_build: ## Build Docker image using musl - docker build -t mpqcli:$(VERSION) -f Dockerfile.musl . +.PHONY: docker_build +docker_build: ## Build the Docker image + docker build -t mpqcli:$(VERSION) . -.PHONY: docker_musl_run -docker_musl_run: ## Run the musl Docker image - @docker run -it mpqcli:$(VERSION) version - -.PHONY: docker_glibc_build -docker_glibc_build: ## Build Docker image using glibc - docker build -t mpqcli:$(VERSION) -f Dockerfile.glibc . - -.PHONY: docker_glibc_run -docker_glibc_run: ## Run the glibc Docker image +.PHONY: docker_run +docker_run: ## Run the Docker image @docker run -it mpqcli:$(VERSION) version ##@ DOCS diff --git a/docs/contributing.md b/docs/contributing.md index 6cf3193..042ea70 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -54,7 +54,7 @@ make build_linux # Linux make build_windows # Windows ``` -A PR automatically triggers the CI build workflow, which compiles and tests across all supported Linux targets (AMD64 and ARM64, glibc and musl). You are not expected to reproduce all of those locally. +A PR automatically triggers the CI build workflow, which compiles and tests across all supported Linux targets (AMD64 and ARM64). You are not expected to reproduce all of those locally. ### 2. Tests pass From b6add4742cbf8ff49a282ae628da52a5cc10af23 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Tue, 22 Sep 2026 19:58:44 +1200 Subject: [PATCH 18/59] Added a warning bar and fixed the conversions it reported --- CMakeLists.txt | 4 ++++ app/CMakeLists.txt | 2 +- src/CMakeLists.txt | 20 ++++++++++++++++++++ src/mpq/add.cpp | 2 +- src/mpq/archive.cpp | 2 +- src/mpq/archive.h | 2 +- src/mpq/list.cpp | 4 ++-- src/mpq/query.cpp | 2 +- src/mpq/verify.cpp | 9 ++++++--- src/util/locales.cpp | 14 +++++++++----- src/util/locales.h | 2 +- 11 files changed, 47 insertions(+), 16 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 64f3b8f..eb073a7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,10 @@ project(MPQCLI VERSION 0.11.0) # carry the project prefix; a bare BUILD_STATIC claims a name anyone might want. option(MPQCLI_BUILD_APP "Build the mpqcli binary" ON) option(MPQCLI_BUILD_STATIC "Build static binary" OFF) +# Off by default, so a new compiler release adding a warning cannot stop a +# developer working. The bar is enforced by check_lint instead: clang-tidy +# replays these flags and .clang-tidy treats every warning as an error +option(MPQCLI_WERROR "Treat compiler warnings as errors" OFF) # Set project defaults set(CMAKE_CXX_STANDARD 17) diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index f9159e2..359d53a 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -27,4 +27,4 @@ add_executable(mpqcli # every generated file in the build tree to #include target_include_directories(mpqcli PRIVATE "${PROJECT_BINARY_DIR}/include") -target_link_libraries(mpqcli PRIVATE mpqcli_core CLI11::CLI11) +target_link_libraries(mpqcli PRIVATE mpqcli_core CLI11::CLI11 mpqcli::warnings) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4939cfe..b12b180 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,3 +1,22 @@ +# The warning bar, defined once and linked privately by every target the project +# owns. -Wsign-conversion is spelled out because clang folds it into -Wconversion +# and GCC does not; left implicit, the two compilers hold different bars for the +# same source. The vendored targets deliberately do not link this +add_library(mpqcli_warnings INTERFACE) +target_compile_options(mpqcli_warnings INTERFACE + $<$:/W4 /permissive-> + $<$>:-Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Wshadow -Wnon-virtual-dtor -Wold-style-cast> +) + +if(MPQCLI_WERROR) + target_compile_options(mpqcli_warnings INTERFACE + $<$:/WX> + $<$>:-Werror> + ) +endif() + +add_library(mpqcli::warnings ALIAS mpqcli_warnings) + # Generated beside the core library so the core and the CLI report the same version configure_file( "${PROJECT_SOURCE_DIR}/cmake/version.h.in" @@ -56,3 +75,4 @@ target_include_directories(mpqcli_core SYSTEM PUBLIC ) target_link_libraries(mpqcli_core PUBLIC storm hash-library) +target_link_libraries(mpqcli_core PRIVATE mpqcli::warnings) diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index 3ff7747..727b770 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -85,7 +85,7 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi SFileSetLocale(locale); HANDLE file; if (SFileOpenFileEx(archive, archive_file_path.c_str(), SFILE_OPEN_FROM_MPQ, &file)) { - const auto file_locale = GetFileInfo(file, SFileInfoLocale); + const auto file_locale = GetFileInfo(file, SFileInfoLocale); if (file_locale == locale) { // --update: leave the archived copy alone while it still matches the local file std::string match_reason; diff --git a/src/mpq/archive.cpp b/src/mpq/archive.cpp index f3e60f3..6a9610d 100644 --- a/src/mpq/archive.cpp +++ b/src/mpq/archive.cpp @@ -10,7 +10,7 @@ namespace mpqcli { -bool OpenMpqArchive(const std::string &filename, HANDLE *archive, int32_t flags) { +bool OpenMpqArchive(const std::string &filename, HANDLE *archive, const DWORD flags) { if (!SFileOpenArchive(filename.c_str(), 0, flags, archive)) { const auto error = SErrGetLastError(); std::cerr << "[!] Failed to open MPQ archive: " << filename << ": (" << error << ") " diff --git a/src/mpq/archive.h b/src/mpq/archive.h index d36515f..d379669 100644 --- a/src/mpq/archive.h +++ b/src/mpq/archive.h @@ -8,7 +8,7 @@ namespace mpqcli { -bool OpenMpqArchive(const std::string &filename, HANDLE *archive, int32_t flags); +bool OpenMpqArchive(const std::string &filename, HANDLE *archive, DWORD flags); bool CloseMpqArchive(HANDLE archive); bool SignMpqArchive(HANDLE archive); diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp index 34d9cdf..ddd7540 100644 --- a/src/mpq/list.cpp +++ b/src/mpq/list.cpp @@ -130,7 +130,7 @@ int ListFiles(HANDLE archive, const std::optional &listfile_name, b << std::dec << " "; } else if (prop == "locale") { std::cout << std::setw(4) - << LocaleToLang(GetFileInfo(file, it->second)) << " "; + << LocaleToLang(GetFileInfo(file, it->second)) << " "; } else if (prop == "byte-offset") { std::cout << std::hex << std::setw(8) << GetFileInfo(file, it->second) << std::dec << " "; @@ -142,7 +142,7 @@ int ListFiles(HANDLE archive, const std::optional &listfile_name, b std::cout << std::setw(8) << GetFileInfo(file, it->second) << " "; } else if (prop == "flags") { std::cout << std::setw(8) - << GetFlagString(GetFileInfo(file, it->second)) << " "; + << GetFlagString(GetFileInfo(file, it->second)) << " "; } else if (prop == "encryption-key" || prop == "encryption-key-raw") { std::cout << std::setfill('0') << std::hex << std::setw(8) << GetFileInfo(file, it->second) << std::setfill(' ') diff --git a/src/mpq/query.cpp b/src/mpq/query.cpp index 7f31765..027db9d 100644 --- a/src/mpq/query.cpp +++ b/src/mpq/query.cpp @@ -21,7 +21,7 @@ bool FileExistsInArchiveForLocale(const HANDLE archive, const std::string &file_ SFileSetLocale(locale); HANDLE file; if (SFileOpenFileEx(archive, file_path.c_str(), SFILE_OPEN_FROM_MPQ, &file)) { - const auto file_locale = GetFileInfo(file, SFileInfoLocale); + const auto file_locale = GetFileInfo(file, SFileInfoLocale); if (file_locale == locale) { file_exists = true; } diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp index abdbc62..f3c25fd 100644 --- a/src/mpq/verify.cpp +++ b/src/mpq/verify.cpp @@ -51,13 +51,15 @@ int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { const fs::path archive_path(target); std::error_code ec; - const std::uintmax_t file_size = fs::file_size(archive_path, ec); + const auto file_size = static_cast(fs::file_size(archive_path, ec)); if (ec) { std::cerr << "[!] Failed to read archive size: (" << ec.value() << ") " << ec.message() << ": " << target << std::endl; return -1; } - int64_t signature_length = file_size - archive_offset - archive_size; + // Signed deliberately: the subtraction can legitimately go negative, and in + // unsigned arithmetic it would wrap to a huge positive and pass the check below + const int64_t signature_length = file_size - archive_offset - archive_size; if (signature_length <= 0) { std::cerr << "[!] Invalid signature length: " << signature_length << std::endl; @@ -67,7 +69,8 @@ int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { std::ifstream file_mpq(archive_path, std::ios::binary); file_mpq.seekg(archive_offset + archive_size, std::ios::beg); signature_content.resize(static_cast(signature_length)); - file_mpq.read(signature_content.data(), signature_content.size()); + file_mpq.read(signature_content.data(), + static_cast(signature_content.size())); file_mpq.close(); PrintAsBinary(signature_content.data(), static_cast(signature_content.size())); diff --git a/src/util/locales.cpp b/src/util/locales.cpp index cf15570..03c1027 100644 --- a/src/util/locales.cpp +++ b/src/util/locales.cpp @@ -19,7 +19,7 @@ namespace { // https://winprotocoldoc.z19.web.core.windows.net/MS-LCID/%5bMS-LCID%5d.pdf // Define a bidirectional map for locale-language mappings -const std::map locale_to_lang_map = { +const std::map locale_to_lang_map = { {0x000, "enUS"}, // Default - English (US) {0x404, "zhTW"}, // Chinese (Taiwan) {0x405, "csCZ"}, // Czech @@ -41,8 +41,8 @@ const std::map locale_to_lang_map = { }; // Create a reverse map for language-to-locale lookups -const std::map lang_to_locale_map = []() { - std::map reverse_map; +const std::map lang_to_locale_map = []() { + std::map reverse_map; for (const auto &[locale, lang] : locale_to_lang_map) { if (locale != default_locale) { // Skip the default locale to avoid duplication reverse_map[lang] = locale; @@ -55,7 +55,11 @@ std::string FormatLocaleAsHex(const LCID locale) { std::stringstream ss; ss << std::hex << std::uppercase << locale; const std::string hex_str = ss.str(); - // Prepend 0s if needed + // Pad to four digits, guarding the subtraction: an LCID wider than 0xFFFF + // formats to more than four characters and would underflow the length + if (hex_str.length() >= 4) { + return hex_str; + } return std::string(4 - hex_str.length(), '0') + hex_str; } } // namespace @@ -82,7 +86,7 @@ LCID ParseHexLocale(const std::string &str) { return locale; } -std::string LocaleToLang(uint16_t locale) { +std::string LocaleToLang(const LCID locale) { auto it = locale_to_lang_map.find(locale); return it != locale_to_lang_map.end() ? it->second : FormatLocaleAsHex(locale); } diff --git a/src/util/locales.h b/src/util/locales.h index 04c2660..df081ba 100644 --- a/src/util/locales.h +++ b/src/util/locales.h @@ -11,7 +11,7 @@ namespace mpqcli { inline constexpr LCID default_locale = 0; -std::string LocaleToLang(uint16_t locale); +std::string LocaleToLang(LCID locale); LCID LangToLocale(const std::string &lang); LCID ParseHexLocale(const std::string &str); std::vector GetAllLocales(); From c967b07c0784326f23f38fb3840c07ce84d122d3 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 23 Sep 2026 19:36:00 +1200 Subject: [PATCH 19/59] Renamed the release assets to x86_64 and aarch64 --- .github/workflows/build.yml | 18 ++++++++++-------- .github/workflows/prerelease.yml | 21 ++++++--------------- .github/workflows/release.yml | 21 ++++++--------------- .github/workflows/test.yml | 10 ++++++---- 4 files changed, 28 insertions(+), 42 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f74b422..02e5051 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,10 +10,12 @@ jobs: include: # AMD64 builds on ubuntu-24.04 - arch: amd64 + asset: mpqcli-linux-x86_64 runner: ubuntu-24.04 platform: linux/amd64 # ARM64 builds on ubuntu-24.04-arm - arch: arm64 + asset: mpqcli-linux-aarch64 runner: ubuntu-24.04-arm platform: linux/arm64 @@ -33,20 +35,20 @@ jobs: # Create a temporary container from the image CONTAINER_ID=$(docker create mpqcli-${{ matrix.arch }}) # Copy the binary from the container - docker cp $CONTAINER_ID:/mpqcli ./mpqcli-linux-${{ matrix.arch }} + docker cp $CONTAINER_ID:/mpqcli ./${{ matrix.asset }} # Clean up the container docker rm $CONTAINER_ID - name: Verify binary run: | - file ./mpqcli-linux-${{ matrix.arch }} - echo "Binary created: mpqcli-linux-${{ matrix.arch }}" + file ./${{ matrix.asset }} + echo "Binary created: ${{ matrix.asset }}" - name: Upload binary as artifact uses: actions/upload-artifact@v7 with: - name: mpqcli-linux-${{ matrix.arch }} - path: mpqcli-linux-${{ matrix.arch }} + name: ${{ matrix.asset }} + path: ${{ matrix.asset }} retention-days: 1 build_windows: @@ -72,13 +74,13 @@ jobs: run: build/bin/Release/mpqcli.exe version - name: Copy mpqcli binary - run: cp build/bin/Release/mpqcli.exe build/bin/mpqcli-windows-amd64.exe + run: cp build/bin/Release/mpqcli.exe build/bin/mpqcli-windows-x86_64.exe - name: Upload Artifact uses: actions/upload-artifact@v7 with: - name: mpqcli-windows-amd64.exe - path: build/bin/mpqcli-windows-amd64.exe + name: mpqcli-windows-x86_64.exe + path: build/bin/mpqcli-windows-x86_64.exe retention-days: 1 build_docker: diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 3ce6025..68f0071 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -14,20 +14,11 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Download Linux AMD64 binary + - name: Download all build artefacts uses: actions/download-artifact@v8 with: - name: mpqcli-linux-amd64 - - - name: Download Linux ARM64 binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-arm64 - - - name: Download Windows binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-windows-amd64.exe + path: dist + merge-multiple: true - name: Delete any existing dev release run: | @@ -48,9 +39,9 @@ jobs: --title "Dev (Pre-release)" \ --prerelease \ --notes "**Commit:** ${{ github.sha }}" \ - mpqcli-linux-amd64 \ - mpqcli-linux-arm64 \ - mpqcli-windows-amd64.exe + dist/mpqcli-linux-x86_64 \ + dist/mpqcli-linux-aarch64 \ + dist/mpqcli-windows-x86_64.exe env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea054e2..e2a6495 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,20 +27,11 @@ jobs: fi echo "version=${version}" >> "$GITHUB_OUTPUT" - - name: Download Linux AMD64 binary + - name: Download all build artefacts uses: actions/download-artifact@v8 with: - name: mpqcli-linux-amd64 - - - name: Download Linux ARM64 binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-linux-arm64 - - - name: Download Windows binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-windows-amd64.exe + path: dist + merge-multiple: true - name: Extract changelog for this tag id: changelog @@ -56,9 +47,9 @@ jobs: gh release create ${{ github.ref_name }} \ --title "${{ github.ref_name }}" \ --notes "$CHANGELOG_CONTENT" \ - mpqcli-linux-amd64 \ - mpqcli-linux-arm64 \ - mpqcli-windows-amd64.exe + dist/mpqcli-linux-x86_64 \ + dist/mpqcli-linux-aarch64 \ + dist/mpqcli-windows-x86_64.exe env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bcd9cf0..2577f98 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,9 +13,11 @@ jobs: include: # AMD64 tests on ubuntu-24.04 - arch: amd64 + asset: mpqcli-linux-x86_64 runner: ubuntu-24.04 # ARM64 tests on ubuntu-24.04-arm - arch: arm64 + asset: mpqcli-linux-aarch64 runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} @@ -29,11 +31,11 @@ jobs: - name: Download Linux binary (${{ matrix.arch }}) uses: actions/download-artifact@v8 with: - name: mpqcli-linux-${{ matrix.arch }} + name: ${{ matrix.asset }} path: build/bin - name: Rename binary to "mpqcli" - run: mv build/bin/mpqcli-linux-${{ matrix.arch }} build/bin/mpqcli + run: mv build/bin/${{ matrix.asset }} build/bin/mpqcli - name: Make binary executable run: chmod +x ./build/bin/mpqcli @@ -67,11 +69,11 @@ jobs: - name: Download Windows binary uses: actions/download-artifact@v8 with: - name: mpqcli-windows-amd64.exe + name: mpqcli-windows-x86_64.exe path: build/bin/Release - name: Rename binary to "mpqcli" - run: mv build/bin/Release/mpqcli-windows-amd64.exe build/bin/mpqcli.exe + run: mv build/bin/Release/mpqcli-windows-x86_64.exe build/bin/mpqcli.exe - name: Check mpqcli version run: ./build/bin/mpqcli.exe version From 3860ed19dcf92e24afc8f1e64380960c12dbff4c Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 23 Sep 2026 19:43:00 +1200 Subject: [PATCH 20/59] Replaced the install scripts with gpipe-generated installers and checksums --- .github/workflows/main.yml | 1 + .github/workflows/pr.yml | 1 + .github/workflows/release.yml | 10 +- .github/workflows/tag.yml | 1 + .gpipe.yml | 12 ++ Makefile | 4 +- README.md | 5 +- docs/installation.md | 7 +- scripts/install.ps1 | 181 -------------------------- scripts/install.sh | 234 ---------------------------------- 10 files changed, 34 insertions(+), 422 deletions(-) create mode 100644 .gpipe.yml delete mode 100644 scripts/install.ps1 delete mode 100755 scripts/install.sh diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c43f6cd..1fd4548 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,6 +18,7 @@ on: - "CMakeLists.txt" - "Dockerfile" - ".dockerignore" + - ".gpipe.yml" concurrency: group: prerelease diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9fae0f8..4b813f9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,6 +15,7 @@ on: - "CMakeLists.txt" - "Dockerfile" - ".dockerignore" + - ".gpipe.yml" permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2a6495..1fcf1cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,9 +3,11 @@ name: Release on: workflow_call +# id-token: write obtains the OIDC token gpipe uses for cosign keyless signing permissions: contents: write packages: write + id-token: write jobs: release_binaries: @@ -33,6 +35,10 @@ jobs: path: dist merge-multiple: true + - uses: thomaslaurenson/gpipe@v1 + with: + cosign_sign: true + - name: Extract changelog for this tag id: changelog run: | @@ -49,7 +55,9 @@ jobs: --notes "$CHANGELOG_CONTENT" \ dist/mpqcli-linux-x86_64 \ dist/mpqcli-linux-aarch64 \ - dist/mpqcli-windows-x86_64.exe + dist/mpqcli-windows-x86_64.exe \ + install.sh install.ps1 \ + checksums.txt checksums.txt.sigstore.json env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }} diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 721c58f..b1d427a 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -8,6 +8,7 @@ on: permissions: contents: write packages: write + id-token: write jobs: build: diff --git a/.gpipe.yml b/.gpipe.yml new file mode 100644 index 0000000..9acbe48 --- /dev/null +++ b/.gpipe.yml @@ -0,0 +1,12 @@ +binary: mpqcli + +platforms: + linux_amd64: + path: ./dist/mpqcli-linux-x86_64 + name: mpqcli-linux-x86_64 + linux_arm64: + path: ./dist/mpqcli-linux-aarch64 + name: mpqcli-linux-aarch64 + windows_amd64: + path: ./dist/mpqcli-windows-x86_64.exe + name: mpqcli-windows-x86_64.exe diff --git a/Makefile b/Makefile index d60665a..985bbf6 100644 --- a/Makefile +++ b/Makefile @@ -146,8 +146,8 @@ ci: configure build check_all test ## Run all CI checks locally ##@ CLEAN .PHONY: clean -clean: test_clean docs_clean ## Remove all build, test, and docs artifacts - rm -rf build $(LINT_DIR) +clean: test_clean docs_clean ## Remove all build, test, docs and release artefacts + rm -rf build $(LINT_DIR) dist install.sh install.ps1 checksums.txt checksums.txt.sigstore.json ##@ GENERATE # The docs site builds from the committed copy, so this is run deliberately and the diff --git a/README.md b/README.md index fe7da85..0e49c4d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Docker images are available on [GitHub packages](https://github.com/thegraydot/m ## Quickstart ```bash -curl -fsSL https://raw.githubusercontent.com/thegraydot/mpqcli/main/scripts/install.sh | bash +curl -fsSL https://github.com/thegraydot/mpqcli/releases/latest/download/install.sh | bash ... mpqcli list ``` @@ -38,7 +38,8 @@ mpqcli list Microsoft Windows: ```powershell -irm https://raw.githubusercontent.com/thegraydot/mpqcli/main/scripts/install.ps1 | iex +Invoke-WebRequest -Uri https://github.com/thegraydot/mpqcli/releases/latest/download/install.ps1 -OutFile install.ps1 +.\install.ps1 ... mpqcli.exe list ``` diff --git a/docs/installation.md b/docs/installation.md index d2b4584..7760a0f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -7,15 +7,18 @@ Pre-built binaries are available for Linux and Windows. Linux/WSL: ```bash -$ curl -fsSL https://raw.githubusercontent.com/thegraydot/mpqcli/main/scripts/install.sh | bash +$ curl -fsSL https://github.com/thegraydot/mpqcli/releases/latest/download/install.sh | bash ``` Microsoft Windows: ```powershell -PS> irm https://raw.githubusercontent.com/thegraydot/mpqcli/main/scripts/install.ps1 | iex +PS> Invoke-WebRequest -Uri https://github.com/thegraydot/mpqcli/releases/latest/download/install.ps1 -OutFile install.ps1 +PS> .\install.ps1 ``` +Both installers put `mpqcli` on the system path and may ask for elevation; pass `--user` on Linux or `-User` on Windows to install for the current user instead. Each release also ships `checksums.txt`, signed with cosign: the installer verifies the SHA-256 of the binary always, and the signature when cosign is installed. + Check the [latest release with binaries](https://github.com/thegraydot/mpqcli/releases). ## Docker Image diff --git a/scripts/install.ps1 b/scripts/install.ps1 deleted file mode 100644 index 11a440f..0000000 --- a/scripts/install.ps1 +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env pwsh - -param( - [string]$Version = "", - [string]$InstallDir = "$env:LOCALAPPDATA\Programs\mpqcli", - [switch]$Help -) - -$ErrorActionPreference = "Stop" -$ProgressPreference = "SilentlyContinue" - -$BinaryName = "mpqcli-windows-amd64.exe" -$Repo = "thegraydot/mpqcli" -$BaseUrl = "https://github.com/$Repo/releases" - -function Write-Info { - param($Message) - Write-Host "[INFO] " -ForegroundColor Green -NoNewline - Write-Host $Message -} - -function Write-Warn { - param($Message) - Write-Host "[WARN] " -ForegroundColor Yellow -NoNewline - Write-Host $Message -} - -function Write-Error-And-Exit { - param($Message) - Write-Host "[ERROR] " -ForegroundColor Red -NoNewline - Write-Host $Message - exit 1 -} - -function Get-LatestVersion { - try { - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" - $version = $release.tag_name -replace 'v', '' - return $version - } catch { - Write-Error-And-Exit "Failed to get latest version: $_" - } -} - -function Get-Platform { - $arch = [System.Environment]::Is64BitOperatingSystem - if ($arch) { - $platform = "x86_64-pc-windows-msvc" - } else { - Write-Error-And-Exit "32-bit Windows is not supported" - } - - # Check for ARM64 - $processorArch = $env:PROCESSOR_ARCHITECTURE - if ($processorArch -eq "ARM64") { - Write-Error-And-Exit "ARM64 Windows is not supported" - } - - return $platform -} - -function Download-File { - param( - [string]$Url, - [string]$OutFile - ) - - try { - Write-Info "Downloading from $Url" - Invoke-WebRequest -Uri $Url -OutFile $OutFile -UseBasicParsing - } catch { - if ($_.Exception.Response.StatusCode -eq 404) { - return $false - } - Write-Error-And-Exit "Failed to download: $_" - } - return $true -} - -function Install-MPQCLI { - param( - [string]$Version, - [string]$InstallDir - ) - - # Get version if not specified - if ([string]::IsNullOrEmpty($Version)) { - Write-Info "Fetching latest version..." - $Version = Get-LatestVersion - } - - Write-Info "Installing mpqcli v$Version" - - # Detect platform - $platform = Get-Platform - Write-Info "Detected platform: $platform" - - # Create temp directory - $tempDir = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemType Directory -Path $_ } - - try { - # Download files - $fileName = "$BinaryName-windows.exe" - $downloadUrl = "$BaseUrl/download/v$Version/$fileName" - - if (-not (Download-File -Url $downloadUrl -OutFile $fileName)) { - Write-Error-And-Exit "Failed to download binary" - } - - # Create install directory - if (-not (Test-Path $InstallDir)) { - New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null - } - - # Install binary - $binaryName = "$BinaryName.exe" - $sourcePath = Join-Path $tempDir $binaryName - $destPath = Join-Path $InstallDir $binaryName - - if (Test-Path $sourcePath) { - Write-Info "Installing to $destPath" - Copy-Item -Path $sourcePath -Destination $destPath -Force - } else { - Write-Error-And-Exit "Binary not found in archive" - } - - # Verify installation - try { - $testOutput = & $destPath --version 2>&1 - Write-Info "Installation successful!" - Write-Info "Binary installed to: $destPath" - Write-Info "Version: $testOutput" - } catch { - Write-Error-And-Exit "Installation verification failed" - } - - # Check PATH - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - if ($userPath -notlike "*$InstallDir*") { - Write-Warn "$InstallDir is not in your PATH" - Write-Info "To add it to PATH, run:" - Write-Host ' [Environment]::SetEnvironmentVariable("Path", $env:Path + ";' + $InstallDir + '", "User")' - Write-Info "Or restart your terminal after running:" - Write-Host " setx PATH `"%PATH%;$InstallDir`"" - } else { - Write-Info "$BinaryName is available in your PATH" - } - - } finally { - # Cleanup - Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue - } -} - -# Main -if ($Help) { - Write-Host @" -Install script for mpqcli - -USAGE: - install.ps1 [-Version ] [-InstallDir ] [-Help] - -OPTIONS: - -Version Install specific version (default: latest) - -InstallDir Installation directory (default: $env:LOCALAPPDATA\Programs\mpqcli) - -Help Show this help message - -EXAMPLES: - # Install latest version - .\install.ps1 - - # Install specific version - .\install.ps1 -Version 0.8.0 - - # Install to custom directory - .\install.ps1 -InstallDir C:\Tools\mpqcli -"@ - exit 0 -} - -Install-MPQCLI -Version $Version -InstallDir $InstallDir diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index b242d26..0000000 --- a/scripts/install.sh +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -REPO="thegraydot/mpqcli" -BASE_URL="https://github.com/${REPO}/releases" - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -error() { - echo -e "${RED}[ERROR]${NC} $1" - exit 1 -} - -# Detect OS and architecture -detect_platform() { - local os arch lib - - # Detect OS - case "$(uname -s)" in - Linux*) os="linux";; - *) error "Unsupported operating system: $(uname -s)";; - esac - - # Detect architecture - case "$(uname -m)" in - x86_64|amd64) arch="amd64";; - aarch64|arm64) arch="arm64";; - *) error "Unsupported architecture: $(uname -m)";; - esac - - # Determine if we should use glibc or musl - # Priority: Try glibc first (if available), fallback to musl - - # Check if we have ldd command - if command -v ldd >/dev/null 2>&1; then - # Check for a musl-based system (like Alpine) - if ldd --version 2>&1 | grep -q musl; then - lib="musl" - # Check for a glibc-based system (like Ubuntu, Debian etc.) - elif ldd --version 2>&1 | grep -q "GNU\|GLIBC"; then - lib="glibc" - fi - # If ldd is not available, check for common glibc files - elif [[ -f /lib/x86_64-linux-gnu/libc.so.6 ]] || \ - [[ -f /lib64/libc.so.6 ]] || \ - [[ -f /usr/lib/libc.so.6 ]]; then - lib="glibc" - else - # If ldd is not available or cannot find glibc - # default to musl for compatibility - lib="musl" - fi - - echo "${os}-${arch}-${lib}" -} - -# Download file -download_file() { - local version="$1" - local platform="$2" - local filename="mpqcli-${platform}" - local download_url="${BASE_URL}/download/v${version}/${filename}" - - info "Downloading from: $download_url" - - if command -v curl >/dev/null 2>&1; then - if curl -fsSL "$download_url" -o "mpqcli"; then - return 0 - fi - elif command -v wget >/dev/null 2>&1; then - if wget -q "$download_url" -O "mpqcli"; then - return 0 - fi - else - error "Neither curl nor wget found. Please install one of them." - fi - - error "Failed to download binary for platform: $platform" -} - -# Get latest release version -get_latest_version() { - local version - version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ - | grep '"tag_name":' \ - | sed -E 's/.*"v([^"]+)".*/\1/') - - if [[ -z "$version" ]]; then - error "Failed to get latest version" - fi - - echo "$version" -} - -# Main installation function -install() { - local version="${1:-}" - local install_dir="${INSTALL_DIR:-/usr/local/bin}" - - # Get version if not specified - if [[ -z "$version" ]]; then - info "Fetching latest version..." - version=$(get_latest_version) - fi - - info "Installing mpqcli ${version}" - - # Detect platform - local platform - platform=$(detect_platform) - - # Show appropriate message based on detected platform - if [[ "$platform" == *"-glibc" ]]; then - info "System appears compatible with GLIBC binaries" - else - info "Using MUSL binary for better compatibility" - fi - - info "Detected platform: ${platform}" - - # Download binar - download_file "$version" "$platform" - - # Ensure binary is executable - if [[ -f "mpqcli" ]]; then - info "Making binary executable..." - chmod +x "mpqcli" - else - error "Binary mpqcli not found after download" - fi - - # Install binary - info "Installing to $install_dir" - warn "You may need to enter your password..." - sudo mv "mpqcli" "$install_dir/" - - # Verify installation - if "${install_dir}/mpqcli" version >/dev/null 2>&1; then - info "Installation successful!" - info "Binary installed to: ${install_dir}/mpqcli" - - # Check if install_dir is in PATH - if ! echo "$PATH" | grep -q "$install_dir"; then - warn "${install_dir} is not in your PATH" - warn "Add it to your PATH by adding this to your shell configuration:" - warn " export PATH=\"${install_dir}:\$PATH\"" - fi - else - error "Installation verification failed" - fi -} - -# Show help -show_help() { - cat << EOF -Install script for mpqcli - -USAGE: - $0 [OPTIONS] [VERSION] - -OPTIONS: - -h, --help Show this help message - -d, --dir DIR Install directory (default: \$HOME/.local/bin) - -t, --tag TAG Install specific release tag - -ENVIRONMENT VARIABLES: - INSTALL_DIR Override default install directory - -EXAMPLES: - # Install latest version - $0 - - # Install specific version - $0 0.8.0 - - # Install to custom directory - INSTALL_DIR=$HOME/.local/bin $0 - - # Install specific tag - $0 --tag v0.8.0 -EOF -} - -# Parse command line arguments -main() { - local version="" - local tag="" - - while [[ $# -gt 0 ]]; do - case $1 in - -h|--help) - show_help - exit 0 - ;; - -d|--dir) - INSTALL_DIR="$2" - shift 2 - ;; - -t|--tag) - tag="$2" - shift 2 - ;; - -*) - error "Unknown option: $1" - ;; - *) - version="$1" - shift - ;; - esac - done - - # Extract version from tag if provided - if [[ -n "$tag" ]]; then - version="${tag#v}" - fi - - install "$version" -} - -# Run main function -main "$@" From 6b7f8ddded2139be0d5c1f15bae9bc65bd581b2c Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 23 Sep 2026 19:51:00 +1200 Subject: [PATCH 21/59] Ran the image as root with /data as the working directory --- Dockerfile | 10 +++++++++- docs/installation.md | 20 +++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 81e7800..529ed12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,11 @@ FROM alpine:3.23 AS build RUN apk add --no-cache \ + # The C and C++ toolchain, with the musl-dev the static link needs build-base \ + # Configures and drives the build cmake \ + # Stamps the commit hash into the version header; see .dockerignore git WORKDIR /src @@ -26,5 +29,10 @@ LABEL org.opencontainers.image.description="A command-line tool to create, add, LABEL org.opencontainers.image.licenses="MIT" COPY --from=build /src/build/release/bin/mpqcli /mpqcli -USER 1001:1001 + +# No USER, deliberately. The image exists to process files on a bind mount, and +# no fixed uid can match whoever owns the host directory, so a non-root default +# would fail every write for most Linux users. Callers who want output owned by +# themselves pass --user "$(id -u):$(id -g)", which the installation docs show +WORKDIR /data ENTRYPOINT ["/mpqcli"] diff --git a/docs/installation.md b/docs/installation.md index 7760a0f..651467c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -34,11 +34,25 @@ $ docker pull ghcr.io/thegraydot/mpqcli:latest You can run `mpqcli` commands directly using the Docker container. For example: ```bash -$ docker run ghcr.io/thegraydot/mpqcli:latest version +$ docker run --rm ghcr.io/thegraydot/mpqcli:latest version ``` -To use local files in the container, mount a directory from your host system. In the following example, the `-v` argument is used to mount the present working directory to `/data` directory in the container. Then the `mpqcli` container runs the `list` subcommand with `/data/example.mpq` as the target MPQ archive. +To use local files, mount a directory from your host system at `/data`. That is the container's working directory, so paths are relative to the mounted directory. The following example lists `example.mpq` from the present working directory: ```bash -$ docker run -v $(pwd):/data ghcr.io/thegraydot/mpqcli:latest list /data/example.mpq +$ docker run --rm -v "$PWD":/data ghcr.io/thegraydot/mpqcli:latest list example.mpq ``` + +The container runs as root, so a file it creates or extracts is owned by root on the host. To have the output owned by you, run the container as your own user: + +```bash +$ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/data ghcr.io/thegraydot/mpqcli:latest create -o example.mpq files/ +``` + +A shell function makes that the default for everyday use: + +```bash +mpqcli() { docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/data ghcr.io/thegraydot/mpqcli:latest "$@"; } +``` + +Under rootless Docker or Podman the container's root already maps to your own user, so run without `--user` there; on Podman, `--userns=keep-id` is the equivalent of the flag above. From 79e6f09a972ba0cb74075ad934652ac8c971a3ca Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 23 Sep 2026 19:57:00 +1200 Subject: [PATCH 22/59] Masked the locale to 16 bits when listing files --- src/mpq/list.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp index ddd7540..8ab4a15 100644 --- a/src/mpq/list.cpp +++ b/src/mpq/list.cpp @@ -129,8 +129,10 @@ int ListFiles(HANDLE archive, const std::optional &listfile_name, b << GetFileInfo(file, it->second) << std::setfill(' ') << std::dec << " "; } else if (prop == "locale") { - std::cout << std::setw(4) - << LocaleToLang(GetFileInfo(file, it->second)) << " "; + // StormLib packs the hash entry's platform byte into bits 16 to 23 of + // the LCID (SFILE_MAKE_LCID); only the low 16 bits are the locale + const LCID file_locale = SFILE_LOCALE(GetFileInfo(file, it->second)); + std::cout << std::setw(4) << LocaleToLang(file_locale) << " "; } else if (prop == "byte-offset") { std::cout << std::hex << std::setw(8) << GetFileInfo(file, it->second) << std::dec << " "; From dbe375a2d8c8b87a13f3f461dc438aa300cbd3ca Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 23 Sep 2026 20:04:00 +1200 Subject: [PATCH 23/59] Smoke-ran the Linux artefacts, saved the image from the amd64 build and passed the registry token through the environment --- .github/workflows/build.yml | 50 ++++++++++++++++---------------- .github/workflows/prerelease.yml | 5 +++- .github/workflows/release.yml | 5 +++- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 02e5051..890f110 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,9 @@ on: jobs: build_linux: strategy: + # Report both architectures independently; one failing says nothing about + # the other, and cancelling hides half the answer + fail-fast: false matrix: include: # AMD64 builds on ubuntu-24.04 @@ -39,10 +42,12 @@ jobs: # Clean up the container docker rm $CONTAINER_ID - - name: Verify binary + # Run the bytes that will ship: a static binary missing its loader builds + # without complaint and only fails here + - name: Smoke-run the artefact run: | - file ./${{ matrix.asset }} - echo "Binary created: ${{ matrix.asset }}" + chmod +x ./${{ matrix.asset }} + ./${{ matrix.asset }} version - name: Upload binary as artifact uses: actions/upload-artifact@v7 @@ -51,6 +56,23 @@ jobs: path: ${{ matrix.asset }} retention-days: 1 + # The amd64 image is the one published. Saving it here rather than + # rebuilding it in a job of its own means release and prerelease push + # exactly the bytes this job smoke-ran + - name: Save Docker image as a tar file + if: matrix.arch == 'amd64' + run: | + docker tag mpqcli-${{ matrix.arch }} mpqcli + docker save mpqcli -o mpqcli-docker.tar + + - name: Upload Docker image as artifact + if: matrix.arch == 'amd64' + uses: actions/upload-artifact@v7 + with: + name: mpqcli-docker + path: mpqcli-docker.tar + retention-days: 1 + build_windows: runs-on: windows-2022 steps: @@ -83,25 +105,3 @@ jobs: path: build/bin/mpqcli-windows-x86_64.exe retention-days: 1 - build_docker: - runs-on: ubuntu-24.04 - steps: - - name: Check out repository code - uses: actions/checkout@v6 - with: - submodules: true - - - name: Build Docker image (AMD64) - run: | - docker build --platform linux/amd64 -t mpqcli . - - - name: Save Docker image as a tar file - run: | - docker save mpqcli -o mpqcli-docker.tar - - - name: Upload Docker image as artifact - uses: actions/upload-artifact@v7 - with: - name: mpqcli-docker - path: mpqcli-docker.tar - retention-days: 1 diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 68f0071..c725d83 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -63,6 +63,9 @@ jobs: docker tag mpqcli ghcr.io/thegraydot/mpqcli:dev - name: Push Docker image to GitHub Container Registry + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTOR: ${{ github.actor }} run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin docker push ghcr.io/thegraydot/mpqcli:dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fcf1cc..77767f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,7 +81,10 @@ jobs: docker tag mpqcli ghcr.io/thegraydot/mpqcli:latest - name: Push Docker image to GitHub Container Registry + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTOR: ${{ github.actor }} run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin docker push ghcr.io/thegraydot/mpqcli:${{ needs.release_binaries.outputs.version }} docker push ghcr.io/thegraydot/mpqcli:latest From 8aea05c43e9d85cbc310671494acb08089a631da Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 23 Sep 2026 20:12:00 +1200 Subject: [PATCH 24/59] Included the headers each file uses --- src/gamerules/rules.cpp | 2 ++ src/mpq/add.cpp | 3 +++ src/mpq/archive.h | 1 - src/mpq/create.cpp | 1 + src/mpq/extract.cpp | 2 ++ src/mpq/info.cpp | 1 + src/mpq/list.cpp | 1 + src/mpq/query.cpp | 1 + src/mpq/verify.cpp | 4 ++++ src/util/locales.cpp | 2 -- src/util/locales.h | 1 - src/util/path.cpp | 2 ++ 12 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/gamerules/rules.cpp b/src/gamerules/rules.cpp index 6bd4cf1..7fb92c0 100644 --- a/src/gamerules/rules.cpp +++ b/src/gamerules/rules.cpp @@ -1,6 +1,8 @@ #include "gamerules/rules.h" #include +#include +#include #include #include "util/string.h" diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index 727b770..923bd35 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -1,9 +1,12 @@ #include "mpq/add.h" #include +#include #include #include +#include #include +#include #include #include diff --git a/src/mpq/archive.h b/src/mpq/archive.h index d379669..091ca5a 100644 --- a/src/mpq/archive.h +++ b/src/mpq/archive.h @@ -1,7 +1,6 @@ #ifndef MPQ_ARCHIVE_H #define MPQ_ARCHIVE_H -#include #include #include diff --git a/src/mpq/create.cpp b/src/mpq/create.cpp index b5cdf9a..6f76731 100644 --- a/src/mpq/create.cpp +++ b/src/mpq/create.cpp @@ -1,5 +1,6 @@ #include "mpq/create.h" +#include #include #include #include diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index 917a569..3b1bc85 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -1,9 +1,11 @@ #include "mpq/extract.h" +#include #include #include #include #include +#include #include diff --git a/src/mpq/info.cpp b/src/mpq/info.cpp index 6bd69a9..d706f8a 100644 --- a/src/mpq/info.cpp +++ b/src/mpq/info.cpp @@ -1,5 +1,6 @@ #include "mpq/info.h" +#include #include #include #include diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp index 8ab4a15..4069ada 100644 --- a/src/mpq/list.cpp +++ b/src/mpq/list.cpp @@ -1,6 +1,7 @@ #include "mpq/list.h" #include +#include #include #include #include diff --git a/src/mpq/query.cpp b/src/mpq/query.cpp index 027db9d..e8d8251 100644 --- a/src/mpq/query.cpp +++ b/src/mpq/query.cpp @@ -1,5 +1,6 @@ #include "mpq/query.h" +#include #include #include #include diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp index f3c25fd..e12dbad 100644 --- a/src/mpq/verify.cpp +++ b/src/mpq/verify.cpp @@ -1,9 +1,13 @@ #include "mpq/verify.h" +#include +#include +#include #include #include #include #include +#include #include #include diff --git a/src/util/locales.cpp b/src/util/locales.cpp index 03c1027..89555bd 100644 --- a/src/util/locales.cpp +++ b/src/util/locales.cpp @@ -1,8 +1,6 @@ #include "locales.h" #include -#include -#include #include #include #include diff --git a/src/util/locales.h b/src/util/locales.h index df081ba..8eb222b 100644 --- a/src/util/locales.h +++ b/src/util/locales.h @@ -1,7 +1,6 @@ #ifndef LOCALES_H #define LOCALES_H -#include #include #include diff --git a/src/util/path.cpp b/src/util/path.cpp index d4a0cc0..9c3db3b 100644 --- a/src/util/path.cpp +++ b/src/util/path.cpp @@ -1,7 +1,9 @@ #include "util/path.h" #include +#include #include +#include #include #include #include From ecfff211d36294fcfc992716fd3caf61c5c65802 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 23 Sep 2026 20:19:00 +1200 Subject: [PATCH 25/59] Aligned header guards, declaration docs and generated headers across the tree --- app/completion.cpp | 8 ++++---- cmake/completion_data.h.in | 20 +++++++++----------- src/CMakeLists.txt | 3 ++- src/gamerules/profile_data.cpp | 1 - src/gamerules/profiles.cpp | 12 ++++++------ src/gamerules/rules.cpp | 9 --------- src/gamerules/rules.h | 30 +++++++++++++++--------------- src/gamerules/settings.h | 13 +++++++------ src/mpq/add.cpp | 5 +++-- src/mpq/archive.cpp | 2 ++ src/mpq/extract.cpp | 7 ++++--- src/mpq/query.cpp | 5 +++-- src/mpq/verify.cpp | 5 +++-- src/util/capacity.cpp | 8 +++----- src/util/hash.cpp | 2 -- src/util/locales.cpp | 2 +- src/util/locales.h | 6 +++--- src/util/path.cpp | 2 -- src/util/path.h | 2 +- 19 files changed, 66 insertions(+), 76 deletions(-) diff --git a/app/completion.cpp b/app/completion.cpp index 580d87e..765da6a 100644 --- a/app/completion.cpp +++ b/app/completion.cpp @@ -4,17 +4,17 @@ #include "commands.h" void HandleCompletionBash() { - std::cout << BashCompletionScript; + std::cout << mpqcli::bash_completion_script; } void HandleCompletionZsh() { - std::cout << ZshCompletionScript; + std::cout << mpqcli::zsh_completion_script; } void HandleCompletionPs() { - std::cout << PsCompletionScript; + std::cout << mpqcli::ps_completion_script; } void HandleCompletionFish() { - std::cout << FishCompletionScript; + std::cout << mpqcli::fish_completion_script; } diff --git a/cmake/completion_data.h.in b/cmake/completion_data.h.in index 71123af..5f1ac21 100644 --- a/cmake/completion_data.h.in +++ b/cmake/completion_data.h.in @@ -1,16 +1,14 @@ -#ifndef COMPLETION_DATA_H -#define COMPLETION_DATA_H +// Generated from the scripts in completion/, do not edit +#pragma once -// Auto-generated from completion/mpqcli.bash, do not edit directly -static constexpr char BashCompletionScript[] = R"BASH_MPQCLI(@BASH_COMPLETION_SCRIPT@)BASH_MPQCLI"; +namespace mpqcli { -// Auto-generated from completion/mpqcli.zsh, do not edit directly -static constexpr char ZshCompletionScript[] = R"ZSH_MPQCLI(@ZSH_COMPLETION_SCRIPT@)ZSH_MPQCLI"; +inline constexpr char bash_completion_script[] = R"BASH_MPQCLI(@BASH_COMPLETION_SCRIPT@)BASH_MPQCLI"; -// Auto-generated from completion/mpqcli.ps1, do not edit directly -static constexpr char PsCompletionScript[] = R"PS_MPQCLI(@PS_COMPLETION_SCRIPT@)PS_MPQCLI"; +inline constexpr char zsh_completion_script[] = R"ZSH_MPQCLI(@ZSH_COMPLETION_SCRIPT@)ZSH_MPQCLI"; -// Auto-generated from completion/mpqcli.fish, do not edit directly -static constexpr char FishCompletionScript[] = R"FISH_MPQCLI(@FISH_COMPLETION_SCRIPT@)FISH_MPQCLI"; +inline constexpr char ps_completion_script[] = R"PS_MPQCLI(@PS_COMPLETION_SCRIPT@)PS_MPQCLI"; -#endif // COMPLETION_DATA_H +inline constexpr char fish_completion_script[] = R"FISH_MPQCLI(@FISH_COMPLETION_SCRIPT@)FISH_MPQCLI"; + +} // namespace mpqcli diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b12b180..31a73c9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -17,7 +17,8 @@ endif() add_library(mpqcli::warnings ALIAS mpqcli_warnings) -# Generated beside the core library so the core and the CLI report the same version +# Generated into the include tree the core exports, so app/ reaches it through the +# core's PUBLIC include directory rather than a second include path configure_file( "${PROJECT_SOURCE_DIR}/cmake/version.h.in" "${PROJECT_BINARY_DIR}/include/mpqcli/version.h" diff --git a/src/gamerules/profile_data.cpp b/src/gamerules/profile_data.cpp index 23a1665..425529b 100644 --- a/src/gamerules/profile_data.cpp +++ b/src/gamerules/profile_data.cpp @@ -2,7 +2,6 @@ namespace mpqcli { -// Initialize rules for the selected game profile void GameRules::InitializeRules() { rules_.clear(); diff --git a/src/gamerules/profiles.cpp b/src/gamerules/profiles.cpp index c5c709e..58354b1 100644 --- a/src/gamerules/profiles.cpp +++ b/src/gamerules/profiles.cpp @@ -7,8 +7,10 @@ namespace mpqcli { -// Get the profile name map (single source of truth for all valid profile names) -static const std::map &GetProfileMap() { +namespace { + +// The single source of truth for valid profile names and their aliases +const std::map &GetProfileMap() { static const std::map profile_map = { {"generic", GameProfile::GENERIC}, {"diablo1", GameProfile::DIABLO1}, @@ -48,7 +50,8 @@ static const std::map &GetProfileMap() { return profile_map; } -// Convert string to GameProfile enum +} // namespace + GameProfile GameRules::StringToProfile(const std::string &profile_name) { const auto &profile_map = GetProfileMap(); std::string lower = ToLower(profile_name); @@ -59,7 +62,6 @@ GameProfile GameRules::StringToProfile(const std::string &profile_name) { return GameProfile::GENERIC; } -// Convert GameProfile enum to string std::string GameRules::ProfileToString(GameProfile profile) { switch (profile) { case GameProfile::GENERIC: @@ -97,7 +99,6 @@ std::string GameRules::ProfileToString(GameProfile profile) { } } -// Get list of canonical game profile names (for display purposes) std::vector GameRules::GetCanonicalProfiles() { static const std::vector all_profiles = { GameProfile::GENERIC, GameProfile::DIABLO1, GameProfile::LORDSOFMAGIC, @@ -115,7 +116,6 @@ std::vector GameRules::GetCanonicalProfiles() { return profiles; } -// Get available profiles as a comma-separated string std::string GameRules::GetAvailableProfiles() { auto profiles = GetCanonicalProfiles(); std::string result; diff --git a/src/gamerules/rules.cpp b/src/gamerules/rules.cpp index 7fb92c0..007feb1 100644 --- a/src/gamerules/rules.cpp +++ b/src/gamerules/rules.cpp @@ -9,12 +9,10 @@ namespace mpqcli { -// Constructor GameRules::GameRules(GameProfile game_profile) : profile_(game_profile) { InitializeRules(); } -// Helper function to match wildcards (* and ?) bool GameRules::MatchFileMask(const std::string &filename, const std::string &mask) { // Convert both to lowercase for case-insensitive matching std::string lower_filename = ToLower(filename); @@ -60,11 +58,6 @@ void GameRules::AddRuleByFileMask(const std::string &file_mask, DWORD mpq_flags, rules_.emplace_back(file_mask, mpq_flags, compression_first, compression_next); } -// Use UINT32_MAX for sizeMax to indicate "no upper limit" -// Examples: -// AddRuleByFileSize(0, 0, ...) - Match files with exactly 0 bytes -// AddRuleByFileSize(0, 0x4000, ...) - Match files from 0 to 16KB -// AddRuleByFileSize(0x4000, UINT32_MAX, ...) - Match files from 16KB onwards void GameRules::AddRuleByFileSize(DWORD size_min, DWORD size_max, DWORD mpq_flags, DWORD compression_first, DWORD compression_next) { rules_.emplace_back(size_min, size_max, mpq_flags, compression_first, compression_next); @@ -74,7 +67,6 @@ void GameRules::AddRuleDefault(DWORD mpq_flags, DWORD compression_first, DWORD c rules_.emplace_back(mpq_flags, compression_first, compression_next); } -// Get compression settings for a specific file CompressionSettings GameRules::GetCompressionSettings(const std::string &filename, const DWORD file_size) const { // Iterate through rules in order (first match wins) @@ -108,7 +100,6 @@ CompressionSettings GameRules::GetCompressionSettings(const std::string &filenam MPQ_COMPRESSION_NEXT_SAME}; } -// Override MPQ creation settings with user-provided values void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides) { // Track whether user explicitly set fileFlags2 (needed for automatic adjustment logic) bool user_set_file_flags2 = false; diff --git a/src/gamerules/rules.h b/src/gamerules/rules.h index af9c68e..7bdd821 100644 --- a/src/gamerules/rules.h +++ b/src/gamerules/rules.h @@ -11,58 +11,58 @@ namespace mpqcli { -// Game rules class that manages compression rules for different games +/// Compression rules and archive creation settings for one game profile class GameRules { private: GameProfile profile_; std::vector rules_; MpqCreateSettings create_settings_; - // Helper function to match file mask pattern + /// Matches filename against a mask with * and ? wildcards, ignoring case static bool MatchFileMask(const std::string &filename, const std::string &mask); - // Add rule by file mask + /// Adds a rule matched by file mask void AddRuleByFileMask(const std::string &file_mask, DWORD mpq_flags, DWORD compression_first, DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); - // Add rule by file size + /// Adds a rule matched by file size; a size_max of UINT32_MAX means no upper limit void AddRuleByFileSize(DWORD size_min, DWORD size_max, DWORD mpq_flags, DWORD compression_first, DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); - // Add default rule + /// Adds the fallback rule used when no other matches void AddRuleDefault(DWORD mpq_flags, DWORD compression_first, DWORD compression_next = MPQ_COMPRESSION_NEXT_SAME); - // Initialize rules for the selected game profile + /// Initialises the rules for the selected game profile void InitializeRules(); - // Convert GameProfile enum to string + /// Converts a GameProfile to its canonical name static std::string ProfileToString(GameProfile profile); public: - // Constructor + /// Builds the rule set for game_profile explicit GameRules(GameProfile game_profile); - // Get compression settings for a specific file + /// Returns the compression settings for a file, first matching rule wins [[nodiscard]] CompressionSettings GetCompressionSettings(const std::string &filename, DWORD file_size) const; - // Get MPQ creation settings + /// Returns the MPQ creation settings [[nodiscard]] const MpqCreateSettings &GetCreateSettings() const { return create_settings_; } - // Override MPQ creation settings + /// Applies user overrides on top of the profile's creation settings void OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides); - // Convert string to GameProfile enum + /// Converts a profile name or alias to a GameProfile, GENERIC when unknown static GameProfile StringToProfile(const std::string &profile_name); - // Get list of canonical game profile names (for display purposes) + /// Returns the canonical profile names, for display static std::vector GetCanonicalProfiles(); - // Get available profiles as a comma-separated string + /// Returns the canonical profile names as one comma-separated string static std::string GetAvailableProfiles(); - // Get default game profile (GENERIC) + /// Returns the default profile, GENERIC static GameProfile GetDefaultProfile() { return GameProfile::GENERIC; } }; diff --git a/src/gamerules/settings.h b/src/gamerules/settings.h index b1e5b5f..df27440 100644 --- a/src/gamerules/settings.h +++ b/src/gamerules/settings.h @@ -9,13 +9,14 @@ namespace mpqcli { +/// How a compression rule selects the files it applies to enum class RuleType { FILE_MASK, // Rule based on file pattern (e.g., "*.wav") FILE_SIZE, // Rule based on file size range DEFAULT // Default rule (fallback) }; -// Structure representing a single compression rule +/// A single compression rule struct CompressionRule { RuleType type; std::string file_mask; // For FILE_MASK rules (e.g., "*.wav", "UI\\*.blp") @@ -41,21 +42,21 @@ struct CompressionRule { compression_first(comp_first), compression_next(comp_next) {} }; -// Structure to hold compression settings for a file +/// The compression settings applied to one file struct CompressionSettings { DWORD mpq_flags; DWORD compression_first; DWORD compression_next; }; -// Structure to hold optional override settings for adding files +/// Optional overrides for the settings applied to added files struct CompressionSettingsOverrides { std::optional flags; std::optional compression; std::optional compression_next; }; -// Structure to hold MPQ archive creation settings +/// MPQ archive creation settings struct MpqCreateSettings { DWORD mpq_version; // MPQ format version (1, 2, 3, or 4) DWORD stream_flags; // Stream flags (e.g., STREAM_PROVIDER_FLAT) @@ -66,7 +67,7 @@ struct MpqCreateSettings { DWORD sector_size; // Sector size (typically 0x1000 or 0x4000) DWORD raw_chunk_size; // Raw chunk size (for MPQ v4, typically 0x4000) - // Constructor with defaults + /// Defaults for a version 1 archive with a flat file stream MpqCreateSettings() : mpq_version(MPQ_FORMAT_VERSION_1), stream_flags(STREAM_PROVIDER_FLAT | BASE_PROVIDER_FILE), @@ -74,7 +75,7 @@ struct MpqCreateSettings { sector_size(0x1000), raw_chunk_size(0) {} }; -// Structure to hold optional override settings for MPQ archive creation +/// Optional overrides for MPQ archive creation settings struct MpqCreateSettingsOverrides { std::optional mpq_version; std::optional stream_flags; diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index 923bd35..c434c74 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -18,10 +18,10 @@ #include "util/locales.h" #include "util/path.h" -namespace mpqcli { - namespace fs = std::filesystem; +namespace mpqcli { + int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, const std::string &path_prefix, LCID locale, const GameRules &game_rules, const CompressionSettingsOverrides &overrides, bool overwrite, bool update, @@ -73,6 +73,7 @@ int AddFiles(HANDLE archive, const std::vector &files, const fs::path return files_failed > 0 ? 1 : 0; } + int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archive_file_path, const LCID locale, const GameRules &game_rules, const CompressionSettingsOverrides &overrides, bool overwrite, bool update, diff --git a/src/mpq/archive.cpp b/src/mpq/archive.cpp index 6a9610d..41d7268 100644 --- a/src/mpq/archive.cpp +++ b/src/mpq/archive.cpp @@ -19,6 +19,7 @@ bool OpenMpqArchive(const std::string &filename, HANDLE *archive, const DWORD fl } return true; } + bool CloseMpqArchive(HANDLE archive) { if (!SFileCloseArchive(archive)) { const auto error = SErrGetLastError(); @@ -28,6 +29,7 @@ bool CloseMpqArchive(HANDLE archive) { } return true; } + bool SignMpqArchive(HANDLE archive) { if (!SFileSignArchive(archive, SIGNATURE_TYPE_WEAK)) { const auto error = SErrGetLastError(); diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index 3b1bc85..9d507a5 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -14,10 +14,10 @@ #include "util/locales.h" #include "util/path.h" -namespace mpqcli { - namespace fs = std::filesystem; +namespace mpqcli { + int ExtractFiles(HANDLE archive, const std::string &output, const std::optional &listfile_name, LCID preferred_locale) { SFileSetLocale(preferred_locale); @@ -41,6 +41,7 @@ int ExtractFiles(HANDLE archive, const std::string &output, SFileFindClose(find_handle); return result; } + int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, bool keep_folder_structure, LCID preferred_locale) { SFileSetLocale(preferred_locale); @@ -93,7 +94,7 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi // Second, through the OS to also catch symlinks. Volumes that cannot report // real paths (RAM disks) fail here, in which case the lexical check above is - // the only guard; HandleExtract warns about this once + // the only guard, and the caller warns about that once fs::path resolved_base = fs::canonical(output_path_base, ec); if (!ec) { fs::path resolved_output = fs::canonical(output_file_path_name.parent_path(), ec) / diff --git a/src/mpq/query.cpp b/src/mpq/query.cpp index e8d8251..e17af18 100644 --- a/src/mpq/query.cpp +++ b/src/mpq/query.cpp @@ -12,10 +12,10 @@ #include "util/locales.h" #include "util/path.h" -namespace mpqcli { - namespace fs = std::filesystem; +namespace mpqcli { + bool FileExistsInArchiveForLocale(const HANDLE archive, const std::string &file_path, const LCID locale) { bool file_exists = false; @@ -30,6 +30,7 @@ bool FileExistsInArchiveForLocale(const HANDLE archive, const std::string &file_ } return file_exists; } + bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file, std::string &match_reason) { const DWORD archived_size = SFileGetFileSize(file, nullptr); diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp index e12dbad..49337e2 100644 --- a/src/mpq/verify.cpp +++ b/src/mpq/verify.cpp @@ -17,13 +17,14 @@ #include "util/format.h" #include "util/locales.h" -namespace mpqcli { - namespace fs = std::filesystem; +namespace mpqcli { + uint32_t VerifyMpqArchive(HANDLE archive) { return SFileVerifyArchive(archive); } + int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { // Determine if we have a strong or weak digital signature int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); diff --git a/src/util/capacity.cpp b/src/util/capacity.cpp index 3e1b8cd..9e5521d 100644 --- a/src/util/capacity.cpp +++ b/src/util/capacity.cpp @@ -18,11 +18,9 @@ uint32_t CalculateMpqMaxFileValue(uint32_t file_count) { // Always add 3 for "special" files file_count += 3; - // Based on file count, determine the max number of files an MPQ archive can hold - // We always have a minimum of 32 - // Anything over is rounded up to the closest power of 2 - // For example: 64, 128, 256 - // This is examples behavior of WoW MPQ archives (patches and installs) + // The table holds at least 32 entries; above that the count rounds up to the + // next power of two (64, 128, 256), which matches the WoW patch and install + // archives if (file_count <= 32) { return 32; } diff --git a/src/util/hash.cpp b/src/util/hash.cpp index 1e14704..c24b363 100644 --- a/src/util/hash.cpp +++ b/src/util/hash.cpp @@ -11,8 +11,6 @@ namespace fs = std::filesystem; namespace mpqcli { -// CRC32 (ZIP/gzip polynomial) and MD5 are provided by the hash-library -// submodule, matching the values StormLib stores in (attributes). std::optional ComputeFileCrc32(const fs::path &path) { std::ifstream f(path, std::ios::binary); if (!f) { diff --git a/src/util/locales.cpp b/src/util/locales.cpp index 89555bd..d231436 100644 --- a/src/util/locales.cpp +++ b/src/util/locales.cpp @@ -1,4 +1,4 @@ -#include "locales.h" +#include "util/locales.h" #include #include diff --git a/src/util/locales.h b/src/util/locales.h index 8eb222b..206e266 100644 --- a/src/util/locales.h +++ b/src/util/locales.h @@ -1,5 +1,5 @@ -#ifndef LOCALES_H -#define LOCALES_H +#ifndef UTIL_LOCALES_H +#define UTIL_LOCALES_H #include #include @@ -19,4 +19,4 @@ std::string PrettyPrintLocale(LCID locale, const std::string &prefix = "", } // namespace mpqcli -#endif // LOCALES_H +#endif diff --git a/src/util/path.cpp b/src/util/path.cpp index 9c3db3b..a7a5ae8 100644 --- a/src/util/path.cpp +++ b/src/util/path.cpp @@ -52,8 +52,6 @@ std::vector ListFilesRecursive(const fs::path &directory, std::error_c return files; } -// Returns the file's last-modification time as a Windows FILETIME value -// (100-nanosecond intervals since 1601-01-01 UTC). Returns 0 on error. uint64_t LocalFileTimestamp(const fs::path &path) { #ifdef _WIN32 // _wstat64 handles paths with non-ASCII characters, which the narrow diff --git a/src/util/path.h b/src/util/path.h index 186365e..75228ac 100644 --- a/src/util/path.h +++ b/src/util/path.h @@ -13,7 +13,7 @@ namespace mpqcli { std::string NormalizeFilePath(const std::filesystem::path &path); std::string WindowsifyFilePath(const std::filesystem::path &path); -/// Reports whether path resolves to a location inside base +/// Reports whether path lies lexically under base, without resolving either bool IsWithinDirectory(const std::filesystem::path &base, const std::filesystem::path &path); /// Builds the in-archive name for a local file From 7b297da3f2dd9b65e1a358cd5c4d8784c20efafe Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 23 Sep 2026 20:25:00 +1200 Subject: [PATCH 26/59] Removed narrating comments and aligned comment style with the conventions --- .github/workflows/build.yml | 9 +++------ .github/workflows/docs.yml | 2 +- .github/workflows/prerelease.yml | 2 +- .github/workflows/release.yml | 2 +- .gitignore | 2 +- completion/mpqcli.bash | 6 +++--- completion/mpqcli.fish | 2 +- completion/mpqcli.ps1 | 20 +++++++++----------- src/gamerules/rules.cpp | 30 ++++++++++++++---------------- src/mpq/add.cpp | 1 - src/mpq/compact.cpp | 1 - src/mpq/create.cpp | 1 - src/mpq/extract.cpp | 1 - src/mpq/info.cpp | 1 - src/mpq/list.cpp | 10 ++-------- src/mpq/query.cpp | 10 +++++----- src/util/hash.cpp | 2 +- src/util/locales.cpp | 5 ----- src/util/locales.h | 1 + 19 files changed, 43 insertions(+), 65 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 890f110..9417f63 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,11 +35,8 @@ jobs: - name: Extract binary from Docker image run: | - # Create a temporary container from the image CONTAINER_ID=$(docker create mpqcli-${{ matrix.arch }}) - # Copy the binary from the container docker cp $CONTAINER_ID:/mpqcli ./${{ matrix.asset }} - # Clean up the container docker rm $CONTAINER_ID # Run the bytes that will ship: a static binary missing its loader builds @@ -49,7 +46,7 @@ jobs: chmod +x ./${{ matrix.asset }} ./${{ matrix.asset }} version - - name: Upload binary as artifact + - name: Upload binary as artefact uses: actions/upload-artifact@v7 with: name: ${{ matrix.asset }} @@ -65,7 +62,7 @@ jobs: docker tag mpqcli-${{ matrix.arch }} mpqcli docker save mpqcli -o mpqcli-docker.tar - - name: Upload Docker image as artifact + - name: Upload Docker image as artefact if: matrix.arch == 'amd64' uses: actions/upload-artifact@v7 with: @@ -98,7 +95,7 @@ jobs: - name: Copy mpqcli binary run: cp build/bin/Release/mpqcli.exe build/bin/mpqcli-windows-x86_64.exe - - name: Upload Artifact + - name: Upload binary as artefact uses: actions/upload-artifact@v7 with: name: mpqcli-windows-x86_64.exe diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b86979c..4d75ba3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -46,7 +46,7 @@ jobs: - name: Build docs run: make docs_build - - name: Upload Pages artifact + - name: Upload Pages artefact uses: actions/upload-pages-artifact@v5 with: path: book/ diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index c725d83..3cc64c0 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -49,7 +49,7 @@ jobs: needs: prerelease_binaries runs-on: ubuntu-24.04 steps: - - name: Download Docker image artifact + - name: Download Docker image artefact uses: actions/download-artifact@v8 with: name: mpqcli-docker diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 77767f8..cfc2e6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: needs: release_binaries runs-on: ubuntu-24.04 steps: - - name: Download Docker image artifact + - name: Download Docker image artefact uses: actions/download-artifact@v8 with: name: mpqcli-docker diff --git a/.gitignore b/.gitignore index d8eec25..7d2fda3 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,7 @@ /build # PYTHON -# Byte-compiled / optimized / DLL files +# Byte-compiled / optimised / DLL files __pycache__/ *.py[cod] *$py.class diff --git a/completion/mpqcli.bash b/completion/mpqcli.bash index cc247da..283f3fd 100644 --- a/completion/mpqcli.bash +++ b/completion/mpqcli.bash @@ -18,7 +18,7 @@ _mpqcli_filedir() { _mpqcli() { local cur prev words cword - # Use bash-completion helpers when available, fall back to COMP_* variables. + # Use bash-completion helpers when available, fall back to COMP_* variables if declare -f _init_completion > /dev/null 2>&1; then _init_completion || return else @@ -150,7 +150,7 @@ _mpqcli() { if [[ "$cur" == -* ]]; then mapfile -t COMPREPLY < <(compgen -W "--locale" -- "$cur") elif [[ $cword -eq 2 ]]; then - # positional 1 is the archive; positionals 2 and 3 are in-archive + # Positional 1 is the archive; positionals 2 and 3 are in-archive # paths, so they get no filesystem completion _mpqcli_filedir mpq fi @@ -202,7 +202,7 @@ _mpqcli() { if [[ "$cur" == -* ]]; then mapfile -t COMPREPLY < <(compgen -W "--locale" -- "$cur") elif [[ $cword -ge 3 ]]; then - # positional 1 is an in-archive path (no filesystem completion); + # Positional 1 is an in-archive path (no filesystem completion); # positional 2+ is the archive file _mpqcli_filedir mpq fi diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 2d7b3b6..3d467aa 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -4,7 +4,7 @@ # Install: # cp mpqcli.fish ~/.config/fish/completions/mpqcli.fish -# Disable file completion globally; re-enable per argument where needed. +# Disable file completion globally; re-enable per argument where needed complete -c mpqcli -f # Shared value sets (sourced directly from main.cpp) diff --git a/completion/mpqcli.ps1 b/completion/mpqcli.ps1 index 1dfd51d..d3f2fa3 100644 --- a/completion/mpqcli.ps1 +++ b/completion/mpqcli.ps1 @@ -158,27 +158,25 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl 'completion' = @{} } - # Options whose *argument* should complete from a static value set. + # Options whose *argument* should complete from a static value set $valueOptions = @{ '--locale' = $locales '-g' = $gameProfiles '--game' = $gameProfiles } - # Property options depend on the subcommand (info vs list), handled below. + # Property options depend on the subcommand (info vs list), handled below - # Parse the current command line - - # Tokenized elements of the command line, excluding the executable itself. + # Tokenised elements of the command line, excluding the executable itself $elements = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() }) - # Identify the active subcommand (first element that is a known subcommand). + # Identify the active subcommand (first element that is a known subcommand) $subcommand = $null foreach ($el in $elements) { if ($subcommands.Contains($el)) { $subcommand = $el; break } } - # The token immediately preceding the cursor (the one we may be an arg to). + # The token immediately preceding the cursor (the one we may be an arg to) $prevToken = $null if ($elements.Count -ge 1) { if ([string]::IsNullOrEmpty($wordToComplete)) { @@ -228,14 +226,14 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl # 4) Completing the argument to a value-bearing option if ($prevToken) { - # Locale / game profile options. + # Locale / game profile options if ($valueOptions.ContainsKey($prevToken)) { $items = foreach ($v in $valueOptions[$prevToken]) { [pscustomobject]@{ Value = $v; Tip = $prevToken } } return & $emit $items 'ParameterValue' } - # Property options: meaning depends on the subcommand. + # Property options: meaning depends on the subcommand if ($prevToken -in @('-p', '--property')) { if ($subcommand -eq 'info') { $items = foreach ($v in $infoProperties) { @@ -247,9 +245,9 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl [pscustomobject]@{ Value = $v; Tip = 'list property' } } return & $emit $items 'ParameterValue' } - # For 'verify', -p/--print is a flag (no value) -> fall through. + # For 'verify', -p/--print is a flag (no value) -> fall through } - # Options that take a file/dir path -> let PowerShell complete paths. + # Options that take a file/dir path -> let PowerShell complete paths $pathOptions = @('-o', '--output', '-l', '--listfile', '-f', '--file', '-p', '--path') if ($prevToken -in $pathOptions -and diff --git a/src/gamerules/rules.cpp b/src/gamerules/rules.cpp index 007feb1..d9997a9 100644 --- a/src/gamerules/rules.cpp +++ b/src/gamerules/rules.cpp @@ -14,7 +14,6 @@ GameRules::GameRules(GameProfile game_profile) : profile_(game_profile) { } bool GameRules::MatchFileMask(const std::string &filename, const std::string &mask) { - // Convert both to lowercase for case-insensitive matching std::string lower_filename = ToLower(filename); std::string lower_mask = ToLower(mask); @@ -69,7 +68,7 @@ void GameRules::AddRuleDefault(DWORD mpq_flags, DWORD compression_first, DWORD c CompressionSettings GameRules::GetCompressionSettings(const std::string &filename, const DWORD file_size) const { - // Iterate through rules in order (first match wins) + // First matching rule wins for (const auto &rule : rules_) { switch (rule.type) { case RuleType::FILE_MASK: @@ -101,12 +100,11 @@ CompressionSettings GameRules::GetCompressionSettings(const std::string &filenam } void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides &overrides) { - // Track whether user explicitly set fileFlags2 (needed for automatic adjustment logic) + // Whether the user set file_flags2 themselves decides the adjustment below bool user_set_file_flags2 = false; - // Step 1: Apply user overrides - // User-provided values always take priority, even if they might be incorrect. - // We only apply override if the optional has a value (i.e., user specified it) + // User overrides first: a value the user gave always wins, even one that + // looks wrong if (overrides.mpq_version.has_value()) { create_settings_.mpq_version = overrides.mpq_version.value(); @@ -141,24 +139,24 @@ void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides &overrid create_settings_.attr_flags = overrides.attr_flags.value(); } - // Step 2: Apply automatic adjustments based on dependencies - // These only apply if the user hasn't explicitly overridden the values + // Then the adjustments the overrides imply, only where the user left the + // dependent value alone - // fileFlags2 controls the (attributes) file, which is only meaningful when - // attrFlags is also set. According to StormLib's SFileCreateArchive.cpp: - // - The (attributes) file is created only when BOTH fileFlags2 AND attrFlags are non-zero - // - If attrFlags is set but fileFlags2 is still 0 (not overridden by user or profile), - // we should set fileFlags2 to MPQ_FILE_DEFAULT_INTERNAL to enable the attributes file + // file_flags2 controls the (attributes) file, which is only meaningful when + // attr_flags is also set. According to StormLib's SFileCreateArchive.cpp: + // - The (attributes) file is created only when BOTH file_flags2 AND attr_flags are non-zero + // - If attr_flags is set but file_flags2 is still 0 (not overridden by user or profile), + // we should set file_flags2 to MPQ_FILE_DEFAULT_INTERNAL to enable the attributes file if (!user_set_file_flags2 && create_settings_.file_flags2 == 0 && create_settings_.attr_flags != 0) { - // User wants attributes (attrFlags is set) but hasn't specified how to store + // User wants attributes (attr_flags is set) but hasn't specified how to store // the (attributes) file itself. Use the default internal file flags. create_settings_.file_flags2 = MPQ_FILE_DEFAULT_INTERNAL; } - // Note: If user explicitly sets fileFlags2 to 0 via override, we respect that choice - // even if attrFlags is non-zero. + // Note: If user explicitly sets file_flags2 to 0 via override, we respect that choice + // even if attr_flags is non-zero. } } // namespace mpqcli diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index c434c74..6c2ceb2 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -85,7 +85,6 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi return 1; } - // Check if file exists in MPQ archive SFileSetLocale(locale); HANDLE file; if (SFileOpenFileEx(archive, archive_file_path.c_str(), SFILE_OPEN_FROM_MPQ, &file)) { diff --git a/src/mpq/compact.cpp b/src/mpq/compact.cpp index 47da094..ae553ad 100644 --- a/src/mpq/compact.cpp +++ b/src/mpq/compact.cpp @@ -12,7 +12,6 @@ namespace mpqcli { int CompactMpqArchive(HANDLE archive, const std::optional &listfile_name) { std::cout << "[*] Compacting archive. This may take some time..." << std::endl; - // Check if the user provided a listfile input const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; if (!SFileCompactArchive(archive, listfile, false)) { diff --git a/src/mpq/create.cpp b/src/mpq/create.cpp index 6f76731..bab8e1d 100644 --- a/src/mpq/create.cpp +++ b/src/mpq/create.cpp @@ -17,7 +17,6 @@ namespace mpqcli { HANDLE CreateMpqArchive(const std::string &output_archive_name, const uint32_t file_count, const GameRules &game_rules) { - // Check if file already exists std::error_code ec; if (fs::exists(output_archive_name, ec)) { std::cerr << "[!] File already exists: " << output_archive_name << " Exiting..." diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index 9d507a5..845c474 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -21,7 +21,6 @@ namespace mpqcli { int ExtractFiles(HANDLE archive, const std::string &output, const std::optional &listfile_name, LCID preferred_locale) { SFileSetLocale(preferred_locale); - // Check if the user provided a listfile input const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; SFILE_FIND_DATA find_data; diff --git a/src/mpq/info.cpp b/src/mpq/info.cpp index d706f8a..985af25 100644 --- a/src/mpq/info.cpp +++ b/src/mpq/info.cpp @@ -15,7 +15,6 @@ namespace mpqcli { void PrintMpqInfo(HANDLE archive, const std::optional &info_property) { - // Map of property names to their corresponding actions std::map> property_actions = { {"format-version", [&](bool print_name) { diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp index 4069ada..75aa793 100644 --- a/src/mpq/list.cpp +++ b/src/mpq/list.cpp @@ -21,7 +21,6 @@ namespace mpqcli { int ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, bool list_detailed, const std::vector &properties) { - // Check if the user provided a listfile input const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; SFILE_FIND_DATA find_data; @@ -58,7 +57,6 @@ int ListFiles(HANDLE archive, const std::optional &listfile_name, b std::set seen_file_names; // 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 (!list_all && std::find(special_mpq_files.begin(), special_mpq_files.end(), @@ -74,8 +72,7 @@ int ListFiles(HANDLE archive, const std::optional &listfile_name, b } seen_file_names.insert(find_data.cFileName); - // Multiple files can be stored with identical filenames under different locales. - // Loop over all locales and print the file details for each locale. + // Multiple files can be stored with identical filenames under different locales DWORD max_locales = 32; // This will be updated in the call to SFileEnumLocales std::vector file_locale_vec(max_locales); LCID *file_locales = file_locale_vec.data(); @@ -101,17 +98,14 @@ int ListFiles(HANDLE archive, const std::optional &listfile_name, b << ". Will only list the " << max_locales << " first files." << std::endl; } - // Loop through all found locales for (DWORD i = 0; i < max_locales; i++) { LCID locale = file_locales[i]; SFileSetLocale(locale); HANDLE file; - // We need to open the file to get detailed information - // Use our custom GetFileInfo function if (!SFileOpenFileEx(archive, find_data.cFileName, SFILE_OPEN_FROM_MPQ, &file)) { std::cerr << "[!] Failed to open file: " << find_data.cFileName << std::endl; - continue; // Skip to the next file + continue; } for (const auto &prop : properties_to_print) { diff --git a/src/mpq/query.cpp b/src/mpq/query.cpp index e17af18..3a2ff2d 100644 --- a/src/mpq/query.cpp +++ b/src/mpq/query.cpp @@ -42,11 +42,11 @@ bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file const DWORD attr_flags = SFileGetAttributes(archive); - // Step 1: Timestamp: cheapest check, no local file I/O. + // Timestamp first: the cheapest check, with no local file I/O if (attr_flags & MPQ_ATTRIBUTE_FILETIME) { const uint64_t archived_time = GetFileInfo(file, SFileInfoFileTime); const uint64_t local_time = LocalFileTimestamp(local_file); - // Compare at second resolution: stat() has only second precision. + // Compare at second resolution: stat() has only second precision if (archived_time != 0 && local_time != 0 && archived_time / 10000000u == local_time / 10000000u) { match_reason = "Timestamp matches"; @@ -54,7 +54,7 @@ bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file } } - // Step 2: MD5: if timestamp did not match or was unavailable. + // MD5 when the timestamp did not match or was unavailable if (attr_flags & MPQ_ATTRIBUTE_MD5) { uint8_t archived_md5[MD5_DIGEST_SIZE]{}; if (SFileGetFileInfo(file, SFileInfoMD5, archived_md5, sizeof(archived_md5), nullptr)) { @@ -73,7 +73,7 @@ bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file } } - // Step 3: CRC32: if neither timestamp nor MD5 matched or was available. + // CRC32 when neither timestamp nor MD5 matched or was available if (attr_flags & MPQ_ATTRIBUTE_CRC32) { const DWORD archived_crc32 = GetFileInfo(file, SFileInfoCRC32); // Zero means "no CRC32 stored"; a file whose real CRC32 is @@ -88,7 +88,7 @@ bool ArchivedFileMatches(HANDLE archive, HANDLE file, const fs::path &local_file } } - // No attributes present, or none matched: re-add the file. + // No attributes present, or none matched: re-add the file return false; } diff --git a/src/util/hash.cpp b/src/util/hash.cpp index c24b363..7be096c 100644 --- a/src/util/hash.cpp +++ b/src/util/hash.cpp @@ -21,7 +21,7 @@ std::optional ComputeFileCrc32(const fs::path &path) { while (f.read(buf, sizeof(buf)) || f.gcount() > 0) { crc32.add(buf, static_cast(f.gcount())); } - // getHash yields the checksum as big-endian bytes; reassemble the value. + // getHash yields the checksum as big-endian bytes; reassemble the value unsigned char digest[CRC32::HashBytes]; crc32.getHash(digest); return (static_cast(digest[0]) << 24) | (static_cast(digest[1]) << 16) | diff --git a/src/util/locales.cpp b/src/util/locales.cpp index d231436..95cba26 100644 --- a/src/util/locales.cpp +++ b/src/util/locales.cpp @@ -16,7 +16,6 @@ namespace { // 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 locale_to_lang_map = { {0x000, "enUS"}, // Default - English (US) {0x404, "zhTW"}, // Chinese (Taiwan) @@ -62,21 +61,17 @@ std::string FormatLocaleAsHex(const LCID locale) { } } // 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) { if (str.length() != 4) { return default_locale; } - // Check if all characters are hexadecimal for (char c : str) { if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) { return default_locale; } } - // Parse the hexadecimal string std::stringstream ss; ss << std::hex << str; LCID locale; diff --git a/src/util/locales.h b/src/util/locales.h index 206e266..b45d6c2 100644 --- a/src/util/locales.h +++ b/src/util/locales.h @@ -12,6 +12,7 @@ inline constexpr LCID default_locale = 0; std::string LocaleToLang(LCID locale); LCID LangToLocale(const std::string &lang); +/// Parses a four-digit hexadecimal locale, returning default_locale for anything else LCID ParseHexLocale(const std::string &str); std::vector GetAllLocales(); std::string PrettyPrintLocale(LCID locale, const std::string &prefix = "", From 219f8bc4cf3ca03940bcc7d1e9ac539af668a9b6 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 23 Sep 2026 20:31:00 +1200 Subject: [PATCH 27/59] Updated the contributing guide for the renamed targets and fixed docs characters and spelling --- CONTRIBUTING.md | 9 ++++----- README.md | 2 +- docs/building.md | 2 +- docs/commands/add.md | 8 ++++---- docs/commands/create.md | 2 +- docs/contributing.md | 9 ++++----- docs/introduction.md | 2 +- 7 files changed, 16 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 042ea70..ba99240 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ Run `make help` to list all available targets. Common ones: | `make format` | Auto-fix formatting in-place | | `make configure_lint` | Configure build-lint/ with clang++ for clang-tidy | | `make check_lint` | Run clang-tidy static analysis | -| `make clean` | Remove all build, test and docs artifacts | +| `make clean` | Remove all build, test and docs artefacts | ## Requirements for a Pull Request @@ -73,11 +73,10 @@ If your change adds or modifies user-facing functionality - such as a new subcom ### 4. Linting must pass -All C++ code is formatted with clang-format and analysed with clang-tidy. `clang-tidy` needs a compile database generated with clang, so run `make configure` first (`make build_linux`/`make build_windows` alone will not work, since they don't set up the compiler flags clang-tidy needs): +All C++ code is formatted with clang-format and analysed with clang-tidy. `make check_all` runs both checks. It configures a clang build tree of its own for clang-tidy, so it needs no prior `make configure`: ``` -make configure -make lint +make check_all ``` If there are formatting violations, auto-fix them with: @@ -134,6 +133,6 @@ If you add a new StormLib call that is locale-sensitive, follow the existing pat 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 configure` and then `make check_all`, fixing any issues +5. Run `make check_all`, fixing 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 0e49c4d..0b50899 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A command-line tool to create, add, remove, list, extract, read, rename, and verify MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). -> ⚠️ **Warning:** This project is under active development and will change functionality between released versions until version 1.0.0. +> **Warning:** This project is under active development and will change functionality between released versions until version 1.0.0. ## Overview diff --git a/docs/building.md b/docs/building.md index 7bcd190..5bc7eeb 100644 --- a/docs/building.md +++ b/docs/building.md @@ -34,7 +34,7 @@ The `mpqcli.exe` binary will be available in: `.\build\bin\Release\mpqcli.exe` ### StormLib -This project requires the [StormLib](https://github.com/ladislav-zezula/StormLib) library. Many thanks to [Ladislav Zezula](https://github.com/ladislav-zezula) for authoring such a good library and releasing the code under an open-source license. The StormLib library has a number of requirements. However, the build method specifies using the libraries bundled with StormLib. +This project requires the [StormLib](https://github.com/ladislav-zezula/StormLib) library. Many thanks to [Ladislav Zezula](https://github.com/ladislav-zezula) for authoring such a good library and releasing the code under an open-source licence. The StormLib library has a number of requirements. However, the build method specifies using the libraries bundled with StormLib. ### CLI11 diff --git a/docs/commands/add.md b/docs/commands/add.md index f31b2f4..e48f09e 100644 --- a/docs/commands/add.md +++ b/docs/commands/add.md @@ -136,13 +136,13 @@ The skip decision follows this chain: 1. **File size** must match. If the sizes differ the file is always re-added. 2. If the sizes match, the archive's `(attributes)` file is consulted: - - **Timestamp** – if the archive stores file timestamps, the local file's + - **Timestamp**: if the archive stores file timestamps, the local file's last-modification time is compared at one-second resolution. A match skips the file. - - **MD5** – if the timestamp did not match or is unavailable, and the archive stores MD5 + - **MD5**: if the timestamp did not match or is unavailable, and the archive stores MD5 checksums, the MD5 of the local file is computed and compared. A match skips the file. - - **CRC32** – if neither timestamp nor MD5 produced a match or was available, and the + - **CRC32**: if neither timestamp nor MD5 produced a match or was available, and the archive stores CRC32 checksums, those are compared. A match skips the file. - - **No attributes** – if the archive has no `(attributes)` file, the file is always + - **No attributes**: if the archive has no `(attributes)` file, the file is always re-added even when sizes match, because no reliable content check is possible. Note: a timestamp match alone skips the file, without comparing checksums. A file whose diff --git a/docs/commands/create.md b/docs/commands/create.md index cbdb31a..4ec1f68 100644 --- a/docs/commands/create.md +++ b/docs/commands/create.md @@ -10,7 +10,7 @@ Create an MPQ file from a target directory. Automatically adds `(listfile)` to t $ mpqcli create ``` -The default mode of operation for the `create` subcommand is to take everything from the "target" directory (and below) and recursively add it to the archive. The directory structure is retained. Windows-style backslash path separators are used (`\`), as per the observed behavior in most MPQ archives. +The default mode of operation for the `create` subcommand is to take everything from the "target" directory (and below) and recursively add it to the archive. The directory structure is retained. Windows-style backslash path separators are used (`\`), as per the observed behaviour in most MPQ archives. ## Create an MPQ archive for a specific game diff --git a/docs/contributing.md b/docs/contributing.md index 042ea70..ba99240 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -41,7 +41,7 @@ Run `make help` to list all available targets. Common ones: | `make format` | Auto-fix formatting in-place | | `make configure_lint` | Configure build-lint/ with clang++ for clang-tidy | | `make check_lint` | Run clang-tidy static analysis | -| `make clean` | Remove all build, test and docs artifacts | +| `make clean` | Remove all build, test and docs artefacts | ## Requirements for a Pull Request @@ -73,11 +73,10 @@ If your change adds or modifies user-facing functionality - such as a new subcom ### 4. Linting must pass -All C++ code is formatted with clang-format and analysed with clang-tidy. `clang-tidy` needs a compile database generated with clang, so run `make configure` first (`make build_linux`/`make build_windows` alone will not work, since they don't set up the compiler flags clang-tidy needs): +All C++ code is formatted with clang-format and analysed with clang-tidy. `make check_all` runs both checks. It configures a clang build tree of its own for clang-tidy, so it needs no prior `make configure`: ``` -make configure -make lint +make check_all ``` If there are formatting violations, auto-fix them with: @@ -134,6 +133,6 @@ If you add a new StormLib call that is locale-sensitive, follow the existing pat 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 configure` and then `make check_all`, fixing any issues +5. Run `make check_all`, fixing 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/docs/introduction.md b/docs/introduction.md index c0a4e1f..46978de 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -2,7 +2,7 @@ A command-line tool to create, add, remove, list, extract, read, rename, verify, and compact MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). -> ⚠️ **Warning:** This project is under active development and will change functionality between released versions until version 1.0.0. +> **Warning:** This project is under active development and will change functionality between released versions until version 1.0.0. ## Overview From ac913b49053bd60991b84bb91ad9b9c029884b80 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Wed, 23 Sep 2026 20:38:00 +1200 Subject: [PATCH 28/59] Bumped the version to 0.12.0 and drafted its changelog entry --- CHANGELOG.md | 13 +++++++++++++ CMakeLists.txt | 3 +-- docs/changelog.md | 13 +++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd9853..761a0bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.12.0 - 2026-09-25 + +### Changed + +- Rename the release assets to mpqcli-linux-x86_64, mpqcli-linux-aarch64 and mpqcli-windows-x86_64.exe +- Ship one static musl Linux binary per architecture instead of glibc and musl pairs +- Publish install scripts and a signed checksums file with each release +- Run the Docker image in /data so mounted paths need no prefix + +### Fixed + +- Fail with a non-zero exit code on an unknown subcommand or a missing argument + ## 0.11.0 - 2026-09-21 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index eb073a7..8f013c7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.25) -project(MPQCLI VERSION 0.11.0) +project(MPQCLI VERSION 0.12.0) # An option() name is global to the CMake cache, including a consumer's, so these # carry the project prefix; a bare BUILD_STATIC claims a name anyone might want. @@ -26,7 +26,6 @@ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") # Windows specific settings if(MSVC) add_compile_definitions(__STORMLIB_NO_STATIC_LINK__) - set(CMAKE_GENERATOR_PLATFORM x64) endif() # Static linking configuration diff --git a/docs/changelog.md b/docs/changelog.md index 7fd9853..761a0bf 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,18 @@ # Changelog +## 0.12.0 - 2026-09-25 + +### Changed + +- Rename the release assets to mpqcli-linux-x86_64, mpqcli-linux-aarch64 and mpqcli-windows-x86_64.exe +- Ship one static musl Linux binary per architecture instead of glibc and musl pairs +- Publish install scripts and a signed checksums file with each release +- Run the Docker image in /data so mounted paths need no prefix + +### Fixed + +- Fail with a non-zero exit code on an unknown subcommand or a missing argument + ## 0.11.0 - 2026-09-21 ### Added From 9acf0120fe045e111f3c23061d8d3f2b3bdd5f52 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 19:05:00 +1200 Subject: [PATCH 29/59] Set the per-config output directories so every generator writes to bin --- .github/workflows/build.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- CMakeLists.txt | 8 ++++++++ docs/building.md | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9417f63..e8819b0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -90,10 +90,10 @@ jobs: run: cmake --build build --config Release - name: Check mpqcli version - run: build/bin/Release/mpqcli.exe version + run: build/bin/mpqcli.exe version - name: Copy mpqcli binary - run: cp build/bin/Release/mpqcli.exe build/bin/mpqcli-windows-x86_64.exe + run: cp build/bin/mpqcli.exe build/bin/mpqcli-windows-x86_64.exe - name: Upload binary as artefact uses: actions/upload-artifact@v7 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2577f98..243a825 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,10 +70,10 @@ jobs: uses: actions/download-artifact@v8 with: name: mpqcli-windows-x86_64.exe - path: build/bin/Release + path: build/bin - name: Rename binary to "mpqcli" - run: mv build/bin/Release/mpqcli-windows-x86_64.exe build/bin/mpqcli.exe + run: mv build/bin/mpqcli-windows-x86_64.exe build/bin/mpqcli.exe - name: Check mpqcli version run: ./build/bin/mpqcli.exe version diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f013c7..26a9382 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,14 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") +# Multi-config generators (Visual Studio, Xcode) append the config name to the +# output directory, so the bare variable above would put the binary in +# bin/Release/ rather than bin/. Set all four configs, not just RELEASE, so a +# Debug build in an IDE lands in the same place. +foreach(cfg IN ITEMS DEBUG RELEASE RELWITHDEBINFO MINSIZEREL) + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_${cfg} "${CMAKE_BINARY_DIR}/bin") +endforeach() + # Windows specific settings if(MSVC) add_compile_definitions(__STORMLIB_NO_STATIC_LINK__) diff --git a/docs/building.md b/docs/building.md index 5bc7eeb..4495b7c 100644 --- a/docs/building.md +++ b/docs/building.md @@ -28,7 +28,7 @@ $ cmake -B build $ cmake --build build --config Release ``` -The `mpqcli.exe` binary will be available in: `.\build\bin\Release\mpqcli.exe` +The `mpqcli.exe` binary will be available in: `.\build\bin\mpqcli.exe` ## Dependencies From 73d207d0ae8b671197f5d7d522ac885b4e365770 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 19:14:00 +1200 Subject: [PATCH 30/59] Installed the clang compiler alongside the lint tools --- CONTRIBUTING.md | 2 +- Makefile | 2 +- docs/contributing.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba99240..7e140b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ Run `make help` to list all available targets. Common ones: | Target | Description | |----------------------------|----------------------------------------------------------------------| -| `make install_clang_tools` | Install clang-format and clang-tidy via apt | +| `make install_clang_tools` | Install clang, clang-format and clang-tidy via apt | | `make configure` | Configure the cmake build (uses the default compiler) | | `make build_linux` | Build for Linux using cmake | | `make build_windows` | Build for Windows using cmake | diff --git a/Makefile b/Makefile index 985bbf6..796aafa 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ help: ## Show this help message ##@ BUILD .PHONY: install_clang_tools install_clang_tools: ## Install clang lint dependencies - sudo apt-get install -y clang-format-$(CLANG_VERSION) clang-tidy-$(CLANG_VERSION) + sudo apt-get install -y clang-$(CLANG_VERSION) clang-format-$(CLANG_VERSION) clang-tidy-$(CLANG_VERSION) .PHONY: configure configure: ## Configure cmake build (debug, with compile_commands.json) diff --git a/docs/contributing.md b/docs/contributing.md index ba99240..7e140b9 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -30,7 +30,7 @@ Run `make help` to list all available targets. Common ones: | Target | Description | |----------------------------|----------------------------------------------------------------------| -| `make install_clang_tools` | Install clang-format and clang-tidy via apt | +| `make install_clang_tools` | Install clang, clang-format and clang-tidy via apt | | `make configure` | Configure the cmake build (uses the default compiler) | | `make build_linux` | Build for Linux using cmake | | `make build_windows` | Build for Windows using cmake | From d466f1203da2691fbec3da746717a400c4d3e9e9 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 19:22:00 +1200 Subject: [PATCH 31/59] Stopped cancelling in-progress runs on main --- .github/workflows/main.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1fd4548..813f4d2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,9 +20,13 @@ on: - ".dockerignore" - ".gpipe.yml" +# Never cancel on main. The prerelease job deletes the dev release, force-moves +# the tag and pushes the image; a run cancelled part-way through leaves those +# three disagreeing with each other, which costs more than the runner minutes +# cancelling would save concurrency: - group: prerelease - cancel-in-progress: true + group: main-${{ github.ref }} + cancel-in-progress: false permissions: contents: write From 2e2827361857e4d8632c2f0a86e73ab6966f7fd3 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 19:29:00 +1200 Subject: [PATCH 32/59] Granted the build and lint workflows read-only contents --- .github/workflows/build.yml | 3 +++ .github/workflows/lint.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e8819b0..8020cb8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,9 @@ name: Build on: workflow_call +permissions: + contents: read + jobs: build_linux: strategy: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 148bbde..71aacff 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,6 +3,9 @@ name: Lint on: workflow_call +permissions: + contents: read + jobs: lint_cpp: runs-on: ubuntu-24.04 From 96cf2f5ab9505af7bd570b333a478d09e8292006 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 19:38:00 +1200 Subject: [PATCH 33/59] Included the headers each file uses --- app/main.cpp | 1 + src/gamerules/profiles.cpp | 1 + src/util/format.cpp | 1 + src/util/hash.cpp | 1 + src/util/locales.cpp | 1 + 5 files changed, 5 insertions(+) diff --git a/app/main.cpp b/app/main.cpp index 167d634..aaa85c2 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -1,3 +1,4 @@ +#include #include #include #include diff --git a/src/gamerules/profiles.cpp b/src/gamerules/profiles.cpp index 58354b1..0caf9d3 100644 --- a/src/gamerules/profiles.cpp +++ b/src/gamerules/profiles.cpp @@ -1,3 +1,4 @@ +#include #include #include #include diff --git a/src/util/format.cpp b/src/util/format.cpp index a4a2812..f145988 100644 --- a/src/util/format.cpp +++ b/src/util/format.cpp @@ -1,5 +1,6 @@ #include "util/format.h" +#include #include #include #include diff --git a/src/util/hash.cpp b/src/util/hash.cpp index 7be096c..c9cc77a 100644 --- a/src/util/hash.cpp +++ b/src/util/hash.cpp @@ -1,5 +1,6 @@ #include "util/hash.h" +#include #include #include #include diff --git a/src/util/locales.cpp b/src/util/locales.cpp index 95cba26..b80f474 100644 --- a/src/util/locales.cpp +++ b/src/util/locales.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace mpqcli { From c91d52ea0b4a819da70268635c202398dbe116c4 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 19:45:00 +1200 Subject: [PATCH 34/59] Dropped an unused include from the archive module --- src/mpq/archive.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mpq/archive.cpp b/src/mpq/archive.cpp index 41d7268..98b070c 100644 --- a/src/mpq/archive.cpp +++ b/src/mpq/archive.cpp @@ -1,6 +1,5 @@ #include "mpq/archive.h" -#include #include #include From 1c6cd048674c38b92c6fc234cfa2d8add41d7c1c Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 19:52:00 +1200 Subject: [PATCH 35/59] Closed every header guard the same way --- app/commands.h | 2 +- app/validators.h | 2 +- src/cli/commands.h | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/commands.h b/app/commands.h index d785960..1db7cc1 100644 --- a/app/commands.h +++ b/app/commands.h @@ -9,4 +9,4 @@ void HandleCompletionZsh(); void HandleCompletionPs(); void HandleCompletionFish(); -#endif // COMMANDS_H +#endif diff --git a/app/validators.h b/app/validators.h index d2b0cdf..e103b66 100644 --- a/app/validators.h +++ b/app/validators.h @@ -6,4 +6,4 @@ extern const CLI::Validator game_profile_valid; extern const CLI::Validator locale_valid; -#endif // VALIDATORS_H +#endif diff --git a/src/cli/commands.h b/src/cli/commands.h index 2ecb80f..8d401c7 100644 --- a/src/cli/commands.h +++ b/src/cli/commands.h @@ -38,4 +38,4 @@ int HandleCompact(const std::string &target, const std::optional &l } // namespace mpqcli -#endif // CLI_COMMANDS_H +#endif From e2c846025ffa4a77e209e1a9e6966cc297b7d89d Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 20:00:00 +1200 Subject: [PATCH 36/59] Added the core alias and linked the app through it --- app/CMakeLists.txt | 2 +- src/CMakeLists.txt | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 359d53a..85d607f 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -27,4 +27,4 @@ add_executable(mpqcli # every generated file in the build tree to #include target_include_directories(mpqcli PRIVATE "${PROJECT_BINARY_DIR}/include") -target_link_libraries(mpqcli PRIVATE mpqcli_core CLI11::CLI11 mpqcli::warnings) +target_link_libraries(mpqcli PRIVATE mpqcli::core CLI11::CLI11 mpqcli::warnings) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 31a73c9..6b65f8e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -25,9 +25,9 @@ configure_file( @ONLY ) -# Internal to this repository, so no include/ and no alias. Keeping the logic in -# a library rather than beside main() is what lets test binaries link it; a test -# that recompiles the sources instead is a second build that drifts from the +# Internal to this repository, so no include/. Keeping the logic in a library +# rather than beside main() is what lets test binaries link it; a test that +# recompiles the sources instead is a second build that drifts from the # shipped one. add_library(mpqcli_core STATIC cli/add.cpp @@ -72,8 +72,13 @@ target_include_directories(mpqcli_core PUBLIC "${PROJECT_BINARY_DIR}/include" ) target_include_directories(mpqcli_core SYSTEM PUBLIC - "${CMAKE_SOURCE_DIR}/extern/StormLib/src" + "${PROJECT_SOURCE_DIR}/extern/StormLib/src" ) target_link_libraries(mpqcli_core PUBLIC storm hash-library) target_link_libraries(mpqcli_core PRIVATE mpqcli::warnings) + +# Nothing outside this repository links the core. The alias is for the +# configure-time check: a typo in mpqcli::core fails now, where a typo in a raw +# mpqcli_core is taken for a system library and fails at link time instead +add_library(mpqcli::core ALIAS mpqcli_core) From fa61a32dd4c5824692276a41217bc69e6dee0eba Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 20:07:00 +1200 Subject: [PATCH 37/59] Referred to project directories rather than the outermost project --- CMakeLists.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 26a9382..d665767 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,14 +21,14 @@ endif() set(CMAKE_POSITION_INDEPENDENT_CODE ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/bin") # Multi-config generators (Visual Studio, Xcode) append the config name to the # output directory, so the bare variable above would put the binary in # bin/Release/ rather than bin/. Set all four configs, not just RELEASE, so a # Debug build in an IDE lands in the same place. foreach(cfg IN ITEMS DEBUG RELEASE RELWITHDEBINFO MINSIZEREL) - set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_${cfg} "${CMAKE_BINARY_DIR}/bin") + set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_${cfg} "${PROJECT_BINARY_DIR}/bin") endforeach() # Windows specific settings @@ -46,7 +46,7 @@ endif() # Determine git commit hash execute_process( COMMAND git rev-parse HEAD - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_VARIABLE GIT_COMMIT_HASH RESULT_VARIABLE GIT_COMMIT_RESULT @@ -57,7 +57,7 @@ if(NOT GIT_COMMIT_RESULT EQUAL 0 OR NOT GIT_COMMIT_HASH) endif() # Handle StormLib dependency -if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/StormLib/CMakeLists.txt") +if (NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/StormLib/CMakeLists.txt") message(FATAL_ERROR "Missing dependency: StormLib mpqcli requires the StormLib library. @@ -76,7 +76,7 @@ add_subdirectory(extern/StormLib) # Handle CLI11 dependency. Only app/ parses arguments, so the core never sees it. if(MPQCLI_BUILD_APP) - if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/CLI11/CMakeLists.txt") + if (NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/CLI11/CMakeLists.txt") message(FATAL_ERROR "Missing dependency: CLI11 mpqcli requires the CLI11 library. @@ -90,7 +90,7 @@ Did you forget to execute the following commands? endif() # Handle hash-library dependency (CRC32 + MD5 for the add --update checks) -if (NOT EXISTS "${CMAKE_SOURCE_DIR}/extern/hash-library/crc32.cpp") +if (NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/hash-library/crc32.cpp") message(FATAL_ERROR "Missing dependency: hash-library mpqcli requires the hash-library library. @@ -105,7 +105,7 @@ add_library(hash-library STATIC extern/hash-library/crc32.cpp extern/hash-library/md5.cpp ) -target_include_directories(hash-library SYSTEM INTERFACE "${CMAKE_SOURCE_DIR}/extern") +target_include_directories(hash-library SYSTEM INTERFACE "${PROJECT_SOURCE_DIR}/extern") # Unconditional: building the core without the app is what proves nothing in # src/ has picked up a dependency on app/. From 52f9f784ac6a36794f3cecced3dfadf71a99a1eb Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 20:14:00 +1200 Subject: [PATCH 38/59] Said what the signature length cast actually guards against --- src/mpq/verify.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp index 49337e2..40e0b16 100644 --- a/src/mpq/verify.cpp +++ b/src/mpq/verify.cpp @@ -62,8 +62,9 @@ int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { << ": " << target << std::endl; return -1; } - // Signed deliberately: the subtraction can legitimately go negative, and in - // unsigned arithmetic it would wrap to a huge positive and pass the check below + // fs::file_size returns uintmax_t, so without the cast the subtraction runs + // unsigned and a legitimately negative length reaches the check below only + // through a conversion C++17 leaves implementation-defined const int64_t signature_length = file_size - archive_offset - archive_size; if (signature_length <= 0) { From c9393e3dabea78d6d1efba724467a90d482aa823 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 20:21:00 +1200 Subject: [PATCH 39/59] Used a generic example version in the template comment --- cmake/version.h.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmake/version.h.in b/cmake/version.h.in index e86af37..305dc1f 100644 --- a/cmake/version.h.in +++ b/cmake/version.h.in @@ -11,7 +11,7 @@ inline constexpr int version_minor = @PROJECT_VERSION_MINOR@; /// Patch version inline constexpr int version_patch = @PROJECT_VERSION_PATCH@; -/// The version as a dotted string, "0.11.0" +/// The version as a dotted string, "1.2.3" inline constexpr const char *version_string = "@PROJECT_VERSION@"; /// The commit the binary was built from, or "unknown" outside a git checkout From e5ba06a453f8aec2f624e9cf8e5dd430c42eab49 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Thu, 24 Sep 2026 20:28:00 +1200 Subject: [PATCH 40/59] Matched the pull request paths filter to the main one --- .github/workflows/pr.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4b813f9..2ab1d49 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - ".github/workflows/**" + - "Makefile" - ".clang-format" - ".clang-tidy" - "src/**" @@ -17,6 +18,12 @@ on: - ".dockerignore" - ".gpipe.yml" +# Grouped by pull request number, so a push only ever supersedes an earlier run of +# its own branch and never one somebody else is waiting on +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read From 6d40927b4fa6fb1f60ae995d96ab285f926e6f63 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sat, 26 Sep 2026 15:40:51 +1200 Subject: [PATCH 41/59] Fixed value completion in the bash, zsh, fish and PowerShell scripts --- CHANGELOG.md | 1 + completion/mpqcli.bash | 248 +++++++++++++++++++++++++---------------- completion/mpqcli.fish | 182 ++++++++++++++++++------------ completion/mpqcli.ps1 | 60 +++++----- completion/mpqcli.zsh | 60 ++++++---- docs/changelog.md | 1 + 6 files changed, 327 insertions(+), 225 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 761a0bf..281601c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed - Fail with a non-zero exit code on an unknown subcommand or a missing argument +- Offer every locale and game profile in zsh, keep filenames out of value lists in fish, and complete archives after a subcommand in PowerShell ## 0.11.0 - 2026-09-21 diff --git a/completion/mpqcli.bash b/completion/mpqcli.bash index 283f3fd..5990f53 100644 --- a/completion/mpqcli.bash +++ b/completion/mpqcli.bash @@ -1,26 +1,73 @@ -# Use _filedir when available, otherwise fall back to compgen. -# Pass an extension (e.g. "mpq") to restrict completions to that type plus directories. +# Bash completion for mpqcli. Uses the bash-completion helpers when they are +# loaded and falls back to plain compgen otherwise, so it also runs on bash 3.2 + +# Fill COMPREPLY with the words in $1 that match the current word +_mpqcli_words() { + local word + COMPREPLY=() + while IFS= read -r word; do + COMPREPLY+=("$word") + done < <(compgen -W "$1" -- "$cur") +} + +# Complete filesystem paths. With no argument every path is offered, with -d +# only directories, and with an extension such as mpq only files of that type +# in either case plus directories, so an archive can be reached by descending _mpqcli_filedir() { if declare -f _filedir > /dev/null 2>&1; then _filedir "${1-}" + return + fi + + local path upper + COMPREPLY=() + compopt -o filenames 2> /dev/null + if [[ "${1-}" == -d ]]; then + while IFS= read -r path; do + COMPREPLY+=("$path") + done < <(compgen -d -- "$cur") + elif [[ -n "${1-}" ]]; then + upper=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]') + while IFS= read -r path; do + if [[ -d "$path" || "$path" == *."$1" || "$path" == *."$upper" ]]; then + COMPREPLY+=("$path") + fi + done < <(compgen -f -- "$cur") else - if [[ -n "${1-}" ]]; then - local -a _f _d - mapfile -t _f < <(compgen -f -X "!*.$1" -- "$cur") - mapfile -t _d < <(compgen -d -- "$cur") - COMPREPLY=("${_f[@]}" "${_d[@]}") - else - mapfile -t COMPREPLY < <(compgen -f -- "$cur") - fi + while IFS= read -r path; do + COMPREPLY+=("$path") + done < <(compgen -f -- "$cur") fi } +# Set pos to the index, from zero, of the positional argument under the cursor. +# Every option is assumed to take a value in the following word, except the +# flags passed as arguments, and a lone - counts as a positional (stdin) +_mpqcli_positional_index() { + local i flag + pos=0 + for ((i = 2; i < cword; i++)); do + case "${words[i]}" in + -) pos=$((pos + 1)) ;; + --*=*) ;; + -*) + for flag in "$@"; do + [[ "${words[i]}" == "$flag" ]] && continue 2 + done + i=$((i + 1)) + ;; + *) pos=$((pos + 1)) ;; + esac + done +} + _mpqcli() { - local cur prev words cword + local cur prev words cword pos - # Use bash-completion helpers when available, fall back to COMP_* variables + # Use bash-completion helpers when available, fall back to COMP_* variables. + # The -s splits --option=value so the value completes like a separate word if declare -f _init_completion > /dev/null 2>&1; then - _init_completion || return + _init_completion -s || return else COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" @@ -38,7 +85,7 @@ _mpqcli() { generic diablo1 diablo d1 lordsofmagic lomse - starcraft starcraft1 sc1 + starcraft starcraft1 sc sc1 warcraft2 wc2 war2 diablo2 d2 warcraft3 wc3 war3 @@ -62,176 +109,179 @@ _mpqcli() { ) if [[ $cword -eq 1 ]]; then - mapfile -t COMPREPLY < <(compgen -W "$subcommands" -- "$cur") + _mpqcli_words "$subcommands" return fi - local subcmd="${words[1]}" - - case "$subcmd" in + case "${words[1]}" in info) case "$prev" in - -p|--property) - mapfile -t COMPREPLY < <(compgen -W "${info_properties[*]}" -- "$cur") - return ;; + -p|--property) _mpqcli_words "${info_properties[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-p --property" -- "$cur") + _mpqcli_words "-p --property" else - _mpqcli_filedir mpq + _mpqcli_positional_index + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi fi ;; create) case "$prev" in - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; - -g|--game) - mapfile -t COMPREPLY < <(compgen -W "${games[*]}" -- "$cur") - return ;; - --version) - mapfile -t COMPREPLY < <(compgen -W "1 2 3 4" -- "$cur") - return ;; - -p|--path|-o|--output|--stream-flags|--sector-size|\ - --raw-chunk-size|--file-flags1|--file-flags2|--file-flags3|--attr-flags|\ + --locale) _mpqcli_words "${locales[*]}"; return ;; + -g|--game) _mpqcli_words "${games[*]}"; return ;; + --version) _mpqcli_words "1 2 3 4"; return ;; + -o|--output) _mpqcli_filedir; return ;; + -p|--path|--stream-flags|--sector-size|--raw-chunk-size|\ + --file-flags1|--file-flags2|--file-flags3|--attr-flags|\ --flags|--compression|--compression-next) return ;; esac if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-p --path -o --output -s --sign \ ---locale -g --game --version --stream-flags --sector-size --raw-chunk-size \ ---file-flags1 --file-flags2 --file-flags3 --attr-flags \ ---flags --compression --compression-next" -- "$cur") + _mpqcli_words "-p --path -o --output -s --sign --locale -g --game --version \ +--stream-flags --sector-size --raw-chunk-size --file-flags1 --file-flags2 --file-flags3 \ +--attr-flags --flags --compression --compression-next" else - _mpqcli_filedir + _mpqcli_positional_index -s --sign + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir + fi fi ;; add) case "$prev" in - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; - -g|--game) - mapfile -t COMPREPLY < <(compgen -W "${games[*]}" -- "$cur") - return ;; - -p|--path|--flags|--compression|--compression-next) - return ;; + --locale) _mpqcli_words "${locales[*]}"; return ;; + -g|--game) _mpqcli_words "${games[*]}"; return ;; + -p|--path|--flags|--compression|--compression-next) return ;; esac if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-p --path -w --overwrite -u --update \ ---locale -g --game \ ---flags --compression --compression-next" -- "$cur") + _mpqcli_words "-p --path -w --overwrite -u --update --locale -g --game \ +--flags --compression --compression-next" else - _mpqcli_filedir + # Positional 1 is the archive; the rest are files or directories to add + _mpqcli_positional_index -w --overwrite -u --update + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + else + _mpqcli_filedir + fi fi ;; remove) case "$prev" in - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; + --locale) _mpqcli_words "${locales[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "--locale" -- "$cur") + _mpqcli_words "--locale" else - _mpqcli_filedir + # Positional 1 is the archive; the rest are in-archive paths, so + # they get no filesystem completion + _mpqcli_positional_index + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi fi ;; rename) case "$prev" in - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; + --locale) _mpqcli_words "${locales[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "--locale" -- "$cur") - elif [[ $cword -eq 2 ]]; then + _mpqcli_words "--locale" + else # Positional 1 is the archive; positionals 2 and 3 are in-archive # paths, so they get no filesystem completion - _mpqcli_filedir mpq + _mpqcli_positional_index + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi fi ;; list) case "$prev" in - -l|--listfile) - _mpqcli_filedir - return ;; - -p|--property) - mapfile -t COMPREPLY < <(compgen -W "${list_properties[*]}" -- "$cur") - return ;; + -l|--listfile) _mpqcli_filedir; return ;; + -p|--property) _mpqcli_words "${list_properties[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-l --listfile -d --detailed -a --all -p --property" -- "$cur") + _mpqcli_words "-l --listfile -d --detailed -a --all -p --property" else - _mpqcli_filedir mpq + _mpqcli_positional_index -d --detailed -a --all + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi fi ;; extract) case "$prev" in - -o|--output) - _mpqcli_filedir - return ;; - -l|--listfile) - _mpqcli_filedir - return ;; - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; - -f|--file) - return ;; + -o|--output) _mpqcli_filedir -d; return ;; + -l|--listfile) _mpqcli_filedir; return ;; + --locale) _mpqcli_words "${locales[*]}"; return ;; + -f|--file) return ;; esac if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-o --output -f --file -k --keep -l --listfile --locale" -- "$cur") + _mpqcli_words "-o --output -f --file -k --keep -l --listfile --locale" else - _mpqcli_filedir mpq + _mpqcli_positional_index -k --keep + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi fi ;; read) case "$prev" in - --locale) - mapfile -t COMPREPLY < <(compgen -W "${locales[*]}" -- "$cur") - return ;; + --locale) _mpqcli_words "${locales[*]}"; return ;; esac if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "--locale" -- "$cur") - elif [[ $cword -ge 3 ]]; then - # Positional 1 is an in-archive path (no filesystem completion); - # positional 2+ is the archive file - _mpqcli_filedir mpq + _mpqcli_words "--locale" + else + # Positional 1 is an in-archive path, so it gets no filesystem + # completion; positional 2 is the archive + _mpqcli_positional_index + if [[ $pos -eq 1 ]]; then + _mpqcli_filedir mpq + fi fi ;; verify) if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-p --print" -- "$cur") + _mpqcli_words "-p --print" else - _mpqcli_filedir mpq + _mpqcli_positional_index -p --print + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi fi ;; compact) case "$prev" in - -l|--listfile) - _mpqcli_filedir - return ;; + -l|--listfile) _mpqcli_filedir; return ;; esac if [[ "$cur" == -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "-l --listfile" -- "$cur") + _mpqcli_words "-l --listfile" else - _mpqcli_filedir mpq + _mpqcli_positional_index + if [[ $pos -eq 0 ]]; then + _mpqcli_filedir mpq + fi fi ;; completion) if [[ "$cur" != -* ]]; then - mapfile -t COMPREPLY < <(compgen -W "bash zsh powershell fish" -- "$cur") + _mpqcli_positional_index + if [[ $pos -eq 0 ]]; then + _mpqcli_words "bash zsh powershell fish" + fi fi ;; diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 3d467aa..7fde146 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -1,13 +1,42 @@ -# Fish shell completions for mpqcli -# Source: generated from mpqcli/src/main.cpp +# Fish completion for mpqcli # # Install: -# cp mpqcli.fish ~/.config/fish/completions/mpqcli.fish +# mpqcli completion fish > ~/.config/fish/completions/mpqcli.fish # Disable file completion globally; re-enable per argument where needed complete -c mpqcli -f -# Shared value sets (sourced directly from main.cpp) +# Index, from one, of the positional argument under the cursor. The command +# name and subcommand are skipped, as are options and the value following any +# option not listed as a flag in the arguments. A lone - counts as a positional +function __mpqcli_positional_index + set -l index 1 + set -l skip 0 + for token in (commandline -poc)[3..-1] + if test $skip -eq 1 + set skip 0 + continue + end + switch $token + case '-' + set index (math $index + 1) + case '--*=*' + case '-*' + if not contains -- $token $argv + set skip 1 + end + case '*' + set index (math $index + 1) + end + end + echo $index +end + +# Shared value sets, kept in step with app/main.cpp, src/util/locales.cpp and +# src/gamerules/profiles.cpp +set -l __mpqcli_subcommands \ + version about info create add remove rename list extract read verify compact completion + set -l __mpqcli_locales \ default \ enUS enGB zhTW zhCN csCZ deDE esES esMX \ @@ -17,7 +46,7 @@ set -l __mpqcli_game_profiles \ generic \ diablo1 diablo d1 \ lordsofmagic lomse \ - starcraft starcraft1 sc1 \ + starcraft starcraft1 sc sc1 \ warcraft2 wc2 war2 \ diablo2 d2 \ warcraft3 wc3 war3 \ @@ -40,41 +69,40 @@ set -l __mpqcli_list_properties \ flags encryption-key encryption-key-raw # Top-level subcommands (no subcommand active yet) -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a version -d 'Print program version' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a about -d 'Print program information' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a info -d 'Print info about an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a create -d 'Create an MPQ archive from a file or directory' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a add -d 'Add files to an existing MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a remove -d 'Remove files from an existing MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a rename -d 'Rename a file in an existing MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a list -d 'List files in an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a extract -d 'Extract files from an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a read -d 'Read a file from an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a verify -d 'Verify an MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a compact -d 'Compact the MPQ archive' -complete -c mpqcli -n 'not __fish_seen_subcommand_from version about info create add remove rename list extract read verify compact completion' \ - -a completion -d 'Generate shell completion script' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a version -d 'Prints program version' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a about -d 'Prints program information' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a info -d 'Prints info about an MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a create -d 'Create an MPQ archive from target file or directory' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a add -d 'Add files to an existing MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a remove -d 'Remove files from an existing MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a rename -d 'Rename a file in an existing MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a list -d 'List files from the MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a extract -d 'Extract files from the MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a read -d 'Read a file from an MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a verify -d 'Verify the MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a compact -d 'Compact the MPQ archive' +complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \ + -a completion -d 'Generate shell completion script' # info # info [-p/--property ] -# positional: existing file -complete -c mpqcli -n '__fish_seen_subcommand_from info' -F +complete -c mpqcli -n '__fish_seen_subcommand_from info; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from info' \ -s p -l property -d 'Print only a specific property value' \ - -r -a "$__mpqcli_info_properties" + -r -f -a "$__mpqcli_info_properties" # create # create [-p/--path] [-o/--output] [-s/--sign] @@ -82,89 +110,95 @@ complete -c mpqcli -n '__fish_seen_subcommand_from info' \ # [--version] [--stream-flags] [--sector-size] [--raw-chunk-size] # [--file-flags1/2/3] [--attr-flags] # [--flags] [--compression] [--compression-next] -complete -c mpqcli -n '__fish_seen_subcommand_from create' -F +complete -c mpqcli -n '__fish_seen_subcommand_from create; and test (__mpqcli_positional_index -s --sign) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -s p -l path -d 'Archive path for a single file, or prefix for a directory' -r + -s p -l path -d 'Archive path for a single file, or prefix for a directory' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -s o -l output -d 'Output MPQ archive path' -r -F complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -s s -l sign -d 'Sign the MPQ archive' complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -l locale -d 'Locale to use for added files' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" complete -c mpqcli -n '__fish_seen_subcommand_from create' \ -s g -l game -d 'Game profile for MPQ creation' \ - -r -a "$__mpqcli_game_profiles" + -r -f -a "$__mpqcli_game_profiles" # Game setting overrides complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l version -d 'Override MPQ archive version (1-4)' -r + -l version -d 'Override MPQ archive version (1-4)' -r -f -a '1 2 3 4' complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l stream-flags -d 'Override stream flags' -r + -l stream-flags -d 'Override stream flags' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l sector-size -d 'Override sector size' -r + -l sector-size -d 'Override sector size' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l raw-chunk-size -d 'Override raw chunk size (MPQ v4)' -r + -l raw-chunk-size -d 'Override raw chunk size (MPQ v4)' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l file-flags1 -d 'Override file flags for (listfile)' -r + -l file-flags1 -d 'Override file flags for (listfile)' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l file-flags2 -d 'Override file flags for (attributes)' -r + -l file-flags2 -d 'Override file flags for (attributes)' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l file-flags3 -d 'Override file flags for (signature)' -r + -l file-flags3 -d 'Override file flags for (signature)' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l attr-flags -d 'Override attribute flags (CRC32, FILETIME, MD5)' -r + -l attr-flags -d 'Override attribute flags (CRC32, FILETIME, MD5)' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l flags -d 'Override MPQ file flags for added files' -r + -l flags -d 'Override MPQ file flags for added files' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l compression -d 'Override compression for first sector of added files' -r + -l compression -d 'Override compression for first sector of added files' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from create' \ - -l compression-next -d 'Override compression for subsequent sectors of added files' -r + -l compression-next -d 'Override compression for subsequent sectors of added files' -r -f # add # add [-p/--path] [-w/--overwrite | -u/--update] # [--locale] [-g/--game] # [--flags] [--compression] [--compression-next] +# Positional 1 is the archive and the rest are files or directories to add, so +# every positional takes a path complete -c mpqcli -n '__fish_seen_subcommand_from add' -F complete -c mpqcli -n '__fish_seen_subcommand_from add' \ - -s p -l path -d 'Archive path for a single file, or prefix for a directory' -r + -s p -l path -d 'Archive path for a single file, or prefix for a directory' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -s w -l overwrite -d 'Replace every file that already exists in the archive' complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -s u -l update -d 'Replace only files that changed' complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -l locale -d 'Locale to use for added file' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" complete -c mpqcli -n '__fish_seen_subcommand_from add' \ -s g -l game -d 'Game profile for compression rules' \ - -r -a "$__mpqcli_game_profiles" + -r -f -a "$__mpqcli_game_profiles" complete -c mpqcli -n '__fish_seen_subcommand_from add' \ - -l flags -d 'Override MPQ file flags' -r + -l flags -d 'Override MPQ file flags' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from add' \ - -l compression -d 'Override compression for first sector' -r + -l compression -d 'Override compression for first sector' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from add' \ - -l compression-next -d 'Override compression for subsequent sectors' -r + -l compression-next -d 'Override compression for subsequent sectors' -r -f # remove # remove [--locale] -complete -c mpqcli -n '__fish_seen_subcommand_from remove' -F +# Positional 1 is the archive; the rest are in-archive paths, so they get no +# filesystem completion +complete -c mpqcli -n '__fish_seen_subcommand_from remove; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from remove' \ -l locale -d 'Locale of the file to remove' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" # rename # rename [--locale] -complete -c mpqcli -n '__fish_seen_subcommand_from rename' -F +# Positional 1 is the archive; positionals 2 and 3 are in-archive paths, so +# they get no filesystem completion +complete -c mpqcli -n '__fish_seen_subcommand_from rename; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from rename' \ -l locale -d 'Locale of the file to rename' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" # list # list [-l/--listfile] [-d/--detailed] [-a/--all] # [-p/--property ] -complete -c mpqcli -n '__fish_seen_subcommand_from list' -F +complete -c mpqcli -n '__fish_seen_subcommand_from list; and test (__mpqcli_positional_index -d --detailed -a --all) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from list' \ -s l -l listfile -d 'External file listing content of the archive' -r -F @@ -174,54 +208,56 @@ complete -c mpqcli -n '__fish_seen_subcommand_from list' \ -s a -l all -d 'Include hidden files' complete -c mpqcli -n '__fish_seen_subcommand_from list' \ -s p -l property -d 'Print only specific property values' \ - -r -a "$__mpqcli_list_properties" + -r -f -a "$__mpqcli_list_properties" # extract # extract [-o/--output] [-f/--file] [-k/--keep] # [-l/--listfile] [--locale] -complete -c mpqcli -n '__fish_seen_subcommand_from extract' -F +complete -c mpqcli -n '__fish_seen_subcommand_from extract; and test (__mpqcli_positional_index -k --keep) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ - -s o -l output -d 'Output directory' -r -F + -s o -l output -d 'Output directory' -r -f -a '(__fish_complete_directories)' complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ - -s f -l file -d 'Target file to extract' -r + -s f -l file -d 'Target file to extract' -r -f complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -s k -l keep -d 'Keep folder structure' complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -s l -l listfile -d 'External file listing content of the archive' -r -F complete -c mpqcli -n '__fish_seen_subcommand_from extract' \ -l locale -d 'Preferred locale for extracted file' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" # read # read [--locale] -complete -c mpqcli -n '__fish_seen_subcommand_from read' -F +# Positional 1 is an in-archive path, so it gets no filesystem completion; +# positional 2 is the archive +complete -c mpqcli -n '__fish_seen_subcommand_from read; and test (__mpqcli_positional_index) -eq 2' -F complete -c mpqcli -n '__fish_seen_subcommand_from read' \ -l locale -d 'Preferred locale for read file' \ - -r -a "$__mpqcli_locales" + -r -f -a "$__mpqcli_locales" # verify # verify [-p/--print] -complete -c mpqcli -n '__fish_seen_subcommand_from verify' -F +complete -c mpqcli -n '__fish_seen_subcommand_from verify; and test (__mpqcli_positional_index -p --print) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from verify' \ -s p -l print -d 'Print the digital signature (in hex)' # compact # compact [-l/--listfile] -complete -c mpqcli -n '__fish_seen_subcommand_from compact' -F +complete -c mpqcli -n '__fish_seen_subcommand_from compact; and test (__mpqcli_positional_index) -eq 1' -F complete -c mpqcli -n '__fish_seen_subcommand_from compact' \ -s l -l listfile -d 'External file listing content of the archive' -r -F # completion # completion -complete -c mpqcli -n '__fish_seen_subcommand_from completion' \ +complete -c mpqcli -n '__fish_seen_subcommand_from completion; and not __fish_seen_subcommand_from bash zsh powershell fish' \ -a bash -d 'Generate bash completion script' -complete -c mpqcli -n '__fish_seen_subcommand_from completion' \ +complete -c mpqcli -n '__fish_seen_subcommand_from completion; and not __fish_seen_subcommand_from bash zsh powershell fish' \ -a zsh -d 'Generate zsh completion script' -complete -c mpqcli -n '__fish_seen_subcommand_from completion' \ +complete -c mpqcli -n '__fish_seen_subcommand_from completion; and not __fish_seen_subcommand_from bash zsh powershell fish' \ -a powershell -d 'Generate PowerShell completion script' -complete -c mpqcli -n '__fish_seen_subcommand_from completion' \ +complete -c mpqcli -n '__fish_seen_subcommand_from completion; and not __fish_seen_subcommand_from bash zsh powershell fish' \ -a fish -d 'Generate fish completion script' diff --git a/completion/mpqcli.ps1 b/completion/mpqcli.ps1 index d3f2fa3..30ae3ac 100644 --- a/completion/mpqcli.ps1 +++ b/completion/mpqcli.ps1 @@ -9,10 +9,9 @@ arguments fall back to PowerShell's native filesystem completion. .NOTES - Source it from your $PROFILE: - . /path/to/mpqcli-completion.ps1 + Append it to your profile so it loads at startup: + mpqcli completion powershell >> $PROFILE - Or copy it into a module / profile script that loads at startup. Works for any executable named 'mpqcli' or 'mpqcli.exe'. #> @@ -41,7 +40,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl 'generic', 'diablo1', 'diablo', 'd1', 'lordsofmagic', 'lomse', - 'starcraft', 'starcraft1', 'sc1', + 'starcraft', 'starcraft1', 'sc', 'sc1', 'warcraft2', 'wc2', 'war2', 'diablo2', 'd2', 'warcraft3', 'wc3', 'war3', @@ -72,14 +71,14 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl 'flags', 'encryption-key', 'encryption-key-raw' ) - # Options available per subcommand. Each value is a hashtable mapping the - # option token to a short help string. + # Options available per subcommand. Each value maps the option token to a + # short help string, in the order the help text lists them $optionSpec = @{ - 'info' = @{ + 'info' = [ordered]@{ '-p' = 'Print only a specific property value' '--property' = 'Print only a specific property value' } - 'create' = @{ + 'create' = [ordered]@{ '-p' = 'Archive path for a single file, or prefix for a directory' '--path' = 'Archive path for a single file, or prefix for a directory' '-o' = 'Output MPQ archive' @@ -101,12 +100,12 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--compression' = 'Override compression for first sector' '--compression-next' = 'Override compression for subsequent sectors' } - 'add' = @{ + 'add' = [ordered]@{ '-p' = 'Archive path for a single file, or prefix for a directory' '--path' = 'Archive path for a single file, or prefix for a directory' - '-w' = 'Overwrite file if it already is in MPQ archive' + '-w' = 'Replace every file that already exists in the archive' '--overwrite' = 'Replace every file that already exists in the archive' - '-u' = 'Skip unchanged files when adding a directory' + '-u' = 'Replace only files that changed' '--update' = 'Replace only files that changed' '--locale' = 'Locale to use for added file' '-g' = 'Game profile for compression rules' @@ -115,13 +114,13 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--compression' = 'Override compression for first sector' '--compression-next' = 'Override compression for subsequent sectors' } - 'remove' = @{ + 'remove' = [ordered]@{ '--locale' = 'Locale of file to remove' } - 'rename' = @{ + 'rename' = [ordered]@{ '--locale' = 'Locale of file to rename' } - 'list' = @{ + 'list' = [ordered]@{ '-l' = 'File listing content of an MPQ archive' '--listfile' = 'File listing content of an MPQ archive' '-d' = 'File listing with additional columns' @@ -131,7 +130,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '-p' = 'Print only specific property values' '--property' = 'Print only specific property values' } - 'extract' = @{ + 'extract' = [ordered]@{ '-o' = 'Output directory' '--output' = 'Output directory' '-f' = 'Target file to extract' @@ -142,14 +141,14 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--listfile' = 'File listing content of an MPQ archive' '--locale' = 'Preferred locale for extracted file' } - 'read' = @{ + 'read' = [ordered]@{ '--locale' = 'Preferred locale for read file' } - 'verify' = @{ + 'verify' = [ordered]@{ '-p' = 'Print the digital signature (in hex)' '--print' = 'Print the digital signature (in hex)' } - 'compact' = @{ + 'compact' = [ordered]@{ '-l' = 'File listing content of an MPQ archive' '--listfile' = 'File listing content of an MPQ archive' } @@ -163,6 +162,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl '--locale' = $locales '-g' = $gameProfiles '--game' = $gameProfiles + '--version' = @('1', '2', '3', '4') } # Property options depend on the subcommand (info vs list), handled below @@ -170,11 +170,13 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl $elements = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() }) - # Identify the active subcommand (first element that is a known subcommand) + # Identify the active subcommand: the first token, provided it is not the + # word still being typed. The @() keeps a single token from collapsing to + # a string, whose [0] would be its first character + $prior = @(if ([string]::IsNullOrEmpty($wordToComplete)) { $elements } + else { $elements | Select-Object -SkipLast 1 }) $subcommand = $null - foreach ($el in $elements) { - if ($subcommands.Contains($el)) { $subcommand = $el; break } - } + if ($prior.Count -ge 1 -and $subcommands.Contains($prior[0])) { $subcommand = $prior[0] } # The token immediately preceding the cursor (the one we may be an arg to) $prevToken = $null @@ -224,7 +226,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl return } - # 4) Completing the argument to a value-bearing option + # 3) Completing the argument to a value-bearing option if ($prevToken) { # Locale / game profile options if ($valueOptions.ContainsKey($prevToken)) { @@ -256,21 +258,17 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl } } - # 5) Completing an option for the current subcommand - if ($wordToComplete -like '-*' -or [string]::IsNullOrEmpty($wordToComplete)) { + # 4) Completing an option for the current subcommand + if ($wordToComplete -like '-*') { $opts = $optionSpec[$subcommand] if ($opts) { $items = foreach ($k in $opts.Keys) { [pscustomobject]@{ Value = $k; Tip = $opts[$k] } } - $results = & $emit $items 'ParameterName' - if ($wordToComplete -like '-*') { return $results } - # When word is empty we still also allow path completion, so only - # return option results if the user has started a dash. - if ($results) { return $results } + return & $emit $items 'ParameterName' } } - # 6) Fall back to native filesystem completion (archives/files) + # 5) Fall back to native filesystem completion (archives/files) return } diff --git a/completion/mpqcli.zsh b/completion/mpqcli.zsh index 87ce3e8..b1f463e 100644 --- a/completion/mpqcli.zsh +++ b/completion/mpqcli.zsh @@ -9,7 +9,7 @@ _mpqcli_games=( generic diablo1 diablo d1 lordsofmagic lomse - starcraft starcraft1 sc1 + starcraft starcraft1 sc sc1 warcraft2 wc2 war2 diablo2 d2 warcraft3 wc3 war3 @@ -35,7 +35,7 @@ _mpqcli_list_properties=( ) _mpqcli() { - local state + local context state state_descr line typeset -A opt_args _arguments -C \ @@ -63,9 +63,9 @@ _mpqcli() { _mpqcli_cmds() { local cmds=( - 'version:Print program version' - 'about:Print program information' - 'info:Print info about an MPQ archive' + 'version:Prints program version' + 'about:Prints program information' + 'info:Prints info about an MPQ archive' 'create:Create an MPQ archive from target file or directory' 'add:Add files to an existing MPQ archive' 'remove:Remove files from an existing MPQ archive' @@ -80,21 +80,28 @@ _mpqcli_cmds() { _describe 'subcommand' cmds } +# MPQ archives in either case plus directories; _files offers every file when +# nothing matches, so archives with other extensions stay reachable +_mpqcli_archives() { + _files -g '*.(#i)mpq' "$@" +} + _mpqcli_info() { local props="${_mpqcli_info_properties[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '(-p --property)'{-p,--property}'[print only a specific property]:property:('"$props"')' } _mpqcli_create() { + local locales="${_mpqcli_locales[*]}" games="${_mpqcli_games[*]}" _arguments \ '1:target:_files' \ '(-p --path)'{-p,--path}'[archive path for a single file, or prefix for a directory]:path' \ '(-o --output)'{-o,--output}'[output archive]:file:_files' \ '(-s --sign)'{-s,--sign}'[sign the archive]' \ - '--locale[locale for added files]:locale:('"${_mpqcli_locales[@]}"')' \ - '(-g --game)'{-g,--game}'[game profile]:profile:('"${_mpqcli_games[@]}"')' \ + '--locale[locale for added files]:locale:('"$locales"')' \ + '(-g --game)'{-g,--game}'[game profile]:profile:('"$games"')' \ '--version[MPQ archive version (1-4)]:version:(1 2 3 4)' \ '--stream-flags[override stream flags]:flags' \ '--sector-size[override sector size]:size' \ @@ -109,38 +116,41 @@ _mpqcli_create() { } _mpqcli_add() { + local locales="${_mpqcli_locales[*]}" games="${_mpqcli_games[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '*:files:_files' \ '(-p --path)'{-p,--path}'[archive path or prefix for directory add]:path' \ '(-w --overwrite -u --update)'{-w,--overwrite}'[replace every existing file]' \ '(-u --update -w --overwrite)'{-u,--update}'[replace only files that changed]' \ - '--locale[locale for added file]:locale:('"${_mpqcli_locales[@]}"')' \ - '(-g --game)'{-g,--game}'[game profile]:profile:('"${_mpqcli_games[@]}"')' \ + '--locale[locale for added file]:locale:('"$locales"')' \ + '(-g --game)'{-g,--game}'[game profile]:profile:('"$games"')' \ '--flags[override MPQ file flags]:flags' \ '--compression[override compression for first sector]:compression' \ '--compression-next[override compression for subsequent sectors]:compression' } _mpqcli_remove() { + local locales="${_mpqcli_locales[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '*:archive paths' \ - '--locale[locale of file to remove]:locale:('"${_mpqcli_locales[@]}"')' + '--locale[locale of file to remove]:locale:('"$locales"')' } _mpqcli_rename() { + local locales="${_mpqcli_locales[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '2:old archive path' \ '3:new archive path' \ - '--locale[locale of file to rename]:locale:("${_mpqcli_locales[@]}")' + '--locale[locale of file to rename]:locale:('"$locales"')' } _mpqcli_list() { local props="${_mpqcli_list_properties[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \ '(-d --detailed)'{-d,--detailed}'[detailed listing with extra columns]' \ '(-a --all)'{-a,--all}'[include hidden files]' \ @@ -148,35 +158,41 @@ _mpqcli_list() { } _mpqcli_extract() { + local locales="${_mpqcli_locales[*]}" _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '(-o --output)'{-o,--output}'[output directory]:dir:_files -/' \ '(-f --file)'{-f,--file}'[target file to extract]:file' \ '(-k --keep)'{-k,--keep}'[keep folder structure]' \ '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \ - '--locale[preferred locale for extracted file]:locale:('"${_mpqcli_locales[@]}"')' + '--locale[preferred locale for extracted file]:locale:('"$locales"')' } _mpqcli_read() { + local locales="${_mpqcli_locales[*]}" _arguments \ '1:file-in-archive' \ - '2:archive:_files' \ - '--locale[preferred locale for read file]:locale:('"${_mpqcli_locales[@]}"')' + '2:archive:_mpqcli_archives' \ + '--locale[preferred locale for read file]:locale:('"$locales"')' } _mpqcli_verify() { _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '(-p --print)'{-p,--print}'[print the digital signature in hex]' } _mpqcli_compact() { _arguments \ - '1:archive:_files' \ + '1:archive:_mpqcli_archives' \ '(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' } _mpqcli_completion() { + _arguments '1:shell:_mpqcli_shells' +} + +_mpqcli_shells() { local shells=( 'bash:Generate bash completion script' 'zsh:Generate zsh completion script' diff --git a/docs/changelog.md b/docs/changelog.md index 761a0bf..281601c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,7 @@ ### Fixed - Fail with a non-zero exit code on an unknown subcommand or a missing argument +- Offer every locale and game profile in zsh, keep filenames out of value lists in fish, and complete archives after a subcommand in PowerShell ## 0.11.0 - 2026-09-21 From 92e9c05bcc2470c7d4d9d557a2dfe27e9cdf6cb1 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sat, 26 Sep 2026 15:55:37 +1200 Subject: [PATCH 42/59] Added an error hierarchy and an RAII archive, caught in main --- CHANGELOG.md | 2 + app/main.cpp | 188 +++++++++++++++++++++++--------------------- docs/changelog.md | 2 + src/CMakeLists.txt | 1 - src/cli/add.cpp | 15 ++-- src/cli/compact.cpp | 14 +--- src/cli/create.cpp | 52 ++++++------ src/cli/extract.cpp | 12 ++- src/cli/info.cpp | 9 +-- src/cli/list.cpp | 9 +-- src/cli/read.cpp | 11 +-- src/cli/remove.cpp | 9 +-- src/cli/rename.cpp | 9 +-- src/cli/verify.cpp | 13 ++- src/errors.h | 68 ++++++++++++++++ src/mpq/archive.cpp | 94 ++++++++++++++++------ src/mpq/archive.h | 45 ++++++++++- src/mpq/compact.cpp | 10 +-- src/mpq/compact.h | 5 +- src/mpq/create.cpp | 57 -------------- src/mpq/create.h | 18 ----- src/mpq/list.cpp | 9 +-- src/mpq/list.h | 7 +- src/mpq/verify.cpp | 19 ++--- src/mpq/verify.h | 6 +- 25 files changed, 371 insertions(+), 313 deletions(-) create mode 100644 src/errors.h delete mode 100644 src/mpq/create.cpp delete mode 100644 src/mpq/create.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 281601c..b4757bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ - Ship one static musl Linux binary per architecture instead of glibc and musl pairs - Publish install scripts and a signed checksums file with each release - Run the Docker image in /data so mounted paths need no prefix +- Exit 2 with a message on an unexpected internal error instead of aborting ### Fixed - Fail with a non-zero exit code on an unknown subcommand or a missing argument +- Fail with a non-zero exit code when closing, signing, listing or printing a signature fails - Offer every locale and game profile in zsh, keep filenames out of value lists in fish, and complete archives after a subcommand in PowerShell ## 0.11.0 - 2026-09-21 diff --git a/app/main.cpp b/app/main.cpp index aaa85c2..ff75c92 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -9,6 +10,7 @@ #include "cli/commands.h" #include "commands.h" +#include "errors.h" #include "gamerules/rules.h" #include "validators.h" @@ -272,118 +274,124 @@ int main(int argc, char **argv) { try { app.parse(argc, argv); - } catch (const CLI::ParseError &e) { - // An empty command line is a request for help rather than a mistake. The argc - // check is what keeps an unknown subcommand, which raises the same error, failing - if (argc == 1 && e.get_exit_code() == static_cast(CLI::ExitCodes::RequiredError)) { - std::cout << app.help() << std::endl; - return 0; - } - return app.exit(e); - } - if (app.got_subcommand(version)) { - return HandleVersion(); - } + if (app.got_subcommand(version)) { + return HandleVersion(); + } - if (app.got_subcommand(about)) { - return HandleAbout(); - } + if (app.got_subcommand(about)) { + return HandleAbout(); + } - if (app.got_subcommand(info)) { - return HandleInfo(base_target, info_property); - } + if (app.got_subcommand(info)) { + return HandleInfo(base_target, info_property); + } - if (app.got_subcommand(create)) { - return HandleCreate(base_target, base_path, base_output, create_sign_archive, base_locale, - base_game_profile, create_mpq_version, create_stream_flags, - create_sector_size, create_raw_chunk_size, create_file_flags1, - create_file_flags2, create_file_flags3, create_attr_flags, file_flags, - file_compression, file_compression_next); - } + if (app.got_subcommand(create)) { + return HandleCreate( + base_target, base_path, base_output, create_sign_archive, base_locale, + base_game_profile, create_mpq_version, create_stream_flags, create_sector_size, + create_raw_chunk_size, create_file_flags1, create_file_flags2, create_file_flags3, + create_attr_flags, file_flags, file_compression, file_compression_next); + } - if (app.got_subcommand(add)) { - std::vector resolved_add_files; - for (const auto &f : add_files) { - if (f == "-") { - std::string line; - while (std::getline(std::cin, line)) { - if (!line.empty()) - resolved_add_files.push_back(line); + if (app.got_subcommand(add)) { + std::vector resolved_add_files; + for (const auto &f : add_files) { + if (f == "-") { + std::string line; + while (std::getline(std::cin, line)) { + if (!line.empty()) + resolved_add_files.push_back(line); + } + } else { + resolved_add_files.push_back(f); } - } else { - resolved_add_files.push_back(f); } + return HandleAdd(resolved_add_files, base_target, base_path, add_overwrite, add_update, + base_locale, base_game_profile, file_flags, file_compression, + file_compression_next); } - return HandleAdd(resolved_add_files, base_target, base_path, add_overwrite, add_update, - base_locale, base_game_profile, file_flags, file_compression, - file_compression_next); - } - if (app.got_subcommand(remove)) { - std::vector resolved_remove_files; - for (const auto &f : remove_files) { - if (f == "-") { - std::string line; - while (std::getline(std::cin, line)) { - if (!line.empty()) - resolved_remove_files.push_back(line); + if (app.got_subcommand(remove)) { + std::vector resolved_remove_files; + for (const auto &f : remove_files) { + if (f == "-") { + std::string line; + while (std::getline(std::cin, line)) { + if (!line.empty()) + resolved_remove_files.push_back(line); + } + } else { + resolved_remove_files.push_back(f); } - } else { - resolved_remove_files.push_back(f); } + return HandleRemove(resolved_remove_files, base_target, base_locale); } - return HandleRemove(resolved_remove_files, base_target, base_locale); - } - - if (app.got_subcommand(rename)) { - return HandleRename(base_file, rename_new_file, base_target, base_locale); - } - if (app.got_subcommand(list)) { - return HandleList(base_target, base_listfile_name, list_all, list_detailed, - list_properties); - } - - if (app.got_subcommand(extract)) { - std::optional extract_file = - base_file.empty() ? std::nullopt : std::make_optional(base_file); - return HandleExtract(base_target, base_output, extract_file, extract_keep_folder_structure, - base_listfile_name, base_locale); - } + if (app.got_subcommand(rename)) { + return HandleRename(base_file, rename_new_file, base_target, base_locale); + } - if (app.got_subcommand(read)) { - return HandleRead(base_file, base_target, base_locale); - } + if (app.got_subcommand(list)) { + return HandleList(base_target, base_listfile_name, list_all, list_detailed, + list_properties); + } - if (app.got_subcommand(verify)) { - return HandleVerify(base_target, verify_print_signature); - } + if (app.got_subcommand(extract)) { + std::optional extract_file = + base_file.empty() ? std::nullopt : std::make_optional(base_file); + return HandleExtract(base_target, base_output, extract_file, + extract_keep_folder_structure, base_listfile_name, base_locale); + } - if (app.got_subcommand(compact)) { - return HandleCompact(base_target, base_listfile_name); - } + if (app.got_subcommand(read)) { + return HandleRead(base_file, base_target, base_locale); + } - if (app.got_subcommand(completion)) { - if (completion->got_subcommand(completion_bash)) { - HandleCompletionBash(); - return 0; + if (app.got_subcommand(verify)) { + return HandleVerify(base_target, verify_print_signature); } - if (completion->got_subcommand(completion_zsh)) { - HandleCompletionZsh(); - return 0; + + if (app.got_subcommand(compact)) { + return HandleCompact(base_target, base_listfile_name); } - if (completion->got_subcommand(completion_ps)) { - HandleCompletionPs(); - return 0; + + if (app.got_subcommand(completion)) { + if (completion->got_subcommand(completion_bash)) { + HandleCompletionBash(); + return 0; + } + if (completion->got_subcommand(completion_zsh)) { + HandleCompletionZsh(); + return 0; + } + if (completion->got_subcommand(completion_ps)) { + HandleCompletionPs(); + return 0; + } + if (completion->got_subcommand(completion_fish)) { + HandleCompletionFish(); + return 0; + } + std::cerr << completion->help(); + return 1; } - if (completion->got_subcommand(completion_fish)) { - HandleCompletionFish(); + + return 0; + } catch (const CLI::ParseError &e) { + // An empty command line is a request for help rather than a mistake. The argc + // check is what keeps an unknown subcommand, which raises the same error, failing + if (argc == 1 && e.get_exit_code() == static_cast(CLI::ExitCodes::RequiredError)) { + std::cout << app.help() << std::endl; return 0; } - std::cerr << completion->help(); + return app.exit(e); + } catch (const mpqcli::Error &e) { + std::cerr << "[!] " << e.what() << std::endl; return 1; + } catch (const std::exception &e) { + std::cerr << "[!] Unexpected error: " << e.what() << std::endl; + return 2; } - - return 0; } diff --git a/docs/changelog.md b/docs/changelog.md index 281601c..b4757bd 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,10 +8,12 @@ - Ship one static musl Linux binary per architecture instead of glibc and musl pairs - Publish install scripts and a signed checksums file with each release - Run the Docker image in /data so mounted paths need no prefix +- Exit 2 with a message on an unexpected internal error instead of aborting ### Fixed - Fail with a non-zero exit code on an unknown subcommand or a missing argument +- Fail with a non-zero exit code when closing, signing, listing or printing a signature fails - Offer every locale and game profile in zsh, keep filenames out of value lists in fish, and complete archives after a subcommand in PowerShell ## 0.11.0 - 2026-09-21 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6b65f8e..1baf9c4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -43,7 +43,6 @@ add_library(mpqcli_core STATIC mpq/add.cpp mpq/archive.cpp mpq/compact.cpp - mpq/create.cpp mpq/extract.cpp mpq/flags.cpp mpq/info.cpp diff --git a/src/cli/add.cpp b/src/cli/add.cpp index 4034e18..5025bfe 100644 --- a/src/cli/add.cpp +++ b/src/cli/add.cpp @@ -25,10 +25,7 @@ int HandleAdd(const std::vector &files, const std::string &target, const std::optional &locale, const std::optional &game_profile, int64_t file_flags, int64_t file_compression, int64_t file_compression_next) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } + Archive archive = Archive::Open(target, 0); LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; @@ -76,14 +73,14 @@ int HandleAdd(const std::vector &files, const std::string &target, continue; } std::string prefix = path.value_or(""); - result |= AddFiles(archive, directory_files, f, prefix, lcid, game_rules, add_overrides, - overwrite, update, &files_skipped); + result |= AddFiles(archive.Handle(), directory_files, f, prefix, lcid, game_rules, + add_overrides, overwrite, update, &files_skipped); } else if (fs::is_regular_file(f, ec)) { const bool treat_as_directory = has_directory || files.size() > 1; std::string archive_path = ResolveArchiveName(f, path, treat_as_directory); - result |= AddFile(archive, f, archive_path, lcid, game_rules, add_overrides, overwrite, - update, &files_skipped); + result |= AddFile(archive.Handle(), f, archive_path, lcid, game_rules, add_overrides, + overwrite, update, &files_skipped); } else { std::cerr << "[!] Not a file or directory: " << f << std::endl; @@ -100,7 +97,7 @@ int HandleAdd(const std::vector &files, const std::string &target, << std::endl; } - CloseMpqArchive(archive); + archive.Close(); return result; } diff --git a/src/cli/compact.cpp b/src/cli/compact.cpp index ed62cca..402333c 100644 --- a/src/cli/compact.cpp +++ b/src/cli/compact.cpp @@ -3,22 +3,16 @@ #include #include -#include - #include "cli/commands.h" #include "mpq/archive.h" namespace mpqcli { int HandleCompact(const std::string &target, const std::optional &listfile_name) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } - - const int result = CompactMpqArchive(archive, listfile_name); - CloseMpqArchive(archive); - return result; + Archive archive = Archive::Open(target, 0); + CompactMpqArchive(archive.Handle(), listfile_name); + archive.Close(); + return 0; } } // namespace mpqcli diff --git a/src/cli/create.cpp b/src/cli/create.cpp index 0a9c5f1..57bfd2c 100644 --- a/src/cli/create.cpp +++ b/src/cli/create.cpp @@ -1,5 +1,3 @@ -#include "mpq/create.h" - #include #include #include @@ -102,36 +100,32 @@ int HandleCreate(const std::string &target, const std::optional &pa CalculateMpqMaxFileValue(is_directory ? static_cast(files.size()) : 1); // Create the MPQ archive and add files - int result = 0; - HANDLE archive = CreateMpqArchive(output_file, file_count, game_rules); - if (archive) { - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - - // Apply AddFileSettings overrides if provided - CompressionSettingsOverrides add_overrides; - if (file_flags >= 0) - add_overrides.flags = static_cast(file_flags); - if (file_compression >= 0) - add_overrides.compression = static_cast(file_compression); - if (file_compression_next >= 0) - add_overrides.compression_next = static_cast(file_compression_next); - - if (is_directory) { - const std::string prefix = path.value_or(""); - result |= AddFiles(archive, files, target, prefix, lcid, game_rules, add_overrides); - } else { - std::string archive_path = ResolveArchiveName(target, path); - result |= AddFile(archive, target, archive_path, lcid, game_rules, add_overrides); - } + Archive archive = Archive::Create(output_file, file_count, game_rules); + LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; + + // Apply AddFileSettings overrides if provided + CompressionSettingsOverrides add_overrides; + if (file_flags >= 0) + add_overrides.flags = static_cast(file_flags); + if (file_compression >= 0) + add_overrides.compression = static_cast(file_compression); + if (file_compression_next >= 0) + add_overrides.compression_next = static_cast(file_compression_next); - if (sign_archive) { - SignMpqArchive(archive); - } - CloseMpqArchive(archive); + int result = 0; + if (is_directory) { + const std::string prefix = path.value_or(""); + result |= + AddFiles(archive.Handle(), files, target, prefix, lcid, game_rules, add_overrides); } else { - std::cerr << "[!] Failed to create MPQ archive." << std::endl; - return 1; + std::string archive_path = ResolveArchiveName(target, path); + result |= AddFile(archive.Handle(), target, archive_path, lcid, game_rules, add_overrides); + } + + if (sign_archive) { + archive.Sign(); } + archive.Close(); return result; } diff --git a/src/cli/extract.cpp b/src/cli/extract.cpp index 0a52967..baf96e5 100644 --- a/src/cli/extract.cpp +++ b/src/cli/extract.cpp @@ -54,10 +54,7 @@ int HandleExtract(const std::string &target, const std::optional &o << effective_output << std::endl; } - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } + Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; if (locale.has_value() && lcid == default_locale) { @@ -67,11 +64,12 @@ int HandleExtract(const std::string &target, const std::optional &o int result; if (file.has_value()) { - result = ExtractFile(archive, effective_output, file.value(), keep_folder_structure, lcid); + result = ExtractFile(archive.Handle(), effective_output, file.value(), + keep_folder_structure, lcid); } else { - result = ExtractFiles(archive, effective_output, listfile_name, lcid); + result = ExtractFiles(archive.Handle(), effective_output, listfile_name, lcid); } - CloseMpqArchive(archive); + archive.Close(); if (result != 0) { std::cerr << std::endl << "[!] Failed to extract all files." << std::endl; diff --git a/src/cli/info.cpp b/src/cli/info.cpp index ce35d55..3d4ec4e 100644 --- a/src/cli/info.cpp +++ b/src/cli/info.cpp @@ -11,12 +11,9 @@ namespace mpqcli { int HandleInfo(const std::string &target, const std::optional &property) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - PrintMpqInfo(archive, property); - CloseMpqArchive(archive); + Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); + PrintMpqInfo(archive.Handle(), property); + archive.Close(); return 0; } diff --git a/src/cli/list.cpp b/src/cli/list.cpp index beb8675..0a024c4 100644 --- a/src/cli/list.cpp +++ b/src/cli/list.cpp @@ -13,12 +13,9 @@ namespace mpqcli { int HandleList(const std::string &target, const std::optional &listfile_name, bool list_all, bool list_detailed, const std::vector &properties) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } - ListFiles(archive, listfile_name, list_all, list_detailed, properties); - CloseMpqArchive(archive); + Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); + ListFiles(archive.Handle(), listfile_name, list_all, list_detailed, properties); + archive.Close(); return 0; } diff --git a/src/cli/read.cpp b/src/cli/read.cpp index 45f0f41..789014e 100644 --- a/src/cli/read.cpp +++ b/src/cli/read.cpp @@ -16,10 +16,7 @@ namespace mpqcli { int HandleRead(const std::string &file, const std::string &target, const std::optional &locale) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } + Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; if (locale.has_value() && lcid == default_locale) { @@ -28,15 +25,15 @@ int HandleRead(const std::string &file, const std::string &target, } uint32_t file_size; - auto file_content = ReadFile(archive, file.c_str(), &file_size, lcid); + auto file_content = ReadFile(archive.Handle(), file.c_str(), &file_size, lcid); if (!file_content) { - CloseMpqArchive(archive); + archive.Close(); return 1; } PrintAsBinary(file_content.get(), file_size); - CloseMpqArchive(archive); + archive.Close(); return 0; } diff --git a/src/cli/remove.cpp b/src/cli/remove.cpp index 206b3c1..8e68580 100644 --- a/src/cli/remove.cpp +++ b/src/cli/remove.cpp @@ -15,10 +15,7 @@ namespace mpqcli { int HandleRemove(const std::vector &files, const std::string &target, const std::optional &locale) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } + Archive archive = Archive::Open(target, 0); LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; std::unordered_set seen; @@ -27,12 +24,12 @@ int HandleRemove(const std::vector &files, const std::string &targe if (!seen.insert(f).second) { continue; } - int result = RemoveFile(archive, f, lcid); + int result = RemoveFile(archive.Handle(), f, lcid); if (result != 0) { overall_result = result; } } - CloseMpqArchive(archive); + archive.Close(); return overall_result; } diff --git a/src/cli/rename.cpp b/src/cli/rename.cpp index 6dea488..9054f30 100644 --- a/src/cli/rename.cpp +++ b/src/cli/rename.cpp @@ -13,14 +13,11 @@ namespace mpqcli { int HandleRename(const std::string &old_file, const std::string &new_file, const std::string &target, const std::optional &locale) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, 0)) { - return 1; - } + Archive archive = Archive::Open(target, 0); LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - int result = RenameFile(archive, old_file, new_file, lcid); - CloseMpqArchive(archive); + const int result = RenameFile(archive.Handle(), old_file, new_file, lcid); + archive.Close(); return result; } diff --git a/src/cli/verify.cpp b/src/cli/verify.cpp index f55a2cd..896caa6 100644 --- a/src/cli/verify.cpp +++ b/src/cli/verify.cpp @@ -11,19 +11,16 @@ namespace mpqcli { int HandleVerify(const std::string &target, bool print_signature) { - HANDLE archive; - if (!OpenMpqArchive(target, &archive, MPQ_OPEN_READ_ONLY)) { - return 1; - } + Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); int result; - switch (VerifyMpqArchive(archive)) { + switch (VerifyMpqArchive(archive.Handle())) { case ERROR_WEAK_SIGNATURE_OK: case ERROR_STRONG_SIGNATURE_OK: if (print_signature) { // If printing the signature, don't print success message // because the user might want to pipe/redirect the signature data - PrintMpqSignature(archive, target); + PrintMpqSignature(archive.Handle(), target); } else { std::cout << "[*] Verify success" << std::endl; } @@ -35,7 +32,7 @@ int HandleVerify(const std::string &target, bool print_signature) { if (print_signature) { // Print the (invalid) signature bytes for forensic inspection, // but still fail: the archive content no longer matches it - PrintMpqSignature(archive, target); + PrintMpqSignature(archive.Handle(), target); } std::cerr << "[!] Verify failed: signature is present but invalid" << std::endl; result = 1; @@ -51,7 +48,7 @@ int HandleVerify(const std::string &target, bool print_signature) { result = 1; break; } - CloseMpqArchive(archive); + archive.Close(); return result; } diff --git a/src/errors.h b/src/errors.h new file mode 100644 index 0000000..7173c93 --- /dev/null +++ b/src/errors.h @@ -0,0 +1,68 @@ +#ifndef ERRORS_H +#define ERRORS_H + +#include +#include +#include +#include +#include + +#include "util/format.h" + +namespace mpqcli { + +/// Base for every exception thrown by mpqcli +class Error : public std::runtime_error { +public: + explicit Error(const std::string &message) : std::runtime_error(message) {} +}; + +/// Base for failures reading or writing an archive +class ArchiveError : public Error { +public: + explicit ArchiveError(const std::string &message) : Error(message) {} +}; + +/// Thrown when a StormLib call fails; the message ends with the code and its text +class StormError : public ArchiveError { +public: + StormError(const std::string &what_failed, uint32_t error_code) + : ArchiveError(what_failed + ": (" + std::to_string(error_code) + ") " + + StormErrorString(error_code)), + error_code_(error_code) {} + + uint32_t ErrorCode() const { return error_code_; } + +private: + uint32_t error_code_; +}; + +/// Thrown when an archive cannot be opened +class ArchiveOpenError : public StormError { +public: + ArchiveOpenError(std::filesystem::path path, uint32_t error_code) + : StormError("Failed to open MPQ archive: " + path.string(), error_code), + path_(std::move(path)) {} + + const std::filesystem::path &Path() const { return path_; } + +private: + std::filesystem::path path_; +}; + +/// Thrown when an archive cannot be created +class ArchiveCreateError : public StormError { +public: + ArchiveCreateError(std::filesystem::path path, uint32_t error_code) + : StormError("Failed to create MPQ archive: " + path.string(), error_code), + path_(std::move(path)) {} + + const std::filesystem::path &Path() const { return path_; } + +private: + std::filesystem::path path_; +}; + +} // namespace mpqcli + +#endif diff --git a/src/mpq/archive.cpp b/src/mpq/archive.cpp index 98b070c..a83c98f 100644 --- a/src/mpq/archive.cpp +++ b/src/mpq/archive.cpp @@ -1,42 +1,92 @@ #include "mpq/archive.h" -#include +#include +#include #include +#include #include -#include "util/format.h" +#include "errors.h" +#include "gamerules/rules.h" +#include "gamerules/settings.h" + +namespace fs = std::filesystem; namespace mpqcli { -bool OpenMpqArchive(const std::string &filename, HANDLE *archive, const DWORD flags) { - if (!SFileOpenArchive(filename.c_str(), 0, flags, archive)) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to open MPQ archive: " << filename << ": (" << error << ") " - << StormErrorString(error) << std::endl; - return false; +Archive Archive::Open(const std::string &filename, const DWORD flags) { + HANDLE handle = nullptr; + if (!SFileOpenArchive(filename.c_str(), 0, flags, &handle)) { + throw ArchiveOpenError(filename, SErrGetLastError()); } - return true; + return Archive(handle); } -bool CloseMpqArchive(HANDLE archive) { - if (!SFileCloseArchive(archive)) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to close MPQ archive: (" << error << ") " - << StormErrorString(error) << std::endl; - return false; +Archive Archive::Create(const std::string &filename, const uint32_t file_count, + const GameRules &game_rules) { + std::error_code ec; + if (fs::exists(filename, ec)) { + throw ArchiveError("File already exists: " + filename + " Exiting..."); + } + + const MpqCreateSettings &settings = game_rules.GetCreateSettings(); + + SFILE_CREATE_MPQ create_info = {}; + create_info.cbSize = sizeof(SFILE_CREATE_MPQ); + create_info.dwMpqVersion = settings.mpq_version; + create_info.dwStreamFlags = settings.stream_flags; + create_info.dwFileFlags1 = settings.file_flags1; + create_info.dwFileFlags2 = settings.file_flags2; + create_info.dwFileFlags3 = settings.file_flags3; + create_info.dwAttrFlags = settings.attr_flags; + create_info.dwSectorSize = settings.sector_size; + create_info.dwRawChunkSize = settings.raw_chunk_size; + create_info.dwMaxFileCount = file_count; + + HANDLE handle = nullptr; + if (!SFileCreateArchive2(filename.c_str(), &create_info, &handle)) { + throw ArchiveCreateError(filename, SErrGetLastError()); + } + return Archive(handle); +} + +Archive::Archive(Archive &&other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; +} + +Archive &Archive::operator=(Archive &&other) noexcept { + if (this != &other) { + if (handle_ != nullptr) { + SFileCloseArchive(handle_); + } + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; +} + +Archive::~Archive() { + if (handle_ != nullptr) { + SFileCloseArchive(handle_); + } +} + +void Archive::Sign() { + if (!SFileSignArchive(handle_, SIGNATURE_TYPE_WEAK)) { + throw StormError("Failed to sign MPQ archive", SErrGetLastError()); } - return true; } -bool SignMpqArchive(HANDLE archive) { - if (!SFileSignArchive(archive, SIGNATURE_TYPE_WEAK)) { +void Archive::Close() { + if (!SFileCloseArchive(handle_)) { + // StormLib frees the handle even when its flush fails, so drop it before + // the throw or the destructor would close it a second time const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to sign MPQ archive: (" << error << ") " << StormErrorString(error) - << std::endl; - return false; + handle_ = nullptr; + throw StormError("Failed to close MPQ archive", error); } - return true; + handle_ = nullptr; } } // namespace mpqcli diff --git a/src/mpq/archive.h b/src/mpq/archive.h index 091ca5a..9707534 100644 --- a/src/mpq/archive.h +++ b/src/mpq/archive.h @@ -1,15 +1,54 @@ #ifndef MPQ_ARCHIVE_H #define MPQ_ARCHIVE_H +#include #include #include +#include "gamerules/rules.h" + namespace mpqcli { -bool OpenMpqArchive(const std::string &filename, HANDLE *archive, DWORD flags); -bool CloseMpqArchive(HANDLE archive); -bool SignMpqArchive(HANDLE archive); +/// An open MPQ archive, closed on destruction unless Close was called first +class Archive { +public: + /// Opens an existing archive with StormLib's MPQ_OPEN_* flags + /// + /// @throws ArchiveOpenError if StormLib cannot open it + static Archive Open(const std::string &filename, DWORD flags); + + /// Creates an archive sized for file_count files with the profile's settings + /// + /// @throws ArchiveError if a file already exists at filename + /// @throws ArchiveCreateError if StormLib cannot create it + static Archive Create(const std::string &filename, uint32_t file_count, + const GameRules &game_rules); + + Archive(const Archive &) = delete; + Archive &operator=(const Archive &) = delete; + Archive(Archive &&other) noexcept; + Archive &operator=(Archive &&other) noexcept; + ~Archive(); + + /// Signs the archive with the Blizzard weak signature + /// + /// @throws StormError if signing fails + void Sign(); + + /// Closes the archive and writes its tables; the destructor closes silently instead + /// + /// @throws StormError if the close fails, leaving the archive possibly incomplete + void Close(); + + /// The StormLib handle, for the calls this class does not wrap + HANDLE Handle() const { return handle_; } + +private: + explicit Archive(HANDLE handle) : handle_(handle) {} + + HANDLE handle_; +}; } // namespace mpqcli diff --git a/src/mpq/compact.cpp b/src/mpq/compact.cpp index ae553ad..73e970a 100644 --- a/src/mpq/compact.cpp +++ b/src/mpq/compact.cpp @@ -6,21 +6,17 @@ #include -#include "util/format.h" +#include "errors.h" namespace mpqcli { -int CompactMpqArchive(HANDLE archive, const std::optional &listfile_name) { +void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name) { std::cout << "[*] Compacting archive. This may take some time..." << std::endl; const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; if (!SFileCompactArchive(archive, listfile, false)) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to compact archive: (" << error << ") " << StormErrorString(error) - << std::endl; - return 1; + throw StormError("Failed to compact archive", SErrGetLastError()); } - return 0; } } // namespace mpqcli diff --git a/src/mpq/compact.h b/src/mpq/compact.h index a4fcaab..cde080a 100644 --- a/src/mpq/compact.h +++ b/src/mpq/compact.h @@ -8,7 +8,10 @@ namespace mpqcli { -int CompactMpqArchive(HANDLE archive, const std::optional &listfile_name); +/// Compacts the archive, resolving names it cannot from listfile_name when given +/// +/// @throws StormError if StormLib cannot compact it +void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name); } // namespace mpqcli diff --git a/src/mpq/create.cpp b/src/mpq/create.cpp deleted file mode 100644 index bab8e1d..0000000 --- a/src/mpq/create.cpp +++ /dev/null @@ -1,57 +0,0 @@ -#include "mpq/create.h" - -#include -#include -#include -#include -#include - -#include - -#include "gamerules/rules.h" -#include "util/format.h" - -namespace fs = std::filesystem; - -namespace mpqcli { - -HANDLE CreateMpqArchive(const std::string &output_archive_name, const uint32_t file_count, - const GameRules &game_rules) { - std::error_code ec; - if (fs::exists(output_archive_name, ec)) { - std::cerr << "[!] File already exists: " << output_archive_name << " Exiting..." - << std::endl; - return nullptr; - } - - HANDLE archive; - - // Use game-specific create settings - const MpqCreateSettings &settings = game_rules.GetCreateSettings(); - - SFILE_CREATE_MPQ create_info = {}; - // All logic for defaults and dependencies is handled in GameRules::OverrideCreateSettings - create_info.cbSize = sizeof(SFILE_CREATE_MPQ); - create_info.dwMpqVersion = settings.mpq_version; - create_info.dwStreamFlags = settings.stream_flags; - create_info.dwFileFlags1 = settings.file_flags1; - create_info.dwFileFlags2 = settings.file_flags2; - create_info.dwFileFlags3 = settings.file_flags3; - create_info.dwAttrFlags = settings.attr_flags; - create_info.dwSectorSize = settings.sector_size; - create_info.dwRawChunkSize = settings.raw_chunk_size; - create_info.dwMaxFileCount = file_count; - - const bool result = SFileCreateArchive2(output_archive_name.c_str(), &create_info, &archive); - - if (!result) { - const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to create MPQ archive: " << output_archive_name << ": (" << error - << ") " << StormErrorString(error) << std::endl; - return nullptr; - } - - return archive; -} - -} // namespace mpqcli diff --git a/src/mpq/create.h b/src/mpq/create.h deleted file mode 100644 index 03109a1..0000000 --- a/src/mpq/create.h +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef MPQ_CREATE_H -#define MPQ_CREATE_H - -#include -#include - -#include - -#include "gamerules/rules.h" - -namespace mpqcli { - -HANDLE CreateMpqArchive(const std::string &output_archive_name, uint32_t file_count, - const GameRules &game_rules); - -} // namespace mpqcli - -#endif diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp index 75aa793..69c5e4e 100644 --- a/src/mpq/list.cpp +++ b/src/mpq/list.cpp @@ -12,6 +12,7 @@ #include +#include "errors.h" #include "mpq/flags.h" #include "mpq/query.h" #include "util/format.h" @@ -19,15 +20,14 @@ namespace mpqcli { -int ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, - bool list_detailed, const std::vector &properties) { +void ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, + bool list_detailed, const std::vector &properties) { const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; SFILE_FIND_DATA find_data; HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); if (find_handle == nullptr) { - std::cerr << "[!] Failed to find first file in MPQ archive." << std::endl; - return -1; + throw ArchiveError("Failed to find first file in MPQ archive."); } std::vector properties_to_print = @@ -159,7 +159,6 @@ int ListFiles(HANDLE archive, const std::optional &listfile_name, b } while (SFileFindNextFile(find_handle, &find_data)); SFileFindClose(find_handle); - return 0; } } // namespace mpqcli diff --git a/src/mpq/list.h b/src/mpq/list.h index 191005c..500f488 100644 --- a/src/mpq/list.h +++ b/src/mpq/list.h @@ -9,8 +9,11 @@ namespace mpqcli { -int ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, - bool list_detailed, const std::vector &properties); +/// Prints the archive's files, one per line, with the requested properties +/// +/// @throws ArchiveError if the archive's files cannot be enumerated +void ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, + bool list_detailed, const std::vector &properties); } // namespace mpqcli diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp index 40e0b16..b05dfa0 100644 --- a/src/mpq/verify.cpp +++ b/src/mpq/verify.cpp @@ -5,13 +5,13 @@ #include #include #include -#include #include #include #include #include +#include "errors.h" #include "mpq/query.h" #include "mpq/read.h" #include "util/format.h" @@ -25,22 +25,21 @@ uint32_t VerifyMpqArchive(HANDLE archive) { return SFileVerifyArchive(archive); } -int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { +void PrintMpqSignature(HANDLE archive, const std::string &target) { // Determine if we have a strong or weak digital signature int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); std::vector signature_content; if (signature_type == SIGNATURE_TYPE_NONE) { - return 1; + return; } else if (signature_type == SIGNATURE_TYPE_WEAK) { const char *file_name = "(signature)"; uint32_t file_size; auto file_content = ReadFile(archive, file_name, &file_size, default_locale); if (!file_content) { - std::cerr << "[!] Failed to read weak signature file." << std::endl; - return -1; + throw ArchiveError("Failed to read weak signature file."); } signature_content.resize(file_size); std::copy(file_content.get(), file_content.get() + file_size, signature_content.begin()); @@ -58,9 +57,8 @@ int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { std::error_code ec; const auto file_size = static_cast(fs::file_size(archive_path, ec)); if (ec) { - std::cerr << "[!] Failed to read archive size: (" << ec.value() << ") " << ec.message() - << ": " << target << std::endl; - return -1; + throw ArchiveError("Failed to read archive size: (" + std::to_string(ec.value()) + + ") " + ec.message() + ": " + target); } // fs::file_size returns uintmax_t, so without the cast the subtraction runs // unsigned and a legitimately negative length reaches the check below only @@ -68,8 +66,7 @@ int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { const int64_t signature_length = file_size - archive_offset - archive_size; if (signature_length <= 0) { - std::cerr << "[!] Invalid signature length: " << signature_length << std::endl; - return -1; + throw ArchiveError("Invalid signature length: " + std::to_string(signature_length)); } std::ifstream file_mpq(archive_path, std::ios::binary); @@ -81,8 +78,6 @@ int32_t PrintMpqSignature(HANDLE archive, const std::string &target) { PrintAsBinary(signature_content.data(), static_cast(signature_content.size())); } - - return 0; } } // namespace mpqcli diff --git a/src/mpq/verify.h b/src/mpq/verify.h index b26b9f6..05841fc 100644 --- a/src/mpq/verify.h +++ b/src/mpq/verify.h @@ -9,7 +9,11 @@ namespace mpqcli { uint32_t VerifyMpqArchive(HANDLE archive); -int32_t PrintMpqSignature(HANDLE archive, const std::string &target); + +/// Writes the archive's weak or strong signature bytes to stdout +/// +/// @throws ArchiveError if the signature cannot be read +void PrintMpqSignature(HANDLE archive, const std::string &target); } // namespace mpqcli From 1b90aaab74ce658b48a4c5c9bfc9ab21ca8a4e63 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sat, 26 Sep 2026 16:03:25 +1200 Subject: [PATCH 43/59] Handed the output streams through the core instead of naming them --- app/main.cpp | 25 +++++++++++-------- src/cli/add.cpp | 31 ++++++++++++------------ src/cli/commands.h | 28 +++++++++++++-------- src/cli/compact.cpp | 6 +++-- src/cli/create.cpp | 26 +++++++++++--------- src/cli/extract.cpp | 28 ++++++++++----------- src/cli/info.cpp | 6 +++-- src/cli/list.cpp | 6 +++-- src/cli/read.cpp | 12 ++++----- src/cli/remove.cpp | 5 ++-- src/cli/rename.cpp | 6 +++-- src/cli/verify.cpp | 17 +++++++------ src/mpq/add.cpp | 59 ++++++++++++++++++++++----------------------- src/mpq/add.h | 4 ++- src/mpq/compact.cpp | 7 +++--- src/mpq/compact.h | 4 ++- src/mpq/extract.cpp | 44 ++++++++++++++++----------------- src/mpq/extract.h | 7 ++++-- src/mpq/info.cpp | 37 ++++++++++++++-------------- src/mpq/info.h | 4 ++- src/mpq/list.cpp | 56 +++++++++++++++++++++--------------------- src/mpq/list.h | 6 +++-- src/mpq/read.cpp | 16 ++++++------ src/mpq/read.h | 3 ++- src/mpq/remove.cpp | 20 +++++++-------- src/mpq/remove.h | 4 ++- src/mpq/rename.cpp | 20 +++++++-------- src/mpq/rename.h | 4 ++- src/mpq/verify.cpp | 10 +++++--- src/mpq/verify.h | 6 +++-- src/util/format.cpp | 11 ++++++--- src/util/format.h | 5 ++-- 32 files changed, 288 insertions(+), 235 deletions(-) diff --git a/app/main.cpp b/app/main.cpp index ff75c92..1733057 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,9 @@ using namespace mpqcli; int main(int argc, char **argv) { + std::ostream &out = std::cout; + std::ostream &err = std::cerr; + CLI::App app{ "A command line tool to create, add, remove, list, extract, read, rename, and verify MPQ " "archives " @@ -284,7 +288,7 @@ int main(int argc, char **argv) { } if (app.got_subcommand(info)) { - return HandleInfo(base_target, info_property); + return HandleInfo(base_target, info_property, out); } if (app.got_subcommand(create)) { @@ -292,7 +296,7 @@ int main(int argc, char **argv) { base_target, base_path, base_output, create_sign_archive, base_locale, base_game_profile, create_mpq_version, create_stream_flags, create_sector_size, create_raw_chunk_size, create_file_flags1, create_file_flags2, create_file_flags3, - create_attr_flags, file_flags, file_compression, file_compression_next); + create_attr_flags, file_flags, file_compression, file_compression_next, out, err); } if (app.got_subcommand(add)) { @@ -310,7 +314,7 @@ int main(int argc, char **argv) { } return HandleAdd(resolved_add_files, base_target, base_path, add_overwrite, add_update, base_locale, base_game_profile, file_flags, file_compression, - file_compression_next); + file_compression_next, out, err); } if (app.got_subcommand(remove)) { @@ -326,35 +330,36 @@ int main(int argc, char **argv) { resolved_remove_files.push_back(f); } } - return HandleRemove(resolved_remove_files, base_target, base_locale); + return HandleRemove(resolved_remove_files, base_target, base_locale, out, err); } if (app.got_subcommand(rename)) { - return HandleRename(base_file, rename_new_file, base_target, base_locale); + return HandleRename(base_file, rename_new_file, base_target, base_locale, out, err); } if (app.got_subcommand(list)) { return HandleList(base_target, base_listfile_name, list_all, list_detailed, - list_properties); + list_properties, out, err); } if (app.got_subcommand(extract)) { std::optional extract_file = base_file.empty() ? std::nullopt : std::make_optional(base_file); return HandleExtract(base_target, base_output, extract_file, - extract_keep_folder_structure, base_listfile_name, base_locale); + extract_keep_folder_structure, base_listfile_name, base_locale, + out, err); } if (app.got_subcommand(read)) { - return HandleRead(base_file, base_target, base_locale); + return HandleRead(base_file, base_target, base_locale, out, err); } if (app.got_subcommand(verify)) { - return HandleVerify(base_target, verify_print_signature); + return HandleVerify(base_target, verify_print_signature, out, err); } if (app.got_subcommand(compact)) { - return HandleCompact(base_target, base_listfile_name); + return HandleCompact(base_target, base_listfile_name, out); } if (app.got_subcommand(completion)) { diff --git a/src/cli/add.cpp b/src/cli/add.cpp index 5025bfe..b669f21 100644 --- a/src/cli/add.cpp +++ b/src/cli/add.cpp @@ -2,8 +2,8 @@ #include #include -#include #include +#include #include #include #include @@ -24,7 +24,8 @@ int HandleAdd(const std::vector &files, const std::string &target, const std::optional &path, bool overwrite, bool update, const std::optional &locale, const std::optional &game_profile, int64_t file_flags, - int64_t file_compression, int64_t file_compression_next) { + int64_t file_compression, int64_t file_compression_next, std::ostream &out, + std::ostream &err) { Archive archive = Archive::Open(target, 0); LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; @@ -32,7 +33,7 @@ int HandleAdd(const std::vector &files, const std::string &target, GameProfile profile; if (game_profile.has_value()) { profile = GameRules::StringToProfile(game_profile.value()); - std::cout << "[*] Using game profile: " << game_profile.value() << std::endl; + out << "[*] Using game profile: " << game_profile.value() << std::endl; } else { profile = GameRules::GetDefaultProfile(); } @@ -59,7 +60,7 @@ int HandleAdd(const std::vector &files, const std::string &target, int files_skipped = 0; for (const auto &f : files) { if (!fs::exists(f, ec)) { - std::cerr << "[!] Path does not exist: " << f << std::endl; + err << "[!] Path does not exist: " << f << std::endl; result |= 1; continue; } @@ -67,23 +68,23 @@ int HandleAdd(const std::vector &files, const std::string &target, if (fs::is_directory(f, ec)) { std::vector directory_files = ListFilesRecursive(f, ec); if (ec) { - std::cerr << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() - << ": " << f << std::endl; + err << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() + << ": " << f << std::endl; result |= 1; continue; } std::string prefix = path.value_or(""); - result |= AddFiles(archive.Handle(), directory_files, f, prefix, lcid, game_rules, - add_overrides, overwrite, update, &files_skipped); + result |= AddFiles(archive.Handle(), directory_files, f, prefix, lcid, game_rules, out, + err, add_overrides, overwrite, update, &files_skipped); } else if (fs::is_regular_file(f, ec)) { const bool treat_as_directory = has_directory || files.size() > 1; std::string archive_path = ResolveArchiveName(f, path, treat_as_directory); - result |= AddFile(archive.Handle(), f, archive_path, lcid, game_rules, add_overrides, - overwrite, update, &files_skipped); + result |= AddFile(archive.Handle(), f, archive_path, lcid, game_rules, out, err, + add_overrides, overwrite, update, &files_skipped); } else { - std::cerr << "[!] Not a file or directory: " << f << std::endl; + err << "[!] Not a file or directory: " << f << std::endl; result |= 1; } } @@ -91,10 +92,10 @@ int HandleAdd(const std::vector &files, const std::string &target, // Skipping pre-existing files is the default, so point at the flags that change it // rather than letting the run look like it silently did nothing. if (!overwrite && !update && files_skipped > 0) { - std::cerr << "[*] " << files_skipped - << " file(s) already in the archive were skipped. Use --overwrite to replace " - "them, or --update to replace only the ones that changed." - << std::endl; + err << "[*] " << files_skipped + << " file(s) already in the archive were skipped. Use --overwrite to replace " + "them, or --update to replace only the ones that changed." + << std::endl; } archive.Close(); diff --git a/src/cli/commands.h b/src/cli/commands.h index 8d401c7..47641e2 100644 --- a/src/cli/commands.h +++ b/src/cli/commands.h @@ -3,38 +3,46 @@ #include #include +#include #include #include namespace mpqcli { -int HandleInfo(const std::string &target, const std::optional &property); +int HandleInfo(const std::string &target, const std::optional &property, + std::ostream &out); int HandleCreate(const std::string &target, const std::optional &path, const std::optional &output, bool sign_archive, const std::optional &locale, const std::optional &game_profile, int32_t mpq_version, int64_t stream_flags, int64_t sector_size, int64_t raw_chunk_size, int64_t file_flags1, int64_t file_flags2, int64_t file_flags3, int64_t attr_flags, - int64_t file_flags, int64_t file_compression, int64_t file_compression_next); + int64_t file_flags, int64_t file_compression, int64_t file_compression_next, + std::ostream &out, std::ostream &err); int HandleAdd(const std::vector &files, const std::string &target, const std::optional &path, bool overwrite, bool update, const std::optional &locale, const std::optional &game_profile, int64_t file_flags, - int64_t file_compression, int64_t file_compression_next); + int64_t file_compression, int64_t file_compression_next, std::ostream &out, + std::ostream &err); int HandleRemove(const std::vector &files, const std::string &target, - const std::optional &locale); + const std::optional &locale, std::ostream &out, std::ostream &err); int HandleRename(const std::string &old_file, const std::string &new_file, - const std::string &target, const std::optional &locale); + const std::string &target, const std::optional &locale, + std::ostream &out, std::ostream &err); int HandleList(const std::string &target, const std::optional &listfile_name, - bool list_all, bool list_detailed, const std::vector &properties); + bool list_all, bool list_detailed, const std::vector &properties, + std::ostream &out, std::ostream &err); int HandleExtract(const std::string &target, const std::optional &output, const std::optional &file, bool keep_folder_structure, const std::optional &listfile_name, - const std::optional &locale); + const std::optional &locale, std::ostream &out, std::ostream &err); int HandleRead(const std::string &file, const std::string &target, - const std::optional &locale); -int HandleVerify(const std::string &target, bool print_signature); -int HandleCompact(const std::string &target, const std::optional &listfile_name); + const std::optional &locale, std::ostream &out, std::ostream &err); +int HandleVerify(const std::string &target, bool print_signature, std::ostream &out, + std::ostream &err); +int HandleCompact(const std::string &target, const std::optional &listfile_name, + std::ostream &out); } // namespace mpqcli diff --git a/src/cli/compact.cpp b/src/cli/compact.cpp index 402333c..a596ce2 100644 --- a/src/cli/compact.cpp +++ b/src/cli/compact.cpp @@ -1,6 +1,7 @@ #include "mpq/compact.h" #include +#include #include #include "cli/commands.h" @@ -8,9 +9,10 @@ namespace mpqcli { -int HandleCompact(const std::string &target, const std::optional &listfile_name) { +int HandleCompact(const std::string &target, const std::optional &listfile_name, + std::ostream &out) { Archive archive = Archive::Open(target, 0); - CompactMpqArchive(archive.Handle(), listfile_name); + CompactMpqArchive(archive.Handle(), listfile_name, out); archive.Close(); return 0; } diff --git a/src/cli/create.cpp b/src/cli/create.cpp index 57bfd2c..592316b 100644 --- a/src/cli/create.cpp +++ b/src/cli/create.cpp @@ -1,7 +1,7 @@ #include #include -#include #include +#include #include #include #include @@ -26,14 +26,15 @@ int HandleCreate(const std::string &target, const std::optional &pa const std::optional &game_profile, int32_t mpq_version, int64_t stream_flags, int64_t sector_size, int64_t raw_chunk_size, int64_t file_flags1, int64_t file_flags2, int64_t file_flags3, int64_t attr_flags, - int64_t file_flags, int64_t file_compression, int64_t file_compression_next) { + int64_t file_flags, int64_t file_compression, int64_t file_compression_next, + std::ostream &out, std::ostream &err) { std::error_code ec; fs::path output_file_path; if (output.has_value()) { output_file_path = fs::absolute(output.value(), ec); if (ec) { - std::cerr << "[!] Failed to resolve output path: (" << ec.value() << ") " - << ec.message() << ": " << output.value() << std::endl; + err << "[!] Failed to resolve output path: (" << ec.value() << ") " << ec.message() + << ": " << output.value() << std::endl; return 1; } } else { @@ -55,8 +56,8 @@ int HandleCreate(const std::string &target, const std::optional &pa } GameRules game_rules(profile); - std::cout << "[*] Game profile: " << game_profile.value_or("default") - << ", Output file: " << output_file << std::endl; + out << "[*] Game profile: " << game_profile.value_or("default") + << ", Output file: " << output_file << std::endl; if (mpq_version > 0) { mpq_version--; // We label versions 1-4, but StormLib uses 0-3 @@ -88,12 +89,12 @@ int HandleCreate(const std::string &target, const std::optional &pa if (is_directory) { files = ListFilesRecursive(target, ec); if (ec) { - std::cerr << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() - << ": " << target << std::endl; + err << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() << ": " + << target << std::endl; return 1; } } else if (!fs::is_regular_file(target, ec)) { - std::cerr << "[!] Not a file or directory: " << target << std::endl; + err << "[!] Not a file or directory: " << target << std::endl; return 1; } const uint32_t file_count = @@ -115,11 +116,12 @@ int HandleCreate(const std::string &target, const std::optional &pa int result = 0; if (is_directory) { const std::string prefix = path.value_or(""); - result |= - AddFiles(archive.Handle(), files, target, prefix, lcid, game_rules, add_overrides); + result |= AddFiles(archive.Handle(), files, target, prefix, lcid, game_rules, out, err, + add_overrides); } else { std::string archive_path = ResolveArchiveName(target, path); - result |= AddFile(archive.Handle(), target, archive_path, lcid, game_rules, add_overrides); + result |= AddFile(archive.Handle(), target, archive_path, lcid, game_rules, out, err, + add_overrides); } if (sign_archive) { diff --git a/src/cli/extract.cpp b/src/cli/extract.cpp index baf96e5..21cccb3 100644 --- a/src/cli/extract.cpp +++ b/src/cli/extract.cpp @@ -1,8 +1,8 @@ #include "mpq/extract.h" #include -#include #include +#include #include #include @@ -19,7 +19,7 @@ namespace mpqcli { int HandleExtract(const std::string &target, const std::optional &output, const std::optional &file, bool keep_folder_structure, const std::optional &listfile_name, - const std::optional &locale) { + const std::optional &locale, std::ostream &out, std::ostream &err) { // If no output directory specified, use MPQ path without extension // If output directory specified, create it if it doesn't exist std::error_code ec; @@ -27,8 +27,8 @@ int HandleExtract(const std::string &target, const std::optional &o if (!output.has_value()) { fs::path target_path = fs::absolute(target, ec); if (ec) { - std::cerr << "[!] Failed to resolve archive path: (" << ec.value() << ") " - << ec.message() << ": " << target << std::endl; + err << "[!] Failed to resolve archive path: (" << ec.value() << ") " << ec.message() + << ": " << target << std::endl; return 1; } effective_output = (target_path.parent_path() / target_path.stem()).u8string(); @@ -39,8 +39,8 @@ int HandleExtract(const std::string &target, const std::optional &o if (ec) { std::error_code query_ec; if (!fs::is_directory(effective_output, query_ec)) { - std::cerr << "[!] Failed to create output directory: (" << ec.value() << ") " - << ec.message() << ": " << effective_output << std::endl; + err << "[!] Failed to create output directory: (" << ec.value() << ") " << ec.message() + << ": " << effective_output << std::endl; return 1; } } @@ -49,30 +49,30 @@ int HandleExtract(const std::string &target, const std::optional &o // real paths; warn once up front on volumes where it cannot (RAM disks) static_cast(fs::canonical(effective_output, ec)); if (ec) { - std::cout << "[!] Warning: Output directory cannot be fully resolved, symlinks will not " - "be checked during extraction: " - << effective_output << std::endl; + out << "[!] Warning: Output directory cannot be fully resolved, symlinks will not " + "be checked during extraction: " + << effective_output << std::endl; } Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; if (locale.has_value() && lcid == default_locale) { - std::cout << "[!] Warning: The locale '" << locale.value() - << "' is unknown. Will use default locale instead." << std::endl; + out << "[!] Warning: The locale '" << locale.value() + << "' is unknown. Will use default locale instead." << std::endl; } int result; if (file.has_value()) { result = ExtractFile(archive.Handle(), effective_output, file.value(), - keep_folder_structure, lcid); + keep_folder_structure, lcid, out, err); } else { - result = ExtractFiles(archive.Handle(), effective_output, listfile_name, lcid); + result = ExtractFiles(archive.Handle(), effective_output, listfile_name, lcid, out, err); } archive.Close(); if (result != 0) { - std::cerr << std::endl << "[!] Failed to extract all files." << std::endl; + err << std::endl << "[!] Failed to extract all files." << std::endl; } return result; } diff --git a/src/cli/info.cpp b/src/cli/info.cpp index 3d4ec4e..ea2f718 100644 --- a/src/cli/info.cpp +++ b/src/cli/info.cpp @@ -1,6 +1,7 @@ #include "mpq/info.h" #include +#include #include #include @@ -10,9 +11,10 @@ namespace mpqcli { -int HandleInfo(const std::string &target, const std::optional &property) { +int HandleInfo(const std::string &target, const std::optional &property, + std::ostream &out) { Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); - PrintMpqInfo(archive.Handle(), property); + PrintMpqInfo(archive.Handle(), property, out); archive.Close(); return 0; } diff --git a/src/cli/list.cpp b/src/cli/list.cpp index 0a024c4..7c61e62 100644 --- a/src/cli/list.cpp +++ b/src/cli/list.cpp @@ -1,6 +1,7 @@ #include "mpq/list.h" #include +#include #include #include @@ -12,9 +13,10 @@ namespace mpqcli { int HandleList(const std::string &target, const std::optional &listfile_name, - bool list_all, bool list_detailed, const std::vector &properties) { + bool list_all, bool list_detailed, const std::vector &properties, + std::ostream &out, std::ostream &err) { Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); - ListFiles(archive.Handle(), listfile_name, list_all, list_detailed, properties); + ListFiles(archive.Handle(), listfile_name, list_all, list_detailed, properties, out, err); archive.Close(); return 0; } diff --git a/src/cli/read.cpp b/src/cli/read.cpp index 789014e..63d4c43 100644 --- a/src/cli/read.cpp +++ b/src/cli/read.cpp @@ -1,8 +1,8 @@ #include "mpq/read.h" #include -#include #include +#include #include #include @@ -15,23 +15,23 @@ namespace mpqcli { int HandleRead(const std::string &file, const std::string &target, - const std::optional &locale) { + const std::optional &locale, std::ostream &out, std::ostream &err) { Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; if (locale.has_value() && lcid == default_locale) { - std::cout << "[!] Warning: The locale '" << locale.value() - << "' is unknown. Will use default locale instead." << std::endl; + out << "[!] Warning: The locale '" << locale.value() + << "' is unknown. Will use default locale instead." << std::endl; } uint32_t file_size; - auto file_content = ReadFile(archive.Handle(), file.c_str(), &file_size, lcid); + auto file_content = ReadFile(archive.Handle(), file.c_str(), &file_size, lcid, err); if (!file_content) { archive.Close(); return 1; } - PrintAsBinary(file_content.get(), file_size); + WriteBinary(out, file_content.get(), file_size); archive.Close(); return 0; diff --git a/src/cli/remove.cpp b/src/cli/remove.cpp index 8e68580..673f24c 100644 --- a/src/cli/remove.cpp +++ b/src/cli/remove.cpp @@ -1,6 +1,7 @@ #include "mpq/remove.h" #include +#include #include #include #include @@ -14,7 +15,7 @@ namespace mpqcli { int HandleRemove(const std::vector &files, const std::string &target, - const std::optional &locale) { + const std::optional &locale, std::ostream &out, std::ostream &err) { Archive archive = Archive::Open(target, 0); LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; @@ -24,7 +25,7 @@ int HandleRemove(const std::vector &files, const std::string &targe if (!seen.insert(f).second) { continue; } - int result = RemoveFile(archive.Handle(), f, lcid); + int result = RemoveFile(archive.Handle(), f, lcid, out, err); if (result != 0) { overall_result = result; } diff --git a/src/cli/rename.cpp b/src/cli/rename.cpp index 9054f30..3df2023 100644 --- a/src/cli/rename.cpp +++ b/src/cli/rename.cpp @@ -1,6 +1,7 @@ #include "mpq/rename.h" #include +#include #include #include @@ -12,11 +13,12 @@ namespace mpqcli { int HandleRename(const std::string &old_file, const std::string &new_file, - const std::string &target, const std::optional &locale) { + const std::string &target, const std::optional &locale, + std::ostream &out, std::ostream &err) { Archive archive = Archive::Open(target, 0); LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - const int result = RenameFile(archive.Handle(), old_file, new_file, lcid); + const int result = RenameFile(archive.Handle(), old_file, new_file, lcid, out, err); archive.Close(); return result; } diff --git a/src/cli/verify.cpp b/src/cli/verify.cpp index 896caa6..e55a5e3 100644 --- a/src/cli/verify.cpp +++ b/src/cli/verify.cpp @@ -1,6 +1,6 @@ #include "mpq/verify.h" -#include +#include #include #include @@ -10,7 +10,8 @@ namespace mpqcli { -int HandleVerify(const std::string &target, bool print_signature) { +int HandleVerify(const std::string &target, bool print_signature, std::ostream &out, + std::ostream &err) { Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); int result; @@ -20,9 +21,9 @@ int HandleVerify(const std::string &target, bool print_signature) { if (print_signature) { // If printing the signature, don't print success message // because the user might want to pipe/redirect the signature data - PrintMpqSignature(archive.Handle(), target); + PrintMpqSignature(archive.Handle(), target, out, err); } else { - std::cout << "[*] Verify success" << std::endl; + out << "[*] Verify success" << std::endl; } result = 0; break; @@ -32,19 +33,19 @@ int HandleVerify(const std::string &target, bool print_signature) { if (print_signature) { // Print the (invalid) signature bytes for forensic inspection, // but still fail: the archive content no longer matches it - PrintMpqSignature(archive.Handle(), target); + PrintMpqSignature(archive.Handle(), target, out, err); } - std::cerr << "[!] Verify failed: signature is present but invalid" << std::endl; + err << "[!] Verify failed: signature is present but invalid" << std::endl; result = 1; break; case ERROR_NO_SIGNATURE: - std::cerr << "[!] Verify failed: archive has no signature" << std::endl; + err << "[!] Verify failed: archive has no signature" << std::endl; result = 1; break; default: // ERROR_VERIFY_FAILED or any other value - std::cerr << "[!] Verify failed" << std::endl; + err << "[!] Verify failed" << std::endl; result = 1; break; } diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index 6c2ceb2..9563f39 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -3,8 +3,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -24,8 +24,8 @@ namespace mpqcli { int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, const std::string &path_prefix, LCID locale, const GameRules &game_rules, - const CompressionSettingsOverrides &overrides, bool overwrite, bool update, - int *skipped) { + std::ostream &out, std::ostream &err, const CompressionSettingsOverrides &overrides, + bool overwrite, bool update, int *skipped) { int files_added = 0; int files_skipped = 0; int files_failed = 0; @@ -46,13 +46,13 @@ int AddFiles(HANDLE archive, const std::vector &files, const fs::path if (std::find(special_mpq_files.begin(), special_mpq_files.end(), archive_file_path) != special_mpq_files.end()) { - std::cout << "[*] Skipping special MPQ file: " << archive_file_path << std::endl; + out << "[*] Skipping special MPQ file: " << archive_file_path << std::endl; continue; } int file_skipped = 0; - if (AddFile(archive, file, archive_file_path, locale, game_rules, overrides, overwrite, - update, &file_skipped) != 0) { + if (AddFile(archive, file, archive_file_path, locale, game_rules, out, err, overrides, + overwrite, update, &file_skipped) != 0) { files_failed++; } else if (file_skipped > 0) { files_skipped++; @@ -62,9 +62,8 @@ int AddFiles(HANDLE archive, const std::vector &files, const fs::path } if (update) { - std::cout << "[*] For " << base_path.u8string() << ": " << files_added << " files added, " - << files_skipped << " files skipped, " << files_failed << " files failed." - << std::endl; + out << "[*] For " << base_path.u8string() << ": " << files_added << " files added, " + << files_skipped << " files skipped, " << files_failed << " files failed." << std::endl; } if (skipped != nullptr) { @@ -75,13 +74,13 @@ int AddFiles(HANDLE archive, const std::vector &files, const fs::path } int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archive_file_path, - const LCID locale, const GameRules &game_rules, + const LCID locale, const GameRules &game_rules, std::ostream &out, std::ostream &err, const CompressionSettingsOverrides &overrides, bool overwrite, bool update, int *skipped) { // Return if file doesn't exist on disk std::error_code ec; if (!fs::exists(local_file, ec)) { - std::cerr << "[!] File doesn't exist on disk: " << local_file << std::endl; + err << "[!] File doesn't exist on disk: " << local_file << std::endl; return 1; } @@ -97,8 +96,8 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi SFileCloseFile(file); if (unchanged) { - std::cout << "[~] Skipping unchanged file: " << archive_file_path << " (" - << match_reason << ")" << std::endl; + out << "[~] Skipping unchanged file: " << archive_file_path << " (" << match_reason + << ")" << std::endl; if (skipped != nullptr) { (*skipped)++; } @@ -108,24 +107,24 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi // Without either flag, leaving the archived copy in place is the documented // default, so this is a skip rather than a failure. if (!overwrite && !update) { - std::cerr << "[!] File" << PrettyPrintLocale(locale, " for locale ") - << " already exists in MPQ archive: " << archive_file_path - << " - Skipping..." << std::endl; + err << "[!] File" << PrettyPrintLocale(locale, " for locale ") + << " already exists in MPQ archive: " << archive_file_path << " - Skipping..." + << std::endl; if (skipped != nullptr) { (*skipped)++; } return 0; } - std::cout << "[+] File" << PrettyPrintLocale(locale, " for locale ") - << " already exists in MPQ archive: " << archive_file_path - << " - Overwriting..." << std::endl; + out << "[+] File" << PrettyPrintLocale(locale, " for locale ") + << " already exists in MPQ archive: " << archive_file_path << " - Overwriting..." + << std::endl; } else { SFileCloseFile(file); } } - std::cout << "[+] Adding file" << PrettyPrintLocale(locale, " for locale ") << ": " - << archive_file_path << std::endl; + out << "[+] Adding file" << PrettyPrintLocale(locale, " for locale ") << ": " + << archive_file_path << std::endl; // Verify that we are not exceeding maxFile size of the archive, and if we do, increase it int32_t number_of_files = GetFileInfo(archive, SFileMpqNumberOfFiles); @@ -136,8 +135,8 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi bool set_max_file_count = SFileSetMaxFileCount(archive, new_max_files); if (!set_max_file_count) { const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to increase new max file count to " << new_max_files << ": (" - << error << ") " << StormErrorString(error) << std::endl; + err << "[!] Failed to increase new max file count to " << new_max_files << ": (" + << error << ") " << StormErrorString(error) << std::endl; return 1; } } @@ -145,14 +144,14 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi // Get file size for rule matching const std::uintmax_t raw_file_size = fs::file_size(local_file, ec); if (ec) { - std::cerr << "[!] Failed to read file size: (" << ec.value() << ") " << ec.message() << ": " - << local_file << std::endl; + err << "[!] Failed to read file size: (" << ec.value() << ") " << ec.message() << ": " + << local_file << std::endl; return 1; } if (raw_file_size > std::numeric_limits::max()) { - std::cerr << "[!] Warning: file exceeds 4GB, size-based compression rules may not apply " - "correctly: " - << local_file << std::endl; + err << "[!] Warning: file exceeds 4GB, size-based compression rules may not apply " + "correctly: " + << local_file << std::endl; } const DWORD file_size = static_cast( std::min(raw_file_size, static_cast(std::numeric_limits::max()))); @@ -177,8 +176,8 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi if (!added_file) { const auto error = SErrGetLastError(); - std::cerr << "[!] Failed to add: " << archive_file_path << ": (" << error << ") " - << StormErrorString(error) << std::endl; + err << "[!] Failed to add: " << archive_file_path << ": (" << error << ") " + << StormErrorString(error) << std::endl; return 1; } diff --git a/src/mpq/add.h b/src/mpq/add.h index ce0bf4c..6016a1d 100644 --- a/src/mpq/add.h +++ b/src/mpq/add.h @@ -2,6 +2,7 @@ #define MPQ_ADD_H #include +#include #include #include @@ -14,11 +15,12 @@ namespace mpqcli { int AddFiles(HANDLE archive, const std::vector &files, const std::filesystem::path &base_path, const std::string &path_prefix, LCID locale, - const GameRules &game_rules, + const GameRules &game_rules, std::ostream &out, std::ostream &err, const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), bool overwrite = false, bool update = false, int *skipped = nullptr); int AddFile(HANDLE archive, const std::filesystem::path &local_file, const std::string &archive_file_path, LCID locale, const GameRules &game_rules, + std::ostream &out, std::ostream &err, const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), bool overwrite = false, bool update = false, int *skipped = nullptr); diff --git a/src/mpq/compact.cpp b/src/mpq/compact.cpp index 73e970a..b65c1ec 100644 --- a/src/mpq/compact.cpp +++ b/src/mpq/compact.cpp @@ -1,7 +1,7 @@ #include "mpq/compact.h" -#include #include +#include #include #include @@ -10,8 +10,9 @@ namespace mpqcli { -void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name) { - std::cout << "[*] Compacting archive. This may take some time..." << std::endl; +void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name, + std::ostream &out) { + out << "[*] Compacting archive. This may take some time..." << std::endl; const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; if (!SFileCompactArchive(archive, listfile, false)) { diff --git a/src/mpq/compact.h b/src/mpq/compact.h index cde080a..c5e9e49 100644 --- a/src/mpq/compact.h +++ b/src/mpq/compact.h @@ -2,6 +2,7 @@ #define MPQ_COMPACT_H #include +#include #include #include @@ -11,7 +12,8 @@ namespace mpqcli { /// Compacts the archive, resolving names it cannot from listfile_name when given /// /// @throws StormError if StormLib cannot compact it -void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name); +void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name, + std::ostream &out); } // namespace mpqcli diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index 845c474..edec7f7 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -2,8 +2,8 @@ #include #include -#include #include +#include #include #include @@ -19,14 +19,15 @@ namespace fs = std::filesystem; namespace mpqcli { int ExtractFiles(HANDLE archive, const std::string &output, - const std::optional &listfile_name, LCID preferred_locale) { + const std::optional &listfile_name, LCID preferred_locale, + std::ostream &out, std::ostream &err) { SFileSetLocale(preferred_locale); const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; SFILE_FIND_DATA find_data; HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); if (find_handle == nullptr) { - std::cerr << "[!] Failed to find first file in MPQ archive." << std::endl; + err << "[!] Failed to find first file in MPQ archive." << std::endl; return 1; } @@ -34,7 +35,7 @@ int ExtractFiles(HANDLE archive, const std::string &output, do { result |= ExtractFile(archive, output, find_data.cFileName, true, // Keep folder structure - preferred_locale); + preferred_locale, out, err); } while (SFileFindNextFile(find_handle, &find_data)); SFileFindClose(find_handle); @@ -42,13 +43,14 @@ int ExtractFiles(HANDLE archive, const std::string &output, } int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, - bool keep_folder_structure, LCID preferred_locale) { + bool keep_folder_structure, LCID preferred_locale, std::ostream &out, + std::ostream &err) { SFileSetLocale(preferred_locale); if (!FileExistsInArchiveForLocale(archive, file_name.c_str(), preferred_locale) && !FileExistsInArchiveForLocale(archive, file_name.c_str(), default_locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name - << std::endl; + err << "[!] Failed: File doesn't exist" + << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name + << std::endl; return 1; } @@ -65,8 +67,8 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi std::error_code ec; fs::path output_path_base = fs::absolute(output, ec).lexically_normal(); if (ec) { - std::cerr << "[!] Failed to resolve output directory: (" << ec.value() << ") " - << ec.message() << ": " << output << std::endl; + err << "[!] Failed to resolve output directory: (" << ec.value() << ") " << ec.message() + << ": " << output << std::endl; return 1; } if (output_path_base.filename().empty()) { @@ -77,17 +79,15 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi // ".." entry is rejected before anything is created on disk fs::path output_file_path_name = (output_path_base / file_name_string).lexically_normal(); if (!IsWithinDirectory(output_path_base, output_file_path_name)) { - std::cerr << "[!] Blocked: path traversal attempt detected: " << file_name_string - << std::endl; + err << "[!] Blocked: path traversal attempt detected: " << file_name_string << std::endl; return 1; } // Ensure sub-directories for folder-nested files exist before resolving fs::create_directories(output_file_path_name.parent_path(), ec); if (ec) { - std::cerr << "[!] Failed to create output directory: (" << ec.value() << ") " - << ec.message() << ": " << output_file_path_name.parent_path().u8string() - << std::endl; + err << "[!] Failed to create output directory: (" << ec.value() << ") " << ec.message() + << ": " << output_file_path_name.parent_path().u8string() << std::endl; return 1; } @@ -99,13 +99,13 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi fs::path resolved_output = fs::canonical(output_file_path_name.parent_path(), ec) / output_file_path_name.filename(); if (ec) { - std::cerr << "[!] Failed to resolve output path: (" << ec.value() << ") " - << ec.message() << ": " << output_file_path_name.u8string() << std::endl; + err << "[!] Failed to resolve output path: (" << ec.value() << ") " << ec.message() + << ": " << output_file_path_name.u8string() << std::endl; return 1; } if (!IsWithinDirectory(resolved_base, resolved_output)) { - std::cerr << "[!] Blocked: path traversal attempt detected: " << file_name_string - << std::endl; + err << "[!] Blocked: path traversal attempt detected: " << file_name_string + << std::endl; return 1; } } @@ -113,11 +113,11 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi std::string output_file_name{output_file_path_name.u8string()}; if (SFileExtractFile(archive, file_name.c_str(), output_file_name.c_str(), 0)) { - std::cout << "[*] Extracted: " << file_name_string << std::endl; + out << "[*] Extracted: " << file_name_string << std::endl; } else { const auto error = SErrGetLastError(); - std::cerr << "[!] Failed: (" << error << ") " << StormErrorString(error) << ": " - << file_name << std::endl; + err << "[!] Failed: (" << error << ") " << StormErrorString(error) << ": " << file_name + << std::endl; return 1; } diff --git a/src/mpq/extract.h b/src/mpq/extract.h index e69ffb6..6e4f49f 100644 --- a/src/mpq/extract.h +++ b/src/mpq/extract.h @@ -2,6 +2,7 @@ #define MPQ_EXTRACT_H #include +#include #include #include @@ -9,9 +10,11 @@ namespace mpqcli { int ExtractFiles(HANDLE archive, const std::string &output, - const std::optional &listfile_name, LCID preferred_locale); + const std::optional &listfile_name, LCID preferred_locale, + std::ostream &out, std::ostream &err); int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, - bool keep_folder_structure, LCID preferred_locale); + bool keep_folder_structure, LCID preferred_locale, std::ostream &out, + std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/info.cpp b/src/mpq/info.cpp index 985af25..7e3accc 100644 --- a/src/mpq/info.cpp +++ b/src/mpq/info.cpp @@ -2,9 +2,9 @@ #include #include -#include #include #include +#include #include #include @@ -14,7 +14,8 @@ namespace mpqcli { -void PrintMpqInfo(HANDLE archive, const std::optional &info_property) { +void PrintMpqInfo(HANDLE archive, const std::optional &info_property, + std::ostream &out) { std::map> property_actions = { {"format-version", [&](bool print_name) { @@ -22,61 +23,61 @@ void PrintMpqInfo(HANDLE archive, const std::optional &info_propert uint16_t format_version = header.wFormatVersion + 1; // Add +1 because StormLib starts at 0 if (print_name) { - std::cout << "Format version: "; + out << "Format version: "; } - std::cout << format_version << std::endl; + out << format_version << std::endl; }}, {"header-offset", [&](bool print_name) { int64_t header_offset = GetFileInfo(archive, SFileMpqHeaderOffset); if (print_name) { - std::cout << "Header offset: "; + out << "Header offset: "; } - std::cout << header_offset << std::endl; + out << header_offset << std::endl; }}, {"header-size", [&](bool print_name) { int64_t header_size = GetFileInfo(archive, SFileMpqHeaderSize); if (print_name) { - std::cout << "Header size: "; + out << "Header size: "; } - std::cout << header_size << std::endl; + out << header_size << std::endl; }}, {"archive-size", [&](bool print_name) { int64_t archive_size = GetFileInfo(archive, SFileMpqArchiveSize64); if (print_name) { - std::cout << "Archive size: "; + out << "Archive size: "; } - std::cout << archive_size << std::endl; + out << archive_size << std::endl; }}, {"file-count", [&](bool print_name) { int32_t number_of_files = GetFileInfo(archive, SFileMpqNumberOfFiles); if (print_name) { - std::cout << "File count: "; + out << "File count: "; } - std::cout << number_of_files << std::endl; + out << number_of_files << std::endl; }}, {"max-files", [&](bool print_name) { int32_t max_files = GetFileInfo(archive, SFileMpqMaxFileCount); if (print_name) { - std::cout << "Max files: "; + out << "Max files: "; } - std::cout << max_files << std::endl; + out << max_files << std::endl; }}, {"signature-type", [&](bool print_name) { int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); if (print_name) { - std::cout << "Signature type: "; + out << "Signature type: "; } if (signature_type == SIGNATURE_TYPE_NONE) { - std::cout << "None" << std::endl; + out << "None" << std::endl; } else if (signature_type == SIGNATURE_TYPE_WEAK) { - std::cout << "Weak" << std::endl; + out << "Weak" << std::endl; } else if (signature_type == SIGNATURE_TYPE_STRONG) { - std::cout << "Strong" << std::endl; + out << "Strong" << std::endl; } }}}; diff --git a/src/mpq/info.h b/src/mpq/info.h index 2f9292f..35a8041 100644 --- a/src/mpq/info.h +++ b/src/mpq/info.h @@ -2,13 +2,15 @@ #define MPQ_INFO_H #include +#include #include #include namespace mpqcli { -void PrintMpqInfo(HANDLE archive, const std::optional &info_property); +void PrintMpqInfo(HANDLE archive, const std::optional &info_property, + std::ostream &out); } // namespace mpqcli diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp index 69c5e4e..dc6194e 100644 --- a/src/mpq/list.cpp +++ b/src/mpq/list.cpp @@ -3,9 +3,9 @@ #include #include #include -#include #include #include +#include #include #include #include @@ -21,7 +21,8 @@ namespace mpqcli { void ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, - bool list_detailed, const std::vector &properties) { + bool list_detailed, const std::vector &properties, std::ostream &out, + std::ostream &err) { const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; SFILE_FIND_DATA find_data; @@ -89,13 +90,13 @@ void ListFiles(HANDLE archive, const std::optional &listfile_name, file_locales[0] = default_locale; } else if (result == ERROR_INVALID_HANDLE || result == ERROR_NOT_SUPPORTED) { - std::cerr << "[!] Internal error for file: " << find_data.cFileName << std::endl; + err << "[!] Internal error for file: " << find_data.cFileName << std::endl; continue; } else if (result == ERROR_INSUFFICIENT_BUFFER) { - std::cerr << "[!] There are more than " << max_locales - << " locales for the file: " << find_data.cFileName - << ". Will only list the " << max_locales << " first files." << std::endl; + err << "[!] There are more than " << max_locales + << " locales for the file: " << find_data.cFileName << ". Will only list the " + << max_locales << " first files." << std::endl; } for (DWORD i = 0; i < max_locales; i++) { @@ -104,7 +105,7 @@ void ListFiles(HANDLE archive, const std::optional &listfile_name, HANDLE file; if (!SFileOpenFileEx(archive, find_data.cFileName, SFILE_OPEN_FROM_MPQ, &file)) { - std::cerr << "[!] Failed to open file: " << find_data.cFileName << std::endl; + err << "[!] Failed to open file: " << find_data.cFileName << std::endl; continue; } @@ -114,46 +115,45 @@ void ListFiles(HANDLE archive, const std::optional &listfile_name, continue; if (prop == "hash-index" || prop == "file-index") { - std::cout << std::setw(5) << GetFileInfo(file, it->second) << " "; + out << std::setw(5) << GetFileInfo(file, it->second) << " "; } else if (prop == "name-hash1" || prop == "name-hash2") { - std::cout << std::setfill('0') << std::hex << std::setw(8) - << GetFileInfo(file, it->second) << std::setfill(' ') - << std::dec << " "; + out << std::setfill('0') << std::hex << std::setw(8) + << GetFileInfo(file, it->second) << std::setfill(' ') + << std::dec << " "; } else if (prop == "name-hash3") { - std::cout << std::setfill('0') << std::hex << std::setw(16) - << GetFileInfo(file, it->second) << std::setfill(' ') - << std::dec << " "; + out << std::setfill('0') << std::hex << std::setw(16) + << GetFileInfo(file, it->second) << std::setfill(' ') + << std::dec << " "; } else if (prop == "locale") { // StormLib packs the hash entry's platform byte into bits 16 to 23 of // the LCID (SFILE_MAKE_LCID); only the low 16 bits are the locale const LCID file_locale = SFILE_LOCALE(GetFileInfo(file, it->second)); - std::cout << std::setw(4) << LocaleToLang(file_locale) << " "; + out << std::setw(4) << LocaleToLang(file_locale) << " "; } else if (prop == "byte-offset") { - std::cout << std::hex << std::setw(8) - << GetFileInfo(file, it->second) << std::dec << " "; + out << std::hex << std::setw(8) << GetFileInfo(file, it->second) + << std::dec << " "; } else if (prop == "file-time") { - std::cout << std::setw(19) - << FileTimeToLsTime(GetFileInfo(file, it->second)) - << " "; + out << std::setw(19) + << FileTimeToLsTime(GetFileInfo(file, it->second)) << " "; } else if (prop == "file-size" || prop == "compressed-size") { - std::cout << std::setw(8) << GetFileInfo(file, it->second) << " "; + out << std::setw(8) << GetFileInfo(file, it->second) << " "; } else if (prop == "flags") { - std::cout << std::setw(8) - << GetFlagString(GetFileInfo(file, it->second)) << " "; + out << std::setw(8) + << GetFlagString(GetFileInfo(file, it->second)) << " "; } else if (prop == "encryption-key" || prop == "encryption-key-raw") { - std::cout << std::setfill('0') << std::hex << std::setw(8) - << GetFileInfo(file, it->second) << std::setfill(' ') - << std::dec << " "; + out << std::setfill('0') << std::hex << std::setw(8) + << GetFileInfo(file, it->second) << std::setfill(' ') + << std::dec << " "; } } - std::cout << " " << find_data.cFileName << std::endl; + out << " " << find_data.cFileName << std::endl; SFileCloseFile(file); } SFileSetLocale(default_locale); // Reset locale to default after changing it } else { // Print just the filename (like default ls command output) - std::cout << find_data.cFileName << std::endl; + out << find_data.cFileName << std::endl; } } while (SFileFindNextFile(find_handle, &find_data)); diff --git a/src/mpq/list.h b/src/mpq/list.h index 500f488..2aa9fdc 100644 --- a/src/mpq/list.h +++ b/src/mpq/list.h @@ -2,6 +2,7 @@ #define MPQ_LIST_H #include +#include #include #include @@ -9,11 +10,12 @@ namespace mpqcli { -/// Prints the archive's files, one per line, with the requested properties +/// Prints the archive's files to out, one per line, with the requested properties /// /// @throws ArchiveError if the archive's files cannot be enumerated void ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, - bool list_detailed, const std::vector &properties); + bool list_detailed, const std::vector &properties, std::ostream &out, + std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/read.cpp b/src/mpq/read.cpp index f7f397f..ee3af7e 100644 --- a/src/mpq/read.cpp +++ b/src/mpq/read.cpp @@ -1,7 +1,7 @@ #include "mpq/read.h" -#include #include +#include #include @@ -12,25 +12,25 @@ namespace mpqcli { std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, - LCID preferred_locale) { + LCID preferred_locale, std::ostream &err) { SFileSetLocale(preferred_locale); if (!FileExistsInArchiveForLocale(archive, file_name, preferred_locale) && !FileExistsInArchiveForLocale(archive, file_name, default_locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name - << std::endl; + err << "[!] Failed: File doesn't exist" + << PrettyPrintLocale(preferred_locale, " for locale ", true) << ": " << file_name + << std::endl; return nullptr; } HANDLE file; if (!SFileOpenFileEx(archive, file_name, SFILE_OPEN_FROM_MPQ, &file)) { - std::cerr << "[!] Failed: File cannot be opened: " << file_name << std::endl; + err << "[!] Failed: File cannot be opened: " << file_name << std::endl; return nullptr; } *file_size = SFileGetFileSize(file, nullptr); if (*file_size == SFILE_INVALID_SIZE) { - std::cerr << "[!] Failed: Invalid file size for: " << file_name << std::endl; + err << "[!] Failed: Invalid file size for: " << file_name << std::endl; SFileCloseFile(file); return nullptr; } @@ -38,7 +38,7 @@ std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned auto file_content = std::make_unique(*file_size); DWORD bytes_read; if (!SFileReadFile(file, file_content.get(), *file_size, &bytes_read, nullptr)) { - std::cerr << "[!] Failed: Cannot read file contents for: " << file_name << std::endl; + err << "[!] Failed: Cannot read file contents for: " << file_name << std::endl; SFileCloseFile(file); return nullptr; } diff --git a/src/mpq/read.h b/src/mpq/read.h index 3360ae8..69f3d04 100644 --- a/src/mpq/read.h +++ b/src/mpq/read.h @@ -2,13 +2,14 @@ #define MPQ_READ_H #include +#include #include namespace mpqcli { std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, - LCID preferred_locale); + LCID preferred_locale, std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/remove.cpp b/src/mpq/remove.cpp index f7c9091..f97353d 100644 --- a/src/mpq/remove.cpp +++ b/src/mpq/remove.cpp @@ -1,6 +1,6 @@ #include "mpq/remove.h" -#include +#include #include #include @@ -11,22 +11,22 @@ namespace mpqcli { -int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale) { +int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale, std::ostream &out, + std::ostream &err) { SFileSetLocale(locale); - std::cout << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") << ": " - << archive_file_path << std::endl; + out << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") << ": " + << archive_file_path << std::endl; if (!FileExistsInArchiveForLocale(archive, archive_file_path, locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(locale, " for locale ", true) << ": " << archive_file_path - << std::endl; + err << "[!] Failed: File doesn't exist" << PrettyPrintLocale(locale, " for locale ", true) + << ": " << archive_file_path << std::endl; return 1; } if (!SFileRemoveFile(archive, archive_file_path.c_str(), 0)) { - std::cerr << "[!] Failed: File cannot be removed" - << PrettyPrintLocale(locale, " for locale ", true) << ": " << archive_file_path - << std::endl; + err << "[!] Failed: File cannot be removed" + << PrettyPrintLocale(locale, " for locale ", true) << ": " << archive_file_path + << std::endl; return 1; } diff --git a/src/mpq/remove.h b/src/mpq/remove.h index a39685a..35f6e30 100644 --- a/src/mpq/remove.h +++ b/src/mpq/remove.h @@ -1,13 +1,15 @@ #ifndef MPQ_REMOVE_H #define MPQ_REMOVE_H +#include #include #include namespace mpqcli { -int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale); +int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale, std::ostream &out, + std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/rename.cpp b/src/mpq/rename.cpp index 1ba7a17..30ba595 100644 --- a/src/mpq/rename.cpp +++ b/src/mpq/rename.cpp @@ -1,6 +1,6 @@ #include "mpq/rename.h" -#include +#include #include #include @@ -13,23 +13,23 @@ namespace mpqcli { int RenameFile(HANDLE archive, const std::string &old_archive_file_path, - const std::string &new_archive_file_path, LCID locale) { + const std::string &new_archive_file_path, LCID locale, std::ostream &out, + std::ostream &err) { SFileSetLocale(locale); const std::string new_stored_path = WindowsifyFilePath(new_archive_file_path); - std::cout << "[~] Renaming file" << PrettyPrintLocale(locale, " for locale ") << ": " - << old_archive_file_path << " -> " << new_stored_path << std::endl; + out << "[~] Renaming file" << PrettyPrintLocale(locale, " for locale ") << ": " + << old_archive_file_path << " -> " << new_stored_path << std::endl; if (!FileExistsInArchiveForLocale(archive, old_archive_file_path, locale)) { - std::cerr << "[!] Failed: File doesn't exist" - << PrettyPrintLocale(locale, " for locale ", true) << ": " - << old_archive_file_path << std::endl; + err << "[!] Failed: File doesn't exist" << PrettyPrintLocale(locale, " for locale ", true) + << ": " << old_archive_file_path << std::endl; return 1; } if (!SFileRenameFile(archive, old_archive_file_path.c_str(), new_stored_path.c_str())) { - std::cerr << "[!] Failed: File cannot be renamed" - << PrettyPrintLocale(locale, " for locale ", true) << ": " - << old_archive_file_path << " -> " << new_stored_path << std::endl; + err << "[!] Failed: File cannot be renamed" + << PrettyPrintLocale(locale, " for locale ", true) << ": " << old_archive_file_path + << " -> " << new_stored_path << std::endl; return 1; } diff --git a/src/mpq/rename.h b/src/mpq/rename.h index 8d01b3f..af9d232 100644 --- a/src/mpq/rename.h +++ b/src/mpq/rename.h @@ -1,6 +1,7 @@ #ifndef MPQ_RENAME_H #define MPQ_RENAME_H +#include #include #include @@ -8,7 +9,8 @@ namespace mpqcli { int RenameFile(HANDLE archive, const std::string &old_archive_file_path, - const std::string &new_archive_file_path, LCID locale); + const std::string &new_archive_file_path, LCID locale, std::ostream &out, + std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp index b05dfa0..a9e40e8 100644 --- a/src/mpq/verify.cpp +++ b/src/mpq/verify.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -25,7 +26,8 @@ uint32_t VerifyMpqArchive(HANDLE archive) { return SFileVerifyArchive(archive); } -void PrintMpqSignature(HANDLE archive, const std::string &target) { +void PrintMpqSignature(HANDLE archive, const std::string &target, std::ostream &out, + std::ostream &err) { // Determine if we have a strong or weak digital signature int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); @@ -36,7 +38,7 @@ void PrintMpqSignature(HANDLE archive, const std::string &target) { } else if (signature_type == SIGNATURE_TYPE_WEAK) { const char *file_name = "(signature)"; uint32_t file_size; - auto file_content = ReadFile(archive, file_name, &file_size, default_locale); + auto file_content = ReadFile(archive, file_name, &file_size, default_locale, err); if (!file_content) { throw ArchiveError("Failed to read weak signature file."); @@ -44,7 +46,7 @@ void PrintMpqSignature(HANDLE archive, const std::string &target) { signature_content.resize(file_size); std::copy(file_content.get(), file_content.get() + file_size, signature_content.begin()); - PrintAsBinary(file_content.get(), file_size); + WriteBinary(out, file_content.get(), file_size); } else if (signature_type == SIGNATURE_TYPE_STRONG) { // StormLib does not expose the strong signature via SFileGetFileInfo into a @@ -76,7 +78,7 @@ void PrintMpqSignature(HANDLE archive, const std::string &target) { static_cast(signature_content.size())); file_mpq.close(); - PrintAsBinary(signature_content.data(), static_cast(signature_content.size())); + WriteBinary(out, signature_content.data(), static_cast(signature_content.size())); } } diff --git a/src/mpq/verify.h b/src/mpq/verify.h index 05841fc..8ebda99 100644 --- a/src/mpq/verify.h +++ b/src/mpq/verify.h @@ -2,6 +2,7 @@ #define MPQ_VERIFY_H #include +#include #include #include @@ -10,10 +11,11 @@ namespace mpqcli { uint32_t VerifyMpqArchive(HANDLE archive); -/// Writes the archive's weak or strong signature bytes to stdout +/// Writes the archive's weak or strong signature bytes to out /// /// @throws ArchiveError if the signature cannot be read -void PrintMpqSignature(HANDLE archive, const std::string &target); +void PrintMpqSignature(HANDLE archive, const std::string &target, std::ostream &out, + std::ostream &err); } // namespace mpqcli diff --git a/src/util/format.cpp b/src/util/format.cpp index f145988..c65857c 100644 --- a/src/util/format.cpp +++ b/src/util/format.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #ifdef _WIN32 @@ -69,11 +70,15 @@ std::string StormErrorString(uint32_t err) { } } -void PrintAsBinary(const char *buffer, uint32_t size) { +void WriteBinary(std::ostream &out, const char *buffer, uint32_t size) { #ifdef _WIN32 - _setmode(_fileno(stdout), _O_BINARY); + // The mode belongs to the file descriptor rather than the stream, so switching + // it for a captured stream would change the real stdout for the rest of the run + if (&out == &std::cout) { + _setmode(_fileno(stdout), _O_BINARY); + } #endif - std::cout.write(buffer, size); + out.write(buffer, size); } } // namespace mpqcli diff --git a/src/util/format.h b/src/util/format.h index d322bb0..80aef84 100644 --- a/src/util/format.h +++ b/src/util/format.h @@ -2,6 +2,7 @@ #define UTIL_FORMAT_H #include +#include #include namespace mpqcli { @@ -9,8 +10,8 @@ namespace mpqcli { std::string FileTimeToLsTime(int64_t file_time); std::string StormErrorString(uint32_t err); -/// Writes size bytes of buffer to stdout without line-ending translation -void PrintAsBinary(const char *buffer, uint32_t size); +/// Writes size bytes of buffer to out without line-ending translation +void WriteBinary(std::ostream &out, const char *buffer, uint32_t size); } // namespace mpqcli From 4cd56b626db4b01a96b046e444edb423d8426172 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sat, 26 Sep 2026 16:16:15 +1200 Subject: [PATCH 44/59] Moved the CLI wiring into app and put the handlers behind options structs --- app/CMakeLists.txt | 10 + app/about.cpp | 30 ++- app/add.cpp | 51 ++++ app/commands.h | 31 ++- app/compact.cpp | 22 ++ app/completion.cpp | 31 +-- app/create.cpp | 75 ++++++ app/extract.cpp | 27 ++ app/info.cpp | 32 +++ app/list.cpp | 39 +++ app/main.cpp | 397 ++---------------------------- app/read.cpp | 22 ++ app/remove.cpp | 28 +++ app/rename.cpp | 24 ++ app/verify.cpp | 21 ++ app/version.cpp | 12 +- src/CMakeLists.txt | 20 +- src/cli/commands.h | 49 ---- src/cli/compact.cpp | 20 -- src/cli/create.cpp | 135 ---------- src/cli/info.cpp | 22 -- src/cli/list.cpp | 24 -- src/cli/read.cpp | 40 --- src/cli/remove.cpp | 37 --- src/cli/rename.cpp | 26 -- src/{cli => commands}/add.cpp | 51 ++-- src/commands/add.h | 30 +++ src/commands/compact.cpp | 17 ++ src/commands/compact.h | 21 ++ src/commands/create.cpp | 97 ++++++++ src/commands/create.h | 29 +++ src/{cli => commands}/extract.cpp | 40 ++- src/commands/extract.h | 25 ++ src/commands/info.cpp | 19 ++ src/commands/info.h | 21 ++ src/commands/list.cpp | 20 ++ src/commands/list.h | 25 ++ src/commands/read.cpp | 37 +++ src/commands/read.h | 22 ++ src/commands/remove.cpp | 33 +++ src/commands/remove.h | 23 ++ src/commands/rename.cpp | 23 ++ src/commands/rename.h | 23 ++ src/{cli => commands}/verify.cpp | 30 ++- src/commands/verify.h | 20 ++ src/util/path.cpp | 19 ++ src/util/path.h | 4 + 47 files changed, 1010 insertions(+), 844 deletions(-) create mode 100644 app/add.cpp create mode 100644 app/compact.cpp create mode 100644 app/create.cpp create mode 100644 app/extract.cpp create mode 100644 app/info.cpp create mode 100644 app/list.cpp create mode 100644 app/read.cpp create mode 100644 app/remove.cpp create mode 100644 app/rename.cpp create mode 100644 app/verify.cpp delete mode 100644 src/cli/commands.h delete mode 100644 src/cli/compact.cpp delete mode 100644 src/cli/create.cpp delete mode 100644 src/cli/info.cpp delete mode 100644 src/cli/list.cpp delete mode 100644 src/cli/read.cpp delete mode 100644 src/cli/remove.cpp delete mode 100644 src/cli/rename.cpp rename src/{cli => commands}/add.cpp (53%) create mode 100644 src/commands/add.h create mode 100644 src/commands/compact.cpp create mode 100644 src/commands/compact.h create mode 100644 src/commands/create.cpp create mode 100644 src/commands/create.h rename src/{cli => commands}/extract.cpp (63%) create mode 100644 src/commands/extract.h create mode 100644 src/commands/info.cpp create mode 100644 src/commands/info.h create mode 100644 src/commands/list.cpp create mode 100644 src/commands/list.h create mode 100644 src/commands/read.cpp create mode 100644 src/commands/read.h create mode 100644 src/commands/remove.cpp create mode 100644 src/commands/remove.h create mode 100644 src/commands/rename.cpp create mode 100644 src/commands/rename.h rename src/{cli => commands}/verify.cpp (66%) create mode 100644 src/commands/verify.h diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 85d607f..320907c 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -18,8 +18,18 @@ configure_file( add_executable(mpqcli main.cpp about.cpp + add.cpp + compact.cpp completion.cpp + create.cpp + extract.cpp + info.cpp + list.cpp + read.cpp + remove.cpp + rename.cpp validators.cpp + verify.cpp version.cpp ) diff --git a/app/about.cpp b/app/about.cpp index 3b18963..55bb54a 100644 --- a/app/about.cpp +++ b/app/about.cpp @@ -1,18 +1,22 @@ -#include #include +#include + +#include #include "commands.h" -int HandleAbout() { - std::cout << "Name: mpqcli" << std::endl; - std::cout << "Version: " << mpqcli::version_string << "-" << mpqcli::git_commit_hash - << std::endl; - std::cout << "Author: Thomas Laurenson" << std::endl; - std::cout << "License: MIT" << std::endl; - std::cout << "GitHub: https://github.com/thegraydot/mpqcli" << std::endl; - std::cout << "Dependencies:" << std::endl; - std::cout << " - StormLib (https://github.com/ladislav-zezula/StormLib)" << std::endl; - std::cout << " - CLI11 (https://github.com/CLIUtils/CLI11)" << std::endl; - std::cout << " - hash-library (https://github.com/stbrumme/hash-library)" << std::endl; - return 0; +void RegisterAbout(CLI::App &app, Context &context) { + auto *sub = app.add_subcommand("about", "Prints program information"); + sub->callback([&context]() { + context.out << "Name: mpqcli" << std::endl; + context.out << "Version: " << mpqcli::version_string << "-" << mpqcli::git_commit_hash + << std::endl; + context.out << "Author: Thomas Laurenson" << std::endl; + context.out << "License: MIT" << std::endl; + context.out << "GitHub: https://github.com/thegraydot/mpqcli" << std::endl; + context.out << "Dependencies:" << std::endl; + context.out << " - StormLib (https://github.com/ladislav-zezula/StormLib)" << std::endl; + context.out << " - CLI11 (https://github.com/CLIUtils/CLI11)" << std::endl; + context.out << " - hash-library (https://github.com/stbrumme/hash-library)" << std::endl; + }); } diff --git a/app/add.cpp b/app/add.cpp new file mode 100644 index 0000000..b4ae84e --- /dev/null +++ b/app/add.cpp @@ -0,0 +1,51 @@ +#include "commands/add.h" + +#include + +#include + +#include "commands.h" +#include "gamerules/rules.h" +#include "util/path.h" +#include "validators.h" + +void RegisterAdd(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("add", "Add files to an existing MPQ archive"); + + sub->add_option("archive", options->archive, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("files", options->files, + "Files or directories to add; pass - to read paths from stdin") + ->required() + ->expected(-1); + sub->add_option("-p,--path", options->path, + "Archive path for a single file, or prefix for a directory"); + CLI::Option *overwrite = sub->add_flag("-w,--overwrite", options->overwrite, + "Replace every file that already exists in the archive"); + sub->add_flag("-u,--update", options->update, + "Replace only files that changed. Compares size, then timestamp/MD5/CRC32") + ->excludes(overwrite); + sub->add_option("--locale", options->locale, "Locale to use for added file") + ->check(locale_valid); + sub->add_option("-g,--game", options->game_profile, + "Game profile for compression rules. Valid options:\n" + + mpqcli::GameRules::GetAvailableProfiles()) + ->check(game_profile_valid); + + mpqcli::CompressionSettingsOverrides &compression = options->compression_overrides; + sub->add_option("--flags", compression.flags, "Override MPQ file flags") + ->group("Game setting overrides"); + sub->add_option("--compression", compression.compression, + "Override compression for first sector") + ->group("Game setting overrides"); + sub->add_option("--compression-next", compression.compression_next, + "Override compression for subsequent sectors") + ->group("Game setting overrides"); + + sub->callback([options, &context]() { + options->files = mpqcli::ExpandStdinMarker(options->files, context.in); + context.exit_code = mpqcli::Add(*options, context.out, context.err) ? 0 : 1; + }); +} diff --git a/app/commands.h b/app/commands.h index 1db7cc1..6031517 100644 --- a/app/commands.h +++ b/app/commands.h @@ -1,12 +1,31 @@ #ifndef COMMANDS_H #define COMMANDS_H -int HandleVersion(); -int HandleAbout(); +#include +#include -void HandleCompletionBash(); -void HandleCompletionZsh(); -void HandleCompletionPs(); -void HandleCompletionFish(); +#include + +/// What main hands every subcommand: the real streams and a slot for the exit code +struct Context { + std::ostream &out; + std::ostream &err; + std::istream ∈ + int exit_code = 0; +}; + +void RegisterVersion(CLI::App &app, Context &context); +void RegisterAbout(CLI::App &app, Context &context); +void RegisterInfo(CLI::App &app, Context &context); +void RegisterCreate(CLI::App &app, Context &context); +void RegisterAdd(CLI::App &app, Context &context); +void RegisterRemove(CLI::App &app, Context &context); +void RegisterRename(CLI::App &app, Context &context); +void RegisterList(CLI::App &app, Context &context); +void RegisterExtract(CLI::App &app, Context &context); +void RegisterRead(CLI::App &app, Context &context); +void RegisterVerify(CLI::App &app, Context &context); +void RegisterCompact(CLI::App &app, Context &context); +void RegisterCompletion(CLI::App &app, Context &context); #endif diff --git a/app/compact.cpp b/app/compact.cpp new file mode 100644 index 0000000..93b461d --- /dev/null +++ b/app/compact.cpp @@ -0,0 +1,22 @@ +#include "commands/compact.h" + +#include + +#include + +#include "commands.h" + +void RegisterCompact(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("compact", "Compact the MPQ archive"); + + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive") + ->check(CLI::ExistingFile); + + sub->callback([options, &context]() { + context.exit_code = mpqcli::Compact(*options, context.out) ? 0 : 1; + }); +} diff --git a/app/completion.cpp b/app/completion.cpp index 765da6a..996be57 100644 --- a/app/completion.cpp +++ b/app/completion.cpp @@ -1,20 +1,21 @@ -#include #include -#include "commands.h" - -void HandleCompletionBash() { - std::cout << mpqcli::bash_completion_script; -} +#include -void HandleCompletionZsh() { - std::cout << mpqcli::zsh_completion_script; -} - -void HandleCompletionPs() { - std::cout << mpqcli::ps_completion_script; -} +#include "commands.h" -void HandleCompletionFish() { - std::cout << mpqcli::fish_completion_script; +void RegisterCompletion(CLI::App &app, Context &context) { + auto *sub = app.add_subcommand("completion", "Generate shell completion script"); + sub->require_subcommand(1); + sub->add_subcommand("bash", "Generate bash completion script")->callback([&context]() { + context.out << mpqcli::bash_completion_script; + }); + sub->add_subcommand("zsh", "Generate zsh completion script")->callback([&context]() { + context.out << mpqcli::zsh_completion_script; + }); + sub->add_subcommand("powershell", "Generate PowerShell completion script") + ->callback([&context]() { context.out << mpqcli::ps_completion_script; }); + sub->add_subcommand("fish", "Generate fish completion script")->callback([&context]() { + context.out << mpqcli::fish_completion_script; + }); } diff --git a/app/create.cpp b/app/create.cpp new file mode 100644 index 0000000..81b474b --- /dev/null +++ b/app/create.cpp @@ -0,0 +1,75 @@ +#include "commands/create.h" + +#include +#include +#include + +#include +#include + +#include "commands.h" +#include "gamerules/rules.h" +#include "validators.h" + +void RegisterCreate(CLI::App &app, Context &context) { + struct Options { + mpqcli::CreateOptions create; + // The documented numbering is 1 to 4; StormLib counts the same formats from 0 + std::optional version; + }; + + auto options = std::make_shared(); + auto *sub = app.add_subcommand("create", "Create an MPQ archive from target file or directory"); + + sub->add_option("target", options->create.target, "Directory or file to put in MPQ archive") + ->required() + ->check(CLI::ExistingPath); + sub->add_option("-p,--path", options->create.path, + "Archive path for a single file, or prefix for a directory"); + sub->add_option("-o,--output", options->create.output, "Output MPQ archive"); + sub->add_flag("-s,--sign", options->create.sign, "Sign the MPQ archive (default false)"); + sub->add_option("--locale", options->create.locale, "Locale to use for added files") + ->check(locale_valid); + sub->add_option("-g,--game", options->create.game_profile, + "Game profile for MPQ creation. Valid options:\n" + + mpqcli::GameRules::GetAvailableProfiles()) + ->check(game_profile_valid); + + mpqcli::MpqCreateSettingsOverrides &create = options->create.create_overrides; + sub->add_option("--version", options->version, "Override the MPQ archive version") + ->check(CLI::Range(1, 4)) + ->group("Game setting overrides"); + sub->add_option("--stream-flags", create.stream_flags, "Override stream flags") + ->group("Game setting overrides"); + sub->add_option("--sector-size", create.sector_size, "Override sector size") + ->group("Game setting overrides"); + sub->add_option("--raw-chunk-size", create.raw_chunk_size, "Override raw chunk size for MPQ v4") + ->group("Game setting overrides"); + sub->add_option("--file-flags1", create.file_flags1, "Override file flags for (listfile)") + ->group("Game setting overrides"); + sub->add_option("--file-flags2", create.file_flags2, "Override file flags for (attributes)") + ->group("Game setting overrides"); + sub->add_option("--file-flags3", create.file_flags3, "Override file flags for (signature)") + ->group("Game setting overrides"); + sub->add_option("--attr-flags", create.attr_flags, + "Override attribute flags (CRC32, FILETIME, MD5)") + ->group("Game setting overrides"); + + mpqcli::CompressionSettingsOverrides &compression = options->create.compression_overrides; + sub->add_option("--flags", compression.flags, "Override MPQ file flags for added files") + ->group("Game setting overrides"); + sub->add_option("--compression", compression.compression, + "Override compression for first sector of added files") + ->group("Game setting overrides"); + sub->add_option("--compression-next", compression.compression_next, + "Override compression for subsequent sectors of added files") + ->group("Game setting overrides"); + + sub->callback([options, &context]() { + if (options->version.has_value()) { + options->create.create_overrides.mpq_version = + static_cast(options->version.value() - 1); + } + context.exit_code = mpqcli::Create(options->create, context.out, context.err) ? 0 : 1; + }); +} diff --git a/app/extract.cpp b/app/extract.cpp new file mode 100644 index 0000000..cf606dd --- /dev/null +++ b/app/extract.cpp @@ -0,0 +1,27 @@ +#include "commands/extract.h" + +#include + +#include + +#include "commands.h" + +void RegisterExtract(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("extract", "Extract files from the MPQ archive"); + + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("-o,--output", options->output, "Output directory"); + sub->add_option("-f,--file", options->file, "Target file to extract"); + sub->add_flag("-k,--keep", options->keep_folder_structure, + "Keep folder structure (default false)"); + sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive") + ->check(CLI::ExistingFile); + sub->add_option("--locale", options->locale, "Preferred locale for extracted file"); + + sub->callback([options, &context]() { + context.exit_code = mpqcli::Extract(*options, context.out, context.err) ? 0 : 1; + }); +} diff --git a/app/info.cpp b/app/info.cpp new file mode 100644 index 0000000..8932618 --- /dev/null +++ b/app/info.cpp @@ -0,0 +1,32 @@ +#include "commands/info.h" + +#include +#include +#include + +#include + +#include "commands.h" + +namespace { + +const std::set valid_properties = { + "format-version", "header-offset", "header-size", "archive-size", + "file-count", "max-files", "signature-type", +}; + +} // namespace + +void RegisterInfo(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("info", "Prints info about an MPQ archive"); + + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("-p,--property", options->property, "Prints only a specific property value") + ->check(CLI::IsMember(valid_properties)); + + sub->callback( + [options, &context]() { context.exit_code = mpqcli::Info(*options, context.out) ? 0 : 1; }); +} diff --git a/app/list.cpp b/app/list.cpp new file mode 100644 index 0000000..9ef4b43 --- /dev/null +++ b/app/list.cpp @@ -0,0 +1,39 @@ +#include "commands/list.h" + +#include +#include +#include + +#include + +#include "commands.h" + +namespace { + +const std::set valid_properties = { + "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", +}; + +} // namespace + +void RegisterList(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("list", "List files from the MPQ archive"); + + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive") + ->check(CLI::ExistingFile); + sub->add_flag("-d,--detailed", options->detailed, + "File listing with additional columns (default false)"); + sub->add_flag("-a,--all", options->all, "File listing including hidden files (default false)"); + sub->add_option("-p,--property", options->properties, "Prints only specific property values") + ->check(CLI::IsMember(valid_properties)); + + sub->callback([options, &context]() { + context.exit_code = mpqcli::List(*options, context.out, context.err) ? 0 : 1; + }); +} diff --git a/app/main.cpp b/app/main.cpp index 1733057..3ff23d1 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -1,402 +1,53 @@ -#include #include #include -#include #include -#include -#include -#include #include -#include "cli/commands.h" #include "commands.h" #include "errors.h" -#include "gamerules/rules.h" -#include "validators.h" - -using namespace mpqcli; int main(int argc, char **argv) { - std::ostream &out = std::cout; - std::ostream &err = std::cerr; + Context context{std::cout, std::cerr, std::cin}; CLI::App app{ "A command line tool to create, add, remove, list, extract, read, rename, and verify MPQ " - "archives " - "using the StormLib library"}; - + "archives using the StormLib library"}; app.require_subcommand(1); - - // CLI: base - // These are reused in multiple subcommands - // clang-format off - std::string base_target; // all subcommands - std::string base_file; // extract, read - std::optional base_locale; // add, create, extract, read, remove - std::optional base_path; // add, create - std::optional base_output; // create, extract - std::optional base_listfile_name; // extract, list, compact - std::optional base_game_profile; // add, create - int64_t file_flags = -1; // add, create - int64_t file_compression = -1; // add, create - int64_t file_compression_next = -1; // add, create - // clang-format on - // CLI: info - std::optional info_property; - // CLI: add - bool add_overwrite = false; - bool add_update = false; - std::vector add_files; - // CLI: remove - std::vector remove_files; - // CLI: rename - std::string rename_new_file; - // CLI: extract - bool extract_keep_folder_structure = false; - // CLI: create - bool create_sign_archive = false; - int32_t create_mpq_version = -1; - int64_t create_stream_flags = -1; - int64_t create_sector_size = -1; - int64_t create_raw_chunk_size = -1; - int64_t create_file_flags1 = -1; - int64_t create_file_flags2 = -1; - int64_t create_file_flags3 = -1; - int64_t create_attr_flags = -1; - // CLI: list - bool list_detailed = false; - bool list_all = false; - std::vector list_properties; - // CLI: verify - bool verify_print_signature = false; - - // Preserve vertical alignment of string set initialisers - // clang-format off - std::set valid_info_properties = { - "format-version", - "header-offset", - "header-size", - "archive-size", - "file-count", - "max-files", - "signature-type", - }; - std::set valid_file_list_properties = { - "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"); - - // Subcommand: About - CLI::App *about = app.add_subcommand("about", "Prints program information"); - - // Subcommand: Info - CLI::App *info = app.add_subcommand("info", "Prints info about an MPQ archive"); - info->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - info->add_option("-p,--property", info_property, "Prints only a specific property value") - ->check(CLI::IsMember(valid_info_properties)); - - // Subcommand: Create - CLI::App *create = - app.add_subcommand("create", "Create an MPQ archive from target file or directory"); - create->add_option("target", base_target, "Directory or file to put in MPQ archive") - ->required() - ->check(CLI::ExistingPath); - create->add_option("-p,--path", base_path, - "Archive path for a single file, or prefix for a directory"); - create->add_option("-o,--output", base_output, "Output MPQ archive"); - create->add_flag("-s,--sign", create_sign_archive, "Sign the MPQ archive (default false)"); - create->add_option("--locale", base_locale, "Locale to use for added files") - ->check(locale_valid); - create - ->add_option("-g,--game", base_game_profile, - "Game profile for MPQ creation. Valid options:\n" + - GameRules::GetAvailableProfiles()) - ->check(game_profile_valid); - // MPQ creation settings overrides - create->add_option("--version", create_mpq_version, "Override the MPQ archive version") - ->check(CLI::Range(1, 4)) - ->group("Game setting overrides"); - create->add_option("--stream-flags", create_stream_flags, "Override stream flags") - ->group("Game setting overrides"); - create->add_option("--sector-size", create_sector_size, "Override sector size") - ->group("Game setting overrides"); - create - ->add_option("--raw-chunk-size", create_raw_chunk_size, - "Override raw chunk size for MPQ v4") - ->group("Game setting overrides"); - create->add_option("--file-flags1", create_file_flags1, "Override file flags for (listfile)") - ->group("Game setting overrides"); - create->add_option("--file-flags2", create_file_flags2, "Override file flags for (attributes)") - ->group("Game setting overrides"); - create->add_option("--file-flags3", create_file_flags3, "Override file flags for (signature)") - ->group("Game setting overrides"); - create - ->add_option("--attr-flags", create_attr_flags, - "Override attribute flags (CRC32, FILETIME, MD5)") - ->group("Game setting overrides"); - // Compression settings overrides for files being added - create->add_option("--flags", file_flags, "Override MPQ file flags for added files") - ->group("Game setting overrides"); - create - ->add_option("--compression", file_compression, - "Override compression for first sector of added files") - ->group("Game setting overrides"); - create - ->add_option("--compression-next", file_compression_next, - "Override compression for subsequent sectors of added files") - ->group("Game setting overrides"); - - // Subcommand: Add - CLI::App *add = app.add_subcommand("add", "Add files to an existing MPQ archive"); - add->add_option("archive", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - add->add_option("files", add_files, - "Files or directories to add; pass - to read paths from stdin") - ->required() - ->expected(-1); - add->add_option("-p,--path", base_path, - "Archive path for a single file, or prefix for a directory"); - CLI::Option *add_overwrite_flag = add->add_flag( - "-w,--overwrite", add_overwrite, "Replace every file that already exists in the archive"); - add->add_flag("-u,--update", add_update, - "Replace only files that changed. Compares size, then timestamp/MD5/CRC32") - ->excludes(add_overwrite_flag); - add->add_option("--locale", base_locale, "Locale to use for added file")->check(locale_valid); - add->add_option("-g,--game", base_game_profile, - "Game profile for compression rules. Valid options:\n" + - GameRules::GetAvailableProfiles()) - ->check(game_profile_valid); - // Compression settings overrides - add->add_option("--flags", file_flags, "Override MPQ file flags") - ->group("Game setting overrides"); - add->add_option("--compression", file_compression, "Override compression for first sector") - ->group("Game setting overrides"); - add->add_option("--compression-next", file_compression_next, - "Override compression for subsequent sectors") - ->group("Game setting overrides"); - - // Subcommand: Remove - CLI::App *remove = app.add_subcommand("remove", "Remove files from an existing MPQ archive"); - remove->add_option("archive", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - remove - ->add_option("files", remove_files, - "Archive paths of files to remove; pass - to read paths from stdin") - ->required() - ->expected(-1); - remove->add_option("--locale", base_locale, "Locale of file to remove")->check(locale_valid); - - // Subcommand: Rename - CLI::App *rename = app.add_subcommand("rename", "Rename a file in an existing MPQ archive"); - rename->add_option("archive", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - rename->add_option("old-file", base_file, "Current archive path of the file")->required(); - rename->add_option("new-file", rename_new_file, "New archive path of the file")->required(); - rename->add_option("--locale", base_locale, "Locale of file to rename")->check(locale_valid); - - // Subcommand: List - CLI::App *list = app.add_subcommand("list", "List files from the MPQ archive"); - list->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - list->add_option("-l,--listfile", base_listfile_name, "File listing content of an MPQ archive") - ->check(CLI::ExistingFile); - list->add_flag("-d,--detailed", list_detailed, - "File listing with additional columns (default false)"); - list->add_flag("-a,--all", list_all, "File listing including hidden files (default false)"); - list->add_option("-p,--property", list_properties, "Prints only specific property values") - ->check(CLI::IsMember(valid_file_list_properties)); - - // Subcommand: Extract - CLI::App *extract = app.add_subcommand("extract", "Extract files from the MPQ archive"); - extract->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - extract->add_option("-o,--output", base_output, "Output directory"); - extract->add_option("-f,--file", base_file, "Target file to extract"); - extract->add_flag("-k,--keep", extract_keep_folder_structure, - "Keep folder structure (default false)"); - extract - ->add_option("-l,--listfile", base_listfile_name, "File listing content of an MPQ archive") - ->check(CLI::ExistingFile); - extract->add_option("--locale", base_locale, "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", base_file, "File to read")->required(); - read->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - read->add_option("--locale", base_locale, "Preferred locale for read file"); - - // Subcommand: Verify - CLI::App *verify = app.add_subcommand("verify", "Verify the MPQ archive"); - verify->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - verify->add_flag("-p,--print", verify_print_signature, "Print the digital signature (in hex)"); - - // Subcommand: Compact - CLI::App *compact = app.add_subcommand("compact", "Compact the MPQ archive"); - compact->add_option("target", base_target, "Target MPQ archive") - ->required() - ->check(CLI::ExistingFile); - compact - ->add_option("-l,--listfile", base_listfile_name, "File listing content of an MPQ archive") - ->check(CLI::ExistingFile); - - // Subcommand: Completion - CLI::App *completion = app.add_subcommand("completion", "Generate shell completion script"); - CLI::App *completion_bash = - completion->add_subcommand("bash", "Generate bash completion script"); - CLI::App *completion_zsh = completion->add_subcommand("zsh", "Generate zsh completion script"); - CLI::App *completion_ps = - completion->add_subcommand("powershell", "Generate PowerShell completion script"); - CLI::App *completion_fish = - completion->add_subcommand("fish", "Generate fish completion script"); + // No option here starts with a slash, so on Windows a path such as /data/patch.mpq + // is an argument rather than an option + app.allow_windows_style_options(false); + + RegisterVersion(app, context); + RegisterAbout(app, context); + RegisterInfo(app, context); + RegisterCreate(app, context); + RegisterAdd(app, context); + RegisterRemove(app, context); + RegisterRename(app, context); + RegisterList(app, context); + RegisterExtract(app, context); + RegisterRead(app, context); + RegisterVerify(app, context); + RegisterCompact(app, context); + RegisterCompletion(app, context); try { app.parse(argc, argv); - - if (app.got_subcommand(version)) { - return HandleVersion(); - } - - if (app.got_subcommand(about)) { - return HandleAbout(); - } - - if (app.got_subcommand(info)) { - return HandleInfo(base_target, info_property, out); - } - - if (app.got_subcommand(create)) { - return HandleCreate( - base_target, base_path, base_output, create_sign_archive, base_locale, - base_game_profile, create_mpq_version, create_stream_flags, create_sector_size, - create_raw_chunk_size, create_file_flags1, create_file_flags2, create_file_flags3, - create_attr_flags, file_flags, file_compression, file_compression_next, out, err); - } - - if (app.got_subcommand(add)) { - std::vector resolved_add_files; - for (const auto &f : add_files) { - if (f == "-") { - std::string line; - while (std::getline(std::cin, line)) { - if (!line.empty()) - resolved_add_files.push_back(line); - } - } else { - resolved_add_files.push_back(f); - } - } - return HandleAdd(resolved_add_files, base_target, base_path, add_overwrite, add_update, - base_locale, base_game_profile, file_flags, file_compression, - file_compression_next, out, err); - } - - if (app.got_subcommand(remove)) { - std::vector resolved_remove_files; - for (const auto &f : remove_files) { - if (f == "-") { - std::string line; - while (std::getline(std::cin, line)) { - if (!line.empty()) - resolved_remove_files.push_back(line); - } - } else { - resolved_remove_files.push_back(f); - } - } - return HandleRemove(resolved_remove_files, base_target, base_locale, out, err); - } - - if (app.got_subcommand(rename)) { - return HandleRename(base_file, rename_new_file, base_target, base_locale, out, err); - } - - if (app.got_subcommand(list)) { - return HandleList(base_target, base_listfile_name, list_all, list_detailed, - list_properties, out, err); - } - - if (app.got_subcommand(extract)) { - std::optional extract_file = - base_file.empty() ? std::nullopt : std::make_optional(base_file); - return HandleExtract(base_target, base_output, extract_file, - extract_keep_folder_structure, base_listfile_name, base_locale, - out, err); - } - - if (app.got_subcommand(read)) { - return HandleRead(base_file, base_target, base_locale, out, err); - } - - if (app.got_subcommand(verify)) { - return HandleVerify(base_target, verify_print_signature, out, err); - } - - if (app.got_subcommand(compact)) { - return HandleCompact(base_target, base_listfile_name, out); - } - - if (app.got_subcommand(completion)) { - if (completion->got_subcommand(completion_bash)) { - HandleCompletionBash(); - return 0; - } - if (completion->got_subcommand(completion_zsh)) { - HandleCompletionZsh(); - return 0; - } - if (completion->got_subcommand(completion_ps)) { - HandleCompletionPs(); - return 0; - } - if (completion->got_subcommand(completion_fish)) { - HandleCompletionFish(); - return 0; - } - std::cerr << completion->help(); - return 1; - } - - return 0; } catch (const CLI::ParseError &e) { // An empty command line is a request for help rather than a mistake. The argc // check is what keeps an unknown subcommand, which raises the same error, failing if (argc == 1 && e.get_exit_code() == static_cast(CLI::ExitCodes::RequiredError)) { - std::cout << app.help() << std::endl; + context.out << app.help() << std::endl; return 0; } - return app.exit(e); + return app.exit(e, context.out, context.err); } catch (const mpqcli::Error &e) { - std::cerr << "[!] " << e.what() << std::endl; + context.err << "[!] " << e.what() << std::endl; return 1; } catch (const std::exception &e) { - std::cerr << "[!] Unexpected error: " << e.what() << std::endl; + context.err << "[!] Unexpected error: " << e.what() << std::endl; return 2; } + return context.exit_code; } diff --git a/app/read.cpp b/app/read.cpp new file mode 100644 index 0000000..5d5997b --- /dev/null +++ b/app/read.cpp @@ -0,0 +1,22 @@ +#include "commands/read.h" + +#include + +#include + +#include "commands.h" + +void RegisterRead(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("read", "Read a file from an MPQ archive"); + + sub->add_option("file", options->file, "File to read")->required(); + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("--locale", options->locale, "Preferred locale for read file"); + + sub->callback([options, &context]() { + context.exit_code = mpqcli::Read(*options, context.out, context.err) ? 0 : 1; + }); +} diff --git a/app/remove.cpp b/app/remove.cpp new file mode 100644 index 0000000..b56c1a5 --- /dev/null +++ b/app/remove.cpp @@ -0,0 +1,28 @@ +#include "commands/remove.h" + +#include + +#include + +#include "commands.h" +#include "util/path.h" +#include "validators.h" + +void RegisterRemove(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("remove", "Remove files from an existing MPQ archive"); + + sub->add_option("archive", options->archive, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("files", options->files, + "Archive paths of files to remove; pass - to read paths from stdin") + ->required() + ->expected(-1); + sub->add_option("--locale", options->locale, "Locale of file to remove")->check(locale_valid); + + sub->callback([options, &context]() { + options->files = mpqcli::ExpandStdinMarker(options->files, context.in); + context.exit_code = mpqcli::Remove(*options, context.out, context.err) ? 0 : 1; + }); +} diff --git a/app/rename.cpp b/app/rename.cpp new file mode 100644 index 0000000..5361a43 --- /dev/null +++ b/app/rename.cpp @@ -0,0 +1,24 @@ +#include "commands/rename.h" + +#include + +#include + +#include "commands.h" +#include "validators.h" + +void RegisterRename(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("rename", "Rename a file in an existing MPQ archive"); + + sub->add_option("archive", options->archive, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_option("old-file", options->old_file, "Current archive path of the file")->required(); + sub->add_option("new-file", options->new_file, "New archive path of the file")->required(); + sub->add_option("--locale", options->locale, "Locale of file to rename")->check(locale_valid); + + sub->callback([options, &context]() { + context.exit_code = mpqcli::Rename(*options, context.out, context.err) ? 0 : 1; + }); +} diff --git a/app/verify.cpp b/app/verify.cpp new file mode 100644 index 0000000..2f6bf57 --- /dev/null +++ b/app/verify.cpp @@ -0,0 +1,21 @@ +#include "commands/verify.h" + +#include + +#include + +#include "commands.h" + +void RegisterVerify(CLI::App &app, Context &context) { + auto options = std::make_shared(); + auto *sub = app.add_subcommand("verify", "Verify the MPQ archive"); + + sub->add_option("target", options->target, "Target MPQ archive") + ->required() + ->check(CLI::ExistingFile); + sub->add_flag("-p,--print", options->print_signature, "Print the digital signature (in hex)"); + + sub->callback([options, &context]() { + context.exit_code = mpqcli::Verify(*options, context.out, context.err) ? 0 : 1; + }); +} diff --git a/app/version.cpp b/app/version.cpp index ac471b0..6d59355 100644 --- a/app/version.cpp +++ b/app/version.cpp @@ -1,9 +1,13 @@ -#include #include +#include + +#include #include "commands.h" -int HandleVersion() { - std::cout << mpqcli::version_string << "-" << mpqcli::git_commit_hash << std::endl; - return 0; +void RegisterVersion(CLI::App &app, Context &context) { + auto *sub = app.add_subcommand("version", "Prints program version"); + sub->callback([&context]() { + context.out << mpqcli::version_string << "-" << mpqcli::git_commit_hash << std::endl; + }); } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 1baf9c4..c84c6b0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -30,16 +30,16 @@ configure_file( # recompiles the sources instead is a second build that drifts from the # shipped one. add_library(mpqcli_core STATIC - cli/add.cpp - cli/compact.cpp - cli/create.cpp - cli/extract.cpp - cli/info.cpp - cli/list.cpp - cli/read.cpp - cli/remove.cpp - cli/rename.cpp - cli/verify.cpp + commands/add.cpp + commands/compact.cpp + commands/create.cpp + commands/extract.cpp + commands/info.cpp + commands/list.cpp + commands/read.cpp + commands/remove.cpp + commands/rename.cpp + commands/verify.cpp mpq/add.cpp mpq/archive.cpp mpq/compact.cpp diff --git a/src/cli/commands.h b/src/cli/commands.h deleted file mode 100644 index 47641e2..0000000 --- a/src/cli/commands.h +++ /dev/null @@ -1,49 +0,0 @@ -#ifndef CLI_COMMANDS_H -#define CLI_COMMANDS_H - -#include -#include -#include -#include -#include - -namespace mpqcli { - -int HandleInfo(const std::string &target, const std::optional &property, - std::ostream &out); -int HandleCreate(const std::string &target, const std::optional &path, - const std::optional &output, bool sign_archive, - const std::optional &locale, - const std::optional &game_profile, int32_t mpq_version, - int64_t stream_flags, int64_t sector_size, int64_t raw_chunk_size, - int64_t file_flags1, int64_t file_flags2, int64_t file_flags3, int64_t attr_flags, - int64_t file_flags, int64_t file_compression, int64_t file_compression_next, - std::ostream &out, std::ostream &err); -int HandleAdd(const std::vector &files, const std::string &target, - const std::optional &path, bool overwrite, bool update, - const std::optional &locale, - const std::optional &game_profile, int64_t file_flags, - int64_t file_compression, int64_t file_compression_next, std::ostream &out, - std::ostream &err); -int HandleRemove(const std::vector &files, const std::string &target, - const std::optional &locale, std::ostream &out, std::ostream &err); -int HandleRename(const std::string &old_file, const std::string &new_file, - const std::string &target, const std::optional &locale, - std::ostream &out, std::ostream &err); -int HandleList(const std::string &target, const std::optional &listfile_name, - bool list_all, bool list_detailed, const std::vector &properties, - std::ostream &out, std::ostream &err); -int HandleExtract(const std::string &target, const std::optional &output, - const std::optional &file, bool keep_folder_structure, - const std::optional &listfile_name, - const std::optional &locale, std::ostream &out, std::ostream &err); -int HandleRead(const std::string &file, const std::string &target, - const std::optional &locale, std::ostream &out, std::ostream &err); -int HandleVerify(const std::string &target, bool print_signature, std::ostream &out, - std::ostream &err); -int HandleCompact(const std::string &target, const std::optional &listfile_name, - std::ostream &out); - -} // namespace mpqcli - -#endif diff --git a/src/cli/compact.cpp b/src/cli/compact.cpp deleted file mode 100644 index a596ce2..0000000 --- a/src/cli/compact.cpp +++ /dev/null @@ -1,20 +0,0 @@ -#include "mpq/compact.h" - -#include -#include -#include - -#include "cli/commands.h" -#include "mpq/archive.h" - -namespace mpqcli { - -int HandleCompact(const std::string &target, const std::optional &listfile_name, - std::ostream &out) { - Archive archive = Archive::Open(target, 0); - CompactMpqArchive(archive.Handle(), listfile_name, out); - archive.Close(); - return 0; -} - -} // namespace mpqcli diff --git a/src/cli/create.cpp b/src/cli/create.cpp deleted file mode 100644 index 592316b..0000000 --- a/src/cli/create.cpp +++ /dev/null @@ -1,135 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "cli/commands.h" -#include "gamerules/rules.h" -#include "mpq/add.h" -#include "mpq/archive.h" -#include "util/capacity.h" -#include "util/locales.h" -#include "util/path.h" - -namespace fs = std::filesystem; - -namespace mpqcli { - -int HandleCreate(const std::string &target, const std::optional &path, - const std::optional &output, bool sign_archive, - const std::optional &locale, - const std::optional &game_profile, int32_t mpq_version, - int64_t stream_flags, int64_t sector_size, int64_t raw_chunk_size, - int64_t file_flags1, int64_t file_flags2, int64_t file_flags3, int64_t attr_flags, - int64_t file_flags, int64_t file_compression, int64_t file_compression_next, - std::ostream &out, std::ostream &err) { - std::error_code ec; - fs::path output_file_path; - if (output.has_value()) { - output_file_path = fs::absolute(output.value(), ec); - if (ec) { - err << "[!] Failed to resolve output path: (" << ec.value() << ") " << ec.message() - << ": " << output.value() << std::endl; - return 1; - } - } else { - output_file_path = fs::path(target); - // If the path ends with a separator (e.g. "dir/"), strip the - // trailing separator first so we get "dir.mpq" - if (output_file_path.filename().empty()) { - output_file_path = output_file_path.parent_path(); - } - output_file_path.replace_extension(".mpq"); - } - std::string output_file = output_file_path.u8string(); - - GameProfile profile; - if (game_profile.has_value()) { - profile = GameRules::StringToProfile(game_profile.value()); - } else { - profile = GameRules::GetDefaultProfile(); - } - GameRules game_rules(profile); - - out << "[*] Game profile: " << game_profile.value_or("default") - << ", Output file: " << output_file << std::endl; - - if (mpq_version > 0) { - mpq_version--; // We label versions 1-4, but StormLib uses 0-3 - } - - // Apply MpqCreateSettings overrides if provided - MpqCreateSettingsOverrides overrides; - if (mpq_version >= 0) - overrides.mpq_version = static_cast(mpq_version); - if (stream_flags >= 0) - overrides.stream_flags = static_cast(stream_flags); - if (file_flags1 >= 0) - overrides.file_flags1 = static_cast(file_flags1); - if (file_flags2 >= 0) - overrides.file_flags2 = static_cast(file_flags2); - if (file_flags3 >= 0) - overrides.file_flags3 = static_cast(file_flags3); - if (attr_flags >= 0) - overrides.attr_flags = static_cast(attr_flags); - if (sector_size >= 0) - overrides.sector_size = static_cast(sector_size); - if (raw_chunk_size >= 0) - overrides.raw_chunk_size = static_cast(raw_chunk_size); - game_rules.OverrideCreateSettings(overrides); - - // List the files up front: the archive's max file count is fixed at creation - std::vector files; - const bool is_directory = fs::is_directory(target, ec); - if (is_directory) { - files = ListFilesRecursive(target, ec); - if (ec) { - err << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() << ": " - << target << std::endl; - return 1; - } - } else if (!fs::is_regular_file(target, ec)) { - err << "[!] Not a file or directory: " << target << std::endl; - return 1; - } - const uint32_t file_count = - CalculateMpqMaxFileValue(is_directory ? static_cast(files.size()) : 1); - - // Create the MPQ archive and add files - Archive archive = Archive::Create(output_file, file_count, game_rules); - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - - // Apply AddFileSettings overrides if provided - CompressionSettingsOverrides add_overrides; - if (file_flags >= 0) - add_overrides.flags = static_cast(file_flags); - if (file_compression >= 0) - add_overrides.compression = static_cast(file_compression); - if (file_compression_next >= 0) - add_overrides.compression_next = static_cast(file_compression_next); - - int result = 0; - if (is_directory) { - const std::string prefix = path.value_or(""); - result |= AddFiles(archive.Handle(), files, target, prefix, lcid, game_rules, out, err, - add_overrides); - } else { - std::string archive_path = ResolveArchiveName(target, path); - result |= AddFile(archive.Handle(), target, archive_path, lcid, game_rules, out, err, - add_overrides); - } - - if (sign_archive) { - archive.Sign(); - } - archive.Close(); - - return result; -} - -} // namespace mpqcli diff --git a/src/cli/info.cpp b/src/cli/info.cpp deleted file mode 100644 index ea2f718..0000000 --- a/src/cli/info.cpp +++ /dev/null @@ -1,22 +0,0 @@ -#include "mpq/info.h" - -#include -#include -#include - -#include - -#include "cli/commands.h" -#include "mpq/archive.h" - -namespace mpqcli { - -int HandleInfo(const std::string &target, const std::optional &property, - std::ostream &out) { - Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); - PrintMpqInfo(archive.Handle(), property, out); - archive.Close(); - return 0; -} - -} // namespace mpqcli diff --git a/src/cli/list.cpp b/src/cli/list.cpp deleted file mode 100644 index 7c61e62..0000000 --- a/src/cli/list.cpp +++ /dev/null @@ -1,24 +0,0 @@ -#include "mpq/list.h" - -#include -#include -#include -#include - -#include - -#include "cli/commands.h" -#include "mpq/archive.h" - -namespace mpqcli { - -int HandleList(const std::string &target, const std::optional &listfile_name, - bool list_all, bool list_detailed, const std::vector &properties, - std::ostream &out, std::ostream &err) { - Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); - ListFiles(archive.Handle(), listfile_name, list_all, list_detailed, properties, out, err); - archive.Close(); - return 0; -} - -} // namespace mpqcli diff --git a/src/cli/read.cpp b/src/cli/read.cpp deleted file mode 100644 index 63d4c43..0000000 --- a/src/cli/read.cpp +++ /dev/null @@ -1,40 +0,0 @@ -#include "mpq/read.h" - -#include -#include -#include -#include - -#include - -#include "cli/commands.h" -#include "mpq/archive.h" -#include "util/format.h" -#include "util/locales.h" - -namespace mpqcli { - -int HandleRead(const std::string &file, const std::string &target, - const std::optional &locale, std::ostream &out, std::ostream &err) { - Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - if (locale.has_value() && lcid == default_locale) { - out << "[!] Warning: The locale '" << locale.value() - << "' is unknown. Will use default locale instead." << std::endl; - } - - uint32_t file_size; - auto file_content = ReadFile(archive.Handle(), file.c_str(), &file_size, lcid, err); - if (!file_content) { - archive.Close(); - return 1; - } - - WriteBinary(out, file_content.get(), file_size); - - archive.Close(); - return 0; -} - -} // namespace mpqcli diff --git a/src/cli/remove.cpp b/src/cli/remove.cpp deleted file mode 100644 index 673f24c..0000000 --- a/src/cli/remove.cpp +++ /dev/null @@ -1,37 +0,0 @@ -#include "mpq/remove.h" - -#include -#include -#include -#include -#include - -#include - -#include "cli/commands.h" -#include "mpq/archive.h" -#include "util/locales.h" - -namespace mpqcli { - -int HandleRemove(const std::vector &files, const std::string &target, - const std::optional &locale, std::ostream &out, std::ostream &err) { - Archive archive = Archive::Open(target, 0); - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - std::unordered_set seen; - int overall_result = 0; - for (const auto &f : files) { - if (!seen.insert(f).second) { - continue; - } - int result = RemoveFile(archive.Handle(), f, lcid, out, err); - if (result != 0) { - overall_result = result; - } - } - archive.Close(); - return overall_result; -} - -} // namespace mpqcli diff --git a/src/cli/rename.cpp b/src/cli/rename.cpp deleted file mode 100644 index 3df2023..0000000 --- a/src/cli/rename.cpp +++ /dev/null @@ -1,26 +0,0 @@ -#include "mpq/rename.h" - -#include -#include -#include - -#include - -#include "cli/commands.h" -#include "mpq/archive.h" -#include "util/locales.h" - -namespace mpqcli { - -int HandleRename(const std::string &old_file, const std::string &new_file, - const std::string &target, const std::optional &locale, - std::ostream &out, std::ostream &err) { - Archive archive = Archive::Open(target, 0); - - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - const int result = RenameFile(archive.Handle(), old_file, new_file, lcid, out, err); - archive.Close(); - return result; -} - -} // namespace mpqcli diff --git a/src/cli/add.cpp b/src/commands/add.cpp similarity index 53% rename from src/cli/add.cpp rename to src/commands/add.cpp index b669f21..a43c196 100644 --- a/src/cli/add.cpp +++ b/src/commands/add.cpp @@ -1,8 +1,6 @@ -#include "mpq/add.h" +#include "commands/add.h" -#include #include -#include #include #include #include @@ -10,8 +8,8 @@ #include -#include "cli/commands.h" #include "gamerules/rules.h" +#include "mpq/add.h" #include "mpq/archive.h" #include "util/locales.h" #include "util/path.h" @@ -20,36 +18,23 @@ namespace fs = std::filesystem; namespace mpqcli { -int HandleAdd(const std::vector &files, const std::string &target, - const std::optional &path, bool overwrite, bool update, - const std::optional &locale, - const std::optional &game_profile, int64_t file_flags, - int64_t file_compression, int64_t file_compression_next, std::ostream &out, - std::ostream &err) { - Archive archive = Archive::Open(target, 0); +bool Add(const AddOptions &options, std::ostream &out, std::ostream &err) { + Archive archive = Archive::Open(options.archive, 0); - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; GameProfile profile; - if (game_profile.has_value()) { - profile = GameRules::StringToProfile(game_profile.value()); - out << "[*] Using game profile: " << game_profile.value() << std::endl; + if (options.game_profile.has_value()) { + profile = GameRules::StringToProfile(options.game_profile.value()); + out << "[*] Using game profile: " << options.game_profile.value() << std::endl; } else { profile = GameRules::GetDefaultProfile(); } GameRules game_rules(profile); - CompressionSettingsOverrides add_overrides; - if (file_flags >= 0) - add_overrides.flags = static_cast(file_flags); - if (file_compression >= 0) - add_overrides.compression = static_cast(file_compression); - if (file_compression_next >= 0) - add_overrides.compression_next = static_cast(file_compression_next); - std::error_code ec; bool has_directory = false; - for (const auto &f : files) { + for (const auto &f : options.files) { if (fs::is_directory(f, ec)) { has_directory = true; break; @@ -58,7 +43,7 @@ int HandleAdd(const std::vector &files, const std::string &target, int result = 0; int files_skipped = 0; - for (const auto &f : files) { + for (const auto &f : options.files) { if (!fs::exists(f, ec)) { err << "[!] Path does not exist: " << f << std::endl; result |= 1; @@ -73,15 +58,17 @@ int HandleAdd(const std::vector &files, const std::string &target, result |= 1; continue; } - std::string prefix = path.value_or(""); + std::string prefix = options.path.value_or(""); result |= AddFiles(archive.Handle(), directory_files, f, prefix, lcid, game_rules, out, - err, add_overrides, overwrite, update, &files_skipped); + err, options.compression_overrides, options.overwrite, + options.update, &files_skipped); } else if (fs::is_regular_file(f, ec)) { - const bool treat_as_directory = has_directory || files.size() > 1; - std::string archive_path = ResolveArchiveName(f, path, treat_as_directory); + const bool treat_as_directory = has_directory || options.files.size() > 1; + std::string archive_path = ResolveArchiveName(f, options.path, treat_as_directory); result |= AddFile(archive.Handle(), f, archive_path, lcid, game_rules, out, err, - add_overrides, overwrite, update, &files_skipped); + options.compression_overrides, options.overwrite, options.update, + &files_skipped); } else { err << "[!] Not a file or directory: " << f << std::endl; @@ -91,7 +78,7 @@ int HandleAdd(const std::vector &files, const std::string &target, // Skipping pre-existing files is the default, so point at the flags that change it // rather than letting the run look like it silently did nothing. - if (!overwrite && !update && files_skipped > 0) { + if (!options.overwrite && !options.update && files_skipped > 0) { err << "[*] " << files_skipped << " file(s) already in the archive were skipped. Use --overwrite to replace " "them, or --update to replace only the ones that changed." @@ -99,7 +86,7 @@ int HandleAdd(const std::vector &files, const std::string &target, } archive.Close(); - return result; + return result == 0; } } // namespace mpqcli diff --git a/src/commands/add.h b/src/commands/add.h new file mode 100644 index 0000000..f577a86 --- /dev/null +++ b/src/commands/add.h @@ -0,0 +1,30 @@ +#ifndef COMMANDS_ADD_H +#define COMMANDS_ADD_H + +#include +#include +#include +#include + +#include "gamerules/settings.h" + +namespace mpqcli { + +/// Settled arguments for the add subcommand +struct AddOptions { + std::string archive; + std::vector files; + std::optional path; + bool overwrite = false; + bool update = false; + std::optional locale; + std::optional game_profile; + CompressionSettingsOverrides compression_overrides; +}; + +/// Adds files and directories to an archive, returning false if any of them failed +bool Add(const AddOptions &options, std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/commands/compact.cpp b/src/commands/compact.cpp new file mode 100644 index 0000000..d167074 --- /dev/null +++ b/src/commands/compact.cpp @@ -0,0 +1,17 @@ +#include "commands/compact.h" + +#include + +#include "mpq/archive.h" +#include "mpq/compact.h" + +namespace mpqcli { + +bool Compact(const CompactOptions &options, std::ostream &out) { + Archive archive = Archive::Open(options.target, 0); + CompactMpqArchive(archive.Handle(), options.listfile, out); + archive.Close(); + return true; +} + +} // namespace mpqcli diff --git a/src/commands/compact.h b/src/commands/compact.h new file mode 100644 index 0000000..76f13a2 --- /dev/null +++ b/src/commands/compact.h @@ -0,0 +1,21 @@ +#ifndef COMMANDS_COMPACT_H +#define COMMANDS_COMPACT_H + +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the compact subcommand +struct CompactOptions { + std::string target; + std::optional listfile; +}; + +/// Compacts the archive +bool Compact(const CompactOptions &options, std::ostream &out); + +} // namespace mpqcli + +#endif diff --git a/src/commands/create.cpp b/src/commands/create.cpp new file mode 100644 index 0000000..cbe2f45 --- /dev/null +++ b/src/commands/create.cpp @@ -0,0 +1,97 @@ +#include "commands/create.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "gamerules/rules.h" +#include "mpq/add.h" +#include "mpq/archive.h" +#include "util/capacity.h" +#include "util/locales.h" +#include "util/path.h" + +namespace fs = std::filesystem; + +namespace mpqcli { + +bool Create(const CreateOptions &options, std::ostream &out, std::ostream &err) { + std::error_code ec; + fs::path output_file_path; + if (options.output.has_value()) { + output_file_path = fs::absolute(options.output.value(), ec); + if (ec) { + err << "[!] Failed to resolve output path: (" << ec.value() << ") " << ec.message() + << ": " << options.output.value() << std::endl; + return false; + } + } else { + output_file_path = fs::path(options.target); + // If the path ends with a separator (e.g. "dir/"), strip the + // trailing separator first so we get "dir.mpq" + if (output_file_path.filename().empty()) { + output_file_path = output_file_path.parent_path(); + } + output_file_path.replace_extension(".mpq"); + } + std::string output_file = output_file_path.u8string(); + + GameProfile profile; + if (options.game_profile.has_value()) { + profile = GameRules::StringToProfile(options.game_profile.value()); + } else { + profile = GameRules::GetDefaultProfile(); + } + GameRules game_rules(profile); + + out << "[*] Game profile: " << options.game_profile.value_or("default") + << ", Output file: " << output_file << std::endl; + + game_rules.OverrideCreateSettings(options.create_overrides); + + // List the files up front: the archive's max file count is fixed at creation + std::vector files; + const bool is_directory = fs::is_directory(options.target, ec); + if (is_directory) { + files = ListFilesRecursive(options.target, ec); + if (ec) { + err << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() << ": " + << options.target << std::endl; + return false; + } + } else if (!fs::is_regular_file(options.target, ec)) { + err << "[!] Not a file or directory: " << options.target << std::endl; + return false; + } + const uint32_t file_count = + CalculateMpqMaxFileValue(is_directory ? static_cast(files.size()) : 1); + + // Create the MPQ archive and add files + Archive archive = Archive::Create(output_file, file_count, game_rules); + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + + int result = 0; + if (is_directory) { + const std::string prefix = options.path.value_or(""); + result |= AddFiles(archive.Handle(), files, options.target, prefix, lcid, game_rules, out, + err, options.compression_overrides); + } else { + std::string archive_path = ResolveArchiveName(options.target, options.path); + result |= AddFile(archive.Handle(), options.target, archive_path, lcid, game_rules, out, + err, options.compression_overrides); + } + + if (options.sign) { + archive.Sign(); + } + archive.Close(); + + return result == 0; +} + +} // namespace mpqcli diff --git a/src/commands/create.h b/src/commands/create.h new file mode 100644 index 0000000..e909c2e --- /dev/null +++ b/src/commands/create.h @@ -0,0 +1,29 @@ +#ifndef COMMANDS_CREATE_H +#define COMMANDS_CREATE_H + +#include +#include +#include + +#include "gamerules/settings.h" + +namespace mpqcli { + +/// Settled arguments for the create subcommand +struct CreateOptions { + std::string target; + std::optional path; + std::optional output; + bool sign = false; + std::optional locale; + std::optional game_profile; + MpqCreateSettingsOverrides create_overrides; + CompressionSettingsOverrides compression_overrides; +}; + +/// Creates an archive from a file or directory, returning false if any file failed to add +bool Create(const CreateOptions &options, std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/cli/extract.cpp b/src/commands/extract.cpp similarity index 63% rename from src/cli/extract.cpp rename to src/commands/extract.cpp index 21cccb3..50d3a6f 100644 --- a/src/cli/extract.cpp +++ b/src/commands/extract.cpp @@ -1,39 +1,35 @@ -#include "mpq/extract.h" +#include "commands/extract.h" #include -#include #include #include #include #include -#include "cli/commands.h" #include "mpq/archive.h" +#include "mpq/extract.h" #include "util/locales.h" namespace fs = std::filesystem; namespace mpqcli { -int HandleExtract(const std::string &target, const std::optional &output, - const std::optional &file, bool keep_folder_structure, - const std::optional &listfile_name, - const std::optional &locale, std::ostream &out, std::ostream &err) { +bool Extract(const ExtractOptions &options, std::ostream &out, std::ostream &err) { // If no output directory specified, use MPQ path without extension // If output directory specified, create it if it doesn't exist std::error_code ec; std::string effective_output; - if (!output.has_value()) { - fs::path target_path = fs::absolute(target, ec); + if (!options.output.has_value()) { + fs::path target_path = fs::absolute(options.target, ec); if (ec) { err << "[!] Failed to resolve archive path: (" << ec.value() << ") " << ec.message() - << ": " << target << std::endl; - return 1; + << ": " << options.target << std::endl; + return false; } effective_output = (target_path.parent_path() / target_path.stem()).u8string(); } else { - effective_output = output.value(); + effective_output = options.output.value(); } fs::create_directory(effective_output, ec); if (ec) { @@ -41,7 +37,7 @@ int HandleExtract(const std::string &target, const std::optional &o if (!fs::is_directory(effective_output, query_ec)) { err << "[!] Failed to create output directory: (" << ec.value() << ") " << ec.message() << ": " << effective_output << std::endl; - return 1; + return false; } } @@ -54,27 +50,27 @@ int HandleExtract(const std::string &target, const std::optional &o << effective_output << std::endl; } - Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); + Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); - LCID lcid = locale.has_value() ? LangToLocale(locale.value()) : default_locale; - if (locale.has_value() && lcid == default_locale) { - out << "[!] Warning: The locale '" << locale.value() + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + if (options.locale.has_value() && lcid == default_locale) { + out << "[!] Warning: The locale '" << options.locale.value() << "' is unknown. Will use default locale instead." << std::endl; } int result; - if (file.has_value()) { - result = ExtractFile(archive.Handle(), effective_output, file.value(), - keep_folder_structure, lcid, out, err); + if (options.file.has_value()) { + result = ExtractFile(archive.Handle(), effective_output, options.file.value(), + options.keep_folder_structure, lcid, out, err); } else { - result = ExtractFiles(archive.Handle(), effective_output, listfile_name, lcid, out, err); + result = ExtractFiles(archive.Handle(), effective_output, options.listfile, lcid, out, err); } archive.Close(); if (result != 0) { err << std::endl << "[!] Failed to extract all files." << std::endl; } - return result; + return result == 0; } } // namespace mpqcli diff --git a/src/commands/extract.h b/src/commands/extract.h new file mode 100644 index 0000000..8ac8313 --- /dev/null +++ b/src/commands/extract.h @@ -0,0 +1,25 @@ +#ifndef COMMANDS_EXTRACT_H +#define COMMANDS_EXTRACT_H + +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the extract subcommand +struct ExtractOptions { + std::string target; + std::optional output; + std::optional file; + bool keep_folder_structure = false; + std::optional listfile; + std::optional locale; +}; + +/// Extracts one file or every file, returning false if any of them failed +bool Extract(const ExtractOptions &options, std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/commands/info.cpp b/src/commands/info.cpp new file mode 100644 index 0000000..80e8a6b --- /dev/null +++ b/src/commands/info.cpp @@ -0,0 +1,19 @@ +#include "commands/info.h" + +#include + +#include + +#include "mpq/archive.h" +#include "mpq/info.h" + +namespace mpqcli { + +bool Info(const InfoOptions &options, std::ostream &out) { + Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + PrintMpqInfo(archive.Handle(), options.property, out); + archive.Close(); + return true; +} + +} // namespace mpqcli diff --git a/src/commands/info.h b/src/commands/info.h new file mode 100644 index 0000000..53dda48 --- /dev/null +++ b/src/commands/info.h @@ -0,0 +1,21 @@ +#ifndef COMMANDS_INFO_H +#define COMMANDS_INFO_H + +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the info subcommand +struct InfoOptions { + std::string target; + std::optional property; +}; + +/// Prints the archive's properties, or only the one named, to out +bool Info(const InfoOptions &options, std::ostream &out); + +} // namespace mpqcli + +#endif diff --git a/src/commands/list.cpp b/src/commands/list.cpp new file mode 100644 index 0000000..d958d0e --- /dev/null +++ b/src/commands/list.cpp @@ -0,0 +1,20 @@ +#include "commands/list.h" + +#include + +#include + +#include "mpq/archive.h" +#include "mpq/list.h" + +namespace mpqcli { + +bool List(const ListOptions &options, std::ostream &out, std::ostream &err) { + Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + ListFiles(archive.Handle(), options.listfile, options.all, options.detailed, options.properties, + out, err); + archive.Close(); + return true; +} + +} // namespace mpqcli diff --git a/src/commands/list.h b/src/commands/list.h new file mode 100644 index 0000000..a01d2d5 --- /dev/null +++ b/src/commands/list.h @@ -0,0 +1,25 @@ +#ifndef COMMANDS_LIST_H +#define COMMANDS_LIST_H + +#include +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the list subcommand +struct ListOptions { + std::string target; + std::optional listfile; + bool detailed = false; + bool all = false; + std::vector properties; +}; + +/// Prints the archive's files to out +bool List(const ListOptions &options, std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/commands/read.cpp b/src/commands/read.cpp new file mode 100644 index 0000000..b96b8e1 --- /dev/null +++ b/src/commands/read.cpp @@ -0,0 +1,37 @@ +#include "commands/read.h" + +#include +#include + +#include + +#include "mpq/archive.h" +#include "mpq/read.h" +#include "util/format.h" +#include "util/locales.h" + +namespace mpqcli { + +bool Read(const ReadOptions &options, std::ostream &out, std::ostream &err) { + Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); + + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + if (options.locale.has_value() && lcid == default_locale) { + out << "[!] Warning: The locale '" << options.locale.value() + << "' is unknown. Will use default locale instead." << std::endl; + } + + uint32_t file_size; + auto file_content = ReadFile(archive.Handle(), options.file.c_str(), &file_size, lcid, err); + if (!file_content) { + archive.Close(); + return false; + } + + WriteBinary(out, file_content.get(), file_size); + + archive.Close(); + return true; +} + +} // namespace mpqcli diff --git a/src/commands/read.h b/src/commands/read.h new file mode 100644 index 0000000..bd7cb96 --- /dev/null +++ b/src/commands/read.h @@ -0,0 +1,22 @@ +#ifndef COMMANDS_READ_H +#define COMMANDS_READ_H + +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the read subcommand +struct ReadOptions { + std::string file; + std::string target; + std::optional locale; +}; + +/// Writes one archived file's bytes to out, returning false if it could not be read +bool Read(const ReadOptions &options, std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/commands/remove.cpp b/src/commands/remove.cpp new file mode 100644 index 0000000..feb7338 --- /dev/null +++ b/src/commands/remove.cpp @@ -0,0 +1,33 @@ +#include "commands/remove.h" + +#include +#include +#include + +#include + +#include "mpq/archive.h" +#include "mpq/remove.h" +#include "util/locales.h" + +namespace mpqcli { + +bool Remove(const RemoveOptions &options, std::ostream &out, std::ostream &err) { + Archive archive = Archive::Open(options.archive, 0); + + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + std::unordered_set seen; + bool all_removed = true; + for (const auto &f : options.files) { + if (!seen.insert(f).second) { + continue; + } + if (RemoveFile(archive.Handle(), f, lcid, out, err) != 0) { + all_removed = false; + } + } + archive.Close(); + return all_removed; +} + +} // namespace mpqcli diff --git a/src/commands/remove.h b/src/commands/remove.h new file mode 100644 index 0000000..a0db432 --- /dev/null +++ b/src/commands/remove.h @@ -0,0 +1,23 @@ +#ifndef COMMANDS_REMOVE_H +#define COMMANDS_REMOVE_H + +#include +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the remove subcommand +struct RemoveOptions { + std::string archive; + std::vector files; + std::optional locale; +}; + +/// Removes files from an archive, returning false if any of them could not be removed +bool Remove(const RemoveOptions &options, std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/commands/rename.cpp b/src/commands/rename.cpp new file mode 100644 index 0000000..f81d85c --- /dev/null +++ b/src/commands/rename.cpp @@ -0,0 +1,23 @@ +#include "commands/rename.h" + +#include + +#include + +#include "mpq/archive.h" +#include "mpq/rename.h" +#include "util/locales.h" + +namespace mpqcli { + +bool Rename(const RenameOptions &options, std::ostream &out, std::ostream &err) { + Archive archive = Archive::Open(options.archive, 0); + + LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; + const int result = + RenameFile(archive.Handle(), options.old_file, options.new_file, lcid, out, err); + archive.Close(); + return result == 0; +} + +} // namespace mpqcli diff --git a/src/commands/rename.h b/src/commands/rename.h new file mode 100644 index 0000000..cb6ac0b --- /dev/null +++ b/src/commands/rename.h @@ -0,0 +1,23 @@ +#ifndef COMMANDS_RENAME_H +#define COMMANDS_RENAME_H + +#include +#include +#include + +namespace mpqcli { + +/// Settled arguments for the rename subcommand +struct RenameOptions { + std::string archive; + std::string old_file; + std::string new_file; + std::optional locale; +}; + +/// Renames a file inside an archive, returning false if it could not be renamed +bool Rename(const RenameOptions &options, std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/cli/verify.cpp b/src/commands/verify.cpp similarity index 66% rename from src/cli/verify.cpp rename to src/commands/verify.cpp index e55a5e3..b68c4d8 100644 --- a/src/cli/verify.cpp +++ b/src/commands/verify.cpp @@ -1,56 +1,54 @@ -#include "mpq/verify.h" +#include "commands/verify.h" #include -#include #include -#include "cli/commands.h" #include "mpq/archive.h" +#include "mpq/verify.h" namespace mpqcli { -int HandleVerify(const std::string &target, bool print_signature, std::ostream &out, - std::ostream &err) { - Archive archive = Archive::Open(target, MPQ_OPEN_READ_ONLY); +bool Verify(const VerifyOptions &options, std::ostream &out, std::ostream &err) { + Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); - int result; + bool verified; switch (VerifyMpqArchive(archive.Handle())) { case ERROR_WEAK_SIGNATURE_OK: case ERROR_STRONG_SIGNATURE_OK: - if (print_signature) { + if (options.print_signature) { // If printing the signature, don't print success message // because the user might want to pipe/redirect the signature data - PrintMpqSignature(archive.Handle(), target, out, err); + PrintMpqSignature(archive.Handle(), options.target, out, err); } else { out << "[*] Verify success" << std::endl; } - result = 0; + verified = true; break; case ERROR_WEAK_SIGNATURE_ERROR: case ERROR_STRONG_SIGNATURE_ERROR: - if (print_signature) { + if (options.print_signature) { // Print the (invalid) signature bytes for forensic inspection, // but still fail: the archive content no longer matches it - PrintMpqSignature(archive.Handle(), target, out, err); + PrintMpqSignature(archive.Handle(), options.target, out, err); } err << "[!] Verify failed: signature is present but invalid" << std::endl; - result = 1; + verified = false; break; case ERROR_NO_SIGNATURE: err << "[!] Verify failed: archive has no signature" << std::endl; - result = 1; + verified = false; break; default: // ERROR_VERIFY_FAILED or any other value err << "[!] Verify failed" << std::endl; - result = 1; + verified = false; break; } archive.Close(); - return result; + return verified; } } // namespace mpqcli diff --git a/src/commands/verify.h b/src/commands/verify.h new file mode 100644 index 0000000..5a07878 --- /dev/null +++ b/src/commands/verify.h @@ -0,0 +1,20 @@ +#ifndef COMMANDS_VERIFY_H +#define COMMANDS_VERIFY_H + +#include +#include + +namespace mpqcli { + +/// Settled arguments for the verify subcommand +struct VerifyOptions { + std::string target; + bool print_signature = false; +}; + +/// Checks the archive's signature, returning false when it is missing or invalid +bool Verify(const VerifyOptions &options, std::ostream &out, std::ostream &err); + +} // namespace mpqcli + +#endif diff --git a/src/util/path.cpp b/src/util/path.cpp index a7a5ae8..98b019d 100644 --- a/src/util/path.cpp +++ b/src/util/path.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +53,24 @@ std::vector ListFilesRecursive(const fs::path &directory, std::error_c return files; } +std::vector ExpandStdinMarker(const std::vector &paths, + std::istream &in) { + std::vector expanded; + for (const auto &arg : paths) { + if (arg != "-") { + expanded.push_back(arg); + continue; + } + std::string line; + while (std::getline(in, line)) { + if (!line.empty()) { + expanded.push_back(line); + } + } + } + return expanded; +} + uint64_t LocalFileTimestamp(const fs::path &path) { #ifdef _WIN32 // _wstat64 handles paths with non-ASCII characters, which the narrow diff --git a/src/util/path.h b/src/util/path.h index 75228ac..b8c05c0 100644 --- a/src/util/path.h +++ b/src/util/path.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -23,6 +24,9 @@ std::string ResolveArchiveName(const std::string &f, const std::optional ListFilesRecursive(const std::filesystem::path &directory, std::error_code &ec); +/// Replaces each "-" in paths with the non-empty lines read from in +std::vector ExpandStdinMarker(const std::vector &paths, std::istream &in); + /// Returns the file's last-modification time as a Windows FILETIME value /// (100-nanosecond intervals since 1601-01-01 UTC), or 0 if it cannot be read uint64_t LocalFileTimestamp(const std::filesystem::path &path); From a2eb3546f4b0cfd5d1ee4dd8bef6b5d07ece93f4 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sat, 26 Sep 2026 16:26:16 +1200 Subject: [PATCH 45/59] Sent progress and warnings to stderr and moved the tests with them --- CHANGELOG.md | 1 + app/add.cpp | 2 +- app/compact.cpp | 2 +- app/create.cpp | 2 +- app/extract.cpp | 2 +- app/remove.cpp | 2 +- app/rename.cpp | 2 +- docs/changelog.md | 1 + src/commands/add.cpp | 12 ++--- src/commands/add.h | 2 +- src/commands/compact.cpp | 4 +- src/commands/compact.h | 2 +- src/commands/create.cpp | 12 ++--- src/commands/create.h | 2 +- src/commands/extract.cpp | 10 ++-- src/commands/extract.h | 2 +- src/commands/read.cpp | 2 +- src/commands/remove.cpp | 4 +- src/commands/remove.h | 2 +- src/commands/rename.cpp | 5 +- src/commands/rename.h | 2 +- src/commands/verify.cpp | 2 +- src/mpq/add.cpp | 22 ++++---- src/mpq/add.h | 4 +- src/mpq/compact.cpp | 4 +- src/mpq/compact.h | 2 +- src/mpq/extract.cpp | 9 ++-- src/mpq/extract.h | 5 +- src/mpq/remove.cpp | 4 +- src/mpq/remove.h | 2 +- src/mpq/rename.cpp | 5 +- src/mpq/rename.h | 3 +- test/test_add.py | 112 +++++++++++++++++++-------------------- test/test_compact.py | 6 +-- test/test_create.py | 8 +-- test/test_extract.py | 57 ++++++++++---------- test/test_read.py | 5 +- test/test_remove.py | 20 ++++--- test/test_rename.py | 24 ++++----- test/test_verify.py | 2 +- 40 files changed, 183 insertions(+), 188 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4757bd..2c5b91f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Publish install scripts and a signed checksums file with each release - Run the Docker image in /data so mounted paths need no prefix - Exit 2 with a message on an unexpected internal error instead of aborting +- Print progress and warnings on stderr, leaving stdout for results alone ### Fixed diff --git a/app/add.cpp b/app/add.cpp index b4ae84e..a1a59bd 100644 --- a/app/add.cpp +++ b/app/add.cpp @@ -46,6 +46,6 @@ void RegisterAdd(CLI::App &app, Context &context) { sub->callback([options, &context]() { options->files = mpqcli::ExpandStdinMarker(options->files, context.in); - context.exit_code = mpqcli::Add(*options, context.out, context.err) ? 0 : 1; + context.exit_code = mpqcli::Add(*options, context.err) ? 0 : 1; }); } diff --git a/app/compact.cpp b/app/compact.cpp index 93b461d..5af718b 100644 --- a/app/compact.cpp +++ b/app/compact.cpp @@ -17,6 +17,6 @@ void RegisterCompact(CLI::App &app, Context &context) { ->check(CLI::ExistingFile); sub->callback([options, &context]() { - context.exit_code = mpqcli::Compact(*options, context.out) ? 0 : 1; + context.exit_code = mpqcli::Compact(*options, context.err) ? 0 : 1; }); } diff --git a/app/create.cpp b/app/create.cpp index 81b474b..5c721a2 100644 --- a/app/create.cpp +++ b/app/create.cpp @@ -70,6 +70,6 @@ void RegisterCreate(CLI::App &app, Context &context) { options->create.create_overrides.mpq_version = static_cast(options->version.value() - 1); } - context.exit_code = mpqcli::Create(options->create, context.out, context.err) ? 0 : 1; + context.exit_code = mpqcli::Create(options->create, context.err) ? 0 : 1; }); } diff --git a/app/extract.cpp b/app/extract.cpp index cf606dd..91c405c 100644 --- a/app/extract.cpp +++ b/app/extract.cpp @@ -22,6 +22,6 @@ void RegisterExtract(CLI::App &app, Context &context) { sub->add_option("--locale", options->locale, "Preferred locale for extracted file"); sub->callback([options, &context]() { - context.exit_code = mpqcli::Extract(*options, context.out, context.err) ? 0 : 1; + context.exit_code = mpqcli::Extract(*options, context.err) ? 0 : 1; }); } diff --git a/app/remove.cpp b/app/remove.cpp index b56c1a5..cb5a2fa 100644 --- a/app/remove.cpp +++ b/app/remove.cpp @@ -23,6 +23,6 @@ void RegisterRemove(CLI::App &app, Context &context) { sub->callback([options, &context]() { options->files = mpqcli::ExpandStdinMarker(options->files, context.in); - context.exit_code = mpqcli::Remove(*options, context.out, context.err) ? 0 : 1; + context.exit_code = mpqcli::Remove(*options, context.err) ? 0 : 1; }); } diff --git a/app/rename.cpp b/app/rename.cpp index 5361a43..7dd2d95 100644 --- a/app/rename.cpp +++ b/app/rename.cpp @@ -19,6 +19,6 @@ void RegisterRename(CLI::App &app, Context &context) { sub->add_option("--locale", options->locale, "Locale of file to rename")->check(locale_valid); sub->callback([options, &context]() { - context.exit_code = mpqcli::Rename(*options, context.out, context.err) ? 0 : 1; + context.exit_code = mpqcli::Rename(*options, context.err) ? 0 : 1; }); } diff --git a/docs/changelog.md b/docs/changelog.md index b4757bd..2c5b91f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,6 +9,7 @@ - Publish install scripts and a signed checksums file with each release - Run the Docker image in /data so mounted paths need no prefix - Exit 2 with a message on an unexpected internal error instead of aborting +- Print progress and warnings on stderr, leaving stdout for results alone ### Fixed diff --git a/src/commands/add.cpp b/src/commands/add.cpp index a43c196..0885211 100644 --- a/src/commands/add.cpp +++ b/src/commands/add.cpp @@ -18,7 +18,7 @@ namespace fs = std::filesystem; namespace mpqcli { -bool Add(const AddOptions &options, std::ostream &out, std::ostream &err) { +bool Add(const AddOptions &options, std::ostream &err) { Archive archive = Archive::Open(options.archive, 0); LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; @@ -26,7 +26,7 @@ bool Add(const AddOptions &options, std::ostream &out, std::ostream &err) { GameProfile profile; if (options.game_profile.has_value()) { profile = GameRules::StringToProfile(options.game_profile.value()); - out << "[*] Using game profile: " << options.game_profile.value() << std::endl; + err << "[*] Using game profile: " << options.game_profile.value() << std::endl; } else { profile = GameRules::GetDefaultProfile(); } @@ -59,14 +59,14 @@ bool Add(const AddOptions &options, std::ostream &out, std::ostream &err) { continue; } std::string prefix = options.path.value_or(""); - result |= AddFiles(archive.Handle(), directory_files, f, prefix, lcid, game_rules, out, - err, options.compression_overrides, options.overwrite, - options.update, &files_skipped); + result |= AddFiles(archive.Handle(), directory_files, f, prefix, lcid, game_rules, err, + options.compression_overrides, options.overwrite, options.update, + &files_skipped); } else if (fs::is_regular_file(f, ec)) { const bool treat_as_directory = has_directory || options.files.size() > 1; std::string archive_path = ResolveArchiveName(f, options.path, treat_as_directory); - result |= AddFile(archive.Handle(), f, archive_path, lcid, game_rules, out, err, + result |= AddFile(archive.Handle(), f, archive_path, lcid, game_rules, err, options.compression_overrides, options.overwrite, options.update, &files_skipped); diff --git a/src/commands/add.h b/src/commands/add.h index f577a86..9169fde 100644 --- a/src/commands/add.h +++ b/src/commands/add.h @@ -23,7 +23,7 @@ struct AddOptions { }; /// Adds files and directories to an archive, returning false if any of them failed -bool Add(const AddOptions &options, std::ostream &out, std::ostream &err); +bool Add(const AddOptions &options, std::ostream &err); } // namespace mpqcli diff --git a/src/commands/compact.cpp b/src/commands/compact.cpp index d167074..b707237 100644 --- a/src/commands/compact.cpp +++ b/src/commands/compact.cpp @@ -7,9 +7,9 @@ namespace mpqcli { -bool Compact(const CompactOptions &options, std::ostream &out) { +bool Compact(const CompactOptions &options, std::ostream &err) { Archive archive = Archive::Open(options.target, 0); - CompactMpqArchive(archive.Handle(), options.listfile, out); + CompactMpqArchive(archive.Handle(), options.listfile, err); archive.Close(); return true; } diff --git a/src/commands/compact.h b/src/commands/compact.h index 76f13a2..c176a90 100644 --- a/src/commands/compact.h +++ b/src/commands/compact.h @@ -14,7 +14,7 @@ struct CompactOptions { }; /// Compacts the archive -bool Compact(const CompactOptions &options, std::ostream &out); +bool Compact(const CompactOptions &options, std::ostream &err); } // namespace mpqcli diff --git a/src/commands/create.cpp b/src/commands/create.cpp index cbe2f45..bf922ea 100644 --- a/src/commands/create.cpp +++ b/src/commands/create.cpp @@ -20,7 +20,7 @@ namespace fs = std::filesystem; namespace mpqcli { -bool Create(const CreateOptions &options, std::ostream &out, std::ostream &err) { +bool Create(const CreateOptions &options, std::ostream &err) { std::error_code ec; fs::path output_file_path; if (options.output.has_value()) { @@ -49,7 +49,7 @@ bool Create(const CreateOptions &options, std::ostream &out, std::ostream &err) } GameRules game_rules(profile); - out << "[*] Game profile: " << options.game_profile.value_or("default") + err << "[*] Game profile: " << options.game_profile.value_or("default") << ", Output file: " << output_file << std::endl; game_rules.OverrideCreateSettings(options.create_overrides); @@ -78,12 +78,12 @@ bool Create(const CreateOptions &options, std::ostream &out, std::ostream &err) int result = 0; if (is_directory) { const std::string prefix = options.path.value_or(""); - result |= AddFiles(archive.Handle(), files, options.target, prefix, lcid, game_rules, out, - err, options.compression_overrides); + result |= AddFiles(archive.Handle(), files, options.target, prefix, lcid, game_rules, err, + options.compression_overrides); } else { std::string archive_path = ResolveArchiveName(options.target, options.path); - result |= AddFile(archive.Handle(), options.target, archive_path, lcid, game_rules, out, - err, options.compression_overrides); + result |= AddFile(archive.Handle(), options.target, archive_path, lcid, game_rules, err, + options.compression_overrides); } if (options.sign) { diff --git a/src/commands/create.h b/src/commands/create.h index e909c2e..d6841d0 100644 --- a/src/commands/create.h +++ b/src/commands/create.h @@ -22,7 +22,7 @@ struct CreateOptions { }; /// Creates an archive from a file or directory, returning false if any file failed to add -bool Create(const CreateOptions &options, std::ostream &out, std::ostream &err); +bool Create(const CreateOptions &options, std::ostream &err); } // namespace mpqcli diff --git a/src/commands/extract.cpp b/src/commands/extract.cpp index 50d3a6f..93038eb 100644 --- a/src/commands/extract.cpp +++ b/src/commands/extract.cpp @@ -15,7 +15,7 @@ namespace fs = std::filesystem; namespace mpqcli { -bool Extract(const ExtractOptions &options, std::ostream &out, std::ostream &err) { +bool Extract(const ExtractOptions &options, std::ostream &err) { // If no output directory specified, use MPQ path without extension // If output directory specified, create it if it doesn't exist std::error_code ec; @@ -45,7 +45,7 @@ bool Extract(const ExtractOptions &options, std::ostream &out, std::ostream &err // real paths; warn once up front on volumes where it cannot (RAM disks) static_cast(fs::canonical(effective_output, ec)); if (ec) { - out << "[!] Warning: Output directory cannot be fully resolved, symlinks will not " + err << "[!] Output directory cannot be fully resolved, symlinks will not " "be checked during extraction: " << effective_output << std::endl; } @@ -54,16 +54,16 @@ bool Extract(const ExtractOptions &options, std::ostream &out, std::ostream &err LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; if (options.locale.has_value() && lcid == default_locale) { - out << "[!] Warning: The locale '" << options.locale.value() + err << "[!] The locale '" << options.locale.value() << "' is unknown. Will use default locale instead." << std::endl; } int result; if (options.file.has_value()) { result = ExtractFile(archive.Handle(), effective_output, options.file.value(), - options.keep_folder_structure, lcid, out, err); + options.keep_folder_structure, lcid, err); } else { - result = ExtractFiles(archive.Handle(), effective_output, options.listfile, lcid, out, err); + result = ExtractFiles(archive.Handle(), effective_output, options.listfile, lcid, err); } archive.Close(); diff --git a/src/commands/extract.h b/src/commands/extract.h index 8ac8313..3b80ba3 100644 --- a/src/commands/extract.h +++ b/src/commands/extract.h @@ -18,7 +18,7 @@ struct ExtractOptions { }; /// Extracts one file or every file, returning false if any of them failed -bool Extract(const ExtractOptions &options, std::ostream &out, std::ostream &err); +bool Extract(const ExtractOptions &options, std::ostream &err); } // namespace mpqcli diff --git a/src/commands/read.cpp b/src/commands/read.cpp index b96b8e1..211556a 100644 --- a/src/commands/read.cpp +++ b/src/commands/read.cpp @@ -17,7 +17,7 @@ bool Read(const ReadOptions &options, std::ostream &out, std::ostream &err) { LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; if (options.locale.has_value() && lcid == default_locale) { - out << "[!] Warning: The locale '" << options.locale.value() + err << "[!] The locale '" << options.locale.value() << "' is unknown. Will use default locale instead." << std::endl; } diff --git a/src/commands/remove.cpp b/src/commands/remove.cpp index feb7338..fa99b34 100644 --- a/src/commands/remove.cpp +++ b/src/commands/remove.cpp @@ -12,7 +12,7 @@ namespace mpqcli { -bool Remove(const RemoveOptions &options, std::ostream &out, std::ostream &err) { +bool Remove(const RemoveOptions &options, std::ostream &err) { Archive archive = Archive::Open(options.archive, 0); LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; @@ -22,7 +22,7 @@ bool Remove(const RemoveOptions &options, std::ostream &out, std::ostream &err) if (!seen.insert(f).second) { continue; } - if (RemoveFile(archive.Handle(), f, lcid, out, err) != 0) { + if (RemoveFile(archive.Handle(), f, lcid, err) != 0) { all_removed = false; } } diff --git a/src/commands/remove.h b/src/commands/remove.h index a0db432..f29620e 100644 --- a/src/commands/remove.h +++ b/src/commands/remove.h @@ -16,7 +16,7 @@ struct RemoveOptions { }; /// Removes files from an archive, returning false if any of them could not be removed -bool Remove(const RemoveOptions &options, std::ostream &out, std::ostream &err); +bool Remove(const RemoveOptions &options, std::ostream &err); } // namespace mpqcli diff --git a/src/commands/rename.cpp b/src/commands/rename.cpp index f81d85c..5e4a6bf 100644 --- a/src/commands/rename.cpp +++ b/src/commands/rename.cpp @@ -10,12 +10,11 @@ namespace mpqcli { -bool Rename(const RenameOptions &options, std::ostream &out, std::ostream &err) { +bool Rename(const RenameOptions &options, std::ostream &err) { Archive archive = Archive::Open(options.archive, 0); LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; - const int result = - RenameFile(archive.Handle(), options.old_file, options.new_file, lcid, out, err); + const int result = RenameFile(archive.Handle(), options.old_file, options.new_file, lcid, err); archive.Close(); return result == 0; } diff --git a/src/commands/rename.h b/src/commands/rename.h index cb6ac0b..77f68bb 100644 --- a/src/commands/rename.h +++ b/src/commands/rename.h @@ -16,7 +16,7 @@ struct RenameOptions { }; /// Renames a file inside an archive, returning false if it could not be renamed -bool Rename(const RenameOptions &options, std::ostream &out, std::ostream &err); +bool Rename(const RenameOptions &options, std::ostream &err); } // namespace mpqcli diff --git a/src/commands/verify.cpp b/src/commands/verify.cpp index b68c4d8..96d5b69 100644 --- a/src/commands/verify.cpp +++ b/src/commands/verify.cpp @@ -21,7 +21,7 @@ bool Verify(const VerifyOptions &options, std::ostream &out, std::ostream &err) // because the user might want to pipe/redirect the signature data PrintMpqSignature(archive.Handle(), options.target, out, err); } else { - out << "[*] Verify success" << std::endl; + err << "[*] Verify success" << std::endl; } verified = true; break; diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index 9563f39..55f7793 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -24,8 +24,8 @@ namespace mpqcli { int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, const std::string &path_prefix, LCID locale, const GameRules &game_rules, - std::ostream &out, std::ostream &err, const CompressionSettingsOverrides &overrides, - bool overwrite, bool update, int *skipped) { + std::ostream &err, const CompressionSettingsOverrides &overrides, bool overwrite, + bool update, int *skipped) { int files_added = 0; int files_skipped = 0; int files_failed = 0; @@ -46,13 +46,13 @@ int AddFiles(HANDLE archive, const std::vector &files, const fs::path if (std::find(special_mpq_files.begin(), special_mpq_files.end(), archive_file_path) != special_mpq_files.end()) { - out << "[*] Skipping special MPQ file: " << archive_file_path << std::endl; + err << "[*] Skipping special MPQ file: " << archive_file_path << std::endl; continue; } int file_skipped = 0; - if (AddFile(archive, file, archive_file_path, locale, game_rules, out, err, overrides, - overwrite, update, &file_skipped) != 0) { + if (AddFile(archive, file, archive_file_path, locale, game_rules, err, overrides, overwrite, + update, &file_skipped) != 0) { files_failed++; } else if (file_skipped > 0) { files_skipped++; @@ -62,7 +62,7 @@ int AddFiles(HANDLE archive, const std::vector &files, const fs::path } if (update) { - out << "[*] For " << base_path.u8string() << ": " << files_added << " files added, " + err << "[*] For " << base_path.u8string() << ": " << files_added << " files added, " << files_skipped << " files skipped, " << files_failed << " files failed." << std::endl; } @@ -74,7 +74,7 @@ int AddFiles(HANDLE archive, const std::vector &files, const fs::path } int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archive_file_path, - const LCID locale, const GameRules &game_rules, std::ostream &out, std::ostream &err, + const LCID locale, const GameRules &game_rules, std::ostream &err, const CompressionSettingsOverrides &overrides, bool overwrite, bool update, int *skipped) { // Return if file doesn't exist on disk @@ -96,7 +96,7 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi SFileCloseFile(file); if (unchanged) { - out << "[~] Skipping unchanged file: " << archive_file_path << " (" << match_reason + err << "[~] Skipping unchanged file: " << archive_file_path << " (" << match_reason << ")" << std::endl; if (skipped != nullptr) { (*skipped)++; @@ -116,14 +116,14 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi return 0; } - out << "[+] File" << PrettyPrintLocale(locale, " for locale ") + err << "[+] File" << PrettyPrintLocale(locale, " for locale ") << " already exists in MPQ archive: " << archive_file_path << " - Overwriting..." << std::endl; } else { SFileCloseFile(file); } } - out << "[+] Adding file" << PrettyPrintLocale(locale, " for locale ") << ": " + err << "[+] Adding file" << PrettyPrintLocale(locale, " for locale ") << ": " << archive_file_path << std::endl; // Verify that we are not exceeding maxFile size of the archive, and if we do, increase it @@ -149,7 +149,7 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi return 1; } if (raw_file_size > std::numeric_limits::max()) { - err << "[!] Warning: file exceeds 4GB, size-based compression rules may not apply " + err << "[!] File exceeds 4GB, size-based compression rules may not apply " "correctly: " << local_file << std::endl; } diff --git a/src/mpq/add.h b/src/mpq/add.h index 6016a1d..397756b 100644 --- a/src/mpq/add.h +++ b/src/mpq/add.h @@ -15,12 +15,12 @@ namespace mpqcli { int AddFiles(HANDLE archive, const std::vector &files, const std::filesystem::path &base_path, const std::string &path_prefix, LCID locale, - const GameRules &game_rules, std::ostream &out, std::ostream &err, + const GameRules &game_rules, std::ostream &err, const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), bool overwrite = false, bool update = false, int *skipped = nullptr); int AddFile(HANDLE archive, const std::filesystem::path &local_file, const std::string &archive_file_path, LCID locale, const GameRules &game_rules, - std::ostream &out, std::ostream &err, + std::ostream &err, const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), bool overwrite = false, bool update = false, int *skipped = nullptr); diff --git a/src/mpq/compact.cpp b/src/mpq/compact.cpp index b65c1ec..3b23b50 100644 --- a/src/mpq/compact.cpp +++ b/src/mpq/compact.cpp @@ -11,8 +11,8 @@ namespace mpqcli { void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name, - std::ostream &out) { - out << "[*] Compacting archive. This may take some time..." << std::endl; + std::ostream &err) { + err << "[*] Compacting archive. This may take some time..." << std::endl; const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; if (!SFileCompactArchive(archive, listfile, false)) { diff --git a/src/mpq/compact.h b/src/mpq/compact.h index c5e9e49..9d7dab4 100644 --- a/src/mpq/compact.h +++ b/src/mpq/compact.h @@ -13,7 +13,7 @@ namespace mpqcli { /// /// @throws StormError if StormLib cannot compact it void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name, - std::ostream &out); + std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index edec7f7..05f442b 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -20,7 +20,7 @@ namespace mpqcli { int ExtractFiles(HANDLE archive, const std::string &output, const std::optional &listfile_name, LCID preferred_locale, - std::ostream &out, std::ostream &err) { + std::ostream &err) { SFileSetLocale(preferred_locale); const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; @@ -35,7 +35,7 @@ int ExtractFiles(HANDLE archive, const std::string &output, do { result |= ExtractFile(archive, output, find_data.cFileName, true, // Keep folder structure - preferred_locale, out, err); + preferred_locale, err); } while (SFileFindNextFile(find_handle, &find_data)); SFileFindClose(find_handle); @@ -43,8 +43,7 @@ int ExtractFiles(HANDLE archive, const std::string &output, } int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, - bool keep_folder_structure, LCID preferred_locale, std::ostream &out, - std::ostream &err) { + bool keep_folder_structure, LCID preferred_locale, std::ostream &err) { SFileSetLocale(preferred_locale); if (!FileExistsInArchiveForLocale(archive, file_name.c_str(), preferred_locale) && !FileExistsInArchiveForLocale(archive, file_name.c_str(), default_locale)) { @@ -113,7 +112,7 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi std::string output_file_name{output_file_path_name.u8string()}; if (SFileExtractFile(archive, file_name.c_str(), output_file_name.c_str(), 0)) { - out << "[*] Extracted: " << file_name_string << std::endl; + err << "[*] Extracted: " << file_name_string << std::endl; } else { const auto error = SErrGetLastError(); err << "[!] Failed: (" << error << ") " << StormErrorString(error) << ": " << file_name diff --git a/src/mpq/extract.h b/src/mpq/extract.h index 6e4f49f..d59d8c9 100644 --- a/src/mpq/extract.h +++ b/src/mpq/extract.h @@ -11,10 +11,9 @@ namespace mpqcli { int ExtractFiles(HANDLE archive, const std::string &output, const std::optional &listfile_name, LCID preferred_locale, - std::ostream &out, std::ostream &err); + std::ostream &err); int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, - bool keep_folder_structure, LCID preferred_locale, std::ostream &out, - std::ostream &err); + bool keep_folder_structure, LCID preferred_locale, std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/remove.cpp b/src/mpq/remove.cpp index f97353d..b1fd299 100644 --- a/src/mpq/remove.cpp +++ b/src/mpq/remove.cpp @@ -11,10 +11,10 @@ namespace mpqcli { -int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale, std::ostream &out, +int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale, std::ostream &err) { SFileSetLocale(locale); - out << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") << ": " + err << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") << ": " << archive_file_path << std::endl; if (!FileExistsInArchiveForLocale(archive, archive_file_path, locale)) { diff --git a/src/mpq/remove.h b/src/mpq/remove.h index 35f6e30..8a430d2 100644 --- a/src/mpq/remove.h +++ b/src/mpq/remove.h @@ -8,7 +8,7 @@ namespace mpqcli { -int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale, std::ostream &out, +int RemoveFile(HANDLE archive, const std::string &archive_file_path, LCID locale, std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/rename.cpp b/src/mpq/rename.cpp index 30ba595..b24e357 100644 --- a/src/mpq/rename.cpp +++ b/src/mpq/rename.cpp @@ -13,11 +13,10 @@ namespace mpqcli { int RenameFile(HANDLE archive, const std::string &old_archive_file_path, - const std::string &new_archive_file_path, LCID locale, std::ostream &out, - std::ostream &err) { + const std::string &new_archive_file_path, LCID locale, std::ostream &err) { SFileSetLocale(locale); const std::string new_stored_path = WindowsifyFilePath(new_archive_file_path); - out << "[~] Renaming file" << PrettyPrintLocale(locale, " for locale ") << ": " + err << "[~] Renaming file" << PrettyPrintLocale(locale, " for locale ") << ": " << old_archive_file_path << " -> " << new_stored_path << std::endl; if (!FileExistsInArchiveForLocale(archive, old_archive_file_path, locale)) { diff --git a/src/mpq/rename.h b/src/mpq/rename.h index af9d232..e066a9d 100644 --- a/src/mpq/rename.h +++ b/src/mpq/rename.h @@ -9,8 +9,7 @@ namespace mpqcli { int RenameFile(HANDLE archive, const std::string &old_archive_file_path, - const std::string &new_archive_file_path, LCID locale, std::ostream &out, - std::ostream &err); + const std::string &new_archive_file_path, LCID locale, std::ostream &err); } // namespace mpqcli diff --git a/test/test_add.py b/test/test_add.py index 5ccbdcc..bdb8ced 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -58,13 +58,13 @@ def test_add_file_to_mpq_archive(binary_path, generate_test_files): ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[+] Adding file: test.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[+] Adding file: test.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -98,8 +98,8 @@ def test_add_multiple_files_to_mpq_archive(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: alpha.txt" in result.stdout - assert "[+] Adding file: beta.txt" in result.stdout + assert "[+] Adding file: alpha.txt" in result.stderr + assert "[+] Adding file: beta.txt" in result.stderr expected_content = { "enUS bytes", @@ -245,14 +245,14 @@ def test_add_existing_file_with_overwrite_should_succeed(binary_path, generate_t ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[+] File already exists in MPQ archive: cats.txt - Overwriting...", - "[+] Adding file: cats.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[+] File already exists in MPQ archive: cats.txt - Overwriting...", + "[+] Adding file: cats.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -278,13 +278,13 @@ def test_add_nonexisting_file_with_overwrite_should_succeed(binary_path, generat ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[+] Adding file: test.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[+] Adding file: test.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -343,13 +343,13 @@ def test_create_mpq_with_locale(binary_path, generate_test_files): ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[+] Adding file for locale esES: cats.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[+] Adding file for locale esES: cats.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -392,8 +392,8 @@ def test_add_file_with_game_profile(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" - assert f"Using game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" - assert f"Adding file: test_{profile}.txt" in result.stdout + assert f"Using game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" + assert f"Adding file: test_{profile}.txt" in result.stderr list_result = subprocess.run( [str(binary_path), "list", str(target_file), "-d", "-p", "flags"], @@ -459,7 +459,7 @@ def test_add_file_with_all_game_profiles(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" - assert f"Using game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" + assert f"Using game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" list_result = subprocess.run( [str(binary_path), "list", str(target_file)], @@ -517,14 +517,14 @@ def test_add_existing_locale_file_with_overwrite_should_succeed(binary_path, gen text=True ) - expected_stdout = { + expected_stderr = { "[+] File for locale esES already exists in MPQ archive: cats.txt - Overwriting...", "[+] Adding file for locale esES: cats.txt", } - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" verify_file_in_mpq_has_content( binary_path, target_file, "cats.txt", @@ -569,11 +569,11 @@ def test_add_file_after_all_locale_variants_removed(binary_path, generate_locale text=True ) - expected_stdout = {"[+] Adding file: cats.txt"} - output_lines = set(result.stdout.splitlines()) + expected_stderr = {"[+] Adding file: cats.txt"} + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"Re-add after full removal failed: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" verify_file_in_mpq_has_content( binary_path, target_file, "cats.txt", @@ -603,8 +603,8 @@ def test_add_directory_to_mpq_archive(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: sub\\a.txt" in result.stdout - assert "[+] Adding file: b.txt" in result.stdout + assert "[+] Adding file: sub\\a.txt" in result.stderr + assert "[+] Adding file: b.txt" in result.stderr list_result = subprocess.run( [str(binary_path), "list", str(target_mpq)], @@ -677,7 +677,7 @@ def test_add_directory_with_overwrite(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] File already exists in MPQ archive: cats.txt - Overwriting..." in result.stdout + assert "[+] File already exists in MPQ archive: cats.txt - Overwriting..." in result.stderr verify_file_in_mpq_has_content(binary_path, target_mpq, "cats.txt", {"This is the replaced cats file."}) finally: @@ -737,10 +737,10 @@ def test_add_update_skips_unchanged_files(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (MD5 matches)" in result.stdout - assert "[~] Skipping unchanged file: dogs.txt (MD5 matches)" in result.stdout - assert "files added" in result.stdout - assert "files skipped" in result.stdout + assert "[~] Skipping unchanged file: cats.txt (MD5 matches)" in result.stderr + assert "[~] Skipping unchanged file: dogs.txt (MD5 matches)" in result.stderr + assert "files added" in result.stderr + assert "files skipped" in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -766,10 +766,10 @@ def test_add_update_adds_changed_files(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stdout - assert "[~] Skipping unchanged file: dogs.txt" in result.stdout - assert "1 files added" in result.stdout - assert "1 files skipped" in result.stdout + assert "[+] Adding file: cats.txt" in result.stderr + assert "[~] Skipping unchanged file: dogs.txt" in result.stderr + assert "1 files added" in result.stderr + assert "1 files skipped" in result.stderr verify_file_in_mpq_has_content( binary_path, target_mpq, "cats.txt", @@ -804,8 +804,8 @@ def test_add_update_second_run_skips_all(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file:" not in result.stdout - assert "0 files added" in result.stdout + assert "[+] Adding file:" not in result.stderr + assert "0 files added" in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -833,8 +833,8 @@ def test_add_update_single_file_skips_when_unchanged(binary_path, generate_test_ ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (MD5 matches)" in result.stdout - assert "[+] Adding file: cats.txt" not in result.stdout + assert "[~] Skipping unchanged file: cats.txt (MD5 matches)" in result.stderr + assert "[+] Adding file: cats.txt" not in result.stderr finally: shutil.rmtree(work_dir, ignore_errors=True) @@ -860,7 +860,7 @@ def test_add_update_single_file_replaces_when_changed(binary_path, generate_test ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stdout + assert "[+] Adding file: cats.txt" in result.stderr verify_file_in_mpq_has_content( binary_path, target_mpq, "cats.txt", @@ -915,8 +915,8 @@ def test_add_update_skips_unchanged_files_via_crc32(binary_path, generate_test_f ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (CRC32 matches)" in result.stdout - assert "files skipped" in result.stdout + assert "[~] Skipping unchanged file: cats.txt (CRC32 matches)" in result.stderr + assert "files skipped" in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -944,8 +944,8 @@ def test_add_update_adds_changed_files_via_crc32(binary_path, generate_test_file ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stdout - assert "Skipping unchanged" not in result.stdout + assert "[+] Adding file: cats.txt" in result.stderr + assert "Skipping unchanged" not in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -974,8 +974,8 @@ def test_add_update_skips_unchanged_files_via_timestamp(binary_path, generate_te ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (Timestamp matches)" in result.stdout - assert "files skipped" in result.stdout + assert "[~] Skipping unchanged file: cats.txt (Timestamp matches)" in result.stderr + assert "files skipped" in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -1006,8 +1006,8 @@ def test_add_update_adds_changed_files_via_timestamp(binary_path, generate_test_ ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stdout - assert "Skipping unchanged" not in result.stdout + assert "[+] Adding file: cats.txt" in result.stderr + assert "Skipping unchanged" not in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -1034,8 +1034,8 @@ def test_add_update_always_adds_without_attributes(binary_path, generate_test_fi ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: cats.txt" in result.stdout - assert "Skipping unchanged" not in result.stdout + assert "[+] Adding file: cats.txt" in result.stderr + assert "Skipping unchanged" not in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -1063,8 +1063,8 @@ def test_add_files_via_stdin(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] Adding file: stdin_a.txt" in result.stdout - assert "[+] Adding file: stdin_b.txt" in result.stdout + assert "[+] Adding file: stdin_a.txt" in result.stderr + assert "[+] Adding file: stdin_b.txt" in result.stderr list_result = subprocess.run( [str(binary_path), "list", str(target_mpq)], diff --git a/test/test_compact.py b/test/test_compact.py index 575fa41..1d53fa7 100644 --- a/test/test_compact.py +++ b/test/test_compact.py @@ -74,8 +74,8 @@ def test_compact_mpq_without_listfile(binary_path, generate_mpq_without_internal output_lines = result.stderr.splitlines() assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" - assert len(output_lines) == 1, f"Unexpected output: {output_lines}" - assert output_lines[0].startswith(expected_prefix), f"Unexpected output: {output_lines}" + assert len(output_lines) == 2, f"Unexpected output: {output_lines}" + assert output_lines[1].startswith(expected_prefix), f"Unexpected output: {output_lines}" def test_compact_mpq_with_listfile(binary_path, generate_mpq_without_internal_listfile): @@ -101,7 +101,7 @@ def test_compact_mpq_with_listfile(binary_path, generate_mpq_without_internal_li text=True ) - output_lines = result.stdout.splitlines() + output_lines = result.stderr.splitlines() assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" assert output_lines == expected_output, f"Unexpected output: {output_lines}" diff --git a/test/test_create.py b/test/test_create.py index abd14ee..235e9c0 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -163,8 +163,8 @@ def test_create_mpq_with_weak_signature(binary_path, generate_test_files): text=True ) assert verify_result.returncode == 0, f"Signature verification failed: {verify_result.stderr}" - assert "[*] Verify success" in verify_result.stdout, \ - f"Unexpected verify output: {verify_result.stdout}" + assert "[*] Verify success" in verify_result.stderr, \ + f"Unexpected verify output: {verify_result.stderr}" def test_create_mpq_already_exists(binary_path, generate_test_files): @@ -377,7 +377,7 @@ def test_create_mpq_with_game_profile(binary_path, generate_test_files): assert result.returncode == 0, f"mpqcli failed with error for profile {profile}: {result.stderr}" assert output_file.exists(), f"MPQ file was not created for profile {profile}" assert output_file.stat().st_size > 0, f"MPQ file is empty for profile {profile}" - assert f"[*] Game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" + assert f"[*] Game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" # Verify the MPQ version version_result = subprocess.run( @@ -480,7 +480,7 @@ def test_create_mpq_with_all_game_profiles(binary_path, generate_test_files): assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}" assert output_file.exists(), f"MPQ file was not created for profile {profile}" - assert f"[*] Game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}" + assert f"[*] Game profile: {profile}" in result.stderr, f"Game profile message not found for {profile}" # Verify the MPQ version version_result = subprocess.run( diff --git a/test/test_extract.py b/test/test_extract.py index 45aa4e9..3fa6400 100644 --- a/test/test_extract.py +++ b/test/test_extract.py @@ -58,7 +58,7 @@ def test_extract_mpq_default_options(binary_path, generate_test_files): text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -120,7 +120,7 @@ def test_extract_mpq_output_directory_specified(binary_path, generate_test_files text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -179,7 +179,7 @@ def test_extract_file_from_mpq_output_directory_specified(binary_path, generate_ text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -228,15 +228,15 @@ def test_extract_file_from_mpq_with_locale(binary_path, generate_locales_mpq_tes text=True ) - expected_stdout = { + expected_stderr = { "[*] Extracted: " + file_to_extract } output_file = output_dir / file_to_extract expected_content = "Este es un archivo sobre gatos." - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" assert output_file.exists(), "Output directory was not created" assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" @@ -265,15 +265,15 @@ def test_extract_file_from_mpq_with_default_locale(binary_path, generate_locales text=True ) - expected_stdout = { + expected_stderr = { "[*] Extracted: " + file_to_extract } output_file = output_dir / file_to_extract expected_content = "This is a file about cats." - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" assert output_file.exists(), "Output directory was not created" assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" @@ -303,16 +303,16 @@ def test_extract_file_from_mpq_with_illegal_locale(binary_path, generate_locales text=True ) - expected_stdout = { - "[!] Warning: The locale 'nosuchlocale' is unknown. Will use default locale instead.", + expected_stderr = { + "[!] The locale 'nosuchlocale' is unknown. Will use default locale instead.", "[*] Extracted: " + file_to_extract } output_file = output_dir / file_to_extract expected_content = "This is a file about cats." - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" assert output_file.exists(), "Output directory was not created" assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" @@ -341,15 +341,15 @@ def test_extract_file_from_mpq_with_locale_not_in_file(binary_path, generate_loc text=True ) - expected_stdout = { + expected_stderr = { "[*] Extracted: " + file_to_extract } output_file = output_dir / file_to_extract expected_content = "This is a file about cats." - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" assert output_file.exists(), "Output directory was not created" assert output_file.read_text(encoding="utf-8") == expected_content, "Unexpected file content" @@ -467,7 +467,7 @@ def test_extract_all_files_from_mpq_without_providing_listfile(binary_path, gene text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -513,7 +513,7 @@ def test_extract_all_files_from_mpq_without_providing_listfile_and_with_given_lo text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -557,7 +557,7 @@ def test_extract_single_file_from_mpq_without_providing_listfile(binary_path, ge text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -604,7 +604,7 @@ def test_extract_all_files_from_mpq_providing_partial_external_listfile(binary_p text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -652,7 +652,7 @@ def test_extract_all_files_from_mpq_providing_partial_external_listfile_and_with text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -699,7 +699,7 @@ def test_extract_all_files_from_mpq_providing_complete_external_listfile(binary_ text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -747,7 +747,7 @@ def test_extract_all_files_from_mpq_providing_complete_external_listfile_and_wit text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = {line for line in result.stderr.splitlines() if line.startswith("[*] Extracted: ")} # Create expected_lines set based on expected output with prefix expected_lines = {f"[*] Extracted: {line}" for line in expected_output} @@ -787,11 +787,10 @@ def test_extract_path_traversal_is_blocked(binary_path, generate_path_traversal_ text=True ) - expected_stdout = { + expected_stdout = set() + expected_stderr = { "[*] Extracted: safe.txt", "[*] Extracted: (listfile)", - } - expected_stderr = { "[!] Blocked: path traversal attempt detected: " + os.path.normpath("../../sneaky.txt"), "", "[!] Failed to extract all files.", @@ -863,11 +862,11 @@ def test_extract_nested_file_from_mpq(binary_path, generate_test_files): text=True ) - expected_stdout = {"[*] Extracted: cats.txt"} - output_lines = set(result.stdout.splitlines()) + expected_stderr = {"[*] Extracted: cats.txt"} + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert output_lines == expected_stdout, f"Unexpected output: {output_lines}" + assert output_lines == expected_stderr, f"Unexpected output: {output_lines}" extracted_file = output_dir / "cats.txt" assert extracted_file.exists(), "Extracted nested file does not exist" diff --git a/test/test_read.py b/test/test_read.py index e72a5bf..8fbf02a 100644 --- a/test/test_read.py +++ b/test/test_read.py @@ -128,13 +128,16 @@ def test_read_file_from_mpq_with_illegal_locale(binary_path, generate_locales_mp ) expected_content = { - "[!] Warning: The locale 'nosuchlocale' is unknown. Will use default locale instead.", "This is a file about cats.", } + expected_warning = { + "[!] The locale 'nosuchlocale' is unknown. Will use default locale instead.", + } output_lines = set(result.stdout.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" assert output_lines == expected_content, f"Unexpected output: {output_lines}" + assert set(result.stderr.splitlines()) == expected_warning, f"Unexpected stderr: {result.stderr}" def test_read_file_from_mpq_with_locale_not_in_file(binary_path, generate_locales_mpq_test_files): diff --git a/test/test_remove.py b/test/test_remove.py index 16da4f1..65aef92 100644 --- a/test/test_remove.py +++ b/test/test_remove.py @@ -44,13 +44,12 @@ def test_remove_target_file_does_not_exist(binary_path, generate_locales_mpq_tes ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[-] Removing file: does-not-exist.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) expected_stderr_output = { + "[-] Removing file: does-not-exist.txt", "[!] Failed: File doesn't exist for locale enUS: does-not-exist.txt", } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" @@ -92,13 +91,12 @@ def test_remove_file_from_mpq_archive_with_wrong_locale_given( ) output_lines = set(result.stdout.splitlines()) - expected_output = { - "[-] Removing file for locale ptPT: " + test_file, - } + expected_output = set() assert output_lines == expected_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) expected_stderr_output = { + "[-] Removing file for locale ptPT: " + test_file, "[!] Failed: File doesn't exist for locale ptPT: " + test_file, } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" @@ -127,13 +125,13 @@ def test_remove_default_locale_file_from_mpq_archive_unique_name(binary_path, ge ) output_lines = set(result.stdout.splitlines()) - expected_output = { - "[-] Removing file: capybaras.txt", - } + expected_output = set() assert output_lines == expected_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[-] Removing file: capybaras.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -311,7 +309,7 @@ def test_remove_files_via_stdin(binary_path, generate_locales_mpq_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[-] Removing file: cats.txt" in result.stdout + assert "[-] Removing file: cats.txt" in result.stderr expected_output = { "deDE cats.txt", diff --git a/test/test_rename.py b/test/test_rename.py index 191a7d1..acbe3b0 100644 --- a/test/test_rename.py +++ b/test/test_rename.py @@ -14,7 +14,7 @@ def test_rename_file_in_mpq_archive(binary_path, generate_mpq_without_internal_l ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Renaming file: capybaras.txt -> renamed.txt" in result.stdout + assert "[~] Renaming file: capybaras.txt -> renamed.txt" in result.stderr result = subprocess.run( [str(binary_path), "list", str(target_file), "--listfile", str(listfile)], @@ -78,13 +78,12 @@ def test_rename_target_file_does_not_exist(binary_path, generate_locales_mpq_tes ) output_lines = set(result.stdout.splitlines()) - expected_stdout_output = { - "[~] Renaming file: does-not-exist.txt -> renamed.txt", - } + expected_stdout_output = set() assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) expected_stderr_output = { + "[~] Renaming file: does-not-exist.txt -> renamed.txt", "[!] Failed: File doesn't exist for locale enUS: does-not-exist.txt", } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" @@ -126,13 +125,12 @@ def test_rename_file_from_mpq_archive_with_wrong_locale_given( ) output_lines = set(result.stdout.splitlines()) - expected_output = { - "[~] Renaming file for locale ptPT: " + test_file + " -> renamed.txt", - } + expected_output = set() assert output_lines == expected_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) expected_stderr_output = { + "[~] Renaming file for locale ptPT: " + test_file + " -> renamed.txt", "[!] Failed: File doesn't exist for locale ptPT: " + test_file, } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" @@ -161,13 +159,13 @@ def test_rename_default_locale_file_from_mpq_archive_unique_name(binary_path, ge ) output_lines = set(result.stdout.splitlines()) - expected_output = { - "[~] Renaming file: capybaras.txt -> renamed.txt", - } + expected_output = set() assert output_lines == expected_output, f"Unexpected output: {output_lines}" output_lines = set(result.stderr.splitlines()) - expected_stderr_output = set() + expected_stderr_output = { + "[~] Renaming file: capybaras.txt -> renamed.txt", + } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" @@ -338,7 +336,7 @@ def test_rename_to_existing_name_fails(binary_path, generate_mpq_without_interna ) assert result.returncode == 1, f"mpqcli unexpectedly succeeded: {result.stdout}" - assert "[~] Renaming file: capybaras.txt -> elephants.txt" in result.stdout + assert "[~] Renaming file: capybaras.txt -> elephants.txt" in result.stderr assert "[!] Failed: File cannot be renamed for locale enUS: capybaras.txt -> elephants.txt" in result.stderr # Verify the archive is unchanged: both files should still exist under their original names. @@ -386,7 +384,7 @@ def test_rename_converts_forward_slashes_to_backslashes(binary_path, generate_mp ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Renaming file: capybaras.txt -> deep\\renamed.txt" in result.stdout + assert "[~] Renaming file: capybaras.txt -> deep\\renamed.txt" in result.stderr result = subprocess.run( [str(binary_path), "list", str(target_file), "--listfile", str(listfile)], diff --git a/test/test_verify.py b/test/test_verify.py index 23e3e1e..95ffd93 100644 --- a/test/test_verify.py +++ b/test/test_verify.py @@ -87,7 +87,7 @@ def test_verify_weak_signature(binary_path): text=True ) - output_lines = set(result.stdout.splitlines()) + output_lines = set(result.stderr.splitlines()) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" assert output_lines == expected_output, f"Unexpected output: {output_lines}" From a66ddfd03f5b5b62a8214756fbcc299a919dbaa5 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sat, 26 Sep 2026 16:49:12 +1200 Subject: [PATCH 46/59] Handled Ctrl-C by polling a flag in the core and writing new archives beside the output --- CHANGELOG.md | 4 ++++ app/add.cpp | 2 +- app/commands.h | 2 ++ app/create.cpp | 2 +- app/extract.cpp | 2 +- app/main.cpp | 29 ++++++++++++++++++++++++- app/remove.cpp | 2 +- docs/changelog.md | 4 ++++ src/commands/add.cpp | 10 ++++++--- src/commands/add.h | 3 ++- src/commands/create.cpp | 46 ++++++++++++++++++++++++++++++++++++---- src/commands/create.h | 3 ++- src/commands/extract.cpp | 6 ++++-- src/commands/extract.h | 3 ++- src/commands/remove.cpp | 5 ++++- src/commands/remove.h | 3 ++- src/errors.h | 14 ++++++++++++ src/mpq/add.cpp | 9 ++++++-- src/mpq/add.h | 3 ++- src/mpq/extract.cpp | 10 ++++++++- src/mpq/extract.h | 3 ++- 21 files changed, 141 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5b91f..9f489b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.12.0 - 2026-09-25 +### Added + +- Stop cleanly on Ctrl-C, leaving no partial archive behind + ### Changed - Rename the release assets to mpqcli-linux-x86_64, mpqcli-linux-aarch64 and mpqcli-windows-x86_64.exe diff --git a/app/add.cpp b/app/add.cpp index a1a59bd..7b157a6 100644 --- a/app/add.cpp +++ b/app/add.cpp @@ -46,6 +46,6 @@ void RegisterAdd(CLI::App &app, Context &context) { sub->callback([options, &context]() { options->files = mpqcli::ExpandStdinMarker(options->files, context.in); - context.exit_code = mpqcli::Add(*options, context.err) ? 0 : 1; + context.exit_code = mpqcli::Add(*options, context.err, context.cancelled) ? 0 : 1; }); } diff --git a/app/commands.h b/app/commands.h index 6031517..c16eaf7 100644 --- a/app/commands.h +++ b/app/commands.h @@ -1,6 +1,7 @@ #ifndef COMMANDS_H #define COMMANDS_H +#include #include #include @@ -11,6 +12,7 @@ struct Context { std::ostream &out; std::ostream &err; std::istream ∈ + const std::atomic &cancelled; int exit_code = 0; }; diff --git a/app/create.cpp b/app/create.cpp index 5c721a2..cb8f4ce 100644 --- a/app/create.cpp +++ b/app/create.cpp @@ -70,6 +70,6 @@ void RegisterCreate(CLI::App &app, Context &context) { options->create.create_overrides.mpq_version = static_cast(options->version.value() - 1); } - context.exit_code = mpqcli::Create(options->create, context.err) ? 0 : 1; + context.exit_code = mpqcli::Create(options->create, context.err, context.cancelled) ? 0 : 1; }); } diff --git a/app/extract.cpp b/app/extract.cpp index 91c405c..ad954cb 100644 --- a/app/extract.cpp +++ b/app/extract.cpp @@ -22,6 +22,6 @@ void RegisterExtract(CLI::App &app, Context &context) { sub->add_option("--locale", options->locale, "Preferred locale for extracted file"); sub->callback([options, &context]() { - context.exit_code = mpqcli::Extract(*options, context.err) ? 0 : 1; + context.exit_code = mpqcli::Extract(*options, context.err, context.cancelled) ? 0 : 1; }); } diff --git a/app/main.cpp b/app/main.cpp index 3ff23d1..e65fa64 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -1,3 +1,5 @@ +#include +#include #include #include #include @@ -7,8 +9,22 @@ #include "commands.h" #include "errors.h" +namespace { + +// The one piece of mutable global state: a signal handler takes no arguments and +// has nowhere else to write, and it may touch nothing but a lock-free atomic +std::atomic interrupted{false}; +static_assert(decltype(interrupted)::is_always_lock_free, + "a signal handler may only touch a lock-free atomic"); + +extern "C" void OnInterrupt(int) { + interrupted.store(true, std::memory_order_relaxed); +} + +} // namespace + int main(int argc, char **argv) { - Context context{std::cout, std::cerr, std::cin}; + Context context{std::cout, std::cerr, std::cin, interrupted}; CLI::App app{ "A command line tool to create, add, remove, list, extract, read, rename, and verify MPQ " @@ -32,6 +48,11 @@ int main(int argc, char **argv) { RegisterCompact(app, context); RegisterCompletion(app, context); + // Windows never delivers SIGTERM; registering it there is harmless and keeps + // one code path + std::signal(SIGINT, OnInterrupt); + std::signal(SIGTERM, OnInterrupt); + try { app.parse(argc, argv); } catch (const CLI::ParseError &e) { @@ -42,6 +63,12 @@ int main(int argc, char **argv) { return 0; } return app.exit(e, context.out, context.err); + } catch (const mpqcli::Interrupted &) { + // The user asked for this, so nothing is reported. Dying of the signal rather + // than returning a code is what lets the shell treat it as an interrupt + std::signal(SIGINT, SIG_DFL); + std::raise(SIGINT); + return 1; // Not reached } catch (const mpqcli::Error &e) { context.err << "[!] " << e.what() << std::endl; return 1; diff --git a/app/remove.cpp b/app/remove.cpp index cb5a2fa..7e3a40a 100644 --- a/app/remove.cpp +++ b/app/remove.cpp @@ -23,6 +23,6 @@ void RegisterRemove(CLI::App &app, Context &context) { sub->callback([options, &context]() { options->files = mpqcli::ExpandStdinMarker(options->files, context.in); - context.exit_code = mpqcli::Remove(*options, context.err) ? 0 : 1; + context.exit_code = mpqcli::Remove(*options, context.err, context.cancelled) ? 0 : 1; }); } diff --git a/docs/changelog.md b/docs/changelog.md index 2c5b91f..9f489b2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ ## 0.12.0 - 2026-09-25 +### Added + +- Stop cleanly on Ctrl-C, leaving no partial archive behind + ### Changed - Rename the release assets to mpqcli-linux-x86_64, mpqcli-linux-aarch64 and mpqcli-windows-x86_64.exe diff --git a/src/commands/add.cpp b/src/commands/add.cpp index 0885211..b37985a 100644 --- a/src/commands/add.cpp +++ b/src/commands/add.cpp @@ -1,5 +1,6 @@ #include "commands/add.h" +#include #include #include #include @@ -8,6 +9,7 @@ #include +#include "errors.h" #include "gamerules/rules.h" #include "mpq/add.h" #include "mpq/archive.h" @@ -18,7 +20,7 @@ namespace fs = std::filesystem; namespace mpqcli { -bool Add(const AddOptions &options, std::ostream &err) { +bool Add(const AddOptions &options, std::ostream &err, const std::atomic &cancelled) { Archive archive = Archive::Open(options.archive, 0); LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; @@ -44,6 +46,8 @@ bool Add(const AddOptions &options, std::ostream &err) { int result = 0; int files_skipped = 0; for (const auto &f : options.files) { + ThrowIfCancelled(cancelled); + if (!fs::exists(f, ec)) { err << "[!] Path does not exist: " << f << std::endl; result |= 1; @@ -60,8 +64,8 @@ bool Add(const AddOptions &options, std::ostream &err) { } std::string prefix = options.path.value_or(""); result |= AddFiles(archive.Handle(), directory_files, f, prefix, lcid, game_rules, err, - options.compression_overrides, options.overwrite, options.update, - &files_skipped); + cancelled, options.compression_overrides, options.overwrite, + options.update, &files_skipped); } else if (fs::is_regular_file(f, ec)) { const bool treat_as_directory = has_directory || options.files.size() > 1; diff --git a/src/commands/add.h b/src/commands/add.h index 9169fde..2ed7981 100644 --- a/src/commands/add.h +++ b/src/commands/add.h @@ -1,6 +1,7 @@ #ifndef COMMANDS_ADD_H #define COMMANDS_ADD_H +#include #include #include #include @@ -23,7 +24,7 @@ struct AddOptions { }; /// Adds files and directories to an archive, returning false if any of them failed -bool Add(const AddOptions &options, std::ostream &err); +bool Add(const AddOptions &options, std::ostream &err, const std::atomic &cancelled); } // namespace mpqcli diff --git a/src/commands/create.cpp b/src/commands/create.cpp index bf922ea..6284468 100644 --- a/src/commands/create.cpp +++ b/src/commands/create.cpp @@ -1,5 +1,6 @@ #include "commands/create.h" +#include #include #include #include @@ -9,6 +10,7 @@ #include +#include "errors.h" #include "gamerules/rules.h" #include "mpq/add.h" #include "mpq/archive.h" @@ -20,7 +22,24 @@ namespace fs = std::filesystem; namespace mpqcli { -bool Create(const CreateOptions &options, std::ostream &err) { +namespace { + +/// Removes the archive being written on the way out unless it was moved into place +struct PartialArchive { + fs::path path; + bool keep = false; + + ~PartialArchive() { + if (!keep) { + std::error_code ec; + fs::remove(path, ec); + } + } +}; + +} // namespace + +bool Create(const CreateOptions &options, std::ostream &err, const std::atomic &cancelled) { std::error_code ec; fs::path output_file_path; if (options.output.has_value()) { @@ -40,6 +59,9 @@ bool Create(const CreateOptions &options, std::ostream &err) { output_file_path.replace_extension(".mpq"); } std::string output_file = output_file_path.u8string(); + if (fs::exists(output_file_path, ec)) { + throw ArchiveError("File already exists: " + output_file + " Exiting..."); + } GameProfile profile; if (options.game_profile.has_value()) { @@ -71,15 +93,23 @@ bool Create(const CreateOptions &options, std::ostream &err) { const uint32_t file_count = CalculateMpqMaxFileValue(is_directory ? static_cast(files.size()) : 1); - // Create the MPQ archive and add files - Archive archive = Archive::Create(output_file, file_count, game_rules); + // The archive is invalid until it is closed, so it is written beside the output + // and renamed into place once complete; an interrupt or error before that leaves + // nothing at the output path. A partial left by an earlier interrupted run would + // otherwise be refused as an existing file + fs::path partial_path = output_file_path; + partial_path += ".partial"; + fs::remove(partial_path, ec); + PartialArchive partial{partial_path}; + + Archive archive = Archive::Create(partial_path.u8string(), file_count, game_rules); LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; int result = 0; if (is_directory) { const std::string prefix = options.path.value_or(""); result |= AddFiles(archive.Handle(), files, options.target, prefix, lcid, game_rules, err, - options.compression_overrides); + cancelled, options.compression_overrides); } else { std::string archive_path = ResolveArchiveName(options.target, options.path); result |= AddFile(archive.Handle(), options.target, archive_path, lcid, game_rules, err, @@ -91,6 +121,14 @@ bool Create(const CreateOptions &options, std::ostream &err) { } archive.Close(); + // A failed rename must not delete the complete archive it was about to move + partial.keep = true; + fs::rename(partial_path, output_file_path, ec); + if (ec) { + throw ArchiveError("Failed to move " + partial_path.u8string() + " to " + output_file + + ": (" + std::to_string(ec.value()) + ") " + ec.message()); + } + return result == 0; } diff --git a/src/commands/create.h b/src/commands/create.h index d6841d0..91f174a 100644 --- a/src/commands/create.h +++ b/src/commands/create.h @@ -1,6 +1,7 @@ #ifndef COMMANDS_CREATE_H #define COMMANDS_CREATE_H +#include #include #include #include @@ -22,7 +23,7 @@ struct CreateOptions { }; /// Creates an archive from a file or directory, returning false if any file failed to add -bool Create(const CreateOptions &options, std::ostream &err); +bool Create(const CreateOptions &options, std::ostream &err, const std::atomic &cancelled); } // namespace mpqcli diff --git a/src/commands/extract.cpp b/src/commands/extract.cpp index 93038eb..543503c 100644 --- a/src/commands/extract.cpp +++ b/src/commands/extract.cpp @@ -1,5 +1,6 @@ #include "commands/extract.h" +#include #include #include #include @@ -15,7 +16,7 @@ namespace fs = std::filesystem; namespace mpqcli { -bool Extract(const ExtractOptions &options, std::ostream &err) { +bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic &cancelled) { // If no output directory specified, use MPQ path without extension // If output directory specified, create it if it doesn't exist std::error_code ec; @@ -63,7 +64,8 @@ bool Extract(const ExtractOptions &options, std::ostream &err) { result = ExtractFile(archive.Handle(), effective_output, options.file.value(), options.keep_folder_structure, lcid, err); } else { - result = ExtractFiles(archive.Handle(), effective_output, options.listfile, lcid, err); + result = ExtractFiles(archive.Handle(), effective_output, options.listfile, lcid, err, + cancelled); } archive.Close(); diff --git a/src/commands/extract.h b/src/commands/extract.h index 3b80ba3..295e695 100644 --- a/src/commands/extract.h +++ b/src/commands/extract.h @@ -1,6 +1,7 @@ #ifndef COMMANDS_EXTRACT_H #define COMMANDS_EXTRACT_H +#include #include #include #include @@ -18,7 +19,7 @@ struct ExtractOptions { }; /// Extracts one file or every file, returning false if any of them failed -bool Extract(const ExtractOptions &options, std::ostream &err); +bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic &cancelled); } // namespace mpqcli diff --git a/src/commands/remove.cpp b/src/commands/remove.cpp index fa99b34..0204567 100644 --- a/src/commands/remove.cpp +++ b/src/commands/remove.cpp @@ -1,24 +1,27 @@ #include "commands/remove.h" +#include #include #include #include #include +#include "errors.h" #include "mpq/archive.h" #include "mpq/remove.h" #include "util/locales.h" namespace mpqcli { -bool Remove(const RemoveOptions &options, std::ostream &err) { +bool Remove(const RemoveOptions &options, std::ostream &err, const std::atomic &cancelled) { Archive archive = Archive::Open(options.archive, 0); LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale; std::unordered_set seen; bool all_removed = true; for (const auto &f : options.files) { + ThrowIfCancelled(cancelled); if (!seen.insert(f).second) { continue; } diff --git a/src/commands/remove.h b/src/commands/remove.h index f29620e..485c4be 100644 --- a/src/commands/remove.h +++ b/src/commands/remove.h @@ -1,6 +1,7 @@ #ifndef COMMANDS_REMOVE_H #define COMMANDS_REMOVE_H +#include #include #include #include @@ -16,7 +17,7 @@ struct RemoveOptions { }; /// Removes files from an archive, returning false if any of them could not be removed -bool Remove(const RemoveOptions &options, std::ostream &err); +bool Remove(const RemoveOptions &options, std::ostream &err, const std::atomic &cancelled); } // namespace mpqcli diff --git a/src/errors.h b/src/errors.h index 7173c93..71ff96d 100644 --- a/src/errors.h +++ b/src/errors.h @@ -1,6 +1,7 @@ #ifndef ERRORS_H #define ERRORS_H +#include #include #include #include @@ -17,6 +18,19 @@ class Error : public std::runtime_error { explicit Error(const std::string &message) : std::runtime_error(message) {} }; +/// Thrown when the cancellation flag was set part-way through a command +class Interrupted : public Error { +public: + Interrupted() : Error("Interrupted") {} +}; + +/// Throws Interrupted once cancelled is set; polled once per item at the top of a long loop +inline void ThrowIfCancelled(const std::atomic &cancelled) { + if (cancelled.load(std::memory_order_relaxed)) { + throw Interrupted{}; + } +} + /// Base for failures reading or writing an archive class ArchiveError : public Error { public: diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index 55f7793..e794e24 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -1,6 +1,7 @@ #include "mpq/add.h" #include +#include #include #include #include @@ -11,6 +12,7 @@ #include +#include "errors.h" #include "gamerules/rules.h" #include "mpq/query.h" #include "util/capacity.h" @@ -24,13 +26,16 @@ namespace mpqcli { int AddFiles(HANDLE archive, const std::vector &files, const fs::path &base_path, const std::string &path_prefix, LCID locale, const GameRules &game_rules, - std::ostream &err, const CompressionSettingsOverrides &overrides, bool overwrite, - bool update, int *skipped) { + std::ostream &err, const std::atomic &cancelled, + const CompressionSettingsOverrides &overrides, bool overwrite, bool update, + int *skipped) { int files_added = 0; int files_skipped = 0; int files_failed = 0; for (const auto &file : files) { + ThrowIfCancelled(cancelled); + // Determine relative path lexically rather than with fs::relative, which // resolves paths through the OS and throws on volumes that cannot report // real paths (RAM disks, some network shares). diff --git a/src/mpq/add.h b/src/mpq/add.h index 397756b..2bd07af 100644 --- a/src/mpq/add.h +++ b/src/mpq/add.h @@ -1,6 +1,7 @@ #ifndef MPQ_ADD_H #define MPQ_ADD_H +#include #include #include #include @@ -15,7 +16,7 @@ namespace mpqcli { int AddFiles(HANDLE archive, const std::vector &files, const std::filesystem::path &base_path, const std::string &path_prefix, LCID locale, - const GameRules &game_rules, std::ostream &err, + const GameRules &game_rules, std::ostream &err, const std::atomic &cancelled, const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(), bool overwrite = false, bool update = false, int *skipped = nullptr); int AddFile(HANDLE archive, const std::filesystem::path &local_file, diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index 05f442b..a0c64e0 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -1,5 +1,6 @@ #include "mpq/extract.h" +#include #include #include #include @@ -9,6 +10,7 @@ #include +#include "errors.h" #include "mpq/query.h" #include "util/format.h" #include "util/locales.h" @@ -20,7 +22,7 @@ namespace mpqcli { int ExtractFiles(HANDLE archive, const std::string &output, const std::optional &listfile_name, LCID preferred_locale, - std::ostream &err) { + std::ostream &err, const std::atomic &cancelled) { SFileSetLocale(preferred_locale); const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; @@ -33,6 +35,12 @@ int ExtractFiles(HANDLE archive, const std::string &output, int32_t result = 0; do { + // Closed by hand rather than through ThrowIfCancelled, so the find handle + // does not leak on the way out + if (cancelled.load(std::memory_order_relaxed)) { + SFileFindClose(find_handle); + throw Interrupted{}; + } result |= ExtractFile(archive, output, find_data.cFileName, true, // Keep folder structure preferred_locale, err); diff --git a/src/mpq/extract.h b/src/mpq/extract.h index d59d8c9..1b414ba 100644 --- a/src/mpq/extract.h +++ b/src/mpq/extract.h @@ -1,6 +1,7 @@ #ifndef MPQ_EXTRACT_H #define MPQ_EXTRACT_H +#include #include #include #include @@ -11,7 +12,7 @@ namespace mpqcli { int ExtractFiles(HANDLE archive, const std::string &output, const std::optional &listfile_name, LCID preferred_locale, - std::ostream &err); + std::ostream &err, const std::atomic &cancelled); int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, bool keep_folder_structure, LCID preferred_locale, std::ostream &err); From b311075fc730229baa7044f54c6c0d2150f3505c Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sat, 26 Sep 2026 17:02:00 +1200 Subject: [PATCH 47/59] Moved the build trees under build, added check_embed and tidied comments, names and badges --- .dockerignore | 1 - .github/workflows/build.yml | 10 +++++----- .github/workflows/lint.yml | 9 +++------ .github/workflows/test.yml | 14 +++++++------- .gitignore | 2 -- CMakeLists.txt | 4 ++-- CONTRIBUTING.md | 14 +++++++------- Makefile | 33 ++++++++++++++++++++------------- README.md | 2 +- docs/building.md | 12 ++++++------ docs/contributing.md | 14 +++++++------- src/commands/create.cpp | 2 +- src/commands/extract.cpp | 2 -- src/commands/read.cpp | 3 ++- src/mpq/add.cpp | 4 ---- src/mpq/archive.cpp | 16 ++++++++-------- src/mpq/archive.h | 8 ++++---- src/mpq/extract.cpp | 2 -- src/mpq/read.cpp | 5 +++-- src/mpq/read.h | 5 +++-- src/mpq/verify.cpp | 3 +-- test/conftest.py | 4 ++-- 22 files changed, 82 insertions(+), 87 deletions(-) diff --git a/.dockerignore b/.dockerignore index d7a31ac..ea1808c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ # Local build, docs and test output: large, stale and never read by the image build build/ -build-lint/ book/ .venv/ test/data/ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8020cb8..c682236 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,24 +84,24 @@ jobs: - name: Run cmake shell: bash run: | - cmake -B build \ + cmake -B build/dev \ -G "Visual Studio 17 2022" \ -A x64 \ -DMPQCLI_BUILD_APP=ON - name: Compile project - run: cmake --build build --config Release + run: cmake --build build/dev --config Release - name: Check mpqcli version - run: build/bin/mpqcli.exe version + run: build/dev/bin/mpqcli.exe version - name: Copy mpqcli binary - run: cp build/bin/mpqcli.exe build/bin/mpqcli-windows-x86_64.exe + run: cp build/dev/bin/mpqcli.exe build/dev/bin/mpqcli-windows-x86_64.exe - name: Upload binary as artefact uses: actions/upload-artifact@v7 with: name: mpqcli-windows-x86_64.exe - path: build/bin/mpqcli-windows-x86_64.exe + path: build/dev/bin/mpqcli-windows-x86_64.exe retention-days: 1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 71aacff..e6b059e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,10 +15,7 @@ jobs: with: submodules: true - - run: make install_clang_tools + - name: Install clang tools + run: sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 - - run: make configure_lint - - - run: make check_format - - - run: make check_lint + - run: make check_all diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 243a825..c914673 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,16 +32,16 @@ jobs: uses: actions/download-artifact@v8 with: name: ${{ matrix.asset }} - path: build/bin + path: build/dev/bin - name: Rename binary to "mpqcli" - run: mv build/bin/${{ matrix.asset }} build/bin/mpqcli + run: mv build/dev/bin/${{ matrix.asset }} build/dev/bin/mpqcli - name: Make binary executable - run: chmod +x ./build/bin/mpqcli + run: chmod +x ./build/dev/bin/mpqcli - name: Check mpqcli version (${{ matrix.arch }}) - run: ./build/bin/mpqcli version + run: ./build/dev/bin/mpqcli version - name: Setup python uses: actions/setup-python@v6 @@ -70,13 +70,13 @@ jobs: uses: actions/download-artifact@v8 with: name: mpqcli-windows-x86_64.exe - path: build/bin + path: build/dev/bin - name: Rename binary to "mpqcli" - run: mv build/bin/mpqcli-windows-x86_64.exe build/bin/mpqcli.exe + run: mv build/dev/bin/mpqcli-windows-x86_64.exe build/dev/bin/mpqcli.exe - name: Check mpqcli version - run: ./build/bin/mpqcli.exe version + run: ./build/dev/bin/mpqcli.exe version - name: Setup python uses: actions/setup-python@v6 diff --git a/.gitignore b/.gitignore index 7d2fda3..319317a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # CUSTOM /book -/build_lint /mermaid*.js /test/data @@ -52,7 +51,6 @@ __pycache__/ # Distribution / packaging .Python build/ -build-lint/ develop-eggs/ dist/ downloads/ diff --git a/CMakeLists.txt b/CMakeLists.txt index d665767..3d79934 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,7 +16,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) if(NOT CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE Release) + set(CMAKE_BUILD_TYPE Debug) endif() set(CMAKE_POSITION_INDEPENDENT_CODE ON) @@ -69,7 +69,7 @@ endif() # Use StormLib's bundled zlib/bzip2/libtommath sources instead of requiring # system packages. This keeps mpqcli buildable everywhere (musl/glibc Docker -# images, MSVC, plain `cmake -B build`) without extra system dependencies. +# images, MSVC, plain `cmake -B build/dev`) without extra system dependencies. set(STORM_USE_BUNDLED_LIBRARIES ON CACHE BOOL "" FORCE) add_subdirectory(extern/StormLib) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e140b9..2e4353f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,10 +18,10 @@ cd mpqcli git submodule update --init --recursive ``` -Install the clang lint tools: +Install the clang lint tools, pinned to the major version the checks are formatted against: ``` -make install_clang_tools +sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 ``` ## Makefile Reference @@ -30,17 +30,17 @@ Run `make help` to list all available targets. Common ones: | Target | Description | |----------------------------|----------------------------------------------------------------------| -| `make install_clang_tools` | Install clang, clang-format and clang-tidy via apt | | `make configure` | Configure the cmake build (uses the default compiler) | | `make build_linux` | Build for Linux using cmake | | `make build_windows` | Build for Windows using cmake | | `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | | `make test_mpqcli` | Run the pytest test suite | -| `make check_all` | Run every static check (clang-format + clang-tidy) | +| `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | | `make check_format` | Check formatting only (dry run) | | `make format` | Auto-fix formatting in-place | -| `make configure_lint` | Configure build-lint/ with clang++ for clang-tidy | +| `make configure_lint` | Configure build/lint with clang++ for clang-tidy | | `make check_lint` | Run clang-tidy static analysis | +| `make check_embed` | Syntax-check the embedded completion scripts | | `make clean` | Remove all build, test and docs artefacts | ## Requirements for a Pull Request @@ -73,7 +73,7 @@ If your change adds or modifies user-facing functionality - such as a new subcom ### 4. Linting must pass -All C++ code is formatted with clang-format and analysed with clang-tidy. `make check_all` runs both checks. It configures a clang build tree of its own for clang-tidy, so it needs no prior `make configure`: +All C++ code is formatted with clang-format and analysed with clang-tidy, and the completion scripts are parsed by their shells. `make check_all` runs all three checks. It configures a clang build tree of its own for clang-tidy, so it needs no prior `make configure`: ``` make check_all @@ -131,7 +131,7 @@ If you add a new StormLib call that is locale-sensitive, follow the existing pat 1. Fork the repository and create a branch for your change 2. Run `git submodule update --init --recursive` after cloning -3. Run `make install_clang_tools` to install lint dependencies +3. Install the clang tools as shown above 4. Make your changes and verify they build: `make build_linux` 5. Run `make check_all`, fixing any issues 6. Run `make test_mpqcli` and confirm all tests pass diff --git a/Makefile b/Makefile index 796aafa..92f72c2 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ CLANG_TIDY ?= $(shell command -v clang-tidy-$(CLANG_VERSION) 2>/dev/null || ec # clang-tidy resolves headers through the compiler that produced # compile_commands.json. Pointed at a GCC build it cannot find libstdc++ and # emits confident diagnostics from a broken AST, so it gets its own tree. -LINT_DIR ?= build-lint +LINT_DIR ?= build/lint GCC_INSTALL_DIR := $(shell dirname "$(shell gcc -print-libgcc-file-name)") VERSION := $(shell awk '/project\(MPQCLI VERSION/ {gsub(/\)/, "", $$3); print $$3}' CMakeLists.txt) @@ -33,34 +33,30 @@ help: ## Show this help message /^[a-zA-Z_-]+:.*## / {printf " %-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) ##@ BUILD -.PHONY: install_clang_tools -install_clang_tools: ## Install clang lint dependencies - sudo apt-get install -y clang-$(CLANG_VERSION) clang-format-$(CLANG_VERSION) clang-tidy-$(CLANG_VERSION) - .PHONY: configure configure: ## Configure cmake build (debug, with compile_commands.json) - cmake -B build \ + cmake -B build/dev \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) .PHONY: build build: ## Build via cmake - cmake --build build --parallel $(JOBS) + cmake --build build/dev --parallel $(JOBS) .PHONY: build_linux build_linux: ## Build for Linux using cmake - cmake -B build \ + cmake -B build/dev \ -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) - cmake --build build --parallel $(JOBS) + cmake --build build/dev --parallel $(JOBS) .PHONY: build_windows build_windows: ## Build for Windows using cmake - cmake -B build \ + cmake -B build/dev \ -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) - cmake --build build --config $(CMAKE_BUILD_TYPE) --parallel $(JOBS) + cmake --build build/dev --config $(CMAKE_BUILD_TYPE) --parallel $(JOBS) ##@ DOCKER .PHONY: docker_build @@ -138,8 +134,19 @@ check_lint: configure_lint ## Run clang-tidy static analysis | grep -v " warnings generated"; \ exit $${PIPESTATUS[0]} +.PHONY: check_embed +check_embed: ## Syntax-check the embedded completion scripts with every shell present + bash -n completion/mpqcli.bash + @if command -v zsh >/dev/null; then zsh -n completion/mpqcli.zsh; \ + else echo "[*] zsh not installed, skipped"; fi + @if command -v fish >/dev/null; then fish --no-execute completion/mpqcli.fish; \ + else echo "[*] fish not installed, skipped"; fi + @if command -v pwsh >/dev/null; then pwsh -NoProfile -Command \ + '[scriptblock]::Create((Get-Content -Raw completion/mpqcli.ps1)) | Out-Null'; \ + else echo "[*] pwsh not installed, skipped"; fi + .PHONY: check_all -check_all: check_format check_lint ## Run every static check +check_all: check_format check_lint check_embed ## Run every static check .PHONY: ci ci: configure build check_all test ## Run all CI checks locally @@ -147,7 +154,7 @@ ci: configure build check_all test ## Run all CI checks locally ##@ CLEAN .PHONY: clean clean: test_clean docs_clean ## Remove all build, test, docs and release artefacts - rm -rf build $(LINT_DIR) dist install.sh install.ps1 checksums.txt checksums.txt.sigstore.json + rm -rf build dist install.sh install.ps1 checksums.txt checksums.txt.sigstore.json ##@ GENERATE # The docs site builds from the committed copy, so this is run deliberately and the diff --git a/README.md b/README.md index 0b50899..8e7b4da 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # mpqcli -![Build Status](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/tag.yml?style=flat) ![Test Status](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/tag.yml?style=flat&label=test) +![Release Build](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/tag.yml?style=flat&label=release&logo=github) ![Main Build](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/main.yml?style=flat&label=main&logo=github) ![Release Version](https://img.shields.io/github/v/release/thegraydot/mpqcli?style=flat) diff --git a/docs/building.md b/docs/building.md index 4495b7c..5f913ab 100644 --- a/docs/building.md +++ b/docs/building.md @@ -13,22 +13,22 @@ ```bash $ git clone --recursive https://github.com/thegraydot/mpqcli.git $ cd mpqcli -$ cmake -B build -$ cmake --build build +$ cmake -B build/release -DCMAKE_BUILD_TYPE=Release +$ cmake --build build/release ``` -The `mpqcli` binary will be available in: `./build/bin/mpqcli` +The `mpqcli` binary will be available in: `./build/release/bin/mpqcli` ## Windows ```bash $ git clone --recursive https://github.com/thegraydot/mpqcli.git $ cd mpqcli -$ cmake -B build -$ cmake --build build --config Release +$ cmake -B build/release +$ cmake --build build/release --config Release ``` -The `mpqcli.exe` binary will be available in: `.\build\bin\mpqcli.exe` +The `mpqcli.exe` binary will be available in: `.\build\release\bin\mpqcli.exe` ## Dependencies diff --git a/docs/contributing.md b/docs/contributing.md index 7e140b9..2e4353f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -18,10 +18,10 @@ cd mpqcli git submodule update --init --recursive ``` -Install the clang lint tools: +Install the clang lint tools, pinned to the major version the checks are formatted against: ``` -make install_clang_tools +sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 ``` ## Makefile Reference @@ -30,17 +30,17 @@ Run `make help` to list all available targets. Common ones: | Target | Description | |----------------------------|----------------------------------------------------------------------| -| `make install_clang_tools` | Install clang, clang-format and clang-tidy via apt | | `make configure` | Configure the cmake build (uses the default compiler) | | `make build_linux` | Build for Linux using cmake | | `make build_windows` | Build for Windows using cmake | | `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | | `make test_mpqcli` | Run the pytest test suite | -| `make check_all` | Run every static check (clang-format + clang-tidy) | +| `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | | `make check_format` | Check formatting only (dry run) | | `make format` | Auto-fix formatting in-place | -| `make configure_lint` | Configure build-lint/ with clang++ for clang-tidy | +| `make configure_lint` | Configure build/lint with clang++ for clang-tidy | | `make check_lint` | Run clang-tidy static analysis | +| `make check_embed` | Syntax-check the embedded completion scripts | | `make clean` | Remove all build, test and docs artefacts | ## Requirements for a Pull Request @@ -73,7 +73,7 @@ If your change adds or modifies user-facing functionality - such as a new subcom ### 4. Linting must pass -All C++ code is formatted with clang-format and analysed with clang-tidy. `make check_all` runs both checks. It configures a clang build tree of its own for clang-tidy, so it needs no prior `make configure`: +All C++ code is formatted with clang-format and analysed with clang-tidy, and the completion scripts are parsed by their shells. `make check_all` runs all three checks. It configures a clang build tree of its own for clang-tidy, so it needs no prior `make configure`: ``` make check_all @@ -131,7 +131,7 @@ If you add a new StormLib call that is locale-sensitive, follow the existing pat 1. Fork the repository and create a branch for your change 2. Run `git submodule update --init --recursive` after cloning -3. Run `make install_clang_tools` to install lint dependencies +3. Install the clang tools as shown above 4. Make your changes and verify they build: `make build_linux` 5. Run `make check_all`, fixing any issues 6. Run `make test_mpqcli` and confirm all tests pass diff --git a/src/commands/create.cpp b/src/commands/create.cpp index 6284468..d5f18d0 100644 --- a/src/commands/create.cpp +++ b/src/commands/create.cpp @@ -102,7 +102,7 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic &cancelled) { - // If no output directory specified, use MPQ path without extension - // If output directory specified, create it if it doesn't exist std::error_code ec; std::string effective_output; if (!options.output.has_value()) { diff --git a/src/commands/read.cpp b/src/commands/read.cpp index 211556a..33c7c3c 100644 --- a/src/commands/read.cpp +++ b/src/commands/read.cpp @@ -22,7 +22,8 @@ bool Read(const ReadOptions &options, std::ostream &out, std::ostream &err) { } uint32_t file_size; - auto file_content = ReadFile(archive.Handle(), options.file.c_str(), &file_size, lcid, err); + auto file_content = + ReadArchivedFile(archive.Handle(), options.file.c_str(), &file_size, lcid, err); if (!file_content) { archive.Close(); return false; diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index e794e24..ceb0e25 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -82,7 +82,6 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi const LCID locale, const GameRules &game_rules, std::ostream &err, const CompressionSettingsOverrides &overrides, bool overwrite, bool update, int *skipped) { - // Return if file doesn't exist on disk std::error_code ec; if (!fs::exists(local_file, ec)) { err << "[!] File doesn't exist on disk: " << local_file << std::endl; @@ -146,7 +145,6 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi } } - // Get file size for rule matching const std::uintmax_t raw_file_size = fs::file_size(local_file, ec); if (ec) { err << "[!] Failed to read file size: (" << ec.value() << ") " << ec.message() << ": " @@ -161,10 +159,8 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi const DWORD file_size = static_cast( std::min(raw_file_size, static_cast(std::numeric_limits::max()))); - // Get game-specific rules const auto settings = game_rules.GetCompressionSettings(archive_file_path, file_size); - // Apply overrides where specified, otherwise use game rules DWORD flags = overrides.flags.value_or(settings.mpq_flags); DWORD compression = overrides.compression.value_or(settings.compression_first); DWORD compression_next = overrides.compression_next.value_or(settings.compression_next); diff --git a/src/mpq/archive.cpp b/src/mpq/archive.cpp index a83c98f..c8031da 100644 --- a/src/mpq/archive.cpp +++ b/src/mpq/archive.cpp @@ -15,19 +15,19 @@ namespace fs = std::filesystem; namespace mpqcli { -Archive Archive::Open(const std::string &filename, const DWORD flags) { +Archive Archive::Open(const fs::path &path, const DWORD flags) { HANDLE handle = nullptr; - if (!SFileOpenArchive(filename.c_str(), 0, flags, &handle)) { - throw ArchiveOpenError(filename, SErrGetLastError()); + if (!SFileOpenArchive(path.string().c_str(), 0, flags, &handle)) { + throw ArchiveOpenError(path, SErrGetLastError()); } return Archive(handle); } -Archive Archive::Create(const std::string &filename, const uint32_t file_count, +Archive Archive::Create(const fs::path &path, const uint32_t file_count, const GameRules &game_rules) { std::error_code ec; - if (fs::exists(filename, ec)) { - throw ArchiveError("File already exists: " + filename + " Exiting..."); + if (fs::exists(path, ec)) { + throw ArchiveError("File already exists: " + path.string() + " Exiting..."); } const MpqCreateSettings &settings = game_rules.GetCreateSettings(); @@ -45,8 +45,8 @@ Archive Archive::Create(const std::string &filename, const uint32_t file_count, create_info.dwMaxFileCount = file_count; HANDLE handle = nullptr; - if (!SFileCreateArchive2(filename.c_str(), &create_info, &handle)) { - throw ArchiveCreateError(filename, SErrGetLastError()); + if (!SFileCreateArchive2(path.string().c_str(), &create_info, &handle)) { + throw ArchiveCreateError(path, SErrGetLastError()); } return Archive(handle); } diff --git a/src/mpq/archive.h b/src/mpq/archive.h index 9707534..ff5d8a7 100644 --- a/src/mpq/archive.h +++ b/src/mpq/archive.h @@ -2,7 +2,7 @@ #define MPQ_ARCHIVE_H #include -#include +#include #include @@ -16,13 +16,13 @@ class Archive { /// Opens an existing archive with StormLib's MPQ_OPEN_* flags /// /// @throws ArchiveOpenError if StormLib cannot open it - static Archive Open(const std::string &filename, DWORD flags); + static Archive Open(const std::filesystem::path &path, DWORD flags); /// Creates an archive sized for file_count files with the profile's settings /// - /// @throws ArchiveError if a file already exists at filename + /// @throws ArchiveError if a file already exists at path /// @throws ArchiveCreateError if StormLib cannot create it - static Archive Create(const std::string &filename, uint32_t file_count, + static Archive Create(const std::filesystem::path &path, uint32_t file_count, const GameRules &game_rules); Archive(const Archive &) = delete; diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index a0c64e0..e51ed13 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -61,11 +61,9 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi return 1; } - // Change forward slashes on non-Windows systems fs::path file_name_path(file_name); std::string file_name_string = NormalizeFilePath(file_name_path); - // Remove folder structure if keepFolderStructure is false if (!keep_folder_structure) { file_name_path = fs::path(file_name_string); file_name_string = file_name_path.filename().u8string(); diff --git a/src/mpq/read.cpp b/src/mpq/read.cpp index ee3af7e..f738029 100644 --- a/src/mpq/read.cpp +++ b/src/mpq/read.cpp @@ -11,8 +11,9 @@ namespace mpqcli { -std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, - LCID preferred_locale, std::ostream &err) { +std::unique_ptr ReadArchivedFile(HANDLE archive, const char *file_name, + unsigned int *file_size, LCID preferred_locale, + std::ostream &err) { SFileSetLocale(preferred_locale); if (!FileExistsInArchiveForLocale(archive, file_name, preferred_locale) && !FileExistsInArchiveForLocale(archive, file_name, default_locale)) { diff --git a/src/mpq/read.h b/src/mpq/read.h index 69f3d04..71a08f2 100644 --- a/src/mpq/read.h +++ b/src/mpq/read.h @@ -8,8 +8,9 @@ namespace mpqcli { -std::unique_ptr ReadFile(HANDLE archive, const char *file_name, unsigned int *file_size, - LCID preferred_locale, std::ostream &err); +std::unique_ptr ReadArchivedFile(HANDLE archive, const char *file_name, + unsigned int *file_size, LCID preferred_locale, + std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp index a9e40e8..6340203 100644 --- a/src/mpq/verify.cpp +++ b/src/mpq/verify.cpp @@ -28,7 +28,6 @@ uint32_t VerifyMpqArchive(HANDLE archive) { void PrintMpqSignature(HANDLE archive, const std::string &target, std::ostream &out, std::ostream &err) { - // Determine if we have a strong or weak digital signature int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); std::vector signature_content; @@ -38,7 +37,7 @@ void PrintMpqSignature(HANDLE archive, const std::string &target, std::ostream & } else if (signature_type == SIGNATURE_TYPE_WEAK) { const char *file_name = "(signature)"; uint32_t file_size; - auto file_content = ReadFile(archive, file_name, &file_size, default_locale, err); + auto file_content = ReadArchivedFile(archive, file_name, &file_size, default_locale, err); if (!file_content) { throw ArchiveError("Failed to read weak signature file."); diff --git a/test/conftest.py b/test/conftest.py index ed62f8e..d4efca6 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -14,9 +14,9 @@ def binary_path(): script_dir = Path(__file__).parent if platform.system() == "Windows": - binary = script_dir.parent / "build" / "bin" / "mpqcli.exe" + binary = script_dir.parent / "build" / "dev" / "bin" / "mpqcli.exe" else: - binary = script_dir.parent / "build" / "bin" / "mpqcli" + binary = script_dir.parent / "build" / "dev" / "bin" / "mpqcli" if not binary.exists(): pytest.fail(f"Binary not found at {binary}") From bf7a15fd824e27369ef55d7078d63405dfc8734f Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 27 Sep 2026 19:12:00 +1300 Subject: [PATCH 48/59] Added FileError, carried paths as filesystem paths through the core and corrected the add and verify output --- app/add.cpp | 29 +++++++++++++++++++---------- app/compact.cpp | 4 +--- app/info.cpp | 3 +-- app/list.cpp | 4 +--- src/commands/add.cpp | 6 +++--- src/commands/add.h | 5 +++-- src/commands/compact.cpp | 3 +-- src/commands/compact.h | 8 ++++---- src/commands/create.cpp | 22 ++++++++-------------- src/commands/create.h | 5 +++-- src/commands/extract.cpp | 16 ++++++---------- src/commands/extract.h | 7 ++++--- src/commands/info.cpp | 3 +-- src/commands/info.h | 5 +++-- src/commands/list.cpp | 3 +-- src/commands/list.h | 7 ++++--- src/commands/read.h | 3 ++- src/commands/remove.h | 3 ++- src/commands/rename.h | 3 ++- src/commands/verify.cpp | 5 +---- src/commands/verify.h | 4 ++-- src/errors.h | 22 ++++++++++++++++++++++ src/mpq/add.cpp | 8 ++++---- src/mpq/archive.cpp | 2 +- src/mpq/compact.cpp | 7 +++++-- src/mpq/compact.h | 4 ++-- src/mpq/extract.cpp | 18 ++++++++++-------- src/mpq/extract.h | 7 ++++--- src/mpq/list.cpp | 11 +++++++---- src/mpq/list.h | 7 ++++--- src/mpq/verify.cpp | 21 ++++++++------------- src/mpq/verify.h | 5 +++-- src/util/path.cpp | 7 +++---- src/util/path.h | 3 ++- test/test_add.py | 18 +++++++++--------- 35 files changed, 156 insertions(+), 132 deletions(-) diff --git a/app/add.cpp b/app/add.cpp index 7b157a6..54dabde 100644 --- a/app/add.cpp +++ b/app/add.cpp @@ -1,6 +1,8 @@ #include "commands/add.h" #include +#include +#include #include @@ -10,31 +12,37 @@ #include "validators.h" void RegisterAdd(CLI::App &app, Context &context) { - auto options = std::make_shared(); + struct Options { + mpqcli::AddOptions add; + // Local paths, or - for the lines on stdin, settled into add.files by the callback + std::vector files; + }; + + auto options = std::make_shared(); auto *sub = app.add_subcommand("add", "Add files to an existing MPQ archive"); - sub->add_option("archive", options->archive, "Target MPQ archive") + sub->add_option("archive", options->add.archive, "Target MPQ archive") ->required() ->check(CLI::ExistingFile); sub->add_option("files", options->files, "Files or directories to add; pass - to read paths from stdin") ->required() ->expected(-1); - sub->add_option("-p,--path", options->path, + sub->add_option("-p,--path", options->add.path, "Archive path for a single file, or prefix for a directory"); - CLI::Option *overwrite = sub->add_flag("-w,--overwrite", options->overwrite, + CLI::Option *overwrite = sub->add_flag("-w,--overwrite", options->add.overwrite, "Replace every file that already exists in the archive"); - sub->add_flag("-u,--update", options->update, + sub->add_flag("-u,--update", options->add.update, "Replace only files that changed. Compares size, then timestamp/MD5/CRC32") ->excludes(overwrite); - sub->add_option("--locale", options->locale, "Locale to use for added file") + sub->add_option("--locale", options->add.locale, "Locale to use for added file") ->check(locale_valid); - sub->add_option("-g,--game", options->game_profile, + sub->add_option("-g,--game", options->add.game_profile, "Game profile for compression rules. Valid options:\n" + mpqcli::GameRules::GetAvailableProfiles()) ->check(game_profile_valid); - mpqcli::CompressionSettingsOverrides &compression = options->compression_overrides; + mpqcli::CompressionSettingsOverrides &compression = options->add.compression_overrides; sub->add_option("--flags", compression.flags, "Override MPQ file flags") ->group("Game setting overrides"); sub->add_option("--compression", compression.compression, @@ -45,7 +53,8 @@ void RegisterAdd(CLI::App &app, Context &context) { ->group("Game setting overrides"); sub->callback([options, &context]() { - options->files = mpqcli::ExpandStdinMarker(options->files, context.in); - context.exit_code = mpqcli::Add(*options, context.err, context.cancelled) ? 0 : 1; + const auto files = mpqcli::ExpandStdinMarker(options->files, context.in); + options->add.files.assign(files.begin(), files.end()); + context.exit_code = mpqcli::Add(options->add, context.err, context.cancelled) ? 0 : 1; }); } diff --git a/app/compact.cpp b/app/compact.cpp index 5af718b..6f516ec 100644 --- a/app/compact.cpp +++ b/app/compact.cpp @@ -16,7 +16,5 @@ void RegisterCompact(CLI::App &app, Context &context) { sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive") ->check(CLI::ExistingFile); - sub->callback([options, &context]() { - context.exit_code = mpqcli::Compact(*options, context.err) ? 0 : 1; - }); + sub->callback([options, &context]() { mpqcli::Compact(*options, context.err); }); } diff --git a/app/info.cpp b/app/info.cpp index 8932618..9b014c1 100644 --- a/app/info.cpp +++ b/app/info.cpp @@ -27,6 +27,5 @@ void RegisterInfo(CLI::App &app, Context &context) { sub->add_option("-p,--property", options->property, "Prints only a specific property value") ->check(CLI::IsMember(valid_properties)); - sub->callback( - [options, &context]() { context.exit_code = mpqcli::Info(*options, context.out) ? 0 : 1; }); + sub->callback([options, &context]() { mpqcli::Info(*options, context.out); }); } diff --git a/app/list.cpp b/app/list.cpp index 9ef4b43..ad2e2f1 100644 --- a/app/list.cpp +++ b/app/list.cpp @@ -33,7 +33,5 @@ void RegisterList(CLI::App &app, Context &context) { sub->add_option("-p,--property", options->properties, "Prints only specific property values") ->check(CLI::IsMember(valid_properties)); - sub->callback([options, &context]() { - context.exit_code = mpqcli::List(*options, context.out, context.err) ? 0 : 1; - }); + sub->callback([options, &context]() { mpqcli::List(*options, context.out, context.err); }); } diff --git a/src/commands/add.cpp b/src/commands/add.cpp index b37985a..3a89e98 100644 --- a/src/commands/add.cpp +++ b/src/commands/add.cpp @@ -49,7 +49,7 @@ bool Add(const AddOptions &options, std::ostream &err, const std::atomic & ThrowIfCancelled(cancelled); if (!fs::exists(f, ec)) { - err << "[!] Path does not exist: " << f << std::endl; + err << "[!] Path does not exist: " << f.string() << std::endl; result |= 1; continue; } @@ -58,7 +58,7 @@ bool Add(const AddOptions &options, std::ostream &err, const std::atomic & std::vector directory_files = ListFilesRecursive(f, ec); if (ec) { err << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message() - << ": " << f << std::endl; + << ": " << f.string() << std::endl; result |= 1; continue; } @@ -75,7 +75,7 @@ bool Add(const AddOptions &options, std::ostream &err, const std::atomic & &files_skipped); } else { - err << "[!] Not a file or directory: " << f << std::endl; + err << "[!] Not a file or directory: " << f.string() << std::endl; result |= 1; } } diff --git a/src/commands/add.h b/src/commands/add.h index 2ed7981..3d337b1 100644 --- a/src/commands/add.h +++ b/src/commands/add.h @@ -2,6 +2,7 @@ #define COMMANDS_ADD_H #include +#include #include #include #include @@ -13,8 +14,8 @@ namespace mpqcli { /// Settled arguments for the add subcommand struct AddOptions { - std::string archive; - std::vector files; + std::filesystem::path archive; + std::vector files; std::optional path; bool overwrite = false; bool update = false; diff --git a/src/commands/compact.cpp b/src/commands/compact.cpp index b707237..21a15a0 100644 --- a/src/commands/compact.cpp +++ b/src/commands/compact.cpp @@ -7,11 +7,10 @@ namespace mpqcli { -bool Compact(const CompactOptions &options, std::ostream &err) { +void Compact(const CompactOptions &options, std::ostream &err) { Archive archive = Archive::Open(options.target, 0); CompactMpqArchive(archive.Handle(), options.listfile, err); archive.Close(); - return true; } } // namespace mpqcli diff --git a/src/commands/compact.h b/src/commands/compact.h index c176a90..69d3116 100644 --- a/src/commands/compact.h +++ b/src/commands/compact.h @@ -1,20 +1,20 @@ #ifndef COMMANDS_COMPACT_H #define COMMANDS_COMPACT_H +#include #include #include -#include namespace mpqcli { /// Settled arguments for the compact subcommand struct CompactOptions { - std::string target; - std::optional listfile; + std::filesystem::path target; + std::optional listfile; }; /// Compacts the archive -bool Compact(const CompactOptions &options, std::ostream &err); +void Compact(const CompactOptions &options, std::ostream &err); } // namespace mpqcli diff --git a/src/commands/create.cpp b/src/commands/create.cpp index d5f18d0..41d2338 100644 --- a/src/commands/create.cpp +++ b/src/commands/create.cpp @@ -45,12 +45,10 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic(files.size()) : 1); @@ -125,8 +119,8 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic +#include #include #include #include @@ -12,9 +13,9 @@ namespace mpqcli { /// Settled arguments for the create subcommand struct CreateOptions { - std::string target; + std::filesystem::path target; std::optional path; - std::optional output; + std::optional output; bool sign = false; std::optional locale; std::optional game_profile; diff --git a/src/commands/extract.cpp b/src/commands/extract.cpp index fd54db4..fde7bb9 100644 --- a/src/commands/extract.cpp +++ b/src/commands/extract.cpp @@ -3,11 +3,11 @@ #include #include #include -#include #include #include +#include "errors.h" #include "mpq/archive.h" #include "mpq/extract.h" #include "util/locales.h" @@ -18,15 +18,13 @@ namespace mpqcli { bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic &cancelled) { std::error_code ec; - std::string effective_output; + fs::path effective_output; if (!options.output.has_value()) { fs::path target_path = fs::absolute(options.target, ec); if (ec) { - err << "[!] Failed to resolve archive path: (" << ec.value() << ") " << ec.message() - << ": " << options.target << std::endl; - return false; + throw FileError("Failed to resolve archive path", options.target, ec); } - effective_output = (target_path.parent_path() / target_path.stem()).u8string(); + effective_output = target_path.parent_path() / target_path.stem(); } else { effective_output = options.output.value(); } @@ -34,9 +32,7 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic if (ec) { std::error_code query_ec; if (!fs::is_directory(effective_output, query_ec)) { - err << "[!] Failed to create output directory: (" << ec.value() << ") " << ec.message() - << ": " << effective_output << std::endl; - return false; + throw FileError("Failed to create output directory", effective_output, ec); } } @@ -46,7 +42,7 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic if (ec) { err << "[!] Output directory cannot be fully resolved, symlinks will not " "be checked during extraction: " - << effective_output << std::endl; + << effective_output.string() << std::endl; } Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); diff --git a/src/commands/extract.h b/src/commands/extract.h index 295e695..bfe997c 100644 --- a/src/commands/extract.h +++ b/src/commands/extract.h @@ -2,6 +2,7 @@ #define COMMANDS_EXTRACT_H #include +#include #include #include #include @@ -10,11 +11,11 @@ namespace mpqcli { /// Settled arguments for the extract subcommand struct ExtractOptions { - std::string target; - std::optional output; + std::filesystem::path target; + std::optional output; std::optional file; bool keep_folder_structure = false; - std::optional listfile; + std::optional listfile; std::optional locale; }; diff --git a/src/commands/info.cpp b/src/commands/info.cpp index 80e8a6b..9388a7e 100644 --- a/src/commands/info.cpp +++ b/src/commands/info.cpp @@ -9,11 +9,10 @@ namespace mpqcli { -bool Info(const InfoOptions &options, std::ostream &out) { +void Info(const InfoOptions &options, std::ostream &out) { Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); PrintMpqInfo(archive.Handle(), options.property, out); archive.Close(); - return true; } } // namespace mpqcli diff --git a/src/commands/info.h b/src/commands/info.h index 53dda48..84ec9c5 100644 --- a/src/commands/info.h +++ b/src/commands/info.h @@ -1,6 +1,7 @@ #ifndef COMMANDS_INFO_H #define COMMANDS_INFO_H +#include #include #include #include @@ -9,12 +10,12 @@ namespace mpqcli { /// Settled arguments for the info subcommand struct InfoOptions { - std::string target; + std::filesystem::path target; std::optional property; }; /// Prints the archive's properties, or only the one named, to out -bool Info(const InfoOptions &options, std::ostream &out); +void Info(const InfoOptions &options, std::ostream &out); } // namespace mpqcli diff --git a/src/commands/list.cpp b/src/commands/list.cpp index d958d0e..e5b4839 100644 --- a/src/commands/list.cpp +++ b/src/commands/list.cpp @@ -9,12 +9,11 @@ namespace mpqcli { -bool List(const ListOptions &options, std::ostream &out, std::ostream &err) { +void List(const ListOptions &options, std::ostream &out, std::ostream &err) { Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY); ListFiles(archive.Handle(), options.listfile, options.all, options.detailed, options.properties, out, err); archive.Close(); - return true; } } // namespace mpqcli diff --git a/src/commands/list.h b/src/commands/list.h index a01d2d5..14e490a 100644 --- a/src/commands/list.h +++ b/src/commands/list.h @@ -1,6 +1,7 @@ #ifndef COMMANDS_LIST_H #define COMMANDS_LIST_H +#include #include #include #include @@ -10,15 +11,15 @@ namespace mpqcli { /// Settled arguments for the list subcommand struct ListOptions { - std::string target; - std::optional listfile; + std::filesystem::path target; + std::optional listfile; bool detailed = false; bool all = false; std::vector properties; }; /// Prints the archive's files to out -bool List(const ListOptions &options, std::ostream &out, std::ostream &err); +void List(const ListOptions &options, std::ostream &out, std::ostream &err); } // namespace mpqcli diff --git a/src/commands/read.h b/src/commands/read.h index bd7cb96..1a02663 100644 --- a/src/commands/read.h +++ b/src/commands/read.h @@ -1,6 +1,7 @@ #ifndef COMMANDS_READ_H #define COMMANDS_READ_H +#include #include #include #include @@ -10,7 +11,7 @@ namespace mpqcli { /// Settled arguments for the read subcommand struct ReadOptions { std::string file; - std::string target; + std::filesystem::path target; std::optional locale; }; diff --git a/src/commands/remove.h b/src/commands/remove.h index 485c4be..bad3df5 100644 --- a/src/commands/remove.h +++ b/src/commands/remove.h @@ -2,6 +2,7 @@ #define COMMANDS_REMOVE_H #include +#include #include #include #include @@ -11,7 +12,7 @@ namespace mpqcli { /// Settled arguments for the remove subcommand struct RemoveOptions { - std::string archive; + std::filesystem::path archive; std::vector files; std::optional locale; }; diff --git a/src/commands/rename.h b/src/commands/rename.h index 77f68bb..2bc8987 100644 --- a/src/commands/rename.h +++ b/src/commands/rename.h @@ -1,6 +1,7 @@ #ifndef COMMANDS_RENAME_H #define COMMANDS_RENAME_H +#include #include #include #include @@ -9,7 +10,7 @@ namespace mpqcli { /// Settled arguments for the rename subcommand struct RenameOptions { - std::string archive; + std::filesystem::path archive; std::string old_file; std::string new_file; std::optional locale; diff --git a/src/commands/verify.cpp b/src/commands/verify.cpp index 96d5b69..26c71c1 100644 --- a/src/commands/verify.cpp +++ b/src/commands/verify.cpp @@ -17,12 +17,9 @@ bool Verify(const VerifyOptions &options, std::ostream &out, std::ostream &err) case ERROR_WEAK_SIGNATURE_OK: case ERROR_STRONG_SIGNATURE_OK: if (options.print_signature) { - // If printing the signature, don't print success message - // because the user might want to pipe/redirect the signature data PrintMpqSignature(archive.Handle(), options.target, out, err); - } else { - err << "[*] Verify success" << std::endl; } + err << "[*] Verify success" << std::endl; verified = true; break; diff --git a/src/commands/verify.h b/src/commands/verify.h index 5a07878..3deb2f8 100644 --- a/src/commands/verify.h +++ b/src/commands/verify.h @@ -1,14 +1,14 @@ #ifndef COMMANDS_VERIFY_H #define COMMANDS_VERIFY_H +#include #include -#include namespace mpqcli { /// Settled arguments for the verify subcommand struct VerifyOptions { - std::string target; + std::filesystem::path target; bool print_signature = false; }; diff --git a/src/errors.h b/src/errors.h index 71ff96d..e0e2bbc 100644 --- a/src/errors.h +++ b/src/errors.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include "util/format.h" @@ -31,6 +32,27 @@ inline void ThrowIfCancelled(const std::atomic &cancelled) { } } +/// Thrown when a filesystem operation fails; the message ends with the code, its text and the path +class FileError : public Error { +public: + FileError(const std::string &what_failed, std::filesystem::path path, + std::error_code error_code) + : Error(what_failed + ": (" + std::to_string(error_code.value()) + ") " + + error_code.message() + ": " + path.string()), + path_(std::move(path)), error_code_(error_code) {} + + /// For a failure with no code to report, such as a path of the wrong kind + FileError(const std::string &what_failed, std::filesystem::path path) + : Error(what_failed + ": " + path.string()), path_(std::move(path)) {} + + const std::filesystem::path &Path() const { return path_; } + const std::error_code &ErrorCode() const { return error_code_; } + +private: + std::filesystem::path path_; + std::error_code error_code_; +}; + /// Base for failures reading or writing an archive class ArchiveError : public Error { public: diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index ceb0e25..aaf4f29 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -67,7 +67,7 @@ int AddFiles(HANDLE archive, const std::vector &files, const fs::path } if (update) { - err << "[*] For " << base_path.u8string() << ": " << files_added << " files added, " + err << "[*] For " << base_path.string() << ": " << files_added << " files added, " << files_skipped << " files skipped, " << files_failed << " files failed." << std::endl; } @@ -100,7 +100,7 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi SFileCloseFile(file); if (unchanged) { - err << "[~] Skipping unchanged file: " << archive_file_path << " (" << match_reason + err << "[*] Skipping unchanged file: " << archive_file_path << " (" << match_reason << ")" << std::endl; if (skipped != nullptr) { (*skipped)++; @@ -120,7 +120,7 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi return 0; } - err << "[+] File" << PrettyPrintLocale(locale, " for locale ") + err << "[~] File" << PrettyPrintLocale(locale, " for locale ") << " already exists in MPQ archive: " << archive_file_path << " - Overwriting..." << std::endl; } else { @@ -172,7 +172,7 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi } bool added_file = - SFileAddFileEx(archive, local_file.u8string().c_str(), archive_file_path.c_str(), flags, + SFileAddFileEx(archive, local_file.string().c_str(), archive_file_path.c_str(), flags, compression, compression_next); if (!added_file) { diff --git a/src/mpq/archive.cpp b/src/mpq/archive.cpp index c8031da..22d0607 100644 --- a/src/mpq/archive.cpp +++ b/src/mpq/archive.cpp @@ -27,7 +27,7 @@ Archive Archive::Create(const fs::path &path, const uint32_t file_count, const GameRules &game_rules) { std::error_code ec; if (fs::exists(path, ec)) { - throw ArchiveError("File already exists: " + path.string() + " Exiting..."); + throw ArchiveError("File already exists: " + path.string()); } const MpqCreateSettings &settings = game_rules.GetCreateSettings(); diff --git a/src/mpq/compact.cpp b/src/mpq/compact.cpp index 3b23b50..6f597f1 100644 --- a/src/mpq/compact.cpp +++ b/src/mpq/compact.cpp @@ -1,5 +1,6 @@ #include "mpq/compact.h" +#include #include #include #include @@ -10,10 +11,12 @@ namespace mpqcli { -void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name, +void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name, std::ostream &err) { err << "[*] Compacting archive. This may take some time..." << std::endl; - const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; + const std::string listfile_string = + listfile_name.has_value() ? listfile_name->string() : std::string(); + const char *listfile = listfile_name.has_value() ? listfile_string.c_str() : nullptr; if (!SFileCompactArchive(archive, listfile, false)) { throw StormError("Failed to compact archive", SErrGetLastError()); diff --git a/src/mpq/compact.h b/src/mpq/compact.h index 9d7dab4..a39441d 100644 --- a/src/mpq/compact.h +++ b/src/mpq/compact.h @@ -1,9 +1,9 @@ #ifndef MPQ_COMPACT_H #define MPQ_COMPACT_H +#include #include #include -#include #include @@ -12,7 +12,7 @@ namespace mpqcli { /// Compacts the archive, resolving names it cannot from listfile_name when given /// /// @throws StormError if StormLib cannot compact it -void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name, +void CompactMpqArchive(HANDLE archive, const std::optional &listfile_name, std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index e51ed13..38e4a58 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -20,11 +20,13 @@ namespace fs = std::filesystem; namespace mpqcli { -int ExtractFiles(HANDLE archive, const std::string &output, - const std::optional &listfile_name, LCID preferred_locale, +int ExtractFiles(HANDLE archive, const fs::path &output, + const std::optional &listfile_name, LCID preferred_locale, std::ostream &err, const std::atomic &cancelled) { SFileSetLocale(preferred_locale); - const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; + const std::string listfile_string = + listfile_name.has_value() ? listfile_name->string() : std::string(); + const char *listfile = listfile_name.has_value() ? listfile_string.c_str() : nullptr; SFILE_FIND_DATA find_data; HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); @@ -50,7 +52,7 @@ int ExtractFiles(HANDLE archive, const std::string &output, return result; } -int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, +int ExtractFile(HANDLE archive, const fs::path &output, const std::string &file_name, bool keep_folder_structure, LCID preferred_locale, std::ostream &err) { SFileSetLocale(preferred_locale); if (!FileExistsInArchiveForLocale(archive, file_name.c_str(), preferred_locale) && @@ -73,7 +75,7 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi fs::path output_path_base = fs::absolute(output, ec).lexically_normal(); if (ec) { err << "[!] Failed to resolve output directory: (" << ec.value() << ") " << ec.message() - << ": " << output << std::endl; + << ": " << output.string() << std::endl; return 1; } if (output_path_base.filename().empty()) { @@ -92,7 +94,7 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi fs::create_directories(output_file_path_name.parent_path(), ec); if (ec) { err << "[!] Failed to create output directory: (" << ec.value() << ") " << ec.message() - << ": " << output_file_path_name.parent_path().u8string() << std::endl; + << ": " << output_file_path_name.parent_path().string() << std::endl; return 1; } @@ -105,7 +107,7 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi output_file_path_name.filename(); if (ec) { err << "[!] Failed to resolve output path: (" << ec.value() << ") " << ec.message() - << ": " << output_file_path_name.u8string() << std::endl; + << ": " << output_file_path_name.string() << std::endl; return 1; } if (!IsWithinDirectory(resolved_base, resolved_output)) { @@ -115,7 +117,7 @@ int ExtractFile(HANDLE archive, const std::string &output, const std::string &fi } } - std::string output_file_name{output_file_path_name.u8string()}; + std::string output_file_name{output_file_path_name.string()}; if (SFileExtractFile(archive, file_name.c_str(), output_file_name.c_str(), 0)) { err << "[*] Extracted: " << file_name_string << std::endl; diff --git a/src/mpq/extract.h b/src/mpq/extract.h index 1b414ba..8c86ec1 100644 --- a/src/mpq/extract.h +++ b/src/mpq/extract.h @@ -2,6 +2,7 @@ #define MPQ_EXTRACT_H #include +#include #include #include #include @@ -10,10 +11,10 @@ namespace mpqcli { -int ExtractFiles(HANDLE archive, const std::string &output, - const std::optional &listfile_name, LCID preferred_locale, +int ExtractFiles(HANDLE archive, const std::filesystem::path &output, + const std::optional &listfile_name, LCID preferred_locale, std::ostream &err, const std::atomic &cancelled); -int ExtractFile(HANDLE archive, const std::string &output, const std::string &file_name, +int ExtractFile(HANDLE archive, const std::filesystem::path &output, const std::string &file_name, bool keep_folder_structure, LCID preferred_locale, std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp index dc6194e..f614897 100644 --- a/src/mpq/list.cpp +++ b/src/mpq/list.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -20,10 +21,12 @@ namespace mpqcli { -void ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, - bool list_detailed, const std::vector &properties, std::ostream &out, - std::ostream &err) { - const char *listfile = listfile_name.has_value() ? listfile_name->c_str() : nullptr; +void ListFiles(HANDLE archive, const std::optional &listfile_name, + bool list_all, bool list_detailed, const std::vector &properties, + std::ostream &out, std::ostream &err) { + const std::string listfile_string = + listfile_name.has_value() ? listfile_name->string() : std::string(); + const char *listfile = listfile_name.has_value() ? listfile_string.c_str() : nullptr; SFILE_FIND_DATA find_data; HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); diff --git a/src/mpq/list.h b/src/mpq/list.h index 2aa9fdc..8a0a81f 100644 --- a/src/mpq/list.h +++ b/src/mpq/list.h @@ -1,6 +1,7 @@ #ifndef MPQ_LIST_H #define MPQ_LIST_H +#include #include #include #include @@ -13,9 +14,9 @@ namespace mpqcli { /// Prints the archive's files to out, one per line, with the requested properties /// /// @throws ArchiveError if the archive's files cannot be enumerated -void ListFiles(HANDLE archive, const std::optional &listfile_name, bool list_all, - bool list_detailed, const std::vector &properties, std::ostream &out, - std::ostream &err); +void ListFiles(HANDLE archive, const std::optional &listfile_name, + bool list_all, bool list_detailed, const std::vector &properties, + std::ostream &out, std::ostream &err); } // namespace mpqcli diff --git a/src/mpq/verify.cpp b/src/mpq/verify.cpp index 6340203..45dbe4a 100644 --- a/src/mpq/verify.cpp +++ b/src/mpq/verify.cpp @@ -1,6 +1,5 @@ #include "mpq/verify.h" -#include #include #include #include @@ -26,12 +25,10 @@ uint32_t VerifyMpqArchive(HANDLE archive) { return SFileVerifyArchive(archive); } -void PrintMpqSignature(HANDLE archive, const std::string &target, std::ostream &out, +void PrintMpqSignature(HANDLE archive, const fs::path &target, std::ostream &out, std::ostream &err) { int32_t signature_type = GetFileInfo(archive, SFileMpqSignatures); - std::vector signature_content; - if (signature_type == SIGNATURE_TYPE_NONE) { return; } else if (signature_type == SIGNATURE_TYPE_WEAK) { @@ -42,8 +39,6 @@ void PrintMpqSignature(HANDLE archive, const std::string &target, std::ostream & if (!file_content) { throw ArchiveError("Failed to read weak signature file."); } - signature_content.resize(file_size); - std::copy(file_content.get(), file_content.get() + file_size, signature_content.begin()); WriteBinary(out, file_content.get(), file_size); @@ -54,12 +49,10 @@ void PrintMpqSignature(HANDLE archive, const std::string &target, std::ostream & int64_t archive_size = GetFileInfo(archive, SFileMpqArchiveSize64); int64_t archive_offset = GetFileInfo(archive, SFileMpqHeaderOffset); - const fs::path archive_path(target); std::error_code ec; - const auto file_size = static_cast(fs::file_size(archive_path, ec)); + const auto file_size = static_cast(fs::file_size(target, ec)); if (ec) { - throw ArchiveError("Failed to read archive size: (" + std::to_string(ec.value()) + - ") " + ec.message() + ": " + target); + throw FileError("Failed to read archive size", target, ec); } // fs::file_size returns uintmax_t, so without the cast the subtraction runs // unsigned and a legitimately negative length reaches the check below only @@ -70,12 +63,14 @@ void PrintMpqSignature(HANDLE archive, const std::string &target, std::ostream & throw ArchiveError("Invalid signature length: " + std::to_string(signature_length)); } - std::ifstream file_mpq(archive_path, std::ios::binary); + std::vector signature_content(static_cast(signature_length)); + std::ifstream file_mpq(target, std::ios::binary); file_mpq.seekg(archive_offset + archive_size, std::ios::beg); - signature_content.resize(static_cast(signature_length)); file_mpq.read(signature_content.data(), static_cast(signature_content.size())); - file_mpq.close(); + if (!file_mpq) { + throw ArchiveError("Failed to read strong signature: " + target.string()); + } WriteBinary(out, signature_content.data(), static_cast(signature_content.size())); } diff --git a/src/mpq/verify.h b/src/mpq/verify.h index 8ebda99..87f0ba4 100644 --- a/src/mpq/verify.h +++ b/src/mpq/verify.h @@ -2,8 +2,8 @@ #define MPQ_VERIFY_H #include +#include #include -#include #include @@ -14,7 +14,8 @@ uint32_t VerifyMpqArchive(HANDLE archive); /// Writes the archive's weak or strong signature bytes to out /// /// @throws ArchiveError if the signature cannot be read -void PrintMpqSignature(HANDLE archive, const std::string &target, std::ostream &out, +/// @throws FileError if the archive's size on disk cannot be read +void PrintMpqSignature(HANDLE archive, const std::filesystem::path &target, std::ostream &out, std::ostream &err); } // namespace mpqcli diff --git a/src/util/path.cpp b/src/util/path.cpp index 98b019d..45a2174 100644 --- a/src/util/path.cpp +++ b/src/util/path.cpp @@ -93,12 +93,11 @@ bool IsWithinDirectory(const fs::path &base, const fs::path &path) { return std::mismatch(base.begin(), base.end(), path.begin(), path.end()).first == base.end(); } -std::string ResolveArchiveName(const std::string &f, const std::optional &path, +std::string ResolveArchiveName(const fs::path &f, const std::optional &path, const bool treat_as_directory) { - fs::path file_path = path.value_or(fs::path(f).filename().u8string()); + fs::path file_path = path.value_or(f.filename().u8string()); if (treat_as_directory) { - const std::string filename = fs::path(f).filename().u8string(); - file_path = path.value_or("") / fs::path(filename); + file_path = fs::path(path.value_or("")) / fs::path(f.filename().u8string()); } return WindowsifyFilePath(file_path); } diff --git a/src/util/path.h b/src/util/path.h index b8c05c0..4e4560e 100644 --- a/src/util/path.h +++ b/src/util/path.h @@ -18,7 +18,8 @@ std::string WindowsifyFilePath(const std::filesystem::path &path); bool IsWithinDirectory(const std::filesystem::path &base, const std::filesystem::path &path); /// Builds the in-archive name for a local file -std::string ResolveArchiveName(const std::string &f, const std::optional &path, +std::string ResolveArchiveName(const std::filesystem::path &f, + const std::optional &path, bool treat_as_directory = false); std::vector ListFilesRecursive(const std::filesystem::path &directory, diff --git a/test/test_add.py b/test/test_add.py index bdb8ced..0b48fb4 100644 --- a/test/test_add.py +++ b/test/test_add.py @@ -250,7 +250,7 @@ def test_add_existing_file_with_overwrite_should_succeed(binary_path, generate_t output_lines = set(result.stderr.splitlines()) expected_stderr_output = { - "[+] File already exists in MPQ archive: cats.txt - Overwriting...", + "[~] File already exists in MPQ archive: cats.txt - Overwriting...", "[+] Adding file: cats.txt", } assert output_lines == expected_stderr_output, f"Unexpected output: {output_lines}" @@ -518,7 +518,7 @@ def test_add_existing_locale_file_with_overwrite_should_succeed(binary_path, gen ) expected_stderr = { - "[+] File for locale esES already exists in MPQ archive: cats.txt - Overwriting...", + "[~] File for locale esES already exists in MPQ archive: cats.txt - Overwriting...", "[+] Adding file for locale esES: cats.txt", } output_lines = set(result.stderr.splitlines()) @@ -677,7 +677,7 @@ def test_add_directory_with_overwrite(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[+] File already exists in MPQ archive: cats.txt - Overwriting..." in result.stderr + assert "[~] File already exists in MPQ archive: cats.txt - Overwriting..." in result.stderr verify_file_in_mpq_has_content(binary_path, target_mpq, "cats.txt", {"This is the replaced cats file."}) finally: @@ -737,8 +737,8 @@ def test_add_update_skips_unchanged_files(binary_path, generate_test_files): ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (MD5 matches)" in result.stderr - assert "[~] Skipping unchanged file: dogs.txt (MD5 matches)" in result.stderr + assert "[*] Skipping unchanged file: cats.txt (MD5 matches)" in result.stderr + assert "[*] Skipping unchanged file: dogs.txt (MD5 matches)" in result.stderr assert "files added" in result.stderr assert "files skipped" in result.stderr finally: @@ -767,7 +767,7 @@ def test_add_update_adds_changed_files(binary_path, generate_test_files): assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" assert "[+] Adding file: cats.txt" in result.stderr - assert "[~] Skipping unchanged file: dogs.txt" in result.stderr + assert "[*] Skipping unchanged file: dogs.txt" in result.stderr assert "1 files added" in result.stderr assert "1 files skipped" in result.stderr @@ -833,7 +833,7 @@ def test_add_update_single_file_skips_when_unchanged(binary_path, generate_test_ ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (MD5 matches)" in result.stderr + assert "[*] Skipping unchanged file: cats.txt (MD5 matches)" in result.stderr assert "[+] Adding file: cats.txt" not in result.stderr finally: shutil.rmtree(work_dir, ignore_errors=True) @@ -915,7 +915,7 @@ def test_add_update_skips_unchanged_files_via_crc32(binary_path, generate_test_f ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (CRC32 matches)" in result.stderr + assert "[*] Skipping unchanged file: cats.txt (CRC32 matches)" in result.stderr assert "files skipped" in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) @@ -974,7 +974,7 @@ def test_add_update_skips_unchanged_files_via_timestamp(binary_path, generate_te ) assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" - assert "[~] Skipping unchanged file: cats.txt (Timestamp matches)" in result.stderr + assert "[*] Skipping unchanged file: cats.txt (Timestamp matches)" in result.stderr assert "files skipped" in result.stderr finally: shutil.rmtree(update_dir, ignore_errors=True) From 257c4b7a80562c61dce18054d31d12d5dad7bdb0 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 27 Sep 2026 19:41:00 +1300 Subject: [PATCH 49/59] Aligned the Makefile targets and sections with the conventions, marked StormLib as a system include and prefixed the vendored target --- CMakeLists.txt | 10 ++- Makefile | 186 +++++++++++++++++++++++----------------- app/CMakeLists.txt | 4 - cmake/mark_system.cmake | 10 +++ src/CMakeLists.txt | 5 +- 5 files changed, 124 insertions(+), 91 deletions(-) create mode 100644 cmake/mark_system.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 3d79934..0b2a29f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.25) -project(MPQCLI VERSION 0.12.0) +project(mpqcli VERSION 0.12.0) # An option() name is global to the CMake cache, including a consumer's, so these # carry the project prefix; a bare BUILD_STATIC claims a name anyone might want. @@ -23,6 +23,8 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${PROJECT_BINARY_DIR}/bin") +include("${PROJECT_SOURCE_DIR}/cmake/mark_system.cmake") + # Multi-config generators (Visual Studio, Xcode) append the config name to the # output directory, so the bare variable above would put the binary in # bin/Release/ rather than bin/. Set all four configs, not just RELEASE, so a @@ -73,6 +75,7 @@ endif() set(STORM_USE_BUNDLED_LIBRARIES ON CACHE BOOL "" FORCE) add_subdirectory(extern/StormLib) +mark_system(storm) # Handle CLI11 dependency. Only app/ parses arguments, so the core never sees it. if(MPQCLI_BUILD_APP) @@ -101,11 +104,12 @@ Did you forget to execute the following commands? endif() # hash-library ships no CMakeLists; compile only the two algorithms we use -add_library(hash-library STATIC +add_library(mpqcli_hash_library STATIC extern/hash-library/crc32.cpp extern/hash-library/md5.cpp ) -target_include_directories(hash-library SYSTEM INTERFACE "${PROJECT_SOURCE_DIR}/extern") +target_include_directories(mpqcli_hash_library SYSTEM INTERFACE "${PROJECT_SOURCE_DIR}/extern") +add_library(mpqcli::hash_library ALIAS mpqcli_hash_library) # Unconditional: building the core without the app is what proves nothing in # src/ has picked up a dependency on app/. diff --git a/Makefile b/Makefile index 92f72c2..adc24a4 100644 --- a/Makefile +++ b/Makefile @@ -5,27 +5,42 @@ SHELL := /bin/bash CPP_DIRS := $(wildcard src app test) CPP_LINT_DIRS := $(wildcard src app) +JOBS ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) +BUILD_TYPE ?= Debug +CMAKE_ARGS ?= CMAKE_BUILD_TYPE := Release MPQCLI_BUILD_APP ?= ON -JOBS ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) # Prefer the versioned tool, fall back to the plain name. Falling back rather # than failing keeps the error legible: "clang-format: command not found" # beats a make-level complaint about an empty variable. CLANG_VERSION ?= 18 -CLANG_FORMAT ?= $(shell command -v clang-format-$(CLANG_VERSION) 2>/dev/null || echo clang-format) -CLANG_TIDY ?= $(shell command -v clang-tidy-$(CLANG_VERSION) 2>/dev/null || echo clang-tidy) +CLANG_CC ?= $(shell command -v clang-$(CLANG_VERSION) || echo clang) +CLANG_CXX ?= $(shell command -v clang++-$(CLANG_VERSION) || echo clang++) +CLANG_FORMAT ?= $(shell command -v clang-format-$(CLANG_VERSION) || echo clang-format) +CLANG_TIDY ?= $(shell command -v clang-tidy-$(CLANG_VERSION) || echo clang-tidy) # clang-tidy resolves headers through the compiler that produced # compile_commands.json. Pointed at a GCC build it cannot find libstdc++ and # emits confident diagnostics from a broken AST, so it gets its own tree. LINT_DIR ?= build/lint -GCC_INSTALL_DIR := $(shell dirname "$(shell gcc -print-libgcc-file-name)") -VERSION := $(shell awk '/project\(MPQCLI VERSION/ {gsub(/\)/, "", $$3); print $$3}' CMakeLists.txt) +# Empty on a machine with no GCC, and configure_lint then passes no +# --gcc-install-dir at all. The && is what keeps it empty: dirname of an empty +# string is ".", which clang would take as the install directory +GCC_INSTALL_DIR := $(shell f=$$(gcc -print-libgcc-file-name 2>/dev/null) && dirname "$$f") + README := README.md PACKAGE_URL := https://github.com/thegraydot/mpqcli/pkgs/container/mpqcli -TAG ?= $(shell git describe --tags --abbrev=0 2>/dev/null) + +# The version is declared once, in project(... VERSION X.Y.Z). The +# cmake_minimum_required line also says VERSION and comes first, so it is skipped +VERSION_AWK := /cmake_minimum_required/ { next } \ + match($$0, /VERSION[ \t]+[0-9]+\.[0-9]+\.[0-9]+/) { \ + v = substr($$0, RSTART, RLENGTH); sub(/VERSION[ \t]+/, "", v); \ + print v; found = 1; exit } \ + END { if (!found) exit 1 } +VERSION = $(shell awk '$(VERSION_AWK)' CMakeLists.txt) .PHONY: help help: ## Show this help message @@ -33,15 +48,17 @@ help: ## Show this help message /^[a-zA-Z_-]+:.*## / {printf " %-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) ##@ BUILD + .PHONY: configure -configure: ## Configure cmake build (debug, with compile_commands.json) +configure: ## Configure the cmake build cmake -B build/dev \ - -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_BUILD_TYPE=$(BUILD_TYPE) \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) + -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) \ + $(CMAKE_ARGS) .PHONY: build -build: ## Build via cmake +build: configure ## Build the project cmake --build build/dev --parallel $(JOBS) .PHONY: build_linux @@ -58,33 +75,8 @@ build_windows: ## Build for Windows using cmake -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) cmake --build build/dev --config $(CMAKE_BUILD_TYPE) --parallel $(JOBS) -##@ DOCKER -.PHONY: docker_build -docker_build: ## Build the Docker image - docker build -t mpqcli:$(VERSION) . - -.PHONY: docker_run -docker_run: ## Run the Docker image - @docker run -it mpqcli:$(VERSION) version - -##@ DOCS -.PHONY: docs_mermaid -docs_mermaid: ## Fetch mermaid.min.js and mermaid-init.js into the repo root (gitignored) - mdbook-mermaid install . - -.PHONY: docs_build -docs_build: docs_mermaid ## Build the documentation site into book/ - mdbook build - -.PHONY: docs_serve -docs_serve: docs_mermaid ## Serve the docs locally with live reload - mdbook serve --open - -.PHONY: docs_clean -docs_clean: ## Remove the generated docs site and mermaid assets - rm -rf book mermaid.min.js mermaid-init.js - ##@ TEST + .PHONY: test test: build test_mpqcli ## Run test suite (builds binary first) @@ -109,23 +101,26 @@ test_lint: ## Run ruff linter on test directory ruff check ./test ##@ LINT -.PHONY: check_format -check_format: ## Check C++ formatting with clang-format - find $(CPP_DIRS) \( -name "*.cpp" -o -name "*.h" \) \ - | xargs $(CLANG_FORMAT) --dry-run --Werror .PHONY: format -format: ## Auto-fix C++ formatting with clang-format +format: ## Format all source files with clang-format find $(CPP_DIRS) \( -name "*.cpp" -o -name "*.h" \) \ | xargs $(CLANG_FORMAT) -i +.PHONY: check_format +check_format: ## Check formatting without modifying files + find $(CPP_DIRS) \( -name "*.cpp" -o -name "*.h" \) \ + | xargs $(CLANG_FORMAT) --dry-run --Werror + .PHONY: configure_lint -configure_lint: ## Configure $(LINT_DIR) with clang++, so clang-tidy can parse the sources +configure_lint: ## Configure $(LINT_DIR) with clang, so clang-tidy can parse the sources cmake -B $(LINT_DIR) \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - -DCMAKE_CXX_COMPILER=clang++-$(CLANG_VERSION) \ - -DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)" + -DCMAKE_C_COMPILER=$(CLANG_CC) \ + -DCMAKE_CXX_COMPILER=$(CLANG_CXX) \ + $(if $(GCC_INSTALL_DIR),-DCMAKE_CXX_FLAGS="--gcc-install-dir=$(GCC_INSTALL_DIR)") \ + $(CMAKE_ARGS) .PHONY: check_lint check_lint: configure_lint ## Run clang-tidy static analysis @@ -145,18 +140,36 @@ check_embed: ## Syntax-check the embedded completion scripts with every shell pr '[scriptblock]::Create((Get-Content -Raw completion/mpqcli.ps1)) | Out-Null'; \ else echo "[*] pwsh not installed, skipped"; fi -.PHONY: check_all -check_all: check_format check_lint check_embed ## Run every static check +##@ DOCS -.PHONY: ci -ci: configure build check_all test ## Run all CI checks locally +.PHONY: docs_mermaid +docs_mermaid: ## Fetch mermaid.min.js and mermaid-init.js into the repo root (gitignored) + mdbook-mermaid install . -##@ CLEAN -.PHONY: clean -clean: test_clean docs_clean ## Remove all build, test, docs and release artefacts - rm -rf build dist install.sh install.ps1 checksums.txt checksums.txt.sigstore.json +.PHONY: docs_build +docs_build: docs_mermaid ## Build the documentation site into book/ + mdbook build + +.PHONY: docs_serve +docs_serve: docs_mermaid ## Serve the docs locally with live reload + mdbook serve --open + +.PHONY: docs_clean +docs_clean: ## Remove the generated docs site and mermaid assets + rm -rf book mermaid.min.js mermaid-init.js + +##@ DOCKER + +.PHONY: docker_build +docker_build: ## Build the Docker image, tagged with the project version + docker build -t mpqcli:$(VERSION) . + +.PHONY: docker_run +docker_run: ## Run the Docker image + @docker run -it mpqcli:$(VERSION) version ##@ GENERATE + # The docs site builds from the committed copy, so this is run deliberately and the # result committed rather than being made a prerequisite of docs_build .PHONY: gen_docs_changelog @@ -164,34 +177,8 @@ gen_docs_changelog: ## Copy CHANGELOG.md into the docs site @cp CHANGELOG.md docs/changelog.md @echo "[*] Updated docs/changelog.md" -##@ GET -.PHONY: get_version -get_version: ## Print the project version from CMakeLists.txt (fails if absent) - @awk '\ - /cmake_minimum_required/ { next } \ - match($$0, /VERSION[ \t]+[0-9]+\.[0-9]+\.[0-9]+/) { \ - v = substr($$0, RSTART, RLENGTH); sub(/VERSION[ \t]+/, "", v); \ - print v; found = 1; exit } \ - END { if (!found) exit 1 }' CMakeLists.txt - -.PHONY: get_changelog -get_changelog: ## Print release notes for TAG to stdout (default: latest tag; override with TAG=v1.0.0) - @if [ -z "$(TAG)" ]; then \ - echo "Error: no tag resolved. Create a git tag or pass TAG=v1.0.0" >&2; \ - exit 1; \ - fi - @notes=$$(awk -v tag="$(TAG)" \ - '/^## /{if(found)exit; if(index($$0,"## "tag" ")==1 || $$0=="## "tag)found=1; next} found{print}' \ - CHANGELOG.md); \ - if [ -z "$$notes" ]; then \ - echo "Error: no CHANGELOG entry found for $(TAG)" >&2; \ - exit 1; \ - fi; \ - echo "$$notes" - -##@ RELEASE -.PHONY: fetch_downloads -fetch_downloads: ## Fetch package downloads and update README.md badge +.PHONY: gen_downloads +gen_downloads: ## Update the package downloads badge in README.md from GitHub @DOWNLOADS=$$(curl -s "$(PACKAGE_URL)" \ | grep -A2 "Total downloads" \ | grep -o '

[0-9]*

' \ @@ -200,3 +187,42 @@ fetch_downloads: ## Fetch package downloads and update README.md badge | head -1); \ sed -i "s/package_downloads-[0-9]*-green/package_downloads-$$DOWNLOADS-green/" $(README); \ echo "[*] Updated package downloads badge: $$DOWNLOADS" + +##@ GET + +.PHONY: get_version +get_version: ## Print the project version from CMakeLists.txt (fails if absent) + @awk '$(VERSION_AWK)' CMakeLists.txt + +.PHONY: get_changelog +get_changelog: ## Print release notes for TAG to stdout (TAG=v1.0.0) + @tag="$(TAG)"; tag="$${tag#v}"; \ + if [[ -z "$$tag" ]]; then \ + printf 'get_changelog: TAG is empty; pass TAG=v1.0.0\n' >&2; \ + exit 1; \ + fi; \ + notes="$$(awk -v tag="$$tag" ' \ + /^## / { if (found) exit; if (index($$0,"## "tag" ")==1 || $$0=="## "tag) found=1; next } \ + found { lines[n++]=$$0 } \ + END { \ + s=0; while (s=s && lines[e]~/^[[:space:]]*$$/) e--; \ + for (i=s;i<=e;i++) print lines[i] \ + }' CHANGELOG.md)"; \ + if [[ -z "$$notes" ]]; then \ + printf 'get_changelog: no CHANGELOG entry for %s\n' "$$tag" >&2; \ + exit 1; \ + fi; \ + printf '%s\n' "$$notes" + +##@ CI + +.PHONY: check_all +check_all: check_format check_lint check_embed ## Run every static check + +.PHONY: ci +ci: check_all test ## Run the checks CI runs + +.PHONY: clean +clean: test_clean docs_clean ## Remove all build, test, docs and release artefacts + rm -rf build dist install.sh install.ps1 checksums.txt checksums.txt.sigstore.json diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 320907c..5760955 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -33,8 +33,4 @@ add_executable(mpqcli version.cpp ) -# An include tree rather than PROJECT_BINARY_DIR itself, which would expose -# every generated file in the build tree to #include -target_include_directories(mpqcli PRIVATE "${PROJECT_BINARY_DIR}/include") - target_link_libraries(mpqcli PRIVATE mpqcli::core CLI11::CLI11 mpqcli::warnings) diff --git a/cmake/mark_system.cmake b/cmake/mark_system.cmake new file mode 100644 index 0000000..7d8c303 --- /dev/null +++ b/cmake/mark_system.cmake @@ -0,0 +1,10 @@ +# Re-declare a dependency's interface includes as SYSTEM includes, so clang-tidy +# and the compiler ignore them. Needed for any submodule that exports its own +# target: linking it otherwise pulls its headers in as ordinary -I paths. +function(mark_system target) + get_target_property(_incs ${target} INTERFACE_INCLUDE_DIRECTORIES) + if(_incs) + set_target_properties(${target} PROPERTIES + INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "${_incs}") + endif() +endfunction() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c84c6b0..b68be24 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -70,11 +70,8 @@ target_include_directories(mpqcli_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}" "${PROJECT_BINARY_DIR}/include" ) -target_include_directories(mpqcli_core SYSTEM PUBLIC - "${PROJECT_SOURCE_DIR}/extern/StormLib/src" -) -target_link_libraries(mpqcli_core PUBLIC storm hash-library) +target_link_libraries(mpqcli_core PUBLIC storm mpqcli::hash_library) target_link_libraries(mpqcli_core PRIVATE mpqcli::warnings) # Nothing outside this repository links the core. The alias is for the From 1aa13b481a8749746c3c3fbab5a888fce4bf0395 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 27 Sep 2026 20:07:00 +1300 Subject: [PATCH 50/59] Built and tested natively in test.yml, gated the release artefacts behind an input and tightened the workflow permissions and wiring --- .github/workflows/build.yml | 43 +++++++++-------- .github/workflows/main.yml | 24 +++++---- .github/workflows/pr.yml | 8 +-- .github/workflows/prerelease.yml | 18 ++++--- .github/workflows/release.yml | 41 ++++++++-------- .github/workflows/tag.yml | 22 ++++----- .github/workflows/test.yml | 83 +++++++++++++++++--------------- 7 files changed, 124 insertions(+), 115 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c682236..296bc5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,12 @@ name: Build on: - workflow_call + workflow_call: + inputs: + artifacts: + description: Build the full release artefact set, not just the cheap subset + type: boolean + default: false permissions: contents: read @@ -58,22 +63,27 @@ jobs: # The amd64 image is the one published. Saving it here rather than # rebuilding it in a job of its own means release and prerelease push - # exactly the bytes this job smoke-ran + # exactly the bytes this job smoke-ran. Only they consume it, so a pull + # request skips the save - name: Save Docker image as a tar file - if: matrix.arch == 'amd64' + if: matrix.arch == 'amd64' && inputs.artifacts run: | docker tag mpqcli-${{ matrix.arch }} mpqcli docker save mpqcli -o mpqcli-docker.tar - name: Upload Docker image as artefact - if: matrix.arch == 'amd64' + if: matrix.arch == 'amd64' && inputs.artifacts uses: actions/upload-artifact@v7 with: name: mpqcli-docker path: mpqcli-docker.tar retention-days: 1 + # Gated on the full artefact set: test.yml already compiles this platform, so + # a pull request learns nothing from a second Windows build, and the runner + # bills at twice a Linux one build_windows: + if: inputs.artifacts runs-on: windows-2022 steps: - name: Check out repository code @@ -81,27 +91,22 @@ jobs: with: submodules: true - - name: Run cmake + # Raw cmake rather than make: the Makefile needs a POSIX shell, and the + # shipped build gets its own tree rather than the everyday build/dev + - name: Build shell: bash run: | - cmake -B build/dev \ - -G "Visual Studio 17 2022" \ - -A x64 \ - -DMPQCLI_BUILD_APP=ON + cmake -B build/release -G "Visual Studio 17 2022" -A x64 -DMPQCLI_BUILD_APP=ON + cmake --build build/release --config Release + mv build/release/bin/mpqcli.exe ./mpqcli-windows-x86_64.exe - - name: Compile project - run: cmake --build build/dev --config Release - - - name: Check mpqcli version - run: build/dev/bin/mpqcli.exe version - - - name: Copy mpqcli binary - run: cp build/dev/bin/mpqcli.exe build/dev/bin/mpqcli-windows-x86_64.exe + - name: Smoke-run the artefact + shell: bash + run: ./mpqcli-windows-x86_64.exe version - name: Upload binary as artefact uses: actions/upload-artifact@v7 with: name: mpqcli-windows-x86_64.exe - path: build/dev/bin/mpqcli-windows-x86_64.exe + path: mpqcli-windows-x86_64.exe retention-days: 1 - diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 813f4d2..4d5a453 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,8 +20,8 @@ on: - ".dockerignore" - ".gpipe.yml" -# Never cancel on main. The prerelease job deletes the dev release, force-moves -# the tag and pushes the image; a run cancelled part-way through leaves those +# Never cancel on main. The prerelease job deletes the dev release, recreates +# its tag and pushes the image; a run cancelled part-way through leaves those # three disagreeing with each other, which costs more than the runner minutes # cancelling would save concurrency: @@ -29,22 +29,20 @@ concurrency: cancel-in-progress: false permissions: - contents: write - packages: write + contents: read jobs: - build: - uses: ./.github/workflows/build.yml lint: uses: ./.github/workflows/lint.yml - needs: build test: uses: ./.github/workflows/test.yml - needs: build + build: + uses: ./.github/workflows/build.yml + with: + artifacts: true prerelease: uses: ./.github/workflows/prerelease.yml - needs: - - build - - lint - - test - secrets: inherit + needs: [build, lint, test] + permissions: + contents: write + packages: write diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2ab1d49..70c7dc0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -27,12 +27,12 @@ concurrency: permissions: contents: read +# No needs between the three: lint configures its own tree, test builds what it +# runs, and nothing reads build's artefacts until a release jobs: - build: - uses: ./.github/workflows/build.yml lint: uses: ./.github/workflows/lint.yml - needs: build test: uses: ./.github/workflows/test.yml - needs: build + build: + uses: ./.github/workflows/build.yml diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 3cc64c0..53de509 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -33,12 +33,15 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # --target pins the tag to the commit that was built rather than to whatever + # main points at by the time this job runs - name: Create prerelease run: | gh release create dev \ + --target "${GITHUB_SHA}" \ --title "Dev (Pre-release)" \ --prerelease \ - --notes "**Commit:** ${{ github.sha }}" \ + --notes "**Commit:** ${GITHUB_SHA}" \ dist/mpqcli-linux-x86_64 \ dist/mpqcli-linux-aarch64 \ dist/mpqcli-windows-x86_64.exe @@ -58,14 +61,15 @@ jobs: run: | docker load -i mpqcli-docker.tar - - name: Tag Docker image as dev - run: | - docker tag mpqcli ghcr.io/thegraydot/mpqcli:dev - - - name: Push Docker image to GitHub Container Registry + # dev only, never latest: latest tracks releases, so pointing it at a + # rolling build makes an untagged docker pull return whatever last landed + # on main + - name: Push dev tag to GitHub Container Registry env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ACTOR: ${{ github.actor }} + IMAGE: ghcr.io/${{ github.repository }} run: | echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin - docker push ghcr.io/thegraydot/mpqcli:dev + docker tag mpqcli "$IMAGE:dev" + docker push "$IMAGE:dev" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfc2e6b..70a41ec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,8 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} steps: + # Default depth. Nothing here reads git history: get_changelog reads + # CHANGELOG.md from the working tree, and gh release create uses the API - name: Checkout code uses: actions/checkout@v6 @@ -35,24 +37,21 @@ jobs: path: dist merge-multiple: true + - name: Extract release notes from CHANGELOG.md + run: make get_changelog TAG="${GITHUB_REF_NAME}" > /tmp/release-notes.md + - uses: thomaslaurenson/gpipe@v1 with: cosign_sign: true - - name: Extract changelog for this tag - id: changelog - run: | - { - echo "content<> "$GITHUB_OUTPUT" - + # Name every asset. A dist/mpqcli-* glob is shorter and wrong: with + # merge-multiple every artefact lands flat in dist/, so the Docker image + # tar matches too and is attached to the release as if it were a binary - name: Release package run: | - gh release create ${{ github.ref_name }} \ - --title "${{ github.ref_name }}" \ - --notes "$CHANGELOG_CONTENT" \ + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --notes-file /tmp/release-notes.md \ dist/mpqcli-linux-x86_64 \ dist/mpqcli-linux-aarch64 \ dist/mpqcli-windows-x86_64.exe \ @@ -60,8 +59,9 @@ jobs: checksums.txt checksums.txt.sigstore.json env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }} + # Gated on the release: a registry outage then leaves a complete release with + # no image, rather than an image with no release release_docker: needs: release_binaries runs-on: ubuntu-24.04 @@ -75,16 +75,15 @@ jobs: run: | docker load -i mpqcli-docker.tar - - name: Tag Docker image with version - run: | - docker tag mpqcli ghcr.io/thegraydot/mpqcli:${{ needs.release_binaries.outputs.version }} - docker tag mpqcli ghcr.io/thegraydot/mpqcli:latest - - - name: Push Docker image to GitHub Container Registry + - name: Push to GitHub Container Registry env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ACTOR: ${{ github.actor }} + IMAGE: ghcr.io/${{ github.repository }} + VERSION: ${{ needs.release_binaries.outputs.version }} run: | echo "$GH_TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin - docker push ghcr.io/thegraydot/mpqcli:${{ needs.release_binaries.outputs.version }} - docker push ghcr.io/thegraydot/mpqcli:latest + docker tag mpqcli "$IMAGE:$VERSION" + docker tag mpqcli "$IMAGE:latest" + docker push "$IMAGE:$VERSION" + docker push "$IMAGE:latest" diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index b1d427a..d9ad926 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -6,23 +6,21 @@ on: - "v*.*.*" permissions: - contents: write - packages: write - id-token: write + contents: read jobs: - build: - uses: ./.github/workflows/build.yml lint: uses: ./.github/workflows/lint.yml - needs: build test: uses: ./.github/workflows/test.yml - needs: build + build: + uses: ./.github/workflows/build.yml + with: + artifacts: true release: uses: ./.github/workflows/release.yml - needs: - - build - - lint - - test - secrets: inherit + needs: [build, lint, test] + permissions: + contents: write + packages: write + id-token: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c914673..3727f55 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,40 +8,51 @@ permissions: jobs: test_linux: + name: test_linux (${{ matrix.compiler }}, ${{ matrix.build_type }}, ${{ matrix.arch }}) strategy: + # Report every entry independently. Cancelling one on another's failure + # hides half the findings on exactly the runs where they matter + fail-fast: false matrix: include: - # AMD64 tests on ubuntu-24.04 - - arch: amd64 - asset: mpqcli-linux-x86_64 + - compiler: gcc + cxx: g++ + build_type: Debug + arch: amd64 runner: ubuntu-24.04 - # ARM64 tests on ubuntu-24.04-arm - - arch: arm64 - asset: mpqcli-linux-aarch64 + # Release paired with one compiler rather than a third amd64 job: NDEBUG + # and the optimiser are what ships, and nothing else exercises them + - compiler: clang + cxx: clang++-18 + build_type: Release + arch: amd64 + runner: ubuntu-24.04 + # build.yml ships an arm64 binary, so arm64 is tested natively as well + - compiler: gcc + cxx: g++ + build_type: Release + arch: arm64 runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} + # BUILD_TYPE goes in the environment, not on the configure line: build re-runs + # configure through its prerequisite chain, and an invocation without it would + # reset the cache to the Debug default, so the Release entries would quietly + # test Debug and report green + env: + BUILD_TYPE: ${{ matrix.build_type }} steps: - # Only need repo content to install test/requirements.txt - name: Check out repository code uses: actions/checkout@v6 with: - submodules: false - - - name: Download Linux binary (${{ matrix.arch }}) - uses: actions/download-artifact@v8 - with: - name: ${{ matrix.asset }} - path: build/dev/bin + submodules: true - - name: Rename binary to "mpqcli" - run: mv build/dev/bin/${{ matrix.asset }} build/dev/bin/mpqcli + - name: Install clang + if: matrix.compiler == 'clang' + run: sudo apt-get install -y clang-18 - - name: Make binary executable - run: chmod +x ./build/dev/bin/mpqcli - - - name: Check mpqcli version (${{ matrix.arch }}) - run: ./build/dev/bin/mpqcli version + - run: make configure CMAKE_ARGS="-DCMAKE_CXX_COMPILER=${{ matrix.cxx }} -DMPQCLI_WERROR=ON" + - run: make build - name: Setup python uses: actions/setup-python@v6 @@ -53,30 +64,25 @@ jobs: python -m pip install --upgrade pip pip install -r test/requirements.txt - - name: Run tests (${{ matrix.arch }}) - run: | - python3 -m pytest test + - name: Run tests + run: python3 -m pytest test test_windows: runs-on: windows-2022 steps: - # Only need repo content to install test/requirements.txt - name: Check out repository code uses: actions/checkout@v6 with: - submodules: false + submodules: true - - name: Download Windows binary - uses: actions/download-artifact@v8 - with: - name: mpqcli-windows-x86_64.exe - path: build/dev/bin - - - name: Rename binary to "mpqcli" - run: mv build/dev/bin/mpqcli-windows-x86_64.exe build/dev/bin/mpqcli.exe - - - name: Check mpqcli version - run: ./build/dev/bin/mpqcli.exe version + # Raw cmake: the Makefile needs a POSIX shell. MPQCLI_WERROR stays off here + # until the tree is known to compile clean under /W4, which nothing has yet + # checked; it is on in the Linux jobs + - name: Configure and build + shell: bash + run: | + cmake -B build/dev -G "Visual Studio 17 2022" -A x64 -DMPQCLI_BUILD_APP=ON + cmake --build build/dev --config Release --parallel - name: Setup python uses: actions/setup-python@v6 @@ -89,5 +95,4 @@ jobs: pip install -r test/requirements.txt - name: Run tests - run: | - python -m pytest test + run: python -m pytest test From 64ccdc7302704370745127a6e7dbbc921e9d4fa0 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 27 Sep 2026 20:19:00 +1300 Subject: [PATCH 51/59] Threw on a failed enumeration in extract and printed local paths as text --- src/mpq/add.cpp | 6 +++--- src/mpq/extract.cpp | 7 +++---- src/mpq/extract.h | 3 +++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp index aaf4f29..80631c5 100644 --- a/src/mpq/add.cpp +++ b/src/mpq/add.cpp @@ -84,7 +84,7 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi int *skipped) { std::error_code ec; if (!fs::exists(local_file, ec)) { - err << "[!] File doesn't exist on disk: " << local_file << std::endl; + err << "[!] File doesn't exist on disk: " << local_file.string() << std::endl; return 1; } @@ -148,13 +148,13 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi const std::uintmax_t raw_file_size = fs::file_size(local_file, ec); if (ec) { err << "[!] Failed to read file size: (" << ec.value() << ") " << ec.message() << ": " - << local_file << std::endl; + << local_file.string() << std::endl; return 1; } if (raw_file_size > std::numeric_limits::max()) { err << "[!] File exceeds 4GB, size-based compression rules may not apply " "correctly: " - << local_file << std::endl; + << local_file.string() << std::endl; } const DWORD file_size = static_cast( std::min(raw_file_size, static_cast(std::numeric_limits::max()))); diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp index 38e4a58..23f70fe 100644 --- a/src/mpq/extract.cpp +++ b/src/mpq/extract.cpp @@ -31,8 +31,7 @@ int ExtractFiles(HANDLE archive, const fs::path &output, SFILE_FIND_DATA find_data; HANDLE find_handle = SFileFindFirstFile(archive, "*", &find_data, listfile); if (find_handle == nullptr) { - err << "[!] Failed to find first file in MPQ archive." << std::endl; - return 1; + throw ArchiveError("Failed to find first file in MPQ archive."); } int32_t result = 0; @@ -83,7 +82,7 @@ int ExtractFile(HANDLE archive, const fs::path &output, const std::string &file_ } // Guard against path traversal attacks in two stages. First lexically, so a - // ".." entry is rejected before anything is created on disk + // ".." entry is rejected before anything is created on disk. fs::path output_file_path_name = (output_path_base / file_name_string).lexically_normal(); if (!IsWithinDirectory(output_path_base, output_file_path_name)) { err << "[!] Blocked: path traversal attempt detected: " << file_name_string << std::endl; @@ -100,7 +99,7 @@ int ExtractFile(HANDLE archive, const fs::path &output, const std::string &file_ // Second, through the OS to also catch symlinks. Volumes that cannot report // real paths (RAM disks) fail here, in which case the lexical check above is - // the only guard, and the caller warns about that once + // the only guard, and the caller warns about that once. fs::path resolved_base = fs::canonical(output_path_base, ec); if (!ec) { fs::path resolved_output = fs::canonical(output_file_path_name.parent_path(), ec) / diff --git a/src/mpq/extract.h b/src/mpq/extract.h index 8c86ec1..101afd5 100644 --- a/src/mpq/extract.h +++ b/src/mpq/extract.h @@ -11,6 +11,9 @@ namespace mpqcli { +/// Extracts every file the archive can name into output, returning non-zero if any failed +/// +/// @throws ArchiveError if the archive's files cannot be enumerated int ExtractFiles(HANDLE archive, const std::filesystem::path &output, const std::optional &listfile_name, LCID preferred_locale, std::ostream &err, const std::atomic &cancelled); From 7a0eb19f7938d45e93b245d444385329b54acf8f Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 27 Sep 2026 20:34:00 +1300 Subject: [PATCH 52/59] Tidied comments, condensed the changelog, fixed the docs markdown and aligned the badges --- .dockerignore | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/main.yml | 2 +- .github/workflows/release.yml | 4 +-- .github/workflows/test.yml | 4 +-- CHANGELOG.md | 13 ++++---- CMakeLists.txt | 17 ++++------- CONTRIBUTING.md | 40 ++++++++++++------------- Dockerfile | 2 +- Makefile | 4 +-- README.md | 4 +-- app/main.cpp | 4 +-- docs/changelog.md | 13 ++++---- docs/commands/add.md | 56 +++++++++++------------------------ docs/contributing.md | 40 ++++++++++++------------- src/CMakeLists.txt | 4 +-- src/commands/create.cpp | 2 +- src/gamerules/rules.cpp | 2 +- src/mpq/info.cpp | 8 ++--- src/mpq/list.cpp | 25 +++++++--------- src/util/locales.cpp | 15 +++------- 21 files changed, 115 insertions(+), 148 deletions(-) diff --git a/.dockerignore b/.dockerignore index ea1808c..7d4c5cf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,4 @@ test/data/ # .git is deliberately not ignored. The root CMakeLists runs git rev-parse to # stamp the commit into the version header, and without the repository the -# released binary reports "unknown" +# released binary reports "unknown". diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 296bc5b..683d7cd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,7 +64,7 @@ jobs: # The amd64 image is the one published. Saving it here rather than # rebuilding it in a job of its own means release and prerelease push # exactly the bytes this job smoke-ran. Only they consume it, so a pull - # request skips the save + # request skips the save. - name: Save Docker image as a tar file if: matrix.arch == 'amd64' && inputs.artifacts run: | diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4d5a453..30ac1ac 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,7 +23,7 @@ on: # Never cancel on main. The prerelease job deletes the dev release, recreates # its tag and pushes the image; a run cancelled part-way through leaves those # three disagreeing with each other, which costs more than the runner minutes -# cancelling would save +# cancelling would save. concurrency: group: main-${{ github.ref }} cancel-in-progress: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70a41ec..8f2496e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: version: ${{ steps.version.outputs.version }} steps: # Default depth. Nothing here reads git history: get_changelog reads - # CHANGELOG.md from the working tree, and gh release create uses the API + # CHANGELOG.md from the working tree, and gh release create uses the API. - name: Checkout code uses: actions/checkout@v6 @@ -46,7 +46,7 @@ jobs: # Name every asset. A dist/mpqcli-* glob is shorter and wrong: with # merge-multiple every artefact lands flat in dist/, so the Docker image - # tar matches too and is attached to the release as if it were a binary + # tar matches too and is attached to the release as if it were a binary. - name: Release package run: | gh release create "${GITHUB_REF_NAME}" \ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3727f55..6d358f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: name: test_linux (${{ matrix.compiler }}, ${{ matrix.build_type }}, ${{ matrix.arch }}) strategy: # Report every entry independently. Cancelling one on another's failure - # hides half the findings on exactly the runs where they matter + # hides half the findings on exactly the runs where they matter. fail-fast: false matrix: include: @@ -77,7 +77,7 @@ jobs: # Raw cmake: the Makefile needs a POSIX shell. MPQCLI_WERROR stays off here # until the tree is known to compile clean under /W4, which nothing has yet - # checked; it is on in the Linux jobs + # checked; it is on in the Linux jobs. - name: Configure and build shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f489b2..eefc648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,21 @@ ### Changed -- Rename the release assets to mpqcli-linux-x86_64, mpqcli-linux-aarch64 and mpqcli-windows-x86_64.exe -- Ship one static musl Linux binary per architecture instead of glibc and musl pairs +- Ship one static musl Linux binary per architecture, with the release assets renamed by OS and architecture - Publish install scripts and a signed checksums file with each release - Run the Docker image in /data so mounted paths need no prefix -- Exit 2 with a message on an unexpected internal error instead of aborting - Print progress and warnings on stderr, leaving stdout for results alone +- Mark an overwritten file as changed rather than added when adding ### Fixed -- Fail with a non-zero exit code on an unknown subcommand or a missing argument +- Fail with a non-zero exit code on an unknown subcommand, a missing argument or an internal error - Fail with a non-zero exit code when closing, signing, listing or printing a signature fails -- Offer every locale and game profile in zsh, keep filenames out of value lists in fish, and complete archives after a subcommand in PowerShell +- Complete every locale, game profile and archive path in the zsh, fish and PowerShell scripts + +### Removed + +- Remove the install scripts from the repository; each release now carries its own ## 0.11.0 - 2026-09-21 diff --git a/CMakeLists.txt b/CMakeLists.txt index 0b2a29f..dcd5d96 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,10 +8,9 @@ option(MPQCLI_BUILD_APP "Build the mpqcli binary" ON) option(MPQCLI_BUILD_STATIC "Build static binary" OFF) # Off by default, so a new compiler release adding a warning cannot stop a # developer working. The bar is enforced by check_lint instead: clang-tidy -# replays these flags and .clang-tidy treats every warning as an error +# replays these flags and .clang-tidy treats every warning as an error. option(MPQCLI_WERROR "Treat compiler warnings as errors" OFF) -# Set project defaults set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) @@ -33,19 +32,16 @@ foreach(cfg IN ITEMS DEBUG RELEASE RELWITHDEBINFO MINSIZEREL) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_${cfg} "${PROJECT_BINARY_DIR}/bin") endforeach() -# Windows specific settings if(MSVC) add_compile_definitions(__STORMLIB_NO_STATIC_LINK__) endif() -# Static linking configuration if(MPQCLI_BUILD_STATIC) set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -static") set(CMAKE_FIND_LIBRARY_SUFFIXES ".a") set(BUILD_SHARED_LIBS OFF) endif() -# Determine git commit hash execute_process( COMMAND git rev-parse HEAD WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} @@ -58,7 +54,6 @@ if(NOT GIT_COMMIT_RESULT EQUAL 0 OR NOT GIT_COMMIT_HASH) set(GIT_COMMIT_HASH "unknown") endif() -# Handle StormLib dependency if (NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/StormLib/CMakeLists.txt") message(FATAL_ERROR "Missing dependency: StormLib @@ -69,15 +64,15 @@ Did you forget to execute the following commands? git submodule update") endif() -# Use StormLib's bundled zlib/bzip2/libtommath sources instead of requiring -# system packages. This keeps mpqcli buildable everywhere (musl/glibc Docker -# images, MSVC, plain `cmake -B build/dev`) without extra system dependencies. +# Use StormLib's bundled zlib, bzip2 and libtommath sources rather than system +# packages, so the musl Docker image, MSVC and a plain cmake -B build/dev all +# build with nothing installed beyond a compiler. set(STORM_USE_BUNDLED_LIBRARIES ON CACHE BOOL "" FORCE) add_subdirectory(extern/StormLib) mark_system(storm) -# Handle CLI11 dependency. Only app/ parses arguments, so the core never sees it. +# Only app/ parses arguments, so the core never sees CLI11 if(MPQCLI_BUILD_APP) if (NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/CLI11/CMakeLists.txt") message(FATAL_ERROR @@ -92,7 +87,7 @@ Did you forget to execute the following commands? add_subdirectory(extern/CLI11) endif() -# Handle hash-library dependency (CRC32 + MD5 for the add --update checks) +# CRC32 and MD5 for the add --update checks if (NOT EXISTS "${PROJECT_SOURCE_DIR}/extern/hash-library/crc32.cpp") message(FATAL_ERROR "Missing dependency: hash-library diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e4353f..b0d8287 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ If you are unsure whether a feature fits the project, or whether an existing too Clone the repository and initialise submodules: -``` +```sh git clone https://github.com/thegraydot/mpqcli.git cd mpqcli git submodule update --init --recursive @@ -20,7 +20,7 @@ git submodule update --init --recursive Install the clang lint tools, pinned to the major version the checks are formatted against: -``` +```sh sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 ``` @@ -28,20 +28,20 @@ sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 Run `make help` to list all available targets. Common ones: -| Target | Description | -|----------------------------|----------------------------------------------------------------------| -| `make configure` | Configure the cmake build (uses the default compiler) | -| `make build_linux` | Build for Linux using cmake | -| `make build_windows` | Build for Windows using cmake | -| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | -| `make test_mpqcli` | Run the pytest test suite | -| `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | -| `make check_format` | Check formatting only (dry run) | -| `make format` | Auto-fix formatting in-place | -| `make configure_lint` | Configure build/lint with clang++ for clang-tidy | -| `make check_lint` | Run clang-tidy static analysis | -| `make check_embed` | Syntax-check the embedded completion scripts | -| `make clean` | Remove all build, test and docs artefacts | +| Target | Description | +|---|---| +| `make configure` | Configure the cmake build (uses the default compiler) | +| `make build_linux` | Build for Linux using cmake | +| `make build_windows` | Build for Windows using cmake | +| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | +| `make test_mpqcli` | Run the pytest test suite | +| `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | +| `make check_format` | Check formatting only (dry run) | +| `make format` | Auto-fix formatting in-place | +| `make configure_lint` | Configure build/lint with clang++ for clang-tidy | +| `make check_lint` | Run clang-tidy static analysis | +| `make check_embed` | Syntax-check the embedded completion scripts | +| `make clean` | Remove all build, test and docs artefacts | ## Requirements for a Pull Request @@ -49,7 +49,7 @@ Run `make help` to list all available targets. Common ones: Make sure the project builds cleanly on your development machine before opening a PR: -``` +```sh make build_linux # Linux make build_windows # Windows ``` @@ -60,7 +60,7 @@ A PR automatically triggers the CI build workflow, which compiles and tests acro Run the test suite before submitting: -``` +```sh make test_create_venv # first-time setup only make test_mpqcli ``` @@ -75,13 +75,13 @@ If your change adds or modifies user-facing functionality - such as a new subcom All C++ code is formatted with clang-format and analysed with clang-tidy, and the completion scripts are parsed by their shells. `make check_all` runs all three checks. It configures a clang build tree of its own for clang-tidy, so it needs no prior `make configure`: -``` +```sh make check_all ``` If there are formatting violations, auto-fix them with: -``` +```sh make format ``` diff --git a/Dockerfile b/Dockerfile index 529ed12..1cef2dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,6 @@ COPY --from=build /src/build/release/bin/mpqcli /mpqcli # No USER, deliberately. The image exists to process files on a bind mount, and # no fixed uid can match whoever owns the host directory, so a non-root default # would fail every write for most Linux users. Callers who want output owned by -# themselves pass --user "$(id -u):$(id -g)", which the installation docs show +# themselves pass --user "$(id -u):$(id -g)", which the installation docs show. WORKDIR /data ENTRYPOINT ["/mpqcli"] diff --git a/Makefile b/Makefile index adc24a4..e3f5656 100644 --- a/Makefile +++ b/Makefile @@ -27,14 +27,14 @@ LINT_DIR ?= build/lint # Empty on a machine with no GCC, and configure_lint then passes no # --gcc-install-dir at all. The && is what keeps it empty: dirname of an empty -# string is ".", which clang would take as the install directory +# string is ".", which clang would take as the install directory. GCC_INSTALL_DIR := $(shell f=$$(gcc -print-libgcc-file-name 2>/dev/null) && dirname "$$f") README := README.md PACKAGE_URL := https://github.com/thegraydot/mpqcli/pkgs/container/mpqcli # The version is declared once, in project(... VERSION X.Y.Z). The -# cmake_minimum_required line also says VERSION and comes first, so it is skipped +# cmake_minimum_required line also says VERSION and comes first, so it is skipped. VERSION_AWK := /cmake_minimum_required/ { next } \ match($$0, /VERSION[ \t]+[0-9]+\.[0-9]+\.[0-9]+/) { \ v = substr($$0, RSTART, RLENGTH); sub(/VERSION[ \t]+/, "", v); \ diff --git a/README.md b/README.md index 8e7b4da..050fbf1 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ ![Release Build](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/tag.yml?style=flat&label=release&logo=github) ![Main Build](https://img.shields.io/github/actions/workflow/status/thegraydot/mpqcli/main.yml?style=flat&label=main&logo=github) -![Release Version](https://img.shields.io/github/v/release/thegraydot/mpqcli?style=flat) +![Release Version](https://img.shields.io/github/v/release/thegraydot/mpqcli?style=flat&logo=github) ![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?style=flat&label=downloads&logo=github) ![Package downloads](https://img.shields.io/badge/package_downloads-996-green?style=flat&logo=github) -![Release downloads](https://img.shields.io/github/downloads/thegraydot/mpqcli/total?label=release_downloads) ![Package downloads](https://img.shields.io/badge/package_downloads-996-green) +![C++ Version](https://img.shields.io/badge/Version-17-blue?style=flat&logo=cplusplus) A command-line tool to create, add, remove, list, extract, read, rename, and verify MPQ archives using the [StormLib library](https://github.com/ladislav-zezula/StormLib). diff --git a/app/main.cpp b/app/main.cpp index e65fa64..e225138 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -57,7 +57,7 @@ int main(int argc, char **argv) { app.parse(argc, argv); } catch (const CLI::ParseError &e) { // An empty command line is a request for help rather than a mistake. The argc - // check is what keeps an unknown subcommand, which raises the same error, failing + // check is what keeps an unknown subcommand, which raises the same error, failing. if (argc == 1 && e.get_exit_code() == static_cast(CLI::ExitCodes::RequiredError)) { context.out << app.help() << std::endl; return 0; @@ -65,7 +65,7 @@ int main(int argc, char **argv) { return app.exit(e, context.out, context.err); } catch (const mpqcli::Interrupted &) { // The user asked for this, so nothing is reported. Dying of the signal rather - // than returning a code is what lets the shell treat it as an interrupt + // than returning a code is what lets the shell treat it as an interrupt. std::signal(SIGINT, SIG_DFL); std::raise(SIGINT); return 1; // Not reached diff --git a/docs/changelog.md b/docs/changelog.md index 9f489b2..eefc648 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,18 +8,21 @@ ### Changed -- Rename the release assets to mpqcli-linux-x86_64, mpqcli-linux-aarch64 and mpqcli-windows-x86_64.exe -- Ship one static musl Linux binary per architecture instead of glibc and musl pairs +- Ship one static musl Linux binary per architecture, with the release assets renamed by OS and architecture - Publish install scripts and a signed checksums file with each release - Run the Docker image in /data so mounted paths need no prefix -- Exit 2 with a message on an unexpected internal error instead of aborting - Print progress and warnings on stderr, leaving stdout for results alone +- Mark an overwritten file as changed rather than added when adding ### Fixed -- Fail with a non-zero exit code on an unknown subcommand or a missing argument +- Fail with a non-zero exit code on an unknown subcommand, a missing argument or an internal error - Fail with a non-zero exit code when closing, signing, listing or printing a signature fails -- Offer every locale and game profile in zsh, keep filenames out of value lists in fish, and complete archives after a subcommand in PowerShell +- Complete every locale, game profile and archive path in the zsh, fish and PowerShell scripts + +### Removed + +- Remove the install scripts from the repository; each release now carries its own ## 0.11.0 - 2026-09-21 diff --git a/docs/commands/add.md b/docs/commands/add.md index e48f09e..570cd34 100644 --- a/docs/commands/add.md +++ b/docs/commands/add.md @@ -2,9 +2,7 @@ Add one or more files to an existing MPQ archive. -The archive is always the first positional argument, followed by one or more files or directories. -All inputs are processed in a single open/close cycle, which is significantly faster than -calling `add` once per file. +The archive is always the first positional argument, followed by one or more files or directories. All inputs are processed in a single open/close cycle, which is significantly faster than calling `add` once per file. ## Add a single file @@ -27,8 +25,7 @@ $ mpqcli add wow-patch.mpq fth.txt fta.txt fts.txt ## Add files from stdin -Pass `-` as the file argument to read paths from standard input. This works with `find`, -`ls`, or any other tool that produces file paths. +Pass `-` as the file argument to read paths from standard input. This works with `find`, `ls`, or any other tool that produces file paths. ```bash $ find . -name "*.blp" | mpqcli add wow-patch.mpq - @@ -39,8 +36,7 @@ $ find . -name "*.blp" | mpqcli add wow-patch.mpq - ## Add a directory -Pass a directory path to recursively add all files within it. The directory structure is -preserved relative to the directory root. +Pass a directory path to recursively add all files within it. The directory structure is preserved relative to the directory root. ```bash $ mpqcli add wow-patch.mpq textures/ @@ -76,21 +72,17 @@ $ mpqcli add wow-patch.mpq textures/ --path textures ## Replacing files that already exist -`add` never replaces an archived file unless you ask it to. Two flags control what -happens when a file is already present, and they are **mutually exclusive**, passing -both is an error, because "replace everything" and "replace only what changed" are -contradictory requests. +`add` never replaces an archived file unless you ask it to. Two flags control what happens when a file is already present, and they are **mutually exclusive**, passing both is an error, because "replace everything" and "replace only what changed" are contradictory requests. | Flags | Behaviour when the file already exists | -| --- | --- | +|---|---| | *(neither)* | Skip it and leave the archived copy alone | | `-w`, `--overwrite` | Replace it unconditionally | | `-u`, `--update` | Replace it only if the local file differs | ### Default: skip -Without either flag, an existing file is skipped. This is a normal outcome, not an -error, so the exit code is still `0`: +Without either flag, an existing file is skipped. This is a normal outcome, not an error, so the exit code is still `0`: ```bash $ mpqcli add wow-patch.mpq allegiance.txt @@ -102,14 +94,13 @@ $ mpqcli add wow-patch.mpq allegiance.txt ```bash $ mpqcli add wow-patch.mpq allegiance.txt --overwrite -[+] File already exists in MPQ archive: allegiance.txt - Overwriting... +[~] File already exists in MPQ archive: allegiance.txt - Overwriting... [+] Adding file: allegiance.txt ``` ## Add with a locale -Use `--locale` to store the file under a specific locale. Files added without `--locale` -use the default locale. +Use `--locale` to store the file under a specific locale. Files added without `--locale` use the default locale. ```bash $ mpqcli add wow-patch.mpq allianz.txt --locale deDE @@ -127,34 +118,23 @@ $ mpqcli add archive.mpq khwhat1.wav --game warcraft2 ## Replace only what changed with --update -The `--update` flag replaces a file only when the local copy differs from the archived -one, and skips it otherwise. This is useful for incremental updates where only changed -files need to be re-added. It applies to single files and directories alike, the -comparison is always made per file. +The `--update` flag replaces a file only when the local copy differs from the archived one, and skips it otherwise. This is useful for incremental updates where only changed files need to be re-added. It applies to single files and directories alike, the comparison is always made per file. The skip decision follows this chain: 1. **File size** must match. If the sizes differ the file is always re-added. 2. If the sizes match, the archive's `(attributes)` file is consulted: - - **Timestamp**: if the archive stores file timestamps, the local file's - last-modification time is compared at one-second resolution. A match skips the file. - - **MD5**: if the timestamp did not match or is unavailable, and the archive stores MD5 - checksums, the MD5 of the local file is computed and compared. A match skips the file. - - **CRC32**: if neither timestamp nor MD5 produced a match or was available, and the - archive stores CRC32 checksums, those are compared. A match skips the file. - - **No attributes**: if the archive has no `(attributes)` file, the file is always - re-added even when sizes match, because no reliable content check is possible. - -Note: a timestamp match alone skips the file, without comparing checksums. A file whose -content changed but whose size and modification time were both preserved (for example by -`cp -p` or tools that restore timestamps) will therefore not be detected as changed. This -is the same trade-off tools like `rsync` make by default. If exact change detection -matters, use `--overwrite` instead of `--update` to unconditionally replace every file. + - **Timestamp**: if the archive stores file timestamps, the local file's last-modification time is compared at one-second resolution. A match skips the file. + - **MD5**: if the timestamp did not match or is unavailable, and the archive stores MD5 checksums, the MD5 of the local file is computed and compared. A match skips the file. + - **CRC32**: if neither timestamp nor MD5 produced a match or was available, and the archive stores CRC32 checksums, those are compared. A match skips the file. + - **No attributes**: if the archive has no `(attributes)` file, the file is always re-added even when sizes match, because no reliable content check is possible. + +Note: a timestamp match alone skips the file, without comparing checksums. A file whose content changed but whose size and modification time were both preserved (for example by `cp -p` or tools that restore timestamps) will therefore not be detected as changed. This is the same trade-off tools like `rsync` make by default. If exact change detection matters, use `--overwrite` instead of `--update` to unconditionally replace every file. ```bash $ mpqcli add wow-patch.mpq textures/ --update -[~] Skipping unchanged file: Creature\Bear\Bear.blp (MD5 matches) -[+] File already exists in MPQ archive: Creature\Wolf\Wolf.blp - Overwriting... +[*] Skipping unchanged file: Creature\Bear\Bear.blp (MD5 matches) +[~] File already exists in MPQ archive: Creature\Wolf\Wolf.blp - Overwriting... [+] Adding file: Creature\Wolf\Wolf.blp [*] For textures: 1 files added, 1 files skipped, 0 files failed. ``` @@ -163,7 +143,7 @@ The same comparison applies when the target is a single file: ```bash $ mpqcli add wow-patch.mpq allegiance.txt --update -[~] Skipping unchanged file: allegiance.txt (MD5 matches) +[*] Skipping unchanged file: allegiance.txt (MD5 matches) ``` Flow chart of update method: diff --git a/docs/contributing.md b/docs/contributing.md index 2e4353f..b0d8287 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -12,7 +12,7 @@ If you are unsure whether a feature fits the project, or whether an existing too Clone the repository and initialise submodules: -``` +```sh git clone https://github.com/thegraydot/mpqcli.git cd mpqcli git submodule update --init --recursive @@ -20,7 +20,7 @@ git submodule update --init --recursive Install the clang lint tools, pinned to the major version the checks are formatted against: -``` +```sh sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 ``` @@ -28,20 +28,20 @@ sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 Run `make help` to list all available targets. Common ones: -| Target | Description | -|----------------------------|----------------------------------------------------------------------| -| `make configure` | Configure the cmake build (uses the default compiler) | -| `make build_linux` | Build for Linux using cmake | -| `make build_windows` | Build for Windows using cmake | -| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | -| `make test_mpqcli` | Run the pytest test suite | -| `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | -| `make check_format` | Check formatting only (dry run) | -| `make format` | Auto-fix formatting in-place | -| `make configure_lint` | Configure build/lint with clang++ for clang-tidy | -| `make check_lint` | Run clang-tidy static analysis | -| `make check_embed` | Syntax-check the embedded completion scripts | -| `make clean` | Remove all build, test and docs artefacts | +| Target | Description | +|---|---| +| `make configure` | Configure the cmake build (uses the default compiler) | +| `make build_linux` | Build for Linux using cmake | +| `make build_windows` | Build for Windows using cmake | +| `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | +| `make test_mpqcli` | Run the pytest test suite | +| `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | +| `make check_format` | Check formatting only (dry run) | +| `make format` | Auto-fix formatting in-place | +| `make configure_lint` | Configure build/lint with clang++ for clang-tidy | +| `make check_lint` | Run clang-tidy static analysis | +| `make check_embed` | Syntax-check the embedded completion scripts | +| `make clean` | Remove all build, test and docs artefacts | ## Requirements for a Pull Request @@ -49,7 +49,7 @@ Run `make help` to list all available targets. Common ones: Make sure the project builds cleanly on your development machine before opening a PR: -``` +```sh make build_linux # Linux make build_windows # Windows ``` @@ -60,7 +60,7 @@ A PR automatically triggers the CI build workflow, which compiles and tests acro Run the test suite before submitting: -``` +```sh make test_create_venv # first-time setup only make test_mpqcli ``` @@ -75,13 +75,13 @@ If your change adds or modifies user-facing functionality - such as a new subcom All C++ code is formatted with clang-format and analysed with clang-tidy, and the completion scripts are parsed by their shells. `make check_all` runs all three checks. It configures a clang build tree of its own for clang-tidy, so it needs no prior `make configure`: -``` +```sh make check_all ``` If there are formatting violations, auto-fix them with: -``` +```sh make format ``` diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b68be24..61ece65 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,7 +1,7 @@ # The warning bar, defined once and linked privately by every target the project # owns. -Wsign-conversion is spelled out because clang folds it into -Wconversion # and GCC does not; left implicit, the two compilers hold different bars for the -# same source. The vendored targets deliberately do not link this +# same source. The vendored targets deliberately do not link this. add_library(mpqcli_warnings INTERFACE) target_compile_options(mpqcli_warnings INTERFACE $<$:/W4 /permissive-> @@ -76,5 +76,5 @@ target_link_libraries(mpqcli_core PRIVATE mpqcli::warnings) # Nothing outside this repository links the core. The alias is for the # configure-time check: a typo in mpqcli::core fails now, where a typo in a raw -# mpqcli_core is taken for a system library and fails at link time instead +# mpqcli_core is taken for a system library and fails at link time instead. add_library(mpqcli::core ALIAS mpqcli_core) diff --git a/src/commands/create.cpp b/src/commands/create.cpp index 41d2338..75f654c 100644 --- a/src/commands/create.cpp +++ b/src/commands/create.cpp @@ -90,7 +90,7 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic &info_propert [&](bool print_name) { TMPQHeader header = GetFileInfo(archive, SFileMpqHeader); uint16_t format_version = - header.wFormatVersion + 1; // Add +1 because StormLib starts at 0 + header.wFormatVersion + 1; // StormLib counts the formats from 0 if (print_name) { out << "Format version: "; } @@ -81,16 +81,14 @@ void PrintMpqInfo(HANDLE archive, const std::optional &info_propert } }}}; - // If infoProperty is not set, print all properties with their names (key) - // Otherwise, print only the specified property value if (!info_property.has_value()) { for (const auto &[key, action] : property_actions) { - action(true); // Print property name and value + action(true); } } else { auto it = property_actions.find(info_property.value()); if (it != property_actions.end()) { - it->second(false); // Print only the value + it->second(false); } } } diff --git a/src/mpq/list.cpp b/src/mpq/list.cpp index f614897..ee174fd 100644 --- a/src/mpq/list.cpp +++ b/src/mpq/list.cpp @@ -38,11 +38,10 @@ void ListFiles(HANDLE archive, const std::optional &listf properties.empty() ? std::vector{"file-size", "locale", "file-time"} : properties; if (!properties.empty()) { - list_detailed = - true; // If the user specified properties, we need to print the detailed output + // Named properties are only printed by the detailed listing + list_detailed = true; } - // Map of property name to SFileInfoClass, defined once, outside the loop static const std::map property_info_class = { {"hash-index", SFileInfoHashIndex}, {"name-hash1", SFileInfoNameHash1}, @@ -59,25 +58,23 @@ void ListFiles(HANDLE archive, const std::optional &listf {"encryption-key-raw", SFileInfoEncryptionKeyRaw}, }; - std::set - seen_file_names; // Used to prevent printing the same file name multiple times + // The enumeration yields one entry per locale of a name and the detailed listing + // prints every locale in one go, so later entries for a seen name are skipped + std::set seen_file_names; do { - // Skip special files unless user wants to list all (like ls -a) if (!list_all && std::find(special_mpq_files.begin(), special_mpq_files.end(), find_data.cFileName) != special_mpq_files.end()) { continue; } - // Print the detailed (long) file listing (like ls -l) if (list_detailed) { if (seen_file_names.find(find_data.cFileName) != seen_file_names.end()) { - // Filename has been seen before, and thus printed before. Skip over it. continue; } seen_file_names.insert(find_data.cFileName); // Multiple files can be stored with identical filenames under different locales - DWORD max_locales = 32; // This will be updated in the call to SFileEnumLocales + DWORD max_locales = 32; // Updated in place by SFileEnumLocales std::vector file_locale_vec(max_locales); LCID *file_locales = file_locale_vec.data(); @@ -85,10 +82,9 @@ void ListFiles(HANDLE archive, const std::optional &listf SFileEnumLocales(archive, find_data.cFileName, file_locales, &max_locales, 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. + // StormLib rejects a name it cannot resolve (IsPseudoFileName) before + // touching file_locales or max_locales, so the unknown name is listed + // once under the default locale max_locales = 1; file_locales[0] = default_locale; @@ -153,9 +149,8 @@ void ListFiles(HANDLE archive, const std::optional &listf out << " " << find_data.cFileName << std::endl; SFileCloseFile(file); } - SFileSetLocale(default_locale); // Reset locale to default after changing it + SFileSetLocale(default_locale); } else { - // Print just the filename (like default ls command output) out << find_data.cFileName << std::endl; } diff --git a/src/util/locales.cpp b/src/util/locales.cpp index b80f474..56a251c 100644 --- a/src/util/locales.cpp +++ b/src/util/locales.cpp @@ -9,12 +9,8 @@ namespace mpqcli { 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: +// Files with the same name can coexist under different locales; the identifiers +// are Windows LCIDs, listed in MS-LCID: // https://winprotocoldoc.z19.web.core.windows.net/MS-LCID/%5bMS-LCID%5d.pdf const std::map locale_to_lang_map = { @@ -38,11 +34,10 @@ const std::map locale_to_lang_map = { {0x816, "ptPT"}, // Portuguese (Portugal) }; -// Create a reverse map for language-to-locale lookups const std::map lang_to_locale_map = []() { std::map reverse_map; for (const auto &[locale, lang] : locale_to_lang_map) { - if (locale != default_locale) { // Skip the default locale to avoid duplication + if (locale != default_locale) { // 0x000 prints as enUS but enUS resolves to 0x409 reverse_map[lang] = locale; } } @@ -91,7 +86,6 @@ LCID LangToLocale(const std::string &lang) { return it->second; } - // Try parsing as a hexadecimal LCID LCID hex_locale = ParseHexLocale(lang); if (hex_locale != default_locale) { return hex_locale; @@ -103,11 +97,10 @@ LCID LangToLocale(const std::string &lang) { std::vector GetAllLocales() { std::vector locales; for (const auto &[locale, lang] : locale_to_lang_map) { - if (locale != default_locale) { // Skip the default locale to avoid duplication + if (locale != default_locale) { // enUS would otherwise be listed twice locales.push_back(lang); } } - // Sort the locales for consistent output std::sort(locales.begin(), locales.end()); return locales; } From c3a13cfc43722bb65cd6777678552aca9a494155 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 27 Sep 2026 20:52:00 +1300 Subject: [PATCH 53/59] Trimmed the game rules comments and put the docs headings in sentence case --- CONTRIBUTING.md | 12 ++++++------ docs/advanced.md | 2 +- docs/commands/completion.md | 2 +- docs/contributing.md | 12 ++++++------ docs/installation.md | 4 ++-- src/gamerules/rules.cpp | 24 +++++++----------------- 6 files changed, 23 insertions(+), 33 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0d8287..e444669 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,13 +2,13 @@ Contributions are welcome. Please read the guidelines below before opening a pull request. -## Before You Start +## Before you start If you are unsure whether a feature fits the project, or whether an existing tool could already be combined with `mpqcli` to achieve the same result, open an issue first. This avoids wasted effort and keeps the project focused. **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 +## Prerequisites and setup Clone the repository and initialise submodules: @@ -24,7 +24,7 @@ Install the clang lint tools, pinned to the major version the checks are formatt sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 ``` -## Makefile Reference +## Makefile reference Run `make help` to list all available targets. Common ones: @@ -43,7 +43,7 @@ Run `make help` to list all available targets. Common ones: | `make check_embed` | Syntax-check the embedded completion scripts | | `make clean` | Remove all build, test and docs artefacts | -## Requirements for a Pull Request +## Requirements for a pull request ### 1. Builds on your platform @@ -114,7 +114,7 @@ if (flags & MPQ_FILE_COMPRESS) result += 'c'; // clang-format on ``` -## Known Design Constraints +## Known design constraints ### StormLib locale state is global and not thread-safe @@ -127,7 +127,7 @@ This means: If you add a new StormLib call that is locale-sensitive, follow the existing pattern: call `SFileSetLocale` immediately before it, with no intervening calls between the two. -## Workflow Summary +## Workflow summary 1. Fork the repository and create a branch for your change 2. Run `git submodule update --init --recursive` after cloning diff --git a/docs/advanced.md b/docs/advanced.md index a44118a..d334761 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -1,4 +1,4 @@ -# Advanced Examples +# Advanced examples ## Search and extract files on Linux diff --git a/docs/commands/completion.md b/docs/commands/completion.md index 4b2639a..5b37a0a 100644 --- a/docs/commands/completion.md +++ b/docs/commands/completion.md @@ -2,7 +2,7 @@ Print a shell completion script to stdout. -## Supported Shells +## Supported shells - bash - zsh diff --git a/docs/contributing.md b/docs/contributing.md index b0d8287..e444669 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -2,13 +2,13 @@ Contributions are welcome. Please read the guidelines below before opening a pull request. -## Before You Start +## Before you start If you are unsure whether a feature fits the project, or whether an existing tool could already be combined with `mpqcli` to achieve the same result, open an issue first. This avoids wasted effort and keeps the project focused. **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 +## Prerequisites and setup Clone the repository and initialise submodules: @@ -24,7 +24,7 @@ Install the clang lint tools, pinned to the major version the checks are formatt sudo apt-get install -y clang-18 clang-format-18 clang-tidy-18 ``` -## Makefile Reference +## Makefile reference Run `make help` to list all available targets. Common ones: @@ -43,7 +43,7 @@ Run `make help` to list all available targets. Common ones: | `make check_embed` | Syntax-check the embedded completion scripts | | `make clean` | Remove all build, test and docs artefacts | -## Requirements for a Pull Request +## Requirements for a pull request ### 1. Builds on your platform @@ -114,7 +114,7 @@ if (flags & MPQ_FILE_COMPRESS) result += 'c'; // clang-format on ``` -## Known Design Constraints +## Known design constraints ### StormLib locale state is global and not thread-safe @@ -127,7 +127,7 @@ This means: If you add a new StormLib call that is locale-sensitive, follow the existing pattern: call `SFileSetLocale` immediately before it, with no intervening calls between the two. -## Workflow Summary +## Workflow summary 1. Fork the repository and create a branch for your change 2. Run `git submodule update --init --recursive` after cloning diff --git a/docs/installation.md b/docs/installation.md index 651467c..73f2b56 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,6 +1,6 @@ # Installation -## Precompiled Binaries +## Precompiled binaries Pre-built binaries are available for Linux and Windows. @@ -21,7 +21,7 @@ Both installers put `mpqcli` on the system path and may ask for elevation; pass Check the [latest release with binaries](https://github.com/thegraydot/mpqcli/releases). -## Docker Image +## Docker image The Docker image for `mpqcli` is hosted on [GitHub Container Registry (GHCR)](https://ghcr.io). It provides a lightweight and portable way to use `mpqcli` without needing to build or download a binary. diff --git a/src/gamerules/rules.cpp b/src/gamerules/rules.cpp index e9ab1ed..cdc5e25 100644 --- a/src/gamerules/rules.cpp +++ b/src/gamerules/rules.cpp @@ -17,11 +17,10 @@ bool GameRules::MatchFileMask(const std::string &filename, const std::string &ma std::string lower_filename = ToLower(filename); std::string lower_mask = ToLower(mask); - // Replace backslashes with forward slashes for consistent path handling + // A mask and a name may each use either separator std::replace(lower_filename.begin(), lower_filename.end(), '\\', '/'); std::replace(lower_mask.begin(), lower_mask.end(), '\\', '/'); - // Simple wildcard matching size_t mask_pos = 0; size_t file_pos = 0; size_t star_pos = std::string::npos; @@ -68,7 +67,6 @@ void GameRules::AddRuleDefault(DWORD mpq_flags, DWORD compression_first, DWORD c CompressionSettings GameRules::GetCompressionSettings(const std::string &filename, const DWORD file_size) const { - // First matching rule wins for (const auto &rule : rules_) { switch (rule.type) { case RuleType::FILE_MASK: @@ -78,7 +76,6 @@ CompressionSettings GameRules::GetCompressionSettings(const std::string &filenam break; case RuleType::FILE_SIZE: { - // Use UINT32_MAX to indicate "no upper limit" bool has_upper_limit = (rule.size_max != UINT32_MAX); bool in_range = file_size >= rule.size_min && (!has_upper_limit || file_size <= rule.size_max); @@ -94,7 +91,7 @@ CompressionSettings GameRules::GetCompressionSettings(const std::string &filenam } } - // Fallback if no rules match (shouldn't happen if DEFAULT rule is present) + // Reached only by a profile that declares no DEFAULT rule return {MPQ_FILE_COMPRESS | MPQ_FILE_ENCRYPTED, MPQ_COMPRESSION_PKWARE, MPQ_COMPRESSION_NEXT_SAME}; } @@ -128,7 +125,7 @@ void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides &overrid if (overrides.file_flags2.has_value()) { create_settings_.file_flags2 = overrides.file_flags2.value(); - user_set_file_flags2 = true; // User explicitly set this value + user_set_file_flags2 = true; } if (overrides.file_flags3.has_value()) { @@ -142,21 +139,14 @@ void GameRules::OverrideCreateSettings(const MpqCreateSettingsOverrides &overrid // Then the adjustments the overrides imply, only where the user left the // dependent value alone - // file_flags2 controls the (attributes) file, which is only meaningful when - // attr_flags is also set. According to StormLib's SFileCreateArchive.cpp: - // - The (attributes) file is created only when BOTH file_flags2 AND attr_flags are non-zero - // - If attr_flags is set but file_flags2 is still 0 (not overridden by user or profile), - // we should set file_flags2 to MPQ_FILE_DEFAULT_INTERNAL to enable the attributes file. - + // StormLib (SFileCreateArchive.cpp) writes the (attributes) file only when both + // file_flags2 and attr_flags are non-zero, so attributes asked for without a + // storage flag would silently never be written. An explicit zero from the user + // is left alone. if (!user_set_file_flags2 && create_settings_.file_flags2 == 0 && create_settings_.attr_flags != 0) { - // User wants attributes (attr_flags is set) but hasn't specified how to store - // the (attributes) file itself. Use the default internal file flags. create_settings_.file_flags2 = MPQ_FILE_DEFAULT_INTERNAL; } - - // Note: If user explicitly sets file_flags2 to 0 via override, we respect that choice - // even if attr_flags is non-zero. } } // namespace mpqcli From b3289f701792196b4ed9321b54b37cfb21701c33 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 4 Oct 2026 17:30:21 +1300 Subject: [PATCH 54/59] Fixed the build directory and venv path in the testing docs --- docs/building.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/building.md b/docs/building.md index 5f913ab..e39f0be 100644 --- a/docs/building.md +++ b/docs/building.md @@ -48,13 +48,17 @@ To configure the testing environment you will need Python installed, as well as ```bash $ sudo apt install python3-venv python3-pip -$ python3 -m venv test/.venv -$ source test/.venv/bin/activate +$ python3 -m venv .venv +$ source .venv/bin/activate $ pip3 install -r test/requirements.txt ``` -Then you can run the tests using: +The tests run the binary in `build/dev`, so build it there and then run the tests using: ```bash +$ cmake -B build/dev +$ cmake --build build/dev $ python3 -m pytest test -s ``` + +`make test` does the same in one step. From bca96dbba933ee6bcb27febe6e0e8efd68d346ae Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 4 Oct 2026 17:30:54 +1300 Subject: [PATCH 55/59] Removed the unused build_linux and build_windows targets and pointed the contributing guide at make build --- CONTRIBUTING.md | 10 +++++----- Makefile | 15 --------------- docs/contributing.md | 10 +++++----- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e444669..b24cc96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,8 +31,7 @@ Run `make help` to list all available targets. Common ones: | Target | Description | |---|---| | `make configure` | Configure the cmake build (uses the default compiler) | -| `make build_linux` | Build for Linux using cmake | -| `make build_windows` | Build for Windows using cmake | +| `make build` | Build the project into build/dev | | `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | | `make test_mpqcli` | Run the pytest test suite | | `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | @@ -50,10 +49,11 @@ Run `make help` to list all available targets. Common ones: Make sure the project builds cleanly on your development machine before opening a PR: ```sh -make build_linux # Linux -make build_windows # Windows +make build ``` +On Windows without a POSIX shell, use the cmake commands from the building page instead. + A PR automatically triggers the CI build workflow, which compiles and tests across all supported Linux targets (AMD64 and ARM64). You are not expected to reproduce all of those locally. ### 2. Tests pass @@ -132,7 +132,7 @@ If you add a new StormLib call that is locale-sensitive, follow the existing pat 1. Fork the repository and create a branch for your change 2. Run `git submodule update --init --recursive` after cloning 3. Install the clang tools as shown above -4. Make your changes and verify they build: `make build_linux` +4. Make your changes and verify they build: `make build` 5. Run `make check_all`, fixing 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/Makefile b/Makefile index e3f5656..5336ff3 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,6 @@ CPP_LINT_DIRS := $(wildcard src app) JOBS ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) BUILD_TYPE ?= Debug CMAKE_ARGS ?= -CMAKE_BUILD_TYPE := Release MPQCLI_BUILD_APP ?= ON # Prefer the versioned tool, fall back to the plain name. Falling back rather @@ -61,20 +60,6 @@ configure: ## Configure the cmake build build: configure ## Build the project cmake --build build/dev --parallel $(JOBS) -.PHONY: build_linux -build_linux: ## Build for Linux using cmake - cmake -B build/dev \ - -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ - -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) - cmake --build build/dev --parallel $(JOBS) - -.PHONY: build_windows -build_windows: ## Build for Windows using cmake - cmake -B build/dev \ - -DCMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \ - -DMPQCLI_BUILD_APP=$(MPQCLI_BUILD_APP) - cmake --build build/dev --config $(CMAKE_BUILD_TYPE) --parallel $(JOBS) - ##@ TEST .PHONY: test diff --git a/docs/contributing.md b/docs/contributing.md index e444669..b24cc96 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -31,8 +31,7 @@ Run `make help` to list all available targets. Common ones: | Target | Description | |---|---| | `make configure` | Configure the cmake build (uses the default compiler) | -| `make build_linux` | Build for Linux using cmake | -| `make build_windows` | Build for Windows using cmake | +| `make build` | Build the project into build/dev | | `make test_create_venv` | Create Python venv and install test dependencies (first-time only) | | `make test_mpqcli` | Run the pytest test suite | | `make check_all` | Run every static check (clang-format, clang-tidy, completion scripts) | @@ -50,10 +49,11 @@ Run `make help` to list all available targets. Common ones: Make sure the project builds cleanly on your development machine before opening a PR: ```sh -make build_linux # Linux -make build_windows # Windows +make build ``` +On Windows without a POSIX shell, use the cmake commands from the building page instead. + A PR automatically triggers the CI build workflow, which compiles and tests across all supported Linux targets (AMD64 and ARM64). You are not expected to reproduce all of those locally. ### 2. Tests pass @@ -132,7 +132,7 @@ If you add a new StormLib call that is locale-sensitive, follow the existing pat 1. Fork the repository and create a branch for your change 2. Run `git submodule update --init --recursive` after cloning 3. Install the clang tools as shown above -4. Make your changes and verify they build: `make build_linux` +4. Make your changes and verify they build: `make build` 5. Run `make check_all`, fixing 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 From 7e9d5161de3934a6e84f7d6536886cdabe143b8f Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 4 Oct 2026 17:31:21 +1300 Subject: [PATCH 56/59] Brought the MPQCLI_WERROR and fish completion comments up to date --- CMakeLists.txt | 5 +++-- completion/mpqcli.fish | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index dcd5d96..4d1091d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,8 +7,9 @@ project(mpqcli VERSION 0.12.0) option(MPQCLI_BUILD_APP "Build the mpqcli binary" ON) option(MPQCLI_BUILD_STATIC "Build static binary" OFF) # Off by default, so a new compiler release adding a warning cannot stop a -# developer working. The bar is enforced by check_lint instead: clang-tidy -# replays these flags and .clang-tidy treats every warning as an error. +# developer working. The Linux CI test builds turn it on, and check_lint holds +# the same bar: clang-tidy replays these flags and .clang-tidy treats every +# warning as an error. option(MPQCLI_WERROR "Treat compiler warnings as errors" OFF) set(CMAKE_CXX_STANDARD 17) diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish index 7fde146..48f3fc0 100644 --- a/completion/mpqcli.fish +++ b/completion/mpqcli.fish @@ -32,8 +32,8 @@ function __mpqcli_positional_index echo $index end -# Shared value sets, kept in step with app/main.cpp, src/util/locales.cpp and -# src/gamerules/profiles.cpp +# Shared value sets, kept in step with app/main.cpp, app/info.cpp, app/list.cpp, +# src/util/locales.cpp and src/gamerules/profiles.cpp set -l __mpqcli_subcommands \ version about info create add remove rename list extract read verify compact completion From e668ec82db7d35d30a25a869a89845e85f1aab5a Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 4 Oct 2026 17:32:04 +1300 Subject: [PATCH 57/59] Dropped five unused includes and included profiles.h in profiles.cpp --- src/gamerules/profiles.cpp | 2 ++ src/mpq/info.cpp | 1 - src/mpq/query.cpp | 1 - src/mpq/read.cpp | 1 - src/mpq/remove.cpp | 1 - src/mpq/rename.cpp | 1 - 6 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/gamerules/profiles.cpp b/src/gamerules/profiles.cpp index 0caf9d3..2796e9f 100644 --- a/src/gamerules/profiles.cpp +++ b/src/gamerules/profiles.cpp @@ -1,3 +1,5 @@ +#include "gamerules/profiles.h" + #include #include #include diff --git a/src/mpq/info.cpp b/src/mpq/info.cpp index 0013e62..adb31b5 100644 --- a/src/mpq/info.cpp +++ b/src/mpq/info.cpp @@ -10,7 +10,6 @@ #include #include "mpq/query.h" -#include "util/format.h" namespace mpqcli { diff --git a/src/mpq/query.cpp b/src/mpq/query.cpp index 3a2ff2d..32d1917 100644 --- a/src/mpq/query.cpp +++ b/src/mpq/query.cpp @@ -9,7 +9,6 @@ #include #include "util/hash.h" -#include "util/locales.h" #include "util/path.h" namespace fs = std::filesystem; diff --git a/src/mpq/read.cpp b/src/mpq/read.cpp index f738029..3fc329e 100644 --- a/src/mpq/read.cpp +++ b/src/mpq/read.cpp @@ -6,7 +6,6 @@ #include #include "mpq/query.h" -#include "util/format.h" #include "util/locales.h" namespace mpqcli { diff --git a/src/mpq/remove.cpp b/src/mpq/remove.cpp index b1fd299..d77b9a9 100644 --- a/src/mpq/remove.cpp +++ b/src/mpq/remove.cpp @@ -6,7 +6,6 @@ #include #include "mpq/query.h" -#include "util/format.h" #include "util/locales.h" namespace mpqcli { diff --git a/src/mpq/rename.cpp b/src/mpq/rename.cpp index b24e357..4aea16e 100644 --- a/src/mpq/rename.cpp +++ b/src/mpq/rename.cpp @@ -6,7 +6,6 @@ #include #include "mpq/query.h" -#include "util/format.h" #include "util/locales.h" #include "util/path.h" From 5197063cc1f1bdca54f932911c53a18f501fd3c0 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 4 Oct 2026 17:32:22 +1300 Subject: [PATCH 58/59] Scoped the Pages permissions in the docs workflow to the deploy job --- .github/workflows/docs.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4d75ba3..a3923f3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -15,11 +15,8 @@ concurrency: group: "pages" cancel-in-progress: false -# Required permissions for GitHub Pages deployment permissions: contents: read - pages: write - id-token: write env: MDBOOK_VERSION: 0.5.4 @@ -54,6 +51,10 @@ jobs: deploy: needs: build runs-on: ubuntu-24.04 + # Only the deploy job publishes, so only it gets the Pages permissions + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} From de2227c1b46687d3ed6f7ffad20e4efc5349afb6 Mon Sep 17 00:00:00 2001 From: thomaslaurenson Date: Sun, 4 Oct 2026 17:32:30 +1300 Subject: [PATCH 59/59] Set the 0.12.0 release date to today --- CHANGELOG.md | 2 +- docs/changelog.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eefc648..6bef9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.12.0 - 2026-09-25 +## 0.12.0 - 2026-10-04 ### Added diff --git a/docs/changelog.md b/docs/changelog.md index eefc648..6bef9b8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,6 @@ # Changelog -## 0.12.0 - 2026-09-25 +## 0.12.0 - 2026-10-04 ### Added