feat(render-core): add default UV scale and refactor UV mapping logic
Some checks failed
Test / Lint (push) Failing after 1m12s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped

- 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:
2026-02-19 09:46:23 +00:00
parent 0d7ae6a017
commit efab61a45c
17 changed files with 800 additions and 299 deletions

View File

@@ -1,6 +1,7 @@
use core::fmt;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
Nres(nres::error::Error),
MissingResource {

View File

@@ -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 {

View File

@@ -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",
..
})
));
}