diff options
Diffstat (limited to 'crates/terrain-core/src/lib.rs')
| -rw-r--r-- | crates/terrain-core/src/lib.rs | 281 |
1 files changed, 281 insertions, 0 deletions
diff --git a/crates/terrain-core/src/lib.rs b/crates/terrain-core/src/lib.rs new file mode 100644 index 0000000..36a3e42 --- /dev/null +++ b/crates/terrain-core/src/lib.rs @@ -0,0 +1,281 @@ +use nres::Archive; +use std::fmt; +use std::path::Path; + +pub const TERRAIN_UV_SCALE: f32 = 1024.0; + +pub type Result<T> = core::result::Result<T, Error>; + +#[derive(Debug)] +pub enum Error { + Nres(nres::error::Error), + MissingChunk(&'static str), + InvalidChunkSize { + label: &'static str, + size: usize, + stride: usize, + }, + VertexCountOverflow { + count: usize, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nres(err) => write!(f, "{err}"), + Self::MissingChunk(label) => write!(f, "missing required terrain chunk: {label}"), + Self::InvalidChunkSize { + label, + size, + stride, + } => write!( + f, + "invalid chunk size for {label}: {size} (must be divisible by {stride})" + ), + Self::VertexCountOverflow { count } => { + write!(f, "terrain vertex count {count} exceeds u16 range") + } + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Nres(err) => Some(err), + _ => None, + } + } +} + +impl From<nres::error::Error> for Error { + fn from(value: nres::error::Error) -> Self { + Self::Nres(value) + } +} + +#[derive(Clone, Debug)] +pub struct TerrainMesh { + pub positions: Vec<[f32; 3]>, + pub uv0: Vec<[f32; 2]>, + pub faces: Vec<TerrainFace>, +} + +#[derive(Copy, Clone, Debug)] +pub struct TerrainFace { + pub indices: [u16; 3], + pub flags: u32, + pub material_tag: u16, + pub aux_tag: u16, +} + +#[derive(Clone, Debug)] +pub struct TerrainRenderMesh { + pub vertices: Vec<TerrainRenderVertex>, + pub indices: Vec<u16>, + pub face_count_raw: usize, + pub face_count_kept: usize, + pub face_count_dropped_invalid: usize, +} + +#[derive(Copy, Clone, Debug)] +pub struct TerrainRenderVertex { + pub position: [f32; 3], + pub uv0: [f32; 2], +} + +pub fn load_land_mesh(path: impl AsRef<Path>) -> Result<TerrainMesh> { + let archive = Archive::open_path(path.as_ref())?; + + let positions_entry = archive + .entries() + .find(|entry| entry.meta.kind == 3) + .ok_or(Error::MissingChunk("type=3 (positions)"))?; + let uv_entry = archive.entries().find(|entry| entry.meta.kind == 5); + let faces_entry = archive + .entries() + .find(|entry| entry.meta.kind == 21) + .ok_or(Error::MissingChunk("type=21 (faces)"))?; + + let positions_payload = archive.read(positions_entry.id)?.into_owned(); + if positions_payload.len() % 12 != 0 { + return Err(Error::InvalidChunkSize { + label: "type=3 (positions)", + size: positions_payload.len(), + stride: 12, + }); + } + + let mut positions = Vec::with_capacity(positions_payload.len() / 12); + for chunk in positions_payload.chunks_exact(12) { + let x = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4])); + let y = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4])); + let z = f32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4])); + positions.push([x, y, z]); + } + + let mut uv0 = vec![[0.0f32, 0.0f32]; positions.len()]; + if let Some(uv_entry) = uv_entry { + let uv_payload = archive.read(uv_entry.id)?.into_owned(); + if uv_payload.len() % 4 != 0 { + return Err(Error::InvalidChunkSize { + label: "type=5 (uv)", + size: uv_payload.len(), + stride: 4, + }); + } + let uv_count = uv_payload.len() / 4; + for idx in 0..uv_count.min(uv0.len()) { + let off = idx * 4; + let u = i16::from_le_bytes([uv_payload[off], uv_payload[off + 1]]) as f32; + let v = i16::from_le_bytes([uv_payload[off + 2], uv_payload[off + 3]]) as f32; + uv0[idx] = [u / TERRAIN_UV_SCALE, v / TERRAIN_UV_SCALE]; + } + } + + let face_payload = archive.read(faces_entry.id)?.into_owned(); + if face_payload.len() % 28 != 0 { + return Err(Error::InvalidChunkSize { + label: "type=21 (faces)", + size: face_payload.len(), + stride: 28, + }); + } + + let mut faces = Vec::with_capacity(face_payload.len() / 28); + for chunk in face_payload.chunks_exact(28) { + let flags = u32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4])); + let material_tag = u16::from_le_bytes(chunk[4..6].try_into().unwrap_or([0; 2])); + let aux_tag = u16::from_le_bytes(chunk[6..8].try_into().unwrap_or([0; 2])); + let i0 = u16::from_le_bytes(chunk[8..10].try_into().unwrap_or([0; 2])); + let i1 = u16::from_le_bytes(chunk[10..12].try_into().unwrap_or([0; 2])); + let i2 = u16::from_le_bytes(chunk[12..14].try_into().unwrap_or([0; 2])); + if usize::from(i0) >= positions.len() + || usize::from(i1) >= positions.len() + || usize::from(i2) >= positions.len() + { + continue; + } + faces.push(TerrainFace { + indices: [i0, i1, i2], + flags, + material_tag, + aux_tag, + }); + } + + Ok(TerrainMesh { + positions, + uv0, + faces, + }) +} + +pub fn build_render_mesh(mesh: &TerrainMesh) -> Result<TerrainRenderMesh> { + if mesh.positions.len() > usize::from(u16::MAX) + 1 { + return Err(Error::VertexCountOverflow { + count: mesh.positions.len(), + }); + } + + let vertices = mesh + .positions + .iter() + .enumerate() + .map(|(idx, &position)| TerrainRenderVertex { + position, + uv0: mesh.uv0.get(idx).copied().unwrap_or([0.0, 0.0]), + }) + .collect::<Vec<_>>(); + + let mut indices = Vec::with_capacity(mesh.faces.len() * 3); + for face in &mesh.faces { + indices.extend_from_slice(&face.indices); + } + + Ok(TerrainRenderMesh { + vertices, + indices, + face_count_raw: mesh.faces.len(), + face_count_kept: mesh.faces.len(), + face_count_dropped_invalid: 0, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use common::collect_files_recursive; + use std::path::{Path, PathBuf}; + + fn game_root() -> Option<PathBuf> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("testdata") + .join("Parkan - Iron Strategy"); + root.is_dir().then_some(root) + } + + #[test] + fn loads_known_land_mesh() { + let Some(root) = game_root() else { + eprintln!("skipping: game root missing"); + return; + }; + + let land = root + .join("DATA") + .join("MAPS") + .join("Tut_1") + .join("Land.msh"); + if !land.is_file() { + eprintln!("skipping missing sample {}", land.display()); + return; + } + + let mesh = load_land_mesh(&land) + .unwrap_or_else(|err| panic!("failed to parse {}: {err}", land.display())); + assert!(mesh.positions.len() > 1000); + assert!(mesh.faces.len() > 1000); + + let render = build_render_mesh(&mesh).expect("failed to build render mesh"); + assert_eq!(render.vertices.len(), mesh.positions.len()); + assert_eq!(render.indices.len(), mesh.faces.len() * 3); + } + + #[test] + fn loads_all_retail_land_meshes() { + let Some(root) = game_root() else { + eprintln!("skipping: game root missing"); + return; + }; + + let maps_root = root.join("DATA").join("MAPS"); + let mut files = Vec::new(); + collect_files_recursive(&maps_root, &mut files); + files.sort(); + + let mut parsed = 0usize; + for path in files { + if !path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.eq_ignore_ascii_case("Land.msh")) + { + continue; + } + let mesh = load_land_mesh(&path) + .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display())); + assert!( + !mesh.positions.is_empty() && !mesh.faces.is_empty(), + "{} parsed but empty", + path.display() + ); + parsed += 1; + } + + assert!(parsed > 0, "no Land.msh files parsed"); + } +} |
