1#![allow(
4 clippy::disallowed_methods,
5 clippy::disallowed_types,
6 reason = "reader I/O: reads the player's data folder"
7)]
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex};
12
13use serde::Serialize;
14
15use crate::bundle::Bundle;
16use crate::resource::StreamingInfo;
17use crate::serialized::{MONO_BEHAVIOUR, ObjectInfo, SerializedFile};
18use crate::tpk::TypeTreePackage;
19use crate::typetree::TypeTree;
20use crate::value::{Array, Value};
21use crate::{Error, Result, invalid};
22
23#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
25pub struct ObjRef {
26 pub file: String,
28 pub path_id: i64,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
36pub struct PPtr {
37 pub file_id: i32,
39 pub path_id: i64,
41}
42
43const MANAGERS: &str = "globalgamemanagers";
46
47pub const URP_ASSET: &str = "UnityEngine.Rendering.Universal.UniversalRenderPipelineAsset";
49
50#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
54pub enum RenderPipeline {
55 BuiltIn,
57 Universal,
59 Other(Vec<String>),
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum SkinWeights {
67 Bones(u32),
69 Unlimited,
71}
72
73impl SkinWeights {
74 pub fn from_setting(value: i64) -> Result<SkinWeights> {
76 match value {
77 1 => Ok(SkinWeights::Bones(1)),
78 2 => Ok(SkinWeights::Bones(2)),
79 4 => Ok(SkinWeights::Bones(4)),
80 255 => Ok(SkinWeights::Unlimited),
81 v => Err(Error::Unsupported(format!("skinWeights {v}"))),
82 }
83 }
84}
85
86impl std::fmt::Display for SkinWeights {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 match self {
89 SkinWeights::Bones(1) => write!(f, "1 bone"),
90 SkinWeights::Bones(n) => write!(f, "{n} bones"),
91 SkinWeights::Unlimited => write!(f, "unlimited"),
92 }
93 }
94}
95
96#[derive(Clone, Debug, PartialEq)]
100pub struct QualityLevel {
101 pub name: String,
103 pub skin_weights: SkinWeights,
105 pub shadows: i64,
107 pub shadow_distance: f32,
109 pub shadow_cascades: i64,
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
117pub enum ColourSpace {
118 Gamma,
120 Linear,
122}
123
124#[derive(Clone, Debug, PartialEq, Eq)]
126pub struct BuildScene {
127 pub index: usize,
129 pub file: String,
131 pub path: String,
133 pub name: String,
135}
136
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub enum NodeSelection<'a> {
140 All,
142 Closure(&'a [&'a str]),
145}
146
147#[derive(Clone, Copy, Debug)]
149pub struct BundleSpec<'a> {
150 pub path: &'a Path,
152 pub nodes: NodeSelection<'a>,
154}
155
156#[derive(Debug)]
160pub struct Player {
161 pub dir: PathBuf,
163 pub files: BTreeMap<String, SerializedFile>,
166 pub folders: BTreeMap<String, PathBuf>,
168 pub bundles: Vec<Arc<Bundle>>,
170 package: TypeTreePackage,
171 trees: Mutex<BTreeMap<(String, i32), std::sync::Arc<TypeTree>>>,
172}
173
174fn file_key(path: &str) -> String {
175 path.replace('\\', "/")
176 .rsplit('/')
177 .next()
178 .unwrap_or(path)
179 .to_lowercase()
180}
181
182impl Player {
183 pub fn open(dir: &Path, package: TypeTreePackage) -> Result<Self> {
190 Self::open_with_bundles(dir, package, &[])
191 }
192
193 pub fn open_with_bundles(dir: &Path, package: TypeTreePackage, bundles: &[BundleSpec]) -> Result<Self> {
199 let mut files = BTreeMap::new();
200 let mut folders = BTreeMap::new();
201 let mut paths_of: BTreeMap<String, PathBuf> = BTreeMap::new();
202 for d in [dir.to_path_buf(), dir.join("Resources")] {
203 let Ok(rd) = std::fs::read_dir(&d) else { continue };
204 let mut paths: Vec<PathBuf> = rd.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
205 paths.sort();
206 for p in paths {
207 let bytes = std::fs::read(&p).map_err(|source| Error::Io {
208 path: p.clone(),
209 source,
210 })?;
211 match SerializedFile::container_version(&bytes) {
212 None => continue,
213 Some(v) if !crate::serialized::VERSIONS.contains(&v) => {
214 return Err(Error::Unsupported(format!(
215 "{}: SerializedFile version {v} (read: {}..={})",
216 p.display(),
217 crate::serialized::VERSIONS.start(),
218 crate::serialized::VERSIONS.end()
219 )));
220 }
221 Some(_) => {}
222 }
223 let name = p
224 .file_name()
225 .map(|n| n.to_string_lossy().to_lowercase())
226 .unwrap_or_default();
227 if let Some(first) = paths_of.insert(name.clone(), p.clone()) {
228 return Err(invalid(format!(
229 "{} and {} are both the player's file {name:?}",
230 first.display(),
231 p.display()
232 )));
233 }
234 let f = SerializedFile::parse_with(bytes, Some(&package))
235 .map_err(|e| invalid(format!("{}: {e}", p.display())))?;
236 folders.insert(name.clone(), d.clone());
237 files.insert(name, f);
238 }
239 }
240 let opened = bundles
241 .iter()
242 .map(|spec| Bundle::open(spec.path).map(Arc::new))
243 .collect::<Result<Vec<_>>>()?;
244 let mut by_key: BTreeMap<String, Vec<(usize, &str)>> = BTreeMap::new();
246 for (i, b) in opened.iter().enumerate() {
247 for n in b.nodes().iter().filter(|n| n.is_serialized()) {
248 by_key
249 .entry(file_key(&n.path))
250 .or_default()
251 .push((i, n.path.as_str()));
252 }
253 }
254 let mut pending: Vec<String> = Vec::new();
255 for (spec, b) in bundles.iter().zip(&opened) {
256 match spec.nodes {
257 NodeSelection::All => pending.extend(
258 b.nodes()
259 .iter()
260 .filter(|n| n.is_serialized())
261 .map(|n| file_key(&n.path)),
262 ),
263 NodeSelection::Closure(paths) => {
264 for path in paths {
265 let n = b.node(path).filter(|n| n.is_serialized()).ok_or_else(|| {
266 invalid(format!("{}: no serialized node {path:?}", spec.path.display()))
267 })?;
268 pending.push(file_key(&n.path));
269 }
270 }
271 }
272 }
273 let mut seen: BTreeSet<String> = BTreeSet::new();
275 while let Some(key) = pending.pop() {
276 if !seen.insert(key.clone()) {
277 continue;
278 }
279 let Some(candidates) = by_key.get(&key) else {
280 continue;
281 };
282 if let Some(loose) = paths_of.get(&key) {
283 return Err(invalid(format!(
284 "{} and {candidates:?} are both the player's file {key:?}",
285 loose.display()
286 )));
287 }
288 let [(b, path)] = candidates.as_slice() else {
289 return Err(invalid(format!(
290 "{} bundled files are all the player's file {key:?}: {candidates:?}",
291 candidates.len()
292 )));
293 };
294 let (b, path) = (*b, *path);
295 let bundle = opened.get(b).ok_or_else(|| invalid("a bundle went missing"))?;
296 let f = SerializedFile::from_bundle(bundle, path, Some(&package))?;
297 pending.extend(f.externals.iter().map(|e| file_key(&e.path)));
298 files.insert(key, f);
299 }
300 Ok(Player {
301 dir: dir.to_path_buf(),
302 files,
303 folders,
304 bundles: opened,
305 package,
306 trees: Mutex::new(BTreeMap::new()),
307 })
308 }
309
310 pub fn file(&self, name: &str) -> Result<&SerializedFile> {
312 self.files
313 .get(name)
314 .ok_or_else(|| invalid(format!("no serialized file {name:?} in the player")))
315 }
316
317 pub fn class_id(&self, r: &ObjRef) -> Option<i32> {
319 self.files.get(&r.file)?.object(r.path_id).map(|o| o.class_id)
320 }
321
322 pub fn class_name(&self, r: &ObjRef) -> Option<&str> {
325 let f = self.files.get(&r.file)?;
326 let o = f.object(r.path_id)?;
327 match f.tree_of(o).and_then(|t| t.root()) {
328 Some(root) => Some(&*root.ty),
329 None => self.package.class_name(o.class_id, f.unity),
330 }
331 }
332
333 fn tree(&self, file: &str, f: &SerializedFile, o: &ObjectInfo) -> Result<Arc<TypeTree>> {
337 let stored = f.tree_of(o);
338 if o.class_id != MONO_BEHAVIOUR
339 && let Some(t) = stored
340 {
341 return Ok(t.clone());
342 }
343 let key = (f.unity_version.clone(), o.class_id);
344 let mut cache = self
346 .trees
347 .lock()
348 .unwrap_or_else(std::sync::PoisonError::into_inner);
349 if let Some(t) = cache.get(&key) {
350 return Ok(t.clone());
351 }
352 match self.package.type_tree(o.class_id, f.unity) {
353 Ok(t) => {
354 let t = Arc::new(t);
355 cache.insert(key, t.clone());
356 Ok(t)
357 }
358 Err(_) if stored.is_some() => stored.cloned().ok_or_else(|| invalid("a type tree went missing")),
360 Err(e) => Err(invalid(format!("{file}: {e}"))),
361 }
362 }
363
364 pub fn decode(&self, r: &ObjRef) -> Result<Value> {
366 let f = self.file(&r.file)?;
367 let o = f
368 .object(r.path_id)
369 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
370 let d = f.decode_with(o, &*self.tree(&r.file, f, o)?)?;
371 if !d.covered(o.class_id) {
372 return Err(invalid(format!(
373 "{}/{} (class {}): {} bytes after its type tree",
374 r.file, r.path_id, o.class_id, d.rest
375 )));
376 }
377 Ok(d.value)
378 }
379
380 pub fn script_fields(&self, r: &ObjRef) -> Result<Option<Value>> {
384 let f = self.file(&r.file)?;
385 let o = f
386 .object(r.path_id)
387 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
388 let Some(tree) = f.tree_of(o).filter(|_| o.class_id == MONO_BEHAVIOUR) else {
389 return Ok(None);
390 };
391 let d = f.decode_with(o, tree)?;
392 if d.rest != 0 {
393 return Err(invalid(format!(
394 "{}/{} (class {}): {} bytes after its stored type tree",
395 r.file, r.path_id, o.class_id, d.rest
396 )));
397 }
398 Ok(Some(d.value))
399 }
400
401 pub fn field(&self, r: &ObjRef, name: &str) -> Result<Option<Value>> {
404 let f = self.file(&r.file)?;
405 let o = f
406 .object(r.path_id)
407 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
408 self.tree(&r.file, f, o)?
409 .decode_field(&f.bytes(o)?, f.big_endian, name)
410 .map_err(|e| invalid(format!("{}/{} (class {}): {e}", r.file, r.path_id, o.class_id)))
411 }
412
413 pub fn read_stream(&self, file: &str, info: &StreamingInfo) -> Result<Vec<u8>> {
416 if let Some((bundle, _)) = self.file(file)?.bundle() {
417 return info.read_bundled(bundle);
418 }
419 let dir = self
420 .folders
421 .get(file)
422 .ok_or_else(|| invalid(format!("no folder for {file}")))?;
423 info.read(dir)
424 }
425
426 pub fn scenes(&self) -> Result<Vec<BuildScene>> {
429 let Some(r) = self.manager_object("BuildSettings")? else {
430 return Ok(Vec::new());
431 };
432 let list = self
433 .field(&r, "scenes")?
434 .ok_or_else(|| invalid(format!("{MANAGERS}: BuildSettings without a scene list")))?;
435 let Value::Array(Array::Values(paths)) = list else {
436 return Err(invalid(format!(
437 "{MANAGERS}: BuildSettings' scenes is not a list"
438 )));
439 };
440 paths
441 .iter()
442 .enumerate()
443 .map(|(index, p)| {
444 let path = p
445 .as_str()
446 .ok_or_else(|| invalid(format!("{MANAGERS}: scene {index} is not a path")))?
447 .to_owned();
448 let name = path
449 .rsplit('/')
450 .next()
451 .unwrap_or(&path)
452 .trim_end_matches(".unity")
453 .to_owned();
454 Ok(BuildScene {
455 index,
456 file: format!("level{index}"),
457 path,
458 name,
459 })
460 })
461 .collect()
462 }
463
464 pub fn behaviour(&self, r: &ObjRef) -> Result<(Value, Vec<u8>)> {
466 let f = self.file(&r.file)?;
467 let o = f
468 .object(r.path_id)
469 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
470 if o.class_id != crate::serialized::MONO_BEHAVIOUR {
471 return Err(invalid(format!("{}/{}: not a MonoBehaviour", r.file, r.path_id)));
472 }
473 let d = f.decode_with(o, &*self.tree(&r.file, f, o)?)?;
474 let bytes = f.bytes(o)?;
475 let script = bytes
476 .len()
477 .checked_sub(d.rest)
478 .and_then(|from| bytes.get(from..))
479 .ok_or_else(|| {
480 invalid(format!(
481 "{}/{}: {} bytes left of {}",
482 r.file,
483 r.path_id,
484 d.rest,
485 bytes.len()
486 ))
487 })?;
488 Ok((d.value, script.to_vec()))
489 }
490
491 #[allow(clippy::type_complexity)]
495 fn pipeline_assets(&self) -> Result<Option<Vec<(Option<String>, Option<ObjRef>)>>> {
496 let Some(graphics) = self.manager("GraphicsSettings")? else {
497 return Ok(None);
498 };
499 let asset = |pptr: Option<&Value>| -> Result<Option<ObjRef>> {
500 Ok(pptr.map(|p| self.resolve(MANAGERS, p)).transpose()?.flatten())
501 };
502 let default = asset(graphics.get("m_CustomRenderPipeline"))?;
503 let mut levels = Vec::new();
504 if let Some(Value::Array(crate::value::Array::Values(qs))) = self
505 .manager("QualitySettings")?
506 .as_ref()
507 .and_then(|q| q.get("m_QualitySettings"))
508 {
509 for q in qs {
510 let name = q.get("name").and_then(Value::as_str).map(str::to_owned);
511 levels.push((
512 name,
513 asset(q.get("customRenderPipeline"))?.or_else(|| default.clone()),
514 ));
515 }
516 }
517 if levels.is_empty() {
518 levels.push((None, default));
519 }
520 Ok(Some(levels))
521 }
522
523 fn pipeline_class(&self, asset: &ObjRef) -> Result<String> {
525 self.script_class(asset)?.ok_or_else(|| {
526 invalid(format!(
527 "{}/{}: pipeline asset without a script",
528 asset.file, asset.path_id
529 ))
530 })
531 }
532
533 pub fn render_pipeline(&self) -> Result<Option<RenderPipeline>> {
536 let Some(levels) = self.pipeline_assets()? else {
537 return Ok(None);
538 };
539 let levels: Vec<Option<String>> = levels
540 .iter()
541 .map(|(_, asset)| asset.as_ref().map(|a| self.pipeline_class(a)).transpose())
542 .collect::<Result<_>>()?;
543 Ok(Some(if levels.iter().all(Option::is_none) {
544 RenderPipeline::BuiltIn
545 } else if levels.iter().all(|l| l.as_deref() == Some(URP_ASSET)) {
546 RenderPipeline::Universal
547 } else {
548 let mut names: Vec<String> = levels
549 .into_iter()
550 .map(|l| l.unwrap_or_else(|| "built-in".into()))
551 .collect();
552 names.sort();
553 names.dedup();
554 RenderPipeline::Other(names)
555 }))
556 }
557
558 pub fn urp_asset(&self, quality: Option<&str>) -> Result<Option<ObjRef>> {
562 let Some(levels) = self.pipeline_assets()? else {
563 return Ok(None);
564 };
565 let chosen: Vec<&Option<ObjRef>> = match quality {
566 Some(name) => vec![
567 levels
568 .iter()
569 .find(|(n, _)| n.as_deref() == Some(name))
570 .map(|(_, a)| a)
571 .ok_or_else(|| invalid(format!("no quality level named {name}")))?,
572 ],
573 None => levels.iter().map(|(_, a)| a).collect(),
574 };
575 let mut found: Option<&ObjRef> = None;
576 for asset in chosen {
577 let Some(asset) = asset else { return Ok(None) };
578 if self.pipeline_class(asset)? != URP_ASSET {
579 return Ok(None);
580 }
581 match found {
582 Some(f) if f != asset => return Ok(None),
583 _ => found = Some(asset),
584 }
585 }
586 Ok(found.cloned())
587 }
588
589 pub fn urp_settings(&self, asset: &ObjRef) -> Result<crate::pipeline::UnityUrpSettings> {
593 let layout = crate::pipeline::UrpAssetLayout::of(self.file(&asset.file)?.unity)?;
594 crate::pipeline::read_urp_settings(&self.behaviour(asset)?.1, layout)
595 }
596
597 pub fn quality_levels(&self) -> Result<Vec<QualityLevel>> {
601 match self.quality_settings()? {
602 Some(settings) => levels_of(&settings),
603 None => Ok(Vec::new()),
604 }
605 }
606
607 pub fn build_quality(&self) -> Result<Option<String>> {
613 let Some(settings) = self.quality_settings()? else {
614 return Ok(None);
615 };
616 current_level(&settings, &levels_of(&settings)?)
617 }
618
619 pub fn colour_space(&self) -> Result<Option<ColourSpace>> {
626 let Some(settings) = self.manager_object("PlayerSettings")? else {
627 return Ok(None);
628 };
629 let value = self.field(&settings, "m_ActiveColorSpace")?;
630 colour_space_of(value.as_ref()).map(Some)
631 }
632
633 fn quality_settings(&self) -> Result<Option<Value>> {
635 self.manager("QualitySettings")
636 }
637
638 fn manager(&self, class: &str) -> Result<Option<Value>> {
641 self.manager_object(class)?.map(|r| self.decode(&r)).transpose()
642 }
643
644 fn manager_object(&self, class: &str) -> Result<Option<ObjRef>> {
646 let Some(f) = self.files.get(MANAGERS) else {
647 return Ok(None);
648 };
649 let mut found = None;
650 for o in &f.objects {
651 let r = ObjRef {
652 file: MANAGERS.into(),
653 path_id: o.path_id,
654 };
655 if self.class_name(&r) == Some(class) {
656 if found.is_some() {
657 return Err(invalid(format!("{MANAGERS}: more than one {class}")));
658 }
659 found = Some(r);
660 }
661 }
662 Ok(found)
663 }
664
665 pub fn resolve(&self, file: &str, pptr: &Value) -> Result<Option<ObjRef>> {
667 let fid = pptr
668 .get("m_FileID")
669 .and_then(Value::as_i64)
670 .ok_or_else(|| invalid("PPtr without m_FileID"))?;
671 let pid = pptr
672 .get("m_PathID")
673 .and_then(Value::as_i64)
674 .ok_or_else(|| invalid("PPtr without m_PathID"))?;
675 let file_id = i32::try_from(fid).map_err(|_| invalid(format!("{file}: PPtr file id {fid}")))?;
676 self.resolve_pptr(
677 file,
678 PPtr {
679 file_id,
680 path_id: pid,
681 },
682 )
683 }
684
685 pub fn resolve_pptr(&self, file: &str, pptr: PPtr) -> Result<Option<ObjRef>> {
688 let PPtr { file_id, path_id } = pptr;
689 if path_id == 0 {
690 return Ok(None);
691 }
692 let target = if file_id == 0 {
693 file.to_owned()
694 } else {
695 let f = self.file(file)?;
696 let ext = file_id
697 .checked_sub(1)
698 .and_then(|i| usize::try_from(i).ok())
699 .and_then(|i| f.externals.get(i))
700 .ok_or_else(|| {
701 invalid(format!(
702 "{file}: PPtr file id {file_id} of {} externals",
703 f.externals.len()
704 ))
705 })?;
706 file_key(&ext.path)
707 };
708 Ok(Some(ObjRef {
709 file: target,
710 path_id,
711 }))
712 }
713
714 pub fn script_class(&self, behaviour: &ObjRef) -> Result<Option<String>> {
717 let (base, _) = self.behaviour(behaviour)?;
718 self.script_class_of(&behaviour.file, &base)
719 }
720
721 pub(crate) fn script_class_of(&self, file: &str, base: &Value) -> Result<Option<String>> {
723 let Some(script) = base
724 .get("m_Script")
725 .map(|p| self.resolve(file, p))
726 .transpose()?
727 .flatten()
728 else {
729 return Ok(None);
730 };
731 let s = self.decode(&script)?;
732 let field = |k: &str| s.get(k).and_then(Value::as_str).unwrap_or("");
733 Ok(Some(match field("m_Namespace") {
734 "" => field("m_ClassName").to_owned(),
735 ns => format!("{ns}.{}", field("m_ClassName")),
736 }))
737 }
738}
739
740fn levels_of(settings: &Value) -> Result<Vec<QualityLevel>> {
742 let levels = match settings.get("m_QualitySettings") {
743 Some(Value::Array(Array::Values(levels))) => levels.as_slice(),
744 Some(Value::Array(a)) if a.is_empty() => &[],
745 _ => {
746 return Err(invalid(format!(
747 "{MANAGERS}: QualitySettings without m_QualitySettings"
748 )));
749 }
750 };
751 levels
752 .iter()
753 .map(|q| {
754 let name = q
755 .get("name")
756 .and_then(Value::as_str)
757 .ok_or_else(|| invalid("a quality level without a name"))?
758 .to_owned();
759 let weights = q
760 .get("skinWeights")
761 .and_then(Value::as_i64)
762 .ok_or_else(|| invalid(format!("quality level {name}: no skinWeights")))?;
763 let field = |k: &str| -> Result<&Value> {
764 q.get(k)
765 .ok_or_else(|| invalid(format!("quality level {name}: no {k}")))
766 };
767 Ok(QualityLevel {
768 skin_weights: SkinWeights::from_setting(weights)?,
769 shadows: field("shadows")?
770 .as_i64()
771 .ok_or_else(|| invalid(format!("quality level {name}: shadows is not a number")))?,
772 shadow_distance: field("shadowDistance")?.as_f32().ok_or_else(|| {
773 invalid(format!("quality level {name}: shadowDistance is not a number"))
774 })?,
775 shadow_cascades: field("shadowCascades")?.as_i64().ok_or_else(|| {
776 invalid(format!("quality level {name}: shadowCascades is not a number"))
777 })?,
778 name,
779 })
780 })
781 .collect()
782}
783
784fn colour_space_of(value: Option<&Value>) -> Result<ColourSpace> {
786 let value = value.ok_or_else(|| invalid("PlayerSettings without m_ActiveColorSpace"))?;
787 match value.as_i64() {
788 Some(0) => Ok(ColourSpace::Gamma),
789 Some(1) => Ok(ColourSpace::Linear),
790 _ => Err(invalid(format!(
791 "PlayerSettings: m_ActiveColorSpace {value:?} is neither 0 (gamma) nor 1 (linear)"
792 ))),
793 }
794}
795
796fn current_level(settings: &Value, levels: &[QualityLevel]) -> Result<Option<String>> {
798 let Some(current) = settings.get("m_CurrentQuality") else {
799 return Ok(None);
800 };
801 let index = current
802 .as_i64()
803 .ok_or_else(|| invalid("QualitySettings: m_CurrentQuality is not a number"))?;
804 usize::try_from(index)
805 .ok()
806 .and_then(|i| levels.get(i))
807 .map(|level| Some(level.name.clone()))
808 .ok_or_else(|| {
809 invalid(format!(
810 "QualitySettings: m_CurrentQuality {index} is not one of its {} levels",
811 levels.len()
812 ))
813 })
814}
815
816#[cfg(test)]
817mod tests {
818 use std::sync::Arc;
819
820 use super::*;
821
822 fn level(name: &str) -> QualityLevel {
823 QualityLevel {
824 name: name.to_owned(),
825 skin_weights: SkinWeights::from_setting(4).expect("a skin weights setting"),
826 shadows: 2,
827 shadow_distance: 50.0,
828 shadow_cascades: 4,
829 }
830 }
831
832 fn settings(current: Option<Value>) -> Value {
833 Value::Struct(
834 current
835 .map(|v| (Arc::<str>::from("m_CurrentQuality"), v))
836 .into_iter()
837 .collect(),
838 )
839 }
840
841 #[test]
844 fn the_build_quality_is_the_level_m_current_quality_indexes() {
845 let levels = [level("Low"), level("Mid"), level("High")];
846 let name = |v| current_level(&settings(Some(v)), &levels);
847 assert_eq!(name(Value::Int(2)).unwrap().as_deref(), Some("High"));
848 assert_eq!(name(Value::UInt(0)).unwrap().as_deref(), Some("Low"));
849 assert_eq!(current_level(&settings(None), &levels).unwrap(), None);
850 for out in [Value::Int(3), Value::Int(-1), Value::UInt(99)] {
851 let error = name(out).unwrap_err().to_string();
852 assert!(error.contains("m_CurrentQuality"), "{error}");
853 }
854 assert!(name(Value::String("2".into())).is_err());
855 assert!(current_level(&settings(Some(Value::Int(0))), &[]).is_err());
856 }
857
858 #[test]
861 fn the_colour_space_is_player_settings_active_color_space() {
862 let space = |v| colour_space_of(Some(&v));
863 assert_eq!(space(Value::Int(0)).unwrap(), ColourSpace::Gamma);
864 assert_eq!(space(Value::Int(1)).unwrap(), ColourSpace::Linear);
865 assert_eq!(space(Value::UInt(1)).unwrap(), ColourSpace::Linear);
866 for refused in [Value::Int(-1), Value::Int(2), Value::String("1".into())] {
867 let error = space(refused).unwrap_err().to_string();
868 assert!(error.contains("m_ActiveColorSpace"), "{error}");
869 }
870 assert!(colour_space_of(None).is_err());
871 }
872
873 fn empty_file(version: u32) -> Vec<u8> {
876 let mut metadata = b"2019.4.20f1\0".to_vec();
877 metadata.extend_from_slice(&19_i32.to_le_bytes());
878 metadata.push(0);
879 metadata.extend_from_slice(&[0; 20]);
881 metadata.push(0);
882 let header = if version < 22 { 20 } else { 48 };
883 let size = header + metadata.len();
884 let mut out = vec![0; header];
885 out[8..12].copy_from_slice(&version.to_be_bytes());
886 if version < 22 {
887 out[0..4].copy_from_slice(&(metadata.len() as u32).to_be_bytes());
888 out[4..8].copy_from_slice(&(size as u32).to_be_bytes());
889 out[12..16].copy_from_slice(&(size as u32).to_be_bytes());
890 } else {
891 out[20..24].copy_from_slice(&(metadata.len() as u32).to_be_bytes());
892 out[24..32].copy_from_slice(&(size as u64).to_be_bytes());
893 out[32..40].copy_from_slice(&(size as u64).to_be_bytes());
894 }
895 out.extend(metadata);
896 out
897 }
898
899 fn scratch(name: &str) -> PathBuf {
900 let dir = std::env::temp_dir().join(format!("unity-formats-{name}-{}", std::process::id()));
901 let _ = std::fs::remove_dir_all(&dir);
902 std::fs::create_dir_all(dir.join("Resources")).unwrap();
903 dir
904 }
905
906 #[test]
909 fn a_file_of_an_unread_version_is_refused_not_absent() {
910 for version in [20, 23] {
911 let dir = scratch(&format!("version{version}"));
912 std::fs::write(dir.join("level0"), empty_file(21)).unwrap();
913 std::fs::write(dir.join("sharedassets0.assets.resS"), [7u8; 100]).unwrap();
914 assert_eq!(
915 Player::open(&dir, TypeTreePackage::empty()).unwrap().files.len(),
916 1
917 );
918 std::fs::write(dir.join("level1"), empty_file(version)).unwrap();
919 let e = Player::open(&dir, TypeTreePackage::empty()).unwrap_err();
920 assert!(matches!(e, Error::Unsupported(_)), "{e}");
921 let e = e.to_string();
922 assert!(
923 e.contains("level1") && e.contains(&format!("version {version}")),
924 "{e}"
925 );
926 std::fs::remove_dir_all(&dir).unwrap();
927 }
928 }
929
930 #[test]
932 fn two_files_with_one_name_are_refused() {
933 let dir = scratch("duplicate");
934 std::fs::write(dir.join("resources.assets"), empty_file(21)).unwrap();
935 std::fs::write(dir.join("Resources").join("Resources.assets"), empty_file(22)).unwrap();
936 let e = Player::open(&dir, TypeTreePackage::empty())
937 .unwrap_err()
938 .to_string();
939 assert!(e.contains("both the player's file \"resources.assets\""), "{e}");
940 std::fs::remove_dir_all(&dir).unwrap();
941 }
942}