diff --git a/crates/client/src/doctor.rs b/crates/client/src/doctor.rs index 5286fde..6f4f6bc 100644 --- a/crates/client/src/doctor.rs +++ b/crates/client/src/doctor.rs @@ -107,6 +107,7 @@ pub fn list(assets: Option, pattern: Option, limit: usize) -> a /// `fs inspect`: определить формат файла и показать сводку. pub fn inspect(assets: Option, path: &str) -> anyhow::Result<()> { let disk = Path::new(path); + let mut vfs: Option = 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, 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> { + 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, 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!(" неизвестный формат"); } diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index 362f97a..1b52e7c 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -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}; diff --git a/crates/formats/src/vtx.rs b/crates/formats/src/vtx.rs new file mode 100644 index 0000000..11066b1 --- /dev/null +++ b/crates/formats/src/vtx.rs @@ -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, + pub indices: Vec, + pub strips: Vec, +} + +/// Меш (материал). +#[derive(Debug, Clone)] +pub struct VtxMesh { + pub flags: u8, + pub strip_groups: Vec, +} + +/// LOD модели. +#[derive(Debug, Clone)] +pub struct VtxLod { + pub switch_point: f32, + pub meshes: Vec, +} + +/// Модель внутри бодипарта. +#[derive(Debug, Clone)] +pub struct VtxModel { + pub lods: Vec, +} + +/// Бодипарт. +#[derive(Debug, Clone)] +pub struct VtxBodyPart { + pub models: Vec, +} + +/// Разобранный VTX. +pub struct Vtx<'a> { + data: &'a [u8], + pub header: VtxHeader, +} + +impl<'a> Vtx<'a> { + /// Разбирает VTX из памяти. + pub fn parse(data: &'a [u8]) -> Result, 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, 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/crates/formats/src/vvd.rs b/crates/formats/src/vvd.rs new file mode 100644 index 0000000..d005d21 --- /dev/null +++ b/crates/formats/src/vvd.rs @@ -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, 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, 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>, 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, 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/crates/formats/tests/real_formats.rs b/crates/formats/tests/real_formats.rs index 808088f..d1e9b01 100644 --- a/crates/formats/tests/real_formats.rs +++ b/crates/formats/tests/real_formats.rs @@ -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 { diff --git a/docs/PLAN.md b/docs/PLAN.md index 9f632f3..481cc2d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 diff --git a/docs/RECON.md b/docs/RECON.md index 40e60ed..1f26836 100644 --- a/docs/RECON.md +++ b/docs/RECON.md @@ -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 и меш-статистику.