diff options
Diffstat (limited to 'crates/fparkan-msh')
| -rw-r--r-- | crates/fparkan-msh/src/lib.rs | 712 |
1 files changed, 622 insertions, 90 deletions
diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs index 2e38082..b6459ab 100644 --- a/crates/fparkan-msh/src/lib.rs +++ b/crates/fparkan-msh/src/lib.rs @@ -18,6 +18,9 @@ pub const STREAM_POSITIONS: u32 = 3; pub const STREAM_NORMALS: u32 = 4; /// Texture coordinate stream. pub const STREAM_UV0: u32 = 5; +/// Optional secondary texture coordinate stream used by native lightmap/detail +/// stages. MSH stores it as the type-18 four-byte packed UV stream. +pub const STREAM_UV1: u32 = 18; /// Triangle index stream. pub const STREAM_INDICES: u32 = 6; /// Animation key stream. @@ -81,14 +84,20 @@ pub struct ModelAsset { pub node_count: usize, /// Raw node table. pub nodes_raw: Vec<u8>, + /// Model-level bounding sphere center from the Res2 header. + pub bounding_sphere_center: [f32; 3], + /// Model-level bounding sphere radius from the Res2 header. + pub bounding_sphere_radius: f32, /// Slot table. pub slots: Vec<Slot>, /// Vertex positions. pub positions: Vec<[f32; 3]>, /// Optional normals. pub normals: Option<Vec<[i8; 4]>>, - /// Optional texture coordinates. - pub uv0: Option<Vec<[i16; 2]>>, + /// Optional primary packed texture coordinates from type 5. + pub uv0: Option<Vec<[u16; 2]>>, + /// Optional secondary packed texture coordinates from type 18. + pub uv1: Option<Vec<[u16; 2]>>, /// Triangle indices. pub indices: Vec<u16>, /// Draw batches. @@ -167,18 +176,24 @@ pub struct Slot { pub struct Batch { /// Batch flags. pub batch_flags: u16, - /// Material index. - pub material_index: u16, - /// Opaque field. - pub opaque4: u16, - /// Opaque field. - pub opaque6: u16, + /// High material selector/slot field at byte offset `+0x02`. + /// + /// The ordinary WEAR material selector is the byte at `+0x04`. This + /// field is retained separately because the native path can override it + /// with a forced material index. + pub material_index_hi: u16, + /// WEAR material selector at byte offset `+0x04`. + pub material_index: u8, + /// Lightmap selector at byte offset `+0x05`. + pub lightmap_index: u8, + /// Local batch index at byte offset `+0x06`. + pub local_batch_index: u16, /// Index count. pub index_count: u16, /// First index offset. pub index_start: u32, - /// Opaque field. - pub opaque14: u16, + /// Vertex count at byte offset `+0x0E`. + pub vertex_count: u16, /// Base vertex. pub base_vertex: u32, } @@ -194,8 +209,10 @@ pub struct VertexStreams { pub positions: Vec<[f32; 3]>, /// Optional normals. pub normals: Option<Vec<[i8; 4]>>, - /// Optional texture coordinates. - pub uv0: Option<Vec<[i16; 2]>>, + /// Optional primary packed texture coordinates from type 5. + pub uv0: Option<Vec<[u16; 2]>>, + /// Optional secondary packed texture coordinates from type 18. + pub uv1: Option<Vec<[u16; 2]>>, } /// Preserved non-core stream. @@ -209,11 +226,11 @@ pub struct PreservedStream { pub bytes: std::sync::Arc<[u8]>, } -/// LOD id. +/// Native state id kept under the historical `Lod` API name. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Lod(pub u8); -/// Group id. +/// Native LOD id kept under the historical `Group` API name. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Group(pub u8); @@ -377,7 +394,10 @@ pub fn validate_msh(document: &MshDocument) -> Result<ModelAsset, MshError> { Ok(model) } -/// Returns the selected slot for a node/lod/group tuple. +/// Returns the selected slot for a node/native-state/native-LOD tuple. +/// +/// The argument names are retained for API compatibility: `Lod` is the +/// three-value native state and `Group` is the five-value native LOD. #[must_use] pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -> Option<SlotId> { if model.node_stride != 38 || lod.0 >= 3 || group.0 >= 5 { @@ -417,12 +437,13 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadat }) } -/// Returns the source fallback pose of a standard node. +/// Returns the fallback key as a native Node38 source-local pose. /// /// The legacy sampler uses this key when a node has no frame map or its current /// frame falls outside that map. It is a static-pose input only; callers must /// still recover animation state and the meaning of `parent_or_link_raw` before -/// assembling a runtime hierarchy. +/// assembling a runtime hierarchy. Its quaternion is converted to match native +/// AniMesh's quaternion-to-matrix convention. #[must_use] pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> { let metadata = node38_metadata(model, node)?; @@ -432,6 +453,18 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> { .keys .get(usize::from(metadata.fallback_key)) .map(AnimKey24::sampling_pose) + .map(node38_native_local_pose) +} + +/// Converts a sampled MSH source-local quaternion into the native AniMesh +/// quaternion-matrix convention before Node38 hierarchy composition. Native +/// AniMesh's matrix coefficients are the transpose of the portable `Pose` +/// basis. +fn node38_native_local_pose(mut pose: Pose) -> Pose { + pose.rotation[0] = -pose.rotation[0]; + pose.rotation[1] = -pose.rotation[1]; + pose.rotation[2] = -pose.rotation[2]; + pose } /// Evaluates the static fallback pose hierarchy of a standard `Node38` model. @@ -462,8 +495,9 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> { evaluate_hierarchy(&parents, &poses).ok() } -/// Evaluates the portable-reference pose hierarchy of a standard `Node38` -/// model at one explicitly supplied logical frame. +/// Evaluates a standard `Node38` hierarchy at one explicitly supplied logical +/// frame, using portable key sampling and native AniMesh local-matrix +/// convention. /// /// A node without a usable type-19 map, or a requested frame outside the /// declared map length, retains its exact fallback key. Mapped keys are @@ -475,6 +509,59 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> { /// representation or the map cannot be safely resolved. #[must_use] pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> { + node38_sampled_hierarchy_with_selector(model, |_| { + Some((u32::from(frame), AnimationTime(f32::from(frame)))) + }) +} + +/// Evaluates a standard `Node38` hierarchy with an independent floating-point +/// sample time for each node. +/// +/// `Some(time)` uses the nearest-even integer to `time - 0.5` as the type-19 +/// frame-map selector, then samples the selected key and its immediate +/// successor at the original fractional time. This matches the native +/// sampler's selector calculation while keeping interpolation time fractional. +/// The selector uses Rust's default-nearest, ties-to-even rounding. This is +/// the portable counterpart of the native x87 conversion under its default +/// rounding mode; it does not claim parity under every x87 control-word mode. +/// A missing time uses that node's exact fallback key. Supplied times must be +/// finite and non-negative; the caller owns choosing the per-node times. +/// +/// Returns `None` when the model is not a complete standard-node animation, +/// the slice does not match the node count, any supplied time is invalid, or a +/// frame-map entry cannot be safely resolved. +#[must_use] +pub fn node38_sampled_hierarchy_at_times( + model: &ModelAsset, + node_times: &[Option<AnimationTime>], +) -> Option<NodePoseBuffer> { + if node_times.len() != model.node_count + || node_times + .iter() + .flatten() + .any(|time| !time.0.is_finite() || time.0 < 0.0) + { + return None; + } + node38_sampled_hierarchy_with_selector(model, |index| { + let time = node_times[index]?; + let selector = (time.0 - 0.5).round_ties_even(); + // A finite, non-negative time can still be beyond the representable + // map range. Saturate to a frame that will resolve to the node's + // fallback key rather than allowing a float-to-index wraparound. + let map_frame = if selector < 0.0 || selector >= u32::MAX as f32 { + u32::MAX + } else { + selector as u32 + }; + Some((map_frame, time)) + }) +} + +fn node38_sampled_hierarchy_with_selector( + model: &ModelAsset, + mut selector_for_node: impl FnMut(usize) -> Option<(u32, AnimationTime)>, +) -> Option<NodePoseBuffer> { if model.node_stride != 38 || model.node_count == 0 { return None; } @@ -492,21 +579,36 @@ pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePo }; let fallback_index = usize::from(metadata.fallback_key); let _fallback = animation.keys.get(fallback_index)?; - let key_index = - if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count { + let sample = selector_for_node(index); + let key_index = if let Some((map_frame, _)) = sample { + if metadata.anim_map_start == u16::MAX + || map_frame == u32::MAX + || map_frame >= animation.frame_count + { fallback_index } else { - let mapped_index = - usize::from(*animation.frame_map.get( - usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?, - )?); + let mapped_index = usize::from( + *animation.frame_map.get( + usize::from(metadata.anim_map_start) + .checked_add(usize::try_from(map_frame).ok()?)?, + )?, + ); if mapped_index < fallback_index { mapped_index } else { fallback_index } - }; - let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?; + } + } else { + fallback_index + }; + let sample_time = sample.map_or(AnimationTime(0.0), |(_, time)| time); + let pose = node38_native_local_pose(sample_node38_key_pair( + &animation.keys, + key_index, + fallback_index, + sample_time, + )?); parents.push(parent); poses.push(pose); } @@ -517,7 +619,7 @@ fn sample_node38_key_pair( keys: &[AnimKey24], key_index: usize, fallback_index: usize, - frame: u16, + sample_time: AnimationTime, ) -> Option<Pose> { let key = *keys.get(key_index)?; if key_index == fallback_index { @@ -541,7 +643,7 @@ fn sample_node38_key_pair( ], ) .ok()?; - track.sample(AnimationTime(f32::from(frame))).ok() + track.sample(sample_time).ok() } /// Returns draw batches for a validated slot. @@ -667,6 +769,8 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError> } let node_count = nodes_stream.bytes.len() / node_stride; + let (bounding_sphere_center, bounding_sphere_radius) = + parse_res2_bounding_sphere(&slots_stream.bytes)?; let slots = parse_slots(&slots_stream.bytes)?; let positions = parse_positions(&positions_stream.bytes)?; let indices = parse_u16_array(&indices_stream.bytes, "Res6")?; @@ -678,7 +782,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError> .map(|raw| parse_i8x4_array(&raw.bytes, "Res4")) .transpose()?; let uv0 = read_optional_stream(document, STREAM_UV0)? - .map(|raw| parse_i16x2_array(&raw.bytes, "Res5")) + .map(|raw| parse_u16x2_array(&raw.bytes, "Res5")) + .transpose()?; + let uv1 = read_optional_stream(document, STREAM_UV1)? + .map(|raw| parse_secondary_uv_stream(raw, positions.len())) .transpose()?; let node_names = read_optional_stream(document, STREAM_NAMES)? .map(|raw| parse_res10_names(&raw.bytes, node_count)) @@ -689,10 +796,13 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError> node_stride, node_count, nodes_raw: nodes_stream.bytes, + bounding_sphere_center, + bounding_sphere_radius, slots, positions, normals, uv0, + uv1, indices, batches, node_names, @@ -822,6 +932,27 @@ fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> { Ok(slots) } +fn parse_res2_bounding_sphere(data: &[u8]) -> Result<([f32; 3], f32), MshError> { + if data.len() < 0x8C { + return Err(MshError::InvalidGeometry(format!( + "invalid Res2 size: {}", + data.len() + ))); + } + let center = [ + read_f32(data, 0x60)?, + read_f32(data, 0x64)?, + read_f32(data, 0x68)?, + ]; + let radius = read_f32(data, 0x6C)?; + if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius < 0.0 { + return Err(MshError::InvalidGeometry( + "invalid Res2 bounding sphere".to_string(), + )); + } + Ok((center, radius)) +} + fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>, MshError> { if !data.len().is_multiple_of(12) { return Err(invalid_resource_size("Res3", data.len(), 12)); @@ -845,12 +976,17 @@ fn parse_batches(data: &[u8]) -> Result<Vec<Batch>, MshError> { for offset in (0..data.len()).step_by(20) { out.push(Batch { batch_flags: read_u16_required(data, offset)?, - material_index: read_u16_required(data, offset + 2)?, - opaque4: read_u16_required(data, offset + 4)?, - opaque6: read_u16_required(data, offset + 6)?, + material_index_hi: read_u16_required(data, offset + 2)?, + material_index: *data + .get(offset + 4) + .ok_or_else(|| MshError::InvalidGeometry("batch material selector".to_string()))?, + lightmap_index: *data + .get(offset + 5) + .ok_or_else(|| MshError::InvalidGeometry("batch lightmap selector".to_string()))?, + local_batch_index: read_u16_required(data, offset + 6)?, index_count: read_u16_required(data, offset + 8)?, index_start: read_u32(data, offset + 10)?, - opaque14: read_u16_required(data, offset + 14)?, + vertex_count: read_u16_required(data, offset + 14)?, base_vertex: read_u32(data, offset + 16)?, }); } @@ -884,17 +1020,51 @@ fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>, Ms Ok(out) } -fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>, MshError> { +fn parse_u16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[u16; 2]>, MshError> { if !data.len().is_multiple_of(4) { return Err(invalid_resource_size(label, data.len(), 4)); } let mut out = Vec::with_capacity(data.len() / 4); for offset in (0..data.len()).step_by(4) { - out.push([read_i16(data, offset)?, read_i16(data, offset + 2)?]); + out.push([ + read_u16_required(data, offset)?, + read_u16_required(data, offset + 2)?, + ]); } Ok(out) } +fn parse_secondary_uv_stream( + stream: RawStream, + position_count: usize, +) -> Result<Vec<[u16; 2]>, MshError> { + if stream.attributes.attr3 != 4 { + return Err(MshError::InvalidGeometry(format!( + "invalid Res18 stride: expected 4, got {}", + stream.attributes.attr3 + ))); + } + let values = parse_u16x2_array(&stream.bytes, "Res18")?; + let declared_count = usize::try_from(stream.attributes.attr1).map_err(|_| { + MshError::InvalidGeometry("Res18 vertex count does not fit usize".to_string()) + })?; + if declared_count != values.len() { + return Err(MshError::InvalidGeometry(format!( + "Res18 vertex count does not match payload: declared={}, decoded={}", + declared_count, + values.len() + ))); + } + if values.len() != position_count { + return Err(MshError::InvalidGeometry(format!( + "Res18 vertex count does not match Res3: uv1={}, positions={}", + values.len(), + position_count + ))); + } + Ok(values) +} + fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>, MshError> { let mut out = Vec::with_capacity(node_count); let mut offset = 0usize; @@ -1038,16 +1208,6 @@ fn read_u16_required(bytes: &[u8], offset: usize) -> Result<u16, MshError> { Ok(u16::from_le_bytes(arr)) } -fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, MshError> { - let raw = bytes - .get(offset..offset.saturating_add(2)) - .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?; - let arr: [u8; 2] = raw - .try_into() - .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?; - Ok(i16::from_le_bytes(arr)) -} - fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> { let byte = bytes .get(offset) @@ -1309,18 +1469,21 @@ mod tests { let mut nodes = root; nodes.extend(child); let mut keys = Vec::new(); - for (x, y, z, qz, qw) in [ + // Type 8 stores WXYZ, which `AnimKey24::decode` reorders to XYZW. + // The native Node38 local-basis conversion happens before hierarchy + // evaluation, not in the generic animation decoder. + for (x, y, z, qw, qz) in [ (1.0, 0.0, 0.0, 23_170_i16, 23_170_i16), - (2.0, 0.0, 0.0, 0_i16, 32_767_i16), + (2.0, 0.0, 0.0, 32_767_i16, 0_i16), ] { push_f32(&mut keys, x); push_f32(&mut keys, y); push_f32(&mut keys, z); push_f32(&mut keys, 0.0); + push_u16(&mut keys, qw.cast_unsigned()); push_u16(&mut keys, 0); push_u16(&mut keys, 0); push_u16(&mut keys, qz.cast_unsigned()); - push_u16(&mut keys, qw.cast_unsigned()); } let document = decode_nested(&build_nres(&[ stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes), @@ -1336,7 +1499,78 @@ mod tests { validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset"); let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy"); assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001); - assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001); + assert!((hierarchy.poses[1].translation[1] + 2.0).abs() < 0.001); + } + + #[test] + fn node38_native_x_rotation_moves_child_translation_for_fallback_and_sampled_paths() { + let mut root = node38([u16::MAX; 15]); + root[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + root[4..6].copy_from_slice(&0_u16.to_le_bytes()); + root[6..8].copy_from_slice(&1_u16.to_le_bytes()); + let mut child = node38([u16::MAX; 15]); + child[2..4].copy_from_slice(&0_u16.to_le_bytes()); + child[4..6].copy_from_slice(&u16::MAX.to_le_bytes()); + child[6..8].copy_from_slice(&2_u16.to_le_bytes()); + let mut nodes = root; + nodes.extend(child); + let half_sqrt_two = std::f32::consts::FRAC_1_SQRT_2; + let root_rotation = [half_sqrt_two, 0.0, 0.0, half_sqrt_two]; + let identity = [0.0, 0.0, 0.0, 1.0]; + let child_translation = [0.0, -1.01209, -0.001792]; + let model = ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 1.0, + slots: Vec::new(), + positions: Vec::new(), + normals: None, + uv0: None, + uv1: None, + indices: Vec::new(), + batches: Vec::new(), + node_names: None, + animation: Some(ModelAnimation { + keys: vec![ + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose { + translation: [0.0; 3], + rotation: root_rotation, + }, + }, + AnimKey24 { + time: AnimationTime(1.0), + pose: Pose { + translation: [0.0; 3], + rotation: root_rotation, + }, + }, + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose { + translation: child_translation, + rotation: identity, + }, + }, + ], + frame_map: vec![0], + frame_count: 1, + }), + }; + + let fallback_root = node38_fallback_pose(&model, NodeId(0)).expect("fallback root pose"); + assert!(fallback_root.rotation[0] < -0.7); + let fallback = node38_fallback_hierarchy(&model).expect("fallback hierarchy"); + let sampled = node38_sampled_hierarchy(&model, 0).expect("sampled hierarchy"); + for hierarchy in [&fallback, &sampled] { + let child_translation = hierarchy.poses[1].translation; + assert!(child_translation[0].abs() < 0.0001); + assert!((child_translation[1] + 0.001792).abs() < 0.0001); + assert!((child_translation[2] - 1.01209).abs() < 0.0001); + } } #[test] @@ -1349,10 +1583,13 @@ mod tests { node_stride: 38, node_count: 1, nodes_raw: node, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 1.0, slots: Vec::new(), positions: Vec::new(), normals: None, uv0: None, + uv1: None, indices: Vec::new(), batches: Vec::new(), node_names: None, @@ -1395,6 +1632,105 @@ mod tests { } #[test] + fn standard_nodes_sample_independent_fractional_times_and_selector_rounding() { + let mut root = node38([u16::MAX; 15]); + root[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + root[4..6].copy_from_slice(&0_u16.to_le_bytes()); + root[6..8].copy_from_slice(&4_u16.to_le_bytes()); + let mut child = node38([u16::MAX; 15]); + child[2..4].copy_from_slice(&0_u16.to_le_bytes()); + child[4..6].copy_from_slice(&2_u16.to_le_bytes()); + child[6..8].copy_from_slice(&5_u16.to_le_bytes()); + let mut nodes = root; + nodes.extend(child); + let model = ModelAsset { + node_stride: 38, + node_count: 2, + nodes_raw: nodes, + bounding_sphere_center: [0.0; 3], + bounding_sphere_radius: 1.0, + slots: Vec::new(), + positions: Vec::new(), + normals: None, + uv0: None, + uv1: None, + indices: Vec::new(), + batches: Vec::new(), + node_names: None, + animation: Some(ModelAnimation { + keys: vec![ + AnimKey24 { + time: AnimationTime(0.0), + pose: Pose { + translation: [4.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(2.0), + pose: Pose { + translation: [8.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(1.0), + pose: Pose { + translation: [20.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(2.0), + pose: Pose { + translation: [30.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(9.0), + pose: Pose { + translation: [100.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + AnimKey24 { + time: AnimationTime(9.0), + pose: Pose { + translation: [300.0, 0.0, 0.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }, + }, + ], + frame_map: vec![0, 1, 0, 2], + frame_count: 4, + }), + }; + + let hierarchy = node38_sampled_hierarchy_at_times( + &model, + &[Some(AnimationTime(1.0)), Some(AnimationTime(1.55))], + ) + .expect("per-node fractional hierarchy"); + // The root's tie selects map frame zero and interpolates to x=6. The + // child independently selects frame one and samples its own 1.55 time; + // its global pose includes the root translation. + assert!((hierarchy.poses[0].translation[0] - 6.0).abs() < f32::EPSILON); + assert!((hierarchy.poses[1].translation[0] - 31.5).abs() < 0.001); + let fallback = node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0)), None]) + .expect("per-node fallback hierarchy"); + assert_eq!(fallback.poses[1].translation[0], 306.0); + assert!(node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0))]).is_none()); + for invalid in [-1.0, f32::NAN, f32::INFINITY] { + assert!(node38_sampled_hierarchy_at_times( + &model, + &[Some(AnimationTime(invalid)), None] + ) + .is_none()); + } + } + + #[test] fn type2_header_and_slot_tail_framing_are_exact() { let too_small = decode_nested(&build_nres(&[ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), @@ -1421,6 +1757,44 @@ mod tests { } #[test] + fn type2_header_bounding_sphere_is_preserved_and_rejects_invalid_values() { + let valid_slots = slots_payload_with_bounds([1.25, -2.5, 3.75], 4.5, &[]); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &valid_slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("nested"); + let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model"); + assert_eq!(model.bounding_sphere_center, [1.25, -2.5, 3.75]); + assert_eq!(model.bounding_sphere_radius, 4.5); + + for (offset, value) in [ + (0x60, f32::NAN), + (0x68, f32::INFINITY), + (0x6C, f32::NAN), + (0x6C, -1.0), + ] { + let mut slots = slots_payload(&[]); + slots[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + ])) + .expect("nested"); + assert!(matches!( + validate_msh(&decode_msh(&document).expect("msh")), + Err(MshError::InvalidGeometry(_)) + )); + } + } + + #[test] fn slot_batch_range_out_of_bounds_is_error() { let slots = slots_payload(&[slot_record(1, 1, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]); let document = decode_nested(&build_nres(&[ @@ -1448,6 +1822,7 @@ mod tests { (STREAM_POSITIONS, b"Res3".as_slice(), vec![0; 11]), (STREAM_NORMALS, b"Res4".as_slice(), vec![0; 3]), (STREAM_UV0, b"Res5".as_slice(), vec![0; 3]), + (STREAM_UV1, b"Res18".as_slice(), vec![0; 3]), (STREAM_INDICES, b"Res6".as_slice(), vec![0; 1]), ] { let slots = slots_payload(&[]); @@ -1472,6 +1847,127 @@ mod tests { } #[test] + fn type18_secondary_uv_decodes_unsigned_pairs_for_each_source_vertex() { + let positions = positions_payload(&[[0.0, 0.0, 0.0]; 4]); + let mut uv1 = Vec::new(); + for pair in [[2_u16, 5_u16], [12, 8], [16, 30], [34, 2]] { + push_u16(&mut uv1, pair[0]); + push_u16(&mut uv1, pair[1]); + } + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 12, b"Res3", &positions), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream_with_attr1(STREAM_UV1, 4, 4, b"Res18", &uv1), + ])) + .expect("nested"); + let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model"); + + assert_eq!(model.positions.len(), 4); + assert_eq!(model.uv1, Some(vec![[2, 5], [12, 8], [16, 30], [34, 2]])); + } + + #[test] + fn type18_secondary_uv_rejects_untrusted_count_or_stride() { + let positions = positions_payload(&[[0.0, 0.0, 0.0]; 1]); + for (attr1, attr3, expected) in [(1, 8, "stride"), (2, 4, "count")] { + let mut uv1 = Vec::new(); + push_u16(&mut uv1, 2); + push_u16(&mut uv1, 5); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &[]), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 12, b"Res3", &positions), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream_with_attr1(STREAM_UV1, attr1, attr3, b"Res18", &uv1), + ])) + .expect("nested"); + let err = validate_msh(&decode_msh(&document).expect("msh")).expect_err("invalid"); + let message = err.to_string(); + assert!(message.contains(expected), "{message}"); + } + } + + #[test] + fn installed_gog_secondary_uv_fixture_is_aligned_and_real() { + let Some(root) = std::env::var_os("FPARKAN_GAME_ROOT") else { + return; + }; + let root = PathBuf::from(root); + assert!( + root.is_dir(), + "FPARKAN_GAME_ROOT is missing: {}", + root.display() + ); + + let mut models_with_uv1 = 0usize; + let mut fixture_found = false; + for path in files_under(&root) { + if !path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("rlb")) + { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(archive) = + fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible) + else { + continue; + }; + for entry in archive + .entries() + .iter() + .filter(|entry| has_msh_extension(entry.name_bytes())) + { + let payload = archive.payload(entry.id()).expect("payload"); + let nested = fparkan_nres::decode( + Arc::from(payload.to_vec().into_boxed_slice()), + ReadProfile::Compatible, + ) + .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes())); + let msh = decode_msh(&nested) + .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes())); + let model = validate_msh(&msh) + .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes())); + let Some(uv1) = model.uv1.as_ref() else { + continue; + }; + models_with_uv1 += 1; + assert_eq!( + uv1.len(), + model.positions.len(), + "{path:?} {:?}", + entry.name_bytes() + ); + let descriptor = msh + .streams() + .iter() + .find(|stream| stream.type_id == STREAM_UV1) + .expect("Res18 descriptor"); + assert_eq!(descriptor.attributes.attr1 as usize, uv1.len()); + assert_eq!(descriptor.attributes.attr3, 4); + + if entry.name_bytes().eq_ignore_ascii_case(b"fr_l_gener.msh") { + assert_eq!(&uv1[..4], &[[2, 5], [12, 8], [16, 30], [34, 2]]); + fixture_found = true; + } + } + } + + assert!(models_with_uv1 > 0, "FPARKAN_GAME_ROOT has no type18 MSH"); + assert!( + fixture_found, + "FPARKAN_GAME_ROOT lacks fr_l_gener.msh fixture" + ); + } + + #[test] fn batch20_uses_unaligned_field_offsets() { let positions = positions_payload(&[[0.0, 0.0, 0.0]]); let mut indices = Vec::new(); @@ -1488,12 +1984,13 @@ mod tests { let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model"); assert_eq!(model.batches[0].batch_flags, 0x1100); - assert_eq!(model.batches[0].material_index, 0x2200); - assert_eq!(model.batches[0].opaque4, 0x3300); - assert_eq!(model.batches[0].opaque6, 0x4400); + assert_eq!(model.batches[0].material_index_hi, 0x2200); + assert_eq!(model.batches[0].material_index, 0x00); + assert_eq!(model.batches[0].lightmap_index, 0x33); + assert_eq!(model.batches[0].local_batch_index, 0x4400); assert_eq!(model.batches[0].index_count, 1); assert_eq!(model.batches[0].index_start, 0); - assert_eq!(model.batches[0].opaque14, 0x5500); + assert_eq!(model.batches[0].vertex_count, 0x5500); assert_eq!(model.batches[0].base_vertex, 0); } @@ -1587,44 +2084,49 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] - fn licensed_corpus_msh_assets_validate() { - for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] { - let root = corpus_root(corpus); - let mut count = 0usize; - for path in files_under(&root) { - let Ok(bytes) = std::fs::read(&path) else { - continue; - }; - let Ok(archive) = fparkan_nres::decode( - Arc::from(bytes.into_boxed_slice()), + fn licensed_corpus_part1_msh_assets_validate() { + validate_licensed_corpus_msh_assets("IS", 435); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_corpus_part2_msh_assets_validate() { + validate_licensed_corpus_msh_assets("IS2", 511); + } + + fn validate_licensed_corpus_msh_assets(corpus: &str, expected: usize) { + let root = corpus_root(corpus); + let mut count = 0usize; + for path in files_under(&root) { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + let Ok(archive) = + fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible) + else { + continue; + }; + for entry in archive + .entries() + .iter() + .filter(|entry| has_msh_extension(entry.name_bytes())) + { + let payload = archive.payload(entry.id()).expect("payload"); + let nested = fparkan_nres::decode( + Arc::from(payload.to_vec().into_boxed_slice()), ReadProfile::Compatible, - ) else { - continue; - }; - for entry in archive - .entries() - .iter() - .filter(|entry| has_msh_extension(entry.name_bytes())) - { - let payload = archive.payload(entry.id()).expect("payload"); - let nested = fparkan_nres::decode( - Arc::from(payload.to_vec().into_boxed_slice()), - ReadProfile::Compatible, - ) - .unwrap_or_else(|err| { - panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) - }); - let msh = decode_msh(&nested).unwrap_or_else(|err| { - panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) - }); - validate_msh(&msh).unwrap_or_else(|err| { - panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) - }); - count += 1; - } + ) + .unwrap_or_else(|err| panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())); + let msh = decode_msh(&nested).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + validate_msh(&msh).unwrap_or_else(|err| { + panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()) + }); + count += 1; } - assert_eq!(count, expected, "{corpus} MSH count"); } + assert_eq!(count, expected, "{corpus} MSH count"); } #[test] @@ -1932,6 +2434,23 @@ mod tests { fn stream<'a>(type_id: u32, attr3: u32, name: &'a [u8], payload: &'a [u8]) -> TestEntry<'a> { TestEntry { type_id, + attr1: 0, + attr3, + name, + payload, + } + } + + fn stream_with_attr1<'a>( + type_id: u32, + attr1: u32, + attr3: u32, + name: &'a [u8], + payload: &'a [u8], + ) -> TestEntry<'a> { + TestEntry { + type_id, + attr1, attr3, name, payload, @@ -1940,6 +2459,7 @@ mod tests { struct TestEntry<'a> { type_id: u32, + attr1: u32, attr3: u32, name: &'a [u8], payload: &'a [u8], @@ -1958,7 +2478,7 @@ mod tests { order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name)); for (idx, entry) in entries.iter().enumerate() { push_u32(&mut out, entry.type_id); - push_u32(&mut out, 0); + push_u32(&mut out, entry.attr1); push_u32(&mut out, 0); push_u32( &mut out, @@ -2000,7 +2520,19 @@ mod tests { } fn slots_payload(records: &[Vec<u8>]) -> Vec<u8> { + slots_payload_with_bounds([0.0; 3], 1.0, records) + } + + fn slots_payload_with_bounds(center: [f32; 3], radius: f32, records: &[Vec<u8>]) -> Vec<u8> { let mut out = vec![0; 0x8c]; + for (offset, value) in [ + (0x60, center[0]), + (0x64, center[1]), + (0x68, center[2]), + (0x6C, radius), + ] { + out[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } for record in records { assert_eq!(record.len(), 68); out.extend_from_slice(record); |
