BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / tests / equipment_presets.rs

equipment_presets.rs

equipment_presets.rs 13.8 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];
34 p.attachments[0].rotation_deg = [5., 0., -15.];
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(p.attachments[0].translation, p.attachments[0].rotation_deg),
99 );
100 assert_eq!(first[0].model, expected);
101 assert_ne!(
102 world,
103 preview::sample_skin_matrices(&body, "walk", 0.)[joint]
104 );
105 controls.time = 0.25;
106 let second = equipment::layers(&p, 0, &body, &controls).unwrap();
107 assert_ne!(first[0].model, second[0].model);
108 let female = equipment::layers(&p, 1, &fixture_body(true), &controls).unwrap();
109 assert!(!female.is_empty());
110}
111
112#[test]
113fn local_euler_order_and_translation_are_not_accumulated() {
114 let m = equipment::local_matrix([1., 2., 3.], [0., 0., 90.]);
115 let point = [m[0] + m[12], m[1] + m[13], m[2] + m[14]];
116 for (a, b) in point.into_iter().zip([1., 3., 3.]) {
117 assert!((a - b).abs() < 1e-5);
118 }
119 assert_eq!(m, equipment::local_matrix([1., 2., 3.], [0., 0., 90.]));
120 let m = equipment::local_matrix([10., 20., 30.], [90., 90., 90.]);
121 let v = [1., 2., 3.];
122 for i in 0..3 {
123 let actual = m[i] * v[0] + m[4 + i] * v[1] + m[8 + i] * v[2] + m[12 + i];
124 assert!(
125 (actual - [13., 22., 29.][i]).abs() < 1e-4,
126 "multi-axis Euler order"
127 );
128 }
129}
130
131#[test]
132fn incompatible_rig_and_missing_joint_require_explicit_remapping() {
133 let mut p = preset();
134 let mut changed = fixture_body(false);
135 changed.nodes[0].translation[0] += 0.01;
136 assert!(
137 equipment::layers(&p, 0, &changed, &PreviewControls::default())
138 .unwrap_err()
139 .contains("rig")
140 );
141 p.attachments[0].bones[0] = Some("missing".into());
142 assert!(equipment::layers(&p, 0, &fixture_body(false), &PreviewControls::default()).is_err());
143 p.applies[0] = false;
144 assert!(
145 equipment::layers(&p, 0, &changed, &PreviewControls::default())
146 .unwrap()
147 .is_empty()
148 );
149 assert_ne!(
150 RigSignature::of(&fixture_body(false)),
151 RigSignature::of(&fixture_body(true))
152 );
153}
154
155#[test]
156fn corrupt_truncated_unsupported_and_excessive_containers_are_rejected() {
157 let p = preset();
158 let valid = p.encode().unwrap();
159 for n in [0, 7, 19, valid.len() - 1] {
160 assert!(Preset::decode(&valid[..n]).is_err());
161 }
162 let mut damaged = valid.clone();
163 *damaged.last_mut().unwrap() ^= 1;
164 assert!(Preset::decode(&damaged).is_err());
165 let mut damaged = valid.clone();
166 damaged[8..12].copy_from_slice(&2u32.to_le_bytes());
167 assert!(Preset::decode(&damaged).is_err());
168 let mut damaged = valid.clone();
169 damaged[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
170 assert!(Preset::decode(&damaged).is_err());
171 let mut damaged = valid.clone();
172 damaged.push(0);
173 let crc = equipment::checksum(&damaged[20..]);
174 damaged[16..20].copy_from_slice(&crc.to_le_bytes());
175 assert!(Preset::decode(&damaged).is_err());
176 assert_eq!(equipment::checksum(b"123456789"), 0xcbf43926);
177}
178
179// Build a malicious but correctly checksummed container without encoder validation.
180fn untrusted(p: &Preset) -> Vec<u8> {
181 let metadata = serde_json::to_vec(p).unwrap();
182 let n = metadata.len();
183 let mut payload = metadata;
184 for a in &p.assets {
185 payload.extend_from_slice(&(a.bytes.len() as u32).to_le_bytes());
186 payload.extend(&a.bytes)
187 }
188 let mut out = MAGIC.to_vec();
189 out.extend(1u32.to_le_bytes());
190 out.extend((n as u32).to_le_bytes());
191 out.extend(equipment::checksum(&payload).to_le_bytes());
192 out.extend(payload);
193 out
194}
195
196#[test]
197fn traversal_invalid_names_nonfinite_and_invalid_geometry_are_rejected() {
198 for name in ["body", "Hello", "with space", "../escape", ""] {
199 let mut p = preset();
200 p.name = name.into();
201 assert!(p.encode().is_err());
202 assert!(Preset::decode(&untrusted(&p)).is_err());
203 }
204 let mut p = preset();
205 p.assets[0].path = "../escape".into();
206 assert!(Preset::decode(&untrusted(&p)).is_err());
207 let mut p = preset();
208 p.assets[0].path = "/absolute".into();
209 assert!(p.encode().is_err());
210 let mut p = preset();
211 p.attachments[0].translation[0] = f32::NAN;
212 assert!(p.encode().is_err());
213 let mut p = preset();
214 p.attachments[0].meshes[0].indices[0] = u32::MAX;
215 assert!(Preset::decode(&untrusted(&p)).is_err());
216}
217
218#[test]
219fn dependency_escape_and_failed_import_leave_existing_preset_unchanged() {
220 let root = scratch();
221 fs::create_dir_all(&root).unwrap();
222 let path = root.join("bad.obj");
223 fs::write(&path, "mtllib ../outside.mtl\nv 0 0 0\n").unwrap();
224 let mut p = preset();
225 let saved = p.clone();
226 assert!(equipment::import_obj(&mut p, &path).is_err());
227 assert_eq!(saved, p);
228 #[cfg(unix)]
229 {
230 std::os::unix::fs::symlink(fixture("sword.mtl"), root.join("outside.mtl")).unwrap();
231 fs::write(&path, "mtllib outside.mtl\nv 0 0 0\n").unwrap();
232 assert!(equipment::import_obj(&mut p, &path).is_err());
233 }
234 fs::remove_dir_all(root).unwrap();
235}
236
237#[test]
238fn atomic_snapshot_never_overwrites_previous_valid_data() {
239 let root = scratch();
240 fs::create_dir_all(&root).unwrap();
241 let path = root.join("sword.ppe");
242 let bytes = preset().encode().unwrap();
243 equipment::write_snapshot(&path, &bytes).unwrap();
244 equipment::write_snapshot(&path, &bytes).unwrap();
245 assert!(equipment::write_snapshot(&path, b"different").is_err());
246 assert_eq!(fs::read(&path).unwrap(), bytes);
247 assert!(fs::read_dir(&root).unwrap().all(|entry| !entry
248 .unwrap()
249 .file_name()
250 .to_string_lossy()
251 .contains("part-")));
252 fs::remove_dir_all(root).unwrap();
253}
254
255#[test]
256fn binary_project_reload_keeps_payload_after_source_disappears() {
257 let root = scratch();
258 let mut session = paperdoll_renderer::session::Session::new();
259 session.install_new_project(&root).unwrap();
260 let index = session.add_equipment();
261 session.equipment[index].slug = "sword".into();
262 session.equipment[index].preset = Some(preset());
263 session.save().unwrap();
264 let manifest = root.join("character.ppd");
265 let reference = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();
266 assert!(reference.ends_with(".ppe"));
267 let mut again = paperdoll_renderer::session::Session::new();
268 again.install_loaded(paperdoll_renderer::project::open_project(&manifest).unwrap());
269 let loaded = again.equipment[0].preset.as_ref().unwrap();
270 assert_eq!(loaded.name, "sword");
271 assert!(
272 !equipment::layers(loaded, 0, &fixture_body(false), &PreviewControls::default())
273 .unwrap()
274 .is_empty()
275 );
276 let previous = fs::read(&manifest).unwrap();
277 again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0] = 0.2;
278 again.set_render(0, 128, 12);
279 assert!(again.save().is_err());
280 assert_eq!(fs::read(&manifest).unwrap(), previous);
281 fs::remove_dir_all(root).unwrap();
282}
283
284#[test]
285fn translated_rotated_parent_uses_the_complete_hierarchy_once() {
286 let mut body = fixture_body(false);
287 body.nodes = vec![
288 preview::SceneNode {
289 name: "root".into(),
290 parent: None,
291 translation: [3., 0., 0.],
292 rotation: [
293 0.,
294 0.,
295 std::f32::consts::FRAC_1_SQRT_2,
296 std::f32::consts::FRAC_1_SQRT_2,
297 ],
298 scale: [1.; 3],
299 },
300 preview::SceneNode {
301 name: "shoulder".into(),
302 parent: Some(0),
303 translation: [0., 2., 0.],
304 rotation: [0., 0., 0., 1.],
305 scale: [1.; 3],
306 },
307 preview::SceneNode {
308 name: "hand".into(),
309 parent: Some(1),
310 translation: [0., 0., 1.],
311 rotation: [0., 0., 0., 1.],
312 scale: [1.; 3],
313 },
314 ];
315 body.joint_nodes = vec![2];
316 body.joint_names = vec!["hand".into()];
317 body.clips.clear();
318 let mut preset = preset();
319 preset.rigs = [None, None];
320 preset.applies = [true, false];
321 preset.attachments[0].bones = [None, None];
322 preset.attachments[0].translation = [0.2, 0.3, 0.4];
323 preset.attachments[0].rotation_deg = [0.; 3];
324 preset.bind(0, &body, 0, 0).unwrap();
325 let layer = equipment::layers(&preset, 0, &body, &PreviewControls::default()).unwrap();
326 for (value, expected) in [layer[0].model[12], layer[0].model[13], layer[0].model[14]]
327 .into_iter()
328 .zip([0.7, 0.2, 1.4])
329 {
330 assert!((value - expected).abs() < 1e-5, "{value} != {expected}");
331 }
332}
333
334#[test]
335fn unsupported_texture_dimensions_and_asset_counts_are_bounded() {
336 let mut p = preset();
337 let mut data = Vec::new();
338 {
339 let mut encoder = png::Encoder::new(&mut data, 2049, 1);
340 encoder.set_color(png::ColorType::Grayscale);
341 encoder.set_depth(png::BitDepth::Eight);
342 let mut writer = encoder.write_header().unwrap();
343 writer.write_image_data(&vec![0; 2049]).unwrap();
344 }
345 let i = p.attachments[0].shiny.unwrap();
346 p.assets[i].bytes = data;
347 assert!(p.encode().is_err());
348 assert!(Preset::decode(&untrusted(&p)).is_err());
349 let mut p = preset();
350 while p.assets.len() < 33 {
351 let n = p.assets.len();
352 p.assets.push(equipment::EmbeddedAsset {
353 path: format!("asset-{n}"),
354 bytes: Vec::new(),
355 });
356 }
357 assert!(Preset::decode(&untrusted(&p)).is_err());
358}
359
360#[test]
361fn multipart_remove_readd_and_shiny_replacement_remap_embedded_assets() {
362 let mut p = preset();
363 equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();
364 equipment::import_shiny(&mut p, 1, &fixture("shiny.png")).unwrap();
365 assert_eq!(p.assets.len(), 8);
366 equipment::remove_attachment(&mut p, 0).unwrap();
367 assert_eq!(p.assets.len(), 4);
368 equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();
369 for _ in 0..40 {
370 equipment::import_shiny(&mut p, 1, &fixture("shiny.png")).unwrap();
371 }
372 assert_eq!(p.assets.len(), 8);
373 let bytes = p.encode().unwrap();
374 assert_eq!(Preset::decode(&bytes).unwrap(), p);
375 assert!(p.attachments.iter().all(|a| a.source_assets.len() == 3));
376 equipment::clear_shiny(&mut p, 1).unwrap();
377 assert_eq!(p.assets.len(), 7);
378 assert!(p.attachments[1].shiny.is_none());
379 p.validate().unwrap();
380 let before = p.clone();
381 assert!(equipment::remove_attachment(&mut p, 999).is_err());
382 assert_eq!(p, before);
383 p.attachments[0].source_assets[0] = usize::MAX;
384 assert!(Preset::decode(&untrusted(&p)).is_err());
385}
386#[test]
387fn non_regular_preset_and_snapshot_targets_fail_without_reading() {
388 let root = scratch();
389 fs::create_dir_all(&root).unwrap();
390 assert!(Preset::read(&root).is_err());
391 assert!(equipment::write_snapshot(&root, b"bytes").is_err());
392 #[cfg(unix)]
393 {
394 let socket = root.join("socket.ppe");
395 let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
396 assert!(Preset::read(&socket).is_err());
397 drop(listener);
398 }
399 fs::remove_dir_all(root).unwrap();
400}