aboutsummaryrefslogtreecommitdiff
path: root/crates/msh-core
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-19 12:46:23 +0300
committerValentin Popov <valentin@popov.link>2026-02-19 12:46:23 +0300
commitefab61a45c8837d3c2aaec464d8f6243fecb7a38 (patch)
treeb511f1cab917f5f2931d6bc2ae2676b553bb8ef9 /crates/msh-core
parent0d7ae6a017b8b2bf26c5c14c39cb62b599e8262d (diff)
downloadfparkan-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.rs1
-rw-r--r--crates/msh-core/src/lib.rs36
-rw-r--r--crates/msh-core/src/tests.rs425
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",
+ ..
+ })
+ ));
+}