gmod-rs/crates/formats/src/vvd.rs
Niko Marmeladkov d6b213cecc formats: VVD/VTX — меш-данные моделей (вершины, fixup, LOD-дерево)
- VVD (IDSV v4): заголовок 64 байта, mstudiovertex_t 48 байт (веса/кости/позиция/нормаль/UV),
  тангенты, таблица fixup
- VTX (packed): BodyPart→Model→LOD→Mesh→StripGroup→Strip, валидация индексов и смещений;
  смещения вложенных структур относительны своей структуры
- fs inspect: для .mdl показывает VVD/VTX-статистику и сверку checksum
- тесты: oildrum (270 вершин/756 индексов), 51 пара VVD/VTX с совпадающим checksum
- docs: прогресс M1 (остался ANIM, PCF/PHY/SND, GMA/mount.cfg)
2026-10-11 23:15:46 +03:00

344 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! VVD — вершинные данные модели (`IDSV`, версия 4).
//!
//! Заголовок 56 байт, затем вершинный блок (mstudiovertex_t = 48 байт),
//! блок тангентов (Vector4D = 16 байт) и таблица fixup'ов (12 байт на запись).
use std::fmt;
/// Сигнатура `IDSV`.
pub const VVD_MAGIC: u32 = u32::from_le_bytes(*b"IDSV");
/// Ожидаемая версия.
pub const VVD_VERSION: i32 = 4;
/// Размер заголовка.
pub const HEADER_SIZE: usize = 64;
/// Размер mstudiovertex_t.
pub const VERTEX_SIZE: usize = 48;
/// Размер Vector4D (тангент).
pub const TANGENT_SIZE: usize = 16;
/// Размер vertexFileFixup_t.
pub const FIXUP_SIZE: usize = 12;
/// Максимум LOD-ов в заголовке.
pub const MAX_LODS: usize = 8;
/// Максимальное число вершин/фиксапов (защита от мусора).
const MAX_VERTICES: usize = 1 << 24;
const MAX_FIXUPS: usize = 1 << 20;
/// Ошибка разбора VVD.
#[derive(Debug, thiserror::Error)]
pub enum VvdError {
#[error("файл короче заголовка VVD")]
Truncated,
#[error("не VVD: магия 0x{found:08x}")]
BadMagic { found: u32 },
#[error("неподдерживаемая версия VVD: {0}")]
UnsupportedVersion(i32),
#[error("диапазон {field} выходит за пределы файла: {offset}+{size} > {file_len}")]
OutOfBounds {
field: &'static str,
offset: usize,
size: usize,
file_len: usize,
},
#[error("слишком много элементов: {field} = {count}")]
TooMany { field: &'static str, count: usize },
#[error("LOD вне диапазона: {0}")]
BadLod(usize),
}
/// Заголовок VVD.
#[derive(Debug, Clone)]
pub struct VvdHeader {
pub version: i32,
pub checksum: i32,
pub num_lods: i32,
pub num_lod_vertexes: [i32; MAX_LODS],
pub num_fixups: i32,
pub fixup_table_start: i32,
pub vertex_data_start: i32,
pub tangent_data_start: i32,
}
/// Вершина (mstudiovertex_t).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Vertex {
pub weights: [f32; 3],
pub bones: [u8; 3],
pub num_bones: u8,
pub position: [f32; 3],
pub normal: [f32; 3],
pub texcoord: [f32; 2],
}
/// Fixup: диапазон вершин из LOD-а в общем вершинном буфере.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fixup {
pub lod: i32,
pub source_vertex_id: i32,
pub num_vertexes: i32,
}
/// Разобранный VVD.
pub struct Vvd<'a> {
data: &'a [u8],
pub header: VvdHeader,
}
impl<'a> Vvd<'a> {
/// Разбирает VVD из памяти.
pub fn parse(data: &'a [u8]) -> Result<Vvd<'a>, VvdError> {
if data.len() < HEADER_SIZE {
return Err(VvdError::Truncated);
}
let magic = u32_at(data, 0).unwrap_or(0);
if magic != VVD_MAGIC {
return Err(VvdError::BadMagic { found: magic });
}
let version = i32_at(data, 4).unwrap_or(0);
if version != VVD_VERSION {
return Err(VvdError::UnsupportedVersion(version));
}
let mut num_lod_vertexes = [0i32; MAX_LODS];
for (i, slot) in num_lod_vertexes.iter_mut().enumerate() {
*slot = i32_at(data, 16 + i * 4).unwrap_or(0);
}
let header = VvdHeader {
version,
checksum: i32_at(data, 8).unwrap_or(0),
num_lods: i32_at(data, 12).unwrap_or(0),
num_lod_vertexes,
num_fixups: i32_at(data, 48).unwrap_or(0),
fixup_table_start: i32_at(data, 52).unwrap_or(0),
vertex_data_start: i32_at(data, 56).unwrap_or(0),
tangent_data_start: i32_at(data, 60).unwrap_or(0),
};
let vvd = Vvd { data, header };
vvd.check_ranges()?;
Ok(vvd)
}
fn check_ranges(&self) -> Result<(), VvdError> {
let file_len = self.data.len();
let h = &self.header;
let num_lods = h.num_lods.clamp(0, MAX_LODS as i32) as usize;
for (lod, &count) in h.num_lod_vertexes.iter().take(num_lods).enumerate() {
if count < 0 {
return Err(VvdError::BadLod(lod));
}
let count = count as usize;
if count > MAX_VERTICES {
return Err(VvdError::TooMany {
field: "vertices",
count,
});
}
}
// Вершинный блок покрывает сумму вершин по LOD-ам (или максимальный LOD).
let total_vertexes = h.num_lod_vertexes[0].max(0) as usize;
let vertex_offset = h.vertex_data_start.max(0) as usize;
check(
file_len,
"vertex_data",
vertex_offset,
total_vertexes * VERTEX_SIZE,
)?;
if h.tangent_data_start > 0 {
let tangent_offset = h.tangent_data_start as usize;
check(
file_len,
"tangent_data",
tangent_offset,
total_vertexes * TANGENT_SIZE,
)?;
}
if h.num_fixups > 0 {
let count = h.num_fixups as usize;
if count > MAX_FIXUPS {
return Err(VvdError::TooMany {
field: "fixups",
count,
});
}
check(
file_len,
"fixup_table",
h.fixup_table_start.max(0) as usize,
count * FIXUP_SIZE,
)?;
}
Ok(())
}
/// Вершины LOD 0.
pub fn vertices(&self, lod: usize) -> Result<Vec<Vertex>, VvdError> {
if lod >= MAX_LODS || lod >= self.header.num_lods.max(0) as usize {
return Err(VvdError::BadLod(lod));
}
let count = self.header.num_lod_vertexes[lod].max(0) as usize;
let base = self.header.vertex_data_start.max(0) as usize;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let o = base + i * VERTEX_SIZE;
let Some(chunk) = self.data.get(o..o + VERTEX_SIZE) else {
return Err(VvdError::OutOfBounds {
field: "vertex",
offset: o,
size: VERTEX_SIZE,
file_len: self.data.len(),
});
};
out.push(Vertex {
weights: [f32_at(chunk, 0), f32_at(chunk, 4), f32_at(chunk, 8)],
bones: [chunk[12], chunk[13], chunk[14]],
num_bones: chunk[15],
position: [f32_at(chunk, 16), f32_at(chunk, 20), f32_at(chunk, 24)],
normal: [f32_at(chunk, 28), f32_at(chunk, 32), f32_at(chunk, 36)],
texcoord: [f32_at(chunk, 40), f32_at(chunk, 44)],
});
}
Ok(out)
}
/// Тангенсы LOD 0 (Vector4D), если блок есть.
pub fn tangents(&self, lod: usize) -> Result<Option<Vec<[f32; 4]>>, VvdError> {
if self.header.tangent_data_start <= 0 {
return Ok(None);
}
if lod >= MAX_LODS || lod >= self.header.num_lods.max(0) as usize {
return Err(VvdError::BadLod(lod));
}
let count = self.header.num_lod_vertexes[lod].max(0) as usize;
let base = self.header.tangent_data_start as usize;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let o = base + i * TANGENT_SIZE;
let Some(chunk) = self.data.get(o..o + TANGENT_SIZE) else {
return Err(VvdError::OutOfBounds {
field: "tangent",
offset: o,
size: TANGENT_SIZE,
file_len: self.data.len(),
});
};
out.push([
f32_at(chunk, 0),
f32_at(chunk, 4),
f32_at(chunk, 8),
f32_at(chunk, 12),
]);
}
Ok(Some(out))
}
/// Таблица fixup'ов.
pub fn fixups(&self) -> Result<Vec<Fixup>, VvdError> {
let count = self.header.num_fixups.max(0) as usize;
let base = self.header.fixup_table_start.max(0) as usize;
let mut out = Vec::with_capacity(count.min(MAX_FIXUPS));
for i in 0..count {
let o = base + i * FIXUP_SIZE;
let Some(chunk) = self.data.get(o..o + FIXUP_SIZE) else {
return Err(VvdError::OutOfBounds {
field: "fixup",
offset: o,
size: FIXUP_SIZE,
file_len: self.data.len(),
});
};
out.push(Fixup {
lod: i32_at(chunk, 0).unwrap_or(0),
source_vertex_id: i32_at(chunk, 4).unwrap_or(0),
num_vertexes: i32_at(chunk, 8).unwrap_or(0),
});
}
Ok(out)
}
}
impl fmt::Debug for Vvd<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Vvd").field("header", &self.header).finish()
}
}
fn check(file_len: usize, field: &'static str, offset: usize, size: usize) -> Result<(), VvdError> {
if offset.saturating_add(size) > file_len {
return Err(VvdError::OutOfBounds {
field,
offset,
size,
file_len,
});
}
Ok(())
}
fn i32_at(data: &[u8], offset: usize) -> Option<i32> {
let b = data.get(offset..offset + 4)?;
Some(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn u32_at(data: &[u8], offset: usize) -> Option<u32> {
let b = data.get(offset..offset + 4)?;
Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn f32_at(data: &[u8], offset: usize) -> f32 {
let Some(b) = data.get(offset..offset + 4) else {
return 0.0;
};
f32::from_le_bytes([b[0], b[1], b[2], b[3]])
}
#[cfg(test)]
mod tests {
use super::*;
fn build_vvd(vertex_count: usize) -> Vec<u8> {
let mut data = vec![0u8; HEADER_SIZE];
data[0..4].copy_from_slice(b"IDSV");
data[4..8].copy_from_slice(&4i32.to_le_bytes());
data[8..12].copy_from_slice(&0x1234i32.to_le_bytes());
data[12..16].copy_from_slice(&1i32.to_le_bytes());
data[16..20].copy_from_slice(&(vertex_count as i32).to_le_bytes());
data[56..60].copy_from_slice(&(HEADER_SIZE as i32).to_le_bytes());
for i in 0..vertex_count {
let mut v = vec![0u8; VERTEX_SIZE];
v[0..4].copy_from_slice(&1.0f32.to_le_bytes());
v[15] = 1; // num_bones
v[16..20].copy_from_slice(&(i as f32).to_le_bytes());
data.extend_from_slice(&v);
}
data
}
#[test]
fn parse_and_vertices() {
let data = build_vvd(3);
let vvd = Vvd::parse(&data).expect("разбор");
assert_eq!(vvd.header.checksum, 0x1234);
let verts = vvd.vertices(0).expect("вершины");
assert_eq!(verts.len(), 3);
assert_eq!(verts[2].position[0], 2.0);
assert_eq!(verts[0].num_bones, 1);
assert!(vvd.tangents(0).expect("тангенты").is_none());
}
#[test]
fn garbage_never_panics() {
for len in [0usize, 4, 55, 56, 200, 4096] {
let mut data = vec![0u8; len];
if len >= 8 {
data[0..4].copy_from_slice(b"IDSV");
data[4..8].copy_from_slice(&4i32.to_le_bytes());
}
let _ = Vvd::parse(&data);
}
}
}