wowlib 0.0.0
Read & write World of Warcraft client files — a C++26 core
Loading...
Searching...
No Matches
mpq_storage.cpp
Go to the documentation of this file.
2
3#include <fstream>
4#include <format>
5#include <ranges>
6#include <set>
7#include <tuple>
8
10
11#define STORMLIB_NO_AUTO_LINK
12#include <StormLib.h>
13
14namespace {
15 namespace fsys = std::filesystem;
16
17 // Read a whole loose file into a buffer. Loose members carry no StormLib state,
18 // so no lock is needed.
19 wowlib::Result<wowlib::FileBuffer> readLooseFile(const fsys::path& path) {
20 std::ifstream in{path, std::ios::binary | std::ios::ate};
21 if (!in)
23 std::format("failed to open loose file '{}'", path.string()));
24 const std::streamoff size = in.tellg();
25 if (size < 0)
27 std::format("failed to size loose file '{}'", path.string()));
28 wowlib::FileBuffer buffer(static_cast<std::size_t>(size));
29 in.seekg(0);
30 if (!buffer.empty() && !in.read(reinterpret_cast<char*>(buffer.data()), size))
32 std::format("failed to read loose file '{}'", path.string()));
33 return buffer;
34 }
35
36 // The names StormLib synthesizes rather than stores: the archive metadata
37 // pseudo-files, and the "File########.ext" placeholders it invents for
38 // hash-table entries whose real name no listfile covers (reads by such a
39 // name would miss, so listing them would only manufacture failures).
40 bool isSyntheticName(std::string_view canonical) {
41 for (const std::string_view metadata : {"(listfile)", "(attributes)", "(signature)", "(patch_metadata)"})
42 if (canonical == metadata) return true;
43
44 if (canonical.size() < 13 || !canonical.starts_with("file") || canonical[12] != '.') return false;
45 for (std::size_t i = 4; i < 12; ++i)
46 if (canonical[i] < '0' || canonical[i] > '9') return false;
47 return true;
48 }
49}
50
51namespace wowlib::fs {
53 MpqStorage storage{std::move(options)};
54 if (auto opened = storage._openChain(); !opened) return std::unexpected(opened.error());
55 return storage;
56 }
57
58 Result<void> MpqStorage::_openChain() {
60 if (!spec)
61 return makeError(ErrorCode::StorageOpenFailed, std::format("no MPQ chain table for client {}.{}.{} (build {})",
62 _options.version.major, _options.version.minor,
63 _options.version.patch, _options.version.build));
64
65 // The caller supplies the locale (via FileSystemSettings); we only verify its
66 // Data/{code}/ directory is actually present rather than scanning for one.
67 // Pre-TBC clients are exempt: stock vanilla installs are flat (locale
68 // subdirectories entered the layout with TBC), and only some later repacks
69 // retrofit a Data/{code}/ tier — expandChain mounts it when it exists.
70 const std::string code{localeCode(_options.locale)};
71 std::error_code ec;
72 if (_options.version.major >= 2 && !std::filesystem::is_directory(_options.dataDir / code, ec))
74 std::format("locale directory '{}' not found under '{}'", code, _options.dataDir.string()));
75
76 auto chain = detail::expandChain(*spec, _options.dataDir, _options.locale);
77 if (!chain) return std::unexpected(chain.error());
78 if (chain->empty())
79 return makeError(ErrorCode::StorageOpenFailed, std::format("no archives of the {}.{}.{} chain exist under '{}'",
80 _options.version.major, _options.version.minor,
81 _options.version.patch, _options.dataDir.string()));
82
83 for (const detail::ChainMember& member : *chain) {
84 if (member.incremental) {
85 // A wow-update archive holds PTCH deltas and added files; it attaches
86 // to every base archive of its own Data directory (updates come after
87 // all base members in the chain, so those are open by now). StormLib
88 // then serves the patched content transparently through the base
89 // handles.
90 //
91 // The prefix is passed as NULL on purpose — that is what makes
92 // patching WORK. StormLib's FindPatchPrefix treats a non-null prefix
93 // as an override and prepends it to every lookup in the patch
94 // archive; retail WoW updates store their entries under BARE paths
95 // ("dbfilesclient\achievement.dbc"), so an explicit "base"/"enUS"
96 // makes every lookup miss and the base file is served unpatched, with
97 // no error anywhere. NULL selects StormLib's own detection, which
98 // recognizes the prefixed Cata-beta archives by their
99 // "base\‍(patch_metadata)" marker and assumes no prefix otherwise.
100 // (Found 2026-08-09: the 5.4.8 client appeared to ship 5.0.x
101 // databases because 18 locale updates' DBC deltas were all ignored.)
102 // StormLib is built ANSI on every platform (TCHAR == char), so paths
103 // cross its API boundary as narrow strings — same as the CascLib side.
104 const std::string patchPath = member.path.string();
105 for (OpenedArchive& archive : _archives) {
106 if (archive.isDirectory || archive.path.parent_path() != member.path.parent_path()) continue;
107 if (!SFileOpenPatchArchive(archive.handle, patchPath.c_str(), nullptr, 0)) {
108 const auto native = SErrGetLastError();
109 _close();
111 std::format("SFileOpenPatchArchive failed attaching '{}' to '{}'", member.path.string(),
112 archive.path.string()), static_cast<std::uint32_t>(native));
113 }
114 archive.patched = true;
115 }
116 continue;
117 }
118
119 if (member.isDirectory) {
120 // Loose directory: index every file by its canonical in-game path so
121 // reads match the client's case-insensitive lookup (both sides are
122 // lowercased/backslashed by normalizePath).
123 OpenedArchive slot{member.path, true};
124 std::error_code walkEc;
125 for (const auto& entry : std::filesystem::recursive_directory_iterator{member.path, walkEc}) {
126 if (!entry.is_regular_file(walkEc)) continue;
127 const auto relative = std::filesystem::relative(entry.path(), member.path, walkEc);
128 if (walkEc) continue;
129 slot.loose.emplace(normalizePath(relative.generic_string()), entry.path());
130 }
131 _archives.push_back(std::move(slot));
132 continue;
133 }
134
135 // NO_LISTFILE/NO_ATTRIBUTES: exact-path reads resolve through the hash
136 // table alone, and parsing those internal files costs seconds per large
137 // archive (7s for 3.3.5a common.MPQ vs 10ms without). Features that need
138 // enumeration must load the listfile on demand instead.
139 constexpr DWORD openFlags = MPQ_OPEN_READ_ONLY | MPQ_OPEN_NO_LISTFILE | MPQ_OPEN_NO_ATTRIBUTES |
140 MPQ_OPEN_NO_HEADER_SEARCH;
141
142 HANDLE handle = nullptr;
143 const std::string archivePath = member.path.string();
144 if (!SFileOpenArchive(archivePath.c_str(), 0, openFlags, &handle)) {
145 const auto native = SErrGetLastError();
146 _close();
148 std::format("SFileOpenArchive failed for '{}'", member.path.string()),
149 static_cast<std::uint32_t>(native));
150 }
151 _archives.push_back(OpenedArchive{.path = member.path, .handle = handle, .mtx = std::make_unique<std::mutex>()});
152 }
153 return {};
154 }
155
156 void MpqStorage::_close() noexcept {
157 for (auto& archive : _archives)
158 if (archive.handle) SFileCloseArchive(archive.handle);
159 _archives.clear();
160 }
161
163 if (!_isOpen()) return makeError(ErrorCode::StorageNotOpen, "MPQ storage is not open");
164 if (!key.path)
166 "MPQ storage is path-addressed; resolve the FileDataID to a " "path through a listfile first");
167
168 // Canonical form already uses backslashes — StormLib's separator.
169 const std::string& name = *key.path;
170
171 // Reverse load order: the last member that carries the file wins.
172 for (const OpenedArchive& archive : _archives | std::views::reverse) {
173 if (archive.isDirectory) {
174 const auto it = archive.loose.find(name);
175 if (it == archive.loose.end()) continue;
176 return readLooseFile(it->second);
177 }
178
179 std::scoped_lock lock{*archive.mtx};
180
181 // The has-file probe checks the archive's own hash table only — it
182 // cannot see files ADDED by attached wow-update patches, so patched
183 // archives go straight to the open call and treat not-found as a miss.
184 if (!archive.patched && !SFileHasFile(archive.handle, name.c_str())) continue;
185
186 HANDLE file = nullptr;
187 if (!SFileOpenFileEx(archive.handle, name.c_str(), SFILE_OPEN_FROM_MPQ, &file)) {
188 const auto native = SErrGetLastError();
189 if (archive.patched && native == ERROR_FILE_NOT_FOUND) continue;
191 std::format("SFileOpenFileEx failed for '{}' in '{}'", name, archive.path.string()),
192 static_cast<std::uint32_t>(native));
193 }
194
195 DWORD sizeHigh = 0;
196 const DWORD sizeLow = SFileGetFileSize(file, &sizeHigh);
197 if (sizeLow == SFILE_INVALID_SIZE) {
198 const auto native = SErrGetLastError();
199 SFileCloseFile(file);
200 return makeError(ErrorCode::BackendError, std::format("SFileGetFileSize failed for '{}'", name),
201 static_cast<std::uint32_t>(native));
202 }
203
204 FileBuffer buffer((static_cast<std::uint64_t>(sizeHigh) << 32) | sizeLow);
205 DWORD read = 0;
206 if (!buffer.empty() && !SFileReadFile(file, buffer.data(), static_cast<DWORD>(buffer.size()), &read, nullptr)) {
207 const auto native = SErrGetLastError();
208 SFileCloseFile(file);
209 return makeError(ErrorCode::BackendError, std::format("SFileReadFile failed for '{}'", name),
210 static_cast<std::uint32_t>(native));
211 }
212
213 SFileCloseFile(file);
214 return buffer;
215 }
216
217 return makeError(ErrorCode::FileNotFound, std::format("'{}' was not found in the MPQ chain", name));
218 }
219
220 bool MpqStorage::exists(const FileKey& key) {
221 if (!_isOpen() || !key.path) return false;
222
223 for (const OpenedArchive& archive : _archives | std::views::reverse) {
224 if (archive.isDirectory) {
225 if (archive.loose.contains(*key.path)) return true;
226 continue;
227 }
228
229 std::scoped_lock lock{*archive.mtx};
230 if (archive.patched) {
231 // Probe through the open call so patch-added files count (see
232 // readFile for why the has-file check is blind to them).
233 HANDLE file = nullptr;
234 if (SFileOpenFileEx(archive.handle, key.path->c_str(), SFILE_OPEN_FROM_MPQ, &file)) {
235 SFileCloseFile(file);
236 return true;
237 }
238 continue;
239 }
240 if (SFileHasFile(archive.handle, key.path->c_str())) return true;
241 }
242 return false;
243 }
244
246 if (!_isOpen()) return makeError(ErrorCode::StorageNotOpen, "MPQ storage is not open");
247
248 // A std::set both deduplicates across the chain and hands the paths back
249 // sorted, matching the contract in one structure.
250 std::set<std::string> paths;
251
252 for (const OpenedArchive& archive : _archives) {
253 if (archive.isDirectory) {
254 // Loose members are indexed by canonical path already.
255 for (const auto& name : archive.loose | std::views::keys) paths.insert(name);
256 continue;
257 }
258
259 std::scoped_lock lock{*archive.mtx};
260
261 // Archives open with MPQ_OPEN_NO_LISTFILE (see _openChain), so the name
262 // source must be loaded on demand; a nullptr list file means "the
263 // archive's own internal listfile" (StormLib walks the patch chain too).
264 // Failure is fine — the find below then yields only placeholder names,
265 // which are filtered out, effectively skipping the archive.
266 std::ignore = SFileAddListFile(archive.handle, nullptr);
267
268 SFILE_FIND_DATA found{};
269 HANDLE find = SFileFindFirstFile(archive.handle, "*", &found, nullptr);
270 if (!find) continue; // nothing enumerable in this archive — skip it silently
271 do {
272 std::string canonical = normalizePath(found.cFileName);
273 if (!isSyntheticName(canonical)) paths.insert(std::move(canonical));
274 }
275 while (SFileFindNextFile(find, &found));
276 SFileFindClose(find);
277 }
278
279 return std::vector<std::string>{std::make_move_iterator(paths.begin()), std::make_move_iterator(paths.end())};
280 }
281}
bool exists(const FileKey &key)
Whether the file exists in any archive of the chain.
Result< std::vector< std::string > > enumeratePaths()
Enumerate every file path reachable through the chain: the members of each archive (named by its inte...
static Result< MpqStorage > open(Options options)
Expand the version's chain and open every archive present on disk.
Result< FileBuffer > readFile(const FileKey &key)
Read a file into memory.
MpqStorage(const MpqStorage &)=delete
The StormLib-backed storage for MPQ-era clients.
const MpqChainSpec * findChainSpec(const ClientVersion &version)
The chain spec for version: exact build match first, then major.minor.patch.
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...
std::string normalizePath(std::string_view path)
Canonicalize a client-internal file path.
Definition path.cpp:4
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::unexpected< Error > makeError(ErrorCode code, std::string message, std::uint32_t nativeError=0)
Shorthand for constructing the error branch of a Result.
Definition error.hpp:107
@ FdidNotResolvable
No path is known for the given FileDataID.
Definition error.hpp:24
@ FileNotFound
The file exists nowhere in the overlay or storage.
Definition error.hpp:22
@ StorageOpenFailed
The client storage (MPQ chain / CASC) failed to initialize.
Definition error.hpp:19
@ BackendError
Unclassified StormLib/CascLib failure; see nativeError.
Definition error.hpp:34
@ StorageNotOpen
Operation on a closed or moved-from storage.
Definition error.hpp:21
@ ArchiveOpenFailed
A single archive within an MPQ chain failed to open.
Definition error.hpp:20
std::vector< std::byte > FileBuffer
Owning byte buffer for file contents read out of a client storage.
Definition buffer.hpp:16
Client-internal path canonicalization.
std::uint16_t patch
Patch version within the minor release.
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.
std::optional< std::string > path
The canonical client-internal path, if known.
Definition file_key.hpp:31
One opened member of the chain, for introspection and tests.
Locale locale
The locale to open; its Data/{code}/ directory must exist on disk.
std::filesystem::path dataDir
The client's Data/ directory.
ClientVersion version
Selects the chain table.
A client version's archive chain: the fixed base tier plus how its patch tier is found.
Definition mpq_chain.hpp:50