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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,15 @@ Add a local file to an already existing MPQ archive.
```
$ echo "For The Horde" > fth.txt
$ mpqcli add fth.txt wow-patch.mpq
[+] Adding file for locale 0: fth.txt
[+] Adding file: fth.txt
```

Alternatively, you can add a file to a specific subdirectory using the `-p` or `--path` argument.

```
$ echo "For The Alliance" > fta.txt
$ mpqcli add fta.txt wow-patch.mpq --path texts
[+] Adding file for locale 0: texts\fta.txt
[+] Adding file: texts\fta.txt
```

### Add files to an MPQ archive with a given locale
Expand All @@ -174,7 +174,7 @@ Use the `--locale` argument to specify the locale that the added file will have

```
$ mpqcli add allianz.txt wow-patch.mpq --locale deDE
[+] Adding file for locale 1031: allianz.txt
[+] Adding file for locale deDE: allianz.txt
```

### Add a file with game-specific properties
Expand All @@ -183,7 +183,7 @@ Target a specific game version by using the `-g` or `--game` argument. This will

```
$ mpqcli add khwhat1.wav archive.mpq --game wc2 # In StarCraft and WarCraft II MPQs, wav files are compressed in ADPCM form
[+] Adding file for locale 0: khwhat1.wav
[+] Adding file: khwhat1.wav
```


Expand All @@ -193,7 +193,7 @@ Remove a file from an existing MPQ archive.

```
$ mpqcli remove fth.txt wow-patch.mpq
[-] Removing file for locale 0: fth.txt
[-] Removing file: fth.txt
```

### Remove a file from an MPQ archive with a given locale
Expand All @@ -202,7 +202,7 @@ Use the `--locale` argument to specify the locale of the file to be removed.

```
$ mpqcli remove alianza.txt wow-patch.mpq --locale esES
[-] Removing file for locale 1034: alianza.txt
[-] Removing file for locale esES: alianza.txt
```


Expand Down
61 changes: 56 additions & 5 deletions src/locales.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
#include <fstream>
#include <vector>
#include <map>
#include <sstream>
#include <iomanip>

#include "locales.h"

Expand All @@ -28,14 +30,15 @@ namespace {
{0x412, "koKR"}, // Korean
{0x413, "nlNL"}, // Dutch
{0x415, "plPL"}, // Polish
{0x416, "ptPT"}, // Portuguese (Portugal)
{0x416, "ptBR"}, // Portuguese (Brazil)
{0x419, "ruRU"}, // Russian
{0x804, "zhCN"}, // Chinese (Simplified)
{0x809, "enGB"}, // English (UK)
{0x80A, "esMX"} // Spanish (Mexico)
{0x80A, "esMX"}, // Spanish (Mexico)
{0x816, "ptPT"}, // Portuguese (Portugal)
};

// Create a reverse map for language to locale lookups
// Create a reverse map for language-to-locale lookups
const std::map<std::string, uint16_t> langToLocaleMap = []() {
std::map<std::string, uint16_t> reverseMap;
for (const auto& [locale, lang] : localeToLangMap) {
Expand All @@ -45,16 +48,56 @@ namespace {
}
return reverseMap;
}();

std::string FormatLocaleAsHex(const LCID locale) {
std::stringstream ss;
ss << std::hex << std::uppercase << locale;
const std::string hexStr = ss.str();
// Prepend 0s if needed
return std::string(4 - hexStr.length(), '0') + hexStr;
}
}

// Check if a string is a 4-character hexadecimal number and parse it
// Returns the parsed LCID if valid, otherwise returns defaultLocale (0)
LCID ParseHexLocale(const std::string& str) {
if (str.length() != 4) {
return defaultLocale;
}

// Check if all characters are hexadecimal
for (char c : str) {
if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) {
return defaultLocale;
}
}

// Parse the hexadecimal string
std::stringstream ss;
ss << std::hex << str;
LCID locale;
ss >> locale;
return locale;
}

std::string LocaleToLang(uint16_t locale) {
auto it = localeToLangMap.find(locale);
return it != localeToLangMap.end() ? it->second : "";
return it != localeToLangMap.end() ? it->second : FormatLocaleAsHex(locale);
}

LCID LangToLocale(const std::string& lang) {
auto it = langToLocaleMap.find(lang);
return it != langToLocaleMap.end() ? it->second : defaultLocale;
if (it != langToLocaleMap.end()) {
return it->second;
}

// Try parsing as a hexadecimal LCID
LCID hexLocale = ParseHexLocale(lang);
if (hexLocale != defaultLocale) {
return hexLocale;
}

return defaultLocale;
}


Expand All @@ -69,3 +112,11 @@ std::vector<std::string> GetAllLocales() {
std::sort(locales.begin(), locales.end());
return locales;
}

std::string PrettyPrintLocale(const LCID locale, const std::string &prefix, bool alwaysPrint) {
if (locale == defaultLocale && !alwaysPrint) {
return "";
}
const auto lang = LocaleToLang(locale);
return prefix + lang;
}
11 changes: 9 additions & 2 deletions src/locales.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@ const LCID defaultLocale = 0;

std::string LocaleToLang(uint16_t locale);
LCID LangToLocale(const std::string &lang);
LCID ParseHexLocale(const std::string& str);
std::vector<std::string> GetAllLocales();
std::string PrettyPrintLocale(LCID locale, const std::string &prefix = "", bool alwaysPrint = false);

// Validator for CLI11
const inline auto LocaleValid = CLI::Validator(
[](const std::string &str) {
if (str == "default") return std::string();

LCID locale = LangToLocale(str);
// Check if it's a 4-character hexadecimal string
if (ParseHexLocale(str) != defaultLocale) {
return std::string();
}

const LCID locale = LangToLocale(str);
if (locale == 0) {
std::string validLocales = "Locale must be nothing, or one of:";
for (const auto& l : GetAllLocales()) {
Expand All @@ -28,7 +35,7 @@ const inline auto LocaleValid = CLI::Validator(
}
return std::string();
},
"Validates locales and outputs valid locales",
"",
"LocaleValidator"
);

Expand Down
10 changes: 5 additions & 5 deletions src/mpq.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -200,13 +200,13 @@ int AddFile(
if (SFileOpenFileEx(hArchive, archiveFilePath.c_str(), SFILE_OPEN_FROM_MPQ, &hFile)) {
int32_t fileLocale = GetFileInfo<int32_t>(hFile, SFileInfoLocale);
if (fileLocale == locale) {
std::cerr << "[!] File for locale " << locale << " already exists in MPQ archive: " << archiveFilePath
std::cerr << "[!] File" << PrettyPrintLocale(locale, " for locale ") << " already exists in MPQ archive: " << archiveFilePath
<< " - Skipping..." << std::endl;
return -1;
}
}
SFileCloseFile(hFile);
std::cout << "[+] Adding file for locale " << locale << ": " << archiveFilePath << std::endl;
std::cout << "[+] Adding file" << PrettyPrintLocale(locale, " for locale ") << ": " << archiveFilePath << std::endl;

// Verify that we are not exceeding maxFile size of the archive, and if we do, increase it
int32_t numberOfFiles = GetFileInfo<int32_t>(hArchive, SFileMpqNumberOfFiles);
Expand Down Expand Up @@ -254,15 +254,15 @@ int AddFile(

int RemoveFile(HANDLE hArchive, const std::string& archiveFilePath, LCID locale) {
SFileSetLocale(locale);
std::cout << "[-] Removing file for locale " << locale <<": " << archiveFilePath << std::endl;
std::cout << "[-] Removing file" << PrettyPrintLocale(locale, " for locale ") <<": " << archiveFilePath << std::endl;

if (!SFileHasFile(hArchive, archiveFilePath.c_str())) {
std::cerr << "[!] Failed: File doesn't exist for locale " << locale << ": " << archiveFilePath << std::endl;
std::cerr << "[!] Failed: File doesn't exist" << PrettyPrintLocale(locale, " for locale ") << ": " << archiveFilePath << std::endl;
return -1;
}

if (!SFileRemoveFile(hArchive, archiveFilePath.c_str(), 0)) {
std::cerr << "[!] Failed: File cannot be removed for locale " << locale << ": " << archiveFilePath << std::endl;
std::cerr << "[!] Failed: File cannot be removed" << PrettyPrintLocale(locale, " for locale ") << ": " << archiveFilePath << std::endl;
return -1;
}

Expand Down
53 changes: 50 additions & 3 deletions test/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import shutil
from datetime import datetime
import os
import platform
Expand Down Expand Up @@ -69,6 +70,7 @@ def generate_locales_mpq_test_files(binary_path):
data_dir.mkdir(parents=True, exist_ok=True)

locales_files_dir = data_dir / "locale_files"
shutil.rmtree(locales_files_dir, ignore_errors=True)
locales_files_dir.mkdir(parents=True, exist_ok=True)

mpq_many_locales_file_name = data_dir / "mpq_with_many_locales.mpq"
Expand All @@ -78,9 +80,10 @@ def generate_locales_mpq_test_files(binary_path):
mpq_one_locale_file_name.unlink(missing_ok=True)

locale_files = {
"": "This is a file about cats.", # Default locale
"deDE": "Dies ist eine Datei über Katzen.",
"esES": "Este es un archivo sobre gatos.",
"": "This is a file about cats.", # Default locale
"041D": "Detta är en fil om katter.", # Swedish locale (not part of locales.cpp)
"deDE": "Dies ist eine Datei über Katzen.", # German locale
"esES": "Este es un archivo sobre gatos.", # Spanish locale
}

# Put all items into mpq_many_locales_file_name with their locale
Expand Down Expand Up @@ -120,6 +123,50 @@ def generate_locales_mpq_test_files(binary_path):
assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}"


@pytest.fixture(scope="function")
def generate_mpq_without_internal_listfile(binary_path):
script_dir = Path(__file__).parent

data_dir = script_dir / "data"
data_dir.mkdir(parents=True, exist_ok=True)

locales_files_dir = data_dir / "locale_files"
shutil.rmtree(locales_files_dir, ignore_errors=True)
locales_files_dir.mkdir(parents=True, exist_ok=True)

mpq_file_name = data_dir / "mpq_without_internal_listfile2.mpq"
mpq_file_name.unlink(missing_ok=True)

content = [
("capybaras.txt", "", "This is a file about capybaras."), # Default locale
("cats.txt", "deDE", "Dies ist eine Datei über Katzen."), # German locale
("dogs.txt", "041D", "Detta är en fil om hundar."), # Swedish locale (not part of locales.cpp)
]

# Put all items into mpq_many_locales_file_name with their locale
for text_file_name, locale, text_content in content:
file_path = locales_files_dir / text_file_name
file_path.write_text(text_content, newline="\n")

if locale == "": # Default locale - create a new MPQ file
result = subprocess.run(
[str(binary_path), "create", "-o", str(mpq_file_name), str(locales_files_dir), "--file-flags1", "0"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}"

else: # Explicit locale - add to existing MPQ file
result = subprocess.run(
[str(binary_path), "add", str(file_path), str(mpq_file_name), "--locale", locale],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}"


@pytest.fixture(scope="session")
def download_test_files():
script_dir = Path(__file__).parent
Expand Down
6 changes: 3 additions & 3 deletions test/test_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def test_add_file_to_mpq_archive(binary_path, generate_test_files):

output_lines = set(result.stdout.splitlines())
expected_stdout_output = {
"[+] Adding file for locale 0: test.txt",
"[+] Adding file: test.txt",
}
assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}"

Expand Down Expand Up @@ -165,7 +165,7 @@ def test_create_mpq_with_locale(binary_path, generate_test_files):

output_lines = set(result.stdout.splitlines())
expected_stdout_output = {
"[+] Adding file for locale 1034: cats.txt",
"[+] Adding file for locale esES: cats.txt",
}
assert output_lines == expected_stdout_output, f"Unexpected output: {output_lines}"

Expand Down Expand Up @@ -227,7 +227,7 @@ def test_add_file_with_game_profile(binary_path, generate_test_files):

assert result.returncode == 0, f"mpqcli failed for profile {profile}: {result.stderr}"
assert f"Using game profile: {profile}" in result.stdout, f"Game profile message not found for {profile}"
assert f"Adding file for locale 0: test_{profile}.txt" in result.stdout
assert f"Adding file: test_{profile}.txt" in result.stdout

# Verify compression flags on the added file
list_result = subprocess.run(
Expand Down
Loading