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)
This commit is contained in:
Niko Marmeladkov 2026-10-11 23:15:46 +03:00
parent 7236910e69
commit d6b213cecc
7 changed files with 1048 additions and 10 deletions

View file

@ -107,6 +107,7 @@ pub fn list(assets: Option<PathBuf>, pattern: Option<String>, limit: usize) -> a
/// `fs inspect`: определить формат файла и показать сводку.
pub fn inspect(assets: Option<PathBuf>, path: &str) -> anyhow::Result<()> {
let disk = Path::new(path);
let mut vfs: Option<VirtualFs> = None;
let (data, source) = if disk.is_file() {
(
std::fs::read(disk).with_context(|| format!("чтение {}", disk.display()))?,
@ -117,16 +118,24 @@ pub fn inspect(assets: Option<PathBuf>, path: &str) -> anyhow::Result<()> {
let layout = tier::paths::detect(config.assets.as_deref()).ok_or_else(|| {
anyhow::anyhow!("ассеты Garry's Mod не найдены; укажите --assets <корень>")
})?;
let mut vfs = VirtualFs::new();
let mut fs = VirtualFs::new();
for vpk in collect_dir_vpks(&layout) {
vfs.mount_vpk(&vpk)
fs.mount_vpk(&vpk)
.with_context(|| format!("монтирование {}", vpk.display()))?;
}
vfs.mount_dir(layout.game.clone());
(
vfs.read(path).with_context(|| format!("чтение {path}"))?,
"VPK".to_string(),
)
fs.mount_dir(layout.game.clone());
let data = fs.read(path).with_context(|| format!("чтение {path}"))?;
vfs = Some(fs);
(data, "VPK".to_string())
};
// Чтение сопутствующего файла (например, .vvd/.dx90.vtx рядом с .mdl).
let read_companion = |companion: &str| -> Option<Vec<u8>> {
if disk.is_file() {
std::fs::read(companion).ok()
} else {
vfs.as_ref().and_then(|fs| fs.read(companion).ok())
}
};
println!(
@ -265,6 +274,50 @@ pub fn inspect(assets: Option<PathBuf>, path: &str) -> anyhow::Result<()> {
if let Some(surface) = mdl.surface_prop() {
println!(" surfaceprop: {surface}");
}
// Сопутствующие меш-данные (.vvd / .dx90.vtx рядом с .mdl).
let stem = path.strip_suffix(".mdl").unwrap_or(path);
if let Some(vvd_data) = read_companion(&format!("{stem}.vvd")) {
match formats::vvd::Vvd::parse(&vvd_data) {
Ok(vvd) => {
let verts = vvd.vertices(0).map(|v| v.len()).unwrap_or(0);
let checksum = if vvd.header.checksum == mdl.header.checksum {
"совпадает"
} else {
"НЕ совпадает"
};
println!(
" VVD v{}: {} вершин (LOD0), {} fixup, checksum {checksum}",
vvd.header.version, verts, vvd.header.num_fixups
);
}
Err(err) => println!(" VVD: {err}"),
}
}
if let Some(vtx_data) = read_companion(&format!("{stem}.dx90.vtx")) {
match formats::vtx::Vtx::parse(&vtx_data) {
Ok(vtx) => match vtx.stats() {
Ok(stats) => {
let checksum = if vtx.header.checksum == mdl.header.checksum {
"совпадает"
} else {
"НЕ совпадает"
};
println!(
" VTX v{}: до {} LOD, мешей {}, вершин {}, индексов {}, стрипов {}, checksum {checksum}",
vtx.header.version,
stats.lods,
stats.meshes,
stats.vertices,
stats.indices,
stats.strips
);
}
Err(err) => println!(" VTX: {err}"),
},
Err(err) => println!(" VTX: {err}"),
}
}
} else {
println!(" неизвестный формат");
}

View file

@ -10,6 +10,8 @@ pub mod mdl;
pub mod pakfile;
pub mod vmt;
pub mod vtf;
pub mod vtx;
pub mod vvd;
pub use bsp::{Bsp, BspError, GameLump, TexData};
pub use kv::{KvDocument, KvError, KvPair, KvValue};
@ -17,3 +19,5 @@ pub use mdl::{Mdl, MdlError, StudioHeader};
pub use pakfile::{PakEntry, PakError, PakFile};
pub use vmt::{VmtDocument, VmtError};
pub use vtf::{ImageFormat, VtfError, VtfHeader};
pub use vtx::{Vtx, VtxError, VtxStats};
pub use vvd::{Vvd, VvdError};

504
crates/formats/src/vtx.rs Normal file
View file

@ -0,0 +1,504 @@
//! VTX — оптимизированные меш-данные модели (LOD-структура).
//!
//! Структуры packed (`#pragma pack(1)`): заголовок 36 байт; далее дерево
//! BodyPart → Model → LOD → Mesh → StripGroup → (Vertex_t, индексы, StripHeader).
//! Все смещения — относительно содержащей структуры.
use std::fmt;
/// Размер FileHeader_t.
pub const HEADER_SIZE: usize = 36;
/// Размер BodyPartHeader_t.
const BODYPART_SIZE: usize = 8;
/// Размер ModelHeader_t.
const MODEL_SIZE: usize = 8;
/// Размер ModelLODHeader_t.
const LOD_SIZE: usize = 12;
/// Размер MeshHeader_t.
const MESH_SIZE: usize = 9;
/// Размер StripGroupHeader_t.
const STRIPGROUP_SIZE: usize = 25;
/// Размер Vertex_t.
const VTX_VERTEX_SIZE: usize = 9;
/// Размер StripHeader_t.
const STRIP_SIZE: usize = 27;
/// Размер BoneStateChangeHeader_t.
const BONE_STATE_SIZE: usize = 8;
const MAX_BODY_PARTS: usize = 64;
const MAX_MODELS: usize = 256;
const MAX_LODS: usize = 8;
const MAX_MESHES: usize = 1024;
const MAX_STRIP_GROUPS: usize = 4096;
const MAX_VERTICES: usize = 1 << 22;
const MAX_INDICES: usize = 1 << 24;
const MAX_STRIPS: usize = 1 << 16;
/// Ошибка разбора VTX.
#[derive(Debug, thiserror::Error)]
pub enum VtxError {
#[error("файл короче заголовка VTX")]
Truncated,
#[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("индекс {index} вне диапазона вершин {num_verts} (strip group)")]
BadIndex { index: u16, num_verts: usize },
}
/// Заголовок VTX.
#[derive(Debug, Clone)]
pub struct VtxHeader {
pub version: i32,
pub vert_cache_size: i32,
pub max_bones_per_strip: u16,
pub max_bones_per_tri: u16,
pub max_bones_per_vert: i32,
pub checksum: i32,
pub num_lods: i32,
pub material_replacement_list_offset: i32,
pub num_body_parts: i32,
pub body_part_offset: i32,
}
/// Вершина VTX (Vertex_t).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VtxVertex {
pub bone_weight_index: [u8; 3],
pub num_bones: u8,
pub orig_mesh_vert_id: u16,
pub bone_ids: [i8; 3],
}
/// Strip (часть strip group, разделённая костями).
#[derive(Debug, Clone)]
pub struct VtxStrip {
pub num_indices: i32,
pub index_offset: i32,
pub num_verts: i32,
pub vert_offset: i32,
pub num_bones: i16,
pub flags: u8,
pub bone_state_changes: Vec<(i32, i32)>,
}
/// Группа стрипов (один vertex/index-буфер).
#[derive(Debug, Clone)]
pub struct VtxStripGroup {
pub flags: u8,
pub vertices: Vec<VtxVertex>,
pub indices: Vec<u16>,
pub strips: Vec<VtxStrip>,
}
/// Меш (материал).
#[derive(Debug, Clone)]
pub struct VtxMesh {
pub flags: u8,
pub strip_groups: Vec<VtxStripGroup>,
}
/// LOD модели.
#[derive(Debug, Clone)]
pub struct VtxLod {
pub switch_point: f32,
pub meshes: Vec<VtxMesh>,
}
/// Модель внутри бодипарта.
#[derive(Debug, Clone)]
pub struct VtxModel {
pub lods: Vec<VtxLod>,
}
/// Бодипарт.
#[derive(Debug, Clone)]
pub struct VtxBodyPart {
pub models: Vec<VtxModel>,
}
/// Разобранный VTX.
pub struct Vtx<'a> {
data: &'a [u8],
pub header: VtxHeader,
}
impl<'a> Vtx<'a> {
/// Разбирает VTX из памяти.
pub fn parse(data: &'a [u8]) -> Result<Vtx<'a>, VtxError> {
if data.len() < HEADER_SIZE {
return Err(VtxError::Truncated);
}
let header = VtxHeader {
version: i32_at(data, 0),
vert_cache_size: i32_at(data, 4),
max_bones_per_strip: u16_at(data, 8),
max_bones_per_tri: u16_at(data, 10),
max_bones_per_vert: i32_at(data, 12),
checksum: i32_at(data, 16),
num_lods: i32_at(data, 20),
material_replacement_list_offset: i32_at(data, 24),
num_body_parts: i32_at(data, 28),
body_part_offset: i32_at(data, 32),
};
let vtx = Vtx { data, header };
vtx.check_ranges()?;
Ok(vtx)
}
fn check_ranges(&self) -> Result<(), VtxError> {
let count = self.header.num_body_parts.max(0) as usize;
if count > MAX_BODY_PARTS {
return Err(VtxError::TooMany {
field: "body parts",
count,
});
}
let offset = self.header.body_part_offset.max(0) as usize;
self.check("body parts", offset, count * BODYPART_SIZE)
}
fn check(&self, field: &'static str, offset: usize, size: usize) -> Result<(), VtxError> {
if offset.saturating_add(size) > self.data.len() {
return Err(VtxError::OutOfBounds {
field,
offset,
size,
file_len: self.data.len(),
});
}
Ok(())
}
/// Полное дерево бодипартов.
pub fn body_parts(&self) -> Result<Vec<VtxBodyPart>, VtxError> {
let count = self.header.num_body_parts.max(0) as usize;
let base = self.header.body_part_offset.max(0) as usize;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let o = base + i * BODYPART_SIZE;
let num_models = i32_at(self.data, o);
let model_offset = i32_at(self.data, o + 4);
if num_models < 0 || num_models as usize > MAX_MODELS {
return Err(VtxError::TooMany {
field: "models",
count: num_models.max(0) as usize,
});
}
// Смещения вложенных структур отсчитываются от своей структуры.
let model_base = rel_base(o, model_offset)?;
self.check("models", model_base, num_models as usize * MODEL_SIZE)?;
let mut models = Vec::with_capacity(num_models as usize);
for m in 0..num_models as usize {
let mo = model_base + m * MODEL_SIZE;
let num_lods = i32_at(self.data, mo);
let lod_offset = i32_at(self.data, mo + 4);
if num_lods < 0 || num_lods as usize > MAX_LODS {
return Err(VtxError::TooMany {
field: "lods",
count: num_lods.max(0) as usize,
});
}
let lod_base = rel_base(mo, lod_offset)?;
self.check("lods", lod_base, num_lods as usize * LOD_SIZE)?;
let mut lods = Vec::with_capacity(num_lods as usize);
for l in 0..num_lods as usize {
let lo = lod_base + l * LOD_SIZE;
let num_meshes = i32_at(self.data, lo);
let mesh_offset = i32_at(self.data, lo + 4);
let switch_point = f32_at(self.data, lo + 8);
if num_meshes < 0 || num_meshes as usize > MAX_MESHES {
return Err(VtxError::TooMany {
field: "meshes",
count: num_meshes.max(0) as usize,
});
}
let mesh_base = rel_base(lo, mesh_offset)?;
self.check("meshes", mesh_base, num_meshes as usize * MESH_SIZE)?;
let mut meshes = Vec::with_capacity(num_meshes as usize);
for me in 0..num_meshes as usize {
let meo = mesh_base + me * MESH_SIZE;
let num_strip_groups = i32_at(self.data, meo);
let strip_group_offset = i32_at(self.data, meo + 4);
let flags = *self.data.get(meo + 8).unwrap_or(&0);
if num_strip_groups < 0 || num_strip_groups as usize > MAX_STRIP_GROUPS {
return Err(VtxError::TooMany {
field: "strip groups",
count: num_strip_groups.max(0) as usize,
});
}
let sg_base = rel_base(meo, strip_group_offset)?;
self.check(
"strip groups",
sg_base,
num_strip_groups as usize * STRIPGROUP_SIZE,
)?;
let mut strip_groups = Vec::with_capacity(num_strip_groups as usize);
for sg in 0..num_strip_groups as usize {
strip_groups
.push(self.parse_strip_group(sg_base + sg * STRIPGROUP_SIZE)?);
}
meshes.push(VtxMesh {
flags,
strip_groups,
});
}
lods.push(VtxLod {
switch_point,
meshes,
});
}
models.push(VtxModel { lods });
}
out.push(VtxBodyPart { models });
}
Ok(out)
}
fn parse_strip_group(&self, base: usize) -> Result<VtxStripGroup, VtxError> {
let num_verts = i32_at(self.data, base);
let vert_offset = i32_at(self.data, base + 4);
let num_indices = i32_at(self.data, base + 8);
let index_offset = i32_at(self.data, base + 12);
let num_strips = i32_at(self.data, base + 16);
let strip_offset = i32_at(self.data, base + 20);
let flags = *self.data.get(base + 24).unwrap_or(&0);
if num_verts < 0 || num_verts as usize > MAX_VERTICES {
return Err(VtxError::TooMany {
field: "vtx vertices",
count: num_verts.max(0) as usize,
});
}
if num_indices < 0 || num_indices as usize > MAX_INDICES {
return Err(VtxError::TooMany {
field: "vtx indices",
count: num_indices.max(0) as usize,
});
}
if num_strips < 0 || num_strips as usize > MAX_STRIPS {
return Err(VtxError::TooMany {
field: "vtx strips",
count: num_strips.max(0) as usize,
});
}
let vert_base = rel_base(base, vert_offset)?;
self.check(
"vtx vertices",
vert_base,
num_verts as usize * VTX_VERTEX_SIZE,
)?;
let index_base = rel_base(base, index_offset)?;
self.check("vtx indices", index_base, num_indices as usize * 2)?;
let strip_base = rel_base(base, strip_offset)?;
self.check("vtx strips", strip_base, num_strips as usize * STRIP_SIZE)?;
let mut vertices = Vec::with_capacity(num_verts as usize);
for v in 0..num_verts as usize {
let o = vert_base + v * VTX_VERTEX_SIZE;
let chunk = self
.data
.get(o..o + VTX_VERTEX_SIZE)
.ok_or(VtxError::OutOfBounds {
field: "vtx vertex",
offset: o,
size: VTX_VERTEX_SIZE,
file_len: self.data.len(),
})?;
vertices.push(VtxVertex {
bone_weight_index: [chunk[0], chunk[1], chunk[2]],
num_bones: chunk[3],
orig_mesh_vert_id: u16::from_le_bytes([chunk[4], chunk[5]]),
bone_ids: [chunk[6] as i8, chunk[7] as i8, chunk[8] as i8],
});
}
let mut indices = Vec::with_capacity(num_indices as usize);
for i in 0..num_indices as usize {
let o = index_base + i * 2;
let b = self.data.get(o..o + 2).ok_or(VtxError::OutOfBounds {
field: "vtx index",
offset: o,
size: 2,
file_len: self.data.len(),
})?;
let index = u16::from_le_bytes([b[0], b[1]]);
if index as usize >= vertices.len() {
return Err(VtxError::BadIndex {
index,
num_verts: vertices.len(),
});
}
indices.push(index);
}
let mut strips = Vec::with_capacity(num_strips as usize);
for s in 0..num_strips as usize {
let o = strip_base + s * STRIP_SIZE;
let num_bone_state_changes = i32_at(self.data, o + 19);
let bone_state_change_offset = i32_at(self.data, o + 23);
let mut changes = Vec::new();
if num_bone_state_changes > 0 {
let count = num_bone_state_changes as usize;
if count > 1 << 20 {
return Err(VtxError::TooMany {
field: "bone state changes",
count,
});
}
let bs_base = rel_base(o, bone_state_change_offset)?;
self.check("bone state changes", bs_base, count * BONE_STATE_SIZE)?;
for c in 0..count {
let co = bs_base + c * BONE_STATE_SIZE;
changes.push((i32_at(self.data, co), i32_at(self.data, co + 4)));
}
}
strips.push(VtxStrip {
num_indices: i32_at(self.data, o),
index_offset: i32_at(self.data, o + 4),
num_verts: i32_at(self.data, o + 8),
vert_offset: i32_at(self.data, o + 12),
num_bones: i16_at(self.data, o + 16),
flags: *self.data.get(o + 18).unwrap_or(&0),
bone_state_changes: changes,
});
}
Ok(VtxStripGroup {
flags,
vertices,
indices,
strips,
})
}
/// Суммарная статистика по LOD 0 (для логов/CLI).
pub fn stats(&self) -> Result<VtxStats, VtxError> {
let parts = self.body_parts()?;
let mut stats = VtxStats::default();
for part in &parts {
for model in &part.models {
if let Some(lod) = model.lods.first() {
stats.lods = stats.lods.max(model.lods.len());
for mesh in &lod.meshes {
stats.meshes += 1;
for sg in &mesh.strip_groups {
stats.vertices += sg.vertices.len();
stats.indices += sg.indices.len();
stats.strips += sg.strips.len();
}
}
}
}
}
Ok(stats)
}
}
/// Статистика VTX (LOD 0).
#[derive(Debug, Default, Clone, Copy)]
pub struct VtxStats {
pub lods: usize,
pub meshes: usize,
pub vertices: usize,
pub indices: usize,
pub strips: usize,
}
impl fmt::Debug for Vtx<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Vtx").field("header", &self.header).finish()
}
}
fn i32_at(data: &[u8], offset: usize) -> i32 {
let Some(b) = data.get(offset..offset + 4) else {
return 0;
};
i32::from_le_bytes([b[0], b[1], b[2], b[3]])
}
/// База вложенной структуры: `base + offset` (offset — относительный).
fn rel_base(base: usize, offset: i32) -> Result<usize, VtxError> {
if offset < 0 {
return Err(VtxError::OutOfBounds {
field: "относительное смещение",
offset: base,
size: offset.unsigned_abs() as usize,
file_len: 0,
});
}
base.checked_add(offset as usize)
.ok_or(VtxError::OutOfBounds {
field: "относительное смещение",
offset: base,
size: offset as usize,
file_len: 0,
})
}
fn u16_at(data: &[u8], offset: usize) -> u16 {
let Some(b) = data.get(offset..offset + 2) else {
return 0;
};
u16::from_le_bytes([b[0], b[1]])
}
fn i16_at(data: &[u8], offset: usize) -> i16 {
let Some(b) = data.get(offset..offset + 2) else {
return 0;
};
i16::from_le_bytes([b[0], b[1]])
}
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::*;
#[test]
fn empty_header() {
let mut data = vec![0u8; HEADER_SIZE];
data[0..4].copy_from_slice(&7i32.to_le_bytes());
data[28..32].copy_from_slice(&0i32.to_le_bytes()); // num_body_parts
let vtx = Vtx::parse(&data).expect("разбор");
assert_eq!(vtx.header.version, 7);
assert!(vtx.body_parts().expect("бодипарты").is_empty());
}
#[test]
fn truncated_is_error() {
assert!(matches!(Vtx::parse(&[0u8; 10]), Err(VtxError::Truncated)));
}
#[test]
fn garbage_never_panics() {
for len in [0usize, 4, 36, 64, 1024] {
let mut data = vec![0u8; len];
if len >= 4 {
data[0..4].copy_from_slice(&7i32.to_le_bytes());
}
let _ = Vtx::parse(&data);
}
}
}

344
crates/formats/src/vvd.rs Normal file
View file

@ -0,0 +1,344 @@
//! 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);
}
}
}

View file

@ -326,6 +326,132 @@ fn many_real_mdls_parse() {
);
}
#[test]
fn real_mdl_vertex_data() {
let Some(root) = assets_root() else {
eprintln!("пропуск: ассеты не найдены");
return;
};
let vfs = mount_game(&root);
let mdl_data = vfs.read("models/props_c17/oildrum001.mdl").expect("mdl");
let vvd_data = vfs.read("models/props_c17/oildrum001.vvd").expect("vvd");
let vtx_data = vfs
.read("models/props_c17/oildrum001.dx90.vtx")
.expect("vtx");
let mdl = formats::mdl::Mdl::parse(&mdl_data).expect("разбор mdl");
let vvd = formats::vvd::Vvd::parse(&vvd_data).expect("разбор vvd");
let vtx = formats::vtx::Vtx::parse(&vtx_data).expect("разбор vtx");
assert_eq!(vvd.header.checksum, mdl.header.checksum, "checksum vvd/mdl");
assert_eq!(vtx.header.checksum, mdl.header.checksum, "checksum vtx/mdl");
let vertices = vvd.vertices(0).expect("вершины");
assert_eq!(vertices.len() as i32, vvd.header.num_lod_vertexes[0]);
assert!(
vertices.len() > 10,
"ожидались вершины, есть {}",
vertices.len()
);
for v in &vertices {
assert!(v.position.iter().all(|c| c.is_finite()));
assert!(v.normal.iter().all(|c| c.is_finite()));
assert!(v.texcoord.iter().all(|c| c.is_finite()));
}
let stats = vtx.stats().expect("статистика vtx");
assert!(stats.vertices > 0, "нет вершин в vtx");
assert!(stats.indices > 0, "нет индексов в vtx");
assert!(stats.strips > 0, "нет стрипов в vtx");
eprintln!(
"oildrum: VVD v{} ({} вершин, {} fixup), VTX v{} ({} мешей, {} вершин, {} индексов, {} стрипов)",
vvd.header.version,
vertices.len(),
vvd.fixups().expect("fixups").len(),
vtx.header.version,
stats.meshes,
stats.vertices,
stats.indices,
stats.strips
);
}
#[test]
fn many_real_vertex_data() {
let Some(root) = assets_root() else {
eprintln!("пропуск: ассеты не найдены");
return;
};
let vfs = mount_game(&root);
let mut pairs = 0usize;
let mut failed = 0usize;
let mut missing = 0usize;
'outer: for mount in vfs.mounts() {
let fs::MountSource::Vpk(vpk) = mount else {
continue;
};
let step = (vpk.entry_count() / 96).max(1);
for entry in vpk.entries().step_by(step) {
if !entry.path.ends_with(".mdl") {
continue;
}
let stem = &entry.path[..entry.path.len() - 4];
// Важно: и модель, и меш-файлы читаем через VFS (единый порядок
// монтирования), иначе копии из разных VPK могут не совпасть.
let Ok(mdl_data) = vfs.read_unverified(&entry.path) else {
continue;
};
let Ok(mdl) = formats::mdl::Mdl::parse(&mdl_data) else {
continue;
};
let vvd_data = vfs.read_unverified(&format!("{stem}.vvd")).ok();
let vtx_data = vfs.read_unverified(&format!("{stem}.dx90.vtx")).ok();
let (Some(vvd_data), Some(vtx_data)) = (vvd_data, vtx_data) else {
missing += 1;
continue;
};
match (
formats::vvd::Vvd::parse(&vvd_data),
formats::vtx::Vtx::parse(&vtx_data),
) {
(Ok(vvd), Ok(vtx)) => {
pairs += 1;
if vvd.header.checksum != mdl.header.checksum
|| vtx.header.checksum != mdl.header.checksum
{
failed += 1;
eprintln!("{}: несовпадение checksum", entry.path);
}
}
(vvd_res, vtx_res) => {
failed += 1;
if failed <= 5 {
let err = vvd_res
.err()
.map(|e| e.to_string())
.or_else(|| vtx_res.err().map(|e| e.to_string()))
.unwrap_or_default();
eprintln!("{}: {err}", entry.path);
}
}
}
if pairs >= 100 {
break 'outer;
}
}
}
eprintln!("VVD/VTX: пар {pairs}, ошибок {failed}, без меш-файлов {missing}");
assert!(pairs >= 20, "слишком мало пар VVD/VTX: {pairs}");
assert_eq!(failed, 0, "ошибки разбора меш-данных");
}
#[test]
fn real_vmt_from_disk() {
let Some(root) = assets_root() else {

View file

@ -110,9 +110,11 @@ Source RS — клиент Garry's Mod, написанный с нуля на Ru
- [x] VMT: шейдер, параметры, patch (include/replace/insert), Proxies, render-modes
- [x] MDL: заголовок (v44–49, studiohdr2), кости, аттачменты, хитбоксы, текстуры,
бодипарты, include-модели, `$keyvalues`, `surfaceprop` (проверено на реальных моделях)
- [x] CLI: `fs doctor`, `fs list`, `fs cat`, `fs inspect` (BSP/VTF/VMT/MDL)
- [x] Тесты на реальных данных: 2 карты, 200 VTF, 200 VMT, 53 MDL, pakfile 599 записей
- [ ] MDL: VVD/VTX (меши) и ANIM (анимации) — следующая итерация
- [x] VVD/VTX: вершины (позиция/нормаль/UV/веса костей), fixup'ы, полное дерево
BodyPart→Model→LOD→Mesh→StripGroup→Strip с валидацией индексов (51 пара на реальных моделях)
- [x] CLI: `fs doctor`, `fs list`, `fs cat`, `fs inspect` (BSP/VTF/VMT/MDL + VVD/VTX)
- [x] Тесты на реальных данных: 2 карты, 200 VTF, 200 VMT, 53 MDL, 51 пара VVD/VTX, pakfile 599 записей
- [ ] MDL: ANIM (анимации) — следующая итерация
- [ ] PCF, PHY (collision), WAV/MP3/OGG
- [ ] `crates/fs`: GMA (workshop), `mount.cfg`, aliases `|all_source_engine_paths|`,
mmap, кэш индексов VPK

View file

@ -106,3 +106,8 @@
studiohdr2_t (256 байт, `studiohdr2index=408`), данные костей начинаются после него.
Смещения полей совпадают у v44–v48 (проверено на 53 моделях: v44 — 47, v45 — 1, v48 — 5,
`length == размер файла` у всех).
- VVD: заголовок 64 байта (`IDSV` v4), вершина mstudiovertex_t = 48 байт.
- VTX: структуры packed (`#pragma pack(1)`); **смещения вложенных структур отсчитываются
от своей структуры**, а не от начала файла (только `bodyPartOffset` — от файла).
Проверено: oildrum001 — 270 вершин / 756 индексов / 1 стрип; 51 пара VVD/VTX — checksum совпадает.
- `fs inspect` для модели показывает кости/аттачменты/текстуры/keyvalues и меш-статистику.