BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / tests / equipment_presets.rs

equipment_presets.rs

equipment_presets.rs 20.0 KiB Download Raw
1use paperdoll_renderer::equipment::{self, Preset, RigSignature, MAGIC};
2use paperdoll_renderer::preview::{self, PreviewControls};
3use std::fs;
4use std::path::PathBuf;
5
6fn fixture(name: &str) -> PathBuf {
7 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
8 .join("tests/fixtures/equipment")
9 .join(name)
10}
11fn fixture_body(female: bool) -> preview::PreviewCharacter {
12 preview::load_gltf_file(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(if female {
13 "tests/fixtures/bodies/test-body-female.glb"
14 } else {
15 "tests/fixtures/bodies/test-body-male.glb"
16 }))
17 .unwrap()
18}
19fn preset() -> Preset {
20 let mut p = Preset::new("sword".into());
21 p.provenance = "CC0-1.0 generated test fixture".into();
22 equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();
23 equipment::import_shiny(&mut p, 0, &fixture("shiny.png")).unwrap();
24 for sex in 0..2 {
25 let body = fixture_body(sex == 1);
26 let joint = body
27 .joint_names
28 .iter()
29 .position(|n| n == "RightHand")
30 .unwrap();
31 p.bind(sex, &body, 0, joint).unwrap();
32 }
33 p.attachments[0].translation = [[0.02, -0.02, -0.03]; 2];
34 p.attachments[0].rotation_deg = [[5., 0., -15.]; 2];
35 p
36}
37fn scratch() -> PathBuf {
38 std::env::temp_dir().join(format!(
39 "paperdoll-equipment-{}",
40 std::time::SystemTime::now()
41 .duration_since(std::time::UNIX_EPOCH)
42 .unwrap()
43 .as_nanos()
44 ))
45}
46
47#[test]
48fn binary_roundtrip_embeds_model_material_textures_and_both_rigs() {
49 let p = preset();
50 let bytes = p.encode().unwrap();
51 assert_eq!(&bytes[..8], MAGIC);
52 assert_eq!(p.encoded_len().unwrap(), bytes.len());
53 let restored = Preset::decode(&bytes).unwrap();
54 assert_eq!(restored, p);
55 assert_eq!(restored.assets.len(), 4);
56 assert_eq!(
57 restored
58 .texture(restored.attachments[0].meshes[0].albedo.unwrap())
59 .unwrap()
60 .width,
61 2
62 );
63 assert_eq!(restored.attachments[0].meshes[0].indices.len(), 6);
64 assert!(restored
65 .assets
66 .iter()
67 .any(|a| a.bytes == fs::read(fixture("sword.obj")).unwrap()));
68 assert!(restored.rigs.iter().all(Option::is_some));
69 let snapshot = fixture("sword.ppe");
70 if std::env::var_os("PAPERDOLL_WRITE_PRESET").is_some() {
71 fs::write(&snapshot, &bytes).unwrap();
72 }
73 assert_eq!(
74 fs::read(snapshot).unwrap(),
75 bytes,
76 "binary preset fixture changed"
77 );
78}
79
80#[test]
81fn attachment_uses_animated_joint_world_not_inverse_bind_twice() {
82 let p = preset();
83 let body = fixture_body(false);
84 let joint = body
85 .joint_names
86 .iter()
87 .position(|n| n == "RightHand")
88 .unwrap();
89 let mut controls = PreviewControls {
90 time: 0.0,
91 clip_name: "walk".into(),
92 ..Default::default()
93 };
94 let first = equipment::layers(&p, 0, &body, &controls).unwrap();
95 let world = preview::animated_joint_world(&body, "walk", 0., joint).unwrap();
96 let expected = preview::mul(
97 world,
98 equipment::local_matrix(
99 p.attachments[0].translation[0],
100 p.attachments[0].rotation_deg[0],
101 ),
102 );
103 assert_eq!(first[0].model, expected);
104 assert_ne!(
105 world,
106 preview::sample_skin_matrices(&body, "walk", 0.)[joint]
107 );
108 controls.time = 0.25;
109 let second = equipment::layers(&p, 0, &body, &controls).unwrap();
110 assert_ne!(first[0].model, second[0].model);
111 let female = equipment::layers(&p, 1, &fixture_body(true), &controls).unwrap();
112 assert!(!female.is_empty());
113}
114
115#[test]
116fn local_euler_order_and_translation_are_not_accumulated() {
117 let m = equipment::local_matrix([1., 2., 3.], [0., 0., 90.]);
118 let point = [m[0] + m[12], m[1] + m[13], m[2] + m[14]];
119 for (a, b) in point.into_iter().zip([1., 3., 3.]) {
120 assert!((a - b).abs() < 1e-5);
121 }
122 assert_eq!(m, equipment::local_matrix([1., 2., 3.], [0., 0., 90.]));
123 let m = equipment::local_matrix([10., 20., 30.], [90., 90., 90.]);
124 let v = [1., 2., 3.];
125 for i in 0..3 {
126 let actual = m[i] * v[0] + m[4 + i] * v[1] + m[8 + i] * v[2] + m[12 + i];
127 assert!(
128 (actual - [13., 22., 29.][i]).abs() < 1e-4,
129 "multi-axis Euler order"
130 );
131 }
132}
133
134#[test]
135fn incompatible_rig_and_missing_joint_require_explicit_remapping() {
136 let mut p = preset();
137 let mut changed = fixture_body(false);
138 changed.nodes[0].translation[0] += 0.01;
139 assert!(
140 equipment::layers(&p, 0, &changed, &PreviewControls::default())
141 .unwrap_err()
142 .contains("rig")
143 );
144 p.attachments[0].bones[0] = Some("missing".into());
145 assert!(equipment::layers(&p, 0, &fixture_body(false), &PreviewControls::default()).is_err());
146 p.applies[0] = false;
147 assert!(
148 equipment::layers(&p, 0, &changed, &PreviewControls::default())
149 .unwrap()
150 .is_empty()
151 );
152 assert_ne!(
153 RigSignature::of(&fixture_body(false)),
154 RigSignature::of(&fixture_body(true))
155 );
156}
157
158#[test]
159fn corrupt_truncated_unsupported_and_excessive_containers_are_rejected() {
160 let p = preset();
161 let valid = p.encode().unwrap();
162 for n in [0, 7, 19, valid.len() - 1] {
163 assert!(Preset::decode(&valid[..n]).is_err());
164 }
165 let mut damaged = valid.clone();
166 *damaged.last_mut().unwrap() ^= 1;
167 assert!(Preset::decode(&damaged).is_err());
168 let mut damaged = valid.clone();
169 damaged[8..12].copy_from_slice(&(equipment::VERSION + 1).to_le_bytes());
170 assert!(Preset::decode(&damaged).is_err());
171 let mut damaged = valid.clone();
172 damaged[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
173 assert!(Preset::decode(&damaged).is_err());
174 let mut damaged = valid.clone();
175 damaged.push(0);
176 let crc = equipment::checksum(&damaged[20..]);
177 damaged[16..20].copy_from_slice(&crc.to_le_bytes());
178 assert!(Preset::decode(&damaged).is_err());
179 assert_eq!(equipment::checksum(b"123456789"), 0xcbf43926);
180}
181
182// Build a malicious but correctly checksummed container without encoder validation.
183fn untrusted(p: &Preset) -> Vec<u8> {
184 let metadata = serde_json::to_vec(p).unwrap();
185 let n = metadata.len();
186 let mut payload = metadata;
187 for a in &p.assets {
188 payload.extend_from_slice(&(a.bytes.len() as u32).to_le_bytes());
189 payload.extend(&a.bytes)
190 }
191 let mut out = MAGIC.to_vec();
192 out.extend(1u32.to_le_bytes());
193 out.extend((n as u32).to_le_bytes());
194 out.extend(equipment::checksum(&payload).to_le_bytes());
195 out.extend(payload);
196 out
197}
198
199#[test]
200fn traversal_invalid_names_nonfinite_and_invalid_geometry_are_rejected() {
201 for name in ["body", "Hello", "with space", "../escape", ""] {
202 let mut p = preset();
203 p.name = name.into();
204 assert!(p.encode().is_err());
205 assert!(Preset::decode(&untrusted(&p)).is_err());
206 }
207 let mut p = preset();
208 p.assets[0].path = "../escape".into();
209 assert!(Preset::decode(&untrusted(&p)).is_err());
210 let mut p = preset();
211 p.assets[0].path = "/absolute".into();
212 assert!(p.encode().is_err());
213 let mut p = preset();
214 p.attachments[0].translation[0][0] = f32::NAN;
215 assert!(p.encode().is_err());
216 let mut p = preset();
217 p.attachments[0].meshes[0].indices[0] = u32::MAX;
218 assert!(Preset::decode(&untrusted(&p)).is_err());
219}
220
221#[test]
222fn dependency_escape_and_failed_import_leave_existing_preset_unchanged() {
223 let root = scratch();
224 fs::create_dir_all(&root).unwrap();
225 let path = root.join("bad.obj");
226 fs::write(&path, "mtllib ../outside.mtl\nv 0 0 0\n").unwrap();
227 let mut p = preset();
228 let saved = p.clone();
229 assert!(equipment::import_obj(&mut p, &path).is_err());
230 assert_eq!(saved, p);
231 #[cfg(unix)]
232 {
233 std::os::unix::fs::symlink(fixture("sword.mtl"), root.join("outside.mtl")).unwrap();
234 fs::write(&path, "mtllib outside.mtl\nv 0 0 0\n").unwrap();
235 assert!(equipment::import_obj(&mut p, &path).is_err());
236 }
237 fs::remove_dir_all(root).unwrap();
238}
239
240#[test]
241fn atomic_snapshot_never_overwrites_previous_valid_data() {
242 let root = scratch();
243 fs::create_dir_all(&root).unwrap();
244 let path = root.join("sword.ppe");
245 let bytes = preset().encode().unwrap();
246 equipment::write_snapshot(&path, &bytes).unwrap();
247 equipment::write_snapshot(&path, &bytes).unwrap();
248 assert!(equipment::write_snapshot(&path, b"different").is_err());
249 assert_eq!(fs::read(&path).unwrap(), bytes);
250 assert!(fs::read_dir(&root).unwrap().all(|entry| !entry
251 .unwrap()
252 .file_name()
253 .to_string_lossy()
254 .contains("part-")));
255 fs::remove_dir_all(root).unwrap();
256}
257
258#[test]
259fn binary_project_reload_keeps_payload_after_source_disappears() {
260 let root = scratch();
261 let mut session = paperdoll_renderer::session::Session::new();
262 session.install_new_project(&root).unwrap();
263 let index = session.add_equipment();
264 session.equipment[index].slug = "sword".into();
265 session.equipment[index].preset = Some(preset());
266 session.save().unwrap();
267 let manifest = root.join("character.ppd");
268 let reference = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();
269 assert!(reference.ends_with(".ppe"));
270 let mut again = paperdoll_renderer::session::Session::new();
271 again.install_loaded(paperdoll_renderer::project::open_project(&manifest).unwrap());
272 let loaded = again.equipment[0].preset.as_ref().unwrap();
273 assert_eq!(loaded.name, "sword");
274 assert!(
275 !equipment::layers(loaded, 0, &fixture_body(false), &PreviewControls::default())
276 .unwrap()
277 .is_empty()
278 );
279 let previous = fs::read(&manifest).unwrap();
280 again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0][0] = 0.2;
281 again.set_render(0, 128, 12);
282 assert!(again.save().is_err());
283 assert_eq!(fs::read(&manifest).unwrap(), previous);
284 fs::remove_dir_all(root).unwrap();
285}
286
287#[test]
288fn translated_rotated_parent_uses_the_complete_hierarchy_once() {
289 let mut body = fixture_body(false);
290 body.nodes = vec![
291 preview::SceneNode {
292 name: "root".into(),
293 parent: None,
294 translation: [3., 0., 0.],
295 rotation: [
296 0.,
297 0.,
298 std::f32::consts::FRAC_1_SQRT_2,
299 std::f32::consts::FRAC_1_SQRT_2,
300 ],
301 scale: [1.; 3],
302 },
303 preview::SceneNode {
304 name: "shoulder".into(),
305 parent: Some(0),
306 translation: [0., 2., 0.],
307 rotation: [0., 0., 0., 1.],
308 scale: [1.; 3],
309 },
310 preview::SceneNode {
311 name: "hand".into(),
312 parent: Some(1),
313 translation: [0., 0., 1.],
314 rotation: [0., 0., 0., 1.],
315 scale: [1.; 3],
316 },
317 ];
318 body.joint_nodes = vec![2];
319 body.joint_names = vec!["hand".into()];
320 body.clips.clear();
321 let mut preset = preset();
322 preset.rigs = [None, None];
323 preset.applies = [true, false];
324 preset.attachments[0].bones = [None, None];
325 preset.attachments[0].translation = [[0.2, 0.3, 0.4]; 2];
326 preset.attachments[0].rotation_deg = [[0.; 3]; 2];
327 preset.bind(0, &body, 0, 0).unwrap();
328 let layer = equipment::layers(&preset, 0, &body, &PreviewControls::default()).unwrap();
329 for (value, expected) in [layer[0].model[12], layer[0].model[13], layer[0].model[14]]
330 .into_iter()
331 .zip([0.7, 0.2, 1.4])
332 {
333 assert!((value - expected).abs() < 1e-5, "{value} != {expected}");
334 }
335}
336
337#[test]
338fn unsupported_texture_dimensions_and_asset_counts_are_bounded() {
339 let mut p = preset();
340 let mut data = Vec::new();
341 {
342 let mut encoder = png::Encoder::new(&mut data, 2049, 1);
343 encoder.set_color(png::ColorType::Grayscale);
344 encoder.set_depth(png::BitDepth::Eight);
345 let mut writer = encoder.write_header().unwrap();
346 writer.write_image_data(&vec![0; 2049]).unwrap();
347 }
348 let i = p.attachments[0].shiny.unwrap();
349 p.assets[i].bytes = data;
350 assert!(p.encode().is_err());
351 assert!(Preset::decode(&untrusted(&p)).is_err());
352 let mut p = preset();
353 while p.assets.len() < 33 {
354 let n = p.assets.len();
355 p.assets.push(equipment::EmbeddedAsset {
356 path: format!("asset-{n}"),
357 bytes: Vec::new(),
358 });
359 }
360 assert!(Preset::decode(&untrusted(&p)).is_err());
361}
362
363#[test]
364fn multipart_remove_readd_and_shiny_replacement_remap_embedded_assets() {
365 let mut p = preset();
366 equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();
367 equipment::import_shiny(&mut p, 1, &fixture("shiny.png")).unwrap();
368 assert_eq!(p.assets.len(), 8);
369 equipment::remove_attachment(&mut p, 0).unwrap();
370 assert_eq!(p.assets.len(), 4);
371 equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();
372 for _ in 0..40 {
373 equipment::import_shiny(&mut p, 1, &fixture("shiny.png")).unwrap();
374 }
375 assert_eq!(p.assets.len(), 8);
376 let bytes = p.encode().unwrap();
377 assert_eq!(Preset::decode(&bytes).unwrap(), p);
378 assert!(p.attachments.iter().all(|a| a.source_assets.len() == 3));
379 equipment::clear_shiny(&mut p, 1).unwrap();
380 assert_eq!(p.assets.len(), 7);
381 assert!(p.attachments[1].shiny.is_none());
382 p.validate().unwrap();
383 let before = p.clone();
384 assert!(equipment::remove_attachment(&mut p, 999).is_err());
385 assert_eq!(p, before);
386 p.attachments[0].source_assets[0] = usize::MAX;
387 assert!(Preset::decode(&untrusted(&p)).is_err());
388}
389#[test]
390fn non_regular_preset_and_snapshot_targets_fail_without_reading() {
391 let root = scratch();
392 fs::create_dir_all(&root).unwrap();
393 assert!(Preset::read(&root).is_err());
394 assert!(equipment::write_snapshot(&root, b"bytes").is_err());
395 #[cfg(unix)]
396 {
397 let socket = root.join("socket.ppe");
398 let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
399 assert!(Preset::read(&socket).is_err());
400 drop(listener);
401 }
402 fs::remove_dir_all(root).unwrap();
403}
404
405fn pack_legacy(
406 preset: &Preset,
407 version: u32,
408 applies: [bool; 2],
409 translation: [f32; 3],
410 rotation_deg: [f32; 3],
411) -> Vec<u8> {
412 let mut value = serde_json::to_value(preset).expect("preset json");
413 value["applies"] = serde_json::json!([applies[0], applies[1]]);
414 for attachment in value["attachments"].as_array_mut().unwrap() {
415 attachment["translation"] = serde_json::json!(translation);
416 attachment["rotation_deg"] = serde_json::json!(rotation_deg);
417 }
418 let metadata = serde_json::to_vec(&value).unwrap();
419 let mut payload = metadata.clone();
420 for asset in &preset.assets {
421 payload.extend_from_slice(&(asset.bytes.len() as u32).to_le_bytes());
422 payload.extend_from_slice(&asset.bytes);
423 }
424 let mut out = MAGIC.to_vec();
425 out.extend_from_slice(&version.to_le_bytes());
426 out.extend_from_slice(&(metadata.len() as u32).to_le_bytes());
427 out.extend_from_slice(&equipment::checksum(&payload).to_le_bytes());
428 out.extend(payload);
429 out
430}
431
432fn rename_joint(
433 mut body: preview::PreviewCharacter,
434 from: &str,
435 to: &str,
436) -> preview::PreviewCharacter {
437 for name in &mut body.joint_names {
438 if name == from {
439 *name = to.to_string();
440 }
441 }
442 for node in &mut body.nodes {
443 if node.name == from {
444 node.name = to.to_string();
445 }
446 }
447 body
448}
449
450#[test]
451fn versions_1_to_3_apply_shared_offsets_to_enabled_sexes_and_round_trip() {
452 let offset = [0.02, -0.02, -0.03];
453 let rotation = [5.0, 0.0, -15.0];
454 for name in ["sword-v1.ppe", "sword-v2.ppe", "sword-v3.ppe"] {
455 let old = fs::read(fixture(name)).unwrap();
456 let version = u32::from_le_bytes(old[8..12].try_into().unwrap());
457 assert!(
458 (1..=3).contains(&version),
459 "{name} should be a legacy fixture, got {version}"
460 );
461 let migrated = Preset::decode(&old).unwrap();
462 assert_eq!(migrated.applies, [true, true]);
463 assert_eq!(migrated.attachments[0].translation, [offset; 2]);
464 assert_eq!(migrated.attachments[0].rotation_deg, [rotation; 2]);
465 let bytes = migrated.encode().unwrap();
466 assert_eq!(
467 u32::from_le_bytes(bytes[8..12].try_into().unwrap()),
468 equipment::VERSION
469 );
470 assert_eq!(Preset::decode(&bytes).unwrap(), migrated);
471 let male = fixture_body(false);
472 let female = fixture_body(true);
473 let controls = PreviewControls::default();
474 let layers_m = equipment::layers(&migrated, 0, &male, &controls).unwrap();
475 let layers_f = equipment::layers(&migrated, 1, &female, &controls).unwrap();
476 assert!(!layers_m.is_empty());
477 assert!(!layers_f.is_empty());
478 }
479}
480
481#[test]
482fn version_3_shared_offset_fills_only_enabled_sex() {
483 let mut source = preset();
484 source.applies = [true, false];
485 source.attachments[0].bones[1] = None;
486 source.rigs[1] = None;
487 let old = pack_legacy(&source, 3, [true, false], [0.2, 0.3, 0.4], [1.0, 2.0, 3.0]);
488 assert_eq!(u32::from_le_bytes(old[8..12].try_into().unwrap()), 3);
489 let migrated = Preset::decode(&old).unwrap();
490 assert_eq!(migrated.applies, [true, false]);
491 assert_eq!(migrated.attachments[0].translation[0], [0.2, 0.3, 0.4]);
492 assert_eq!(migrated.attachments[0].rotation_deg[0], [1.0, 2.0, 3.0]);
493 assert_eq!(migrated.attachments[0].translation[1], [0.0, 0.0, 0.0]);
494 assert_eq!(migrated.attachments[0].rotation_deg[1], [0.0, 0.0, 0.0]);
495 assert!(migrated.attachments[0].bones[1].is_none());
496}
497
498#[test]
499fn copy_rigid_mapping_matching_names_partial_and_none() {
500 let male = fixture_body(false);
501 let female = fixture_body(true);
502 let joint = male
503 .joint_names
504 .iter()
505 .position(|n| n == "RightHand")
506 .unwrap();
507 let mut preset = Preset::new("helm".into());
508 equipment::import_obj(&mut preset, &fixture("sword.obj")).unwrap();
509 preset.applies = [true, false];
510 preset.bind(0, &male, 0, joint).unwrap();
511 preset.attachments[0].translation[0] = [0.11, 0.22, 0.33];
512 preset.attachments[0].rotation_deg[0] = [4.0, 5.0, 6.0];
513
514 // Same names: the female RightHand path is used, offsets are copied.
515 preset.applies[1] = true;
516 let copied = preset.copy_gender_mapping(0, 1, &female).unwrap();
517 assert_eq!(copied.mapped, 1);
518 assert_eq!(copied.unmatched, 0);
519 let female_hand = preview::joint_paths(&female)
520 .into_iter()
521 .find(|p| equipment::joint_leaf_name(p) == "RightHand")
522 .unwrap();
523 assert_eq!(
524 preset.attachments[0].bones[1].as_deref(),
525 Some(female_hand.as_str())
526 );
527 assert_eq!(preset.attachments[0].translation[1], [0.11, 0.22, 0.33]);
528 assert_eq!(preset.attachments[0].rotation_deg[1], [4.0, 5.0, 6.0]);
529 assert_eq!(
530 equipment::joint_leaf_name(preset.attachments[0].bones[1].as_ref().unwrap()),
531 "RightHand"
532 );
533
534 // Existing female mapping is left alone.
535 preset.attachments[0].translation[1] = [9.0, 9.0, 9.0];
536 let again = preset.copy_gender_mapping(0, 1, &female).unwrap();
537 assert_eq!(again, equipment::CopyGenderMapping::default());
538 assert_eq!(preset.attachments[0].translation[1], [9.0, 9.0, 9.0]);
539
540 // No overlap: dest body has no RightHand.
541 let mut empty = Preset::new("helm".into());
542 equipment::import_obj(&mut empty, &fixture("sword.obj")).unwrap();
543 empty.bind(0, &male, 0, joint).unwrap();
544 empty.attachments[0].translation[0] = [0.11, 0.22, 0.33];
545 let renamed = rename_joint(female.clone(), "RightHand", "OtherHand");
546 let none = empty.copy_gender_mapping(0, 1, &renamed).unwrap();
547 assert_eq!(none.mapped, 0);
548 assert_eq!(none.unmatched, 1);
549 assert!(empty.attachments[0].bones[1].is_none());
550 assert_eq!(empty.attachments[0].translation[1], [0.0, 0.0, 0.0]);
551
552 // Independent save/load of the two sexes.
553 preset.attachments[0].translation[0] = [1.0, 0.0, 0.0];
554 preset.attachments[0].translation[1] = [0.0, 1.0, 0.0];
555 let restored = Preset::decode(&preset.encode().unwrap()).unwrap();
556 assert_eq!(restored.attachments[0].translation[0], [1.0, 0.0, 0.0]);
557 assert_eq!(restored.attachments[0].translation[1], [0.0, 1.0, 0.0]);
558}