diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 69796d3..c0d57f7 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,4 +1,6 @@ +use std::fs; use std::io; +use std::path::{Path, PathBuf}; /// Resource payload that can be either borrowed from mapped bytes or owned. #[derive(Clone, Debug)] @@ -42,3 +44,18 @@ impl OutputBuffer for Vec { Ok(()) } } + +/// Recursively collects all files under `root`. +pub fn collect_files_recursive(root: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_files_recursive(&path, out); + } else if path.is_file() { + out.push(path); + } + } +} diff --git a/crates/msh-core/Cargo.toml b/crates/msh-core/Cargo.toml index cdea317..86b0846 100644 --- a/crates/msh-core/Cargo.toml +++ b/crates/msh-core/Cargo.toml @@ -4,4 +4,9 @@ version = "0.1.0" edition = "2021" [dependencies] +encoding_rs = "0.8" nres = { path = "../nres" } + +[dev-dependencies] +common = { path = "../common" } +proptest = "1" diff --git a/crates/msh-core/src/lib.rs b/crates/msh-core/src/lib.rs index 1a50fb7..bc51357 100644 --- a/crates/msh-core/src/lib.rs +++ b/crates/msh-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod error; use crate::error::Error; +use encoding_rs::WINDOWS_1251; use std::sync::Arc; pub type Result = core::result::Result; @@ -347,13 +348,18 @@ fn parse_res10_names(data: &[u8], node_count: usize) -> Result String { + let (decoded, _, _) = WINDOWS_1251.decode(bytes); + decoded.into_owned() +} + struct RawResource { meta: nres::EntryMeta, bytes: Vec, diff --git a/crates/msh-core/src/tests.rs b/crates/msh-core/src/tests.rs index 07b05c7..90a7fdc 100644 --- a/crates/msh-core/src/tests.rs +++ b/crates/msh-core/src/tests.rs @@ -1,22 +1,10 @@ use super::*; +use common::collect_files_recursive; use nres::Archive; +use proptest::prelude::*; use std::fs; use std::path::{Path, PathBuf}; -fn collect_files_recursive(root: &Path, out: &mut Vec) { - let Ok(entries) = fs::read_dir(root) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_files_recursive(&path, out); - } else if path.is_file() { - out.push(path); - } - } -} - fn nres_test_files() -> Vec { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -169,18 +157,17 @@ fn res13_single_batch(index_start: u32, index_count: u16) -> Vec { batch } -fn res10_names(names: &[Option<&str>]) -> Vec { +fn res10_names_raw(names: &[Option<&[u8]>]) -> Vec { 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()) + &u32::try_from(name.len()) .expect("name size overflow in test") .to_le_bytes(), ); - out.extend_from_slice(bytes); + out.extend_from_slice(name); out.push(0); } None => out.extend_from_slice(&0u32.to_le_bytes()), @@ -189,6 +176,11 @@ fn res10_names(names: &[Option<&str>]) -> Vec { out } +fn res10_names(names: &[Option<&str>]) -> Vec { + let raw: Vec> = names.iter().map(|name| name.map(str::as_bytes)).collect(); + res10_names_raw(&raw) +} + fn base_synthetic_entries() -> Vec { vec![ synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))), @@ -339,6 +331,22 @@ fn parse_synthetic_model_with_optional_res4_res5_res10() { 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(); @@ -419,3 +427,12 @@ fn parse_fails_for_batch_index_range_out_of_bounds() { }) )); } + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn parse_model_payload_never_panics_on_random_bytes(data in proptest::collection::vec(any::(), 0..8192)) { + let _ = parse_model_payload(&data); + } +} diff --git a/crates/nres/src/tests.rs b/crates/nres/src/tests.rs index 6de02e5..bfa75a8 100644 --- a/crates/nres/src/tests.rs +++ b/crates/nres/src/tests.rs @@ -1,4 +1,5 @@ use super::*; +use common::collect_files_recursive; use std::any::Any; use std::fs; use std::panic::{catch_unwind, AssertUnwindSafe}; @@ -13,20 +14,6 @@ struct SyntheticEntry<'a> { data: &'a [u8], } -fn collect_files_recursive(root: &Path, out: &mut Vec) { - let Ok(entries) = fs::read_dir(root) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_files_recursive(&path, out); - } else if path.is_file() { - out.push(path); - } - } -} - fn nres_test_files() -> Vec { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("..") diff --git a/crates/render-core/Cargo.toml b/crates/render-core/Cargo.toml index b856d12..c93d624 100644 --- a/crates/render-core/Cargo.toml +++ b/crates/render-core/Cargo.toml @@ -7,4 +7,5 @@ edition = "2021" msh-core = { path = "../msh-core" } [dev-dependencies] +common = { path = "../common" } nres = { path = "../nres" } diff --git a/crates/render-core/src/lib.rs b/crates/render-core/src/lib.rs index d06761a..c7a69d6 100644 --- a/crates/render-core/src/lib.rs +++ b/crates/render-core/src/lib.rs @@ -1,4 +1,5 @@ use msh_core::Model; +use std::collections::HashMap; pub const DEFAULT_UV_SCALE: f32 = 1024.0; @@ -11,21 +12,24 @@ pub struct RenderVertex { #[derive(Clone, Debug)] pub struct RenderMesh { pub vertices: Vec, + pub indices: Vec, pub batch_count: usize, + pub index_overflow: bool, } impl RenderMesh { pub fn triangle_count(&self) -> usize { - self.vertices.len() / 3 + self.indices.len() / 3 } } -/// Builds an expanded triangle list for a specific LOD/group pair. -/// -/// The output is suitable for simple `glDrawArrays(GL_TRIANGLES, ...)` paths. +/// Builds an indexed triangle mesh for a specific LOD/group pair. pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh { let mut vertices = Vec::new(); + let mut indices = Vec::new(); + let mut index_remap: HashMap = HashMap::new(); let mut batch_count = 0usize; + let mut index_overflow = false; let uv0 = model.uv0.as_ref(); for node_index in 0..model.node_count { @@ -49,36 +53,62 @@ pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh continue; } + let batch_out_start = indices.len(); + let mut batch_valid = true; for &idx in &model.indices[index_start..index_end] { let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx)); let Ok(final_idx) = usize::try_from(final_idx_u64) else { - continue; + batch_valid = false; + break; }; let Some(pos) = model.positions.get(final_idx) else { - continue; + batch_valid = false; + break; }; - let uv = uv0 - .and_then(|uvs| uvs.get(final_idx)) - .copied() - .map(|packed| { - [ - packed[0] as f32 / DEFAULT_UV_SCALE, - packed[1] as f32 / DEFAULT_UV_SCALE, - ] - }) - .unwrap_or([0.0, 0.0]); - vertices.push(RenderVertex { - position: *pos, - uv0: uv, - }); + + let local_index = if let Some(&mapped) = index_remap.get(&final_idx) { + mapped + } else { + let Ok(mapped) = u16::try_from(vertices.len()) else { + index_overflow = true; + batch_valid = false; + break; + }; + let uv = uv0 + .and_then(|uvs| uvs.get(final_idx)) + .copied() + .map(|packed| { + [ + packed[0] as f32 / DEFAULT_UV_SCALE, + packed[1] as f32 / DEFAULT_UV_SCALE, + ] + }) + .unwrap_or([0.0, 0.0]); + vertices.push(RenderVertex { + position: *pos, + uv0: uv, + }); + index_remap.insert(final_idx, mapped); + mapped + }; + + indices.push(local_index); } + + if !batch_valid { + indices.truncate(batch_out_start); + continue; + } + batch_count += 1; } } RenderMesh { vertices, + indices, batch_count, + index_overflow, } } diff --git a/crates/render-core/src/tests.rs b/crates/render-core/src/tests.rs index c9b55a0..1c5285e 100644 --- a/crates/render-core/src/tests.rs +++ b/crates/render-core/src/tests.rs @@ -1,23 +1,10 @@ use super::*; +use common::collect_files_recursive; use msh_core::parse_model_payload; use nres::Archive; use std::fs; use std::path::{Path, PathBuf}; -fn collect_files_recursive(root: &Path, out: &mut Vec) { - let Ok(entries) = fs::read_dir(root) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_files_recursive(&path, out); - } else if path.is_file() { - out.push(path); - } - } -} - fn nres_test_files() -> Vec { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -71,12 +58,20 @@ fn build_render_mesh_for_real_models() { ) }); let mesh = build_render_mesh(&model, 0, 0); - if !mesh.vertices.is_empty() { + if !mesh.indices.is_empty() { meshes_non_empty += 1; } if compute_bounds_for_mesh(&mesh.vertices).is_some() { bounds_non_empty += 1; } + for &index in &mesh.indices { + assert!( + usize::from(index) < mesh.vertices.len(), + "index out of bounds for '{}' in {}", + entry.meta.name, + archive_path.display() + ); + } for vertex in &mesh.vertices { assert!( vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(), @@ -189,6 +184,7 @@ fn build_render_mesh_handles_empty_slot_model() { let mesh = build_render_mesh(&model, 0, 0); assert!(mesh.vertices.is_empty()); + assert!(mesh.indices.is_empty()); assert_eq!(mesh.batch_count, 0); assert_eq!(mesh.triangle_count(), 0); } @@ -225,9 +221,36 @@ fn build_render_mesh_supports_multi_node_and_uv_scaling() { let mesh = build_render_mesh(&model, 0, 0); assert_eq!(mesh.batch_count, 2); assert_eq!(mesh.vertices.len(), 6); + assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]); assert_eq!(mesh.triangle_count(), 2); assert_eq!(mesh.vertices[0].uv0, [1.0, -1.0]); assert_eq!(mesh.vertices[1].uv0, [0.5, 0.25]); assert_eq!(mesh.vertices[2].uv0, [0.0, 0.0]); assert_eq!(mesh.vertices[3].uv0, [1.0, 1.0]); } + +#[test] +fn build_render_mesh_deduplicates_shared_vertices() { + let model = msh_core::Model { + node_stride: 38, + node_count: 1, + nodes_raw: nodes_with_slot_refs(&[Some(0)]), + slots: vec![slot(0, 1)], + positions: vec![ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + ], + normals: None, + uv0: None, + indices: vec![0, 1, 2, 2, 1, 3], + batches: vec![batch(0, 6, 0)], + node_names: None, + }; + + let mesh = build_render_mesh(&model, 0, 0); + assert_eq!(mesh.vertices.len(), 4); + assert_eq!(mesh.indices, vec![0, 1, 2, 2, 1, 3]); + assert_eq!(mesh.triangle_count(), 2); +} diff --git a/crates/render-demo/Cargo.toml b/crates/render-demo/Cargo.toml index 94c2e46..b7b62cd 100644 --- a/crates/render-demo/Cargo.toml +++ b/crates/render-demo/Cargo.toml @@ -17,6 +17,9 @@ sdl2 = { version = "0.37", optional = true, default-features = false, features = glow = { version = "0.16", optional = true } image = { version = "0.25", optional = true, default-features = false, features = ["png"] } +[dev-dependencies] +common = { path = "../common" } + [[bin]] name = "parkan-render-demo" path = "src/main.rs" diff --git a/crates/render-demo/src/lib.rs b/crates/render-demo/src/lib.rs index c82e055..9555151 100644 --- a/crates/render-demo/src/lib.rs +++ b/crates/render-demo/src/lib.rs @@ -435,23 +435,10 @@ fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result) { - let Ok(entries) = fs::read_dir(root) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_files_recursive(&path, out); - } else if path.is_file() { - out.push(path); - } - } - } - fn archive_with_msh() -> Option { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("..") diff --git a/crates/render-demo/src/main.rs b/crates/render-demo/src/main.rs index 8d309d1..70f2209 100644 --- a/crates/render-demo/src/main.rs +++ b/crates/render-demo/src/main.rs @@ -221,12 +221,17 @@ fn run(args: Args) -> Result<(), String> { ) })?; let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group); - if mesh.vertices.is_empty() { + if mesh.indices.is_empty() { return Err(format!( "model has no renderable triangles for lod={} group={}", args.lod, args.group )); } + if mesh.index_overflow { + eprintln!( + "warning: mesh exceeds u16 index space and may be partially rendered on GLES2 targets" + ); + } let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else { return Err(String::from("failed to compute mesh bounds")); }; @@ -304,6 +309,7 @@ fn run(args: Args) -> Result<(), String> { vertex_data.push(vertex.uv0[1]); } let vertex_bytes = f32_slice_to_ne_bytes(&vertex_data); + let index_bytes = u16_slice_to_ne_bytes(&mesh.indices); let gl = unsafe { glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _) @@ -319,9 +325,13 @@ fn run(args: Args) -> Result<(), String> { .ok_or_else(|| String::from("shader attribute a_uv is missing"))?; let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? }; + let ebo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? }; unsafe { gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW); + gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo)); + gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW); + gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None); gl.bind_buffer(glow::ARRAY_BUFFER, None); } @@ -341,8 +351,9 @@ fn run(args: Args) -> Result<(), String> { a_pos, a_uv, vbo, + ebo, gpu_texture.as_ref(), - mesh.vertices.len(), + mesh.indices.len(), &args, center, camera_distance, @@ -360,8 +371,9 @@ fn run(args: Args) -> Result<(), String> { a_pos, a_uv, vbo, + ebo, gpu_texture.as_ref(), - mesh.vertices.len(), + mesh.indices.len(), &args, center, camera_distance, @@ -372,6 +384,7 @@ fn run(args: Args) -> Result<(), String> { if let Some(texture) = gpu_texture { gl.delete_texture(texture.handle); } + gl.delete_buffer(ebo); gl.delete_buffer(vbo); gl.delete_program(program); } @@ -452,8 +465,9 @@ fn run_capture( a_pos: u32, a_uv: u32, vbo: glow::NativeBuffer, + ebo: glow::NativeBuffer, texture: Option<&GpuTexture>, - vertex_count: usize, + index_count: usize, args: &Args, center: [f32; 3], camera_distance: f32, @@ -478,8 +492,9 @@ fn run_capture( a_pos, a_uv, vbo, + ebo, texture, - vertex_count, + index_count, args.width, args.height, &mvp, @@ -504,8 +519,9 @@ fn run_interactive( a_pos: u32, a_uv: u32, vbo: glow::NativeBuffer, + ebo: glow::NativeBuffer, texture: Option<&GpuTexture>, - vertex_count: usize, + index_count: usize, args: &Args, center: [f32; 3], camera_distance: f32, @@ -543,8 +559,9 @@ fn run_interactive( a_pos, a_uv, vbo, + ebo, texture, - vertex_count, + index_count, w, h, &mvp, @@ -584,8 +601,9 @@ unsafe fn draw_frame( a_pos: u32, a_uv: u32, vbo: glow::NativeBuffer, + ebo: glow::NativeBuffer, texture: Option<&GpuTexture>, - vertex_count: usize, + index_count: usize, width: u32, height: u32, mvp: &[f32; 16], @@ -614,17 +632,20 @@ unsafe fn draw_frame( } gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); + gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo)); gl.enable_vertex_attrib_array(a_pos); gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0); gl.enable_vertex_attrib_array(a_uv); gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12); - gl.draw_arrays( + gl.draw_elements( glow::TRIANGLES, + index_count.min(i32::MAX as usize) as i32, + glow::UNSIGNED_SHORT, 0, - vertex_count.min(i32::MAX as usize) as i32, ); gl.disable_vertex_attrib_array(a_uv); gl.disable_vertex_attrib_array(a_pos); + gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None); gl.bind_buffer(glow::ARRAY_BUFFER, None); gl.bind_texture(glow::TEXTURE_2D, None); gl.use_program(None); @@ -758,6 +779,14 @@ fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec { out } +fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec { + let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::())); + for &value in slice { + out.extend_from_slice(&value.to_ne_bytes()); + } + out +} + fn mat4_identity() -> [f32; 16] { [ 1.0, 0.0, 0.0, 0.0, // diff --git a/crates/rsli/Cargo.toml b/crates/rsli/Cargo.toml index faad224..0ab3036 100644 --- a/crates/rsli/Cargo.toml +++ b/crates/rsli/Cargo.toml @@ -6,3 +6,6 @@ edition = "2021" [dependencies] common = { path = "../common" } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } + +[dev-dependencies] +proptest = "1" diff --git a/crates/rsli/src/tests.rs b/crates/rsli/src/tests.rs index 07807d3..ffd611d 100644 --- a/crates/rsli/src/tests.rs +++ b/crates/rsli/src/tests.rs @@ -1,14 +1,17 @@ use super::*; use crate::compress::lzh::{LZH_MAX_FREQ, LZH_N_CHAR, LZH_R, LZH_T}; use crate::compress::xor::xor_stream; +use common::collect_files_recursive; use flate2::write::DeflateEncoder; use flate2::write::ZlibEncoder; use flate2::Compression; +use proptest::prelude::*; use std::any::Any; use std::fs; use std::io::Write as _; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::PathBuf; +use std::sync::Arc; #[derive(Clone, Debug)] struct SyntheticRsliEntry { @@ -37,20 +40,6 @@ impl Default for RsliBuildOptions { } } -fn collect_files_recursive(root: &Path, out: &mut Vec) { - let Ok(entries) = fs::read_dir(root) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_files_recursive(&path, out); - } else if path.is_file() { - out.push(path); - } - } -} - fn rsli_test_files() -> Vec { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -1335,3 +1324,15 @@ fn rsli_validation_error_cases() { } let _ = fs::remove_file(&path); } + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn parse_library_is_panic_free_on_random_bytes(data in proptest::collection::vec(any::(), 0..4096)) { + let _ = crate::parse::parse_library( + Arc::from(data.into_boxed_slice()), + OpenOptions::default(), + ); + } +} diff --git a/crates/texm/Cargo.toml b/crates/texm/Cargo.toml index 216bb44..f9c49b6 100644 --- a/crates/texm/Cargo.toml +++ b/crates/texm/Cargo.toml @@ -4,4 +4,6 @@ version = "0.1.0" edition = "2021" [dev-dependencies] +common = { path = "../common" } nres = { path = "../nres" } +proptest = "1" diff --git a/crates/texm/src/tests.rs b/crates/texm/src/tests.rs index ba8aeeb..49a7100 100644 --- a/crates/texm/src/tests.rs +++ b/crates/texm/src/tests.rs @@ -1,22 +1,10 @@ use super::*; +use common::collect_files_recursive; use nres::Archive; +use proptest::prelude::*; use std::fs; use std::path::{Path, PathBuf}; -fn collect_files_recursive(root: &Path, out: &mut Vec) { - let Ok(entries) = fs::read_dir(root) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_files_recursive(&path, out); - } else if path.is_file() { - out.push(path); - } - } -} - fn nres_test_files() -> Vec { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -327,3 +315,16 @@ fn texm_errors_for_page_chunk_and_mip_bounds() { Err(Error::MipDataOutOfBounds { .. }) )); } + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn parse_texm_is_panic_free_on_random_bytes(payload in proptest::collection::vec(any::(), 0..4096)) { + if let Ok(texture) = parse_texm(&payload) { + for mip_index in 0..texture.mip_levels.len() { + let _ = decode_mip_rgba8(&texture, &payload, mip_index); + } + } + } +}