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:
parent
7236910e69
commit
d6b213cecc
7 changed files with 1048 additions and 10 deletions
|
|
@ -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!(" неизвестный формат");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
504
crates/formats/src/vtx.rs
Normal 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
344
crates/formats/src/vvd.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 и меш-статистику.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue