diff options
| author | Valentin Popov <valentin@popov.link> | 2026-02-19 12:46:23 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-02-19 12:46:23 +0300 |
| commit | efab61a45c8837d3c2aaec464d8f6243fecb7a38 (patch) | |
| tree | b511f1cab917f5f2931d6bc2ae2676b553bb8ef9 /crates/msh-core | |
| parent | 0d7ae6a017b8b2bf26c5c14c39cb62b599e8262d (diff) | |
| download | fparkan-efab61a45c8837d3c2aaec464d8f6243fecb7a38.tar.xz fparkan-efab61a45c8837d3c2aaec464d8f6243fecb7a38.zip | |
feat(render-core): add default UV scale and refactor UV mapping logic
- Introduced a constant `DEFAULT_UV_SCALE` for UV scaling.
- Refactored UV mapping in `build_render_mesh` to use the new constant.
- Simplified `compute_bounds` functions by extracting common logic into `compute_bounds_impl`.
test(render-core): add tests for rendering with empty and multi-node models
- Added tests to verify behavior when building render meshes from models with no slots and multiple nodes.
- Ensured UV scaling is correctly applied in tests.
feat(render-demo): add FOV argument and improve error handling
- Added a `--fov` command-line argument to set the field of view.
- Enhanced error messages for texture resolution failures.
- Updated MVP computation to use the new FOV parameter.
fix(rsli): improve error handling in LZH decompression
- Added checks to prevent out-of-bounds access in LZH decoding logic.
refactor(texm): streamline texture parsing and decoding tests
- Created a helper function `build_texm_payload` for constructing test payloads.
- Added tests for various texture formats including RGB565, RGB556, ARGB4444, and Luminance Alpha.
- Improved error handling for invalid TEXM headers and mip bounds.
Diffstat (limited to 'crates/msh-core')
| -rw-r--r-- | crates/msh-core/src/error.rs | 1 | ||||
| -rw-r--r-- | crates/msh-core/src/lib.rs | 36 | ||||
| -rw-r--r-- | crates/msh-core/src/tests.rs | 425 |
3 files changed, 312 insertions, 150 deletions
diff --git a/crates/msh-core/src/error.rs b/crates/msh-core/src/error.rs index 81fe54f..d46c7b1 100644 --- a/crates/msh-core/src/error.rs +++ b/crates/msh-core/src/error.rs @@ -1,6 +1,7 @@ use core::fmt; #[derive(Debug)] +#[non_exhaustive] pub enum Error { Nres(nres::error::Error), MissingResource { diff --git a/crates/msh-core/src/lib.rs b/crates/msh-core/src/lib.rs index 84e8a86..1a50fb7 100644 --- a/crates/msh-core/src/lib.rs +++ b/crates/msh-core/src/lib.rs @@ -164,6 +164,8 @@ pub fn parse_model_payload(payload: &[u8]) -> Result<Model> { 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")?), @@ -192,6 +194,40 @@ pub fn parse_model_payload(payload: &[u8]) -> Result<Model> { }) } +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 { diff --git a/crates/msh-core/src/tests.rs b/crates/msh-core/src/tests.rs index 1eefb31..07b05c7 100644 --- a/crates/msh-core/src/tests.rs +++ b/crates/msh-core/src/tests.rs @@ -39,6 +39,166 @@ 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(names: &[Option<&str>]) -> Vec<u8> { + let mut out = Vec::new(); + for name in names { + match name { + Some(name) => { + let bytes = name.as_bytes(); + out.extend_from_slice( + &u32::try_from(bytes.len()) + .expect("name size overflow in test") + .to_le_bytes(), + ); + out.extend_from_slice(bytes); + out.push(0); + } + None => out.extend_from_slice(&0u32.to_le_bytes()), + } + } + out +} + +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(); @@ -137,156 +297,7 @@ fn parse_all_game_msh_models() { #[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 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); @@ -294,3 +305,117 @@ fn parse_minimal_synthetic_model() { 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_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", + .. + }) + )); +} |
