wowlib 0.0.0
Read & write World of Warcraft client files — a C++26 core
Loading...
Searching...
No Matches
m2.hpp
Go to the documentation of this file.
1#pragma once
2
10
11#include <array>
12#include <cstring>
13#include <format>
14#include <optional>
15#include <ranges>
16#include <span>
17#include <string>
18#include <string_view>
19#include <vector>
20
22#include <wowlib/core/error.hpp>
24#include <wowlib/core/lang.hpp>
36
37namespace wowlib::formats::m2 {
39 using root::Md20Magic;
40 using bone::BoneFile;
41 using skin::Skin;
42 using skin::SkinMagic;
43
53 struct [[
54 =welder::weld,
55 =welder::weld_as("M2"),
57 =welder::doc(R"(
58 A whole model, abstract over the client version — the MD20 body with
59 its satellite files (.skin, .anim) baked in. Construct the concrete
60 version with M2.for_version(expansion), then read()/write(); the
61 per-version M2* classes are subclasses. See https://wowdev.wiki/M2.)")
63 bool operator==(const M2Base&) const = default;
64 };
65
66 namespace detail {
67 // The trait primaries are EMPTY and unconstrained: the binding walk
68 // completes absent<Trait>'s template argument even for the versions a
69 // slot leaves inactive, so an out-of-era instantiation must be valid —
70 // the members live in constrained partial specializations instead.
73 template <ClientVersion V>
74 struct AssemblySkins {
75 [[=welder::mark::exclude]]
76 bool operator==(const AssemblySkins&) const = default;
77 };
79 template <ClientVersion V> requires (V >= M2PerSequenceTimelines)
80 struct AssemblySkins<V> {
81 [[
82 =welder::mark::no_reassign,
83 =welder::doc(R"(The model's LOD views, in view order ("{model}0N.skin"
84 files, WotLK+). The source of truth for the view
85 count: write() stamps the body's num_skin_profiles
86 layout field from this vector's length.)")]]
87 std::vector<Skin<V>> skins;
88
89 [[=welder::mark::exclude]]
90 bool operator==(const AssemblySkins&) const = default;
91 };
92
95 template <ClientVersion V>
97 [[=welder::mark::exclude]]
98 bool operator==(const AssemblyLegion&) const = default;
99 };
101 template <ClientVersion V> requires (V >= M2ChunkedContainer)
102 struct AssemblyLegion<V> {
103 [[
104 =welder::doc(R"(The chunked .m2 stream (Legion+): the satellite chunks
105 (FileDataIDs, extended particles, parent overrides)
106 plus preserved unknown chunks. The MD21 transport blob
107 inside is TRANSIENT: read() decodes it into `root` and
108 drops the bytes; write() re-encodes root into the
109 stream and refreshes the FileDataID chunks.)")]]
111
112 [[
113 =welder::mark::no_reassign,
114 =welder::doc("The LOD-band skins (the SFID entries beyond "
115 "num_skin_profiles), in band order.")]]
116 std::vector<Skin<V>> lodSkins;
117
118 [[
119 =welder::doc("The referenced .phys file bytes (PFID), baked in "
120 "verbatim — structured physics is a follow-up "
121 "milestone. Inline physics (PFDC) stays a chunk on "
122 "the stream.")]]
123 ChunkBlob phys;
124
125 [[
126 =welder::doc(R"(The shared skeleton (SKID), fully baked in — bones,
127 attachments, sequences and the parent link. Engaged
128 exactly when the chunk stream carries a skeleton
129 FileDataID; skeletons are shared between models, so
130 edit with care or write the .skel standalone.)")]]
131 // through the m2:: alias, NOT the sibling detail raw — the skeleton
132 // collapses to its single chunked-era instantiation
133 m2::Skeleton<V> skel{};
134
135 [[
136 =welder::mark::no_reassign,
137 =welder::doc("The .bone facial-pose files (BFID; the skeleton's when "
138 "skel-based), in variant order.")]]
139 std::vector<BoneFile> boneFiles;
141 [[=welder::mark::exclude]]
142 bool operator==(const AssemblyLegion&) const = default;
143 };
144 }
145
146 namespace detail {
158 template <ClientVersion V>
159 struct [[
160 =welder::weld,
161 =welder::doc(R"(
162 A whole model for one client version: the MD20 body with skins and
163 external sequence data baked in. A written model is canonical-layout
164 and re-reads equal (no byte-perfect guarantee for offset formats).
165 See https://wowdev.wiki/M2.)")
166 ]] M2 : M2Base,
169 static constexpr ClientVersion Version = V;
170
171 [[=welder::doc("The MD20 body — the model's root, uniform across every "
172 "era (pre-Legion it IS the file; Legion+ it is the "
173 "decoded MD21 image, whose transport blob `chunks` drops "
174 "after read).")]]
175 M2Root<V> root{};
176
177 // read()/write() weld the (FileSystem, FileKey) load/save on LUA AND C#
178 // ONLY — mirroring WMO: on Python the module glue attaches the richer
179 // read/write/convert/for_version surface to M2Base instead (stage 4).
180 // Lua and C# have no such glue, so they take these methods directly.
181
182 [[=welder::mark::only(welder::lang::lua, wowlib::lang::Cs),
183 =welder::doc("Load the model and all its satellite files from a client "
184 "filesystem, replacing this entity's contents.")]]
185 Result<void> read(fs::FileSystem& fs [[=welder::doc("the filesystem gateway")]],
186 const FileKey& key [[=welder::doc("the .m2 file identity (path and/or FileDataID)")]]);
187
188 [[=welder::mark::only(welder::lang::lua, wowlib::lang::Cs),
189 =welder::doc("Serialize and store the model and every satellite file "
190 "through the filesystem's project overlay; satellite names "
191 "derive from the key, which must resolve to a path.")]]
192 Result<void> write(fs::FileSystem& fs [[=welder::doc("the filesystem gateway")]],
193 const FileKey& key
194 [[=welder::doc(
195 "the .m2 file identity; must resolve to a path")]]) const;
196
197 // Beyond the body's and each skin's own contracts, this sees the ones only
198 // the assembly can: each skin's local->global vertex lookup landing inside
199 // the body's vertices, every batch resolving its material, color and
200 // lookup slices, and the bone lookups against whichever list supplies the
201 // bones (a skel-based model keeps them in the .skel).
202 [[nodiscard]]
203 [[=welder::doc(R"(
204 Check the logical integrity contracts this model must satisfy to LOAD
205 in the client — across the body AND every skin — which write()
206 deliberately never enforces. Call it before writing when you want to
207 know the model will load. A model read from a client and left
208 unmodified reports no errors; warnings mark states real client files
209 ship.)"),
210 =welder::returns(R"(every violated contract, each with its member path
211 ("root..." / "skins[i]..."))")]]
213
214 [[nodiscard]]
215 [[=welder::doc(
216 "Validate and raise on the first error instead of returning "
217 "a report — the assert-style face of validate()."),
218 =welder::returns("nothing; raises when validate() finds any error")]]
220
221 bool operator==(const M2&) const = default;
222
223 private:
224 // --- internal fs-I/O helpers (definitions at the bottom of this header;
225 // --- private so the Python/Lua surface and the C++ API stay verbs-only) -
226
232 static Result<void> _checkHeader(std::uint32_t magic, std::uint32_t formatVersion);
233
243 static Result<void> _readSkinInto(fs::FileSystem& fs, const FileKey& key, std::string_view what, auto& out)
244 requires (V >= M2PerSequenceTimelines);
245
254 Result<void> _readMonolithic(fs::FileSystem& fs, const FileKey& key, std::span<const std::byte> main) requires (V
256
265 Result<void> _readChunked(fs::FileSystem& fs, const FileKey& key, std::span<const std::byte> main) requires (V >=
267
273 Result<void> _loadSkeleton(fs::FileSystem& fs) requires (V >= M2ChunkedContainer);
274
283 Result<void> _readChunkedBody(fs::FileSystem& fs, const FileKey& key, bool hasSkel) requires (V >=
285
291 Result<void> _readChunkedSkins(fs::FileSystem& fs) requires (V >= M2ChunkedContainer);
292
297 Result<void> _readBoneFiles(fs::FileSystem& fs, bool hasSkel) requires (V >= M2ChunkedContainer);
298
303 void _readPhysics(fs::FileSystem& fs) requires (V >= M2ChunkedContainer);
304
313 Result<FileBuffer> _writeBodyImage(AnimBuffers& afm2Bufs, const auto& sequences) const requires (V >=
315
323 Result<void> _writeMonolithic(fs::FileSystem& fs, const std::string& path, const SatellitePaths& paths) const
324 requires (V >= M2PerSequenceTimelines && V < M2ChunkedContainer);
325
333 Result<void> _writeChunked(fs::FileSystem& fs, const std::string& path, const SatellitePaths& paths) const
334 requires (V >= M2ChunkedContainer);
335
341 Result<std::vector<std::uint32_t>> _writeBoneFiles(fs::FileSystem& fs, const SatellitePaths& paths) const
342 requires (V >= M2ChunkedContainer);
343
352 Result<void> _writePlainAnims(fs::FileSystem& fs,
353 const SatellitePaths& paths,
354 AnimBuffers& afm2Bufs,
355 auto& stream) const requires (V >= M2ChunkedContainer);
356
369 Result<void> _writeSkeletonSatellites(fs::FileSystem& fs,
370 const SatellitePaths& paths,
371 AnimBuffers& afm2Bufs,
372 const auto& sequences,
373 const std::vector<std::uint32_t>& boneFdids,
374 auto& stream) const requires (V >= M2ChunkedContainer);
375
382 Result<void> _writeChunkedSkins(fs::FileSystem& fs, const SatellitePaths& paths, auto& stream) const requires (V
384
391 Result<void> _writeChunkedPhys(fs::FileSystem& fs, const SatellitePaths& paths, auto& stream) const requires (V
393
400 static bool _skeletonEngaged(std::span<const std::uint32_t> skeletonFdid) {
401 return !skeletonFdid.empty() && skeletonFdid.front() != 0;
402 }
403 };
404 }
405
409 template <ClientVersion V>
410 using M2 = detail::M2<canonicalVersion(V, M2AssemblyPivots, M2Versions)>;
411}
412
413// --- fs-level read/write definitions -----------------------------------------
414// Inline in this header (not a separate io.hpp): the entities are templates,
415// so the definitions must be visible for implicit instantiation anyway — the
416// library ships NO explicit instantiations; every consumer TU instantiates
417// exactly the versions it uses (the bindings expand the full matrix in their
418// own translation units, see bindings/instantiations/).
419namespace wowlib::formats::m2 {
420 template <ClientVersion V>
421 Result<void> detail::M2<V>::_checkHeader(std::uint32_t magic, std::uint32_t formatVersion) {
422 if (magic != Md20Magic)
424 std::format(
425 "not an MD20 model (magic {:#010x}; a Legion+ chunked " "file starts with MD21 instead)",
426 magic));
427 constexpr auto range = m2FormatVersionRange(V);
428 if (formatVersion < range.first || formatVersion > range.second)
430 std::format("MD20 version {} is outside the requested client's " "era [{}, {}]", formatVersion,
431 range.first, range.second));
432 return {};
433 }
434
435 template <ClientVersion V>
436 Result<void> detail::M2<V>::_readSkinInto(fs::FileSystem& fs, const FileKey& key, std::string_view what, auto& out)
437 requires (V >= M2PerSequenceTimelines) {
438 const auto bytes = fs.readFile(key);
439 if (!bytes)
440 return makeError(bytes.error().code, std::format("{}: {}", what, bytes.error().message),
441 bytes.error().nativeError);
443 if (auto r = skin.read(std::span<const std::byte>{*bytes}); !r)
444 return makeError(r.error().code, std::format("{}: {}", what, r.error().message), r.error().nativeError);
445 if (skin.magic != SkinMagic)
447 std::format("{} magic is {:#010x}, expected 'SKIN'", what, skin.magic));
448 out.push_back(std::move(skin));
449 return {};
450 }
451
452 template <ClientVersion V>
453 Result<void> detail::M2<V>::_readMonolithic(fs::FileSystem& fs, const FileKey& key, std::span<const std::byte> main)
454 requires (V >= M2PerSequenceTimelines) {
455 const FileKey resolved = fs.resolve(key);
456 if (!resolved.path)
458 "a monolithic M2's satellite files (.skin/.anim) need a " "resolvable path for the model key");
459 const SatellitePaths paths{*resolved.path};
460
461 // Low-priority sequence data lives in per-sequence .anim files; the
462 // context resolves them lazily — the sequences table is populated
463 // before any track member reads (layout order), so the flags are already
464 // decoded when the first track consults us. A missing .anim file
465 // leaves its sequences' tracks empty rather than failing.
466 AnimCache cache{
467 [&](SequenceKey seq) {
468 return fs.readFile(FileKey{paths.anim(seq)});
469 }
470 };
472 ctx.sequenceBase = cache.sequenceBase(this->root.sequences, main, AnimCache::Afm2Magic);
473 if (auto r = this->root.read(main, ctx); !r) return r;
474 if (auto r = _checkHeader(this->root.magic, this->root.formatVersion); !r) return r;
475
476 this->skins.reserve(this->root.numSkinProfiles);
477 for (std::uint32_t i = 0; i < this->root.numSkinProfiles; ++i)
478 if (auto r = _readSkinInto(fs, FileKey{paths.skin(i)}, std::format("skin {}", i), this->skins); !r) return r;
479 return {};
480 }
481
482 template <ClientVersion V>
483 Result<void> detail::M2<V>::_readChunked(fs::FileSystem& fs, const FileKey& key, std::span<const std::byte> main)
484 requires (V >= M2ChunkedContainer) {
485 // 1. the chunk shell (MD21 transport blob + the reference/data chunks).
486 if (auto r = this->chunks.read(main); !r) return r;
487
488 // 2. the skeleton, when the model is skel-based — its sequences and
489 // AFID/BFID tables then stand in for the body's (which are empty).
490 const bool hasSkel = _skeletonEngaged(this->chunks.skeletonFdid);
491 if (hasSkel)
492 if (auto r = _loadSkeleton(fs); !r) return r;
493
494 // 3. the MD20 body out of the MD21 image; the transport blob is spent once
495 // decoded (the body's md21 span dies inside _readChunkedBody), so drop
496 // it now rather than keep a second whole-model image in memory —
497 // write() re-encodes root into a fresh stream.
498 if (auto r = _readChunkedBody(fs, key, hasSkel); !r) return r;
499 this->chunks.md21.bytes = {};
500
501 // 4. the LOD views, then 5. the .bone files and 6. the (optional) physics
502 // blob — a monadic chain, so each phase runs only if the prior succeeded.
503 return _readChunkedSkins(fs).and_then([&] { return _readBoneFiles(fs, hasSkel); }).transform([&] {
504 _readPhysics(fs);
505 });
506 }
507
508 template <ClientVersion V>
509 Result<void> detail::M2<V>::_loadSkeleton(fs::FileSystem& fs) requires (V >= M2ChunkedContainer) {
510 if (auto r = this->skel.read(fs, FileKey{FileDataID{this->chunks.skeletonFdid.front()}}); !r)
511 return makeError(r.error().code, std::format(".skel: {}", r.error().message), r.error().nativeError);
512 return {};
513 }
514
515 template <ClientVersion V>
516 Result<void> detail::M2<V>::_readChunkedBody(fs::FileSystem& fs, const FileKey& key, bool hasSkel) requires (V >=
518 // name fallback for satellites without FileDataID entries
519 std::optional<SatellitePaths> paths;
520 if (const FileKey resolved = fs.resolve(key); resolved.path) paths.emplace(*resolved.path);
521
522 const std::span<const std::byte> md21{this->chunks.md21.bytes};
523
524 // skel-based models keep their sequences (and thus the external-data
525 // flags) in the skeleton; the body's own table is empty then
526 const auto* sequences = hasSkel ? &this->skel.sequenceBlock.sequences : &this->root.sequences;
527 const auto& afids = hasSkel ? this->skel.effectiveAnimFdids() : this->chunks.animFdids;
528
529 AnimCache cache{
530 [&, paths](SequenceKey seq) -> Result<FileBuffer> {
531 const std::uint32_t fdid = AnimCache::afidLookup(afids, seq);
532 if (fdid != 0) return fs.readFile(FileKey{FileDataID{fdid}});
533 if (paths) return fs.readFile(FileKey{paths->anim(seq)});
534 return makeError(ErrorCode::FileNotFound, "no AFID entry and no path");
535 }
536 };
538 ctx.sequenceBase = cache.sequenceBase(*sequences, md21, AnimCache::Afm2Magic);
539 if (auto r = this->root.read(md21, ctx); !r) return r;
540 return _checkHeader(this->root.magic, this->root.formatVersion);
541 }
542
543 template <ClientVersion V>
544 Result<void> detail::M2<V>::_readChunkedSkins(fs::FileSystem& fs) requires (V >= M2ChunkedContainer) {
545 // the first numSkinProfiles SFID entries are the views, the rest the LOD
546 // bands (real files occasionally truncate the LOD tail)
547 if (this->chunks.skinFdids.size() < this->root.numSkinProfiles)
548 return makeError(ErrorCode::InvalidEntityState, std::format("SFID holds {} entries, the body declares {} views",
549 this->chunks.skinFdids.size(),
550 this->root.numSkinProfiles));
551 for (const auto [i, fdid] : std::views::enumerate(this->chunks.skinFdids) | std::views::take(
552 this->root.numSkinProfiles))
553 if (auto r = _readSkinInto(fs, FileKey{FileDataID{fdid}}, std::format("skin {}", i), this->skins); !r) return r;
554 for (const auto [i, fdid] : std::views::enumerate(this->chunks.skinFdids) | std::views::drop(
555 this->root.numSkinProfiles))
556 if (fdid != 0)
557 if (auto r = _readSkinInto(fs, FileKey{FileDataID{fdid}}, std::format("lod skin {}", i), this->lodSkins); !r)
558 return r;
559 return {};
560 }
561
562 template <ClientVersion V>
563 Result<void> detail::M2<V>::_readBoneFiles(fs::FileSystem& fs, bool hasSkel) requires (V >= M2ChunkedContainer) {
564 const auto& bfids = hasSkel ? this->skel.effectiveBoneFdids() : this->chunks.boneFdids;
565 for (const auto [i, fdid] : std::views::enumerate(bfids)) {
566 if (fdid == 0) continue;
567 const auto bytes = fs.readFile(FileKey{FileDataID{fdid}});
568 if (!bytes)
569 return makeError(bytes.error().code, std::format(".bone {}: {}", i, bytes.error().message),
570 bytes.error().nativeError);
572 if (auto r = bone.read(std::span<const std::byte>{*bytes}); !r)
573 return makeError(r.error().code, std::format(".bone {}: {}", i, r.error().message), r.error().nativeError);
574 this->boneFiles.push_back(std::move(bone));
575 }
576 return {};
577 }
578
579 template <ClientVersion V>
580 void detail::M2<V>::_readPhysics(fs::FileSystem& fs) requires (V >= M2ChunkedContainer) {
581 if (!this->chunks.physFdid.empty() && this->chunks.physFdid.front() != 0)
582 if (auto bytes = fs.readFile(FileKey{FileDataID{this->chunks.physFdid.front()}})) this->phys.bytes =
583 std::move(*bytes);
584 }
585
586 template <ClientVersion V>
587 Result<void> detail::M2<V>::read(fs::FileSystem& fs, const FileKey& key) {
588 const auto main = fs.readFile(key);
589 if (!main) return std::unexpected{main.error()};
590
591 root = {};
592 if constexpr (V >= M2PerSequenceTimelines) this->skins.clear();
593 if constexpr (V >= M2ChunkedContainer) {
594 this->chunks = {};
595 this->lodSkins.clear();
596 this->phys = {};
597 this->skel = {};
598 this->boneFiles.clear();
599 }
600
601 if constexpr (V < M2PerSequenceTimelines) {
602 if (auto r = root.read(std::span<const std::byte>{*main}); !r) return r;
603 return _checkHeader(root.magic, root.formatVersion);
604 }
605 else if constexpr (V < M2ChunkedContainer) {
606 return _readMonolithic(fs, key, std::span<const std::byte>{*main});
607 }
608 else {
609 std::uint32_t lead = 0;
610 if (main->size() >= 4) std::memcpy(&lead, main->data(), 4);
611 if (lead == Md20Magic) {
612 // Legion clients still served leftover raw MD20 models; from BfA on
613 // none exist, so a bare image under a BfA+ target is a mismatch, not
614 // a fallback (M2ChunkedOnly).
615 if constexpr (V < M2ChunkedOnly) return _readMonolithic(fs, key, std::span<const std::byte>{*main});
616 else
617 return makeError(ErrorCode::FormatVersionMismatch,
618 "bare MD20 model under a BfA+ target — raw (unchunked) .m2 files "
619 "no longer exist from 8.0 on; if this is a Legion-era file, read "
620 "it with the legion target");
621 }
622 return _readChunked(fs, key, std::span<const std::byte>{*main});
623 }
624 }
625
626 template <ClientVersion V>
627 Result<void> detail::M2<V>::write(fs::FileSystem& fs, const FileKey& key) const {
628 const FileKey resolved = fs.resolve(key);
629 if (!resolved.path)
630 return makeError(ErrorCode::PathNotResolvable, "saving an M2 needs a path for the key");
631 const SatellitePaths paths{*resolved.path};
632
633 if constexpr (V < M2PerSequenceTimelines) {
634 const auto bytes = root.write();
635 if (!bytes) return std::unexpected{bytes.error()};
636 if (auto r = fs.addFile(*resolved.path, *bytes); !r) return std::unexpected{r.error()};
637 return {};
638 }
639 else if constexpr (V < M2ChunkedContainer) return _writeMonolithic(fs, *resolved.path, paths);
640 else return _writeChunked(fs, *resolved.path, paths);
641 }
642
643 template <ClientVersion V>
644 Result<FileBuffer> detail::M2<V>::_writeBodyImage(AnimBuffers& afm2Bufs, const auto& sequences) const requires (V
646 // Low-priority sequences split back out: every external sequence gets an
647 // .anim buffer, filled as the tracks route their per-sequence blocks
648 // through the sinks (empty ones still write — the client requests the file
649 // whenever the flags say so).
650 auto bytes = root.write(afm2Bufs.sink(sequences));
651 if (!bytes) return std::unexpected{bytes.error()};
652
653 // stamp the derived skin count into the freshly written image — the baked
654 // skins vector is the source of truth (numSkinProfiles is a hidden
655 // layout field, see M2Root)
656 constexpr std::size_t countAt = M2Root < V > ::memberOffset("numSkinProfiles");
657 const auto count = static_cast<std::uint32_t>(this->skins.size());
658 std::memcpy(bytes->data() + countAt, &count, sizeof count);
659 return bytes;
660 }
661
662 template <ClientVersion V>
663 Result<void> detail::M2<V>::_writeMonolithic(fs::FileSystem& fs,
664 const std::string& path,
665 const SatellitePaths& paths) const requires (V >=
667 AnimBuffers afm2Bufs;
668 const auto bytes = _writeBodyImage(afm2Bufs, root.sequences);
669 if (!bytes) return std::unexpected{bytes.error()};
670
671 // pre-Legion: the body and raw .anim payloads under conventional names
672 if (auto r = fs.addFile(path, *bytes); !r) return std::unexpected{r.error()};
673 for (const auto& [seq, buf] : afm2Bufs.entries())
674 if (auto r = fs.addFile(paths.anim(seq), buf); !r)
675 return makeError(r.error().code, std::format("anim {:04}-{:02}: {}", seq.id, seq.variation, r.error().message),
676 r.error().nativeError);
677 for (const auto [i, skin] : std::views::enumerate(this->skins)) {
678 const auto skinBytes = skin.write();
679 if (!skinBytes) return std::unexpected{skinBytes.error()};
680 if (auto r = fs.addFile(paths.skin(static_cast<std::uint32_t>(i)), *skinBytes); !r)
681 return makeError(r.error().code, std::format("skin {}: {}", i, r.error().message), r.error().nativeError);
682 }
683 return {};
684 }
685
686 template <ClientVersion V>
687 Result<void> detail::M2<V>::_writeChunked(fs::FileSystem& fs,
688 const std::string& path,
689 const SatellitePaths& paths) const requires (V >= M2ChunkedContainer) {
690 // the SAME engagement predicate the read path uses — a stored SKID of 0
691 // must not flip a non-skel model into a skel-based write; a skel-based
692 // model's sequence table (which drives the .anim split) lives in the skel
693 const bool hasSkel = _skeletonEngaged(this->chunks.skeletonFdid);
694
695 AnimBuffers afm2Bufs;
696 auto bytes = hasSkel
697 ? _writeBodyImage(afm2Bufs, this->skel.sequenceBlock.sequences)
698 : _writeBodyImage(afm2Bufs, root.sequences);
699 if (!bytes) return std::unexpected{bytes.error()};
700
701 // rebuild the chunk stream around the re-encoded image: satellites write
702 // first so their fresh FileDataIDs land in the reference chunks
703 M2ChunkedFile < V > stream = this->chunks;
704 stream.md21.bytes = std::move(*bytes);
705
706 // .bone files (their fdids land in the skeleton for skel-based models)
707 const auto boneFdids = _writeBoneFiles(fs, paths);
708 if (!boneFdids) return std::unexpected{boneFdids.error()};
709
710 // .anim files + (for skel models) the .skel; each records its fdids on the
711 // stream (non-skel) or the skeleton (skel), rebuilt fresh
712 stream.animFdids.clear();
713 if (!hasSkel) {
714 if (auto r = _writePlainAnims(fs, paths, afm2Bufs, stream); !r) return r;
715 stream.boneFdids = *boneFdids;
716 }
717 else if (auto r = _writeSkeletonSatellites(fs, paths, afm2Bufs, this->skel.sequenceBlock.sequences, *boneFdids,
718 stream); !r) return r;
719
720 // the .skin views, then the physics blob, then finalize: serialize the
721 // rebuilt stream and store it — a monadic chain that short-circuits on the
722 // first failure.
723 return _writeChunkedSkins(fs, paths, stream).and_then([&] { return _writeChunkedPhys(fs, paths, stream); }).
724 and_then([&] { return stream.write(); }).and_then(
725 [&](const FileBuffer& streamBytes) {
726 return fs.addFile(path, streamBytes).transform(
727 [](FileDataID) {});
728 });
729 }
730
731 template <ClientVersion V>
732 Result<std::vector<std::uint32_t>>
733 detail::M2<V>::_writeBoneFiles(fs::FileSystem& fs, const SatellitePaths& paths) const requires (V >=
735 std::vector<std::uint32_t> boneFdids;
736 for (const auto [i, bone] : std::views::enumerate(this->boneFiles)) {
737 const auto boneBytes = bone.write();
738 if (!boneBytes) return std::unexpected{boneBytes.error()};
739 const auto r = fs.addFile(paths.bone(static_cast<std::uint32_t>(i)), *boneBytes);
740 if (!r)
741 return makeError(r.error().code, std::format(".bone {}: {}", i, r.error().message), r.error().nativeError);
742 boneFdids.push_back(r->value);
743 }
744 return boneFdids;
745 }
746
747 template <ClientVersion V>
748 Result<void> detail::M2<V>::_writePlainAnims(fs::FileSystem& fs,
749 const SatellitePaths& paths,
750 AnimBuffers& afm2Bufs,
751 auto& stream) const requires (V >= M2ChunkedContainer) {
752 // .anim payloads, AFM2-wrapped when the model asks for chunked ones
753 for (const auto& [seq, buf] : afm2Bufs.entries()) {
754 FileBuffer file;
755 if (hasFlag(root.globalFlags, GlobalFlags::ChunkedAnimFiles)) afm2Bufs.appendChunkTo(
756 file, AnimCache::Afm2Magic, seq);
757 else file = buf;
758 const auto r = fs.addFile(paths.anim(seq), file);
759 if (!r)
760 return makeError(r.error().code, std::format("anim {:04}-{:02}: {}", seq.id, seq.variation, r.error().message),
761 r.error().nativeError);
762 stream.animFdids.push_back({seq.id, seq.variation, r->value});
763 }
764 return {};
765 }
766
767 template <ClientVersion V>
768 Result<void> detail::M2<V>::_writeSkeletonSatellites(fs::FileSystem& fs,
769 const SatellitePaths& paths,
770 AnimBuffers& afm2Bufs,
771 const auto& sequences,
772 const std::vector<std::uint32_t>& boneFdids,
773 auto& stream) const requires (V >= M2ChunkedContainer) {
774 // re-encode the skeleton's bone/attachment blocks (splitting AFSB/AFSA per
775 // sequence), then assemble the shared .anim files as AFM2 (body events) +
776 // AFSA (attachments) + AFSB (bones) and hang every satellite id off the
777 // skeleton.
778 // deduced: inside m2::detail the bare Skeleton names the RAW template, but
779 // the member is the collapsed m2::Skeleton alias type
780 auto skelCopy = this->skel;
781 AnimBuffers afsaBufs;
782 AnimBuffers afsbBufs;
783 {
784 auto encoded = this->skel.boneBlock.write(afsbBufs.sink(sequences));
785 if (!encoded) return std::unexpected{encoded.error()};
786 skelCopy.skb1.bytes = std::move(*encoded);
787 auto attachments = this->skel.attachmentBlock.write(afsaBufs.sink(sequences));
788 if (!attachments) return std::unexpected{attachments.error()};
789 skelCopy.ska1.bytes = std::move(*attachments);
790 }
791
792 skelCopy.animFdids.clear();
793 for (const SequenceKey seq : AnimBuffers::mergedKeys({&afm2Bufs, &afsaBufs, &afsbBufs})) {
794 FileBuffer file;
795 afm2Bufs.appendChunkTo(file, AnimCache::Afm2Magic, seq);
796 afsaBufs.appendChunkTo(file, AnimCache::AfsaMagic, seq);
797 afsbBufs.appendChunkTo(file, AnimCache::AfsbMagic, seq);
798 const auto r = fs.addFile(paths.anim(seq), file);
799 if (!r)
800 return makeError(r.error().code, std::format("anim {:04}-{:02}: {}", seq.id, seq.variation, r.error().message),
801 r.error().nativeError);
802 skelCopy.animFdids.push_back({seq.id, seq.variation, r->value});
803 }
804 skelCopy.boneFdids = boneFdids;
805
806 const auto skelBytes = skelCopy.template ChunkedFile<m2::Skeleton<V>>::write();
807 if (!skelBytes) return std::unexpected{skelBytes.error()};
808 const auto r = fs.addFile(paths.skel(), *skelBytes);
809 if (!r)
810 return makeError(r.error().code, std::format(".skel: {}", r.error().message), r.error().nativeError);
811 stream.skeletonFdid.assign(1, r->value);
812 return {};
813 }
814
815 template <ClientVersion V>
816 Result<void> detail::M2<V>::_writeChunkedSkins(fs::FileSystem& fs, const SatellitePaths& paths, auto& stream) const
817 requires (V >= M2ChunkedContainer) {
818 stream.skinFdids.clear();
819 for (const auto [i, skin] : std::views::enumerate(this->skins)) {
820 const auto skinBytes = skin.write();
821 if (!skinBytes) return std::unexpected{skinBytes.error()};
822 const auto r = fs.addFile(paths.skin(static_cast<std::uint32_t>(i)), *skinBytes);
823 if (!r)
824 return makeError(r.error().code, std::format("skin {}: {}", i, r.error().message), r.error().nativeError);
825 stream.skinFdids.push_back(r->value);
826 }
827 for (const auto [i, skin] : std::views::enumerate(this->lodSkins)) {
828 const auto skinBytes = skin.write();
829 if (!skinBytes) return std::unexpected{skinBytes.error()};
830 const auto r = fs.addFile(paths.lodSkin(static_cast<std::uint32_t>(i) + 1), *skinBytes);
831 if (!r)
832 return makeError(r.error().code, std::format("lod skin {}: {}", i, r.error().message), r.error().nativeError);
833 stream.skinFdids.push_back(r->value);
834 }
835 return {};
836 }
837
838 template <ClientVersion V>
839 Result<void> detail::M2<V>::_writeChunkedPhys(fs::FileSystem& fs, const SatellitePaths& paths, auto& stream) const
840 requires (V >= M2ChunkedContainer) {
841 stream.physFdid.clear();
842 if (!this->phys.bytes.empty()) {
843 const auto r = fs.addFile(paths.phys(), this->phys.bytes);
844 if (!r)
845 return makeError(r.error().code, std::format(".phys: {}", r.error().message), r.error().nativeError);
846 stream.physFdid.push_back(r->value);
847 }
848 return {};
849 }
850
851 namespace detail {
860 template <typename Profile, typename Root>
861 void validateProfileAgainstRoot(const Profile& profile, const Root& root, ValidationReport& report) {
862 formats::detail::validateIndexElements(profile.vertices, root.vertices.size(), "vertices", "the model vertices",
863 report);
864
865 for (std::size_t i = 0; i < profile.submeshes.size() && !report.full(); ++i) {
866 const auto& submesh = profile.submeshes[i];
867 if (std::size_t{submesh.boneComboIndex} + submesh.boneCount > root.boneLookupTable.size())
868 report.addError(std::format("submeshes[{}]", i),
869 std::format("bone-lookup range [{}, {}) overruns the {} entries", submesh.boneComboIndex,
870 submesh.boneComboIndex + submesh.boneCount, root.boneLookupTable.size()));
871 }
872
873 for (std::size_t i = 0; i < profile.batches.size() && !report.full(); ++i) {
874 const auto& batch = profile.batches[i];
875 const std::string path = std::format("batches[{}]", i);
876 if (batch.materialIndex >= root.materials.size())
877 report.addError(path, std::format("material_index {} out of range: {} materials", batch.materialIndex,
878 root.materials.size()));
879 if (!formats::detail::isNoIndex(batch.colorIndex) && batch.colorIndex >= root.colors.size())
880 report.addError(path, std::format("color_index {} out of range: {} colors", batch.colorIndex,
881 root.colors.size()));
882
883 // A batch addresses `textureCount` consecutive TEXTURE-lookup entries.
884 // The weight and transform lookups do NOT follow textureCount — real
885 // files routinely declare a longer run than those tables hold (a 3.3.5a
886 // humanmale batch spans 6 over a 2-entry transparency table, the
887 // Northrend glue screen 2 over 10 from index 9), so only their starting
888 // entry is validated, and only when the table exists at all.
889 if (std::size_t{batch.textureComboIndex} + batch.textureCount > root.textureLookupTable.size())
890 report.addError(path, std::format("texture-lookup range [{}, {}) overruns the {} entries",
891 batch.textureComboIndex, batch.textureComboIndex + batch.textureCount,
892 root.textureLookupTable.size()));
893 const auto lookupStart = [&](std::uint16_t first, const auto& table, std::string_view what) {
894 if (!table.empty() && first >= table.size())
895 report.addError(path, std::format("{} start {} out of range: {} entries", what, first, table.size()));
896 };
897 lookupStart(batch.textureWeightComboIndex, root.transparencyLookupTable, "transparency-lookup");
898 lookupStart(batch.textureTransformComboIndex, root.textureTransformsLookupTable,
899 "texture-transform-lookup");
900 }
901 }
902 }
903
904 template <ClientVersion V>
905 ValidationReport detail::M2<V>::validate() const {
906 ValidationReport report;
907 {
908 const std::size_t mark = report.size();
910 report.prefixFrom(mark, "root");
911 }
912
913 // The bone lookups address whichever list actually supplies the bones: a
914 // skel-based model (Legion+) leaves root.bones empty and keeps them in the
915 // .skel, so only the assembly can resolve this.
916 {
917 const auto* bones = &root.bones;
918 if constexpr (requires { this->skel.boneBlock.bones; })
919 if (root.bones.empty() && !this->skel.boneBlock.bones.empty()) bones = &this->skel.boneBlock.bones;
920 formats::detail::validateOptionalIndexElements(root.boneLookupTable, bones->size(), "root.bone_lookup_table",
921 "the model bones", report);
922 formats::detail::validateOptionalIndexElements(root.keyBoneLookup, bones->size(), "root.key_bone_lookup",
923 "the model bones", report);
924 }
925
926 // the LOD views: external .skin files WotLK+, embedded profiles before
927 if constexpr (requires { this->skins; }) {
928 const auto walk = [&](const auto& views, std::string_view what) {
929 for (std::size_t i = 0; i < views.size() && !report.full(); ++i) {
930 const std::size_t mark = report.size();
931 formats::detail::validateEntity(views[i], report);
932 {
933 // the cross-file findings sit on the same member the entity walk
934 // reports under, so they read "skins[0].profile.vertices" too
935 const std::size_t profileMark = report.size();
936 detail::validateProfileAgainstRoot(views[i].profile, root, report);
937 report.prefixFrom(profileMark, "profile");
938 }
939 report.prefixFrom(mark, std::format("{}[{}]", what, i));
940 }
941 };
942 walk(this->skins, "skins");
943 if constexpr (requires { this->lodSkins; }) // the LOD bands are Legion+
944 walk(this->lodSkins, "lod_skins");
945 }
946 else {
947 for (std::size_t i = 0; i < root.skinProfiles.size() && !report.full(); ++i) {
948 const std::size_t mark = report.size();
949 detail::validateProfileAgainstRoot(root.skinProfiles[i], root, report);
950 report.prefixFrom(mark, std::format("root.skin_profiles[{}]", i));
951 }
952 }
953 return report;
954 }
955
956 template <ClientVersion V>
957 Result<void> detail::M2<V>::ensureValid() const {
958 return validate().toResult();
959 }
960}
961
962// There are NO welded per-range alias tables, extern-template declarations or
963// explicit instantiations here: C++ consumer TUs implicitly instantiate
964// exactly the versions they use (the read/write definitions live in this
965// header and the serializer engines). The language bindings,
966// which weld and expand the FULL version matrix, declare the range alias
967// tables and the instantiation matrix in their own translation units — see
968// bindings/instantiations/m2_ranges.hpp and m2_matrix.inl.
The .bone entity (namespace wowlib::formats::m2::bone), WoD+: a tiny chunked file (with a raw u32 pre...
The Legion+ chunked .m2 shell (namespace wowlib::formats::m2::chunked): M2ChunkedFile — the on-disk c...
void addError(std::string path, std::string message)
Record an error finding (see add()).
The per-sequence external write buffers every M2-family write path shares: an external sequence's dat...
A lazy per-sequence .anim loader handing out resolution windows: for a chunked file (leading AFM2) th...
auto sequenceBase(const std::vector< Sequence > &sequences, std::span< const std::byte > inlineBase, std::uint32_t target)
The per-sequence base resolver every M2-family read path shares: sequence i's inner arrays resolve ag...
static constexpr std::uint32_t Afm2Magic
static std::uint32_t afidLookup(std::span< const chunked::record::AnimFileEntry > entries, SequenceKey key)
The AFID FileDataID for sequence key, 0 when unlisted.
The satellite-file naming conventions around one model path: every companion file of "creature\\x\\x....
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.
Flag-testing convenience for the binary formats' bit-mask enums.
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
The MD20 body entity (namespace wowlib::formats::m2::root): M2Root — the client's own name for the of...
void validateEntity(const E &entity, ValidationReport &report)
Validate a whole entity — the engine behind every validate() method; see ChunkedFile::validate() for ...
void validateOptionalIndexElements(const Values &values, std::size_t targetCount, std::string_view member, std::string_view target, ValidationReport &report)
Report every non-sentinel element of values that is not a valid index into a targetCount-element targ...
constexpr bool isNoIndex(T value)
Whether value is the client's "no reference" sentinel for an index element: negative in a signed look...
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 .bone facial-pose file entity (WoD+).
Definition bone.hpp:17
A shared model skeleton (.skel, 7.3+): the bones, attachments and sequences a skel-based model moved ...
Definition m2.hpp:62
void validateProfileAgainstRoot(const Profile &profile, const Root &root, ValidationReport &report)
Check one skin profile's references INTO the model body — the half of a skin's contracts that needs b...
Definition m2.hpp:799
The MD20 model body (M2Root) with its per-version classes.
Definition bone.hpp:17
GlobalFlags
M2Root::globalFlags bits.
Definition root.hpp:49
constexpr std::uint32_t Md20Magic
The MD20 leading magic, as memcpy'd from disk.
Definition root.hpp:45
The external LOD view entity (.skin file, WotLK+) and the skin profile records (submeshes,...
Definition records.hpp:26
detail::Skin< canonicalVersion(V, M2SkinPivots, M2SkinVersions)> Skin
An external LOD view — the canonicalizing face of detail::Skin: every WotLK+ version maps to its rang...
Definition skin.hpp:67
constexpr std::uint32_t SkinMagic
The .skin leading magic, as memcpy'd from disk.
Definition skin.hpp:22
chunked::M2ChunkedFile< canonicalVersion(V, M2FilePivots, M2ChunkedVersions)> M2ChunkedFile
The chunked .m2 stream — the canonicalizing face of chunked::M2ChunkedFile: every Legion+ version map...
Definition chunked.hpp:189
constexpr std::uint32_t SkinMagic
The .skin leading magic, as memcpy'd from disk.
Definition skin.hpp:22
consteval std::pair< std::uint32_t, std::uint32_t > m2FormatVersionRange(ClientVersion v)
The inclusive MD20 version range a client era's files may carry — reading accepts the whole era (a 2....
constexpr ClientVersion M2ChunkedContainer
legion (7.0.1.20740): the on-disk .m2 becomes a chunked file — the MD20 image moves into the MD21 chu...
root::M2Root< canonicalVersion(V, M2DataPivots, M2Versions)> M2Root
The MD20 body — the canonicalizing face of root::M2Root: every client version maps to its range's fir...
Definition root.hpp:364
constexpr ClientVersion M2PerSequenceTimelines
WotLK (v264): every M2Track nests one timestamp/value array per sequence (the vanilla single timeline...
constexpr std::uint32_t Md20Magic
The MD20 leading magic, as memcpy'd from disk.
Definition root.hpp:45
detail::Skeleton< canonicalVersion(V, M2ChunkPayloadPivots, M2ChunkedVersions)> Skeleton
A shared model skeleton — the canonicalizing face of detail::Skeleton: the whole chunked era is one r...
Definition skeleton.hpp:277
constexpr ClientVersion M2ChunkedOnly
BfA (8.0.1): the chunked container is universal — Legion clients still served leftover raw MD20 model...
detail::M2< canonicalVersion(V, M2AssemblyPivots, M2Versions)> M2
A whole model — the canonicalizing face of detail::M2: every client version maps to its range's first...
Definition m2.hpp:348
constexpr std::array M2AssemblyPivots
The M2 assembly: the union of the body, skin and stream pivots plus the bare-MD20 read gate (M2Chunke...
constexpr std::array M2Versions
The versions M2 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 bool hasFlag(std::underlying_type_t< E > value, E flag)
Whether flag bit flag is set in the raw binary value value.
Definition flags.hpp:26
std::conditional_t<(V.formatLineage() >=Since &&V.formatLineage()< Until), Trait, Absent< Trait > > Slot
A version-gated base: the entity inherits Trait (flattening its chunk members in) iff Since <= V < Un...
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
@ FileNotFound
The file exists nowhere in the overlay or storage.
Definition error.hpp:22
@ 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::vector< std::byte > FileBuffer
Owning byte buffer for file contents read out of a client storage.
Definition buffer.hpp:16
The offset-format storage vocabulary and serializer engine for the M2 family, in one header.
The M2 family's satellite-file vocabulary (namespace wowlib::formats::m2), shared by the fs-level rea...
The .skel skeleton entity (namespace wowlib::formats::m2), 7.3+: a FIRST-CLASS entity with its own fi...
The external .skin file entity (namespace wowlib::formats::m2::skin), WotLK+: the 'SKIN' magic follow...
std::optional< std::string > path
The canonical client-internal path, if known.
Definition file_key.hpp:31
An unparsed chunk payload, preserved verbatim for round-trip.
The serialization face of a chunked entity, mixed in CRTP-style: an entity struct E : ChunkedFile<E> ...
Result< FileBuffer > write() const
Serialize this entity.
Result< void > read(std::span< const std::byte > data)
Deserialize file bytes into this entity, replacing its contents.
The version-agnostic root of every file-level entity (welded as "FileEntity").
One .bone file: which bones get facial-pose offset matrices, and the matrices.
Definition bone.hpp:34
The version-agnostic base of every M2<V> (welded as "M2").
Definition m2.hpp:58
bool operator==(const M2Base &) const =default
Resolution of SequenceData members while reading: where each outer element's nested data lives.
std::function< std::span< const std::byte >(std::size_t i)> sequenceBase
The base span the inner arrays of outer element i resolve their offsets against — the matching ....
A sequence's satellite identity: the (animation id, variation) pair the .anim naming convention ("{st...
One .bone file: which bones get facial-pose offset matrices, and the matrices.
Definition bone.hpp:34
std::vector< Skin< V > > lodSkins
The LOD-band skins (the SFID entries beyond num_skin_profiles), in band order.
Definition m2.hpp:93
std::vector< BoneFile > boneFiles
The .bone facial-pose files (BFID; the skeleton's when skel-based), in variant order.
Definition m2.hpp:103
ChunkBlob phys
The referenced .phys file bytes (PFID), baked in verbatim — structured physics is a follow-up milesto...
Definition m2.hpp:96
bool operator==(const AssemblyLegion &) const =default
legion+ assembly members: the chunk stream and the satellites it references.
Definition m2.hpp:84
bool operator==(const AssemblyLegion &) const =default
bool operator==(const AssemblySkins &) const =default
WotLK+ assembly members: the external .skin LOD views baked in.
Definition m2.hpp:70
bool operator==(const AssemblySkins &) const =default
A whole M2 model for one client version: the MD20 body plus every baked-in satellite.
Definition m2.hpp:124
Result< void > read(fs::FileSystem &fs, const FileKey &key)
Load the model and all its satellite files from a client filesystem, replacing this entity's contents...
Definition m2.hpp:525
Result< void > write(fs::FileSystem &fs, const FileKey &key) const
Serialize and store the model and every satellite file through the filesystem's project overlay; sate...
Definition m2.hpp:565
ValidationReport validate() const
Definition m2.hpp:843
bool operator==(const M2 &) const =default
Result< void > ensureValid() const
Validate and raise on the first error instead of returning a report — the assert-style face of valida...
Definition m2.hpp:895
The conditional-base mechanism that gives a versioned chunked entity exactly the fields its client ve...