aboutsummaryrefslogtreecommitdiff
path: root/crates/msh-core/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/msh-core/src
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz
fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/msh-core/src')
-rw-r--r--crates/msh-core/src/error.rs75
-rw-r--r--crates/msh-core/src/lib.rs434
-rw-r--r--crates/msh-core/src/tests.rs438
3 files changed, 0 insertions, 947 deletions
diff --git a/crates/msh-core/src/error.rs b/crates/msh-core/src/error.rs
deleted file mode 100644
index d46c7b1..0000000
--- a/crates/msh-core/src/error.rs
+++ /dev/null
@@ -1,75 +0,0 @@
-use core::fmt;
-
-#[derive(Debug)]
-#[non_exhaustive]
-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
deleted file mode 100644
index bc51357..0000000
--- a/crates/msh-core/src/lib.rs
+++ /dev/null
@@ -1,434 +0,0 @@
-pub mod error;
-
-use crate::error::Error;
-use encoding_rs::WINDOWS_1251;
-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)?;
- validate_slot_batch_ranges(&slots, batches.len())?;
- validate_batch_index_ranges(&batches, indices.len())?;
-
- 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 validate_slot_batch_ranges(slots: &[Slot], batch_count: usize) -> Result<()> {
- for slot in slots {
- let start = usize::from(slot.batch_start);
- let end = start
- .checked_add(usize::from(slot.batch_count))
- .ok_or(Error::IntegerOverflow)?;
- if end > batch_count {
- return Err(Error::IndexOutOfBounds {
- label: "Res2.batch_range",
- index: end,
- limit: batch_count,
- });
- }
- }
- Ok(())
-}
-
-fn validate_batch_index_ranges(batches: &[Batch], index_count: usize) -> Result<()> {
- for batch in batches {
- let start = usize::try_from(batch.index_start).map_err(|_| Error::IntegerOverflow)?;
- let end = start
- .checked_add(usize::from(batch.index_count))
- .ok_or(Error::IntegerOverflow)?;
- if end > index_count {
- return Err(Error::IndexOutOfBounds {
- label: "Res13.index_range",
- index: end,
- limit: index_count,
- });
- }
- }
- Ok(())
-}
-
-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 = decode_cp1251(text);
- out.push(Some(decoded));
- off = end;
- }
- Ok(out)
-}
-
-fn decode_cp1251(bytes: &[u8]) -> String {
- let (decoded, _, _) = WINDOWS_1251.decode(bytes);
- decoded.into_owned()
-}
-
-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
deleted file mode 100644
index 90a7fdc..0000000
--- a/crates/msh-core/src/tests.rs
+++ /dev/null
@@ -1,438 +0,0 @@
-use super::*;
-use common::collect_files_recursive;
-use nres::Archive;
-use proptest::prelude::*;
-use std::fs;
-use std::path::{Path, PathBuf};
-
-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")
-}
-
-#[derive(Clone)]
-struct SyntheticEntry {
- kind: u32,
- name: String,
- attr1: u32,
- attr2: u32,
- attr3: u32,
- data: Vec<u8>,
-}
-
-fn build_nested_nres(entries: &[SyntheticEntry]) -> Vec<u8> {
- let mut payload = Vec::new();
- payload.extend_from_slice(b"NRes");
- payload.extend_from_slice(&0x100u32.to_le_bytes());
- payload.extend_from_slice(
- &u32::try_from(entries.len())
- .expect("entry count overflow in test")
- .to_le_bytes(),
- );
- payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder
-
- let mut resource_offsets = Vec::with_capacity(entries.len());
- for entry in entries {
- resource_offsets.push(u32::try_from(payload.len()).expect("offset overflow in test"));
- payload.extend_from_slice(&entry.data);
- while !payload.len().is_multiple_of(8) {
- payload.push(0);
- }
- }
-
- for (index, entry) in entries.iter().enumerate() {
- payload.extend_from_slice(&entry.kind.to_le_bytes());
- payload.extend_from_slice(&entry.attr1.to_le_bytes());
- payload.extend_from_slice(&entry.attr2.to_le_bytes());
- payload.extend_from_slice(
- &u32::try_from(entry.data.len())
- .expect("size overflow in test")
- .to_le_bytes(),
- );
- payload.extend_from_slice(&entry.attr3.to_le_bytes());
-
- let mut name_raw = [0u8; 36];
- let name_bytes = entry.name.as_bytes();
- assert!(name_bytes.len() <= 35, "name too long for synthetic test");
- name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
- payload.extend_from_slice(&name_raw);
-
- payload.extend_from_slice(&resource_offsets[index].to_le_bytes());
- payload.extend_from_slice(&(index as u32).to_le_bytes());
- }
-
- let total_size = u32::try_from(payload.len()).expect("size overflow in test");
- payload[12..16].copy_from_slice(&total_size.to_le_bytes());
- payload
-}
-
-fn synthetic_entry(kind: u32, name: &str, attr3: u32, data: Vec<u8>) -> SyntheticEntry {
- SyntheticEntry {
- kind,
- name: name.to_string(),
- attr1: 1,
- attr2: 0,
- attr3,
- data,
- }
-}
-
-fn res1_stride38_nodes(node_count: usize, node0_slot00: Option<u16>) -> Vec<u8> {
- let mut out = vec![0u8; node_count.saturating_mul(38)];
- for node in 0..node_count {
- let node_off = node * 38;
- for i in 0..15 {
- let off = node_off + 8 + i * 2;
- out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
- }
- }
- if let Some(slot) = node0_slot00 {
- out[8..10].copy_from_slice(&slot.to_le_bytes());
- }
- out
-}
-
-fn res1_stride24_nodes(node_count: usize) -> Vec<u8> {
- vec![0u8; node_count.saturating_mul(24)]
-}
-
-fn res2_single_slot(batch_start: u16, batch_count: u16) -> Vec<u8> {
- 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(&batch_start.to_le_bytes()); // batch_start
- res2[0x8C + 6..0x8C + 8].copy_from_slice(&batch_count.to_le_bytes()); // batch_count
- res2
-}
-
-fn res3_triangle_positions() -> Vec<u8> {
- [0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32]
- .iter()
- .flat_map(|v| v.to_le_bytes())
- .collect()
-}
-
-fn res4_normals() -> Vec<u8> {
- vec![127u8, 0u8, 128u8, 0u8]
-}
-
-fn res5_uv0() -> Vec<u8> {
- [1024i16, -1024i16]
- .iter()
- .flat_map(|v| v.to_le_bytes())
- .collect()
-}
-
-fn res6_triangle_indices() -> Vec<u8> {
- [0u16, 1u16, 2u16]
- .iter()
- .flat_map(|v| v.to_le_bytes())
- .collect()
-}
-
-fn res13_single_batch(index_start: u32, index_count: u16) -> Vec<u8> {
- let mut batch = vec![0u8; 20];
- batch[0..2].copy_from_slice(&0u16.to_le_bytes());
- batch[2..4].copy_from_slice(&0u16.to_le_bytes());
- batch[8..10].copy_from_slice(&index_count.to_le_bytes());
- batch[10..14].copy_from_slice(&index_start.to_le_bytes());
- batch[16..20].copy_from_slice(&0u32.to_le_bytes());
- batch
-}
-
-fn res10_names_raw(names: &[Option<&[u8]>]) -> Vec<u8> {
- let mut out = Vec::new();
- for name in names {
- match name {
- Some(name) => {
- out.extend_from_slice(
- &u32::try_from(name.len())
- .expect("name size overflow in test")
- .to_le_bytes(),
- );
- out.extend_from_slice(name);
- out.push(0);
- }
- None => out.extend_from_slice(&0u32.to_le_bytes()),
- }
- }
- out
-}
-
-fn res10_names(names: &[Option<&str>]) -> Vec<u8> {
- let raw: Vec<Option<&[u8]>> = names.iter().map(|name| name.map(str::as_bytes)).collect();
- res10_names_raw(&raw)
-}
-
-fn base_synthetic_entries() -> Vec<SyntheticEntry> {
- vec![
- synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))),
- synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 1)),
- synthetic_entry(RES3_POSITIONS, "Res3", 12, res3_triangle_positions()),
- synthetic_entry(RES6_INDICES, "Res6", 2, res6_triangle_indices()),
- synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(0, 3)),
- ]
-}
-
-#[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() {
- let payload = build_nested_nres(&base_synthetic_entries());
- 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));
-}
-
-#[test]
-fn parse_synthetic_stride24_variant() {
- let mut entries = base_synthetic_entries();
- entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 24, res1_stride24_nodes(1));
- let payload = build_nested_nres(&entries);
-
- let model = parse_model_payload(&payload).expect("failed to parse stride24 model");
- assert_eq!(model.node_stride, 24);
- assert_eq!(model.node_count, 1);
- assert_eq!(model.slot_index(0, 0, 0), None);
-}
-
-#[test]
-fn parse_synthetic_model_with_optional_res4_res5_res10() {
- let mut entries = base_synthetic_entries();
- entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, res4_normals()));
- entries.push(synthetic_entry(RES5_UV0, "Res5", 4, res5_uv0()));
- entries.push(synthetic_entry(
- RES10_NAMES,
- "Res10",
- 1,
- res10_names(&[Some("Hull"), None]),
- ));
- entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(2, Some(0)));
- let payload = build_nested_nres(&entries);
-
- let model = parse_model_payload(&payload).expect("failed to parse model with optional data");
- assert_eq!(model.node_count, 2);
- assert_eq!(model.normals.as_ref().map(Vec::len), Some(1));
- assert_eq!(model.uv0.as_ref().map(Vec::len), Some(1));
- assert_eq!(model.node_names, Some(vec![Some("Hull".to_string()), None]));
-}
-
-#[test]
-fn parse_res10_names_decodes_cp1251() {
- let mut entries = base_synthetic_entries();
- entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0)));
- entries.push(synthetic_entry(
- RES10_NAMES,
- "Res10",
- 1,
- res10_names_raw(&[Some(&[0xC0])]),
- ));
- let payload = build_nested_nres(&entries);
-
- let model = parse_model_payload(&payload).expect("failed to parse model with cp1251 name");
- assert_eq!(model.node_names, Some(vec![Some("А".to_string())]));
-}
-
-#[test]
-fn parse_fails_when_required_resource_missing() {
- let mut entries = base_synthetic_entries();
- entries.retain(|entry| entry.kind != RES13_BATCHES);
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::MissingResource {
- kind: RES13_BATCHES,
- label: "Res13"
- })
- ));
-}
-
-#[test]
-fn parse_fails_for_invalid_res2_size() {
- let mut entries = base_synthetic_entries();
- entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, vec![0u8; 0x8B]);
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::InvalidRes2Size { .. })
- ));
-}
-
-#[test]
-fn parse_fails_for_unsupported_node_stride() {
- let mut entries = base_synthetic_entries();
- entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 30, vec![0u8; 30]);
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::UnsupportedNodeStride { stride: 30 })
- ));
-}
-
-#[test]
-fn parse_fails_for_invalid_optional_resource_size() {
- let mut entries = base_synthetic_entries();
- entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, vec![1, 2, 3]));
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::InvalidResourceSize { label: "Res4", .. })
- ));
-}
-
-#[test]
-fn parse_fails_for_slot_batch_range_out_of_bounds() {
- let mut entries = base_synthetic_entries();
- entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 2));
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::IndexOutOfBounds {
- label: "Res2.batch_range",
- ..
- })
- ));
-}
-
-#[test]
-fn parse_fails_for_batch_index_range_out_of_bounds() {
- let mut entries = base_synthetic_entries();
- entries[4] = synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(1, 3));
- let payload = build_nested_nres(&entries);
-
- assert!(matches!(
- parse_model_payload(&payload),
- Err(Error::IndexOutOfBounds {
- label: "Res13.index_range",
- ..
- })
- ));
-}
-
-proptest! {
- #![proptest_config(ProptestConfig::with_cases(64))]
-
- #[test]
- fn parse_model_payload_never_panics_on_random_bytes(data in proptest::collection::vec(any::<u8>(), 0..8192)) {
- let _ = parse_model_payload(&data);
- }
-}