wowlib 0.0.0
Read & write World of Warcraft client files — a C++26 core
Loading...
Searching...
No Matches
wmo.hpp
Go to the documentation of this file.
1#pragma once
2
9
10#include <array>
11#include <format>
12#include <span>
13#include <string>
14#include <string_view>
15#include <vector>
16
18#include <wowlib/core/error.hpp>
21#include <wowlib/core/lang.hpp>
25
26
27namespace wowlib::formats::wmo {
28 using root::WMORoot;
29 using group::WMOGroup;
30
42 struct [[
43 =welder::weld,
44 =welder::weld_as("WMO"),
46 =welder::doc(R"(
47 A whole world map object, abstract over the client version — the root
48 file and all its group files as one entity. Construct the concrete
49 version with WMO.for_version(expansion), then read()/write(); the
50 per-version WMO* classes are subclasses. See https://wowdev.wiki/WMO.)")
51 ]] WMOBase : FileEntityBase {};
52
53 namespace detail {
66 @tparam V the client version this assembly targets.
67 @see https://wowdev.wiki/WMO */
68 template <ClientVersion V>
69 struct [[
70 =welder::weld,
71 =welder::doc(R"(
72 A whole world map object for one client version: the root file and all
73 its group files as one entity. Group files are located by GFID (Legion+
74 clients) or the "{root}_NNN.wmo" naming convention. An entity read from
75 a client and left unmodified rewrites byte-for-byte. See
76 https://wowdev.wiki/WMO.)")
77 ]] WMO : WMOBase {
78 static constexpr ClientVersion Version = V;
79
80 [[=welder::doc("The root file contents.")]]
82
83 [[=welder::doc("The group files, in group order."), =
84 welder::mark::no_reassign]]
85 std::vector<WMOGroup<V>> groups;
86
87 // read()/write() weld the (FileSystem, FileKey) load/save on LUA AND C#
88 // ONLY — that is the whole WMO scripting surface in both. On Python the
89 // module glue attaches the read()/write()/convert()/for_version() surface to
90 // WMOBase instead (dispatching to the concrete version), so the per-version
91 // Python classes stay pure data and `w: WMO` speaks the ops; Lua and C# have
92 // no such glue, so they take these methods directly. The span-of-spans parse
93 // below stays C++/glue-only.
94
95 [[=welder::mark::only(welder::lang::lua, wowlib::lang::Cs),
96 =welder::doc("Load the WMO and all its group files from a client "
97 "filesystem, replacing this entity's contents.")]]
98 Result<void> read(fs::FileSystem& fs [[=welder::doc("the filesystem gateway")]],
99 const FileKey& key [[=welder::doc("the root file identity (path and/or FileDataID)")]]);
100
108 [[=welder::mark::exclude]]
109 Result<void> read(std::span<const std::byte> rootData, std::span<const std::span<const std::byte>> groupDatas);
110
111 [[=welder::mark::only(welder::lang::lua, wowlib::lang::Cs),
112 =welder::doc(
113 "Serialize and store the WMO (root and every group) through "
114 "the filesystem's project overlay; group file names are "
115 "derived from the root key, which must resolve to a path.")]]
116 Result<void> write(fs::FileSystem& fs [[=welder::doc("the filesystem gateway")]],
117 const FileKey& key [[=welder::doc("the root file identity; must resolve to a path")]]) const;
118
119 // Beyond the root's and each group's own contracts, this sees the ones
120 // only the assembly can: MOGI describing exactly the groups held, the
121 // groups' indexesInRoot references, the header portal ranges into MOPR,
122 // and every rendered face resolving its material in MOMT.
123 [[nodiscard]]
124 [[=welder::doc(R"(
125 Check the logical integrity contracts this object must satisfy to LOAD
126 in the client — across the root file AND every group file — which
127 write() deliberately never enforces. Call it before writing when you
128 want to know the files will load. An object read from a client and left
129 unmodified reports no errors; warnings mark states real client files
130 ship.)"),
131 =welder::returns(R"(every violated contract, each with its member path
132 ("root..." / "groups[i]..."))")]]
134
135 [[nodiscard]]
136 [[=welder::doc(
137 "Validate and raise on the first error instead of returning "
138 "a report — the assert-style face of validate()."),
139 =welder::returns("nothing; raises when validate() finds any error")]]
140 Result<void> ensureValid() const;
141
142 private:
143 // --- internal fs-I/O helpers (definitions at the bottom of this header;
144 // --- private so the Python/Lua surface and the C++ API stay verbs-only) -
145
151 static std::string _groupPath(std::string_view rootPath, std::size_t index);
152
156 "group 3", ...).
157 @return nothing, or FormatVersionMismatch. */
158 static Result<void>
159 _checkMver(std::uint32_t mver, std::string_view which);
160 };
161 }
162
166 template <ClientVersion V>
168}
169
170// --- fs-level read/write definitions -----------------------------------------
171// Inline in this header (not a separate io.hpp): the entities are templates,
172// so the definitions must be visible for implicit instantiation anyway — the
173// library ships NO explicit instantiations; every consumer TU instantiates
174// exactly the versions it uses (the bindings expand the full matrix in their
175// own translation units, see bindings/instantiations/).
176namespace wowlib::formats::wmo {
177 template <ClientVersion V>
178 std::string detail::WMO<V>::_groupPath(std::string_view rootPath, std::size_t index) {
179 std::string_view stem = rootPath;
180 if (stem.ends_with(".wmo")) stem.remove_suffix(4);
181 return std::format("{}_{:03}.wmo", stem, index);
182 }
183
184 template <ClientVersion V>
185 ValidationReport detail::WMO<V>::validate() const {
186 ValidationReport report;
187 {
188 const std::size_t mark = report.size();
190 report.prefixFrom(mark, "root");
191 }
192 for (std::size_t i = 0; i < groups.size(); ++i) {
193 const std::size_t mark = report.size();
194 formats::detail::validateEntity(groups[i], report);
195 report.prefixFrom(mark, std::format("groups[{}]", i));
196 }
197
198 // the MOGI table must describe exactly the groups the assembly holds
199 if (root.groupInfos.size() != groups.size())
200 report.addError("root.groupInfos",
201 std::format("count {} != {} group files held", root.groupInfos.size(), groups.size()));
202
203 // Legion+: GFID locates the group files - too few cannot load; LOD WMOs
204 // repeat the table per level, so any whole multiple is fine
205 if constexpr (requires { root.groupFdids; })
206 if (!groups.empty() && !root.groupFdids.empty()) {
207 if (root.groupFdids.size() < groups.size())
208 report.addError("root.group_fdids",
209 std::format("count {} < {} group files held", root.groupFdids.size(), groups.size()));
210 else if (root.groupFdids.size() % groups.size() != 0)
211 report.addWarning("root.group_fdids",
212 std::format(
213 "count {} is not a whole multiple of the {} groups " "(LOD tables repeat per level)",
214 root.groupFdids.size(), groups.size()));
215 }
216
217 constexpr std::size_t maxReported = 8;
218 const std::size_t materialCount = root.materials.size();
219 for (std::size_t i = 0; i < groups.size(); ++i) {
220 const auto& body = groups[i].body;
221 const std::string prefix = std::format("groups[{}].body", i);
222
223 // the groups' declarative references into the root's arrays
224 static constexpr auto BodyMembers = formats::detail::membersOf<std::remove_cvref_t<decltype(body)>>();
225 template for (constexpr auto m : BodyMembers) {
226 if constexpr (constexpr auto ir = formats::detail::annotation<formats::detail::IndexesInRootSpec, m>(); ir.
227 has_value()) {
228 constexpr auto target = formats::detail::memberNamed<WMORoot<V>>(ir->view());
229 static_assert(target != std::meta::info{}, "indexes_in_root names no member of the root entity");
230 constexpr const char* ident = std::define_static_string(std::meta::identifier_of(m));
231 formats::detail::validateIndexElements(body.[:m:], root.[:target:].size(),
232 std::format("{}.{}", prefix, ident), ir->view(), report);
233 }
234 }
235
236 // the header's portal slice references MOPR
237 if (body.header.portalCount > 0 && body.header.portalStart + body.header.portalCount > root.portalRefs.size())
238 report.addError(std::format("{}.header", prefix),
239 std::format("portal range [{}, {}) overruns the {} portal references",
240 body.header.portalStart, body.header.portalStart + body.header.portalCount,
241 root.portalRefs.size()));
242
243 // every rendered face and batch must resolve its material in MOMT
244 // (0xFF marks a collision-only face with no material)
245 std::size_t badPolys = 0;
246 for (std::size_t j = 0; j < body.polys.size(); ++j)
247 if (body.polys[j].materialId != 0xFF && body.polys[j].materialId >= materialCount)
248 if (++badPolys <= maxReported)
249 report.addError(std::format("{}.polys[{}]", prefix, j),
250 std::format("material {} out of range: {} materials", body.polys[j].materialId,
251 materialCount));
252 if (badPolys > maxReported)
253 report.addError(std::format("{}.polys", prefix),
254 std::format("... and {} more unresolvable materials", badPolys - maxReported));
255 for (std::size_t j = 0; j < body.batches.size(); ++j) {
256 const auto& batch = body.batches[j];
257 std::size_t material = batch.materialId;
258 if constexpr (requires { batch.materialIdLarge; })
259 if ((batch.flags & 0x2) != 0) material = batch.materialIdLarge;
260 if (material >= materialCount)
261 report.addError(std::format("{}.batches[{}]", prefix, j),
262 std::format("material {} out of range: {} materials", material, materialCount));
263 }
264
265 // note: MOGI flags are deliberately NOT compared against the group
266 // header's - real files differ on runtime-managed bits in nearly every
267 // group (corpus: hundreds of divergences per client), so a mirror check
268 // is pure noise
269 }
270 return report;
271 }
272
273 template <ClientVersion V>
275 return validate().toResult();
276 }
277
278 template <ClientVersion V>
279 Result<void> detail::WMO<V>::_checkMver(std::uint32_t mver, std::string_view which) {
280 if (mver != WmoVersionV17)
282 std::format("{} MVER is {}, expected {}", which, mver, WmoVersionV17));
283 return {};
284 }
285
286 template <ClientVersion V>
287 Result<void> detail::WMO<V>::read(std::span<const std::byte> rootData,
288 std::span<const std::span<const std::byte>> groupDatas) {
289 root = {};
290 groups.clear();
291
292 if (auto r = root.read(rootData); !r) return std::unexpected{r.error()};
293 if (auto r = _checkMver(root.mver, "root"); !r) return std::unexpected{r.error()};
294
295 groups.reserve(groupDatas.size());
296 for (std::size_t i = 0; i < groupDatas.size(); ++i) {
297 WMOGroup < V > group;
298 if (auto r = group.read(groupDatas[i]); !r)
299 return makeError(r.error().code, std::format("group {}: {}", i, r.error().message), r.error().nativeError);
300 if (auto r = _checkMver(group.mver, std::format("group {}", i)); !r) return std::unexpected{r.error()};
301 groups.push_back(std::move(group));
302 }
303 return {};
304 }
305
306 template <ClientVersion V>
307 Result<void> detail::WMO<V>::read(fs::FileSystem& fs, const FileKey& key) {
308 const auto rootData = fs.readFile(key);
309 if (!rootData) return std::unexpected{rootData.error()};
310
311 root = {};
312 groups.clear();
313
314 if (auto r = root.read(*rootData); !r) return std::unexpected{r.error()};
315 if (auto r = _checkMver(root.mver, "root"); !r) return std::unexpected{r.error()};
316
317 // MOGI (one info record per group file) is the group count's source of
318 // truth — header.nGroups is a derived binary field stamped from it.
319 const std::size_t nGroups = root.groupInfos.size();
320 // GFID (group FileDataIDs) is Legion+; pre-Legion roots have no such member
321 // (it lives in a version trait that version does not inherit), so they always
322 // locate groups by the "{root}_NNN.wmo" path convention.
323 bool byFdid = false;
324 if constexpr (requires { root.groupFdids; }) byFdid = root.groupFdids.size() >= nGroups;
325
326 std::string rootPath;
327 if (!byFdid) {
328 const FileKey resolved = fs.resolve(key);
329 if (!resolved.path)
331 "group files need the root path (no GFID chunk and the root " "key has no resolvable path)");
332 rootPath = *resolved.path;
333 }
334
335 groups.reserve(nGroups);
336 for (std::size_t i = 0; i < nGroups; ++i) {
337 const FileKey groupKey = [&]() -> FileKey {
338 if constexpr (requires { root.groupFdids; })
339 if (byFdid) return FileKey{FileDataID{root.groupFdids[i]}};
340 return FileKey{_groupPath(rootPath, i)};
341 }();
342 const auto groupData = fs.readFile(groupKey);
343 if (!groupData)
344 return makeError(groupData.error().code, std::format("group {}: {}", i, groupData.error().message),
345 groupData.error().nativeError);
346
348 if (auto r = group.read(*groupData); !r)
349 return makeError(r.error().code, std::format("group {}: {}", i, r.error().message), r.error().nativeError);
350 if (auto r = _checkMver(group.mver, std::format("group {}", i)); !r) return std::unexpected{r.error()};
351 groups.push_back(std::move(group));
352 }
353 return {};
354 }
355
356 template <ClientVersion V>
357 Result<void> detail::WMO<V>::write(fs::FileSystem& fs, const FileKey& key) const {
358 const FileKey resolved = fs.resolve(key);
359 if (!resolved.path)
360 return makeError(ErrorCode::PathNotResolvable, "saving a WMO needs a path for the root key");
361 // the derived nGroups is stamped from MOGI, so the two group tables the
362 // entity does keep (info records and group files) must agree
363 if (root.groupInfos.size() != groups.size())
365 std::format(
366 "the MOGI group-info table holds {} records but {} group "
367 "files are baked in — every group needs its info record", root.groupInfos.size(),
368 groups.size()));
369
370 const auto rootData = root.write();
371 if (!rootData) return std::unexpected{rootData.error()};
372 if (auto r = fs.addFile(*resolved.path, *rootData); !r) return std::unexpected{r.error()};
373
374 for (std::size_t i = 0; i < groups.size(); ++i) {
375 const auto groupData = groups[i].write();
376 if (!groupData) return std::unexpected{groupData.error()};
377 if (auto r = fs.addFile(_groupPath(*resolved.path, i), *groupData); !r)
378 return makeError(r.error().code, std::format("group {}: {}", i, r.error().message), r.error().nativeError);
379 }
380 return {};
381 }
382}
383
384// There are NO welded per-range alias tables, extern-template declarations or
385// explicit instantiations here: C++ consumer TUs implicitly instantiate
386// exactly the versions they use (the read/write definitions live in this
387// header and the chunk serializer). The language bindings, which weld and expand
388// the FULL version matrix, declare the range alias tables and the
389// instantiation matrix in their own translation units — see
390// bindings/instantiations/wmo_ranges.hpp and wmo_matrix.inl.
void addWarning(std::string path, std::string message)
Record a warning finding (see add()).
void addError(std::string path, std::string message)
Record an error finding (see add()).
Client version identity, the flavor axis that separates a client's CONTENT version from the engine ge...
The error-handling vocabulary: ErrorCode, Error and the Result<T> alias every fallible wowlib operati...
File identity types: the strong FileDataID and the FileKey a read request travels as.
The runtime facade over the static compositions — the primary welder binding surface of the fs layer.
The WMO group-file entities (namespace wowlib::formats::wmo::group): the MOGP container body (geometr...
Binding-language identities welder's core does not name.
#define WOWLIB_CS_FAMILY_SURFACE
The C# rod's family-surface opt-in, spellable in every build.
Definition lang.hpp:44
consteval std::optional< Spec > annotation()
The first annotation of type Spec on reflected member M, if any.
void validateEntity(const E &entity, ValidationReport &report)
Validate a whole entity — the engine behind every validate() method; see ChunkedFile::validate() for ...
consteval auto membersOf()
The reflected member list of E, public bases flattened in (see collectMembers).
consteval std::meta::info memberNamed(std::string_view name)
The reflected member of E named name (public bases flattened, like membersOf), or the null reflection...
void validateIndexElements(const Values &values, std::size_t targetCount, std::string_view member, std::string_view target, ValidationReport &report)
Report every element of values that is not a valid index into a targetCount-element target,...
The WMO group-file entities: WMOGroup, WMOGroupBody and their per-version classes.
Definition geometry.hpp:18
group::detail::WMOGroup< canonicalVersion(V, WmoGroupPivots, WmoVersions)> WMOGroup
One WMO group file — the canonicalizing face of detail::WMOGroup (same pivots as the body it contains...
Definition group.hpp:756
The WMO root-file entity: WMORoot and its per-version classes.
Definition doodad.hpp:17
detail::WMORoot< canonicalVersion(V, WmoRootPivots, WmoVersions)> WMORoot
A WMO root file — the canonicalizing face of detail::WMORoot: every client version maps to its range'...
Definition root.hpp:431
constexpr std::array WmoAssemblyPivots
The WMO assembly: the union of the root and group pivots.
constexpr std::uint32_t WmoVersionV17
The WMO format version every supported client uses (MVER payload).
detail::WMO< canonicalVersion(V, WmoAssemblyPivots, WmoVersions)> WMO
A whole WMO — the canonicalizing face of detail::WMO: every client version maps to its range's first ...
Definition wmo.hpp:138
constexpr std::array WmoVersions
The versions WMO is instantiated (and welded) for: every targeted last-minor-of-major release,...
constexpr ClientVersion canonicalVersion(ClientVersion v, std::span< const ClientVersion > pivots, std::span< const ClientVersion > grid)
The canonical version v collapses to: the FIRST grid version in v's range.
constexpr welder::lang Cs
C#/.NET — the welder-csharp rod's identity (user-range slot 0), respelled for wowlib's annotation sit...
Definition lang.hpp:23
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::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
@ InvalidEntityState
An entity's members disagree (e.g.
Definition error.hpp:40
@ PathNotResolvable
No FileDataID is known for the given path (listfile miss).
Definition error.hpp:23
@ FormatVersionMismatch
The file's version chunk disagrees with the requested version.
Definition error.hpp:41
std::optional< std::string > path
The canonical client-internal path, if known.
Definition file_key.hpp:31
The version-agnostic root of every file-level entity (welded as "FileEntity").
The version-agnostic base of every WMO<V> (welded as "WMO").
Definition wmo.hpp:47
A whole WMO (world map object) for one client version: the root file and all its group files unified ...
Definition wmo.hpp:65
Result< void > read(fs::FileSystem &fs, const FileKey &key)
Load the WMO and all its group files from a client filesystem, replacing this entity's contents.
Definition wmo.hpp:278
Result< void > read(std::span< const std::byte > rootData, std::span< const std::span< const std::byte > > groupDatas)
Parse the WMO from already-loaded buffers (no filesystem access), replacing this entity's contents.
Definition wmo.hpp:258
ValidationReport validate() const
Definition wmo.hpp:156
Result< void > write(fs::FileSystem &fs, const FileKey &key) const
Serialize and store the WMO (root and every group) through the filesystem's project overlay; group fi...
Definition wmo.hpp:328
Result< void > ensureValid() const
Validate and raise on the first error instead of returning a report — the assert-style face of valida...
Definition wmo.hpp:245
std::uint32_t mver
The WMO format version; 17 for every supported client.
Definition root.hpp:188
The WMO root-file entity (namespace wowlib::formats::wmo::root): header, materials,...