diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bef9b8..4844132 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,36 @@
# Changelog
+## 0.13.0 - 2026-10-16
+
+### Added
+
+- Accept several files, or a list read from stdin, in create and extract
+- Add a flat option to extract
+- Add short flags -e, -j, -q and -x for the locale, flat, property and print options
+
+### Changed
+
+- Take the archive as the first argument in every subcommand, including create and read
+- Keep folders when extracting named files
+- Rename the create version option to format-version
+- Reject an unknown locale in extract and read instead of using the default locale
+
+### Fixed
+
+- Show the encryption keys of encrypted files listed without their names
+- Accept Windows line endings in paths read from stdin
+- Stop add and create from storing an archive inside itself
+- Leave no archive behind when create cannot add every file
+
+### Removed
+
+- Remove create -o, extract -f and -k, and the -p flag from info, list and verify
+
+### Thanks
+
+- Thanks to @sjoblomj for the contributions in this release
+- Thanks to @MikaMika for reporting the inconsistent subcommand arguments
+
## 0.12.0 - 2026-10-04
### Added
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4d1091d..d3260c2 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.13.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.
diff --git a/Makefile b/Makefile
index 5336ff3..dced9a2 100644
--- a/Makefile
+++ b/Makefile
@@ -162,16 +162,21 @@ gen_docs_changelog: ## Copy CHANGELOG.md into the docs site
@cp CHANGELOG.md docs/changelog.md
@echo "[*] Updated docs/changelog.md"
+# The exact count is read from the title attribute (
1.22K
)
+# and abbreviated to match the shields.io release downloads badge (1.2k)
.PHONY: gen_downloads
gen_downloads: ## Update the package downloads badge in README.md from GitHub
- @DOWNLOADS=$$(curl -s "$(PACKAGE_URL)" \
+ @DOWNLOADS=$$(curl -fs "$(PACKAGE_URL)" \
| grep -A2 "Total downloads" \
- | grep -o '[0-9]*
' \
- | grep -o 'title="[0-9]*"' \
- | grep -o '[0-9]*' \
+ | sed -n 's/.*&2; \
+ exit 1; \
+ fi; \
+ BADGE=$$(awk -v n="$$DOWNLOADS" 'BEGIN { if (n < 1000) print n; else printf "%.1fk\n", n / 1000 }'); \
+ sed -i "s/package_downloads-[^-]*-green/package_downloads-$$BADGE-green/" $(README); \
+ echo "[*] Updated package downloads badge: $$BADGE ($$DOWNLOADS)"
##@ GET
diff --git a/README.md b/README.md
index 050fbf1..9946845 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 
-  
+  

diff --git a/app/add.cpp b/app/add.cpp
index 54dabde..0b8b5d4 100644
--- a/app/add.cpp
+++ b/app/add.cpp
@@ -21,7 +21,7 @@ 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->add.archive, "Target MPQ archive")
+ sub->add_option("archive", options->add.archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_option("files", options->files,
@@ -29,13 +29,13 @@ void RegisterAdd(CLI::App &app, Context &context) {
->required()
->expected(-1);
sub->add_option("-p,--path", options->add.path,
- "Archive path for a single file, or prefix for a directory");
+ "Archive path for a single file, or prefix for a directory or several inputs");
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->add.update,
"Replace only files that changed. Compares size, then timestamp/MD5/CRC32")
->excludes(overwrite);
- sub->add_option("--locale", options->add.locale, "Locale to use for added file")
+ sub->add_option("-e,--locale", options->add.locale, "Locale to use for added file")
->check(locale_valid);
sub->add_option("-g,--game", options->add.game_profile,
"Game profile for compression rules. Valid options:\n" +
diff --git a/app/compact.cpp b/app/compact.cpp
index 6f516ec..74ef392 100644
--- a/app/compact.cpp
+++ b/app/compact.cpp
@@ -10,7 +10,7 @@ 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")
+ sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive")
diff --git a/app/create.cpp b/app/create.cpp
index cb8f4ce..05518d5 100644
--- a/app/create.cpp
+++ b/app/create.cpp
@@ -3,32 +3,40 @@
#include
#include
#include
+#include
+#include
#include
#include
#include "commands.h"
#include "gamerules/rules.h"
+#include "util/path.h"
#include "validators.h"
void RegisterCreate(CLI::App &app, Context &context) {
struct Options {
mpqcli::CreateOptions create;
+ // Local paths, or - for the lines on stdin, settled into create.files by the callback
+ std::vector files;
// The documented numbering is 1 to 4; StormLib counts the same formats from 0
- std::optional version;
+ std::optional format_version;
};
auto options = std::make_shared();
- auto *sub = app.add_subcommand("create", "Create an MPQ archive from target file or directory");
+ auto *sub = app.add_subcommand("create", "Create an MPQ archive from files and directories");
- sub->add_option("target", options->create.target, "Directory or file to put in MPQ archive")
+ sub->add_option("archive", options->create.archive, "MPQ archive to create")
->required()
- ->check(CLI::ExistingPath);
+ ->check(CLI::NonexistentPath);
+ 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->create.path,
- "Archive path for a single file, or prefix for a directory");
- sub->add_option("-o,--output", options->create.output, "Output MPQ archive");
+ "Archive path for a single file, or prefix for a directory or several inputs");
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")
+ sub->add_option("-e,--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" +
@@ -36,7 +44,8 @@ void RegisterCreate(CLI::App &app, Context &context) {
->check(game_profile_valid);
mpqcli::MpqCreateSettingsOverrides &create = options->create.create_overrides;
- sub->add_option("--version", options->version, "Override the MPQ archive version")
+ sub->add_option("--format-version", options->format_version,
+ "Override the MPQ archive format version")
->check(CLI::Range(1, 4))
->group("Game setting overrides");
sub->add_option("--stream-flags", create.stream_flags, "Override stream flags")
@@ -66,9 +75,11 @@ void RegisterCreate(CLI::App &app, Context &context) {
->group("Game setting overrides");
sub->callback([options, &context]() {
- if (options->version.has_value()) {
+ const auto files = mpqcli::ExpandStdinMarker(options->files, context.in);
+ options->create.files.assign(files.begin(), files.end());
+ if (options->format_version.has_value()) {
options->create.create_overrides.mpq_version =
- static_cast(options->version.value() - 1);
+ static_cast(options->format_version.value() - 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 ad954cb..4b3d501 100644
--- a/app/extract.cpp
+++ b/app/extract.cpp
@@ -1,27 +1,50 @@
#include "commands/extract.h"
#include
+#include
+#include
#include
#include "commands.h"
+#include "util/path.h"
+#include "validators.h"
void RegisterExtract(CLI::App &app, Context &context) {
- auto options = std::make_shared();
+ struct Options {
+ mpqcli::ExtractOptions extract;
+ // Names inside the archive, or - for the lines on stdin, settled into extract.files
+ // by the callback
+ std::vector files;
+ };
+
+ 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")
+ sub->add_option("archive", options->extract.archive, "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");
+ CLI::Option *files =
+ sub->add_option("files", options->files,
+ "Files to extract, or every file when omitted; pass - to read "
+ "names from stdin");
+ sub->add_option("-o,--output", options->extract.output, "Output directory");
+ sub->add_flag("-j,--flat", options->extract.flat,
+ "Write files into the output directory without their folders");
+ sub->add_option("-l,--listfile", options->extract.listfile,
+ "File listing content of an MPQ archive, when extracting every file")
+ ->check(CLI::ExistingFile)
+ ->excludes(files);
+ sub->add_option("-e,--locale", options->extract.locale, "Preferred locale for extracted files")
+ ->check(locale_valid);
sub->callback([options, &context]() {
- context.exit_code = mpqcli::Extract(*options, context.err, context.cancelled) ? 0 : 1;
+ // An empty stdin extracts nothing rather than everything, so a pipeline whose
+ // filter matched no names cannot unpack the whole archive
+ if (!options->files.empty()) {
+ options->extract.files = mpqcli::ExpandStdinMarker(options->files, context.in);
+ }
+ context.exit_code =
+ mpqcli::Extract(options->extract, context.err, context.cancelled) ? 0 : 1;
});
}
diff --git a/app/info.cpp b/app/info.cpp
index 9b014c1..cc34710 100644
--- a/app/info.cpp
+++ b/app/info.cpp
@@ -21,10 +21,10 @@ 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")
+ sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
- sub->add_option("-p,--property", options->property, "Prints only a specific property value")
+ sub->add_option("-q,--property", options->property, "Prints only a specific property value")
->check(CLI::IsMember(valid_properties));
sub->callback([options, &context]() { mpqcli::Info(*options, context.out); });
diff --git a/app/list.cpp b/app/list.cpp
index ad2e2f1..77ccb76 100644
--- a/app/list.cpp
+++ b/app/list.cpp
@@ -22,7 +22,7 @@ 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")
+ sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
sub->add_option("-l,--listfile", options->listfile, "File listing content of an MPQ archive")
@@ -30,7 +30,7 @@ void RegisterList(CLI::App &app, Context &context) {
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")
+ sub->add_option("-q,--property", options->properties, "Prints only specific property values")
->check(CLI::IsMember(valid_properties));
sub->callback([options, &context]() { mpqcli::List(*options, context.out, context.err); });
diff --git a/app/read.cpp b/app/read.cpp
index 5d5997b..e3448fd 100644
--- a/app/read.cpp
+++ b/app/read.cpp
@@ -5,16 +5,18 @@
#include
#include "commands.h"
+#include "validators.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")
+ sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
- sub->add_option("--locale", options->locale, "Preferred locale for read file");
+ sub->add_option("file", options->file, "File to read")->required();
+ sub->add_option("-e,--locale", options->locale, "Preferred locale for read file")
+ ->check(locale_valid);
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
index 7e3a40a..bf5a794 100644
--- a/app/remove.cpp
+++ b/app/remove.cpp
@@ -12,14 +12,15 @@ 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")
+ sub->add_option("archive", options->archive, "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->add_option("-e,--locale", options->locale, "Locale of file to remove")
+ ->check(locale_valid);
sub->callback([options, &context]() {
options->files = mpqcli::ExpandStdinMarker(options->files, context.in);
diff --git a/app/rename.cpp b/app/rename.cpp
index 7dd2d95..c3f51c7 100644
--- a/app/rename.cpp
+++ b/app/rename.cpp
@@ -11,12 +11,13 @@ 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")
+ sub->add_option("archive", options->archive, "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->add_option("-e,--locale", options->locale, "Locale of file to rename")
+ ->check(locale_valid);
sub->callback([options, &context]() {
context.exit_code = mpqcli::Rename(*options, context.err) ? 0 : 1;
diff --git a/app/verify.cpp b/app/verify.cpp
index 2f6bf57..3fbafc9 100644
--- a/app/verify.cpp
+++ b/app/verify.cpp
@@ -10,10 +10,10 @@ 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")
+ sub->add_option("archive", options->archive, "MPQ archive")
->required()
->check(CLI::ExistingFile);
- sub->add_flag("-p,--print", options->print_signature, "Print the digital signature (in hex)");
+ sub->add_flag("-x,--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/completion/mpqcli.bash b/completion/mpqcli.bash
index 5990f53..4f3c77d 100644
--- a/completion/mpqcli.bash
+++ b/completion/mpqcli.bash
@@ -116,10 +116,10 @@ _mpqcli() {
case "${words[1]}" in
info)
case "$prev" in
- -p|--property) _mpqcli_words "${info_properties[*]}"; return ;;
+ -q|--property) _mpqcli_words "${info_properties[*]}"; return ;;
esac
if [[ "$cur" == -* ]]; then
- _mpqcli_words "-p --property"
+ _mpqcli_words "-q --property"
else
_mpqcli_positional_index
if [[ $pos -eq 0 ]]; then
@@ -130,35 +130,33 @@ _mpqcli() {
create)
case "$prev" in
- --locale) _mpqcli_words "${locales[*]}"; return ;;
+ -e|--locale) _mpqcli_words "${locales[*]}"; return ;;
-g|--game) _mpqcli_words "${games[*]}"; return ;;
- --version) _mpqcli_words "1 2 3 4"; return ;;
- -o|--output) _mpqcli_filedir; return ;;
+ --format-version) _mpqcli_words "1 2 3 4"; 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
- _mpqcli_words "-p --path -o --output -s --sign --locale -g --game --version \
+ _mpqcli_words "-p --path -s --sign -e --locale -g --game --format-version \
--stream-flags --sector-size --raw-chunk-size --file-flags1 --file-flags2 --file-flags3 \
--attr-flags --flags --compression --compression-next"
else
- _mpqcli_positional_index -s --sign
- if [[ $pos -eq 0 ]]; then
- _mpqcli_filedir
- fi
+ # Positional 1 is the new archive; the rest are files or directories
+ # to add, so every positional takes a path
+ _mpqcli_filedir
fi
;;
add)
case "$prev" in
- --locale) _mpqcli_words "${locales[*]}"; return ;;
+ -e|--locale) _mpqcli_words "${locales[*]}"; return ;;
-g|--game) _mpqcli_words "${games[*]}"; return ;;
-p|--path|--flags|--compression|--compression-next) return ;;
esac
if [[ "$cur" == -* ]]; then
- _mpqcli_words "-p --path -w --overwrite -u --update --locale -g --game \
+ _mpqcli_words "-p --path -w --overwrite -u --update -e --locale -g --game \
--flags --compression --compression-next"
else
# Positional 1 is the archive; the rest are files or directories to add
@@ -173,10 +171,10 @@ _mpqcli() {
remove)
case "$prev" in
- --locale) _mpqcli_words "${locales[*]}"; return ;;
+ -e|--locale) _mpqcli_words "${locales[*]}"; return ;;
esac
if [[ "$cur" == -* ]]; then
- _mpqcli_words "--locale"
+ _mpqcli_words "-e --locale"
else
# Positional 1 is the archive; the rest are in-archive paths, so
# they get no filesystem completion
@@ -189,10 +187,10 @@ _mpqcli() {
rename)
case "$prev" in
- --locale) _mpqcli_words "${locales[*]}"; return ;;
+ -e|--locale) _mpqcli_words "${locales[*]}"; return ;;
esac
if [[ "$cur" == -* ]]; then
- _mpqcli_words "--locale"
+ _mpqcli_words "-e --locale"
else
# Positional 1 is the archive; positionals 2 and 3 are in-archive
# paths, so they get no filesystem completion
@@ -206,10 +204,10 @@ _mpqcli() {
list)
case "$prev" in
-l|--listfile) _mpqcli_filedir; return ;;
- -p|--property) _mpqcli_words "${list_properties[*]}"; return ;;
+ -q|--property) _mpqcli_words "${list_properties[*]}"; return ;;
esac
if [[ "$cur" == -* ]]; then
- _mpqcli_words "-l --listfile -d --detailed -a --all -p --property"
+ _mpqcli_words "-l --listfile -d --detailed -a --all -q --property"
else
_mpqcli_positional_index -d --detailed -a --all
if [[ $pos -eq 0 ]]; then
@@ -222,13 +220,14 @@ _mpqcli() {
case "$prev" in
-o|--output) _mpqcli_filedir -d; return ;;
-l|--listfile) _mpqcli_filedir; return ;;
- --locale) _mpqcli_words "${locales[*]}"; return ;;
- -f|--file) return ;;
+ -e|--locale) _mpqcli_words "${locales[*]}"; return ;;
esac
if [[ "$cur" == -* ]]; then
- _mpqcli_words "-o --output -f --file -k --keep -l --listfile --locale"
+ _mpqcli_words "-o --output -j --flat -l --listfile -e --locale"
else
- _mpqcli_positional_index -k --keep
+ # Positional 1 is the archive; the rest are in-archive paths, so
+ # they get no filesystem completion
+ _mpqcli_positional_index -j --flat
if [[ $pos -eq 0 ]]; then
_mpqcli_filedir mpq
fi
@@ -237,15 +236,15 @@ _mpqcli() {
read)
case "$prev" in
- --locale) _mpqcli_words "${locales[*]}"; return ;;
+ -e|--locale) _mpqcli_words "${locales[*]}"; return ;;
esac
if [[ "$cur" == -* ]]; then
- _mpqcli_words "--locale"
+ _mpqcli_words "-e --locale"
else
- # Positional 1 is an in-archive path, so it gets no filesystem
- # completion; positional 2 is the archive
+ # Positional 1 is the archive; positional 2 is an in-archive path,
+ # so it gets no filesystem completion
_mpqcli_positional_index
- if [[ $pos -eq 1 ]]; then
+ if [[ $pos -eq 0 ]]; then
_mpqcli_filedir mpq
fi
fi
@@ -253,9 +252,9 @@ _mpqcli() {
verify)
if [[ "$cur" == -* ]]; then
- _mpqcli_words "-p --print"
+ _mpqcli_words "-x --print"
else
- _mpqcli_positional_index -p --print
+ _mpqcli_positional_index -x --print
if [[ $pos -eq 0 ]]; then
_mpqcli_filedir mpq
fi
diff --git a/completion/mpqcli.fish b/completion/mpqcli.fish
index 48f3fc0..33d4c63 100644
--- a/completion/mpqcli.fish
+++ b/completion/mpqcli.fish
@@ -76,7 +76,7 @@ complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \
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'
+ -a create -d 'Create an MPQ archive from files and directories'
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" \
@@ -97,36 +97,36 @@ complete -c mpqcli -n "not __fish_seen_subcommand_from $__mpqcli_subcommands" \
-a completion -d 'Generate shell completion script'
# info
-# info [-p/--property ]
+# info [-q/--property ]
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' \
+ -s q -l property -d 'Print only a specific property value' \
-r -f -a "$__mpqcli_info_properties"
# create
-# create [-p/--path] [-o/--output] [-s/--sign]
-# [--locale] [-g/--game]
-# [--version] [--stream-flags] [--sector-size] [--raw-chunk-size]
+# create [-p/--path] [-s/--sign]
+# [-e/--locale] [-g/--game]
+# [--format-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; and test (__mpqcli_positional_index -s --sign) -eq 1' -F
+# Positional 1 is the new archive and the rest are files or directories to add,
+# so every positional takes a path
+complete -c mpqcli -n '__fish_seen_subcommand_from create' -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 -f
-complete -c mpqcli -n '__fish_seen_subcommand_from create' \
- -s o -l output -d 'Output MPQ archive path' -r -F
+ -s p -l path -d 'Archive path for a single file, or prefix for a directory or several inputs' -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' \
+ -s e -l locale -d 'Locale to use for added files' \
-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 -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 -f -a '1 2 3 4'
+ -l format-version -d 'Override MPQ archive format 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 -f
complete -c mpqcli -n '__fish_seen_subcommand_from create' \
@@ -150,20 +150,20 @@ complete -c mpqcli -n '__fish_seen_subcommand_from create' \
# add
# add [-p/--path] [-w/--overwrite | -u/--update]
-# [--locale] [-g/--game]
+# [-e/--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 -f
+ -s p -l path -d 'Archive path for a single file, or prefix for a directory or several inputs' -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' \
+ -s e -l locale -d 'Locale to use for added file' \
-r -f -a "$__mpqcli_locales"
complete -c mpqcli -n '__fish_seen_subcommand_from add' \
-s g -l game -d 'Game profile for compression rules' \
@@ -176,28 +176,28 @@ complete -c mpqcli -n '__fish_seen_subcommand_from add' \
-l compression-next -d 'Override compression for subsequent sectors' -r -f
# remove
-# remove [--locale]
+# remove [-e/--locale]
# 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' \
+ -s e -l locale -d 'Locale of the file to remove' \
-r -f -a "$__mpqcli_locales"
# rename
-# rename [--locale]
+# rename [-e/--locale]
# 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' \
+ -s e -l locale -d 'Locale of the file to rename' \
-r -f -a "$__mpqcli_locales"
# list
-# list [-l/--listfile] [-d/--detailed] [-a/--all]
-# [-p/--property ]
+# list [-l/--listfile] [-d/--detailed] [-a/--all]
+# [-q/--property ]
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' \
@@ -207,45 +207,45 @@ complete -c mpqcli -n '__fish_seen_subcommand_from list' \
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' \
+ -s q -l property -d 'Print only specific property values' \
-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; and test (__mpqcli_positional_index -k --keep) -eq 1' -F
+# extract [files...] [-o/--output] [-j/--flat]
+# [-l/--listfile] [-e/--locale]
+# 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 extract; and test (__mpqcli_positional_index -j --flat) -eq 1' -F
complete -c mpqcli -n '__fish_seen_subcommand_from extract' \
-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 -f
-complete -c mpqcli -n '__fish_seen_subcommand_from extract' \
- -s k -l keep -d 'Keep folder structure'
+ -s j -l flat -d 'Write files without their folders'
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' \
+ -s e -l locale -d 'Preferred locale for extracted files' \
-r -f -a "$__mpqcli_locales"
# read
-# read [--locale]
-# 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
+# read [-e/--locale]
+# Positional 1 is the archive; positional 2 is an in-archive path, so it gets
+# no filesystem completion
+complete -c mpqcli -n '__fish_seen_subcommand_from read; and test (__mpqcli_positional_index) -eq 1' -F
complete -c mpqcli -n '__fish_seen_subcommand_from read' \
- -l locale -d 'Preferred locale for read file' \
+ -s e -l locale -d 'Preferred locale for read file' \
-r -f -a "$__mpqcli_locales"
# verify
-# verify [-p/--print]
-complete -c mpqcli -n '__fish_seen_subcommand_from verify; and test (__mpqcli_positional_index -p --print) -eq 1' -F
+# verify [-x/--print]
+complete -c mpqcli -n '__fish_seen_subcommand_from verify; and test (__mpqcli_positional_index -x --print) -eq 1' -F
complete -c mpqcli -n '__fish_seen_subcommand_from verify' \
- -s p -l print -d 'Print the digital signature (in hex)'
+ -s x -l print -d 'Print the digital signature (in hex)'
# compact
-# compact [-l/--listfile]
+# compact [-l/--listfile]
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' \
diff --git a/completion/mpqcli.ps1 b/completion/mpqcli.ps1
index 30ae3ac..16b0233 100644
--- a/completion/mpqcli.ps1
+++ b/completion/mpqcli.ps1
@@ -24,7 +24,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl
'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'
+ 'create' = 'Create an MPQ archive from files and directories'
'add' = 'Add files to an existing MPQ archive'
'remove' = 'Remove files from an existing MPQ archive'
'rename' = 'Rename a file in an existing MPQ archive'
@@ -75,20 +75,19 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl
# short help string, in the order the help text lists them
$optionSpec = @{
'info' = [ordered]@{
- '-p' = 'Print only a specific property value'
+ '-q' = 'Print only a specific property value'
'--property' = 'Print only a specific property value'
}
'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'
- '--output' = 'Output MPQ archive'
+ '-p' = 'Archive path for a single file, or prefix for a directory or several inputs'
+ '--path' = 'Archive path for a single file, or prefix for a directory or several inputs'
'-s' = 'Sign the MPQ archive'
'--sign' = 'Sign the MPQ archive'
+ '-e' = 'Locale to use for added files'
'--locale' = 'Locale to use for added files'
'-g' = 'Game profile for MPQ creation'
'--game' = 'Game profile for MPQ creation'
- '--version' = 'Override the MPQ archive version (1-4)'
+ '--format-version'= 'Override the MPQ archive format version (1-4)'
'--stream-flags' = 'Override stream flags'
'--sector-size' = 'Override sector size'
'--raw-chunk-size'= 'Override raw chunk size for MPQ v4'
@@ -101,12 +100,13 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl
'--compression-next' = 'Override compression for subsequent sectors'
}
'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'
+ '-p' = 'Archive path for a single file, or prefix for a directory or several inputs'
+ '--path' = 'Archive path for a single file, or prefix for a directory or several inputs'
'-w' = 'Replace every file that already exists in the archive'
'--overwrite' = 'Replace every file that already exists in the archive'
'-u' = 'Replace only files that changed'
'--update' = 'Replace only files that changed'
+ '-e' = 'Locale to use for added file'
'--locale' = 'Locale to use for added file'
'-g' = 'Game profile for compression rules'
'--game' = 'Game profile for compression rules'
@@ -115,9 +115,11 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl
'--compression-next' = 'Override compression for subsequent sectors'
}
'remove' = [ordered]@{
+ '-e' = 'Locale of file to remove'
'--locale' = 'Locale of file to remove'
}
'rename' = [ordered]@{
+ '-e' = 'Locale of file to rename'
'--locale' = 'Locale of file to rename'
}
'list' = [ordered]@{
@@ -127,25 +129,25 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl
'--detailed' = 'File listing with additional columns'
'-a' = 'File listing including hidden files'
'--all' = 'File listing including hidden files'
- '-p' = 'Print only specific property values'
+ '-q' = 'Print only specific property values'
'--property' = 'Print only specific property values'
}
'extract' = [ordered]@{
'-o' = 'Output directory'
'--output' = 'Output directory'
- '-f' = 'Target file to extract'
- '--file' = 'Target file to extract'
- '-k' = 'Keep folder structure'
- '--keep' = 'Keep folder structure'
+ '-j' = 'Write files into the output directory without their folders'
+ '--flat' = 'Write files into the output directory without their folders'
'-l' = 'File listing content of an MPQ archive'
'--listfile' = 'File listing content of an MPQ archive'
- '--locale' = 'Preferred locale for extracted file'
+ '-e' = 'Preferred locale for extracted files'
+ '--locale' = 'Preferred locale for extracted files'
}
'read' = [ordered]@{
+ '-e' = 'Preferred locale for read file'
'--locale' = 'Preferred locale for read file'
}
'verify' = [ordered]@{
- '-p' = 'Print the digital signature (in hex)'
+ '-x' = 'Print the digital signature (in hex)'
'--print' = 'Print the digital signature (in hex)'
}
'compact' = [ordered]@{
@@ -159,10 +161,11 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl
# Options whose *argument* should complete from a static value set
$valueOptions = @{
- '--locale' = $locales
- '-g' = $gameProfiles
- '--game' = $gameProfiles
- '--version' = @('1', '2', '3', '4')
+ '-e' = $locales
+ '--locale' = $locales
+ '-g' = $gameProfiles
+ '--game' = $gameProfiles
+ '--format-version' = @('1', '2', '3', '4')
}
# Property options depend on the subcommand (info vs list), handled below
@@ -236,7 +239,7 @@ Register-ArgumentCompleter -Native -CommandName 'mpqcli', 'mpqcli.exe' -ScriptBl
return & $emit $items 'ParameterValue'
}
# Property options: meaning depends on the subcommand
- if ($prevToken -in @('-p', '--property')) {
+ if ($prevToken -in @('-q', '--property')) {
if ($subcommand -eq 'info') {
$items = foreach ($v in $infoProperties) {
[pscustomobject]@{ Value = $v; Tip = 'info property' } }
@@ -247,13 +250,10 @@ 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
}
# 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
- -not ($subcommand -in @('info', 'list') -and $prevToken -in @('-p', '--property'))) {
+ $pathOptions = @('-o', '--output', '-l', '--listfile', '-p', '--path')
+ if ($prevToken -in $pathOptions) {
return # empty -> native path completion takes over
}
}
diff --git a/completion/mpqcli.zsh b/completion/mpqcli.zsh
index b1f463e..9fa9d6f 100644
--- a/completion/mpqcli.zsh
+++ b/completion/mpqcli.zsh
@@ -66,7 +66,7 @@ _mpqcli_cmds() {
'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'
+ 'create:Create an MPQ archive from files and directories'
'add:Add files to an existing MPQ archive'
'remove:Remove files from an existing MPQ archive'
'rename:Rename a file in an existing MPQ archive'
@@ -90,19 +90,19 @@ _mpqcli_info() {
local props="${_mpqcli_info_properties[*]}"
_arguments \
'1:archive:_mpqcli_archives' \
- '(-p --property)'{-p,--property}'[print only a specific property]:property:('"$props"')'
+ '(-q --property)'{-q,--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' \
+ '1:archive:_files' \
+ '*:files:_files' \
+ '(-p --path)'{-p,--path}'[archive path for a single file, or prefix for a directory or several inputs]:path' \
'(-s --sign)'{-s,--sign}'[sign the archive]' \
- '--locale[locale for added files]:locale:('"$locales"')' \
+ '(-e --locale)'{-e,--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)' \
+ '--format-version[MPQ archive format version (1-4)]:version:(1 2 3 4)' \
'--stream-flags[override stream flags]:flags' \
'--sector-size[override sector size]:size' \
'--raw-chunk-size[override raw chunk size for MPQ v4]:size' \
@@ -120,10 +120,10 @@ _mpqcli_add() {
_arguments \
'1:archive:_mpqcli_archives' \
'*:files:_files' \
- '(-p --path)'{-p,--path}'[archive path or prefix for directory add]:path' \
+ '(-p --path)'{-p,--path}'[archive path for a single file, or prefix for a directory or several inputs]: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:('"$locales"')' \
+ '(-e --locale)'{-e,--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' \
@@ -135,7 +135,7 @@ _mpqcli_remove() {
_arguments \
'1:archive:_mpqcli_archives' \
'*:archive paths' \
- '--locale[locale of file to remove]:locale:('"$locales"')'
+ '(-e --locale)'{-e,--locale}'[locale of file to remove]:locale:('"$locales"')'
}
_mpqcli_rename() {
@@ -144,7 +144,7 @@ _mpqcli_rename() {
'1:archive:_mpqcli_archives' \
'2:old archive path' \
'3:new archive path' \
- '--locale[locale of file to rename]:locale:('"$locales"')'
+ '(-e --locale)'{-e,--locale}'[locale of file to rename]:locale:('"$locales"')'
}
_mpqcli_list() {
@@ -154,32 +154,32 @@ _mpqcli_list() {
'(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \
'(-d --detailed)'{-d,--detailed}'[detailed listing with extra columns]' \
'(-a --all)'{-a,--all}'[include hidden files]' \
- '(-p --property)'{-p,--property}'[print specific property values]:property:('"$props"')'
+ '*'{-q,--property}'[print specific property values]:property:('"$props"')'
}
_mpqcli_extract() {
local locales="${_mpqcli_locales[*]}"
_arguments \
'1:archive:_mpqcli_archives' \
+ '*:archive paths' \
'(-o --output)'{-o,--output}'[output directory]:dir:_files -/' \
- '(-f --file)'{-f,--file}'[target file to extract]:file' \
- '(-k --keep)'{-k,--keep}'[keep folder structure]' \
+ '(-j --flat)'{-j,--flat}'[write files without their folders]' \
'(-l --listfile)'{-l,--listfile}'[listfile path]:file:_files' \
- '--locale[preferred locale for extracted file]:locale:('"$locales"')'
+ '(-e --locale)'{-e,--locale}'[preferred locale for extracted files]:locale:('"$locales"')'
}
_mpqcli_read() {
local locales="${_mpqcli_locales[*]}"
_arguments \
- '1:file-in-archive' \
- '2:archive:_mpqcli_archives' \
- '--locale[preferred locale for read file]:locale:('"$locales"')'
+ '1:archive:_mpqcli_archives' \
+ '2:file-in-archive' \
+ '(-e --locale)'{-e,--locale}'[preferred locale for read file]:locale:('"$locales"')'
}
_mpqcli_verify() {
_arguments \
'1:archive:_mpqcli_archives' \
- '(-p --print)'{-p,--print}'[print the digital signature in hex]'
+ '(-x --print)'{-x,--print}'[print the digital signature in hex]'
}
_mpqcli_compact() {
diff --git a/docs/advanced.md b/docs/advanced.md
index d334761..68c51e6 100644
--- a/docs/advanced.md
+++ b/docs/advanced.md
@@ -7,7 +7,7 @@ The `mpqcli` tool has no native search feature - instead, it is designed to be i
The following command lists all files in an MPQ archive, and each filename is filtered using `grep` - selecting files ending in `.exe` (note: `.` is a regex wildcard matching any character; use `\.exe` for a strictly literal match) - which is then passed back to `mpqcli extract`. The result: search and extract all `exe` files.
```bash
-$ mpqcli list wow-patch.mpq | grep -i \.exe | xargs -I@ mpqcli extract -f "@" wow-patch.mpq
+$ mpqcli list wow-patch.mpq | grep -i \.exe | mpqcli extract wow-patch.mpq -
[*] Extracted: Launcher.exe
[*] Extracted: BackgroundDownloader.exe
[*] Extracted: WoW.exe
@@ -16,11 +16,10 @@ $ mpqcli list wow-patch.mpq | grep -i \.exe | xargs -I@ mpqcli extract -f "@" wo
[*] Extracted: WowError.exe
```
-Note that directories are specified with backslashes, which need to be escaped (due to how grep and xargs handles them). The following example extracts all `dat` files in the `arr` directory from the `StarDat.mpq` archive and handles escaping via `sed`.
+Note that directories are specified with backslashes. The names reach `mpqcli extract` on stdin unchanged, so only the backslash in the `grep` pattern needs escaping. The following example extracts all `dat` files in the `arr` directory from the `StarDat.mpq` archive, keeping the folder.
```bash
-$ mpqcli list -l scbw.txt StarDat.mpq | grep -i "arr\\\.*dat$" | sort | sed 's|\\|\\\\|g' | \
- xargs -I@ mpqcli extract -f "@" -k StarDat.mpq
+$ mpqcli list -l scbw.txt StarDat.mpq | grep -i 'arr\\.*dat$' | sort | mpqcli extract StarDat.mpq -
[*] Extracted: arr/flingy.dat
[*] Extracted: arr/images.dat
[*] Extracted: arr/mapdata.dat
@@ -36,11 +35,11 @@ $ mpqcli list -l scbw.txt StarDat.mpq | grep -i "arr\\\.*dat$" | sort | sed 's|\
## Search and extract files on Windows
-The following command lists all files in an MPQ archive, and each filename is filtered using `Select-String` - selecting files ending in `.exe` (note: `.` is a regex wildcard matching any character; use `\.exe` for a strictly literal match) - which is then passed back to `mpqcli extract`. The result: search and extract all `exe` files.
+The following command lists all files in an MPQ archive, and each filename is filtered using `Select-String` - selecting files ending in `.exe` (note: `.` is a regex wildcard matching any character; use `\.exe` for a strictly literal match) - which is then passed back to `mpqcli extract` on stdin. `ForEach-Object Line` passes on the text of each match rather than the match object. The result: search and extract all `exe` files.
```powershell
PS> mpqcli.exe list wow-patch.mpq | Select-String -Pattern \.exe | `
- ForEach-Object { mpqcli.exe extract -f $_ wow-patch.mpq }
+ ForEach-Object Line | mpqcli.exe extract wow-patch.mpq -
[*] Extracted: Launcher.exe
[*] Extracted: BackgroundDownloader.exe
[*] Extracted: WoW.exe
diff --git a/docs/changelog.md b/docs/changelog.md
index 6bef9b8..4844132 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,36 @@
# Changelog
+## 0.13.0 - 2026-10-16
+
+### Added
+
+- Accept several files, or a list read from stdin, in create and extract
+- Add a flat option to extract
+- Add short flags -e, -j, -q and -x for the locale, flat, property and print options
+
+### Changed
+
+- Take the archive as the first argument in every subcommand, including create and read
+- Keep folders when extracting named files
+- Rename the create version option to format-version
+- Reject an unknown locale in extract and read instead of using the default locale
+
+### Fixed
+
+- Show the encryption keys of encrypted files listed without their names
+- Accept Windows line endings in paths read from stdin
+- Stop add and create from storing an archive inside itself
+- Leave no archive behind when create cannot add every file
+
+### Removed
+
+- Remove create -o, extract -f and -k, and the -p flag from info, list and verify
+
+### Thanks
+
+- Thanks to @sjoblomj for the contributions in this release
+- Thanks to @MikaMika for reporting the inconsistent subcommand arguments
+
## 0.12.0 - 2026-10-04
### Added
diff --git a/docs/commands/add.md b/docs/commands/add.md
index 570cd34..9bb1818 100644
--- a/docs/commands/add.md
+++ b/docs/commands/add.md
@@ -100,7 +100,7 @@ $ mpqcli add wow-patch.mpq allegiance.txt --overwrite
## Add with a locale
-Use `--locale` to store the file under a specific locale. Files added without `--locale` use the default locale.
+Use `-e` or `--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
diff --git a/docs/commands/create.md b/docs/commands/create.md
index 4ec1f68..7111214 100644
--- a/docs/commands/create.md
+++ b/docs/commands/create.md
@@ -1,26 +1,44 @@
# create
-Create an MPQ archive from a target directory or a single file.
+Create an MPQ archive from files and directories.
-## Create an MPQ archive from a target directory
+## Create an MPQ archive from a directory
-Create an MPQ file from a target directory. Automatically adds `(listfile)` to the archive, and will skip this file if it exists in the target directory.
+Name the new archive first, then the directory to put in it. Automatically adds `(listfile)` to the archive, and will skip this file if it exists in the directory.
```bash
-$ mpqcli create
+$ mpqcli create archive.mpq
```
-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.
+The default mode of operation for the `create` subcommand is to take everything from the directory (and below) and recursively add it to the archive. The directory's contents go at the root of the archive, and the structure below it is retained. Windows-style backslash path separators are used (`\`), as per the observed behaviour in most MPQ archives.
+
+The archive must not already exist; `create` never overwrites a file. If an input is missing or any file cannot be added, no archive is created.
+
+## Create an MPQ archive from several files and directories
+
+Name as many files and directories as needed after the archive. Each file is stored under its own name, and each directory's contents go at the root of the archive. If two inputs hold a file with the same name, no archive is created.
+
+```bash
+$ mpqcli create archive.mpq readme.txt textures/ sounds/
+```
+
+Pass `-` in place of the files to read their paths from stdin, one per line:
+
+```bash
+$ find . -maxdepth 1 -name '*.txt' | mpqcli create texts.mpq -
+```
+
+As with files named on the command line, each file is stored under its own name, without the folders in its path. To keep the folder structure, name the directory instead.
## Create an MPQ archive for a specific game
Target a specific game version by using the `-g` or `--game` argument. This will automatically set the correct archive format version and settings, although they can be overridden.
```bash
-$ mpqcli create -g starcraft
+$ mpqcli create -g starcraft archive.mpq
$ mpqcli create --game wow-wotlk \
--sector-size 16384 \
- --version 3
+ --format-version 3 archive.mpq
```
## Create an MPQ archive from a single file
@@ -28,22 +46,22 @@ $ mpqcli create --game wow-wotlk \
Create an MPQ file from a single file.
```bash
-$ mpqcli create --game diablo2
+$ mpqcli create --game diablo2 archive.mpq
```
## Control where files are stored
-For single files, one can specify both directory and filename in one step using `-p` or `--path`:
+For a single file, one can specify both directory and filename in one step using `-p` or `--path`:
```bash
-$ mpqcli create fts.txt --output archive.mpq --path "texts\swarm.txt"
+$ mpqcli create archive.mpq fts.txt --path "texts\swarm.txt"
[+] Adding file: texts\swarm.txt
```
-For directories, one can specify the base directory using `-p` or `--path`:
+For directories, or several inputs, `-p` or `--path` is a prefix that every file is stored under:
```bash
-$ mpqcli create textures/ --output archive.mpq --path textures
+$ mpqcli create archive.mpq textures/ --path textures
[+] Adding file: textures\Creature\Bear\Bear.blp
[+] Adding file: textures\Creature\Wolf\Wolf.blp
```
@@ -53,13 +71,13 @@ $ mpqcli create textures/ --output archive.mpq --path textures
Use the `-s` or `--sign` argument to cryptographically sign an MPQ archive with the Blizzard weak signature.
```bash
-$ mpqcli create --version 1 --sign
+$ mpqcli create --format-version 1 --sign archive.mpq
```
## Create an MPQ archive with a given locale
-Use the `--locale` argument to specify the locale that all added files will have in the archive. Note that subsequent added files will have the default locale unless the `--locale` argument is specified again.
+Use the `-e` or `--locale` argument to specify the locale that all added files will have in the archive. Note that subsequent added files will have the default locale unless the `--locale` argument is specified again.
```bash
-$ mpqcli create --locale koKR
+$ mpqcli create archive.mpq --locale koKR
```
diff --git a/docs/commands/extract.md b/docs/commands/extract.md
index d8c0ee7..9317ad1 100644
--- a/docs/commands/extract.md
+++ b/docs/commands/extract.md
@@ -1,10 +1,10 @@
# extract
-Extract one or all files from a target MPQ archive.
+Extract every file, or only the named files, from an MPQ archive.
## Extract all files from an MPQ archive
-The output will be saved in a folder with the same name as the target MPQ file, without the extension.
+The output will be saved in a folder with the same name as the MPQ archive, without the extension. Files keep the folder structure they have in the archive.
```bash
$ mpqcli extract wow-patch.mpq
@@ -12,27 +12,27 @@ $ mpqcli extract wow-patch.mpq
[*] Extracted: Blizzard_CraftUI.xml
[*] Extracted: CreatureSoundData.dbc
...
-[*] Extracted: Blizzard_CraftUI.lua
+[*] Extracted: Interface/FrameXML/BlizzardFrameXML.lua
[*] Extracted: 30ee7bd3959906e358eff01332cf045e.blp
[*] Extracted: realmlist.wtf
```
-## Extract all files to a target directory
+## Extract all files to an output directory
-Extract files to a specific target directory, which will be created if it doesn't already exist. In this example, `patch-1.10` is the user-specified output directory.
+Extract files to a specific output directory, which will be created if it doesn't already exist. In this example, `patch-1.10` is the user-specified output directory.
```bash
$ mpqcli extract -o patch-1.10 wow-patch.mpq
```
-## Extract all files keeping the folder structure
+## Extract all files without the folder structure
-Use the `-k` or `--keep` flag to preserve the directory structure from the archive when extracting. Without this flag, all files are placed flat in the output directory, with only the filename retained.
+Use the `-j` or `--flat` flag to write every file straight into the output directory, keeping only the filename. If two files would end up with the same name, nothing is extracted.
```bash
-$ mpqcli extract -k wow-patch.mpq
-[*] Extracted: Interface/FrameXML/BlizzardFrameXML.lua
-[*] Extracted: Interface/FrameXML/BlizzardFrameXML.xml
+$ mpqcli extract --flat wow-patch.mpq
+[*] Extracted: BlizzardFrameXML.lua
+[*] Extracted: BlizzardFrameXML.xml
```
## Extract all files with an external listfile
@@ -43,19 +43,30 @@ Older MPQ archives do not contain (complete) file paths of their content. By pro
$ mpqcli extract -l path/to/listfile War2Dat.mpq
```
-## Extract one specific file
+The listfile only applies when extracting every file, so it cannot be combined with named files.
+
+## Extract specific files
+
+Name one or more files after the archive to extract only those. A file nested in a directory needs its full path in the archive, and is extracted with its folders; add `--flat` to drop them. Similar to the examples above, you can use the `-o` argument to specify the output directory.
+
+```bash
+$ mpqcli extract "World of Warcraft_1.12.1.5875/Data/base.MPQ" \
+ "Documentation\Layout\Greeting.html"
+[*] Extracted: Documentation/Layout/Greeting.html
+```
+
+## Extract files named on stdin
-Extract a single file using the `-f` option. If the target file in the MPQ archive is nested (in a directory) you need to include the full path. Similar to the examples above, you can use the `-o` argument to specify the output directory.
+Pass `-` in place of the file names to read them from stdin, one per line. If no names arrive, nothing is extracted. See the [advanced examples](../advanced.md) for searching an archive and extracting the matches.
```bash
-$ mpqcli extract -f "Documentation\Layout\Greeting.html" \
- "World of Warcraft_1.12.1.5875/Data/base.MPQ"
+$ mpqcli list wow-patch.mpq | grep -i '\.exe$' | mpqcli extract wow-patch.mpq -
```
## Extract one specific file with locale
-Use the `--locale` argument to specify the locale of the file to extract. If there is no file with the requested name and locale, the default locale will be used instead.
+Use the `-e` or `--locale` argument to specify the locale of the file to extract. If there is no file with the requested name and locale, the default locale will be used instead.
```bash
-$ mpqcli extract -f "rez\gluBNRes.res" Patch_rt.mpq --locale deDE
+$ mpqcli extract Patch_rt.mpq "rez\gluBNRes.res" --locale deDE
```
diff --git a/docs/commands/info.md b/docs/commands/info.md
index 5d726bd..1799902 100644
--- a/docs/commands/info.md
+++ b/docs/commands/info.md
@@ -29,9 +29,9 @@ The `info` subcommand supports the following properties:
- `max-files`
- `signature-type`
-You can use the `-p` or `--property` argument with the `info` subcommand to print just the value of a specific property. This can be useful for automation, for example, to determine the signature type of a directory of MPQ archives.
+You can use the `-q` or `--property` argument with the `info` subcommand to print just the value of a specific property. This can be useful for automation, for example, to determine the signature type of a directory of MPQ archives.
```bash
-$ mpqcli info -p file-count wow-patch.mpq
+$ mpqcli info --property file-count wow-patch.mpq
65
```
diff --git a/docs/commands/list.md b/docs/commands/list.md
index 0048326..fcd8d52 100644
--- a/docs/commands/list.md
+++ b/docs/commands/list.md
@@ -1,6 +1,6 @@
# list
-List files in a target MPQ archive.
+List files in an MPQ archive.
## List all files in an MPQ archive
@@ -63,10 +63,10 @@ The `list` subcommand supports listing the following properties:
* `n`: Use default flags for internal files.
* `f`: Fix key; This is obsolete.
-You can use the `-p` or `--property` argument with the `list` subcommand to print the given properties. Many properties can be given, and they will be printed in the order given.
+You can use the `-q` or `--property` argument with the `list` subcommand to print the given properties. Many properties can be given, and they will be printed in the order given.
```bash
-$ mpqcli list -d -a Patch_rt.mpq -p hash-index -p locale -p flags
+$ mpqcli list -d -a Patch_rt.mpq --property hash-index --property locale --property flags
...
10893 enUS ixmn glue\ScorePv\pMain.pcx
11173 enUS ixmn rez\gluAll.tbl
diff --git a/docs/commands/read.md b/docs/commands/read.md
index 0d6952e..337a108 100644
--- a/docs/commands/read.md
+++ b/docs/commands/read.md
@@ -7,7 +7,7 @@ Read a specific file to stdout.
Read the `patch.cmd` file from an MPQ archive and print the file contents to stdout. Even though the subcommand always outputs bytes, plaintext files will be human-readable.
```bash
-$ mpqcli read patch.cmd wow-patch.mpq
+$ mpqcli read wow-patch.mpq patch.cmd
* set the product patch name
PatchVersion This patch upgrades World of Warcraft from version 1.10.0.5195 to version 1.10.1.5230.
* make sure that we don't patch version 1.10.1.5230 or greater
@@ -24,13 +24,13 @@ PatchSize 1992294400
The tool will always print output in bytes and is designed to be "piped" or redirected. For example, redirect the binary `WoW.exe` file from a WoW patch to a local file:
```bash
-$ mpqcli read "WoW.exe" wow-patch.mpq > WoW.exe
+$ mpqcli read wow-patch.mpq "WoW.exe" > WoW.exe
```
Another example, piping the bytes to the `xxd` tool.
```bash
-$ mpqcli read "WoW.exe" wow-patch.mpq | xxd
+$ mpqcli read wow-patch.mpq "WoW.exe" | xxd
00000000: 1800 0404 de2a a5da 3240 4500 3250 4500 .....*..2@E.2PE.
00000010: 69ac 2703 0859 c601 0a7a 4500 8942 5344 i.'..Y...zE..BSD
00000020: 4946 4634 30a0 2905 8203 f244 0482 3250 IFF40.)....D..2P
@@ -39,8 +39,8 @@ $ mpqcli read "WoW.exe" wow-patch.mpq | xxd
## Read one specific file with locale
-Use the `--locale` argument to specify the locale of the file to read. If there is no file with the requested name and locale, the default locale will be used instead.
+Use the `-e` or `--locale` argument to specify the locale of the file to read. If there is no file with the requested name and locale, the default locale will be used instead.
```bash
-$ mpqcli read "rez\stat_txt.tbl" Patch_rt.mpq --locale ptPT
+$ mpqcli read Patch_rt.mpq "rez\stat_txt.tbl" --locale ptPT
```
diff --git a/docs/commands/remove.md b/docs/commands/remove.md
index d80f4f8..3c67151 100644
--- a/docs/commands/remove.md
+++ b/docs/commands/remove.md
@@ -35,7 +35,7 @@ $ echo -e "fth.txt\nfta.txt" | mpqcli remove wow-patch.mpq -
## Remove a file with a given locale
-Use `--locale` to remove only the copy stored under a specific locale. Without `--locale`,
+Use `-e` or `--locale` to remove only the copy stored under a specific locale. Without `--locale`,
the file stored under the default locale is removed.
```bash
diff --git a/docs/commands/rename.md b/docs/commands/rename.md
index 81975cf..b8732a9 100644
--- a/docs/commands/rename.md
+++ b/docs/commands/rename.md
@@ -10,7 +10,7 @@ $ mpqcli rename wow-patch.mpq old-name.txt new-name.txt
[~] Renaming file: old-name.txt -> new-name.txt
```
-Use `--locale` to rename only the copy stored under a specific locale. Without `--locale`,
+Use `-e` or `--locale` to rename only the copy stored under a specific locale. Without `--locale`,
the file stored under the default locale is renamed.
```bash
diff --git a/docs/commands/verify.md b/docs/commands/verify.md
index c5120a0..1638b62 100644
--- a/docs/commands/verify.md
+++ b/docs/commands/verify.md
@@ -1,6 +1,6 @@
# verify
-Verify a target MPQ archive signature.
+Verify an MPQ archive signature.
## Verify an MPQ archive
@@ -20,8 +20,8 @@ $ echo $?
## Verify an MPQ archive and print the digital signature
-Check the digital signature of an MPQ archive, by verifying the signature in the archive and also printing the digital signature value in hexadecimal using the `-p` or `--print` argument.
+Check the digital signature of an MPQ archive, by verifying the signature in the archive and also printing the digital signature value in hexadecimal using the `-x` or `--print` argument.
```bash
-$ mpqcli verify -p wow-patch.mpq > signature
+$ mpqcli verify --print wow-patch.mpq > signature
```
diff --git a/docs/commands_list.md b/docs/commands_list.md
index a40e8a5..a7e6dce 100644
--- a/docs/commands_list.md
+++ b/docs/commands_list.md
@@ -11,12 +11,12 @@ The `mpqcli` program has the following subcommands:
| [`about`](./commands/about.md) | Print information about the tool |
| [`completion`](./commands/completion.md) | Print shell completion script to stdout |
| [`info`](./commands/info.md) | Print information about MPQ archive properties |
-| [`create`](./commands/create.md) | Create an MPQ archive from a target directory or a single file |
+| [`create`](./commands/create.md) | Create an MPQ archive from files and directories |
| [`add`](./commands/add.md) | Add a file to an existing MPQ archive |
| [`remove`](./commands/remove.md) | Remove a file from an existing MPQ archive |
| [`rename`](./commands/rename.md) | Rename a file in an existing MPQ archive |
-| [`list`](./commands/list.md) | List files in a target MPQ archive |
-| [`extract`](./commands/extract.md) | Extract one or all files from a target MPQ archive |
+| [`list`](./commands/list.md) | List files in an MPQ archive |
+| [`extract`](./commands/extract.md) | Extract files from an MPQ archive |
| [`read`](./commands/read.md) | Read a specific file to stdout |
-| [`verify`](./commands/verify.md) | Verify a target MPQ archive signature |
+| [`verify`](./commands/verify.md) | Verify an MPQ archive signature |
| [`compact`](./commands/compact.md) | Compact (defragment) an MPQ archive |
diff --git a/docs/installation.md b/docs/installation.md
index 73f2b56..54a7d8a 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -46,7 +46,7 @@ $ docker run --rm -v "$PWD":/data ghcr.io/thegraydot/mpqcli:latest list example.
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/
+$ docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/data ghcr.io/thegraydot/mpqcli:latest create example.mpq files/
```
A shell function makes that the default for everyday use:
diff --git a/src/commands/add.cpp b/src/commands/add.cpp
index 3a89e98..b3e896c 100644
--- a/src/commands/add.cpp
+++ b/src/commands/add.cpp
@@ -1,22 +1,15 @@
#include "commands/add.h"
#include
-#include
#include
#include
-#include
-#include
#include
-#include "errors.h"
#include "gamerules/rules.h"
#include "mpq/add.h"
#include "mpq/archive.h"
#include "util/locales.h"
-#include "util/path.h"
-
-namespace fs = std::filesystem;
namespace mpqcli {
@@ -34,51 +27,10 @@ bool Add(const AddOptions &options, std::ostream &err, const std::atomic &
}
GameRules game_rules(profile);
- std::error_code ec;
- bool has_directory = false;
- for (const auto &f : options.files) {
- if (fs::is_directory(f, ec)) {
- has_directory = true;
- break;
- }
- }
-
- 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.string() << std::endl;
- result |= 1;
- continue;
- }
-
- if (fs::is_directory(f, ec)) {
- std::vector directory_files = ListFilesRecursive(f, ec);
- if (ec) {
- err << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message()
- << ": " << f.string() << std::endl;
- result |= 1;
- continue;
- }
- std::string prefix = options.path.value_or("");
- result |= AddFiles(archive.Handle(), directory_files, f, prefix, lcid, game_rules, err,
- 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;
- std::string archive_path = ResolveArchiveName(f, options.path, treat_as_directory);
- result |= AddFile(archive.Handle(), f, archive_path, lcid, game_rules, err,
- options.compression_overrides, options.overwrite, options.update,
- &files_skipped);
-
- } else {
- err << "[!] Not a file or directory: " << f.string() << std::endl;
- result |= 1;
- }
- }
+ const int result = AddInputs(archive.Handle(), options.archive, options.files, options.path,
+ lcid, game_rules, err, cancelled, options.compression_overrides,
+ options.overwrite, options.update, &files_skipped);
// 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.
diff --git a/src/commands/compact.cpp b/src/commands/compact.cpp
index 21a15a0..7226cfb 100644
--- a/src/commands/compact.cpp
+++ b/src/commands/compact.cpp
@@ -8,7 +8,7 @@
namespace mpqcli {
void Compact(const CompactOptions &options, std::ostream &err) {
- Archive archive = Archive::Open(options.target, 0);
+ Archive archive = Archive::Open(options.archive, 0);
CompactMpqArchive(archive.Handle(), options.listfile, err);
archive.Close();
}
diff --git a/src/commands/compact.h b/src/commands/compact.h
index 69d3116..20bb36a 100644
--- a/src/commands/compact.h
+++ b/src/commands/compact.h
@@ -9,7 +9,7 @@ namespace mpqcli {
/// Settled arguments for the compact subcommand
struct CompactOptions {
- std::filesystem::path target;
+ std::filesystem::path archive;
std::optional listfile;
};
diff --git a/src/commands/create.cpp b/src/commands/create.cpp
index 75f654c..66ca6b4 100644
--- a/src/commands/create.cpp
+++ b/src/commands/create.cpp
@@ -40,26 +40,41 @@ struct PartialArchive {
} // namespace
bool Create(const CreateOptions &options, std::ostream &err, const std::atomic &cancelled) {
+ if (options.files.empty()) {
+ throw Error("No files or directories to add");
+ }
+
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) {
- throw FileError("Failed to resolve output path", options.output.value(), ec);
- }
- } else {
- output_file_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");
+ const fs::path output_file_path = fs::absolute(options.archive, ec);
+ if (ec) {
+ throw FileError("Failed to resolve archive path", options.archive, ec);
}
if (fs::exists(output_file_path, ec)) {
throw ArchiveError("File already exists: " + output_file_path.string());
}
+ // Count every input up front: the archive's max file count is fixed at creation,
+ // and a missing input or a directory that cannot be listed fails here, before the
+ // archive exists
+ uint32_t input_file_count = 0;
+ for (const auto &f : options.files) {
+ if (!fs::exists(f, ec)) {
+ throw FileError("Path does not exist", f);
+ }
+ if (fs::is_directory(f, ec)) {
+ const std::vector directory_files = ListFilesRecursive(f, ec);
+ if (ec) {
+ throw FileError("Failed to list directory", f, ec);
+ }
+ input_file_count += static_cast(directory_files.size());
+ } else if (fs::is_regular_file(f, ec)) {
+ input_file_count++;
+ } else {
+ throw FileError("Not a file or directory", f);
+ }
+ }
+ const uint32_t file_count = CalculateMpqMaxFileValue(input_file_count);
+
GameProfile profile;
if (options.game_profile.has_value()) {
profile = GameRules::StringToProfile(options.game_profile.value());
@@ -73,20 +88,6 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic files;
- const bool is_directory = fs::is_directory(options.target, ec);
- if (is_directory) {
- files = ListFilesRecursive(options.target, ec);
- if (ec) {
- throw FileError("Failed to list directory", options.target, ec);
- }
- } else if (!fs::is_regular_file(options.target, ec)) {
- throw FileError("Not a file or directory", options.target);
- }
- const uint32_t file_count =
- CalculateMpqMaxFileValue(is_directory ? static_cast(files.size()) : 1);
-
// 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
@@ -99,15 +100,23 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic 0) {
+ err << "[!] " << files_skipped
+ << " file(s) were not added because an earlier input added a file by the same name"
+ << std::endl;
+ }
+
+ // An archive missing some of its files is not the one that was asked for, so it is
+ // discarded with the partial and the output path stays empty, as for a missing input
+ if (result != 0 || files_skipped > 0) {
+ err << "[!] Failed to add every file, so no archive was created: "
+ << output_file_path.string() << std::endl;
+ return false;
}
if (options.sign) {
@@ -123,7 +132,7 @@ bool Create(const CreateOptions &options, std::ostream &err, const std::atomic
#include
#include
+#include
#include "gamerules/settings.h"
@@ -13,9 +14,9 @@ namespace mpqcli {
/// Settled arguments for the create subcommand
struct CreateOptions {
- std::filesystem::path target;
+ std::filesystem::path archive;
+ std::vector files;
std::optional path;
- std::optional output;
bool sign = false;
std::optional locale;
std::optional game_profile;
@@ -23,7 +24,7 @@ struct CreateOptions {
CompressionSettingsOverrides compression_overrides;
};
-/// Creates an archive from a file or directory, returning false if any file failed to add
+/// Creates an archive from files and directories, creating nothing if any file failed to add
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 fde7bb9..3c7ec3a 100644
--- a/src/commands/extract.cpp
+++ b/src/commands/extract.cpp
@@ -3,7 +3,9 @@
#include
#include
#include
+#include
#include
+#include
#include
@@ -20,14 +22,40 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic
std::error_code ec;
fs::path effective_output;
if (!options.output.has_value()) {
- fs::path target_path = fs::absolute(options.target, ec);
+ fs::path archive_path = fs::absolute(options.archive, ec);
if (ec) {
- throw FileError("Failed to resolve archive path", options.target, ec);
+ throw FileError("Failed to resolve archive path", options.archive, ec);
}
- effective_output = target_path.parent_path() / target_path.stem();
+ effective_output = archive_path.parent_path() / archive_path.stem();
} else {
effective_output = options.output.value();
}
+
+ Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY);
+
+ LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale;
+
+ // Every name is settled before the output directory is created, so a
+ // rejected --flat run leaves nothing on disk
+ const std::vector file_names =
+ options.files.has_value()
+ ? options.files.value()
+ : ListArchivedFiles(archive.Handle(), options.listfile, cancelled);
+ if (file_names.empty()) {
+ err << "[*] No files to extract" << std::endl;
+ archive.Close();
+ return true;
+ }
+ if (options.flat) {
+ const auto collision = FindFlatCollision(file_names);
+ if (collision.has_value()) {
+ err << "[!] --flat would extract " << collision->first << " and " << collision->second
+ << " to the same file" << std::endl;
+ archive.Close();
+ return false;
+ }
+ }
+
fs::create_directory(effective_output, ec);
if (ec) {
std::error_code query_ec;
@@ -45,22 +73,8 @@ bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic
<< effective_output.string() << std::endl;
}
- 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) {
- 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, err);
- } else {
- result = ExtractFiles(archive.Handle(), effective_output, options.listfile, lcid, err,
- cancelled);
- }
+ const int result = ExtractFiles(archive.Handle(), effective_output, file_names, options.flat,
+ lcid, err, cancelled);
archive.Close();
if (result != 0) {
diff --git a/src/commands/extract.h b/src/commands/extract.h
index bfe997c..c3468e4 100644
--- a/src/commands/extract.h
+++ b/src/commands/extract.h
@@ -6,20 +6,22 @@
#include
#include
#include
+#include
namespace mpqcli {
/// Settled arguments for the extract subcommand
struct ExtractOptions {
- std::filesystem::path target;
+ std::filesystem::path archive;
std::optional output;
- std::optional file;
- bool keep_folder_structure = false;
+ /// Files inside the archive; unset extracts every file the archive can name
+ std::optional> files;
+ bool flat = false;
std::optional listfile;
std::optional locale;
};
-/// Extracts one file or every file, returning false if any of them failed
+/// Extracts the named files, or every file, returning false if any of them failed
bool Extract(const ExtractOptions &options, std::ostream &err, const std::atomic &cancelled);
} // namespace mpqcli
diff --git a/src/commands/info.cpp b/src/commands/info.cpp
index 9388a7e..7a6664e 100644
--- a/src/commands/info.cpp
+++ b/src/commands/info.cpp
@@ -10,7 +10,7 @@
namespace mpqcli {
void Info(const InfoOptions &options, std::ostream &out) {
- Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY);
+ Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY);
PrintMpqInfo(archive.Handle(), options.property, out);
archive.Close();
}
diff --git a/src/commands/info.h b/src/commands/info.h
index 84ec9c5..570215d 100644
--- a/src/commands/info.h
+++ b/src/commands/info.h
@@ -10,7 +10,7 @@ namespace mpqcli {
/// Settled arguments for the info subcommand
struct InfoOptions {
- std::filesystem::path target;
+ std::filesystem::path archive;
std::optional property;
};
diff --git a/src/commands/list.cpp b/src/commands/list.cpp
index e5b4839..7a844b8 100644
--- a/src/commands/list.cpp
+++ b/src/commands/list.cpp
@@ -10,7 +10,7 @@
namespace mpqcli {
void List(const ListOptions &options, std::ostream &out, std::ostream &err) {
- Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY);
+ Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY);
ListFiles(archive.Handle(), options.listfile, options.all, options.detailed, options.properties,
out, err);
archive.Close();
diff --git a/src/commands/list.h b/src/commands/list.h
index 14e490a..78e315d 100644
--- a/src/commands/list.h
+++ b/src/commands/list.h
@@ -11,7 +11,7 @@ namespace mpqcli {
/// Settled arguments for the list subcommand
struct ListOptions {
- std::filesystem::path target;
+ std::filesystem::path archive;
std::optional listfile;
bool detailed = false;
bool all = false;
diff --git a/src/commands/read.cpp b/src/commands/read.cpp
index 33c7c3c..069fbcd 100644
--- a/src/commands/read.cpp
+++ b/src/commands/read.cpp
@@ -13,13 +13,9 @@
namespace mpqcli {
bool Read(const ReadOptions &options, std::ostream &out, std::ostream &err) {
- Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY);
+ Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY);
LCID lcid = options.locale.has_value() ? LangToLocale(options.locale.value()) : default_locale;
- if (options.locale.has_value() && lcid == default_locale) {
- err << "[!] The locale '" << options.locale.value()
- << "' is unknown. Will use default locale instead." << std::endl;
- }
uint32_t file_size;
auto file_content =
diff --git a/src/commands/read.h b/src/commands/read.h
index 1a02663..c930fb6 100644
--- a/src/commands/read.h
+++ b/src/commands/read.h
@@ -10,8 +10,8 @@ namespace mpqcli {
/// Settled arguments for the read subcommand
struct ReadOptions {
+ std::filesystem::path archive;
std::string file;
- std::filesystem::path target;
std::optional locale;
};
diff --git a/src/commands/verify.cpp b/src/commands/verify.cpp
index 26c71c1..f7fd765 100644
--- a/src/commands/verify.cpp
+++ b/src/commands/verify.cpp
@@ -10,14 +10,14 @@
namespace mpqcli {
bool Verify(const VerifyOptions &options, std::ostream &out, std::ostream &err) {
- Archive archive = Archive::Open(options.target, MPQ_OPEN_READ_ONLY);
+ Archive archive = Archive::Open(options.archive, MPQ_OPEN_READ_ONLY);
bool verified;
switch (VerifyMpqArchive(archive.Handle())) {
case ERROR_WEAK_SIGNATURE_OK:
case ERROR_STRONG_SIGNATURE_OK:
if (options.print_signature) {
- PrintMpqSignature(archive.Handle(), options.target, out, err);
+ PrintMpqSignature(archive.Handle(), options.archive, out, err);
}
err << "[*] Verify success" << std::endl;
verified = true;
@@ -28,7 +28,7 @@ bool Verify(const VerifyOptions &options, std::ostream &out, std::ostream &err)
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(), options.target, out, err);
+ PrintMpqSignature(archive.Handle(), options.archive, out, err);
}
err << "[!] Verify failed: signature is present but invalid" << std::endl;
verified = false;
diff --git a/src/commands/verify.h b/src/commands/verify.h
index 3deb2f8..f068e57 100644
--- a/src/commands/verify.h
+++ b/src/commands/verify.h
@@ -8,7 +8,7 @@ namespace mpqcli {
/// Settled arguments for the verify subcommand
struct VerifyOptions {
- std::filesystem::path target;
+ std::filesystem::path archive;
bool print_signature = false;
};
diff --git a/src/mpq/add.cpp b/src/mpq/add.cpp
index 80631c5..5c22554 100644
--- a/src/mpq/add.cpp
+++ b/src/mpq/add.cpp
@@ -5,6 +5,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -24,6 +25,22 @@ namespace fs = std::filesystem;
namespace mpqcli {
+namespace {
+
+/// Whether file is the archive's own file, reporting the skip when it is
+bool SkipIfArchiveFile(const fs::path &file, const fs::path &archive_file, std::ostream &err) {
+ // Compared by identity rather than by path, so a symlink to the archive or another
+ // spelling of its path is caught too
+ std::error_code ec;
+ if (!fs::equivalent(file, archive_file, ec)) {
+ return false;
+ }
+ err << "[*] Skipping the archive itself: " << file.string() << std::endl;
+ return true;
+}
+
+} // namespace
+
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 std::atomic &cancelled,
@@ -185,4 +202,65 @@ int AddFile(HANDLE archive, const fs::path &local_file, const std::string &archi
return 0;
}
+int AddInputs(HANDLE archive, const fs::path &archive_file, const std::vector &inputs,
+ const std::optional &path, LCID locale, const GameRules &game_rules,
+ std::ostream &err, const std::atomic &cancelled,
+ const CompressionSettingsOverrides &overrides, bool overwrite, bool update,
+ int *skipped) {
+ std::error_code ec;
+ bool has_directory = false;
+ for (const auto &f : inputs) {
+ if (fs::is_directory(f, ec)) {
+ has_directory = true;
+ break;
+ }
+ }
+
+ int result = 0;
+ for (const auto &f : inputs) {
+ ThrowIfCancelled(cancelled);
+
+ if (!fs::exists(f, ec)) {
+ err << "[!] Path does not exist: " << f.string() << std::endl;
+ result |= 1;
+ continue;
+ }
+
+ if (fs::is_directory(f, ec)) {
+ std::vector directory_files = ListFilesRecursive(f, ec);
+ if (ec) {
+ err << "[!] Failed to list directory: (" << ec.value() << ") " << ec.message()
+ << ": " << f.string() << std::endl;
+ result |= 1;
+ continue;
+ }
+ // A directory can hold the archive being written, as "create out.mpq ." does
+ const auto skip_archive = [&](const fs::path &file) {
+ return SkipIfArchiveFile(file, archive_file, err);
+ };
+ directory_files.erase(
+ std::remove_if(directory_files.begin(), directory_files.end(), skip_archive),
+ directory_files.end());
+ std::string prefix = path.value_or("");
+ result |= AddFiles(archive, directory_files, f, prefix, locale, game_rules, err,
+ cancelled, overrides, overwrite, update, skipped);
+
+ } else if (fs::is_regular_file(f, ec)) {
+ if (SkipIfArchiveFile(f, archive_file, err)) {
+ continue;
+ }
+ const bool treat_as_directory = has_directory || inputs.size() > 1;
+ std::string archive_path = ResolveArchiveName(f, path, treat_as_directory);
+ result |= AddFile(archive, f, archive_path, locale, game_rules, err, overrides,
+ overwrite, update, skipped);
+
+ } else {
+ err << "[!] Not a file or directory: " << f.string() << std::endl;
+ result |= 1;
+ }
+ }
+
+ return result;
+}
+
} // namespace mpqcli
diff --git a/src/mpq/add.h b/src/mpq/add.h
index 2bd07af..50d17b1 100644
--- a/src/mpq/add.h
+++ b/src/mpq/add.h
@@ -3,6 +3,7 @@
#include
#include
+#include
#include
#include
#include
@@ -25,6 +26,19 @@ int AddFile(HANDLE archive, const std::filesystem::path &local_file,
const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(),
bool overwrite = false, bool update = false, int *skipped = nullptr);
+/// Adds each local file or directory in inputs, returning 1 if any of them failed
+///
+/// A directory's contents go under path, or at the archive root without it. A lone file is
+/// stored at path when given; beside other inputs or any directory, path is a prefix.
+/// archive_file is the archive's own file on disk, which is skipped wherever it turns up
+/// among the inputs, so an archive is never added to itself.
+int AddInputs(HANDLE archive, const std::filesystem::path &archive_file,
+ const std::vector &inputs,
+ const std::optional &path, LCID locale, const GameRules &game_rules,
+ std::ostream &err, const std::atomic &cancelled,
+ const CompressionSettingsOverrides &overrides = CompressionSettingsOverrides(),
+ bool overwrite = false, bool update = false, int *skipped = nullptr);
+
} // namespace mpqcli
#endif
diff --git a/src/mpq/extract.cpp b/src/mpq/extract.cpp
index 23f70fe..5aff018 100644
--- a/src/mpq/extract.cpp
+++ b/src/mpq/extract.cpp
@@ -1,12 +1,14 @@
#include "mpq/extract.h"
#include
-#include
#include
+#include