Source code for valvemdl.mdl

"""
.. Mdl API
"""

import os

from valvemdl.constants import MDL_VERSIONS, STUDIO_ANIM_MASK, VTX_EXTENSIONS
from valvemdl.exceptions import MdlSidecarMissingError
from valvemdl.loader import (load_animations, load_attachments,
                             load_bodyparts, load_bonecontrollers, load_bones,
                             load_boneflexdrivers, load_cdtextures,
                             load_flexcontrollers, load_flexcontrollerui,
                             load_flexdescs, load_flexrules, load_hitboxsets,
                             load_ikchains, load_includemodels,
                             load_linearbones, load_poseparameters,
                             load_sequences, load_skins,
                             load_srcbonetransforms, load_textures,
                             read_string)
from valvemdl.structs.studiohdr import studiohdr_t, studiohdr2_t
from valvemdl.structs.vvd import mstudiovertex_t
from valvemdl.unloader import rebuild
from valvemdl.vtx import Vtx
from valvemdl.vvd import Vvd


[docs] class Mdl(object): """Contains all the data from an Mdl file. An mdl is small (a couple of kilobytes is typical) and its contents form one interlinked graph rather than a set of independent blocks, so the file is parsed eagerly and in full. Its sidecars are not. A model is really a set of files -- ``.mdl``, ``.vvd``, and one ``.vtx`` per graphics backend -- and most callers only ever want the mdl. Opening an mdl therefore only *detects* the sidecars sitting next to it; they are parsed on first access to :py:attr:`vvd<vvd>` or :py:attr:`vtx<vtx>`, and cached from then on. """
[docs] def __init__(self, path=None): """Creates an empty instance of Mdl. :param path: A path to an existing mdl file. :type path: str, optional """ self.source_path = path #: :type: (str) - The location of the parsed file. self.header = None #: :type: (construct.Container) - The parsed studiohdr_t. #: :type: (construct.Container) - The parsed studiohdr2_t, None when absent. self.header2 = None # the file, verbatim. kept because every offset in the format is # relative to its own structure, and because the writer will need the # original bytes to compare against. self._data = None #: :type: (list[construct.Container]) - The skeleton, in file order. self.bones = [] #: :type: (list[construct.Container]) - The materials the model names. self.textures = [] #: :type: (list[str]) - The material search paths, in engine order. self.cdtextures = [] #: :type: (list[list[int]]) - The skin table, one row per skin family. self.skins = [] # each bodypart carries .models, and each model carries .meshes #: :type: (list[construct.Container]) - The geometry tree. self.bodyparts = [] # each set carries .hitboxes #: :type: (list[construct.Container]) - The hitbox sets. self.hitboxsets = [] #: :type: (list[construct.Container]) - The attachment points. self.attachments = [] #: :type: (list[construct.Container]) - The pose parameters. self.poseparameters = [] #: :type: (list[construct.Container]) - The $includemodel groups. self.includemodels = [] # each carries .movements, .sections and .ikrules #: :type: (list[construct.Container]) - The animations, local to this file. self.animations = [] # each carries .events, .autolayers, .iklocks and .animindices #: :type: (list[construct.Container]) - The sequences, local to this file. self.sequences = [] # each carries .links #: :type: (list[construct.Container]) - The ik chains. self.ikchains = [] #: :type: (list[construct.Container]) - The bone controllers. self.bonecontrollers = [] #: :type: (list[construct.Container]) - The flex targets. self.flexdescs = [] #: :type: (list[construct.Container]) - The flex controllers. self.flexcontrollers = [] # each carries .ops #: :type: (list[construct.Container]) - The flex rules. self.flexrules = [] #: :type: (list[construct.Container]) - The flex controller ui groups. self.flexcontrollerui = [] #: :type: (construct.Container) - studiohdr2's linear bone table, or None. self.linearbones = None #: :type: (list[construct.Container]) - The source bone transforms. self.srcbonetransforms = [] # each carries .controls #: :type: (list[construct.Container]) - The bone flex drivers. self.boneflexdrivers = [] self._vvd = None self._vtx = {} self._header2_truncated = False #: :type: (dict[str, str]) - Sidecars found next to the mdl, by extension. self.sidecar_paths = {} if self.source_path: with open(self.source_path, 'rb') as f: self._data = f.read() self.header = studiohdr_t.parse(self._data) # a truncated file can point studiohdr2index past its own end. # don't crash on it: a broken model is exactly the one somebody is # trying to look at, and validate() has more useful things to say # about it than a StreamError from 250 bytes deep would. index = self.header.studiohdr2index if index and index + studiohdr2_t.sizeof() <= len(self._data): self.header2 = studiohdr2_t.parse(self._data[index:]) elif index: self._header2_truncated = True self.bones = load_bones(self, self._data) self.textures = load_textures(self, self._data) self.cdtextures = load_cdtextures(self, self._data) self.skins = load_skins(self, self._data) self.bodyparts = load_bodyparts(self, self._data) self.hitboxsets = load_hitboxsets(self, self._data) self.attachments = load_attachments(self, self._data) self.poseparameters = load_poseparameters(self, self._data) self.includemodels = load_includemodels(self, self._data) self.bonecontrollers = load_bonecontrollers(self, self._data) self.flexdescs = load_flexdescs(self, self._data) self.flexcontrollers = load_flexcontrollers(self, self._data) self.flexrules = load_flexrules(self, self._data) self.flexcontrollerui = load_flexcontrollerui(self, self._data) self.linearbones = load_linearbones(self, self._data) self.srcbonetransforms = load_srcbonetransforms(self, self._data) self.boneflexdrivers = load_boneflexdrivers(self, self._data) self.animations = load_animations(self, self._data) self.sequences = load_sequences(self, self._data) self.ikchains = load_ikchains(self, self._data) self._find_sidecars()
@property def version(self): """Team Fortress 2 ships 44 through 48. :ref:`See the constants page<constants>`. :type: (int) - The mdl version. """ return self.header.version @property def checksum(self): """The engine refuses to load a set whose checksums disagree. :type: (int) - Ties the mdl to its vvd and vtx files. """ return self.header.checksum @property def name(self): """The model's own idea of its path, as baked in at compile time. ``studiohdr_t.name`` is a fixed 64 byte buffer, so studiomdl spills longer paths into ``studiohdr2_t.sznameindex`` instead. This resolves whichever one is live, the same way the engine's ``pszName()`` does. :type: (str) - The model's path. """ if self.header2 is not None and self.header2.sznameindex: # sznameindex is relative to the studiohdr2_t, not to the file return read_string(self._data, self.header.studiohdr2index + self.header2.sznameindex) return self.header.name @property def surfaceprop(self): """The model-wide surface property, named in the qc by ``$surfaceprop``. Individual bones carry their own, which is what the engine actually uses when it needs to know what a given bone is made of. :type: (str) - A surfaceprop name, e.g. ``'metal'``. """ return read_string(self._data, self.header.surfacepropindex) @property def models(self): """Every model in the file, flattened out of the bodypart tree. Note that these are *alternatives* -- a model here is one option inside one bodypart, not one model in the everyday sense. Only one per bodypart is ever drawn at a time. :type: (list[construct.Container]) - All the models. """ return [model for bodypart in self.bodyparts for model in bodypart.models] @property def meshes(self): """Every mesh in the file, flattened out of the bodypart tree. :type: (list[construct.Container]) - All the meshes. """ return [mesh for model in self.models for mesh in model.meshes]
[docs] def bodygroup(self, bodynum): """Decodes a packed bodygroup int into one model per bodypart. studiomdl packs the whole choice of alternatives into a single integer, which entities carry as ``m_nBody``. Each bodypart pulls its own choice back out with its ``base`` as the stride. :param bodynum: The packed value. :type bodynum: int :returns: The chosen model for each bodypart, in bodypart order. :rtype: list[construct.Container] """ chosen = [] for bodypart in self.bodyparts: if not bodypart.nummodels or not bodypart.base: continue index = (bodynum // bodypart.base) % bodypart.nummodels chosen.append(bodypart.models[index]) return chosen
[docs] def save(self, destination=None): """Writes the model back out. The bytes are rebuilt from the object graph rather than copied from the file, so what lands on disk reflects the model as it now is. :param destination: Where to write. Overwrites the original when not given. :type destination: str, optional """ with open(destination or self.source_path, 'wb') as f: f.write(bytes(rebuild(self).data))
[docs] def coverage(self): """Reports how much of the file |proj_name| can account for. Rebuilding into an empty buffer means every byte has to be put there by something; anything left is something we have not understood. This reports what happened, which is more honest than a bare "it worked": - ``struct`` -- bytes regenerated from parsed structures. - ``opaque`` -- bytes copied at an extent we worked out ourselves, the compressed animation streams above all. - ``unclaimed`` -- bytes nothing wrote. Harmless where the original is zero (alignment padding, which the rebuild gets for free) and a real gap where it is not. :returns: Byte counts, plus ``differ``: how many bytes the rebuild got wrong. Zero means the round trip is byte-exact. :rtype: dict """ out = rebuild(self) report = out.report() report['differ'] = sum(1 for a, b in zip(out.data, self._data) if a != b) return report
[docs] def skin_texture(self, skin, material): """Resolves a mesh's material through the skin table. A mesh does not name its texture directly. ``mstudiomesh_t.material`` is a column in the skin table, and the row is whichever skin the entity happens to be using -- which is how one model ships red and blu variants without duplicating any geometry. :param skin: The skin family, 0 being the default. :type skin: int :param material: A mesh's material field. :type material: int :returns: The texture, or None when the model has no skin table to resolve through. :rtype: construct.Container """ if not self.skins or skin >= len(self.skins): return None row = self.skins[skin] if material >= len(row): return None index = row[material] if index < 0 or index >= len(self.textures): return None return self.textures[index]
def _find_sidecars(self): # a model is a set of files; note which of them are actually here base = os.path.splitext(self.source_path)[0] for ext in ['.vvd'] + VTX_EXTENSIONS: candidate = base + ext if os.path.isfile(candidate): self.sidecar_paths[ext] = candidate @property def vvd(self): """The model's vertex file, parsed on first access. :type: (Vvd) - The vertex file. :raises MdlSidecarMissingError: when there is no vvd next to the mdl. """ if self._vvd is None: if '.vvd' not in self.sidecar_paths: raise MdlSidecarMissingError( os.path.splitext(self.source_path)[0] + '.vvd') self._vvd = Vvd(self.sidecar_paths['.vvd']) return self._vvd @property def vtx(self): """Defaults to the ``dx90`` flavour; use :py:meth:`get_vtx` for the others. :type: (Vtx) - The model's optimized mesh file, parsed on first access. """ return self.get_vtx()
[docs] def get_vtx(self, extension='.dx90.vtx'): """Provides one of the model's vtx files, parsing it on first access. studiomdl emits a vtx per graphics backend. They describe the same meshes optimized differently, so they are not interchangeable. :param extension: Which flavour to load. :ref:`See the constants page<constants>` for the available values. :type extension: str, optional :returns: The parsed vtx. :rtype: Vtx :raises MdlSidecarMissingError: when that flavour is not next to the mdl. """ if extension not in self._vtx: if extension not in self.sidecar_paths: raise MdlSidecarMissingError( os.path.splitext(self.source_path)[0] + extension) self._vtx[extension] = Vtx(self.sidecar_paths[extension]) return self._vtx[extension]
[docs] def validate(self): """Checks the invariants the format promises, and returns what is broken. This is deliberately a report rather than an exception: a model that fails one of these is still worth looking at, and the failures themselves are interesting. :returns: One human readable string per broken invariant. Empty when the model is sound. :rtype: list[str] """ problems = [] if self.version not in MDL_VERSIONS: problems.append('unknown version {0}'.format(self.version)) actual = os.path.getsize(self.source_path) if self.header.length != actual: problems.append( 'header length ({0}) != file size ({1})'.format( self.header.length, actual)) if self._header2_truncated: problems.append( 'studiohdr2index ({0}) + 256 runs past the end of the file ' '({1})'.format(self.header.studiohdr2index, actual)) if len(self.bones) != self.header.numbones: problems.append( 'read {0} of {1} bones; the array runs past the end of the ' 'file'.format(len(self.bones), self.header.numbones)) if len(self.textures) != self.header.numtextures: problems.append( 'read {0} of {1} textures; the array runs past the end of the ' 'file'.format(len(self.textures), self.header.numtextures)) for family, row in enumerate(self.skins): if len(row) != self.header.numskinref: problems.append( 'skin family {0} is {1} wide, expected {2}'.format( family, len(row), self.header.numskinref)) for i, index in enumerate(row): if index < 0 or index >= len(self.textures): problems.append( 'skin family {0} slot {1} names texture {2}, of ' '{3}'.format(family, i, index, len(self.textures))) if len(self.bodyparts) != self.header.numbodyparts: problems.append( 'read {0} of {1} bodyparts; the array runs past the end of ' 'the file'.format(len(self.bodyparts), self.header.numbodyparts)) for bodypart in self.bodyparts: if len(bodypart.models) != bodypart.nummodels: problems.append( 'bodypart {0} declares {1} models but only {2} fit in the ' 'file'.format(bodypart.name, bodypart.nummodels, len(bodypart.models))) for model in bodypart.models: # vertexindex is a byte offset into the vvd, so it has to be a # whole number of vertices. a non-multiple means we, or the # file, are wrong about what the field means. if model.vertexindex % mstudiovertex_t.sizeof(): problems.append( 'model {0} has a vertexindex ({1}) that is not a ' 'multiple of sizeof(mstudiovertex_t)'.format( model.name, model.vertexindex)) if len(model.meshes) != model.nummeshes: problems.append( 'model {0} declares {1} meshes but only {2} fit in ' 'the file'.format(model.name, model.nummeshes, len(model.meshes))) for mesh in model.meshes: if mesh.modelindex > 0: problems.append( 'mesh {0} of model {1} has a modelindex ({2}) ' 'that does not point backwards'.format( mesh.meshid, model.name, mesh.modelindex)) if mesh.vertexoffset + mesh.numvertices > model.numvertices: problems.append( 'mesh {0} of model {1} runs past the end of its ' 'model vertices'.format(mesh.meshid, model.name)) for i, bone in enumerate(self.bones): if bone.parent < -1 or bone.parent >= len(self.bones): problems.append( 'bone {0} ({1}) has an out of range parent ({2})'.format( i, bone.name, bone.parent)) elif bone.parent >= i: # the engine builds world transforms in a single forward pass, # which only works because studiomdl emits parents first problems.append( 'bone {0} ({1}) has a parent ({2}) that does not precede ' 'it'.format(i, bone.name, bone.parent)) for ext in sorted(self.sidecar_paths): sidecar = self.vvd if ext == '.vvd' else self.get_vtx(ext) if sidecar.checksum != self.checksum: problems.append( '{0} checksum ({1}) != mdl checksum ({2})'.format( ext, sidecar.checksum, self.checksum)) problems.extend(self._validate_bone_references()) problems.extend(self._validate_flexes()) problems.extend(self._validate_animations()) problems.extend(self._validate_against_vvd()) return problems
def _validate_flexes(self): problems = [] for i, rule in enumerate(self.flexrules): if rule.flex < 0 or rule.flex >= len(self.flexdescs): problems.append( 'flex rule {0} targets flexdesc {1}, of {2}'.format( i, rule.flex, len(self.flexdescs))) if len(rule.ops) != rule.numops: problems.append( 'flex rule {0} declares {1} ops but only {2} fit in the ' 'file'.format(i, rule.numops, len(rule.ops))) for mesh in self.meshes: for flex in mesh.flexes: if flex.flexdesc < 0 or flex.flexdesc >= len(self.flexdescs): problems.append( 'a flex targets flexdesc {0}, of {1}'.format( flex.flexdesc, len(self.flexdescs))) if len(flex.vertanims) != flex.numverts: problems.append( 'a flex declares {0} vertanims but only {1} fit in ' 'the file'.format(flex.numverts, len(flex.vertanims))) for model in self.models: for eyeball in model.eyeballs: if eyeball.bone < 0 or eyeball.bone >= len(self.bones): problems.append( 'eyeball {0} names bone {1}, of {2}'.format( eyeball.name, eyeball.bone, len(self.bones))) if self.linearbones is not None: if self.linearbones.numbones != len(self.bones): problems.append( 'the linear bone table has {0} bones but the model has ' '{1}'.format(self.linearbones.numbones, len(self.bones))) else: # the table is a transposed copy of the bones, so it has to # agree with them. a writer that updates one and not the other # gets a model that animates from one and draws from the other. for i, parent in enumerate(self.linearbones.parent): if parent != self.bones[i].parent: problems.append( 'the linear bone table gives bone {0} parent {1}, ' 'but the bone says {2}'.format( i, parent, self.bones[i].parent)) break for driver in self.boneflexdrivers: if driver.m_nBoneIndex < 0 or \ driver.m_nBoneIndex >= len(self.bones): problems.append( 'a bone flex driver names bone {0}, of {1}'.format( driver.m_nBoneIndex, len(self.bones))) for control in driver.controls: if control.m_nFlexControllerIndex < 0 or \ control.m_nFlexControllerIndex >= \ len(self.flexcontrollers): problems.append( 'a bone flex driver control names flex controller ' '{0}, of {1}'.format(control.m_nFlexControllerIndex, len(self.flexcontrollers))) return problems def _validate_anim_nodes(self, anim): """Byte accounting over one animation's node stream. This is the check that matters for :any:`mstudioanim_t`, because it is the one thing that cannot be fooled. The nodes are variable length, so the only way to know we have understood a node is to measure what it occupies and see whether the next one starts exactly there. If our idea of a node's extent is wrong by a single byte, ``nextoffset`` disagrees and we hear about it -- unlike a decoder, which would happily produce plausible nonsense. Note that ``studiobyteswap.cpp`` describes ``mstudioanim_t`` as 4 bytes and stops, so there is no datadesc to check this against. This stands in for it. """ problems = [] for i, node in enumerate(anim.nodes): if node.extent is None: problems.append( 'animation {0}: node {1} (bone {2}, flags 0x{3:02x}) could ' 'not be walked'.format(anim.name, i, node.bone, node.flags)) continue if node.terminator: # bone 255 ends the list; it names no bone and carries no # payload, so there is nothing else to check about it continue if node.flags & ~STUDIO_ANIM_MASK: problems.append( 'animation {0}: node {1} has unknown flag bits ' '0x{2:02x}'.format(anim.name, i, node.flags & ~STUDIO_ANIM_MASK)) if node.bone >= len(self.bones): problems.append( 'animation {0}: node {1} names bone {2}, of {3}'.format( anim.name, i, node.bone, len(self.bones))) # the accounting itself: a node followed by another must occupy # exactly the gap between them if node.nextoffset and node.nextoffset != node.extent: problems.append( 'animation {0}: node {1} (bone {2}, flags 0x{3:02x}) spans ' '{4} bytes but the next node is {5} away'.format( anim.name, i, node.bone, node.flags, node.extent, node.nextoffset)) return problems def _validate_animations(self): problems = [] if len(self.animations) != self.header.numlocalanim: problems.append( 'read {0} of {1} animations; the array runs past the end of ' 'the file'.format(len(self.animations), self.header.numlocalanim)) if len(self.sequences) != self.header.numlocalseq: problems.append( 'read {0} of {1} sequences; the array runs past the end of ' 'the file'.format(len(self.sequences), self.header.numlocalseq)) for anim in self.animations: # baseptr walks back to the studiohdr_t, which is at 0, so it is # exactly the negation of the animdesc's own position if anim._offset + anim.baseptr != 0: problems.append( 'animation {0} has a baseptr ({1}) that does not resolve ' 'to the studiohdr_t'.format(anim.name, anim.baseptr)) if anim.animblock < 0: problems.append( 'animation {0} has animblock {1}; the model needs ' 'recompiling'.format(anim.name, anim.animblock)) for rule in anim.ikrules: if rule.chain < 0 or rule.chain >= len(self.ikchains): problems.append( 'animation {0} has an ik rule naming chain {1}, of ' '{2}'.format(anim.name, rule.chain, len(self.ikchains))) problems.extend(self._validate_anim_nodes(anim)) for seq in self.sequences: if seq._offset + seq.baseptr != 0: problems.append( 'sequence {0} has a baseptr ({1}) that does not resolve ' 'to the studiohdr_t'.format(seq.label, seq.baseptr)) for row in seq.animindices: for index in row: if index < 0 or index >= len(self.animations): problems.append( 'sequence {0} blends animation {1}, of {2}'.format( seq.label, index, len(self.animations))) # paramindex is only ours to check when two things hold. -1 means # explicitly unused, and a groupsize of 0 means the axis does not # exist, in which case studiomdl leaves paramindex as 0 rather than # -1 and the value means nothing. And a model with $includemodel # indexes the *merged* pose parameter list, which we do not build: # the nine c_*_arms viewmodels are exactly this case. if self.includemodels: continue for i in range(2): param = seq.paramindex[i] if param == -1 or not seq.groupsize[i]: continue if param < 0 or param >= len(self.poseparameters): problems.append( 'sequence {0} names pose parameter {1}, of ' '{2}'.format(seq.label, param, len(self.poseparameters))) for chain in self.ikchains: for link in chain.links: if link.bone < 0 or link.bone >= len(self.bones): problems.append( 'ik chain {0} has a link naming bone {1}, of ' '{2}'.format(chain.name, link.bone, len(self.bones))) return problems def _validate_bone_references(self): # hitboxes, attachments and mouths all name a bone by index, and a # stale one is the kind of damage a decompile-recompile leaves behind problems = [] count = len(self.bones) for hitboxset in self.hitboxsets: for i, hitbox in enumerate(hitboxset.hitboxes): if hitbox.bone < 0 or hitbox.bone >= count: problems.append( 'hitbox {0} of set {1} names bone {2}, of {3}'.format( i, hitboxset.name, hitbox.bone, count)) for attachment in self.attachments: if attachment.localbone < 0 or attachment.localbone >= count: problems.append( 'attachment {0} names bone {1}, of {2}'.format( attachment.name, attachment.localbone, count)) return problems def _validate_against_vvd(self): # the mdl and the vvd describe the same vertices from two sides, and # they have to agree: every mesh says how many vertices it keeps at # each lod, and the vvd says the totals. this is the check that would # catch us misreading vertexindex as a count rather than a byte offset. if '.vvd' not in self.sidecar_paths or not self.bodyparts: return [] problems = [] header = self.vvd.header meshes = self.meshes for lod in range(min(header.numLODs, len(header.numLODVertexes))): ours = sum(mesh.vertexdata.numLODVertexes[lod] for mesh in meshes) theirs = header.numLODVertexes[lod] if ours != theirs: problems.append( 'meshes hold {0} vertices at lod {1} but the vvd says ' '{2}'.format(ours, lod, theirs)) for model in self.models: ours = sum(mesh.numvertices for mesh in model.meshes) if ours != model.numvertices: problems.append( 'model {0} says {1} vertices but its meshes hold ' '{2}'.format(model.name, model.numvertices, ours)) return problems
[docs] def __repr__(self): return "<{0}.{1} v{2} '{3}' at {4}>".format(type(self).__module__, type(self).__name__, self.version, self.name, hex(id(self)))