Editor/Conversion/Source1/VvdReader.cs

A reader for Source engine VVD vertex files. It parses VVD headers, handles fixup tables to reconstruct LOD0 vertex order, reads vertex attributes (position, normal, UV) and bone weights, and returns a VvdFile containing a vertex list.

File Access
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace GmodAssetImporter;

public sealed class VvdFile
{
	public int Checksum;
	public int LodCount;
	public List<MeshVertex> Vertices { get; } = new();
}

public static class VvdReader
{
	const int IdStudioVertex = 0x56534449; // "IDSV"

	public static VvdFile Load( string path ) => Load( File.ReadAllBytes( path ) );

	public static VvdFile Load( byte[] data )
	{
		using var ms = new MemoryStream( data );
		using var br = new BinaryReader( ms );

		var id = br.ReadInt32();
		if ( id != IdStudioVertex )
			throw new InvalidDataException( $"Invalid VVD id 0x{id:X8}" );

		var version = br.ReadInt32();
		if ( version != 4 )
			throw new InvalidDataException( $"Unsupported VVD version {version}" );

		var checksum = br.ReadInt32();
		var numLods = br.ReadInt32();
		var numLodVertexes = new int[8];
		for ( var i = 0; i < 8; i++ )
			numLodVertexes[i] = br.ReadInt32();

		var numFixups = br.ReadInt32();
		var fixupTableStart = br.ReadInt32();
		var vertexDataStart = br.ReadInt32();
		br.ReadInt32(); // tangentDataStart

		var vertexCount = numLodVertexes[0];
		var file = new VvdFile { Checksum = checksum, LodCount = numLods };

		if ( numFixups == 0 )
		{
			br.BaseStream.Position = vertexDataStart;
			for ( var i = 0; i < vertexCount; i++ )
				file.Vertices.Add( ReadVertex( br ) );
		}
		else
		{
			// Apply fixup table to reconstruct LOD0 order
			var raw = new List<MeshVertex>( vertexCount );
			br.BaseStream.Position = vertexDataStart;
			// After fixups, vertex buffer still holds all vertices; fixups remap
			var totalVerts = 0;
			for ( var i = 0; i < numLods; i++ )
				totalVerts = Math.Max( totalVerts, numLodVertexes[i] );

			// Read full vertex buffer — size is typically numLodVertexes[0] before fixup apply,
			// but with fixups the on-disk count is the sum of fixup ranges.
			var maxVert = 0;
			br.BaseStream.Position = fixupTableStart;
			var fixups = new List<(int lod, int source, int count)>();
			for ( var i = 0; i < numFixups; i++ )
			{
				var lod = br.ReadInt32();
				var source = br.ReadInt32();
				var count = br.ReadInt32();
				fixups.Add( (lod, source, count) );
				maxVert = Math.Max( maxVert, source + count );
			}

			br.BaseStream.Position = vertexDataStart;
			var buffer = new MeshVertex[maxVert];
			for ( var i = 0; i < maxVert; i++ )
				buffer[i] = ReadVertex( br );

			// A fixup tagged lod N is used by every LOD <= N, so LOD0 takes all of them.
			foreach ( var fix in fixups )
			{
				if ( fix.lod < 0 ) continue;
				for ( var i = 0; i < fix.count; i++ )
					file.Vertices.Add( buffer[fix.source + i] );
			}

			if ( file.Vertices.Count == 0 )
			{
				// Fallback: use buffer as-is
				file.Vertices.AddRange( buffer );
			}
		}

		return file;
	}

	static MeshVertex ReadVertex( BinaryReader br )
	{
		// mstudiovertex_t / mstudioboneweight_t
		var w0 = br.ReadSingle();
		var w1 = br.ReadSingle();
		var w2 = br.ReadSingle();
		var b0 = br.ReadByte();
		var b1 = br.ReadByte();
		var b2 = br.ReadByte();
		var numBones = br.ReadByte();
		var pos = BinaryUtil.ReadVec3( br );
		var normal = BinaryUtil.ReadVec3( br );
		var u = br.ReadSingle();
		var v = br.ReadSingle();

		var vertex = new MeshVertex
		{
			Position = pos,
			Normal = normal,
			Uv = new Vec2( u, v )
		};

		AddWeight( vertex, b0, w0, numBones, 0 );
		AddWeight( vertex, b1, w1, numBones, 1 );
		AddWeight( vertex, b2, w2, numBones, 2 );
		NormalizeWeights( vertex );
		return vertex;
	}

	static void AddWeight( MeshVertex v, byte bone, float weight, int numBones, int index )
	{
		if ( index >= numBones || weight <= 0f ) return;
		v.Weights.Add( new BoneWeight { Bone = bone, Weight = weight } );
	}

	static void NormalizeWeights( MeshVertex v )
	{
		if ( v.Weights.Count == 0 )
		{
			v.Weights.Add( new BoneWeight { Bone = 0, Weight = 1 } );
			return;
		}

		// Cap at 4 influences
		if ( v.Weights.Count > 4 )
		{
			v.Weights.Sort( ( a, b ) => b.Weight.CompareTo( a.Weight ) );
			v.Weights.RemoveRange( 4, v.Weights.Count - 4 );
		}

		var sum = v.Weights.Sum( w => w.Weight );
		if ( sum <= 0.0001f )
		{
			v.Weights.Clear();
			v.Weights.Add( new BoneWeight { Bone = 0, Weight = 1 } );
			return;
		}

		foreach ( var w in v.Weights )
			w.Weight /= sum;
	}
}