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.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use core::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
Nres(nres::error::Error),
|
||||
MissingResource {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user