diff --git a/README.md b/README.md index 01920ae..0bbe2b2 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A command line tool to create, add, remove, list, extract, read, and verify MPQ **This is a command-line tool, designed for automation and built with the Unix philosophy in mind.** It is designed to work seamlessly with other command-line tools, supporting piping, redirection, and integration into shell scripts and workflows. For example: -- Run one command to create an MPQ archive from a directory of files +- Run one command to create an MPQ archive from a directory of files or a single file - Run one command to list all files in an MPQ archive - Pipe the output to `grep` or other tools to search, filter, or process files - Redirect output to files or other commands for further automation @@ -72,7 +72,7 @@ The `mpqcli` program has the following subcommands: - `version`: Print the tool version number - `about`: Print information about the tool - `info`: Print information about MPQ archive properties -- `create`: Create an MPQ archive from a target directory +- `create`: Create an MPQ archive from a target directory or a single file - `add`: Add a file to an existing MPQ archive - `remove`: Remove a file from an existing MPQ archive - `list`: List files in a target MPQ archive @@ -127,6 +127,16 @@ mpqcli create The default mode of operation for the `create` subcommand is to take everything from the "target" directory (and below) and recursively add it to the archive. The directory structure is retained. Windows-style backslash path separators are used (`\`), as per the observed behavior in most MPQ archives. +### Create an MPQ archive from a single file + +Create an MPQ file from a single file. Automatically adds `(listfile)` to the archive. + +``` +mpqcli create +``` + +This will put the given file in the root of the MPQ archive. By optionally providing a path in the `--name-in-archive` parameter, the name that the file has in the MPQ archive can be changed, and it can be put in a directory. + ### Create an MPQ archive using a specific version Support for creating an MPQ archive version 1 or version 2 by using the `-v` or `--version` argument. diff --git a/src/helpers.cpp b/src/helpers.cpp index 2a52bb4..ca5c521 100644 --- a/src/helpers.cpp +++ b/src/helpers.cpp @@ -47,20 +47,22 @@ std::string WindowsifyFilePath(const fs::path &path) { return filePath; } -int32_t CalculateMpqMaxFileValue(const std::string &directory) { +int32_t CalculateMpqMaxFileValue(const std::string &path) { int32_t fileCount = 0; - // Determine number of files in target directory, recusively - for (const auto &entry : fs::recursive_directory_iterator(directory)) { - if (fs::is_regular_file(entry.path())) { - ++fileCount; + // Determine the number of files in the target directory, recusively + if (!fs::is_regular_file(path)) { + for (const auto &entry: fs::recursive_directory_iterator(path)) { + if (fs::is_regular_file(entry.path())) { + ++fileCount; + } } } // Always add 3 for "special" files fileCount += 3; - // Based on file count, determine max number of files an MPQ archive can hold + // Based on file count, determine the max number of files an MPQ archive can hold // We always have a minimum of 32 // Anything over is rounded up to the closest power of 2 // For example: 64, 128, 256 diff --git a/src/helpers.h b/src/helpers.h index 951fc02..ac5efa2 100644 --- a/src/helpers.h +++ b/src/helpers.h @@ -9,7 +9,7 @@ namespace fs = std::filesystem; std::string FileTimeToLsTime(int64_t fileTime); std::string NormalizeFilePath(const fs::path &path); std::string WindowsifyFilePath(const fs::path &path); -int32_t CalculateMpqMaxFileValue(const std::string &directory); +int32_t CalculateMpqMaxFileValue(const std::string &path); int32_t NextPowerOfTwo(int32_t n); void PrintAsBinary(const char* buffer, uint32_t size); diff --git a/src/main.cpp b/src/main.cpp index c6a2aa3..5df8ed1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -27,6 +27,7 @@ int main(int argc, char **argv) { std::string baseFile = "default"; // add, remove, extract, read std::string basePath = "default"; // add std::string baseLocale = "default"; // create, add, remove, extract, read + std::string baseNameInArchive = "default"; // add, create std::string baseOutput = "default"; // create, extract std::string baseListfileName = "default"; // list, extract // CLI: info @@ -84,16 +85,17 @@ int main(int argc, char **argv) { ->check(CLI::IsMember(validInfoProperties)); // Subcommand: Create - CLI::App *create = app.add_subcommand("create", "Create an MPQ archive from target directory"); - create->add_option("target", baseTarget, "Target directory") + CLI::App *create = app.add_subcommand("create", "Create an MPQ archive from target file or directory"); + create->add_option("target", baseTarget, "Directory or file to put in MPQ archive") ->required() - ->check(CLI::ExistingDirectory); + ->check(CLI::ExistingPath); create->add_option("-o,--output", baseOutput, "Output MPQ archive"); create->add_flag("-s,--sign", createSignArchive, "Sign the MPQ archive (default false)"); create->add_option("-v,--version", createMpqVersion, "Set the MPQ archive version (default 1)") ->check(CLI::Range(1, 2)); create->add_option("--locale", baseLocale, "Locale to use for added files") ->check(LocaleValid); + create->add_option("-n,--name-in-archive", baseNameInArchive, "Filename inside MPQ archive"); // Subcommand: Add CLI::App *add = app.add_subcommand("add", "Add a file to an existing MPQ archive"); @@ -199,6 +201,10 @@ int main(int argc, char **argv) { // Handle subcommand: Create if (app.got_subcommand(create)) { + if (!fs::is_regular_file(baseTarget) && baseNameInArchive != "default") { + std::cerr << "[!] Cannot specify --name-in-archive when adding a directory." << std::endl; + return 1; + } fs::path outputFilePath; if (baseOutput != "default") { outputFilePath = fs::absolute(baseOutput); @@ -217,8 +223,19 @@ int main(int argc, char **argv) { HANDLE hArchive = CreateMpqArchive(outputFile, fileCount, createMpqVersion); if (hArchive) { LCID locale = LangToLocale(baseLocale); - AddFiles(hArchive, baseTarget, locale); - + if (fs::is_regular_file(baseTarget)) { + // Default: use the filename as path, saves file to root of MPQ + fs::path filePath = fs::path(baseTarget); + std::string archivePath = filePath.filename().u8string(); + if (baseNameInArchive != "default") { // Optional: specified filename inside archive + filePath = fs::path(baseNameInArchive); + archivePath = WindowsifyFilePath(filePath); // Normalise path for MPQ + } + + AddFile(hArchive, baseTarget, archivePath, locale); + } else { + AddFiles(hArchive, baseTarget, locale); + } if (createSignArchive) { SignMpqArchive(hArchive); } @@ -327,7 +344,7 @@ int main(int argc, char **argv) { uint32_t fileSize; char* fileContent = ReadFile(hArchive, baseFile.c_str(), &fileSize, locale); - if (fileContent == NULL) { + if (fileContent == nullptr) { return 1; } diff --git a/test/test_create.py b/test/test_create.py index a8fe6bf..02b5824 100644 --- a/test/test_create.py +++ b/test/test_create.py @@ -141,6 +141,98 @@ def test_create_mpq_already_exists(binary_path, generate_test_files): assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" +def test_create_mpq_from_file(binary_path, generate_test_files): + """ + Test MPQ archive creation from a file rather than a directory. + + This test checks: + - MPQ archive creation from a file. + """ + _ = generate_test_files + script_dir = Path(__file__).parent + + # Create a new test file on the fly + test_file = script_dir / "data" / "test.txt" + test_file.write_text("This is a test file for MPQ addition.") + + target_file = test_file.with_suffix(".mpq") + # Remove the target file if it exists + # Testing creation when file exists is handled: + # test_create_mpq_already_exists + target_file.unlink(missing_ok=True) + result = subprocess.run( + [str(binary_path), "create", str(test_file), "--output", str(target_file)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert target_file.exists(), f"MPQ file was not created)" + assert target_file.stat().st_size > 0, f"MPQ file is empty)" + + verify_archive_file_content(binary_path, target_file, {"enUS test.txt"}) + + +def test_create_mpq_from_file_with_nameinarchive_parameter(binary_path, generate_test_files): + """ + Test MPQ archive creation from a file, with the --name-in-archive parameter. + This test checks: + - MPQ archive creation from a file. + - That the --name-in-archive parameter is correctly handled. + """ + _ = generate_test_files + script_dir = Path(__file__).parent + + # Create a new test file on the fly + test_file = script_dir / "data" / "test.txt" + test_file.write_text("This is a test file for MPQ addition.") + + target_file = test_file.with_suffix(".mpq") + # Remove the target file if it exists + # Testing creation when file exists is handled: + # test_create_mpq_already_exists + target_file.unlink(missing_ok=True) + result = subprocess.run( + [str(binary_path), "create", str(test_file), "--output", str(target_file), "--name-in-archive", "messages\\important.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}" + assert target_file.exists(), f"MPQ file was not created)" + assert target_file.stat().st_size > 0, f"MPQ file is empty)" + + verify_archive_file_content(binary_path, target_file, {"enUS messages\\important.txt"}) + + +def test_create_mpq_from_directory_with_nameinarchive_parameter(binary_path, generate_test_files): + """ + Test MPQ archive creation from a directory, with the --name-in-archive parameter. + This test checks: + - No MPQ archive is created. + """ + _ = generate_test_files + script_dir = Path(__file__).parent + target_dir = script_dir / "data" / "files" + + target_file = target_dir.with_name("files_test").with_suffix(".mpq") + # Remove the target file if it exists + # Testing creation when file exists is handled: + # test_create_mpq_already_exists + target_file.unlink(missing_ok=True) + result = subprocess.run( + [str(binary_path), "create", str(target_dir), "--output", str(target_file), "--name-in-archive", "messages\\important.txt"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + assert result.returncode == 1, f"mpqcli failed with error: {result.stderr}" + assert not target_file.exists(), f"MPQ file was created)" + + def test_create_mpq_with_illegal_locale(binary_path, generate_test_files): """ Test MPQ file creation with illegal locale.