use paperdoll_renderer::equipment::{self, Preset, RigSignature, MAGIC}; use paperdoll_renderer::preview::{self, PreviewControls}; use std::fs; use std::path::PathBuf; fn fixture(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/equipment") .join(name) } fn fixture_body(female: bool) -> preview::PreviewCharacter { preview::load_gltf_file(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(if female { "tests/fixtures/bodies/test-body-female.glb" } else { "tests/fixtures/bodies/test-body-male.glb" })) .unwrap() } fn preset() -> Preset { let mut p = Preset::new("sword".into()); p.provenance = "CC0-1.0 generated test fixture".into(); equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap(); equipment::import_shiny(&mut p, 0, &fixture("shiny.png")).unwrap(); for sex in 0..2 { let body = fixture_body(sex == 1); let joint = body .joint_names .iter() .position(|n| n == "RightHand") .unwrap(); p.bind(sex, &body, 0, joint).unwrap(); } p.attachments[0].translation = [0.02, -0.02, -0.03]; p.attachments[0].rotation_deg = [5., 0., -15.]; p } fn scratch() -> PathBuf { std::env::temp_dir().join(format!( "paperdoll-equipment-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )) } #[test] fn binary_roundtrip_embeds_model_material_textures_and_both_rigs() { let p = preset(); let bytes = p.encode().unwrap(); assert_eq!(&bytes[..8], MAGIC); assert_eq!(p.encoded_len().unwrap(), bytes.len()); let restored = Preset::decode(&bytes).unwrap(); assert_eq!(restored, p); assert_eq!(restored.assets.len(), 4); assert_eq!( restored .texture(restored.attachments[0].meshes[0].albedo.unwrap()) .unwrap() .width, 2 ); assert_eq!(restored.attachments[0].meshes[0].indices.len(), 6); assert!(restored .assets .iter() .any(|a| a.bytes == fs::read(fixture("sword.obj")).unwrap())); assert!(restored.rigs.iter().all(Option::is_some)); let snapshot = fixture("sword.ppe"); if std::env::var_os("PAPERDOLL_WRITE_PRESET").is_some() { fs::write(&snapshot, &bytes).unwrap(); } assert_eq!( fs::read(snapshot).unwrap(), bytes, "binary preset fixture changed" ); } #[test] fn attachment_uses_animated_joint_world_not_inverse_bind_twice() { let p = preset(); let body = fixture_body(false); let joint = body .joint_names .iter() .position(|n| n == "RightHand") .unwrap(); let mut controls = PreviewControls { time: 0.0, clip_name: "walk".into(), ..Default::default() }; let first = equipment::layers(&p, 0, &body, &controls).unwrap(); let world = preview::animated_joint_world(&body, "walk", 0., joint).unwrap(); let expected = preview::mul( world, equipment::local_matrix(p.attachments[0].translation, p.attachments[0].rotation_deg), ); assert_eq!(first[0].model, expected); assert_ne!( world, preview::sample_skin_matrices(&body, "walk", 0.)[joint] ); controls.time = 0.25; let second = equipment::layers(&p, 0, &body, &controls).unwrap(); assert_ne!(first[0].model, second[0].model); let female = equipment::layers(&p, 1, &fixture_body(true), &controls).unwrap(); assert!(!female.is_empty()); } #[test] fn local_euler_order_and_translation_are_not_accumulated() { let m = equipment::local_matrix([1., 2., 3.], [0., 0., 90.]); let point = [m[0] + m[12], m[1] + m[13], m[2] + m[14]]; for (a, b) in point.into_iter().zip([1., 3., 3.]) { assert!((a - b).abs() < 1e-5); } assert_eq!(m, equipment::local_matrix([1., 2., 3.], [0., 0., 90.])); let m = equipment::local_matrix([10., 20., 30.], [90., 90., 90.]); let v = [1., 2., 3.]; for i in 0..3 { let actual = m[i] * v[0] + m[4 + i] * v[1] + m[8 + i] * v[2] + m[12 + i]; assert!( (actual - [13., 22., 29.][i]).abs() < 1e-4, "multi-axis Euler order" ); } } #[test] fn incompatible_rig_and_missing_joint_require_explicit_remapping() { let mut p = preset(); let mut changed = fixture_body(false); changed.nodes[0].translation[0] += 0.01; assert!( equipment::layers(&p, 0, &changed, &PreviewControls::default()) .unwrap_err() .contains("rig") ); p.attachments[0].bones[0] = Some("missing".into()); assert!(equipment::layers(&p, 0, &fixture_body(false), &PreviewControls::default()).is_err()); p.applies[0] = false; assert!( equipment::layers(&p, 0, &changed, &PreviewControls::default()) .unwrap() .is_empty() ); assert_ne!( RigSignature::of(&fixture_body(false)), RigSignature::of(&fixture_body(true)) ); } #[test] fn corrupt_truncated_unsupported_and_excessive_containers_are_rejected() { let p = preset(); let valid = p.encode().unwrap(); for n in [0, 7, 19, valid.len() - 1] { assert!(Preset::decode(&valid[..n]).is_err()); } let mut damaged = valid.clone(); *damaged.last_mut().unwrap() ^= 1; assert!(Preset::decode(&damaged).is_err()); let mut damaged = valid.clone(); damaged[8..12].copy_from_slice(&(equipment::VERSION + 1).to_le_bytes()); assert!(Preset::decode(&damaged).is_err()); let mut damaged = valid.clone(); damaged[12..16].copy_from_slice(&u32::MAX.to_le_bytes()); assert!(Preset::decode(&damaged).is_err()); let mut damaged = valid.clone(); damaged.push(0); let crc = equipment::checksum(&damaged[20..]); damaged[16..20].copy_from_slice(&crc.to_le_bytes()); assert!(Preset::decode(&damaged).is_err()); assert_eq!(equipment::checksum(b"123456789"), 0xcbf43926); } // Build a malicious but correctly checksummed container without encoder validation. fn untrusted(p: &Preset) -> Vec { let metadata = serde_json::to_vec(p).unwrap(); let n = metadata.len(); let mut payload = metadata; for a in &p.assets { payload.extend_from_slice(&(a.bytes.len() as u32).to_le_bytes()); payload.extend(&a.bytes) } let mut out = MAGIC.to_vec(); out.extend(1u32.to_le_bytes()); out.extend((n as u32).to_le_bytes()); out.extend(equipment::checksum(&payload).to_le_bytes()); out.extend(payload); out } #[test] fn traversal_invalid_names_nonfinite_and_invalid_geometry_are_rejected() { for name in ["body", "Hello", "with space", "../escape", ""] { let mut p = preset(); p.name = name.into(); assert!(p.encode().is_err()); assert!(Preset::decode(&untrusted(&p)).is_err()); } let mut p = preset(); p.assets[0].path = "../escape".into(); assert!(Preset::decode(&untrusted(&p)).is_err()); let mut p = preset(); p.assets[0].path = "/absolute".into(); assert!(p.encode().is_err()); let mut p = preset(); p.attachments[0].translation[0] = f32::NAN; assert!(p.encode().is_err()); let mut p = preset(); p.attachments[0].meshes[0].indices[0] = u32::MAX; assert!(Preset::decode(&untrusted(&p)).is_err()); } #[test] fn dependency_escape_and_failed_import_leave_existing_preset_unchanged() { let root = scratch(); fs::create_dir_all(&root).unwrap(); let path = root.join("bad.obj"); fs::write(&path, "mtllib ../outside.mtl\nv 0 0 0\n").unwrap(); let mut p = preset(); let saved = p.clone(); assert!(equipment::import_obj(&mut p, &path).is_err()); assert_eq!(saved, p); #[cfg(unix)] { std::os::unix::fs::symlink(fixture("sword.mtl"), root.join("outside.mtl")).unwrap(); fs::write(&path, "mtllib outside.mtl\nv 0 0 0\n").unwrap(); assert!(equipment::import_obj(&mut p, &path).is_err()); } fs::remove_dir_all(root).unwrap(); } #[test] fn atomic_snapshot_never_overwrites_previous_valid_data() { let root = scratch(); fs::create_dir_all(&root).unwrap(); let path = root.join("sword.ppe"); let bytes = preset().encode().unwrap(); equipment::write_snapshot(&path, &bytes).unwrap(); equipment::write_snapshot(&path, &bytes).unwrap(); assert!(equipment::write_snapshot(&path, b"different").is_err()); assert_eq!(fs::read(&path).unwrap(), bytes); assert!(fs::read_dir(&root).unwrap().all(|entry| !entry .unwrap() .file_name() .to_string_lossy() .contains("part-"))); fs::remove_dir_all(root).unwrap(); } #[test] fn binary_project_reload_keeps_payload_after_source_disappears() { let root = scratch(); let mut session = paperdoll_renderer::session::Session::new(); session.install_new_project(&root).unwrap(); let index = session.add_equipment(); session.equipment[index].slug = "sword".into(); session.equipment[index].preset = Some(preset()); session.save().unwrap(); let manifest = root.join("character.ppd"); let reference = session.opened.as_ref().unwrap().document.equipment_presets[0].clone(); assert!(reference.ends_with(".ppe")); let mut again = paperdoll_renderer::session::Session::new(); again.install_loaded(paperdoll_renderer::project::open_project(&manifest).unwrap()); let loaded = again.equipment[0].preset.as_ref().unwrap(); assert_eq!(loaded.name, "sword"); assert!( !equipment::layers(loaded, 0, &fixture_body(false), &PreviewControls::default()) .unwrap() .is_empty() ); let previous = fs::read(&manifest).unwrap(); again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0] = 0.2; again.set_render(0, 128, 12); assert!(again.save().is_err()); assert_eq!(fs::read(&manifest).unwrap(), previous); fs::remove_dir_all(root).unwrap(); } #[test] fn translated_rotated_parent_uses_the_complete_hierarchy_once() { let mut body = fixture_body(false); body.nodes = vec![ preview::SceneNode { name: "root".into(), parent: None, translation: [3., 0., 0.], rotation: [ 0., 0., std::f32::consts::FRAC_1_SQRT_2, std::f32::consts::FRAC_1_SQRT_2, ], scale: [1.; 3], }, preview::SceneNode { name: "shoulder".into(), parent: Some(0), translation: [0., 2., 0.], rotation: [0., 0., 0., 1.], scale: [1.; 3], }, preview::SceneNode { name: "hand".into(), parent: Some(1), translation: [0., 0., 1.], rotation: [0., 0., 0., 1.], scale: [1.; 3], }, ]; body.joint_nodes = vec![2]; body.joint_names = vec!["hand".into()]; body.clips.clear(); let mut preset = preset(); preset.rigs = [None, None]; preset.applies = [true, false]; preset.attachments[0].bones = [None, None]; preset.attachments[0].translation = [0.2, 0.3, 0.4]; preset.attachments[0].rotation_deg = [0.; 3]; preset.bind(0, &body, 0, 0).unwrap(); let layer = equipment::layers(&preset, 0, &body, &PreviewControls::default()).unwrap(); for (value, expected) in [layer[0].model[12], layer[0].model[13], layer[0].model[14]] .into_iter() .zip([0.7, 0.2, 1.4]) { assert!((value - expected).abs() < 1e-5, "{value} != {expected}"); } } #[test] fn unsupported_texture_dimensions_and_asset_counts_are_bounded() { let mut p = preset(); let mut data = Vec::new(); { let mut encoder = png::Encoder::new(&mut data, 2049, 1); encoder.set_color(png::ColorType::Grayscale); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder.write_header().unwrap(); writer.write_image_data(&vec![0; 2049]).unwrap(); } let i = p.attachments[0].shiny.unwrap(); p.assets[i].bytes = data; assert!(p.encode().is_err()); assert!(Preset::decode(&untrusted(&p)).is_err()); let mut p = preset(); while p.assets.len() < 33 { let n = p.assets.len(); p.assets.push(equipment::EmbeddedAsset { path: format!("asset-{n}"), bytes: Vec::new(), }); } assert!(Preset::decode(&untrusted(&p)).is_err()); } #[test] fn multipart_remove_readd_and_shiny_replacement_remap_embedded_assets() { let mut p = preset(); equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap(); equipment::import_shiny(&mut p, 1, &fixture("shiny.png")).unwrap(); assert_eq!(p.assets.len(), 8); equipment::remove_attachment(&mut p, 0).unwrap(); assert_eq!(p.assets.len(), 4); equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap(); for _ in 0..40 { equipment::import_shiny(&mut p, 1, &fixture("shiny.png")).unwrap(); } assert_eq!(p.assets.len(), 8); let bytes = p.encode().unwrap(); assert_eq!(Preset::decode(&bytes).unwrap(), p); assert!(p.attachments.iter().all(|a| a.source_assets.len() == 3)); equipment::clear_shiny(&mut p, 1).unwrap(); assert_eq!(p.assets.len(), 7); assert!(p.attachments[1].shiny.is_none()); p.validate().unwrap(); let before = p.clone(); assert!(equipment::remove_attachment(&mut p, 999).is_err()); assert_eq!(p, before); p.attachments[0].source_assets[0] = usize::MAX; assert!(Preset::decode(&untrusted(&p)).is_err()); } #[test] fn non_regular_preset_and_snapshot_targets_fail_without_reading() { let root = scratch(); fs::create_dir_all(&root).unwrap(); assert!(Preset::read(&root).is_err()); assert!(equipment::write_snapshot(&root, b"bytes").is_err()); #[cfg(unix)] { let socket = root.join("socket.ppe"); let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); assert!(Preset::read(&socket).is_err()); drop(listener); } fs::remove_dir_all(root).unwrap(); }