diff options
| author | Valentin Popov <valentin@popov.link> | 2026-02-19 03:46:23 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-02-19 03:46:23 +0300 |
| commit | 0e19660eb5122c8c52d5e909927884ad5c50b813 (patch) | |
| tree | 6a53c24544ca828f08c2b6872d568b1edc1a4cef /crates | |
| parent | 8a69872576eed41a918643be52a80fe74a054974 (diff) | |
| download | fparkan-0e19660eb5122c8c52d5e909927884ad5c50b813.tar.xz fparkan-0e19660eb5122c8c52d5e909927884ad5c50b813.zip | |
Refactor documentation structure and add new specifications
- Updated MSH documentation to reflect changes in material, wear, and texture specifications.
- Introduced new `render.md` file detailing the render pipeline process.
- Removed outdated sections from `runtime-pipeline.md` and redirected to `render.md`.
- Added detailed specifications for `Texm` texture format and `WEAR` wear table.
- Updated navigation in `mkdocs.yml` to align with new documentation structure.
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/msh-core/Cargo.toml | 7 | ||||
| -rw-r--r-- | crates/msh-core/README.md | 14 | ||||
| -rw-r--r-- | crates/msh-core/src/error.rs | 74 | ||||
| -rw-r--r-- | crates/msh-core/src/lib.rs | 392 | ||||
| -rw-r--r-- | crates/msh-core/src/tests.rs | 296 | ||||
| -rw-r--r-- | crates/render-core/Cargo.toml | 8 | ||||
| -rw-r--r-- | crates/render-core/README.md | 14 | ||||
| -rw-r--r-- | crates/render-core/src/lib.rs | 84 | ||||
| -rw-r--r-- | crates/render-core/src/tests.rs | 101 | ||||
| -rw-r--r-- | crates/render-demo/Cargo.toml | 20 | ||||
| -rw-r--r-- | crates/render-demo/README.md | 30 | ||||
| -rw-r--r-- | crates/render-demo/build.rs | 4 | ||||
| -rw-r--r-- | crates/render-demo/src/lib.rs | 113 | ||||
| -rw-r--r-- | crates/render-demo/src/main.rs | 357 | ||||
| -rw-r--r-- | crates/texm/Cargo.toml | 7 | ||||
| -rw-r--r-- | crates/texm/README.md | 15 | ||||
| -rw-r--r-- | crates/texm/src/error.rs | 61 | ||||
| -rw-r--r-- | crates/texm/src/lib.rs | 258 | ||||
| -rw-r--r-- | crates/texm/src/tests.rs | 150 |
19 files changed, 2005 insertions, 0 deletions
diff --git a/crates/msh-core/Cargo.toml b/crates/msh-core/Cargo.toml new file mode 100644 index 0000000..cdea317 --- /dev/null +++ b/crates/msh-core/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "msh-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +nres = { path = "../nres" } diff --git a/crates/msh-core/README.md b/crates/msh-core/README.md new file mode 100644 index 0000000..016df7a --- /dev/null +++ b/crates/msh-core/README.md @@ -0,0 +1,14 @@ +# msh-core + +Парсер core-части формата `MSH`. + +Покрывает: + +- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные); +- `Res4`, `Res5`, `Res10` (опциональные); +- slot lookup по `node/lod/group`. + +Тесты: + +- прогон по всем `.msh` в `testdata`; +- синтетическая минимальная модель. diff --git a/crates/msh-core/src/error.rs b/crates/msh-core/src/error.rs new file mode 100644 index 0000000..81fe54f --- /dev/null +++ b/crates/msh-core/src/error.rs @@ -0,0 +1,74 @@ +use core::fmt; + +#[derive(Debug)] +pub enum Error { + Nres(nres::error::Error), + MissingResource { + kind: u32, + label: &'static str, + }, + InvalidResourceSize { + label: &'static str, + size: usize, + stride: usize, + }, + InvalidRes2Size { + size: usize, + }, + UnsupportedNodeStride { + stride: usize, + }, + IndexOutOfBounds { + label: &'static str, + index: usize, + limit: usize, + }, + IntegerOverflow, +} + +impl From<nres::error::Error> for Error { + fn from(value: nres::error::Error) -> Self { + Self::Nres(value) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nres(err) => write!(f, "{err}"), + Self::MissingResource { kind, label } => { + write!(f, "missing required resource type={kind} ({label})") + } + Self::InvalidResourceSize { + label, + size, + stride, + } => { + write!( + f, + "invalid {label} size={size}, expected multiple of stride={stride}" + ) + } + Self::InvalidRes2Size { size } => { + write!(f, "invalid Res2 size={size}, expected >= 140") + } + Self::UnsupportedNodeStride { stride } => { + write!( + f, + "unsupported Res1 node stride={stride}, expected 38 or 24" + ) + } + Self::IndexOutOfBounds { + label, + index, + limit, + } => write!( + f, + "{label} index out of bounds: index={index}, limit={limit}" + ), + Self::IntegerOverflow => write!(f, "integer overflow"), + } + } +} + +impl std::error::Error for Error {} diff --git a/crates/msh-core/src/lib.rs b/crates/msh-core/src/lib.rs new file mode 100644 index 0000000..84e8a86 --- /dev/null +++ b/crates/msh-core/src/lib.rs @@ -0,0 +1,392 @@ +pub mod error; + +use crate::error::Error; +use std::sync::Arc; + +pub type Result<T> = core::result::Result<T, Error>; + +pub const RES1_NODE_TABLE: u32 = 1; +pub const RES2_SLOTS: u32 = 2; +pub const RES3_POSITIONS: u32 = 3; +pub const RES4_NORMALS: u32 = 4; +pub const RES5_UV0: u32 = 5; +pub const RES6_INDICES: u32 = 6; +pub const RES10_NAMES: u32 = 10; +pub const RES13_BATCHES: u32 = 13; + +#[derive(Clone, Debug)] +pub struct Slot { + pub tri_start: u16, + pub tri_count: u16, + pub batch_start: u16, + pub batch_count: u16, + pub aabb_min: [f32; 3], + pub aabb_max: [f32; 3], + pub sphere_center: [f32; 3], + pub sphere_radius: f32, + pub opaque: [u32; 5], +} + +#[derive(Clone, Debug)] +pub struct Batch { + pub batch_flags: u16, + pub material_index: u16, + pub opaque4: u16, + pub opaque6: u16, + pub index_count: u16, + pub index_start: u32, + pub opaque14: u16, + pub base_vertex: u32, +} + +#[derive(Clone, Debug)] +pub struct Model { + pub node_stride: usize, + pub node_count: usize, + pub nodes_raw: Vec<u8>, + pub slots: Vec<Slot>, + pub positions: Vec<[f32; 3]>, + pub normals: Option<Vec<[i8; 4]>>, + pub uv0: Option<Vec<[i16; 2]>>, + pub indices: Vec<u16>, + pub batches: Vec<Batch>, + pub node_names: Option<Vec<Option<String>>>, +} + +impl Model { + pub fn slot_index(&self, node_index: usize, lod: usize, group: usize) -> Option<usize> { + if node_index >= self.node_count || lod >= 3 || group >= 5 { + return None; + } + if self.node_stride != 38 { + return None; + } + let node_off = node_index.checked_mul(self.node_stride)?; + let matrix_off = node_off.checked_add(8)?; + let word_off = matrix_off.checked_add((lod * 5 + group) * 2)?; + let raw = read_u16(&self.nodes_raw, word_off).ok()?; + if raw == u16::MAX { + return None; + } + let idx = usize::from(raw); + if idx >= self.slots.len() { + return None; + } + Some(idx) + } +} + +pub fn parse_model_payload(payload: &[u8]) -> Result<Model> { + let archive = nres::Archive::open_bytes( + Arc::from(payload.to_vec().into_boxed_slice()), + nres::OpenOptions::default(), + )?; + + let res1 = read_required(&archive, RES1_NODE_TABLE, "Res1")?; + let res2 = read_required(&archive, RES2_SLOTS, "Res2")?; + let res3 = read_required(&archive, RES3_POSITIONS, "Res3")?; + let res6 = read_required(&archive, RES6_INDICES, "Res6")?; + let res13 = read_required(&archive, RES13_BATCHES, "Res13")?; + + let res4 = read_optional(&archive, RES4_NORMALS)?; + let res5 = read_optional(&archive, RES5_UV0)?; + let res10 = read_optional(&archive, RES10_NAMES)?; + + let node_stride = usize::try_from(res1.meta.attr3).map_err(|_| Error::IntegerOverflow)?; + if node_stride != 38 && node_stride != 24 { + return Err(Error::UnsupportedNodeStride { + stride: node_stride, + }); + } + if res1.bytes.len() % node_stride != 0 { + return Err(Error::InvalidResourceSize { + label: "Res1", + size: res1.bytes.len(), + stride: node_stride, + }); + } + let node_count = res1.bytes.len() / node_stride; + + if res2.bytes.len() < 0x8C { + return Err(Error::InvalidRes2Size { + size: res2.bytes.len(), + }); + } + let slot_blob = res2 + .bytes + .len() + .checked_sub(0x8C) + .ok_or(Error::IntegerOverflow)?; + if slot_blob % 68 != 0 { + return Err(Error::InvalidResourceSize { + label: "Res2.slots", + size: slot_blob, + stride: 68, + }); + } + let slot_count = slot_blob / 68; + let mut slots = Vec::with_capacity(slot_count); + for i in 0..slot_count { + let off = 0x8Cusize + .checked_add(i.checked_mul(68).ok_or(Error::IntegerOverflow)?) + .ok_or(Error::IntegerOverflow)?; + slots.push(Slot { + tri_start: read_u16(&res2.bytes, off)?, + tri_count: read_u16(&res2.bytes, off + 2)?, + batch_start: read_u16(&res2.bytes, off + 4)?, + batch_count: read_u16(&res2.bytes, off + 6)?, + aabb_min: [ + read_f32(&res2.bytes, off + 8)?, + read_f32(&res2.bytes, off + 12)?, + read_f32(&res2.bytes, off + 16)?, + ], + aabb_max: [ + read_f32(&res2.bytes, off + 20)?, + read_f32(&res2.bytes, off + 24)?, + read_f32(&res2.bytes, off + 28)?, + ], + sphere_center: [ + read_f32(&res2.bytes, off + 32)?, + read_f32(&res2.bytes, off + 36)?, + read_f32(&res2.bytes, off + 40)?, + ], + sphere_radius: read_f32(&res2.bytes, off + 44)?, + opaque: [ + read_u32(&res2.bytes, off + 48)?, + read_u32(&res2.bytes, off + 52)?, + read_u32(&res2.bytes, off + 56)?, + read_u32(&res2.bytes, off + 60)?, + read_u32(&res2.bytes, off + 64)?, + ], + }); + } + + let positions = parse_positions(&res3.bytes)?; + let indices = parse_u16_array(&res6.bytes, "Res6")?; + let batches = parse_batches(&res13.bytes)?; + + let normals = match res4 { + Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?), + None => None, + }; + let uv0 = match res5 { + Some(raw) => Some(parse_i16x2_array(&raw.bytes, "Res5")?), + None => None, + }; + let node_names = match res10 { + Some(raw) => Some(parse_res10_names(&raw.bytes, node_count)?), + None => None, + }; + + Ok(Model { + node_stride, + node_count, + nodes_raw: res1.bytes, + slots, + positions, + normals, + uv0, + indices, + batches, + node_names, + }) +} + +fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>> { + if !data.len().is_multiple_of(12) { + return Err(Error::InvalidResourceSize { + label: "Res3", + size: data.len(), + stride: 12, + }); + } + let count = data.len() / 12; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let off = i * 12; + out.push([ + read_f32(data, off)?, + read_f32(data, off + 4)?, + read_f32(data, off + 8)?, + ]); + } + Ok(out) +} + +fn parse_batches(data: &[u8]) -> Result<Vec<Batch>> { + if !data.len().is_multiple_of(20) { + return Err(Error::InvalidResourceSize { + label: "Res13", + size: data.len(), + stride: 20, + }); + } + let count = data.len() / 20; + let mut out = Vec::with_capacity(count); + for i in 0..count { + let off = i * 20; + out.push(Batch { + batch_flags: read_u16(data, off)?, + material_index: read_u16(data, off + 2)?, + opaque4: read_u16(data, off + 4)?, + opaque6: read_u16(data, off + 6)?, + index_count: read_u16(data, off + 8)?, + index_start: read_u32(data, off + 10)?, + opaque14: read_u16(data, off + 14)?, + base_vertex: read_u32(data, off + 16)?, + }); + } + Ok(out) +} + +fn parse_u16_array(data: &[u8], label: &'static str) -> Result<Vec<u16>> { + if !data.len().is_multiple_of(2) { + return Err(Error::InvalidResourceSize { + label, + size: data.len(), + stride: 2, + }); + } + let mut out = Vec::with_capacity(data.len() / 2); + for i in (0..data.len()).step_by(2) { + out.push(read_u16(data, i)?); + } + Ok(out) +} + +fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>> { + if !data.len().is_multiple_of(4) { + return Err(Error::InvalidResourceSize { + label, + size: data.len(), + stride: 4, + }); + } + let mut out = Vec::with_capacity(data.len() / 4); + for i in (0..data.len()).step_by(4) { + out.push([ + read_i8(data, i)?, + read_i8(data, i + 1)?, + read_i8(data, i + 2)?, + read_i8(data, i + 3)?, + ]); + } + Ok(out) +} + +fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>> { + if !data.len().is_multiple_of(4) { + return Err(Error::InvalidResourceSize { + label, + size: data.len(), + stride: 4, + }); + } + let mut out = Vec::with_capacity(data.len() / 4); + for i in (0..data.len()).step_by(4) { + out.push([read_i16(data, i)?, read_i16(data, i + 2)?]); + } + Ok(out) +} + +fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>> { + let mut out = Vec::with_capacity(node_count); + let mut off = 0usize; + for _ in 0..node_count { + let len = usize::try_from(read_u32(data, off)?).map_err(|_| Error::IntegerOverflow)?; + off = off.checked_add(4).ok_or(Error::IntegerOverflow)?; + if len == 0 { + out.push(None); + continue; + } + let need = len.checked_add(1).ok_or(Error::IntegerOverflow)?; + let end = off.checked_add(need).ok_or(Error::IntegerOverflow)?; + let slice = data.get(off..end).ok_or(Error::InvalidResourceSize { + label: "Res10", + size: data.len(), + stride: 1, + })?; + let text = if slice.last().copied() == Some(0) { + &slice[..slice.len().saturating_sub(1)] + } else { + slice + }; + let decoded = String::from_utf8_lossy(text).to_string(); + out.push(Some(decoded)); + off = end; + } + Ok(out) +} + +struct RawResource { + meta: nres::EntryMeta, + bytes: Vec<u8>, +} + +fn read_required(archive: &nres::Archive, kind: u32, label: &'static str) -> Result<RawResource> { + let id = archive + .entries() + .find(|entry| entry.meta.kind == kind) + .map(|entry| entry.id) + .ok_or(Error::MissingResource { kind, label })?; + let entry = archive.get(id).ok_or(Error::IndexOutOfBounds { + label, + index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?, + limit: archive.entry_count(), + })?; + let data = archive.read(id)?.into_owned(); + Ok(RawResource { + meta: entry.meta.clone(), + bytes: data, + }) +} + +fn read_optional(archive: &nres::Archive, kind: u32) -> Result<Option<RawResource>> { + let Some(id) = archive + .entries() + .find(|entry| entry.meta.kind == kind) + .map(|entry| entry.id) + else { + return Ok(None); + }; + let entry = archive.get(id).ok_or(Error::IndexOutOfBounds { + label: "optional", + index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?, + limit: archive.entry_count(), + })?; + let data = archive.read(id)?.into_owned(); + Ok(Some(RawResource { + meta: entry.meta.clone(), + bytes: data, + })) +} + +fn read_u16(data: &[u8], offset: usize) -> Result<u16> { + let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(u16::from_le_bytes(arr)) +} + +fn read_i16(data: &[u8], offset: usize) -> Result<i16> { + let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(i16::from_le_bytes(arr)) +} + +fn read_i8(data: &[u8], offset: usize) -> Result<i8> { + let byte = data.get(offset).copied().ok_or(Error::IntegerOverflow)?; + Ok(i8::from_le_bytes([byte])) +} + +fn read_u32(data: &[u8], offset: usize) -> Result<u32> { + let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(u32::from_le_bytes(arr)) +} + +fn read_f32(data: &[u8], offset: usize) -> Result<f32> { + Ok(f32::from_bits(read_u32(data, offset)?)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/msh-core/src/tests.rs b/crates/msh-core/src/tests.rs new file mode 100644 index 0000000..1eefb31 --- /dev/null +++ b/crates/msh-core/src/tests.rs @@ -0,0 +1,296 @@ +use super::*; +use nres::Archive; +use std::fs; +use std::path::{Path, PathBuf}; + +fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } +} + +fn nres_test_files() -> Vec<PathBuf> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + files + .into_iter() + .filter(|path| { + fs::read(path) + .map(|bytes| bytes.get(0..4) == Some(b"NRes")) + .unwrap_or(false) + }) + .collect() +} + +fn is_msh_name(name: &str) -> bool { + name.to_ascii_lowercase().ends_with(".msh") +} + +#[test] +fn parse_all_game_msh_models() { + let archives = nres_test_files(); + if archives.is_empty() { + eprintln!("skipping parse_all_game_msh_models: no NRes files in testdata"); + return; + } + + let mut model_count = 0usize; + let mut renderable_count = 0usize; + let mut legacy_stride24_count = 0usize; + + for archive_path in archives { + let archive = Archive::open_path(&archive_path) + .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display())); + + for entry in archive.entries() { + if !is_msh_name(&entry.meta.name) { + continue; + } + model_count += 1; + let payload = archive.read(entry.id).unwrap_or_else(|err| { + panic!( + "failed to read model '{}' in {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| { + panic!( + "failed to parse model '{}' in {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + + if model.node_stride == 24 { + legacy_stride24_count += 1; + } + + for node_index in 0..model.node_count { + for lod in 0..3 { + for group in 0..5 { + if let Some(slot_idx) = model.slot_index(node_index, lod, group) { + assert!( + slot_idx < model.slots.len(), + "slot index out of bounds in '{}' ({})", + entry.meta.name, + archive_path.display() + ); + } + } + } + } + + let mut has_renderable_batch = false; + for node_index in 0..model.node_count { + let Some(slot_idx) = model.slot_index(node_index, 0, 0) else { + continue; + }; + let slot = &model.slots[slot_idx]; + let batch_end = + usize::from(slot.batch_start).saturating_add(usize::from(slot.batch_count)); + if batch_end > model.batches.len() { + continue; + } + for batch in &model.batches[usize::from(slot.batch_start)..batch_end] { + let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX); + let index_count = usize::from(batch.index_count); + let end = index_start.saturating_add(index_count); + if end <= model.indices.len() && index_count >= 3 { + has_renderable_batch = true; + break; + } + } + if has_renderable_batch { + break; + } + } + if has_renderable_batch { + renderable_count += 1; + } + } + } + + assert!(model_count > 0, "no .msh entries found"); + assert!( + renderable_count > 0, + "no renderable models (lod0/group0) were detected" + ); + assert!( + legacy_stride24_count <= model_count, + "internal test accounting error" + ); +} + +#[test] +fn parse_minimal_synthetic_model() { + // Nested NRes with required resources only. + let mut payload = Vec::new(); + payload.extend_from_slice(b"NRes"); + payload.extend_from_slice(&0x100u32.to_le_bytes()); + payload.extend_from_slice(&5u32.to_le_bytes()); // entry_count + payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder + + let mut resource_offsets = Vec::new(); + let mut resource_sizes = Vec::new(); + let mut resource_types = Vec::new(); + let mut resource_attr3 = Vec::new(); + let mut resource_names = Vec::new(); + + let add_resource = |payload: &mut Vec<u8>, + offsets: &mut Vec<u32>, + sizes: &mut Vec<u32>, + types: &mut Vec<u32>, + attr3: &mut Vec<u32>, + names: &mut Vec<String>, + kind: u32, + name: &str, + data: &[u8], + attr3_val: u32| { + offsets.push(u32::try_from(payload.len()).expect("offset overflow")); + payload.extend_from_slice(data); + while !payload.len().is_multiple_of(8) { + payload.push(0); + } + sizes.push(u32::try_from(data.len()).expect("size overflow")); + types.push(kind); + attr3.push(attr3_val); + names.push(name.to_string()); + }; + + let node = { + let mut b = vec![0u8; 38]; + // slot[0][0] = 0 + b[8..10].copy_from_slice(&0u16.to_le_bytes()); + for i in 1..15 { + let off = 8 + i * 2; + b[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes()); + } + b + }; + let mut res2 = vec![0u8; 0x8C + 68]; + res2[0x8C..0x8C + 2].copy_from_slice(&0u16.to_le_bytes()); // tri_start + res2[0x8C + 2..0x8C + 4].copy_from_slice(&0u16.to_le_bytes()); // tri_count + res2[0x8C + 4..0x8C + 6].copy_from_slice(&0u16.to_le_bytes()); // batch_start + res2[0x8C + 6..0x8C + 8].copy_from_slice(&1u16.to_le_bytes()); // batch_count + let positions = [0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::<Vec<_>>(); + let indices = [0u16, 1, 2] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::<Vec<_>>(); + let batch = { + let mut b = vec![0u8; 20]; + b[0..2].copy_from_slice(&0u16.to_le_bytes()); + b[2..4].copy_from_slice(&0u16.to_le_bytes()); + b[8..10].copy_from_slice(&3u16.to_le_bytes()); // index_count + b[10..14].copy_from_slice(&0u32.to_le_bytes()); // index_start + b[16..20].copy_from_slice(&0u32.to_le_bytes()); // base_vertex + b + }; + + add_resource( + &mut payload, + &mut resource_offsets, + &mut resource_sizes, + &mut resource_types, + &mut resource_attr3, + &mut resource_names, + RES1_NODE_TABLE, + "Res1", + &node, + 38, + ); + add_resource( + &mut payload, + &mut resource_offsets, + &mut resource_sizes, + &mut resource_types, + &mut resource_attr3, + &mut resource_names, + RES2_SLOTS, + "Res2", + &res2, + 68, + ); + add_resource( + &mut payload, + &mut resource_offsets, + &mut resource_sizes, + &mut resource_types, + &mut resource_attr3, + &mut resource_names, + RES3_POSITIONS, + "Res3", + &positions, + 12, + ); + add_resource( + &mut payload, + &mut resource_offsets, + &mut resource_sizes, + &mut resource_types, + &mut resource_attr3, + &mut resource_names, + RES6_INDICES, + "Res6", + &indices, + 2, + ); + add_resource( + &mut payload, + &mut resource_offsets, + &mut resource_sizes, + &mut resource_types, + &mut resource_attr3, + &mut resource_names, + RES13_BATCHES, + "Res13", + &batch, + 20, + ); + + let directory_offset = payload.len(); + for i in 0..resource_types.len() { + payload.extend_from_slice(&resource_types[i].to_le_bytes()); + payload.extend_from_slice(&1u32.to_le_bytes()); // attr1 + payload.extend_from_slice(&0u32.to_le_bytes()); // attr2 + payload.extend_from_slice(&resource_sizes[i].to_le_bytes()); + payload.extend_from_slice(&resource_attr3[i].to_le_bytes()); + let mut name_raw = [0u8; 36]; + let bytes = resource_names[i].as_bytes(); + name_raw[..bytes.len()].copy_from_slice(bytes); + payload.extend_from_slice(&name_raw); + payload.extend_from_slice(&resource_offsets[i].to_le_bytes()); + payload.extend_from_slice(&(i as u32).to_le_bytes()); // sort index + } + let total_size = u32::try_from(payload.len()).expect("size overflow"); + payload[12..16].copy_from_slice(&total_size.to_le_bytes()); + assert_eq!( + directory_offset + resource_types.len() * 64, + payload.len(), + "synthetic nested NRes layout invalid" + ); + + let model = parse_model_payload(&payload).expect("failed to parse synthetic model"); + assert_eq!(model.node_count, 1); + assert_eq!(model.positions.len(), 3); + assert_eq!(model.indices.len(), 3); + assert_eq!(model.batches.len(), 1); + assert_eq!(model.slot_index(0, 0, 0), Some(0)); +} diff --git a/crates/render-core/Cargo.toml b/crates/render-core/Cargo.toml new file mode 100644 index 0000000..4bdaa9e --- /dev/null +++ b/crates/render-core/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "render-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +msh-core = { path = "../msh-core" } +nres = { path = "../nres" } diff --git a/crates/render-core/README.md b/crates/render-core/README.md new file mode 100644 index 0000000..1b58aec --- /dev/null +++ b/crates/render-core/README.md @@ -0,0 +1,14 @@ +# render-core + +CPU-подготовка draw-данных для моделей `MSH`. + +Покрывает: + +- обход `node -> slot -> batch`; +- раскрытие индексов в triangle-list (`Vec<[f32;3]>`); +- расчёт bounds по вершинам. + +Тесты: + +- построение рендер-сеток на реальных `.msh` из `testdata`; +- unit-test bounds. diff --git a/crates/render-core/src/lib.rs b/crates/render-core/src/lib.rs new file mode 100644 index 0000000..8e0b5e8 --- /dev/null +++ b/crates/render-core/src/lib.rs @@ -0,0 +1,84 @@ +use msh_core::Model; + +#[derive(Clone, Debug)] +pub struct RenderMesh { + pub vertices: Vec<[f32; 3]>, + pub batch_count: usize, +} + +impl RenderMesh { + pub fn triangle_count(&self) -> usize { + self.vertices.len() / 3 + } +} + +/// Builds an expanded triangle list for a specific LOD/group pair. +/// +/// The output is suitable for simple `glDrawArrays(GL_TRIANGLES, ...)` paths. +pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh { + let mut vertices = Vec::new(); + let mut batch_count = 0usize; + + for node_index in 0..model.node_count { + let Some(slot_idx) = model.slot_index(node_index, lod, group) else { + continue; + }; + let Some(slot) = model.slots.get(slot_idx) else { + continue; + }; + let batch_start = usize::from(slot.batch_start); + let batch_end = batch_start.saturating_add(usize::from(slot.batch_count)); + if batch_end > model.batches.len() { + continue; + } + + for batch in &model.batches[batch_start..batch_end] { + let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX); + let index_count = usize::from(batch.index_count); + let index_end = index_start.saturating_add(index_count); + if index_end > model.indices.len() || index_count < 3 { + continue; + } + + for &idx in &model.indices[index_start..index_end] { + let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx)); + let Ok(final_idx) = usize::try_from(final_idx_u64) else { + continue; + }; + let Some(pos) = model.positions.get(final_idx) else { + continue; + }; + vertices.push(*pos); + } + batch_count += 1; + } + } + + RenderMesh { + vertices, + batch_count, + } +} + +pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> { + let mut iter = vertices.iter(); + let first = iter.next()?; + let mut min_v = *first; + let mut max_v = *first; + + for v in iter { + for i in 0..3 { + if v[i] < min_v[i] { + min_v[i] = v[i]; + } + if v[i] > max_v[i] { + max_v[i] = v[i]; + } + } + } + + Some((min_v, max_v)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/render-core/src/tests.rs b/crates/render-core/src/tests.rs new file mode 100644 index 0000000..9c5eb5d --- /dev/null +++ b/crates/render-core/src/tests.rs @@ -0,0 +1,101 @@ +use super::*; +use msh_core::parse_model_payload; +use nres::Archive; +use std::fs; +use std::path::{Path, PathBuf}; + +fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } +} + +fn nres_test_files() -> Vec<PathBuf> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + files + .into_iter() + .filter(|path| { + fs::read(path) + .map(|bytes| bytes.get(0..4) == Some(b"NRes")) + .unwrap_or(false) + }) + .collect() +} + +#[test] +fn build_render_mesh_for_real_models() { + let archives = nres_test_files(); + if archives.is_empty() { + eprintln!("skipping build_render_mesh_for_real_models: no NRes files in testdata"); + return; + } + + let mut models_checked = 0usize; + let mut meshes_non_empty = 0usize; + let mut bounds_non_empty = 0usize; + + for archive_path in archives { + let archive = Archive::open_path(&archive_path) + .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display())); + for entry in archive.entries() { + if !entry.meta.name.to_ascii_lowercase().ends_with(".msh") { + continue; + } + models_checked += 1; + let payload = archive.read(entry.id).unwrap_or_else(|err| { + panic!( + "failed to read model '{}' from {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| { + panic!( + "failed to parse model '{}' from {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + let mesh = build_render_mesh(&model, 0, 0); + if !mesh.vertices.is_empty() { + meshes_non_empty += 1; + } + if compute_bounds(&mesh.vertices).is_some() { + bounds_non_empty += 1; + } + } + } + + assert!(models_checked > 0, "no MSH models found"); + assert!( + meshes_non_empty > 0, + "all generated render meshes are empty" + ); + assert_eq!( + meshes_non_empty, bounds_non_empty, + "bounds must be available for every non-empty mesh" + ); +} + +#[test] +fn compute_bounds_handles_empty_and_non_empty() { + assert!(compute_bounds(&[]).is_none()); + let bounds = compute_bounds(&[[1.0, 2.0, 3.0], [-2.0, 5.0, 0.5], [0.0, -1.0, 9.0]]) + .expect("bounds expected"); + assert_eq!(bounds.0, [-2.0, -1.0, 0.5]); + assert_eq!(bounds.1, [1.0, 5.0, 9.0]); +} diff --git a/crates/render-demo/Cargo.toml b/crates/render-demo/Cargo.toml new file mode 100644 index 0000000..376a25e --- /dev/null +++ b/crates/render-demo/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "render-demo" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +demo = ["dep:sdl2", "dep:glow"] + +[dependencies] +msh-core = { path = "../msh-core" } +nres = { path = "../nres" } +render-core = { path = "../render-core" } +sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] } +glow = { version = "0.16", optional = true } + +[[bin]] +name = "parkan-render-demo" +path = "src/main.rs" +required-features = ["demo"] diff --git a/crates/render-demo/README.md b/crates/render-demo/README.md new file mode 100644 index 0000000..b33b18c --- /dev/null +++ b/crates/render-demo/README.md @@ -0,0 +1,30 @@ +# render-demo + +Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL ES 2.0`). + +## Назначение + +- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах. +- Служить минимальным reference-приложением. + +## Запуск + +```bash +cargo run -p render-demo --features demo -- \ + --archive "testdata/Parkan - Iron Strategy/animals.rlb" \ + --model "A_L_01.msh" \ + --lod 0 \ + --group 0 +``` + +Параметры: + +- `--archive` (обязательный): NRes-архив с `.msh` entry. +- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`. +- `--lod` (опционально, default `0`). +- `--group` (опционально, default `0`). + +## Ограничения + +- Рендер только геометрии (без материалов/текстур/FX). +- Вывод через `glDrawArrays(GL_TRIANGLES)` из расширенного triangle-list. diff --git a/crates/render-demo/build.rs b/crates/render-demo/build.rs new file mode 100644 index 0000000..126d1d7 --- /dev/null +++ b/crates/render-demo/build.rs @@ -0,0 +1,4 @@ +fn main() { + #[cfg(windows)] + println!("cargo:rustc-link-lib=advapi32"); +} diff --git a/crates/render-demo/src/lib.rs b/crates/render-demo/src/lib.rs new file mode 100644 index 0000000..4c73c09 --- /dev/null +++ b/crates/render-demo/src/lib.rs @@ -0,0 +1,113 @@ +use msh_core::{parse_model_payload, Model}; +use nres::Archive; +use std::path::Path; + +#[derive(Debug)] +pub enum Error { + Nres(nres::error::Error), + Msh(msh_core::error::Error), + NoMshEntries, + ModelNotFound(String), +} + +impl From<nres::error::Error> for Error { + fn from(value: nres::error::Error) -> Self { + Self::Nres(value) + } +} + +impl From<msh_core::error::Error> for Error { + fn from(value: msh_core::error::Error) -> Self { + Self::Msh(value) + } +} + +pub type Result<T> = core::result::Result<T, Error>; + +pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result<Model> { + let archive = Archive::open_path(path)?; + let mut msh_entries = Vec::new(); + for entry in archive.entries() { + if entry.meta.name.to_ascii_lowercase().ends_with(".msh") { + msh_entries.push((entry.id, entry.meta.name.clone())); + } + } + if msh_entries.is_empty() { + return Err(Error::NoMshEntries); + } + + let target_id = if let Some(name) = model_name { + msh_entries + .iter() + .find(|(_, n)| n.eq_ignore_ascii_case(name)) + .map(|(id, _)| *id) + .ok_or_else(|| Error::ModelNotFound(name.to_string()))? + } else { + msh_entries[0].0 + }; + + let payload = archive.read(target_id)?; + Ok(parse_model_payload(payload.as_slice())?) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + + fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } + } + + fn archive_with_msh() -> Option<PathBuf> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + for path in files { + let Ok(bytes) = fs::read(&path) else { + continue; + }; + if bytes.get(0..4) != Some(b"NRes") { + continue; + } + let Ok(archive) = Archive::open_path(&path) else { + continue; + }; + if archive + .entries() + .any(|entry| entry.meta.name.to_ascii_lowercase().ends_with(".msh")) + { + return Some(path); + } + } + None + } + + #[test] + fn load_model_from_real_archive() { + let Some(path) = archive_with_msh() else { + eprintln!("skipping load_model_from_real_archive: no .msh archives in testdata"); + return; + }; + let model = load_model_from_archive(&path, None) + .unwrap_or_else(|err| panic!("failed to load model from {}: {err:?}", path.display())); + assert!(model.node_count > 0); + assert!(!model.positions.is_empty()); + assert!(!model.indices.is_empty()); + } +} diff --git a/crates/render-demo/src/main.rs b/crates/render-demo/src/main.rs new file mode 100644 index 0000000..c991c80 --- /dev/null +++ b/crates/render-demo/src/main.rs @@ -0,0 +1,357 @@ +use glow::HasContext as _; +use render_core::{build_render_mesh, compute_bounds}; +use render_demo::load_model_from_archive; +use std::path::PathBuf; +use std::time::Instant; + +struct Args { + archive: PathBuf, + model: Option<String>, + lod: usize, + group: usize, +} + +fn parse_args() -> Result<Args, String> { + let mut archive = None; + let mut model = None; + let mut lod = 0usize; + let mut group = 0usize; + + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + match arg.as_str() { + "--archive" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --archive"))?; + archive = Some(PathBuf::from(value)); + } + "--model" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --model"))?; + model = Some(value); + } + "--lod" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --lod"))?; + lod = value + .parse::<usize>() + .map_err(|_| String::from("invalid --lod value"))?; + } + "--group" => { + let value = it + .next() + .ok_or_else(|| String::from("missing value for --group"))?; + group = value + .parse::<usize>() + .map_err(|_| String::from("invalid --group value"))?; + } + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + other => { + return Err(format!("unknown argument: {other}")); + } + } + } + + let archive = archive.ok_or_else(|| String::from("missing required --archive"))?; + Ok(Args { + archive, + model, + lod, + group, + }) +} + +fn print_help() { + eprintln!("parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N]"); +} + +fn main() { + let args = match parse_args() { + Ok(v) => v, + Err(err) => { + eprintln!("{err}"); + print_help(); + std::process::exit(2); + } + }; + + let model = match load_model_from_archive(&args.archive, args.model.as_deref()) { + Ok(v) => v, + Err(err) => { + eprintln!("failed to load model: {err:?}"); + std::process::exit(1); + } + }; + + let mesh = build_render_mesh(&model, args.lod, args.group); + if mesh.vertices.is_empty() { + eprintln!( + "model has no renderable triangles for lod={} group={}", + args.lod, args.group + ); + std::process::exit(1); + } + let Some((bounds_min, bounds_max)) = compute_bounds(&mesh.vertices) else { + eprintln!("failed to compute mesh bounds"); + std::process::exit(1); + }; + + let center = [ + 0.5 * (bounds_min[0] + bounds_max[0]), + 0.5 * (bounds_min[1] + bounds_max[1]), + 0.5 * (bounds_min[2] + bounds_max[2]), + ]; + let extent = [ + bounds_max[0] - bounds_min[0], + bounds_max[1] - bounds_min[1], + bounds_max[2] - bounds_min[2], + ]; + let radius = + (extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5; + let camera_distance = (radius * 2.5).max(2.0); + + let sdl = sdl2::init().expect("failed to init SDL2"); + let video = sdl.video().expect("failed to init SDL2 video"); + + { + let gl_attr = video.gl_attr(); + gl_attr.set_context_profile(sdl2::video::GLProfile::GLES); + gl_attr.set_context_version(2, 0); + gl_attr.set_depth_size(24); + gl_attr.set_double_buffer(true); + } + + let window = video + .window("Parkan Render Demo (SDL2 + OpenGL ES 2.0)", 1280, 720) + .opengl() + .resizable() + .build() + .expect("failed to create window"); + + let gl_ctx = window + .gl_create_context() + .expect("failed to create OpenGL context"); + window + .gl_make_current(&gl_ctx) + .expect("failed to make GL context current"); + let _ = video.gl_set_swap_interval(1); + + let mut vertices_flat = Vec::with_capacity(mesh.vertices.len() * 3); + for pos in &mesh.vertices { + vertices_flat.extend_from_slice(pos); + } + + let gl = unsafe { + glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _) + }; + + let program = unsafe { create_program(&gl).expect("failed to create shader program") }; + let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") }; + let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }; + let a_pos = a_pos.expect("shader attribute a_pos is missing"); + + let vbo = unsafe { gl.create_buffer().expect("failed to create VBO") }; + unsafe { + gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); + gl.buffer_data_u8_slice( + glow::ARRAY_BUFFER, + cast_slice_u8(&vertices_flat), + glow::STATIC_DRAW, + ); + gl.bind_buffer(glow::ARRAY_BUFFER, None); + } + + let mut events = sdl.event_pump().expect("failed to get SDL event pump"); + let start = Instant::now(); + + 'main_loop: loop { + for event in events.poll_iter() { + match event { + sdl2::event::Event::Quit { .. } => break 'main_loop, + sdl2::event::Event::KeyDown { + keycode: Some(sdl2::keyboard::Keycode::Escape), + .. + } => break 'main_loop, + _ => {} + } + } + + let elapsed = start.elapsed().as_secs_f32(); + let (w, h) = window.size(); + let aspect = (w as f32 / (h.max(1) as f32)).max(0.01); + + let proj = mat4_perspective(60.0_f32.to_radians(), aspect, 0.01, camera_distance * 10.0); + let view = mat4_translation(0.0, 0.0, -camera_distance); + let center_shift = mat4_translation(-center[0], -center[1], -center[2]); + let rot = mat4_rotation_y(elapsed * 0.35); + let model_m = mat4_mul(&rot, ¢er_shift); + let vp = mat4_mul(&view, &model_m); + let mvp = mat4_mul(&proj, &vp); + + unsafe { + gl.viewport(0, 0, w as i32, h as i32); + gl.enable(glow::DEPTH_TEST); + gl.clear_color(0.06, 0.08, 0.12, 1.0); + gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); + + gl.use_program(Some(program)); + gl.uniform_matrix_4_f32_slice(u_mvp.as_ref(), false, &mvp); + + gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); + gl.enable_vertex_attrib_array(a_pos); + gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 12, 0); + gl.draw_arrays( + glow::TRIANGLES, + 0, + i32::try_from(mesh.vertices.len()).unwrap_or(i32::MAX), + ); + gl.disable_vertex_attrib_array(a_pos); + gl.bind_buffer(glow::ARRAY_BUFFER, None); + gl.use_program(None); + } + + window.gl_swap_window(); + } + + unsafe { + gl.delete_buffer(vbo); + gl.delete_program(program); + } +} + +unsafe fn create_program(gl: &glow::Context) -> Result<glow::NativeProgram, String> { + let vs_src = r#" +attribute vec3 a_pos; +uniform mat4 u_mvp; +void main() { + gl_Position = u_mvp * vec4(a_pos, 1.0); +} +"#; + + let fs_src = r#" +precision mediump float; +void main() { + gl_FragColor = vec4(0.85, 0.90, 1.00, 1.0); +} +"#; + + let program = gl.create_program().map_err(|e| e.to_string())?; + let vs = gl + .create_shader(glow::VERTEX_SHADER) + .map_err(|e| e.to_string())?; + let fs = gl + .create_shader(glow::FRAGMENT_SHADER) + .map_err(|e| e.to_string())?; + + gl.shader_source(vs, vs_src); + gl.compile_shader(vs); + if !gl.get_shader_compile_status(vs) { + let log = gl.get_shader_info_log(vs); + gl.delete_shader(vs); + gl.delete_shader(fs); + gl.delete_program(program); + return Err(format!("vertex shader compile failed: {log}")); + } + + gl.shader_source(fs, fs_src); + gl.compile_shader(fs); + if !gl.get_shader_compile_status(fs) { + let log = gl.get_shader_info_log(fs); + gl.delete_shader(vs); + gl.delete_shader(fs); + gl.delete_program(program); + return Err(format!("fragment shader compile failed: {log}")); + } + + gl.attach_shader(program, vs); + gl.attach_shader(program, fs); + gl.link_program(program); + + gl.detach_shader(program, vs); + gl.detach_shader(program, fs); + gl.delete_shader(vs); + gl.delete_shader(fs); + + if !gl.get_program_link_status(program) { + let log = gl.get_program_info_log(program); + gl.delete_program(program); + return Err(format!("program link failed: {log}")); + } + + Ok(program) +} + +fn cast_slice_u8<T>(slice: &[T]) -> &[u8] { + unsafe { std::slice::from_raw_parts(slice.as_ptr() as *const u8, std::mem::size_of_val(slice)) } +} + +fn mat4_identity() -> [f32; 16] { + [ + 1.0, 0.0, 0.0, 0.0, // + 0.0, 1.0, 0.0, 0.0, // + 0.0, 0.0, 1.0, 0.0, // + 0.0, 0.0, 0.0, 1.0, // + ] +} + +fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] { + let mut m = mat4_identity(); + m[12] = x; + m[13] = y; + m[14] = z; + m +} + +fn mat4_rotation_y(rad: f32) -> [f32; 16] { + let c = rad.cos(); + let s = rad.sin(); + [ + c, 0.0, -s, 0.0, // + 0.0, 1.0, 0.0, 0.0, // + s, 0.0, c, 0.0, // + 0.0, 0.0, 0.0, 1.0, // + ] +} + +fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] { + let f = 1.0 / (0.5 * fovy).tan(); + let nf = 1.0 / (near - far); + [ + f / aspect, + 0.0, + 0.0, + 0.0, + 0.0, + f, + 0.0, + 0.0, + 0.0, + 0.0, + (far + near) * nf, + -1.0, + 0.0, + 0.0, + (2.0 * far * near) * nf, + 0.0, + ] +} + +fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] { + let mut out = [0.0f32; 16]; + for c in 0..4 { + for r in 0..4 { + let mut acc = 0.0f32; + for k in 0..4 { + acc += a[k * 4 + r] * b[c * 4 + k]; + } + out[c * 4 + r] = acc; + } + } + out +} diff --git a/crates/texm/Cargo.toml b/crates/texm/Cargo.toml new file mode 100644 index 0000000..7085293 --- /dev/null +++ b/crates/texm/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "texm" +version = "0.1.0" +edition = "2021" + +[dependencies] +nres = { path = "../nres" } diff --git a/crates/texm/README.md b/crates/texm/README.md new file mode 100644 index 0000000..370ac54 --- /dev/null +++ b/crates/texm/README.md @@ -0,0 +1,15 @@ +# texm + +Парсер формата текстур `Texm`. + +Покрывает: + +- header (`width/height/mipCount/flags/format`); +- core size расчёт; +- optional `Page` chunk; +- строгую валидацию layout. + +Тесты: + +- прогон по реальным `Texm` из `testdata`; +- синтетические edge-cases (indexed + page, minimal rgba). diff --git a/crates/texm/src/error.rs b/crates/texm/src/error.rs new file mode 100644 index 0000000..a5dda77 --- /dev/null +++ b/crates/texm/src/error.rs @@ -0,0 +1,61 @@ +use core::fmt; + +#[derive(Debug)] +pub enum Error { + HeaderTooSmall { + size: usize, + }, + InvalidMagic { + got: u32, + }, + InvalidDimensions { + width: u32, + height: u32, + }, + InvalidMipCount { + mip_count: u32, + }, + UnknownFormat { + format: u32, + }, + IntegerOverflow, + CoreDataOutOfBounds { + expected_end: usize, + actual_size: usize, + }, + InvalidPageMagic, + InvalidPageSize { + expected: usize, + actual: usize, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HeaderTooSmall { size } => { + write!(f, "Texm payload too small for header: {size}") + } + Self::InvalidMagic { got } => write!(f, "invalid Texm magic: 0x{got:08X}"), + Self::InvalidDimensions { width, height } => { + write!(f, "invalid Texm dimensions: {width}x{height}") + } + Self::InvalidMipCount { mip_count } => write!(f, "invalid Texm mip_count={mip_count}"), + Self::UnknownFormat { format } => write!(f, "unknown Texm format={format}"), + Self::IntegerOverflow => write!(f, "integer overflow"), + Self::CoreDataOutOfBounds { + expected_end, + actual_size, + } => write!( + f, + "Texm core data out of bounds: expected_end={expected_end}, actual_size={actual_size}" + ), + Self::InvalidPageMagic => write!(f, "Texm tail exists but Page magic is missing"), + Self::InvalidPageSize { expected, actual } => { + write!(f, "invalid Page chunk size: expected={expected}, actual={actual}") + } + } + } +} + +impl std::error::Error for Error {} diff --git a/crates/texm/src/lib.rs b/crates/texm/src/lib.rs new file mode 100644 index 0000000..c3616d5 --- /dev/null +++ b/crates/texm/src/lib.rs @@ -0,0 +1,258 @@ +pub mod error; + +use crate::error::Error; + +pub type Result<T> = core::result::Result<T, Error>; + +pub const TEXM_MAGIC: u32 = 0x6D78_6554; +pub const PAGE_MAGIC: u32 = 0x6567_6150; + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum PixelFormat { + Indexed8, + Rgb565, + Rgb556, + Argb4444, + LuminanceAlpha88, + Rgb888, + Argb8888, +} + +impl PixelFormat { + pub fn from_raw(raw: u32) -> Option<Self> { + match raw { + 0 => Some(Self::Indexed8), + 565 => Some(Self::Rgb565), + 556 => Some(Self::Rgb556), + 4444 => Some(Self::Argb4444), + 88 => Some(Self::LuminanceAlpha88), + 888 => Some(Self::Rgb888), + 8888 => Some(Self::Argb8888), + _ => None, + } + } + + pub fn bytes_per_pixel(self) -> usize { + match self { + Self::Indexed8 => 1, + Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2, + Self::Rgb888 | Self::Argb8888 => 4, + } + } +} + +#[derive(Clone, Debug)] +pub struct Header { + pub width: u32, + pub height: u32, + pub mip_count: u32, + pub flags4: u32, + pub flags5: u32, + pub unk6: u32, + pub format_raw: u32, + pub format: PixelFormat, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct MipLevel { + pub width: u32, + pub height: u32, + pub offset: usize, + pub size: usize, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct PageRect { + pub x: i16, + pub w: i16, + pub y: i16, + pub h: i16, +} + +#[derive(Clone, Debug)] +pub struct Texture { + pub header: Header, + pub palette: Option<[u8; 1024]>, + pub mip_levels: Vec<MipLevel>, + pub page_rects: Vec<PageRect>, +} + +impl Texture { + pub fn core_size(&self) -> usize { + let mut size = 32usize; + if self.palette.is_some() { + size += 1024; + } + for level in &self.mip_levels { + size += level.size; + } + size + } +} + +pub fn parse_texm(payload: &[u8]) -> Result<Texture> { + if payload.len() < 32 { + return Err(Error::HeaderTooSmall { + size: payload.len(), + }); + } + + let magic = read_u32(payload, 0)?; + if magic != TEXM_MAGIC { + return Err(Error::InvalidMagic { got: magic }); + } + + let width = read_u32(payload, 4)?; + let height = read_u32(payload, 8)?; + let mip_count = read_u32(payload, 12)?; + let flags4 = read_u32(payload, 16)?; + let flags5 = read_u32(payload, 20)?; + let unk6 = read_u32(payload, 24)?; + let format_raw = read_u32(payload, 28)?; + + if width == 0 || height == 0 { + return Err(Error::InvalidDimensions { width, height }); + } + if mip_count == 0 { + return Err(Error::InvalidMipCount { mip_count }); + } + + let format = + PixelFormat::from_raw(format_raw).ok_or(Error::UnknownFormat { format: format_raw })?; + let bytes_per_pixel = format.bytes_per_pixel(); + + let mut offset = 32usize; + let palette = if format == PixelFormat::Indexed8 { + let end = offset.checked_add(1024).ok_or(Error::IntegerOverflow)?; + if end > payload.len() { + return Err(Error::CoreDataOutOfBounds { + expected_end: end, + actual_size: payload.len(), + }); + } + let mut pal = [0u8; 1024]; + pal.copy_from_slice(&payload[offset..end]); + offset = end; + Some(pal) + } else { + None + }; + + let mut mip_levels = + Vec::with_capacity(usize::try_from(mip_count).map_err(|_| Error::IntegerOverflow)?); + let mut w = width; + let mut h = height; + for _ in 0..mip_count { + let pixel_count_u64 = u64::from(w) + .checked_mul(u64::from(h)) + .ok_or(Error::IntegerOverflow)?; + let level_size_u64 = pixel_count_u64 + .checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| Error::IntegerOverflow)?) + .ok_or(Error::IntegerOverflow)?; + let level_size = usize::try_from(level_size_u64).map_err(|_| Error::IntegerOverflow)?; + let level_offset = offset; + offset = offset + .checked_add(level_size) + .ok_or(Error::IntegerOverflow)?; + if offset > payload.len() { + return Err(Error::CoreDataOutOfBounds { + expected_end: offset, + actual_size: payload.len(), + }); + } + mip_levels.push(MipLevel { + width: w, + height: h, + offset: level_offset, + size: level_size, + }); + w = w.max(1) >> 1; + h = h.max(1) >> 1; + if w == 0 { + w = 1; + } + if h == 0 { + h = 1; + } + } + + let page_rects = parse_page_tail(payload, offset)?; + + Ok(Texture { + header: Header { + width, + height, + mip_count, + flags4, + flags5, + unk6, + format_raw, + format, + }, + palette, + mip_levels, + page_rects, + }) +} + +fn parse_page_tail(payload: &[u8], core_end: usize) -> Result<Vec<PageRect>> { + if core_end == payload.len() { + return Ok(Vec::new()); + } + if payload.len().saturating_sub(core_end) < 8 { + return Err(Error::InvalidPageSize { + expected: 8, + actual: payload.len().saturating_sub(core_end), + }); + } + let magic = read_u32(payload, core_end)?; + if magic != PAGE_MAGIC { + return Err(Error::InvalidPageMagic); + } + let rect_count = read_u32(payload, core_end + 4)?; + let rect_count_usize = usize::try_from(rect_count).map_err(|_| Error::IntegerOverflow)?; + let expected_size = 8usize + .checked_add( + rect_count_usize + .checked_mul(8) + .ok_or(Error::IntegerOverflow)?, + ) + .ok_or(Error::IntegerOverflow)?; + let actual = payload.len().saturating_sub(core_end); + if expected_size != actual { + return Err(Error::InvalidPageSize { + expected: expected_size, + actual, + }); + } + + let mut rects = Vec::with_capacity(rect_count_usize); + for i in 0..rect_count_usize { + let off = core_end + .checked_add(8) + .and_then(|v| v.checked_add(i * 8)) + .ok_or(Error::IntegerOverflow)?; + rects.push(PageRect { + x: read_i16(payload, off)?, + w: read_i16(payload, off + 2)?, + y: read_i16(payload, off + 4)?, + h: read_i16(payload, off + 6)?, + }); + } + Ok(rects) +} + +fn read_u32(data: &[u8], offset: usize) -> Result<u32> { + let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(u32::from_le_bytes(arr)) +} + +fn read_i16(data: &[u8], offset: usize) -> Result<i16> { + let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?; + let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?; + Ok(i16::from_le_bytes(arr)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/texm/src/tests.rs b/crates/texm/src/tests.rs new file mode 100644 index 0000000..d021346 --- /dev/null +++ b/crates/texm/src/tests.rs @@ -0,0 +1,150 @@ +use super::*; +use nres::Archive; +use std::fs; +use std::path::{Path, PathBuf}; + +fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } +} + +fn nres_test_files() -> Vec<PathBuf> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata"); + let mut files = Vec::new(); + collect_files_recursive(&root, &mut files); + files.sort(); + files + .into_iter() + .filter(|path| { + fs::read(path) + .map(|bytes| bytes.get(0..4) == Some(b"NRes")) + .unwrap_or(false) + }) + .collect() +} + +#[test] +fn texm_parse_all_game_textures() { + let archives = nres_test_files(); + if archives.is_empty() { + eprintln!("skipping texm_parse_all_game_textures: no NRes files in testdata"); + return; + } + + let mut texm_total = 0usize; + let mut texm_with_page = 0usize; + for archive_path in archives { + let archive = Archive::open_path(&archive_path) + .unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display())); + + for entry in archive.entries() { + if entry.meta.kind != TEXM_MAGIC { + continue; + } + texm_total += 1; + let payload = archive.read(entry.id).unwrap_or_else(|err| { + panic!( + "failed to read Texm entry '{}' in {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + let texture = parse_texm(payload.as_slice()).unwrap_or_else(|err| { + panic!( + "failed to parse Texm '{}' in {}: {err}", + entry.meta.name, + archive_path.display() + ) + }); + if !texture.page_rects.is_empty() { + texm_with_page += 1; + } + + assert!( + texture.core_size() <= payload.as_slice().len(), + "core size must be within payload for '{}' in {}", + entry.meta.name, + archive_path.display() + ); + assert_eq!( + usize::try_from(texture.header.mip_count).ok(), + Some(texture.mip_levels.len()), + "mip count mismatch for '{}' in {}", + entry.meta.name, + archive_path.display() + ); + } + } + + assert!(texm_total > 0, "no Texm textures found"); + assert!( + texm_with_page > 0, + "expected at least one Texm texture with Page chunk" + ); +} + +#[test] +fn texm_parse_minimal_argb8888_no_page() { + let mut payload = Vec::new(); + payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); + payload.extend_from_slice(&1u32.to_le_bytes()); // width + payload.extend_from_slice(&1u32.to_le_bytes()); // height + payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count + payload.extend_from_slice(&0u32.to_le_bytes()); // flags4 + payload.extend_from_slice(&0u32.to_le_bytes()); // flags5 + payload.extend_from_slice(&0u32.to_le_bytes()); // unk6 + payload.extend_from_slice(&8888u32.to_le_bytes()); // format + payload.extend_from_slice(&[1, 2, 3, 4]); // one pixel + + let parsed = parse_texm(&payload).expect("failed to parse minimal texm"); + assert_eq!(parsed.header.width, 1); + assert_eq!(parsed.header.height, 1); + assert_eq!(parsed.mip_levels.len(), 1); + assert!(parsed.page_rects.is_empty()); +} + +#[test] +fn texm_parse_indexed_with_page_chunk() { + let mut payload = Vec::new(); + payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); + payload.extend_from_slice(&2u32.to_le_bytes()); // width + payload.extend_from_slice(&2u32.to_le_bytes()); // height + payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count + payload.extend_from_slice(&0u32.to_le_bytes()); // flags4 + payload.extend_from_slice(&0u32.to_le_bytes()); // flags5 + payload.extend_from_slice(&0u32.to_le_bytes()); // unk6 + payload.extend_from_slice(&0u32.to_le_bytes()); // format indexed8 + payload.extend_from_slice(&[0u8; 1024]); // palette + payload.extend_from_slice(&[1, 2, 3, 4]); // pixels + payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes()); + payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count + payload.extend_from_slice(&0i16.to_le_bytes()); // x + payload.extend_from_slice(&2i16.to_le_bytes()); // w + payload.extend_from_slice(&0i16.to_le_bytes()); // y + payload.extend_from_slice(&2i16.to_le_bytes()); // h + + let parsed = parse_texm(&payload).expect("failed to parse indexed texm"); + assert!(parsed.palette.is_some()); + assert_eq!(parsed.page_rects.len(), 1); + assert_eq!( + parsed.page_rects[0], + PageRect { + x: 0, + w: 2, + y: 0, + h: 2 + } + ); +} |
