Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
[submodule "extern/StormLib"]
path = extern/StormLib
url = https://github.com/ladislav-zezula/StormLib.git
[submodule "extern/hash-library"]
path = extern/hash-library
url = https://github.com/stbrumme/hash-library.git
18 changes: 18 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ if(BUILD_MPQCLI)

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)
endif()
29 changes: 22 additions & 7 deletions docs/commands/add.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,28 @@ $ mpqcli add wow-patch.mpq textures/ --path textures

## Skip unchanged files with --update

When adding a directory, the `--update` flag skips any file whose on-disk size matches the
size already stored in the archive. This is useful for incremental updates where only
changed files need to be re-added.
When adding a directory, the `--update` flag skips files that have not changed since they
were last added to the archive. This is useful for incremental updates where only changed
files need to be re-added.

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, pass `--overwrite` without `--update` to unconditionally replace every file.

```bash
$ mpqcli add wow-patch.mpq textures/ --update --overwrite
Expand All @@ -69,10 +88,6 @@ $ mpqcli add wow-patch.mpq textures/ --update --overwrite
[*] For textures: 1 files added, 1 files skipped, 0 files failed.
```

Note: the skip check is size-based only. Files with the same size but different content
are not detected as changed. If precise change detection matters, pass `--overwrite`
without `--update` to unconditionally replace every file.

## Control where files are stored

For single files, one can specify both directory and filename in one step using `-p` or `--path`:
Expand Down
1 change: 1 addition & 0 deletions extern/hash-library
Submodule hash-library added at d389d1
2 changes: 1 addition & 1 deletion src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@ target_include_directories(mpqcli PRIVATE
)

# Link libraries
target_link_libraries(mpqcli PRIVATE storm CLI11::CLI11)
target_link_libraries(mpqcli PRIVATE storm CLI11::CLI11 hash-library)
2 changes: 1 addition & 1 deletion src/completion/mpqcli.fish
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ complete -c mpqcli -n '__fish_seen_subcommand_from add' \
complete -c mpqcli -n '__fish_seen_subcommand_from add' \
-s w -l overwrite -d 'Overwrite file if it already exists in the archive'
complete -c mpqcli -n '__fish_seen_subcommand_from add' \
-s u -l update -d 'Skip files whose archived size matches on-disk size'
-s u -l update -d 'Skip unchanged files when adding a directory'
complete -c mpqcli -n '__fish_seen_subcommand_from add' \
-l locale -d 'Locale to use for added file' \
-r -a "$__mpqcli_locales"
Expand Down
4 changes: 2 additions & 2 deletions src/completion/mpqcli.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl
'--path' = 'Archive path for a single file, or prefix for a directory'
'-w' = 'Overwrite file if it already is in MPQ archive'
'--overwrite' = 'Overwrite file if it already is in MPQ archive'
'-u' = 'Skip files whose archived size matches on-disk size'
'--update' = 'Skip files whose archived size matches on-disk size'
'-u' = 'Skip unchanged files when adding a directory'
'--update' = 'Skip unchanged files when adding a directory'
'--locale' = 'Locale to use for added file'
'-g' = 'Game profile for compression rules'
'--game' = 'Game profile for compression rules'
Expand Down
57 changes: 57 additions & 0 deletions src/helpers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <hash-library/crc32.h>
#include <hash-library/md5.h>
#include <iostream>
#include <sys/stat.h>

#ifdef _WIN32
#include <fcntl.h>
Expand Down Expand Up @@ -128,3 +132,56 @@ void PrintAsBinary(const char *buffer, uint32_t size) {
#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<uint32_t> 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<size_t>(f.gcount()));
}
// getHash yields the checksum as big-endian bytes; reassemble the value.
unsigned char digest[CRC32::HashBytes];
crc32.getHash(digest);
return (static_cast<uint32_t>(digest[0]) << 24) | (static_cast<uint32_t>(digest[1]) << 16) |
(static_cast<uint32_t>(digest[2]) << 8) | static_cast<uint32_t>(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<size_t>(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<uint64_t>((static_cast<int64_t>(st.st_mtime) + epoch_diff) * 10000000LL);
}
8 changes: 8 additions & 0 deletions src/helpers.h
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#ifndef HELPERS_H
#define HELPERS_H

#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>

namespace fs = std::filesystem;
Expand All @@ -14,4 +16,10 @@ uint32_t CalculateMpqMaxFileValue(const std::string &path);
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<uint32_t> ComputeFileCrc32(const fs::path &path);
bool ComputeFileMd5(const fs::path &path, uint8_t *md5_out);
uint64_t LocalFileTimestamp(const fs::path &path);

#endif
5 changes: 3 additions & 2 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,9 @@ int main(int argc, char **argv) {
"Archive path for a single file, or prefix for a directory");
add->add_flag("-w,--overwrite", add_overwrite,
"Overwrite file if it already is in MPQ archive");
add->add_flag("-u,--update", add_update,
"Skip files whose archived size matches the on-disk size (directory add only)");
add->add_flag(
"-u,--update", add_update,
"Skip unchanged files when adding a directory. Compares size, then timestamp/MD5/CRC32");
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" +
Expand Down
67 changes: 64 additions & 3 deletions src/mpq.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "mpq.h"

#include <algorithm>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <functional>
Expand Down Expand Up @@ -230,11 +231,71 @@ int AddFiles(HANDLE archive, const std::string &input_path, const std::string &p
int32_t file_locale = GetFileInfo<int32_t>(file, SFileInfoLocale);
if (file_locale == locale) {
DWORD archived_size = SFileGetFileSize(file, nullptr);
SFileCloseFile(file);
uintmax_t disk_size = fs::file_size(entry.path());
bool skip = false;
std::string skip_reason;
if (disk_size == static_cast<uintmax_t>(archived_size)) {
std::cout << "[~] Skipping unchanged file: " << archive_file_path
<< std::endl;
const DWORD attr_flags = SFileGetAttributes(archive);

// Step 1: Timestamp: cheapest check, no local file I/O.
if (!skip && (attr_flags & MPQ_ATTRIBUTE_FILETIME)) {
const uint64_t archived_time =
GetFileInfo<uint64_t>(file, SFileInfoFileTime);
const uint64_t local_time = LocalFileTimestamp(entry.path());
// Compare at second resolution: stat() has only second precision.
if (archived_time != 0 && local_time != 0 &&
archived_time / 10000000u == local_time / 10000000u) {
skip = true;
skip_reason = "Timestamp matches";
}
}

// Step 2: MD5: if timestamp did not match or was unavailable.
if (!skip && (attr_flags & MPQ_ATTRIBUTE_MD5)) {
// StormLib has no SFileInfoMD5 class, so SFileInfoFileEntry
// (TFileEntry, declared in StormLib.h) is the only public way
// to read the MD5 stored in (attributes).
// Buffer must accommodate the struct plus the trailing filename.
constexpr DWORD entry_buf_size = sizeof(TFileEntry) + 1024;
uint8_t fe_buf[entry_buf_size]{};
if (SFileGetFileInfo(file, SFileInfoFileEntry, fe_buf, entry_buf_size,
nullptr)) {
const auto *fe = reinterpret_cast<const TFileEntry *>(fe_buf);
// 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(fe->md5, zero_md5, MD5_DIGEST_SIZE) != 0) {
uint8_t local_md5[MD5_DIGEST_SIZE]{};
if (ComputeFileMd5(entry.path(), local_md5)) {
skip =
(std::memcmp(local_md5, fe->md5, MD5_DIGEST_SIZE) == 0);
if (skip)
skip_reason = "MD5 matches";
}
}
}
}

// Step 3: CRC32: if neither timestamp nor MD5 matched or was available.
if (!skip && (attr_flags & MPQ_ATTRIBUTE_CRC32)) {
const DWORD archived_crc32 = GetFileInfo<DWORD>(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(entry.path())) {
skip = (*local_crc32 == archived_crc32);
if (skip)
skip_reason = "CRC32 matches";
}
}
}
// If no attributes are present or none matched, always add the file.
}
SFileCloseFile(file);
if (skip) {
std::cout << "[~] Skipping unchanged file: " << archive_file_path << " ("
<< skip_reason << ")" << std::endl;
files_skipped++;
continue;
}
Expand Down
Loading