Animations

studiohdr_t.localanimindex reaches an array of mstudioanimdesc_t, one per animation. Every model in Team Fortress 2 has at least one, static props included – there are 16021 of them across the game’s 9858 models.

animdescs are not sequences. An animdesc is raw animation data; a mstudioseqdesc_t is what the game plays, and it may blend several animdescs together. The local in numlocalanim matters: a model using $includemodel borrows animations from other files, and the engine merges the lists at runtime. ValveMDL reads the local set, which is what is actually in the file.

Where the data is. animblock selects it, and the SDK’s pAnimBlock spells out all three cases:

if (block == -1) return NULL;                                      // broken
if (block == 0)  return (mstudioanim_t *)(((byte *)this) + index);  // local
...                                                                // .ani

animblock == 0 means the nodes sit in the mdl at animindex, relative to the animdesc. Non-zero means they live in the external .ani named by studiohdr_t.szanimblocknameindex, at animindex from that block’s datastart.

In practice every animdesc in Team Fortress 2 is local: all 16021 of them have animblock == 0. Five models even ship a populated animblock table and a real multi-megabyte .ani beside them – boss_tank_part1_destruction.mdl has 150 blocks and a 4.3 MB sidecar – and still keep every animdesc local. Why those files exist is an open question; nothing appears to read them.

Zeroframe data is dead. zeroframeindex is a low resolution cache for stalled async loads. Not one animdesc in the game uses it. The v47 rule that it must be zero, and the v48 permission to use it, are both moot here.

Sections. When sectionframes is non-zero the frames are chunked, and sectionindex reaches an array of mstudioanimsections_t, each naming its own block and index. 2.34% of animdescs use this, headless_hatman among them. The section count is (numframes / sectionframes) + 2, not + 1: long animations get an extra section holding just the last frame. Sectioned animations written by v45 are declared incompatible by Studio_ConvertStudioHdrToNewVersion.

The compressed node stream. mstudioanim_t is the hardest thing in the format, and the only structure here whose real size is not the size of its declaration.

It is a linked list, not an array. Each node is 4 bytes of header followed by a variable length payload, and nextoffset – relative to the node – is the only way to find the next one. Zero ends the list. You cannot index it, and you cannot skip a node without decoding enough of it to know its length.

The payload starts after the header, and its layout is decided by ``flags``. Note that pData() returns this + sizeof(mstudioanim_t), i.e. +4, so the payload sits after nextoffset rather than between it and the flags:

Flag

Value

Bytes

Payload

STUDIO_ANIM_RAWROT

0x02

6

a Quaternion48

STUDIO_ANIM_RAWROT2

0x20

8

a Quaternion64

STUDIO_ANIM_RAWPOS

0x01

6

a Vector48

STUDIO_ANIM_ANIMROT

0x08

6

an mstudioanim_valueptr_t

STUDIO_ANIM_ANIMPOS

0x04

6

an mstudioanim_valueptr_t

STUDIO_ANIM_DELTA

0x10

0

none; the values are additive instead

Rotation always precedes position, and the SDK computes the position’s offset with boolean arithmetic rather than a branch:

inline Vector48 *pPos( void ) const {
    return (Vector48 *)(pData()
        + ((flags & STUDIO_ANIM_RAWROT)  != 0) * sizeof( *pQuat48() )
        + ((flags & STUDIO_ANIM_RAWROT2) != 0) * sizeof( *pQuat64() ) ); };

Likewise pPosV() is pRotV() + ((flags & STUDIO_ANIM_ANIMROT) != 0). The raw and animated forms can be mixed freely – a bone whose rotation is animated but whose position never moves is the common case.

Only 12 flag combinations occur across the whole of Team Fortress 2, and no node uses a bit outside the six defined ones. RAWPOS|RAWROT2 is the most common by far, then bare nodes with no payload at all, then RAWROT2, then ANIMPOS|ANIMROT.

The animated forms point at run length encoded streams. An mstudioanim_valueptr_t holds one offset per axis, each reaching a chain of mstudioanimvalue_t – a two byte union that is either a {valid, total} run header or a short value, depending on where you are in the stream. Each run is one header followed by valid values; the run then holds its last value for total - valid further frames. Seeking to frame k means walking runs and subtracting total until you land inside one.

A node ends where the next one begins. Nodes are packed end to end, streams included, so a node’s true extent is exactly its nextoffset. That is a strong check and ValveMDL uses it as one: every one of the 295057 animation nodes in Team Fortress 2’s 9858 models is measured and lands exactly on the byte the next node starts at. See Mdl.validate().

bone 255 ends the list. bone_setup.cpp walks nodes with while (panim && panim->bone < 255), so a node naming bone 255 is a terminator rather than a bone, and carries no payload whatever its flags say. An animation that moves nothing at all is written as exactly one of these: ff 00 00 00. There are 3293 of them in the game.

The scale is not here. Values are multiplied by the bone’s posscale or rotscale – see mstudiobone_t. An animation read without its skeleton is meaningless numbers.

struct valvemdl.structs.anim.mstudioanim_t
bone(int)<parsed from unsigned char>

which bone this node animates

flags(int)<parsed from unsigned char>

a combination of the STUDIO_ANIM_* flags; decides what follows the header

nextoffset(int)<parsed from short>

offset to the next node, relative to this one; 0 ends the list

struct valvemdl.structs.anim.mstudioanim_valueptr_t
offset(int[3])<parsed from short>

one offset per axis to an mstudioanimvalue_t stream, relative to this structure; must be > 0 to be valid

struct valvemdl.structs.anim.mstudiomovement_t
endframe(int)<parsed from long>
motionflags(int)<parsed from long>
v0(float)<parsed from float>

velocity at the start of the block

v1(float)<parsed from float>

velocity at the end of the block

angle(float)<parsed from float>

yaw rotation at the end of this block

vector(Vector)

movement vector, relative to this block initial angle

position(Vector)
struct valvemdl.structs.anim.mstudioanimsections_t
animblock(int)<parsed from long>
animindex(int)<parsed from long>
struct valvemdl.structs.anim.mstudioanimdesc_t
baseptr(int)<parsed from long>

NEGATIVE offset back to the studiohdr_t

sznameindex(int)<parsed from long>

offset to a string

fps(float)<parsed from float>

frames per second

flags(int)<parsed from long>

looping and delta flags; the STUDIO_LOOPING family

numframes(int)<parsed from long>
nummovements(int)<parsed from long>

piecewise movement

movementindex(int)<parsed from long>

offset to an array of mstudiomovement_t

unused1(int[6])<parsed from long>
animblock(int)<parsed from long>

0 means the nodes are here in the mdl; >0 names a block in the external .ani; -1 means the model needs recompiling

animindex(int)<parsed from long>

offset to the first mstudioanim_t; non-zero when the data is not in sections

numikrules(int)<parsed from long>
ikruleindex(int)<parsed from long>

offset to an array of mstudioikrule_t; non-zero when the ik data is in the mdl

animblockikruleindex(int)<parsed from long>

offset to the ik data when it lives in the .ani instead

numlocalhierarchy(int)<parsed from long>
localhierarchyindex(int)<parsed from long>

offset to an array of mstudiolocalhierarchy_t

sectionindex(int)<parsed from long>

offset to an array of mstudioanimsections_t

sectionframes(int)<parsed from long>

frames per section, 0 when the animation is not sectioned

zeroframespan(int)<parsed from short>

frames per span

zeroframecount(int)<parsed from short>

number of spans

zeroframeindex(int)<parsed from long>

offset to the zeroframe cache; 0 means none, which is the case for every animation in tf2

zeroframestalltime(float)<parsed from float>

runtime scratch; written during read stalls