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