Skip to content

Conversation

@sjoblomj
Copy link
Contributor

@sjoblomj sjoblomj commented Jan 7, 2026

Listing and extracting works in older games, but not MPQ creation or adding files to archives.

The Readme says (emphasis mine):

This project is primarily for older World of Warcraft MPQ archives. [...] No testing has been performed on other MPQ versions or archives from other games. However, the tool will most likely work on other MPQ archive versions, as the underlying Stormlib library supports all MPQ archive versions.

... however, this is incorrect; mpqcli currently compresses using Zlib, which is a compression type that older games don't handle. Different games use different flags and compression methods, and they may differ on file types.

This PR implements support for all relevant Blizzard/Sierra games starting with Diablo I (1997) up until Diablo III (2012).

The CLI now exposes a --game flag for create and add subcommands, and by providing it, mpqcli automatically selects the correct settings based on the game and what sort of file the user is adding. The CLI also exposes ways to override all of these settings for advanced users, but just giving e.g. --game warcraft3 should be enough for all normal usage.

Three rules exist to determine what settings to chose: based on file size, based on a file name mask (e.g. *.wav), or default rules if nothing else applies.

This "compatibility matrix" of settings and games comes directly from Ladislav Zezula: ladislav-zezula/StormLib#406 (comment)

I realize it is quite a big PR, but it should hopefully be straight-forward enough. Please let me know if you have any thoughts or issues.

Comment on lines 173 to 207
{"generic", GameProfile::GENERIC},
{"diablo1", GameProfile::DIABLO1},
{"diablo", GameProfile::DIABLO1},
{"lordsofmagic", GameProfile::LORDSOFMAGIC},
{"lomse", GameProfile::LORDSOFMAGIC},
{"starcraft", GameProfile::STARCRAFT1},
{"starcraft1", GameProfile::STARCRAFT1},
{"sc", GameProfile::STARCRAFT1},
{"sc1", GameProfile::STARCRAFT1},
{"warcraft2", GameProfile::WARCRAFT2},
{"wc2", GameProfile::WARCRAFT2},
{"war2", GameProfile::WARCRAFT2},
{"diablo2", GameProfile::DIABLO2},
{"d2", GameProfile::DIABLO2},
{"warcraft3", GameProfile::WARCRAFT3},
{"wc3", GameProfile::WARCRAFT3},
{"war3", GameProfile::WARCRAFT3},
{"warcraft3-map", GameProfile::WARCRAFT3_MAP},
{"wc3-map", GameProfile::WARCRAFT3_MAP},
{"war3-map", GameProfile::WARCRAFT3_MAP},
{"wow1", GameProfile::WOW_1X},
{"wow-vanilla", GameProfile::WOW_1X},
{"wow2", GameProfile::WOW_2X},
{"wow-tbc", GameProfile::WOW_2X},
{"wow3", GameProfile::WOW_3X},
{"wow-wotlk", GameProfile::WOW_3X},
{"wow4", GameProfile::WOW_4X},
{"wow-cataclysm", GameProfile::WOW_4X},
{"wow5", GameProfile::WOW_5X},
{"wow-mop", GameProfile::WOW_5X},
{"starcraft2", GameProfile::STARCRAFT2},
{"sc2", GameProfile::STARCRAFT2},
{"diablo3", GameProfile::DIABLO3},
{"d3", GameProfile::DIABLO3}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are all valid strings to use for the --game parameter

Comment on lines +225 to +241
case GameProfile::GENERIC: return "generic";
case GameProfile::DIABLO1: return "diablo1";
case GameProfile::LORDSOFMAGIC: return "lordsofmagic";
case GameProfile::WARCRAFT2: return "warcraft2";
case GameProfile::STARCRAFT1: return "starcraft1";
case GameProfile::DIABLO2: return "diablo2";
case GameProfile::WARCRAFT3: return "warcraft3";
case GameProfile::WARCRAFT3_MAP: return "warcraft3-map";
case GameProfile::WOW_1X: return "wow-vanilla";
case GameProfile::WOW_2X: return "wow-tbc";
case GameProfile::WOW_3X: return "wow-wotlk";
case GameProfile::WOW_4X: return "wow-cataclysm";
case GameProfile::WOW_5X: return "wow-mop";
case GameProfile::STARCRAFT2: return "starcraft2";
case GameProfile::DIABLO3: return "diablo3";
default: return "generic";
Copy link
Contributor Author

@sjoblomj sjoblomj Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These values will be displayed by CLI11. They are fewer than what the --game parameter accepts. I figured it would be a bit repetitive for the user to have to wade through too many aliases, e.g. "warcraft3, wc3, war3, warcraft3-map, wc3-map, war3-map". The reason I put these aliases there are, of course, to make it convenient for the user

Comment on lines +80 to +90
str(binary_path), "create",
"--version", str(version),
"--file-flags1", "4294967295",
"--file-flags2", "4294967295",
"--file-flags3", "0",
"--attr-flags", "15",
"--flags", "66048",
"--compression", "2",
"--compression-next", "4294967295",
"-o", str(output_file),
str(target_dir),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to have these MPQs be as similar as possible to how they were before, otherwise many old tests would need to be updated. But this test becomes a bit "messy" because of it.

Comment on lines +124 to +133
str(binary_path), "create", "-s",
"--file-flags1", "4294967295",
"--file-flags2", "4294967295",
"--file-flags3", "0",
"--attr-flags", "15",
"--flags", "512",
"--compression", "2",
"--compression-next", "4294967295",
"-o", str(output_file),
str(target_dir),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to have these MPQs be as similar as possible to how they were before, otherwise many old tests would need to be updated. But this test becomes a bit "messy" because of it.

Comment on lines -152 to +157
# Create output_file path without suffix (default extract behavior is MPQ without extension)
output_file = output_dir.with_suffix("")

# Create output_files set based on directory contents (not full path)
output_files = set(fi.name for fi in output_file.glob("*"))
output_files = set(fi.name for fi in output_dir.glob("*"))

assert result.returncode == 0, f"mpqcli failed with error: {result.stderr}"
assert output_lines == expected_lines, f"Unexpected output: {output_lines}"
assert output_file.exists(), "Output directory was not created"
assert output_dir.exists(), "Output directory was not created"
Copy link
Contributor Author

@sjoblomj sjoblomj Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see now that this actually isn't related to the rest of the PR (sorry!). This change makes the test nicer and more consistent with other test cases though.

"Header offset: 0",
"Header size: 32",
"Archive size: 1380",
"Archive size: 1381",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're now creating archives with SFileCreateArchive2 instead of SFileCreateArchive, there are some minor size differences.

expected_output = {
" 27 enUS (listfile)",
" 148 enUS (attributes)",
" 149 enUS (attributes)",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're now creating archives with SFileCreateArchive2 instead of SFileCreateArchive, there are some minor size differences.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant