BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / tests / equipment_presets.rs

equipment_presets.rs

equipment_presets.rs 11.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 let restored = Preset::decode(&bytes).unwrap();
53 assert_eq!(restored, p);
54 assert_eq!(restored.assets.len(), 4);
55 assert_eq!(
56 restored
57 .texture(restored.attachments[0].meshes[0].albedo.unwrap())
58 .unwrap()
59 .width,
60 2
61 );
62 assert_eq!(restored.attachments[0].meshes[0].indices.len(), 6);
63 assert!(restored
64 .assets
65 .iter()
66 .any(|a| a.bytes == fs::read(fixture("sword.obj")).unwrap()));
67 assert!(restored.rigs.iter().all(Option::is_some));
68 let snapshot = fixture("sword.ppe");
69 if std::env::var_os("PAPERDOLL_WRITE_PRESET").is_some() {
70 fs::write(&snapshot, &bytes).unwrap();
71 }
72 assert_eq!(
73 fs::read(snapshot).unwrap(),
74 bytes,
75 "binary preset fixture changed"
76 );
77}
78
79#[test]
80fn attachment_uses_animated_joint_world_not_inverse_bind_twice() {
81 let p = preset();
82 let body = fixture_body(false);
83 let joint = body
84 .joint_names
85 .iter()
86 .position(|n| n == "RightHand")
87 .unwrap();
88 let mut controls = PreviewControls {
89 time: 0.0,
90 clip_name: "walk".into(),
91 ..Default::default()
92 };
93 let first = equipment::layers(&p, 0, &body, &controls).unwrap();
94 let world = preview::animated_joint_world(&body, "walk", 0., joint).unwrap();
95 let expected = preview::mul(
96 world,
97 equipment::local_matrix(p.attachments[0].translation, p.attachments[0].rotation_deg),
98 );
99 assert_eq!(first[0].model, expected);
100 assert_ne!(
101 world,
102 preview::sample_skin_matrices(&body, "walk", 0.)[joint]
103 );
104 controls.time = 0.25;
105 let second = equipment::layers(&p, 0, &body, &controls).unwrap();
106 assert_ne!(first[0].model, second[0].model);
107 let female = equipment::layers(&p, 1, &fixture_body(true), &controls).unwrap();
108 assert!(!female.is_empty());
109}
110
111#[test]
112fn local_euler_order_and_translation_are_not_accumulated() {
113 let m = equipment::local_matrix([1., 2., 3.], [0., 0., 90.]);
114 let point = [m[0] + m[12], m[1] + m[13], m[2] + m[14]];
115 for (a, b) in point.into_iter().zip([1., 3., 3.]) {
116 assert!((a - b).abs() < 1e-5);
117 }
118 assert_eq!(m, equipment::local_matrix([1., 2., 3.], [0., 0., 90.]));
119}
120
121#[test]
122fn incompatible_rig_and_missing_joint_require_explicit_remapping() {
123 let mut p = preset();
124 let mut changed = fixture_body(false);
125 changed.nodes[0].translation[0] += 0.01;
126 assert!(
127 equipment::layers(&p, 0, &changed, &PreviewControls::default())
128 .unwrap_err()
129 .contains("rig")
130 );
131 p.attachments[0].bones[0] = Some("missing".into());
132 assert!(equipment::layers(&p, 0, &fixture_body(false), &PreviewControls::default()).is_err());
133 p.applies[0] = false;
134 assert!(
135 equipment::layers(&p, 0, &changed, &PreviewControls::default())
136 .unwrap()
137 .is_empty()
138 );
139 assert_ne!(
140 RigSignature::of(&fixture_body(false)),
141 RigSignature::of(&fixture_body(true))
142 );
143}
144
145#[test]
146fn corrupt_truncated_unsupported_and_excessive_containers_are_rejected() {
147 let p = preset();
148 let valid = p.encode().unwrap();
149 for n in [0, 7, 19, valid.len() - 1] {
150 assert!(Preset::decode(&valid[..n]).is_err());
151 }
152 let mut damaged = valid.clone();
153 *damaged.last_mut().unwrap() ^= 1;
154 assert!(Preset::decode(&damaged).is_err());
155 let mut damaged = valid.clone();
156 damaged[8..12].copy_from_slice(&2u32.to_le_bytes());
157 assert!(Preset::decode(&damaged).is_err());
158 let mut damaged = valid.clone();
159 damaged[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
160 assert!(Preset::decode(&damaged).is_err());
161 let mut damaged = valid.clone();
162 damaged.push(0);
163 let crc = equipment::checksum(&damaged[20..]);
164 damaged[16..20].copy_from_slice(&crc.to_le_bytes());
165 assert!(Preset::decode(&damaged).is_err());
166 assert_eq!(equipment::checksum(b"123456789"), 0xcbf43926);
167}
168
169// Build a malicious but correctly checksummed container without encoder validation.
170fn untrusted(p: &Preset) -> Vec<u8> {
171 let metadata = serde_json::to_vec(p).unwrap();
172 let n = metadata.len();
173 let mut payload = metadata;
174 for a in &p.assets {
175 payload.extend_from_slice(&(a.bytes.len() as u32).to_le_bytes());
176 payload.extend(&a.bytes)
177 }
178 let mut out = MAGIC.to_vec();
179 out.extend(1u32.to_le_bytes());
180 out.extend((n as u32).to_le_bytes());
181 out.extend(equipment::checksum(&payload).to_le_bytes());
182 out.extend(payload);
183 out
184}
185
186#[test]
187fn traversal_invalid_names_nonfinite_and_invalid_geometry_are_rejected() {
188 for name in ["body", "Hello", "with space", "../escape", ""] {
189 let mut p = preset();
190 p.name = name.into();
191 assert!(p.encode().is_err());
192 assert!(Preset::decode(&untrusted(&p)).is_err());
193 }
194 let mut p = preset();
195 p.assets[0].path = "../escape".into();
196 assert!(Preset::decode(&untrusted(&p)).is_err());
197 let mut p = preset();
198 p.assets[0].path = "/absolute".into();
199 assert!(p.encode().is_err());
200 let mut p = preset();
201 p.attachments[0].translation[0] = f32::NAN;
202 assert!(p.encode().is_err());
203 let mut p = preset();
204 p.attachments[0].meshes[0].indices[0] = u32::MAX;
205 assert!(Preset::decode(&untrusted(&p)).is_err());
206}
207
208#[test]
209fn dependency_escape_and_failed_import_leave_existing_preset_unchanged() {
210 let root = scratch();
211 fs::create_dir_all(&root).unwrap();
212 let path = root.join("bad.obj");
213 fs::write(&path, "mtllib ../outside.mtl\nv 0 0 0\n").unwrap();
214 let mut p = preset();
215 let saved = p.clone();
216 assert!(equipment::import_obj(&mut p, &path).is_err());
217 assert_eq!(saved, p);
218 #[cfg(unix)]
219 {
220 std::os::unix::fs::symlink(fixture("sword.mtl"), root.join("outside.mtl")).unwrap();
221 fs::write(&path, "mtllib outside.mtl\nv 0 0 0\n").unwrap();
222 assert!(equipment::import_obj(&mut p, &path).is_err());
223 }
224 fs::remove_dir_all(root).unwrap();
225}
226
227#[test]
228fn atomic_snapshot_never_overwrites_previous_valid_data() {
229 let root = scratch();
230 fs::create_dir_all(&root).unwrap();
231 let path = root.join("sword.ppe");
232 let bytes = preset().encode().unwrap();
233 equipment::write_snapshot(&path, &bytes).unwrap();
234 equipment::write_snapshot(&path, &bytes).unwrap();
235 assert!(equipment::write_snapshot(&path, b"different").is_err());
236 assert_eq!(fs::read(&path).unwrap(), bytes);
237 assert!(fs::read_dir(&root).unwrap().all(|entry| !entry
238 .unwrap()
239 .file_name()
240 .to_string_lossy()
241 .contains("part-")));
242 fs::remove_dir_all(root).unwrap();
243}
244
245#[test]
246fn binary_project_reload_keeps_payload_after_source_disappears() {
247 let root = scratch();
248 let mut session = paperdoll_renderer::session::Session::new();
249 session.install_new_project(&root).unwrap();
250 let index = session.add_equipment();
251 session.equipment[index].slug = "sword".into();
252 session.equipment[index].preset = Some(preset());
253 session.save().unwrap();
254 let manifest = root.join("character.ppd");
255 let reference = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();
256 assert!(reference.ends_with(".ppe"));
257 let mut again = paperdoll_renderer::session::Session::new();
258 again.install_loaded(paperdoll_renderer::project::open_project(&manifest).unwrap());
259 let loaded = again.equipment[0].preset.as_ref().unwrap();
260 assert_eq!(loaded.name, "sword");
261 assert!(
262 !equipment::layers(loaded, 0, &fixture_body(false), &PreviewControls::default())
263 .unwrap()
264 .is_empty()
265 );
266 let previous = fs::read(&manifest).unwrap();
267 again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0] = 0.2;
268 again.set_render(0, 128, 12);
269 assert!(again.save().is_err());
270 assert_eq!(fs::read(&manifest).unwrap(), previous);
271 fs::remove_dir_all(root).unwrap();
272}
273
274#[test]
275fn translated_rotated_parent_uses_the_complete_hierarchy_once() {
276 let mut body = fixture_body(false);
277 body.nodes = vec![
278 preview::SceneNode {
279 name: "root".into(),
280 parent: None,
281 translation: [3., 0., 0.],
282 rotation: [
283 0.,
284 0.,
285 std::f32::consts::FRAC_1_SQRT_2,
286 std::f32::consts::FRAC_1_SQRT_2,
287 ],
288 scale: [1.; 3],
289 },
290 preview::SceneNode {
291 name: "shoulder".into(),
292 parent: Some(0),
293 translation: [0., 2., 0.],
294 rotation: [0., 0., 0., 1.],
295 scale: [1.; 3],
296 },
297 preview::SceneNode {
298 name: "hand".into(),
299 parent: Some(1),
300 translation: [0., 0., 1.],
301 rotation: [0., 0., 0., 1.],
302 scale: [1.; 3],
303 },
304 ];
305 body.joint_nodes = vec![2];
306 body.joint_names = vec!["hand".into()];
307 body.clips.clear();
308 let mut preset = preset();
309 preset.rigs = [None, None];
310 preset.applies = [true, false];
311 preset.attachments[0].bones = [None, None];
312 preset.attachments[0].translation = [0.2, 0.3, 0.4];
313 preset.attachments[0].rotation_deg = [0.; 3];
314 preset.bind(0, &body, 0, 0).unwrap();
315 let layer = equipment::layers(&preset, 0, &body, &PreviewControls::default()).unwrap();
316 for (value, expected) in [layer[0].model[12], layer[0].model[13], layer[0].model[14]]
317 .into_iter()
318 .zip([0.7, 0.2, 1.4])
319 {
320 assert!((value - expected).abs() < 1e-5, "{value} != {expected}");
321 }
322}
323
324#[test]
325fn unsupported_texture_dimensions_and_asset_counts_are_bounded() {
326 let mut p = preset();
327 let mut data = Vec::new();
328 {
329 let mut encoder = png::Encoder::new(&mut data, 2049, 1);
330 encoder.set_color(png::ColorType::Grayscale);
331 encoder.set_depth(png::BitDepth::Eight);
332 let mut writer = encoder.write_header().unwrap();
333 writer.write_image_data(&vec![0; 2049]).unwrap();
334 }
335 let i = p.attachments[0].shiny.unwrap();
336 p.assets[i].bytes = data;
337 assert!(p.encode().is_err());
338 assert!(Preset::decode(&untrusted(&p)).is_err());
339 let mut p = preset();
340 while p.assets.len() < 33 {
341 let n = p.assets.len();
342 p.assets.push(equipment::EmbeddedAsset {
343 path: format!("asset-{n}"),
344 bytes: Vec::new(),
345 });
346 }
347 assert!(Preset::decode(&untrusted(&p)).is_err());
348}