Skip to content

Models (M2)

An M2 is WoW's animated model format — characters, creatures, doodads, spell effects. Unlike the chunked formats, an M2 root is an offset file: one blob whose structures point at each other by byte offset. And unlike a WMO, a modern model is spread across external companion files — .skin render profiles from Wrath on, and the Legion+ split into .skel skeletons, .anim streams, .bone files and the chunked .m2 wrapper.

wowlib hides all of that behind one compound entity: M2<version> reads the root and pulls every companion its era uses; writing bakes the offsets back and splits the companions out again. Because satellites are fetched during the read, the compound entity always reads through the filesystem gateway (standalone pieces like a Skin or Skeleton also read from plain bytes).

Reading a model

#include <wowlib/formats/m2/m2.hpp>

wowlib::formats::m2::M2<wowlib::versions::Wotlk> model;
if (auto r = model.read(fs, wowlib::FileKey{
      "Creature/Murloc/Murloc.m2"}); !r)
  return report(r.error());
import wowlib
from wowlib.formats import m2

model = m2.M2.for_version(wowlib.Expansion.Wotlk)
model.read(fs, wowlib.FileKey("Creature/Murloc/Murloc.m2"))
using WoWLib;

using var model = WoWLib.Formats.M2.M2.Era.Wotlk();   // or M2.ForVersion(era)
model.Read(fs, new FileKey("Creature/Murloc/Murloc.m2"));

Walking the data

The root exposes the model tables; the render profiles (skins) hang off the compound. Geometry and track keyframe vectors are live views — numeric ones zero-copy.

const auto& root = model.root;
use(root.name, root.vertices.size());
for (const auto& sequence : root.sequences)
  use(sequence.duration);
for (const auto& skin : model.skins)
  use(skin);                     // batches, submeshes, index lists
root = model.root
print(root.name, len(root.vertices))
for sequence in root.sequences:
    print(sequence.duration)
for skin in model.skins:         # WotLK+: external .skin profiles
    ...
var root = model.Root;
Console.WriteLine($"{root.Name}: {root.Vertices.Count} vertices");
foreach (var sequence in root.Sequences)
    Use(sequence.Duration);
foreach (var skin in model.Skins)       // WotLK+: external .skin profiles
    Use(skin);

Animation data lives in tracks (M2Track<T>): per-sequence timestamp and value arrays, typed by what they animate. The M2 records page documents every family with per-era layout badges.

Writing and converting

write(fs, key) re-bakes the offset engine and emits the era's own file set — inline data for a vanilla model, root + .skin files for Wrath, the full chunked split for Legion+. convert() re-targets a model between eras and restructures the companions accordingly:

legion_model = model.convert(wowlib.Expansion.Legion)
legion_model.write(fs, wowlib.FileKey("Creature/Murloc/Murloc.m2"))

Semantic round-trip

An offset file has no single canonical byte layout, so M2 (like ADT) guarantees a semantic round-trip — write → re-read → equal values — rather than WMO/WDT's byte-identical one. validate() before writing catches inconsistent cross-references; see Validating before writing.

Animation tracks are two layouts, one surface

Track storage changed at WotLK: pre-WotLK tracks share one global timeline, sliced per sequence by interpolation ranges; WotLK+ tracks nest one timestamp/value array per sequence (external sequences keep theirs in the .anim file). The raw members reflect whichever layout the entity is — but every track also carries the canonical timeline surface, so version-agnostic code never branches:

  • timeline_count() — one timeline per sequence (or a single timeline for a global-sequence-driven track);
  • key_count(timeline), timeline_timestamps(timeline), timeline_values(timeline) — the slice pre-WotLK, the nested array WotLK+, returned as copies; out-of-range timelines error.
template <wowlib::ClientVersion V>
void dump_keys(const wowlib::formats::m2::root::record::M2Track<
               wowlib::formats::C3Vector, V>& track)
{
  for (std::size_t s = 0; s < track.timelineCount(); ++s)
    if (auto values = track.timelineValues(s))
      use(*values);
}
def dump_keys(track):          # any era's track, duck-typed
    for s in range(track.timeline_count()):
        use(track.timeline_timestamps(s), track.timeline_values(s))
// Hoisted onto the family base: no pattern matching, either era.
static void DumpKeys(Formats.M2.Root.Record.M2TrackC3Vector track)
{
    for (ulong s = 0; s < track.TimelineCount(); s++)
        Use(track.TimelineTimestamps(s), track.TimelineValues(s));
}

In C#, the timing half of that surface is also a real interface — IAnimTimeline (TimelineCount/KeyCount/TimelineTimestamps), implemented by every track family base — so sequencers and event schedulers hold one interface across all nine value families and the event tracks:

static ulong TotalKeys(IEnumerable<Formats.M2.Root.Record.IAnimTimeline> tracks)
    => tracks.Aggregate(0UL, (n, t) => {
           for (ulong s = 0; s < t.TimelineCount(); s++) n += t.KeyCount(s);
           return n;
       });

The raw members stay available on the concrete classes for zero-copy work — the accessors return copies, which is the right trade for correctness-first code; renderers hot-looping keys can still branch once on the two range classes and use spans.