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
50pub const URP_COMPATIBILITY_LOG: &str = "RenderGraph is now ";
54
55#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
59pub enum RenderPipeline {
60 BuiltIn,
62 Universal,
64 Other(Vec<String>),
66}
67
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum SkinWeights {
72 Bones(u32),
74 Unlimited,
76}
77
78impl SkinWeights {
79 pub fn from_setting(value: i64) -> Result<SkinWeights> {
81 match value {
82 1 => Ok(SkinWeights::Bones(1)),
83 2 => Ok(SkinWeights::Bones(2)),
84 4 => Ok(SkinWeights::Bones(4)),
85 255 => Ok(SkinWeights::Unlimited),
86 v => Err(Error::Unsupported(format!("skinWeights {v}"))),
87 }
88 }
89}
90
91impl std::fmt::Display for SkinWeights {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 match self {
94 SkinWeights::Bones(1) => write!(f, "1 bone"),
95 SkinWeights::Bones(n) => write!(f, "{n} bones"),
96 SkinWeights::Unlimited => write!(f, "unlimited"),
97 }
98 }
99}
100
101#[derive(Clone, Debug, PartialEq)]
105pub struct QualityLevel {
106 pub name: String,
108 pub skin_weights: SkinWeights,
110 pub shadows: i64,
112 pub shadow_distance: f32,
114 pub shadow_cascades: i64,
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
122pub enum ColourSpace {
123 Gamma,
125 Linear,
127}
128
129#[derive(Clone, Debug, PartialEq, Eq)]
131pub struct BuildScene {
132 pub index: usize,
134 pub file: String,
136 pub path: String,
138 pub name: String,
140}
141
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum NodeSelection<'a> {
145 All,
147 Closure(&'a [&'a str]),
150}
151
152#[derive(Clone, Copy, Debug)]
154pub struct BundleSpec<'a> {
155 pub path: &'a Path,
157 pub nodes: NodeSelection<'a>,
159}
160
161#[derive(Debug)]
165pub struct Player {
166 pub dir: PathBuf,
168 pub files: BTreeMap<String, SerializedFile>,
171 pub folders: BTreeMap<String, PathBuf>,
173 pub bundles: Vec<Arc<Bundle>>,
175 package: TypeTreePackage,
176 trees: Mutex<BTreeMap<(String, i32), std::sync::Arc<TypeTree>>>,
177}
178
179fn file_key(path: &str) -> String {
180 path.replace('\\', "/")
181 .rsplit('/')
182 .next()
183 .unwrap_or(path)
184 .to_lowercase()
185}
186
187impl Player {
188 pub fn open(dir: &Path, package: TypeTreePackage) -> Result<Self> {
195 Self::open_with_bundles(dir, package, &[])
196 }
197
198 pub fn open_with_bundles(dir: &Path, package: TypeTreePackage, bundles: &[BundleSpec]) -> Result<Self> {
204 let mut files = BTreeMap::new();
205 let mut folders = BTreeMap::new();
206 let mut paths_of: BTreeMap<String, PathBuf> = BTreeMap::new();
207 for d in [dir.to_path_buf(), dir.join("Resources")] {
208 let Ok(rd) = std::fs::read_dir(&d) else { continue };
209 let mut paths: Vec<PathBuf> = rd.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
210 paths.sort();
211 for p in paths {
212 let bytes = std::fs::read(&p).map_err(|source| Error::Io {
213 path: p.clone(),
214 source,
215 })?;
216 match SerializedFile::container_version(&bytes) {
217 None => continue,
218 Some(v) if !crate::serialized::VERSIONS.contains(&v) => {
219 return Err(Error::Unsupported(format!(
220 "{}: SerializedFile version {v} (read: {}..={})",
221 p.display(),
222 crate::serialized::VERSIONS.start(),
223 crate::serialized::VERSIONS.end()
224 )));
225 }
226 Some(_) => {}
227 }
228 let name = p
229 .file_name()
230 .map(|n| n.to_string_lossy().to_lowercase())
231 .unwrap_or_default();
232 if let Some(first) = paths_of.insert(name.clone(), p.clone()) {
233 return Err(invalid(format!(
234 "{} and {} are both the player's file {name:?}",
235 first.display(),
236 p.display()
237 )));
238 }
239 let f = SerializedFile::parse_with(bytes, Some(&package))
240 .map_err(|e| invalid(format!("{}: {e}", p.display())))?;
241 folders.insert(name.clone(), d.clone());
242 files.insert(name, f);
243 }
244 }
245 let opened = bundles
246 .iter()
247 .map(|spec| Bundle::open(spec.path).map(Arc::new))
248 .collect::<Result<Vec<_>>>()?;
249 let mut by_key: BTreeMap<String, Vec<(usize, &str)>> = BTreeMap::new();
251 for (i, b) in opened.iter().enumerate() {
252 for n in b.nodes().iter().filter(|n| n.is_serialized()) {
253 by_key
254 .entry(file_key(&n.path))
255 .or_default()
256 .push((i, n.path.as_str()));
257 }
258 }
259 let mut pending: Vec<String> = Vec::new();
260 for (spec, b) in bundles.iter().zip(&opened) {
261 match spec.nodes {
262 NodeSelection::All => pending.extend(
263 b.nodes()
264 .iter()
265 .filter(|n| n.is_serialized())
266 .map(|n| file_key(&n.path)),
267 ),
268 NodeSelection::Closure(paths) => {
269 for path in paths {
270 let n = b.node(path).filter(|n| n.is_serialized()).ok_or_else(|| {
271 invalid(format!("{}: no serialized node {path:?}", spec.path.display()))
272 })?;
273 pending.push(file_key(&n.path));
274 }
275 }
276 }
277 }
278 let mut seen: BTreeSet<String> = BTreeSet::new();
280 while let Some(key) = pending.pop() {
281 if !seen.insert(key.clone()) {
282 continue;
283 }
284 let Some(candidates) = by_key.get(&key) else {
285 continue;
286 };
287 if let Some(loose) = paths_of.get(&key) {
288 return Err(invalid(format!(
289 "{} and {candidates:?} are both the player's file {key:?}",
290 loose.display()
291 )));
292 }
293 let [(b, path)] = candidates.as_slice() else {
294 return Err(invalid(format!(
295 "{} bundled files are all the player's file {key:?}: {candidates:?}",
296 candidates.len()
297 )));
298 };
299 let (b, path) = (*b, *path);
300 let bundle = opened.get(b).ok_or_else(|| invalid("a bundle went missing"))?;
301 let f = SerializedFile::from_bundle(bundle, path, Some(&package))?;
302 pending.extend(f.externals.iter().map(|e| file_key(&e.path)));
303 files.insert(key, f);
304 }
305 Ok(Player {
306 dir: dir.to_path_buf(),
307 files,
308 folders,
309 bundles: opened,
310 package,
311 trees: Mutex::new(BTreeMap::new()),
312 })
313 }
314
315 pub fn file(&self, name: &str) -> Result<&SerializedFile> {
317 self.files
318 .get(name)
319 .ok_or_else(|| invalid(format!("no serialized file {name:?} in the player")))
320 }
321
322 pub fn class_id(&self, r: &ObjRef) -> Option<i32> {
324 self.files.get(&r.file)?.object(r.path_id).map(|o| o.class_id)
325 }
326
327 pub fn class_name(&self, r: &ObjRef) -> Option<&str> {
330 let f = self.files.get(&r.file)?;
331 let o = f.object(r.path_id)?;
332 match f.tree_of(o).and_then(|t| t.root()) {
333 Some(root) => Some(&*root.ty),
334 None => self.package.class_name(o.class_id, f.unity),
335 }
336 }
337
338 fn tree(&self, file: &str, f: &SerializedFile, o: &ObjectInfo) -> Result<Arc<TypeTree>> {
342 let stored = f.tree_of(o);
343 if o.class_id != MONO_BEHAVIOUR
344 && let Some(t) = stored
345 {
346 return Ok(t.clone());
347 }
348 let key = (f.unity_version.clone(), o.class_id);
349 let mut cache = self
351 .trees
352 .lock()
353 .unwrap_or_else(std::sync::PoisonError::into_inner);
354 if let Some(t) = cache.get(&key) {
355 return Ok(t.clone());
356 }
357 match self.package.type_tree(o.class_id, f.unity) {
358 Ok(t) => {
359 let t = Arc::new(t);
360 cache.insert(key, t.clone());
361 Ok(t)
362 }
363 Err(_) if stored.is_some() => stored.cloned().ok_or_else(|| invalid("a type tree went missing")),
365 Err(e) => Err(invalid(format!("{file}: {e}"))),
366 }
367 }
368
369 pub fn decode(&self, r: &ObjRef) -> Result<Value> {
371 let f = self.file(&r.file)?;
372 let o = f
373 .object(r.path_id)
374 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
375 let d = f.decode_with(o, &*self.tree(&r.file, f, o)?)?;
376 if !d.covered(o.class_id) {
377 return Err(invalid(format!(
378 "{}/{} (class {}): {} bytes after its type tree",
379 r.file, r.path_id, o.class_id, d.rest
380 )));
381 }
382 Ok(d.value)
383 }
384
385 pub fn script_fields(&self, r: &ObjRef) -> Result<Option<Value>> {
389 let f = self.file(&r.file)?;
390 let o = f
391 .object(r.path_id)
392 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
393 let Some(tree) = f.tree_of(o).filter(|_| o.class_id == MONO_BEHAVIOUR) else {
394 return Ok(None);
395 };
396 let d = f.decode_with(o, tree)?;
397 if d.rest != 0 {
398 return Err(invalid(format!(
399 "{}/{} (class {}): {} bytes after its stored type tree",
400 r.file, r.path_id, o.class_id, d.rest
401 )));
402 }
403 Ok(Some(d.value))
404 }
405
406 pub fn field(&self, r: &ObjRef, name: &str) -> Result<Option<Value>> {
409 let f = self.file(&r.file)?;
410 let o = f
411 .object(r.path_id)
412 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
413 self.tree(&r.file, f, o)?
414 .decode_field(&f.bytes(o)?, f.big_endian, name)
415 .map_err(|e| invalid(format!("{}/{} (class {}): {e}", r.file, r.path_id, o.class_id)))
416 }
417
418 pub fn read_stream(&self, file: &str, info: &StreamingInfo) -> Result<Vec<u8>> {
421 if let Some((bundle, _)) = self.file(file)?.bundle() {
422 return info.read_bundled(bundle);
423 }
424 let dir = self
425 .folders
426 .get(file)
427 .ok_or_else(|| invalid(format!("no folder for {file}")))?;
428 info.read(dir)
429 }
430
431 pub fn scenes(&self) -> Result<Vec<BuildScene>> {
434 let Some(r) = self.manager_object("BuildSettings")? else {
435 return Ok(Vec::new());
436 };
437 let list = self
438 .field(&r, "scenes")?
439 .ok_or_else(|| invalid(format!("{MANAGERS}: BuildSettings without a scene list")))?;
440 let Value::Array(Array::Values(paths)) = list else {
441 return Err(invalid(format!(
442 "{MANAGERS}: BuildSettings' scenes is not a list"
443 )));
444 };
445 paths
446 .iter()
447 .enumerate()
448 .map(|(index, p)| {
449 let path = p
450 .as_str()
451 .ok_or_else(|| invalid(format!("{MANAGERS}: scene {index} is not a path")))?
452 .to_owned();
453 let name = path
454 .rsplit('/')
455 .next()
456 .unwrap_or(&path)
457 .trim_end_matches(".unity")
458 .to_owned();
459 Ok(BuildScene {
460 index,
461 file: format!("level{index}"),
462 path,
463 name,
464 })
465 })
466 .collect()
467 }
468
469 pub fn behaviour(&self, r: &ObjRef) -> Result<(Value, Vec<u8>)> {
471 let f = self.file(&r.file)?;
472 let o = f
473 .object(r.path_id)
474 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
475 if o.class_id != crate::serialized::MONO_BEHAVIOUR {
476 return Err(invalid(format!("{}/{}: not a MonoBehaviour", r.file, r.path_id)));
477 }
478 let d = f.decode_with(o, &*self.tree(&r.file, f, o)?)?;
479 let bytes = f.bytes(o)?;
480 let script = bytes
481 .len()
482 .checked_sub(d.rest)
483 .and_then(|from| bytes.get(from..))
484 .ok_or_else(|| {
485 invalid(format!(
486 "{}/{}: {} bytes left of {}",
487 r.file,
488 r.path_id,
489 d.rest,
490 bytes.len()
491 ))
492 })?;
493 Ok((d.value, script.to_vec()))
494 }
495
496 #[allow(clippy::type_complexity)]
500 fn pipeline_assets(&self) -> Result<Option<Vec<(Option<String>, Option<ObjRef>)>>> {
501 let Some(graphics) = self.manager("GraphicsSettings")? else {
502 return Ok(None);
503 };
504 let asset = |pptr: Option<&Value>| -> Result<Option<ObjRef>> {
505 Ok(pptr.map(|p| self.resolve(MANAGERS, p)).transpose()?.flatten())
506 };
507 let default = asset(graphics.get("m_CustomRenderPipeline"))?;
508 let mut levels = Vec::new();
509 if let Some(Value::Array(crate::value::Array::Values(qs))) = self
510 .manager("QualitySettings")?
511 .as_ref()
512 .and_then(|q| q.get("m_QualitySettings"))
513 {
514 for q in qs {
515 let name = q.get("name").and_then(Value::as_str).map(str::to_owned);
516 levels.push((
517 name,
518 asset(q.get("customRenderPipeline"))?.or_else(|| default.clone()),
519 ));
520 }
521 }
522 if levels.is_empty() {
523 levels.push((None, default));
524 }
525 Ok(Some(levels))
526 }
527
528 fn pipeline_class(&self, asset: &ObjRef) -> Result<String> {
530 self.script_class(asset)?.ok_or_else(|| {
531 invalid(format!(
532 "{}/{}: pipeline asset without a script",
533 asset.file, asset.path_id
534 ))
535 })
536 }
537
538 pub fn render_pipeline(&self) -> Result<Option<RenderPipeline>> {
541 let Some(levels) = self.pipeline_assets()? else {
542 return Ok(None);
543 };
544 let levels: Vec<Option<String>> = levels
545 .iter()
546 .map(|(_, asset)| asset.as_ref().map(|a| self.pipeline_class(a)).transpose())
547 .collect::<Result<_>>()?;
548 Ok(Some(if levels.iter().all(Option::is_none) {
549 RenderPipeline::BuiltIn
550 } else if levels.iter().all(|l| l.as_deref() == Some(URP_ASSET)) {
551 RenderPipeline::Universal
552 } else {
553 let mut names: Vec<String> = levels
554 .into_iter()
555 .map(|l| l.unwrap_or_else(|| "built-in".into()))
556 .collect();
557 names.sort();
558 names.dedup();
559 RenderPipeline::Other(names)
560 }))
561 }
562
563 pub fn urp_asset(&self, quality: Option<&str>) -> Result<Option<ObjRef>> {
567 let Some(levels) = self.pipeline_assets()? else {
568 return Ok(None);
569 };
570 let chosen: Vec<&Option<ObjRef>> = match quality {
571 Some(name) => vec![
572 levels
573 .iter()
574 .find(|(n, _)| n.as_deref() == Some(name))
575 .map(|(_, a)| a)
576 .ok_or_else(|| invalid(format!("no quality level named {name}")))?,
577 ],
578 None => levels.iter().map(|(_, a)| a).collect(),
579 };
580 let mut found: Option<&ObjRef> = None;
581 for asset in chosen {
582 let Some(asset) = asset else { return Ok(None) };
583 if self.pipeline_class(asset)? != URP_ASSET {
584 return Ok(None);
585 }
586 match found {
587 Some(f) if f != asset => return Ok(None),
588 _ => found = Some(asset),
589 }
590 }
591 Ok(found.cloned())
592 }
593
594 pub fn urp_settings(&self, asset: &ObjRef) -> Result<crate::pipeline::UnityUrpSettings> {
598 let layout = crate::pipeline::UrpAssetLayout::of(self.file(&asset.file)?.unity)?;
599 crate::pipeline::read_urp_settings(&self.behaviour(asset)?.1, layout)
600 }
601
602 pub fn urp_global_settings(&self) -> Result<Option<crate::pipeline::UnityUrpGlobalSettings>> {
606 let Some(graphics) = self.manager("GraphicsSettings")? else {
607 return Ok(None);
608 };
609 let Some(r) = graphics
610 .get("m_CurrentRenderPipelineGlobalSettings")
611 .map(|p| self.resolve(MANAGERS, p))
612 .transpose()?
613 .flatten()
614 else {
615 return Ok(None);
616 };
617 let class = self.script_class(&r)?.unwrap_or_default();
618 if class != crate::pipeline::URP_GLOBAL_SETTINGS {
619 return Err(invalid(format!(
620 "{}/{}: the render pipeline's global settings are a {class:?}, not Universal RP's",
621 r.file, r.path_id
622 )));
623 }
624 crate::pipeline::read_urp_global_settings(&self.behaviour(&r)?.1).map(Some)
625 }
626
627 pub fn urp_compatibility_code(&self) -> Result<Option<bool>> {
635 let read = |path: PathBuf| -> Result<Option<Vec<u8>>> {
636 match std::fs::read(&path) {
637 Ok(b) => Ok(Some(b)),
638 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
639 Err(source) => Err(Error::Io { path, source }),
640 }
641 };
642 let holds = |bytes: &[u8], needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
643 if let Some(dll) = read(
644 self.dir
645 .join("Managed")
646 .join("Unity.RenderPipelines.Universal.Runtime.dll"),
647 )? {
648 let utf16: Vec<u8> = URP_COMPATIBILITY_LOG
649 .encode_utf16()
650 .flat_map(u16::to_le_bytes)
651 .collect();
652 return Ok(Some(holds(&dll, &utf16)));
653 }
654 if let Some(metadata) = read(
655 self.dir
656 .join("il2cpp_data")
657 .join("Metadata")
658 .join("global-metadata.dat"),
659 )? {
660 return Ok(Some(holds(&metadata, URP_COMPATIBILITY_LOG.as_bytes())));
661 }
662 Ok(None)
663 }
664
665 pub fn quality_levels(&self) -> Result<Vec<QualityLevel>> {
669 match self.quality_settings()? {
670 Some(settings) => levels_of(&settings),
671 None => Ok(Vec::new()),
672 }
673 }
674
675 pub fn build_quality(&self) -> Result<Option<String>> {
681 let Some(settings) = self.quality_settings()? else {
682 return Ok(None);
683 };
684 current_level(&settings, &levels_of(&settings)?)
685 }
686
687 pub fn colour_space(&self) -> Result<Option<ColourSpace>> {
694 let Some(settings) = self.manager_object("PlayerSettings")? else {
695 return Ok(None);
696 };
697 let value = self.field(&settings, "m_ActiveColorSpace")?;
698 colour_space_of(value.as_ref()).map(Some)
699 }
700
701 fn quality_settings(&self) -> Result<Option<Value>> {
703 self.manager("QualitySettings")
704 }
705
706 fn manager(&self, class: &str) -> Result<Option<Value>> {
709 self.manager_object(class)?.map(|r| self.decode(&r)).transpose()
710 }
711
712 fn manager_object(&self, class: &str) -> Result<Option<ObjRef>> {
714 let Some(f) = self.files.get(MANAGERS) else {
715 return Ok(None);
716 };
717 let mut found = None;
718 for o in &f.objects {
719 let r = ObjRef {
720 file: MANAGERS.into(),
721 path_id: o.path_id,
722 };
723 if self.class_name(&r) == Some(class) {
724 if found.is_some() {
725 return Err(invalid(format!("{MANAGERS}: more than one {class}")));
726 }
727 found = Some(r);
728 }
729 }
730 Ok(found)
731 }
732
733 pub fn resolve(&self, file: &str, pptr: &Value) -> Result<Option<ObjRef>> {
735 let fid = pptr
736 .get("m_FileID")
737 .and_then(Value::as_i64)
738 .ok_or_else(|| invalid("PPtr without m_FileID"))?;
739 let pid = pptr
740 .get("m_PathID")
741 .and_then(Value::as_i64)
742 .ok_or_else(|| invalid("PPtr without m_PathID"))?;
743 let file_id = i32::try_from(fid).map_err(|_| invalid(format!("{file}: PPtr file id {fid}")))?;
744 self.resolve_pptr(
745 file,
746 PPtr {
747 file_id,
748 path_id: pid,
749 },
750 )
751 }
752
753 pub fn resolve_pptr(&self, file: &str, pptr: PPtr) -> Result<Option<ObjRef>> {
756 let PPtr { file_id, path_id } = pptr;
757 if path_id == 0 {
758 return Ok(None);
759 }
760 let target = if file_id == 0 {
761 file.to_owned()
762 } else {
763 let f = self.file(file)?;
764 let ext = file_id
765 .checked_sub(1)
766 .and_then(|i| usize::try_from(i).ok())
767 .and_then(|i| f.externals.get(i))
768 .ok_or_else(|| {
769 invalid(format!(
770 "{file}: PPtr file id {file_id} of {} externals",
771 f.externals.len()
772 ))
773 })?;
774 file_key(&ext.path)
775 };
776 Ok(Some(ObjRef {
777 file: target,
778 path_id,
779 }))
780 }
781
782 pub fn script_class(&self, behaviour: &ObjRef) -> Result<Option<String>> {
785 let (base, _) = self.behaviour(behaviour)?;
786 self.script_class_of(&behaviour.file, &base)
787 }
788
789 pub(crate) fn script_class_of(&self, file: &str, base: &Value) -> Result<Option<String>> {
791 let Some(script) = base
792 .get("m_Script")
793 .map(|p| self.resolve(file, p))
794 .transpose()?
795 .flatten()
796 else {
797 return Ok(None);
798 };
799 let s = self.decode(&script)?;
800 let field = |k: &str| s.get(k).and_then(Value::as_str).unwrap_or("");
801 Ok(Some(match field("m_Namespace") {
802 "" => field("m_ClassName").to_owned(),
803 ns => format!("{ns}.{}", field("m_ClassName")),
804 }))
805 }
806}
807
808fn levels_of(settings: &Value) -> Result<Vec<QualityLevel>> {
810 let levels = match settings.get("m_QualitySettings") {
811 Some(Value::Array(Array::Values(levels))) => levels.as_slice(),
812 Some(Value::Array(a)) if a.is_empty() => &[],
813 _ => {
814 return Err(invalid(format!(
815 "{MANAGERS}: QualitySettings without m_QualitySettings"
816 )));
817 }
818 };
819 levels
820 .iter()
821 .map(|q| {
822 let name = q
823 .get("name")
824 .and_then(Value::as_str)
825 .ok_or_else(|| invalid("a quality level without a name"))?
826 .to_owned();
827 let weights = q
828 .get("skinWeights")
829 .and_then(Value::as_i64)
830 .ok_or_else(|| invalid(format!("quality level {name}: no skinWeights")))?;
831 let field = |k: &str| -> Result<&Value> {
832 q.get(k)
833 .ok_or_else(|| invalid(format!("quality level {name}: no {k}")))
834 };
835 Ok(QualityLevel {
836 skin_weights: SkinWeights::from_setting(weights)?,
837 shadows: field("shadows")?
838 .as_i64()
839 .ok_or_else(|| invalid(format!("quality level {name}: shadows is not a number")))?,
840 shadow_distance: field("shadowDistance")?.as_f32().ok_or_else(|| {
841 invalid(format!("quality level {name}: shadowDistance is not a number"))
842 })?,
843 shadow_cascades: field("shadowCascades")?.as_i64().ok_or_else(|| {
844 invalid(format!("quality level {name}: shadowCascades is not a number"))
845 })?,
846 name,
847 })
848 })
849 .collect()
850}
851
852fn colour_space_of(value: Option<&Value>) -> Result<ColourSpace> {
854 let value = value.ok_or_else(|| invalid("PlayerSettings without m_ActiveColorSpace"))?;
855 match value.as_i64() {
856 Some(0) => Ok(ColourSpace::Gamma),
857 Some(1) => Ok(ColourSpace::Linear),
858 _ => Err(invalid(format!(
859 "PlayerSettings: m_ActiveColorSpace {value:?} is neither 0 (gamma) nor 1 (linear)"
860 ))),
861 }
862}
863
864fn current_level(settings: &Value, levels: &[QualityLevel]) -> Result<Option<String>> {
866 let Some(current) = settings.get("m_CurrentQuality") else {
867 return Ok(None);
868 };
869 let index = current
870 .as_i64()
871 .ok_or_else(|| invalid("QualitySettings: m_CurrentQuality is not a number"))?;
872 usize::try_from(index)
873 .ok()
874 .and_then(|i| levels.get(i))
875 .map(|level| Some(level.name.clone()))
876 .ok_or_else(|| {
877 invalid(format!(
878 "QualitySettings: m_CurrentQuality {index} is not one of its {} levels",
879 levels.len()
880 ))
881 })
882}
883
884#[cfg(test)]
885mod tests {
886 use std::sync::Arc;
887
888 use super::*;
889
890 fn level(name: &str) -> QualityLevel {
891 QualityLevel {
892 name: name.to_owned(),
893 skin_weights: SkinWeights::from_setting(4).expect("a skin weights setting"),
894 shadows: 2,
895 shadow_distance: 50.0,
896 shadow_cascades: 4,
897 }
898 }
899
900 fn settings(current: Option<Value>) -> Value {
901 Value::Struct(
902 current
903 .map(|v| (Arc::<str>::from("m_CurrentQuality"), v))
904 .into_iter()
905 .collect(),
906 )
907 }
908
909 #[test]
912 fn the_build_quality_is_the_level_m_current_quality_indexes() {
913 let levels = [level("Low"), level("Mid"), level("High")];
914 let name = |v| current_level(&settings(Some(v)), &levels);
915 assert_eq!(name(Value::Int(2)).unwrap().as_deref(), Some("High"));
916 assert_eq!(name(Value::UInt(0)).unwrap().as_deref(), Some("Low"));
917 assert_eq!(current_level(&settings(None), &levels).unwrap(), None);
918 for out in [Value::Int(3), Value::Int(-1), Value::UInt(99)] {
919 let error = name(out).unwrap_err().to_string();
920 assert!(error.contains("m_CurrentQuality"), "{error}");
921 }
922 assert!(name(Value::String("2".into())).is_err());
923 assert!(current_level(&settings(Some(Value::Int(0))), &[]).is_err());
924 }
925
926 #[test]
929 fn the_colour_space_is_player_settings_active_color_space() {
930 let space = |v| colour_space_of(Some(&v));
931 assert_eq!(space(Value::Int(0)).unwrap(), ColourSpace::Gamma);
932 assert_eq!(space(Value::Int(1)).unwrap(), ColourSpace::Linear);
933 assert_eq!(space(Value::UInt(1)).unwrap(), ColourSpace::Linear);
934 for refused in [Value::Int(-1), Value::Int(2), Value::String("1".into())] {
935 let error = space(refused).unwrap_err().to_string();
936 assert!(error.contains("m_ActiveColorSpace"), "{error}");
937 }
938 assert!(colour_space_of(None).is_err());
939 }
940
941 fn empty_file(version: u32) -> Vec<u8> {
944 let mut metadata = b"2019.4.20f1\0".to_vec();
945 metadata.extend_from_slice(&19_i32.to_le_bytes());
946 metadata.push(0);
947 metadata.extend_from_slice(&[0; 20]);
949 metadata.push(0);
950 let header = if version < 22 { 20 } else { 48 };
951 let size = header + metadata.len();
952 let mut out = vec![0; header];
953 out[8..12].copy_from_slice(&version.to_be_bytes());
954 if version < 22 {
955 out[0..4].copy_from_slice(&(metadata.len() as u32).to_be_bytes());
956 out[4..8].copy_from_slice(&(size as u32).to_be_bytes());
957 out[12..16].copy_from_slice(&(size as u32).to_be_bytes());
958 } else {
959 out[20..24].copy_from_slice(&(metadata.len() as u32).to_be_bytes());
960 out[24..32].copy_from_slice(&(size as u64).to_be_bytes());
961 out[32..40].copy_from_slice(&(size as u64).to_be_bytes());
962 }
963 out.extend(metadata);
964 out
965 }
966
967 fn scratch(name: &str) -> PathBuf {
968 let dir = std::env::temp_dir().join(format!("unity-formats-{name}-{}", std::process::id()));
969 let _ = std::fs::remove_dir_all(&dir);
970 std::fs::create_dir_all(dir.join("Resources")).unwrap();
971 dir
972 }
973
974 #[test]
977 fn a_file_of_an_unread_version_is_refused_not_absent() {
978 for version in [20, 23] {
979 let dir = scratch(&format!("version{version}"));
980 std::fs::write(dir.join("level0"), empty_file(21)).unwrap();
981 std::fs::write(dir.join("sharedassets0.assets.resS"), [7u8; 100]).unwrap();
982 assert_eq!(
983 Player::open(&dir, TypeTreePackage::empty()).unwrap().files.len(),
984 1
985 );
986 std::fs::write(dir.join("level1"), empty_file(version)).unwrap();
987 let e = Player::open(&dir, TypeTreePackage::empty()).unwrap_err();
988 assert!(matches!(e, Error::Unsupported(_)), "{e}");
989 let e = e.to_string();
990 assert!(
991 e.contains("level1") && e.contains(&format!("version {version}")),
992 "{e}"
993 );
994 std::fs::remove_dir_all(&dir).unwrap();
995 }
996 }
997
998 #[test]
1000 fn two_files_with_one_name_are_refused() {
1001 let dir = scratch("duplicate");
1002 std::fs::write(dir.join("resources.assets"), empty_file(21)).unwrap();
1003 std::fs::write(dir.join("Resources").join("Resources.assets"), empty_file(22)).unwrap();
1004 let e = Player::open(&dir, TypeTreePackage::empty())
1005 .unwrap_err()
1006 .to_string();
1007 assert!(e.contains("both the player's file \"resources.assets\""), "{e}");
1008 std::fs::remove_dir_all(&dir).unwrap();
1009 }
1010}