feat: complete map viewer scene and static CTL pose preview

Complete the interactive mission viewer with environment rendering, audio events, dynamic shadows, free-flight camera controls, and per-component CTL pose sampling.
This commit is contained in:
2026-10-11 13:12:50 +04:00
parent aa51f3574d
commit a8faba8aad
42 changed files with 24411 additions and 1234 deletions
+622 -90
View File
@@ -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,
@@ -1394,6 +1631,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(&[
@@ -1420,6 +1756,44 @@ mod tests {
assert!(matches!(err, MshError::InvalidGeometry(_)));
}
#[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)]);
@@ -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(&[]);
@@ -1471,6 +1846,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]]);
@@ -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);