Part of #41: the comments of the thirteen small crates (`benilla-adt`, `-blp`, `-buildstamp`, `-bytes`, `-dbc`, `-m2`, `-mpq`, `-srp`, `-visual`, `-wdt`, `-wmo`, `-worldview` and `benilla`) rewritten to the rule in `docs/METHOD.md`: what the code does and the reference fact behind it, in a line. 2,176 comment lines become 1,029. - The citations that were only a private record number go first, mechanically, each edit shown to be a pure removal. - The rewrite corrects the comments that stated wrong facts: the M2 header's selection-ring claim, the texture-transform and MCNK impassable-flag notes, a DBC reader note that said short records zero-pad (they do not, #17), and about twenty more found on the way. The SRP handshake's redraw and redial are marked as a deviation, with the measured reason. - Verified: every file strips to identical code, and `cargo fmt --check` is clean. A second reader compared every file old against new and restored the facts the rewrite had dropped. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
555 lines
22 KiB
Rust
555 lines
22 KiB
Rust
use super::*;
|
||
|
||
// The header through the event table at 0x114, as far as `parse_m2` walks.
|
||
const HEADER_LEN: usize = 284;
|
||
|
||
// v256 header offsets of the M2Arrays under test.
|
||
const OFS_PLAYABLE_ANIM_LOOKUP: usize = 44;
|
||
const OFS_TEX_ANIM: usize = 0x74;
|
||
const OFS_TEX_UNIT_LOOKUP: usize = 0x9c;
|
||
const OFS_TRANS_LOOKUP: usize = 0xa4;
|
||
const OFS_TEX_ANIM_LOOKUP: usize = 0xac;
|
||
const OFS_BONES: usize = 52;
|
||
const OFS_VERTICES: usize = 68;
|
||
const OFS_VIEWS: usize = 76;
|
||
const OFS_ATTACHMENTS: usize = 260;
|
||
const OFS_EVENTS: usize = 276;
|
||
|
||
/// A v256 MD20 header with every M2Array empty; tests patch arrays with [`set_arr`].
|
||
fn header() -> Vec<u8> {
|
||
let mut b = vec![0u8; HEADER_LEN];
|
||
b[0..4].copy_from_slice(b"MD20");
|
||
b[4..8].copy_from_slice(&256u32.to_le_bytes());
|
||
b
|
||
}
|
||
|
||
fn set_arr(b: &mut [u8], ofs: usize, count: u32, offset: u32) {
|
||
b[ofs..ofs + 4].copy_from_slice(&count.to_le_bytes());
|
||
b[ofs + 4..ofs + 8].copy_from_slice(&offset.to_le_bytes());
|
||
}
|
||
|
||
fn parse(b: &[u8]) -> Result<M2Format> {
|
||
parse_m2(&mut Cursor::new(b))
|
||
}
|
||
|
||
#[test]
|
||
fn minimal_header_parses_to_an_empty_model() {
|
||
let b = header();
|
||
let fmt = parse(&b).expect("an all-zero-array header parses");
|
||
let m = fmt.model();
|
||
assert!(m.vertices.is_empty());
|
||
assert!(m.textures.is_empty());
|
||
assert!(m.materials.is_empty());
|
||
assert!(m.bones.is_empty());
|
||
assert!(m.color_alpha_tracks.is_empty());
|
||
assert!(m.color_rgb_tracks.is_empty());
|
||
assert!(m.transparency_tracks.is_empty());
|
||
assert!(m.transparency_lookup.is_empty());
|
||
assert!(m.raw_data.texture_lookup_table.is_empty());
|
||
assert!(m.raw_data.bounding_triangles.is_empty());
|
||
assert!(m.raw_data.bounding_vertices.is_empty());
|
||
assert!(m.attachments.is_empty());
|
||
assert!(m.playable_animation_lookup.is_empty());
|
||
assert!(m.texture_transforms.is_empty());
|
||
assert!(m.texture_transform_lookup.is_empty());
|
||
}
|
||
|
||
/// One v256 M2Track (stride 0x1c): interp@0, gseq@2, timestamps M2Array@0x0c, values M2Array@0x14.
|
||
fn m2track(interp: u16, gseq: u16, ts: (u32, u32), vals: (u32, u32)) -> Vec<u8> {
|
||
let mut t = vec![0u8; 0x1c];
|
||
t[0..2].copy_from_slice(&interp.to_le_bytes());
|
||
t[2..4].copy_from_slice(&gseq.to_le_bytes());
|
||
t[0x0c..0x10].copy_from_slice(&ts.0.to_le_bytes());
|
||
t[0x10..0x14].copy_from_slice(&ts.1.to_le_bytes());
|
||
t[0x14..0x18].copy_from_slice(&vals.0.to_le_bytes());
|
||
t[0x18..0x1c].copy_from_slice(&vals.1.to_le_bytes());
|
||
t
|
||
}
|
||
|
||
/// `TanarisTrollGate.m2` swaps its intact and burnt gates with these ±1 keys. Both fix16 records
|
||
/// are checked: the M2Color alpha (header `0x54`, track @ `+0x1c`) and the M2TextureWeight.
|
||
#[test]
|
||
fn a_negative_fix16_key_decodes_signed() {
|
||
let mut b = header();
|
||
let ts_ofs = b.len() as u32;
|
||
b.extend(0u32.to_le_bytes());
|
||
b.extend(333u32.to_le_bytes());
|
||
let val_ofs = b.len() as u32;
|
||
b.extend(0x7fffu16.to_le_bytes()); // +1.0, drawn
|
||
b.extend(0x8001u16.to_le_bytes()); // −1.0, hidden
|
||
// One M2Color: a keyless RGB track, then the alpha track.
|
||
let color_ofs = b.len() as u32;
|
||
b.extend(m2track(0, 0xffff, (0, 0), (0, 0)));
|
||
b.extend(m2track(1, 0xffff, (2, ts_ofs), (2, val_ofs)));
|
||
set_arr(&mut b, 0x54, 1, color_ofs);
|
||
// One M2TextureWeight with the same keys.
|
||
let weight_ofs = b.len() as u32;
|
||
b.extend(m2track(1, 0xffff, (2, ts_ofs), (2, val_ofs)));
|
||
set_arr(&mut b, 0x64, 1, weight_ofs);
|
||
|
||
let fmt = parse(&b).expect("a colour + weight fixture parses");
|
||
let m = fmt.model();
|
||
let keys = |t: &M2ScalarTrack| t.keys.iter().map(|&(_, v)| v).collect::<Vec<_>>();
|
||
let alpha = keys(&m.color_alpha_tracks[0]);
|
||
assert!(
|
||
(alpha[0] - 1.0).abs() < 1e-4,
|
||
"0x7fff is +1.0 (got {})",
|
||
alpha[0]
|
||
);
|
||
assert!(
|
||
(alpha[1] + 1.0).abs() < 1e-4,
|
||
"0x8001 is −1.0 — read unsigned it would be +1.0 and the batch would draw (got {})",
|
||
alpha[1]
|
||
);
|
||
let weight = keys(&m.transparency_tracks[0]);
|
||
assert!(
|
||
(weight[1] + 1.0).abs() < 1e-4,
|
||
"the transparency weight decodes signed too (got {})",
|
||
weight[1]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn texture_transform_translation_track_decodes() {
|
||
let mut b = header();
|
||
let ts_ofs = b.len() as u32;
|
||
b.extend(0u32.to_le_bytes());
|
||
b.extend(1000u32.to_le_bytes());
|
||
let val_ofs = b.len() as u32;
|
||
for v in [[0.0f32, 0.0, 0.0], [0.5, 1.0, 0.0]] {
|
||
for c in v {
|
||
b.extend(c.to_le_bytes());
|
||
}
|
||
}
|
||
// One M2TextureTransform: a 2-key gseq-0 translation, then keyless rotation and scaling.
|
||
let rec_ofs = b.len() as u32;
|
||
b.extend(m2track(1, 0, (2, ts_ofs), (2, val_ofs)));
|
||
b.extend(m2track(0, 0xffff, (0, 0), (0, 0)));
|
||
b.extend(m2track(0, 0xffff, (0, 0), (0, 0)));
|
||
set_arr(&mut b, OFS_TEX_ANIM, 1, rec_ofs);
|
||
let lk_ofs = b.len() as u32;
|
||
b.extend(0u16.to_le_bytes());
|
||
set_arr(&mut b, OFS_TEX_ANIM_LOOKUP, 1, lk_ofs);
|
||
|
||
let fmt = parse(&b).expect("header with one texture transform parses");
|
||
let m = fmt.model();
|
||
assert_eq!(m.texture_transform_lookup, vec![0]);
|
||
assert!(m.transparency_lookup.is_empty());
|
||
assert_eq!(m.texture_transforms.len(), 1);
|
||
let t = &m.texture_transforms[0];
|
||
assert_eq!(t.translation.interp, 1);
|
||
assert_eq!(t.translation.gseq, 0);
|
||
assert_eq!(
|
||
t.translation.keys,
|
||
vec![(0, [0.0, 0.0, 0.0]), (1000, [0.5, 1.0, 0.0])]
|
||
);
|
||
assert!(t.translation.constant().is_none());
|
||
assert!(t.rotation.keys.is_empty());
|
||
assert!(t.scaling.keys.is_empty());
|
||
}
|
||
|
||
/// Shaped like StormwindMagePortal01's header: [0] at 0x9c, [0,1,2,3] at 0xa4, [0xffff] at 0xac.
|
||
#[test]
|
||
fn transparency_lookup_reads_0xa4_not_the_texture_unit_lookup_at_0x9c() {
|
||
let mut b = header();
|
||
let unit_ofs = b.len() as u32;
|
||
b.extend(0u16.to_le_bytes());
|
||
set_arr(&mut b, OFS_TEX_UNIT_LOOKUP, 1, unit_ofs);
|
||
let trans_ofs = b.len() as u32;
|
||
for v in [0u16, 1, 2, 3] {
|
||
b.extend(v.to_le_bytes());
|
||
}
|
||
set_arr(&mut b, OFS_TRANS_LOOKUP, 4, trans_ofs);
|
||
let ta_ofs = b.len() as u32;
|
||
b.extend(0xffffu16.to_le_bytes());
|
||
set_arr(&mut b, OFS_TEX_ANIM_LOOKUP, 1, ta_ofs);
|
||
|
||
let fmt = parse(&b).expect("header with all three lookup tables parses");
|
||
let m = fmt.model();
|
||
assert_eq!(m.transparency_lookup, vec![0, 1, 2, 3]);
|
||
assert_eq!(m.texture_transform_lookup, vec![0xffff]);
|
||
}
|
||
|
||
#[test]
|
||
fn playable_animation_lookup_decodes_low16_high16() {
|
||
let mut b = header();
|
||
let pal_ofs = b.len() as u32;
|
||
// Row 1 is HumanMale's `playableAnimationLookup[6] = 0x00030001`: id 1, code 3.
|
||
b.extend(0u32.to_le_bytes());
|
||
b.extend(0x0003_0001u32.to_le_bytes());
|
||
set_arr(&mut b, OFS_PLAYABLE_ANIM_LOOKUP, 2, pal_ofs);
|
||
|
||
let fmt = parse(&b).expect("header with a playable-animation-lookup table parses");
|
||
let pal = &fmt.model().playable_animation_lookup;
|
||
assert_eq!(pal.len(), 2);
|
||
assert_eq!(pal[0].resolved_id, 0);
|
||
assert_eq!(pal[0].dir_flags, 0);
|
||
assert_eq!(pal[1].resolved_id, 1);
|
||
assert_eq!(pal[1].dir_flags, 3);
|
||
}
|
||
|
||
fn bone_record(pivot: [f32; 3]) -> Vec<u8> {
|
||
let mut b = vec![0u8; 108];
|
||
b[96..100].copy_from_slice(&pivot[0].to_le_bytes());
|
||
b[100..104].copy_from_slice(&pivot[1].to_le_bytes());
|
||
b[104..108].copy_from_slice(&pivot[2].to_le_bytes());
|
||
b
|
||
}
|
||
|
||
fn attachment_record(id: u32, bone: u32, position: [f32; 3]) -> Vec<u8> {
|
||
let mut b = vec![0u8; 48]; // id, bone, position (20 bytes), the skipped 28-byte track
|
||
b[0..4].copy_from_slice(&id.to_le_bytes());
|
||
b[4..8].copy_from_slice(&bone.to_le_bytes());
|
||
b[8..12].copy_from_slice(&position[0].to_le_bytes());
|
||
b[12..16].copy_from_slice(&position[1].to_le_bytes());
|
||
b[16..20].copy_from_slice(&position[2].to_le_bytes());
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn attachments_parse_and_skip_out_of_range_records() {
|
||
let mut b = header();
|
||
// Two bones, so a valid bone index is 0 or 1.
|
||
let bones_ofs = b.len() as u32;
|
||
b.extend(bone_record([0.0, 0.0, 0.0]));
|
||
b.extend(bone_record([1.0, 1.0, 1.0]));
|
||
set_arr(&mut b, OFS_BONES, 2, bones_ofs);
|
||
|
||
let att_ofs = b.len() as u32;
|
||
b.extend(attachment_record(1, 0, [1.0, 2.0, 3.0])); // valid
|
||
b.extend(attachment_record(70_000, 0, [0.0, 0.0, 0.0])); // id overflows u16 -> skipped
|
||
b.extend(attachment_record(2, 5, [9.0, 9.0, 9.0])); // bone out of range -> skipped
|
||
set_arr(&mut b, OFS_ATTACHMENTS, 3, att_ofs);
|
||
|
||
let fmt = parse(&b).expect("header with bones + attachments parses");
|
||
let atts = &fmt.model().attachments;
|
||
assert_eq!(atts.len(), 1, "only the in-range record survives");
|
||
assert_eq!(atts[0].id, 1);
|
||
assert_eq!(atts[0].bone, 0);
|
||
assert_eq!(atts[0].position, [1.0, 2.0, 3.0]);
|
||
}
|
||
|
||
fn event_record(ident: &[u8; 4], bone: u32, position: [f32; 3]) -> Vec<u8> {
|
||
let mut b = vec![0u8; 44]; // ident, data, bone, position (24 bytes), the skipped 20-byte track
|
||
b[0..4].copy_from_slice(ident);
|
||
b[8..12].copy_from_slice(&bone.to_le_bytes());
|
||
b[12..16].copy_from_slice(&position[0].to_le_bytes());
|
||
b[16..20].copy_from_slice(&position[1].to_le_bytes());
|
||
b[20..24].copy_from_slice(&position[2].to_le_bytes());
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn event_markers_parse_and_skip_out_of_range_records() {
|
||
let mut b = header();
|
||
let bones_ofs = b.len() as u32;
|
||
b.extend(bone_record([0.0, 0.0, 0.0]));
|
||
set_arr(&mut b, OFS_BONES, 1, bones_ofs);
|
||
|
||
let ev_ofs = b.len() as u32;
|
||
b.extend(event_record(b"$CSL", 0, [1.0, 2.0, 3.0])); // valid
|
||
b.extend(event_record(b"$CSR", 5, [9.0, 9.0, 9.0])); // bone out of range -> skipped
|
||
set_arr(&mut b, OFS_EVENTS, 2, ev_ofs);
|
||
|
||
let fmt = parse(&b).expect("header with bones + events parses");
|
||
let markers = &fmt.model().event_markers;
|
||
assert_eq!(markers.len(), 1, "only the in-range record survives");
|
||
assert_eq!(&markers[0].ident, b"$CSL");
|
||
assert_eq!(markers[0].bone, 0);
|
||
assert_eq!(markers[0].position, [1.0, 2.0, 3.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn one_vertex_decodes_all_fields() {
|
||
let mut b = header();
|
||
set_arr(&mut b, OFS_VERTICES, 1, HEADER_LEN as u32);
|
||
let mut vert = vec![0u8; 48];
|
||
vert[0..4].copy_from_slice(&1.0f32.to_le_bytes());
|
||
vert[4..8].copy_from_slice(&2.0f32.to_le_bytes());
|
||
vert[8..12].copy_from_slice(&3.0f32.to_le_bytes());
|
||
vert[12..16].copy_from_slice(&[10, 20, 30, 40]);
|
||
vert[16..20].copy_from_slice(&[1, 2, 3, 4]);
|
||
vert[20..24].copy_from_slice(&4.0f32.to_le_bytes());
|
||
vert[24..28].copy_from_slice(&5.0f32.to_le_bytes());
|
||
vert[28..32].copy_from_slice(&6.0f32.to_le_bytes());
|
||
vert[32..36].copy_from_slice(&0.5f32.to_le_bytes());
|
||
vert[36..40].copy_from_slice(&0.25f32.to_le_bytes());
|
||
b.extend(vert);
|
||
|
||
let fmt = parse(&b).expect("one full vertex record parses");
|
||
let v = &fmt.model().vertices[0];
|
||
assert_eq!((v.position.x, v.position.y, v.position.z), (1.0, 2.0, 3.0));
|
||
assert_eq!(v.bone_weights, [10, 20, 30, 40]);
|
||
assert_eq!(v.bone_indices, [1, 2, 3, 4]);
|
||
assert_eq!((v.normal.x, v.normal.y, v.normal.z), (4.0, 5.0, 6.0));
|
||
assert_eq!((v.tex_coords.x, v.tex_coords.y), (0.5, 0.25));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_vertex_count_errs_cleanly_not_oom() {
|
||
// `u32::MAX` vertices must fail at the first read, not reserve `u32::MAX * 48` bytes.
|
||
let mut b = header();
|
||
set_arr(&mut b, OFS_VERTICES, u32::MAX, HEADER_LEN as u32);
|
||
assert!(matches!(parse(&b), Err(Error::Truncated)));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_attachment_count_errs_cleanly_not_oom() {
|
||
// `emitted_at` is sized from the count, so a table the file cannot hold is refused first.
|
||
let mut b = header();
|
||
set_arr(&mut b, OFS_ATTACHMENTS, u32::MAX, HEADER_LEN as u32);
|
||
assert!(matches!(parse(&b), Err(Error::Truncated)));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_track_record_counts_walk_only_what_the_file_holds() {
|
||
// The colour (0x54), weight (0x64) and texture-transform (0x74) readers never fail, so the
|
||
// file bounds their loops, not the count.
|
||
let mut b = header();
|
||
set_arr(&mut b, 0x54, u32::MAX, HEADER_LEN as u32);
|
||
set_arr(&mut b, 0x64, u32::MAX, HEADER_LEN as u32);
|
||
set_arr(&mut b, OFS_TEX_ANIM, u32::MAX, HEADER_LEN as u32);
|
||
let fmt = parse(&b).expect("lenient blocks never fail the parse");
|
||
let m = fmt.model();
|
||
assert!(m.color_alpha_tracks.is_empty());
|
||
assert!(m.color_rgb_tracks.is_empty());
|
||
assert!(m.transparency_tracks.is_empty());
|
||
assert!(m.texture_transforms.is_empty());
|
||
// A count that overshoots a partly-present block yields exactly the whole records in it.
|
||
let mut b = header();
|
||
let ofs = b.len() as u32;
|
||
b.extend(m2track(0, 0xffff, (0, 0), (0, 0))); // one whole 0x1c weight record …
|
||
b.extend(vec![0u8; 0x10]); // … and part of a second
|
||
set_arr(&mut b, 0x64, 5, ofs);
|
||
assert_eq!(parse(&b).unwrap().model().transparency_tracks.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn truncated_vertex_record_errs_cleanly() {
|
||
let mut b = header();
|
||
set_arr(&mut b, OFS_VERTICES, 1, HEADER_LEN as u32);
|
||
b.extend(vec![0u8; 40]); // one vertex declared, but only 40 of its 48 bytes are present
|
||
assert!(matches!(parse(&b), Err(Error::Truncated)));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_shapes_do_not_panic() {
|
||
assert!(matches!(parse(&[]), Err(Error::NotMd20)));
|
||
assert!(matches!(parse(&[0u8; 7]), Err(Error::NotMd20)));
|
||
let mut b = vec![0u8; HEADER_LEN];
|
||
b[0..4].copy_from_slice(b"MD20");
|
||
b[4..8].copy_from_slice(&999u32.to_le_bytes());
|
||
assert!(matches!(parse(&b), Err(Error::UnsupportedVersion(999))));
|
||
assert!(matches!(
|
||
parse(&header()[..HEADER_LEN - 4]),
|
||
Err(Error::Truncated)
|
||
));
|
||
}
|
||
|
||
/// An MD20 file with one embedded skin profile: three indices, three triangle indices, one
|
||
/// submesh, one batch.
|
||
fn model_with_one_skin() -> Vec<u8> {
|
||
let mut b = header();
|
||
let vp = HEADER_LEN as u32; // M2View header right after the fixed header
|
||
set_arr(&mut b, OFS_VIEWS, 1, vp);
|
||
b.extend(vec![0u8; 44]); // M2View header, patched below by absolute offset
|
||
let idx_ofs = b.len() as u32;
|
||
b.extend([1u16, 2, 3].iter().flat_map(|v| v.to_le_bytes()));
|
||
let tri_ofs = b.len() as u32;
|
||
b.extend([4u16, 5, 6].iter().flat_map(|v| v.to_le_bytes()));
|
||
let sub_ofs = b.len() as u32;
|
||
let mut section = vec![0u8; 32]; // v256 (<260) M2SkinSection stride
|
||
section[0..2].copy_from_slice(&7u16.to_le_bytes()); // skinSectionId
|
||
section[8..10].copy_from_slice(&0u16.to_le_bytes()); // triangle_start
|
||
section[10..12].copy_from_slice(&3u16.to_le_bytes()); // triangle_count
|
||
b.extend(section);
|
||
let bat_ofs = b.len() as u32;
|
||
let mut batch = vec![0u8; 24];
|
||
batch[4..6].copy_from_slice(&0u16.to_le_bytes()); // skin_section_index
|
||
batch[8..10].copy_from_slice(&11u16.to_le_bytes()); // color_index
|
||
batch[0x0a..0x0c].copy_from_slice(&2u16.to_le_bytes()); // material_index
|
||
batch[0x0e..0x10].copy_from_slice(&1u16.to_le_bytes()); // texture_count
|
||
batch[0x10..0x12].copy_from_slice(&5u16.to_le_bytes()); // texture_combo_index
|
||
batch[0x14..0x16].copy_from_slice(&9u16.to_le_bytes()); // weight_combo_index
|
||
b.extend(batch);
|
||
|
||
set_arr(&mut b, vp as usize, 3, idx_ofs);
|
||
set_arr(&mut b, vp as usize + 8, 3, tri_ofs);
|
||
set_arr(&mut b, vp as usize + 24, 1, sub_ofs);
|
||
set_arr(&mut b, vp as usize + 32, 1, bat_ofs);
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn embedded_skin_decodes_geometry_and_batches() {
|
||
let b = model_with_one_skin();
|
||
let fmt = parse(&b).expect("synthetic one-skin model parses");
|
||
let skin = fmt
|
||
.model()
|
||
.parse_embedded_skin(&b, 0)
|
||
.expect("skin profile 0 decodes");
|
||
assert_eq!(*skin.indices(), vec![1, 2, 3]);
|
||
assert_eq!(*skin.triangles(), vec![4, 5, 6]);
|
||
assert_eq!(skin.submeshes().len(), 1);
|
||
assert_eq!(skin.submeshes()[0].id, 7);
|
||
assert_eq!(skin.submeshes()[0].triangle_count, 3);
|
||
assert_eq!(skin.batches().len(), 1);
|
||
assert_eq!(skin.batches()[0].material_index, 2);
|
||
assert_eq!(skin.batches()[0].color_index, 11);
|
||
assert_eq!(skin.batches()[0].texture_combo_index, 5);
|
||
assert_eq!(skin.batches()[0].weight_combo_index, 9);
|
||
}
|
||
|
||
#[test]
|
||
fn embedded_skin_out_of_range_index_errs() {
|
||
let b = model_with_one_skin();
|
||
let fmt = parse(&b).expect("synthetic one-skin model parses");
|
||
assert!(matches!(
|
||
fmt.model().parse_embedded_skin(&b, 1),
|
||
Err(Error::Truncated)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_submesh_count_errs_cleanly_not_oom() {
|
||
// The vertex case, in `parse_embedded_skin`'s M2View arrays.
|
||
let mut b = model_with_one_skin();
|
||
let vp = HEADER_LEN; // M2View header offset (see `model_with_one_skin`)
|
||
let end = b.len() as u32;
|
||
set_arr(&mut b, vp + 24, u32::MAX, end); // submeshes M2Array, offset = end of file
|
||
let fmt = parse(&b).expect("header + view arrays are still well-formed");
|
||
assert!(matches!(
|
||
fmt.model().parse_embedded_skin(&b, 0),
|
||
Err(Error::Truncated)
|
||
));
|
||
}
|
||
|
||
// --- cameras (header 0x124/0x128) + the cubic samplers ---------------------------------------
|
||
|
||
/// A header long enough for `cameras`@`0x124` and `cameraLookup`@`0x12c`, past [`HEADER_LEN`].
|
||
const CAM_HEADER_LEN: usize = 0x134;
|
||
const OFS_CAMERAS: usize = 0x124;
|
||
const OFS_CAMERA_LOOKUP: usize = 0x12c;
|
||
|
||
fn cam_header() -> Vec<u8> {
|
||
let mut b = header();
|
||
b.resize(CAM_HEADER_LEN, 0);
|
||
b
|
||
}
|
||
|
||
/// One fixture key: `(ms, value, in_tan, out_tan)`.
|
||
type SplineKeyFixture = (u32, [f32; 3], [f32; 3], [f32; 3]);
|
||
|
||
/// Append a cubic `C3Vector` track's `{value, inTan, outTan}` keys and return the track's 28 bytes.
|
||
fn spline_vec3_track(b: &mut Vec<u8>, interp: u16, keys: &[SplineKeyFixture]) -> Vec<u8> {
|
||
let times_ofs = b.len() as u32;
|
||
for (ms, ..) in keys {
|
||
b.extend_from_slice(&ms.to_le_bytes());
|
||
}
|
||
let vals_ofs = b.len() as u32;
|
||
for (_, v, i, o) in keys {
|
||
for triple in [v, i, o] {
|
||
for c in triple {
|
||
b.extend_from_slice(&c.to_le_bytes());
|
||
}
|
||
}
|
||
}
|
||
let mut t = Vec::new();
|
||
t.extend_from_slice(&interp.to_le_bytes());
|
||
t.extend_from_slice(&0xffffu16.to_le_bytes()); // gseq: an ordinary sequence-timeline track
|
||
t.extend_from_slice(&0u32.to_le_bytes()); // ranges count
|
||
t.extend_from_slice(&0u32.to_le_bytes()); // ranges offset
|
||
t.extend_from_slice(&(keys.len() as u32).to_le_bytes());
|
||
t.extend_from_slice(×_ofs.to_le_bytes());
|
||
t.extend_from_slice(&(keys.len() as u32).to_le_bytes());
|
||
t.extend_from_slice(&vals_ofs.to_le_bytes());
|
||
t
|
||
}
|
||
|
||
/// A one-camera model whose position track runs X from 0 to 3 over 1000 ms, with
|
||
/// `outTan[k0] = 1` and `inTan[k1] = 2`.
|
||
fn model_with_one_camera(interp: u16) -> Vec<u8> {
|
||
let mut b = cam_header();
|
||
let rec = b.len() as u32;
|
||
b.resize(rec as usize + 0x7c, 0);
|
||
// fov / far / near, then the two bases.
|
||
b[rec as usize + 0x04..rec as usize + 0x08]
|
||
.copy_from_slice(&std::f32::consts::FRAC_PI_4.to_le_bytes());
|
||
b[rec as usize + 0x08..rec as usize + 0x0c].copy_from_slice(&27.777779f32.to_le_bytes());
|
||
b[rec as usize + 0x0c..rec as usize + 0x10].copy_from_slice(&0.22222222f32.to_le_bytes());
|
||
for (ofs, base) in [(0x2c, [10.0f32, 20.0, 30.0]), (0x54, [40.0f32, 50.0, 60.0])] {
|
||
for (i, c) in base.iter().enumerate() {
|
||
let at = rec as usize + ofs + i * 4;
|
||
b[at..at + 4].copy_from_slice(&c.to_le_bytes());
|
||
}
|
||
}
|
||
let track = spline_vec3_track(
|
||
&mut b,
|
||
interp,
|
||
&[
|
||
(0, [0.0; 3], [0.0; 3], [1.0, 0.0, 0.0]),
|
||
(1000, [3.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0; 3]),
|
||
],
|
||
);
|
||
let at = rec as usize + 0x10;
|
||
b[at..at + 0x1c].copy_from_slice(&track);
|
||
// The lookup: one entry, pointing at camera 0.
|
||
let lk = b.len() as u32;
|
||
b.extend_from_slice(&0u16.to_le_bytes());
|
||
set_arr(&mut b, OFS_CAMERAS, 1, rec);
|
||
set_arr(&mut b, OFS_CAMERA_LOOKUP, 1, lk);
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn camera_record_fields_and_lookup_parse() {
|
||
let b = model_with_one_camera(2);
|
||
let cams = parse_cameras(&b);
|
||
assert_eq!(cams.len(), 1);
|
||
let c = &cams[0];
|
||
assert!((c.fov - std::f32::consts::FRAC_PI_4).abs() < 1e-6);
|
||
assert!((c.far_clip - 27.777779).abs() < 1e-4);
|
||
assert!((c.near_clip - 0.22222222).abs() < 1e-7);
|
||
assert_eq!(c.position_base, [10.0, 20.0, 30.0]);
|
||
assert_eq!(c.target_base, [40.0, 50.0, 60.0]);
|
||
assert_eq!(c.positions.keys.len(), 2);
|
||
assert_eq!(parse_camera_lookup(&b), vec![0]);
|
||
// …and the whole-model parse carries the same records.
|
||
let fmt = parse(&b).expect("fixture parses");
|
||
assert_eq!(fmt.model().cameras.len(), 1);
|
||
assert_eq!(fmt.model().camera_lookup, vec![0]);
|
||
}
|
||
|
||
#[test]
|
||
fn cubic_sampler_takes_all_four_interp_legs() {
|
||
// At the midpoint, hand-computed from the bases in `M2Track::sample_ms`:
|
||
// step → value[k0] = 0
|
||
// linear → 0 + (3−0)·0.5 = 1.5
|
||
// Bézier → 0.125·0 + 0.375·1 + 0.375·2 + 0.125·3 = 1.5
|
||
// Hermite→ 0.5·0 + 0.125·1 + 0.5·3 + (−0.125)·2 = 1.375
|
||
for (interp, want) in [(0u16, 0.0f32), (1, 1.5), (2, 1.5), (3, 1.375)] {
|
||
let b = model_with_one_camera(interp);
|
||
let cam = parse_cameras(&b).remove(0);
|
||
let got = cam.positions.sample_ms(500).expect("keyed track samples")[0];
|
||
assert!(
|
||
(got - want).abs() < 1e-5,
|
||
"interp {interp}: got {got}, want {want}"
|
||
);
|
||
// End-clamped at both ends, on every leg.
|
||
assert_eq!(cam.positions.sample_ms(0).unwrap()[0], 0.0);
|
||
assert_eq!(cam.positions.sample_ms(9_999).unwrap()[0], 3.0);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_truncated_camera_record_is_dropped_not_half_read() {
|
||
let mut b = model_with_one_camera(2);
|
||
// Claim two records where only one fits: the second must not appear with default tracks.
|
||
let (rec, _) = (
|
||
u32::from_le_bytes(b[OFS_CAMERAS + 4..OFS_CAMERAS + 8].try_into().unwrap()),
|
||
0,
|
||
);
|
||
set_arr(&mut b, OFS_CAMERAS, 2, rec);
|
||
assert_eq!(parse_cameras(&b).len(), 1);
|
||
}
|