wowlib 0.0.0
Read & write World of Warcraft client files — a C++26 core
Loading...
Searching...
No Matches
mpq_chain.cpp
Go to the documentation of this file.
2
3#include <algorithm>
4#include <array>
5#include <cstdint>
6#include <format>
7#include <optional>
8#include <string>
9#include <utility>
10
11namespace wowlib::fs::detail {
12 namespace {
13 namespace fsys = std::filesystem;
14
15 // 3.3.5a (build 12340). Base tier: the archives the client binary hardcodes,
16 // loaded below the patch range in this fixed order. Patches load above via
17 // ClassicWildcard — `patch.MPQ` + single-char `patch-?.MPQ` in Data/ and the
18 // `patch-{locale}[-?]` equivalents in Data/{locale}/, all merged into ONE
19 // list and sorted by the client's case-insensitive, extension-stripped
20 // filename order (verified: the loader's sorted pass globs both wildcards
21 // together). Base and locale patches interleave — a base letter-patch whose
22 // infix sorts past the locale code (`patch-Z` vs `patch-enUS`) outranks the
23 // locale patches, so custom content wins as the client intends.
24 constexpr std::array WotlkBase{
25 ChainEntry{ChainEntryKind::Fixed, "common.MPQ"},
26 ChainEntry{ChainEntryKind::Fixed, "common-2.MPQ"},
27 ChainEntry{ChainEntryKind::Fixed, "expansion.MPQ"},
28 ChainEntry{ChainEntryKind::Fixed, "lichking.MPQ"},
29 ChainEntry{ChainEntryKind::LocaleFixed, "locale-{locale}.MPQ"},
30 ChainEntry{ChainEntryKind::LocaleFixed, "speech-{locale}.MPQ"},
31 ChainEntry{ChainEntryKind::LocaleFixed, "expansion-locale-{locale}.MPQ"},
32 ChainEntry{ChainEntryKind::LocaleFixed, "expansion-speech-{locale}.MPQ"},
33 ChainEntry{ChainEntryKind::LocaleFixed, "lichking-locale-{locale}.MPQ"},
34 ChainEntry{ChainEntryKind::LocaleFixed, "lichking-speech-{locale}.MPQ"},
35 };
36
37 // Vanilla 1.12.x (build 5875). Base tier: the media/data archives the client
38 // binary hardcodes, plus the two locale archives in Data/{locale}/. Patches
39 // load above via the same ClassicWildcard scheme as WotLK (`patch.MPQ` +
40 // `patch-?.MPQ` in Data/, `patch-{locale}[-?].MPQ` in Data/{locale}/).
41 //
42 // Base order VERIFIED against samwhosung/benilla (a from-scratch 1.12.1 client
43 // in Rust; crates/benilla-formats/src/lib.rs VANILLA_LOAD_ORDER): identical
44 // base list and order — base, dbc, fonts, interface, misc, model, sound,
45 // speech, terrain, texture, wmo — then patch.MPQ, patch-2.MPQ layered on top.
46 // benilla ends the chain at patch-2; our ClassicWildcard glob reproduces that
47 // and additionally picks up patch-3/letter patches (this ruRU repack ships
48 // patch-3.MPQ). benilla itself carries no locale rows (it targets enUS base
49 // content); we keep locale-{loc}/speech-{loc} for real localized installs —
50 // absent members are skipped, so the ruRU repack (localization folded into
51 // base.MPQ + Data/ruRU/patch-N.MPQ) still opens and serves world data from
52 // terrain/model/wmo/patch. (Order among base archives is anyway immaterial:
53 // they partition the namespace and never share a path; every patch wins.)
54 constexpr std::array VanillaBase{
58 ChainEntry{ChainEntryKind::Fixed, "interface.MPQ"},
62 ChainEntry{ChainEntryKind::Fixed, "speech.MPQ"},
63 ChainEntry{ChainEntryKind::Fixed, "terrain.MPQ"},
64 ChainEntry{ChainEntryKind::Fixed, "texture.MPQ"},
66 ChainEntry{ChainEntryKind::LocaleFixed, "locale-{locale}.MPQ"},
67 ChainEntry{ChainEntryKind::LocaleFixed, "speech-{locale}.MPQ"},
68 };
69
70 // The Burning Crusade 2.4.3 (build 8606). Structurally the WotLK chain minus
71 // the WotLK-only base archives: base tier is `common` + `expansion` (no
72 // `common-2`, no `lichking`) plus the four locale archives in Data/{locale}/;
73 // patches load above via the same ClassicWildcard scheme. `expansion` (TBC)
74 // outranks `common` (vanilla) where they overlap — it is later in the table,
75 // and expandChain searches in reverse (see the WotLK note above for the
76 // Ghidra-verified base-priority direction). The loader mechanism is shared
77 // across the 1.x/2.x/3.x clients (verified against Wow.exe 3.3.5a); the base
78 // NAMES here are canonical and present on disk. As on 3.3.5a, `base-{loc}.MPQ`
79 // and `backup-{loc}.MPQ` are on-disk distractors NOT in the binary's table, so
80 // they get no row. This 2.4.3 install ships full enGB and ruRU locale sets;
81 // open with the desired Locale (the "{locale}" rows and locale patches expand
82 // to that code, absent members skipped).
83 constexpr std::array TbcBase{
84 ChainEntry{ChainEntryKind::Fixed, "common.MPQ"},
85 ChainEntry{ChainEntryKind::Fixed, "expansion.MPQ"},
86 ChainEntry{ChainEntryKind::LocaleFixed, "locale-{locale}.MPQ"},
87 ChainEntry{ChainEntryKind::LocaleFixed, "speech-{locale}.MPQ"},
88 ChainEntry{ChainEntryKind::LocaleFixed, "expansion-locale-{locale}.MPQ"},
89 ChainEntry{ChainEntryKind::LocaleFixed, "expansion-speech-{locale}.MPQ"},
90 };
91
92 // Cataclysm 4.3.4 (build 15595). The base tier switched to themed archives
93 // (art/sound/world/world2 + per-expansion), localization stays under
94 // Data/{locale}/. The patch tier is the NEW UpdateChain scheme: the client
95 // no longer wildcard-loads standalone patch archives — it attaches the
96 // `wow-update-base-{build}.MPQ` (Data/, in-archive prefix `base\`) and
97 // `wow-update-{locale}-{build}.MPQ` (Data/{locale}/, prefix `{locale}\`)
98 // archives as INCREMENTAL patches (PTCH deltas + added files) over the base
99 // archives, ascending build. Base order among themed archives is immaterial
100 // (they partition the namespace); absent rows (base-Mac on a Windows
101 // install and vice versa) are skipped. DBFilesClient lives in
102 // locale-{locale}.MPQ, patched by the locale updates — the WDB2 corpus
103 // reads resolve through exactly that pair.
104 constexpr std::array CataBase{
105 ChainEntry{ChainEntryKind::Fixed, "base-Win.MPQ"},
106 ChainEntry{ChainEntryKind::Fixed, "base-Mac.MPQ"},
108 ChainEntry{ChainEntryKind::Fixed, "sound.MPQ"},
109 ChainEntry{ChainEntryKind::Fixed, "world.MPQ"},
110 ChainEntry{ChainEntryKind::Fixed, "world2.MPQ"},
111 ChainEntry{ChainEntryKind::Fixed, "expansion1.MPQ"},
112 ChainEntry{ChainEntryKind::Fixed, "expansion2.MPQ"},
113 ChainEntry{ChainEntryKind::Fixed, "expansion3.MPQ"},
114 ChainEntry{ChainEntryKind::LocaleFixed, "locale-{locale}.MPQ"},
115 ChainEntry{ChainEntryKind::LocaleFixed, "speech-{locale}.MPQ"},
116 ChainEntry{ChainEntryKind::LocaleFixed, "expansion1-locale-{locale}.MPQ"},
117 ChainEntry{ChainEntryKind::LocaleFixed, "expansion1-speech-{locale}.MPQ"},
118 ChainEntry{ChainEntryKind::LocaleFixed, "expansion2-locale-{locale}.MPQ"},
119 ChainEntry{ChainEntryKind::LocaleFixed, "expansion2-speech-{locale}.MPQ"},
120 ChainEntry{ChainEntryKind::LocaleFixed, "expansion3-locale-{locale}.MPQ"},
121 ChainEntry{ChainEntryKind::LocaleFixed, "expansion3-speech-{locale}.MPQ"},
122 };
123
124 // Mists of Pandaria 5.4.8 (build 18414). Structurally the Cata scheme with
125 // MoP's themed base set (interface/itemtexture/misc/model/texture join,
126 // world2/art leave, expansion4 arrives) and the same UpdateChain patch
127 // tier. Verified against the CI fleet's complete 5.4.8 build-18414 install
128 // (chain opens and serves DBFilesClient through the update tier) — rows
129 // for archives a given install lacks are skipped, so a superset table is
130 // safe.
131 constexpr std::array MopBase{
132 ChainEntry{ChainEntryKind::Fixed, "base-Win.MPQ"},
133 ChainEntry{ChainEntryKind::Fixed, "base-Mac.MPQ"},
135 ChainEntry{ChainEntryKind::Fixed, "interface.MPQ"},
136 ChainEntry{ChainEntryKind::Fixed, "itemtexture.MPQ"},
138 ChainEntry{ChainEntryKind::Fixed, "model.MPQ"},
139 ChainEntry{ChainEntryKind::Fixed, "sound.MPQ"},
140 ChainEntry{ChainEntryKind::Fixed, "texture.MPQ"},
141 ChainEntry{ChainEntryKind::Fixed, "world.MPQ"},
142 ChainEntry{ChainEntryKind::Fixed, "world2.MPQ"},
143 ChainEntry{ChainEntryKind::Fixed, "expansion1.MPQ"},
144 ChainEntry{ChainEntryKind::Fixed, "expansion2.MPQ"},
145 ChainEntry{ChainEntryKind::Fixed, "expansion3.MPQ"},
146 ChainEntry{ChainEntryKind::Fixed, "expansion4.MPQ"},
147 ChainEntry{ChainEntryKind::LocaleFixed, "locale-{locale}.MPQ"},
148 ChainEntry{ChainEntryKind::LocaleFixed, "speech-{locale}.MPQ"},
149 ChainEntry{ChainEntryKind::LocaleFixed, "expansion1-locale-{locale}.MPQ"},
150 ChainEntry{ChainEntryKind::LocaleFixed, "expansion1-speech-{locale}.MPQ"},
151 ChainEntry{ChainEntryKind::LocaleFixed, "expansion2-locale-{locale}.MPQ"},
152 ChainEntry{ChainEntryKind::LocaleFixed, "expansion2-speech-{locale}.MPQ"},
153 ChainEntry{ChainEntryKind::LocaleFixed, "expansion3-locale-{locale}.MPQ"},
154 ChainEntry{ChainEntryKind::LocaleFixed, "expansion3-speech-{locale}.MPQ"},
155 ChainEntry{ChainEntryKind::LocaleFixed, "expansion4-locale-{locale}.MPQ"},
156 ChainEntry{ChainEntryKind::LocaleFixed, "expansion4-speech-{locale}.MPQ"},
157 };
158
159 constexpr std::array ChainSpecs{
165 };
166
167 std::string expandLocale(std::string_view pattern, std::string_view code) {
168 std::string out;
169 out.reserve(pattern.size() + code.size());
170 for (std::size_t i = 0; i < pattern.size();) {
171 if (pattern.compare(i, 8, "{locale}") == 0) {
172 out += code;
173 i += 8;
174 }
175 else out += pattern[i++];
176 }
177 return out;
178 }
179
180 constexpr char asciiLower(char c) {
181 return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
182 }
183
184 bool ciEqual(std::string_view a, std::string_view b) {
185 return a.size() == b.size() && std::ranges::equal(a, b, [](char x, char y) {
186 return asciiLower(x) == asciiLower(y);
187 });
188 }
189
190 // The client's patch comparator is `-__strnicmp` — case-insensitive
191 // lexicographic order over the extension-stripped filename. `patch` precedes
192 // `patch-2` (shorter prefix first); base and locale patches interleave by this
193 // same key, since the locale code is part of the locale patch's name.
194 bool ciLess(std::string_view a, std::string_view b) {
195 return std::ranges::lexicographical_compare(a, b, [](char x, char y) { return asciiLower(x) < asciiLower(y); });
196 }
197
198 // The name without a trailing ".MPQ" (any case), or nullopt when it is not an
199 // MPQ name — non-archive directory entries are not chain members.
200 std::optional<std::string_view> mpqCore(std::string_view name) {
201 if (name.size() < 4 || !ciEqual(name.substr(name.size() - 4), ".MPQ")) return std::nullopt;
202 return name.substr(0, name.size() - 4);
203 }
204
205 // Whether `core` (an extension-stripped name) names a patch for `stem`: either
206 // the bare stem ("patch") or the single-char wildcard ("patch-x"). The client
207 // matches case-insensitively.
208 bool isPatchCore(std::string_view core, std::string_view stem) {
209 if (ciEqual(core, stem)) return true;
210 return core.size() == stem.size() + 2 && core[stem.size()] == '-' && ciEqual(core.substr(0, stem.size()), stem);
211 }
212
213 // A fixed base member: prefer a real archive file, fall back to a same-named
214 // directory of loose files. absent -> skipped. (A single Data root cannot
215 // hold a file and a directory of one name, so the preference is only ever
216 // exercised as the file/dir discriminator.)
217 void pushBase(std::vector<ChainMember>& out, fsys::path candidate) {
218 std::error_code ec;
219 if (fsys::is_regular_file(candidate, ec)) out.push_back({std::move(candidate), false});
220 else if (fsys::is_directory(candidate, ec)) out.push_back({std::move(candidate), true});
221 }
222
223 // The build number of a `wow-update-*` archive name (extension-stripped
224 // core): the digits after the last '-'. `wow-update-15595`,
225 // `wow-update-base-15595` and `wow-update-ruRU-15595` all yield 15595;
226 // names whose tail is not purely numeric yield nullopt (not an update).
227 std::optional<std::uint32_t> updateBuild(std::string_view core) {
228 constexpr std::string_view stem = "wow-update-";
229 if (core.size() <= stem.size() || !ciEqual(core.substr(0, stem.size()), stem)) return std::nullopt;
230 const auto dash = core.rfind('-');
231 const std::string_view tail = core.substr(dash + 1);
232 if (tail.empty()) return std::nullopt;
233 std::uint32_t build = 0;
234 for (const char c : tail) {
235 if (c < '0' || c > '9') return std::nullopt;
236 build = build * 10 + static_cast<std::uint32_t>(c - '0');
237 }
238 return build;
239 }
240
241 // Collect the `wow-update-*` incremental patches of `dir` into `found`,
242 // keyed by build number for the ascending sort. `prefix` is the in-archive
243 // path prefix the storage passes to StormLib when attaching ("base" for
244 // Data/ updates, the locale code for Data/{locale}/ ones).
245 void collectUpdates(std::vector<std::pair<std::uint32_t, ChainMember>>& found,
246 const fsys::path& dir,
247 std::string_view prefix) {
248 std::error_code ec;
249 for (const auto& entry : fsys::directory_iterator{dir, ec}) {
250 const std::string name = entry.path().filename().string();
251 const auto core = mpqCore(name);
252 if (!core) continue;
253 const auto build = updateBuild(*core);
254 if (!build || !entry.is_regular_file(ec)) continue;
255 found.emplace_back(
256 *build, ChainMember{.path = entry.path(), .incremental = true, .prefix = std::string{prefix}});
257 }
258 }
259
260 // Collect the patches of `dir` matching `stem` into `found`, keyed by the
261 // extension-stripped filename (the client's sort key). The client globs the
262 // single-char wildcard (`patch-?` / `patch-{loc}-?`) plus the bare stem,
263 // case-insensitively; a match may be a real archive or a same-named loose
264 // directory. Base and locale roots are collected into one `found` and sorted
265 // together by the caller, so the two groups interleave exactly as the
266 // client's single sorted patch list does.
267 void collectPatches(std::vector<std::pair<std::string, ChainMember>>& found,
268 const fsys::path& dir,
269 std::string_view stem) {
270 std::error_code ec;
271 for (const auto& entry : fsys::directory_iterator{dir, ec}) {
272 const std::string name = entry.path().filename().string();
273 const auto core = mpqCore(name);
274 if (!core || !isPatchCore(*core, stem)) continue;
275 const bool isDir = entry.is_directory(ec);
276 if (!isDir && !entry.is_regular_file(ec)) continue;
277 found.emplace_back(std::string{*core}, ChainMember{entry.path(), isDir});
278 }
279 }
280 }
281
282 const MpqChainSpec* findChainSpec(const ClientVersion& version) {
283 for (const auto& spec : ChainSpecs)
284 if (spec.version.build == version.build) return &spec;
285 for (const auto& spec : ChainSpecs)
286 if (spec.version.major == version.major && spec.version.minor == version.minor && spec.version.patch == version.
287 patch) return &spec;
288 return nullptr;
289 }
290
292 expandChain(const MpqChainSpec& spec, const std::filesystem::path& dataDir, Locale locale) {
293 const std::string code{localeCode(locale)};
294 const fsys::path localeDir = dataDir / code;
295
296 std::vector<ChainMember> out;
297
298 // Base tier, in table order (lowest priority).
299 for (const ChainEntry& entry : spec.baseEntries) {
300 switch (entry.kind) {
302 pushBase(out, dataDir / entry.pattern);
303 break;
305 pushBase(out, localeDir / expandLocale(entry.pattern, code));
306 break;
307 }
308 }
309
310 // Patch tier: base (Data/) and locale (Data/{locale}/) patches form ONE list
311 // sorted by the client's case-insensitive, extension-stripped filename order.
312 // `patch` sorts before `patch-2` (shorter prefix); base and locale patches
313 // interleave, so a high base letter-patch (`patch-F`..`patch-Z` for enUS)
314 // outranks the locale patches.
316 std::vector<std::pair<std::string, ChainMember>> patches;
317 collectPatches(patches, dataDir, "patch");
318 collectPatches(patches, localeDir, std::format("patch-{}", code));
319 std::ranges::stable_sort(patches, ciLess, &std::pair<std::string, ChainMember>::first);
320 for (auto& [key, member] : patches) out.push_back(std::move(member));
321 }
322
323 // Update tier (Cata/MoP): the wow-update archives of Data/ and
324 // Data/{locale}/, ascending by build (the client applies older deltas
325 // first). The stable sort keeps base updates ahead of same-build locale
326 // updates — immaterial for attachment (they patch disjoint archives) but
327 // deterministic.
329 std::vector<std::pair<std::uint32_t, ChainMember>> updates;
330 collectUpdates(updates, dataDir, "base");
331 collectUpdates(updates, localeDir, code);
332 std::ranges::stable_sort(updates, {}, &std::pair<std::uint32_t, ChainMember>::first);
333 for (auto& [build, member] : updates) out.push_back(std::move(member));
334 }
335
336 return out;
337 }
338}
Per-version MPQ archive chain tables and their expansion against a real or fake Data/ directory.
@ Fixed
One archive in Data/, e.g.
Definition mpq_chain.hpp:21
@ LocaleFixed
One archive in Data/{locale}/, "{locale}" expanded.
Definition mpq_chain.hpp:22
const MpqChainSpec * findChainSpec(const ClientVersion &version)
The chain spec for version: exact build match first, then major.minor.patch.
@ UpdateChain
cata/MoP incremental updates: wow-update-{build}.MPQ / wow-update-base-{build}.MPQ in Data/ and wow-u...
Definition mpq_chain.hpp:38
@ ClassicWildcard
Pre-Cata wildcard patch tier.
Definition mpq_chain.hpp:34
Result< std::vector< ChainMember > > expandChain(const MpqChainSpec &spec, const std::filesystem::path &dataDir, Locale locale)
Expand a chain spec against a Data directory into concrete members in load order (lowest -> highest p...
constexpr ClientVersion Mop
constexpr ClientVersion Tbc
constexpr ClientVersion Vanilla
constexpr ClientVersion Cata
constexpr ClientVersion Wotlk
std::expected< T, Error > Result
Every fallible wowlib operation returns Result<T>; bindings translate the error branch into a target-...
Definition error.hpp:100
std::string_view localeCode(Locale locale)
The four-letter code of locale ("enUS", ...) as used in MPQ locale directory and archive names.
std::uint32_t build
Exact client build number, e.g.
std::uint16_t major
Expansion number, e.g.
std::uint16_t minor
Minor version within the expansion.
One resolved member of a chain, in load order.
Definition mpq_chain.hpp:61
A client version's archive chain: the fixed base tier plus how its patch tier is found.
Definition mpq_chain.hpp:50
std::span< const ChainEntry > baseEntries
Definition mpq_chain.hpp:52