"""
.. Vvd API
"""
from construct import Array
from valvemdl.structs.common import Vector4D
from valvemdl.structs.vvd import (mstudiovertex_t, vertexFileFixup_t,
vertexFileHeader_t)
[docs]
class Vvd(object):
"""Contains the data from a vvd file.
An mdl carries no vertex positions; they all live here, in one flat pool
shared by every mesh in the model. The header is parsed when the file is
opened; the pools are large, so they are parsed on first access and cached.
**The pools are stored exactly as they sit on disk**, which for a model with
fixups means LOD-sorted rather than mesh-sorted. :py:meth:`mesh_vertices`
replays the fixups to hand back the mesh order that
``mstudiomodel_t.vertexindex`` expects. Keeping the raw order is what lets a
vvd be written back byte for byte.
"""
[docs]
def __init__(self, path=None):
"""Creates an empty instance of Vvd.
:param path: A path to an existing vvd 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 vertexFileHeader_t.
# the file verbatim, kept for the pools and for writing back
self._data = None
self._vertices = None
self._tangents = None
self._fixups = None
if self.source_path:
with open(self.source_path, 'rb') as f:
self._data = f.read()
self.header = vertexFileHeader_t.parse(self._data)
@property
def checksum(self):
""":type: (int) - Ties this vvd to its mdl and vtx."""
return self.header.checksum
@property
def vertices(self):
"""The vertex pool, in the order it is stored.
That order is LOD-sorted when :py:attr:`header` ``.numFixups`` is
non-zero. Use :py:meth:`mesh_vertices` for the order a mesh indexes.
:type: (list[construct.Container]) - ``numLODVertexes[0]`` vertices.
"""
if self._vertices is None:
self._vertices = self._read_pool(
mstudiovertex_t, self.header.vertexDataStart)
return self._vertices
@property
def tangents(self):
"""The tangent pool, parallel to :py:attr:`vertices`.
Tangents are kept in their own pool rather than inside the vertex,
which is what holds :any:`mstudiovertex_t` at 48 bytes. Empty when
``tangentDataStart`` is 0.
:type: (list[construct.Container]) - One ``Vector4D`` per vertex.
"""
if self._tangents is None:
if not self.header.tangentDataStart:
self._tangents = []
else:
self._tangents = self._read_pool(
Vector4D, self.header.tangentDataStart)
return self._tangents
@property
def fixups(self):
"""The fixup table.
Empty unless the pool is LOD-sorted. Each entry names a run of the pool
and the LOD it belongs to; replaying them rebuilds mesh order.
:type: (list[construct.Container]) - ``numFixups`` fixups.
"""
if self._fixups is None:
if not self.header.numFixups:
self._fixups = []
else:
start = self.header.fixupTableStart
size = vertexFileFixup_t.sizeof()
raw = self._data[start:start + self.header.numFixups * size]
self._fixups = list(
Array(self.header.numFixups, vertexFileFixup_t).parse(raw))
return self._fixups
def _read_pool(self, struct, start):
# the pool always holds numLODVertexes[0] entries -- the top lod count,
# which is the largest, per Studio_LoadVertexes
count = self.header.numLODVertexes[0]
size = struct.sizeof()
# clamp to what the file actually holds, so a truncated pool reports
# short rather than raising from inside construct
available = max(0, (len(self._data) - start) // size)
count = min(count, available)
raw = self._data[start:start + count * size]
return list(Array(count, struct).parse(raw))
[docs]
def mesh_vertices(self, root_lod=0):
"""The vertices in mesh order, for a given root LOD.
When the pool is LOD-sorted (``numFixups`` non-zero), a mesh's
``vertexoffset`` indexes an order that only exists once the fixup table
has been replayed. This does that, following ``Studio_LoadVertexes``:
each fixup at or below the requested LOD copies a run of the stored pool
into the next free slot, and the runs land consecutively.
With no fixups the pool is already in mesh order and this returns it
unchanged.
:param root_lod: The most detailed LOD to include; 0 is full detail.
:type root_lod: int, optional
:returns: The vertices a mesh can index into.
:rtype: list[construct.Container]
"""
return self._replay(self.vertices, root_lod)
[docs]
def mesh_tangents(self, root_lod=0):
"""The tangents in mesh order, for a given root LOD.
Tangents run parallel to vertices, so they take the identical fixup
replay -- reordering one without the other would pair each vertex with
the wrong tangent.
:param root_lod: The most detailed LOD to include; 0 is full detail.
:type root_lod: int, optional
:returns: The tangents, in the same order as :py:meth:`mesh_vertices`.
:rtype: list[construct.Container]
"""
return self._replay(self.tangents, root_lod)
def _replay(self, pool, root_lod):
if not self.fixups:
return list(pool)
out = []
for fixup in self.fixups:
if fixup.lod < root_lod:
# working from full detail, skip the higher-detail lods
continue
out.extend(pool[fixup.sourceVertexID:
fixup.sourceVertexID + fixup.numVertexes])
return out
def _replay_count(self, root_lod):
# how many vertices mesh_vertices(root_lod) would return, without
# touching the pool -- a fixup at or below the lod contributes its run
return sum(f.numVertexes for f in self.fixups if f.lod >= root_lod)
[docs]
def validate(self):
"""Checks the vvd's internal invariants.
Like :py:meth:`Mdl.validate<valvemdl.Mdl.validate>` this reports rather
than raises: a vvd that fails one of these is the interesting one. It is
deliberately cheap -- every check reads the header and the small fixup
table, never the vertex pool -- so the corpus sweep can run it on every
file without parsing a quarter megabyte of vertices each time.
:returns: One string per broken invariant, empty when sound.
:rtype: list[str]
"""
problems = []
header = self.header
pool = header.numLODVertexes[0]
if header.id != b'IDSV':
problems.append('bad magic ({0!r})'.format(header.id))
span = header.tangentDataStart - header.vertexDataStart
if header.tangentDataStart and span != pool * mstudiovertex_t.sizeof():
problems.append(
'the vertex pool is {0} bytes, not the {1} its vertex count '
'implies'.format(span, pool * mstudiovertex_t.sizeof()))
# each fixup names a run of the pool; none may point past it
for i, fixup in enumerate(self.fixups):
if fixup.sourceVertexID < 0 or \
fixup.sourceVertexID + fixup.numVertexes > pool:
problems.append(
'fixup {0} runs off the end of the vertex pool'.format(i))
# replaying the fixups at a lod must reproduce that lod's vertex count
if self.fixups:
for lod in range(header.numLODs):
got = self._replay_count(lod)
if got != header.numLODVertexes[lod]:
problems.append(
'fixups give {0} vertices at lod {1}, but the header '
'says {2}'.format(got, lod,
header.numLODVertexes[lod]))
return problems
[docs]
def save(self, destination=None):
"""Writes the vvd back out, rebuilt from its pools.
:param destination: Where to write. Overwrites the original when not
given.
:type destination: str, optional
"""
from valvemdl.unloader import rebuild_vvd
with open(destination or self.source_path, 'wb') as f:
f.write(bytes(rebuild_vvd(self).data))
[docs]
def coverage(self):
"""How much of the vvd |proj_name| can account for.
A vvd has no compressed regions, so a sound one comes back essentially
all ``struct`` -- every byte regenerated from the object graph, none
merely copied. See :py:meth:`Mdl.coverage<valvemdl.Mdl.coverage>`.
:returns: Byte counts, plus ``differ``: bytes the rebuild got wrong.
:rtype: dict
"""
from valvemdl.unloader import rebuild_vvd
out = rebuild_vvd(self)
report = out.report()
report['differ'] = sum(1 for a, b in zip(out.data, self._data)
if a != b)
return report
[docs]
def __repr__(self):
return "<{0}.{1} '{2}' at {3}>".format(type(self).__module__,
type(self).__name__,
self.source_path,
hex(id(self)))