Skip to main content

unity_formats/
player.rs

1//! A Unity player's data folder: its SerializedFiles by name, pointers (`PPtr`) resolved across them, objects decoded
2//! by their classes' type trees.
3#![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/// An object of the player: its file (lower-case name) and path id.
24#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
25pub struct ObjRef {
26    /// The file's lower-case name.
27    pub file: String,
28    /// The object's path id in that file.
29    pub path_id: i64,
30}
31
32/// The script class of Universal Render Pipeline's asset (`m_Namespace.m_ClassName` of its MonoScript).
33pub const URP_ASSET: &str = "UnityEngine.Rendering.Universal.UniversalRenderPipelineAsset";
34
35/// The render pipeline a player draws with: GraphicsSettings' default pipeline asset (`m_CustomRenderPipeline`), or a
36/// quality level's own (`customRenderPipeline`) where it sets one. Which quality level runs is the user's choice, so
37/// the pipeline is known only when every level gives the same kind.
38#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
39pub enum RenderPipeline {
40    /// No pipeline asset for any quality level: Unity's built-in renderer.
41    BuiltIn,
42    /// A Universal Render Pipeline asset ([`URP_ASSET`]) for every quality level.
43    Universal,
44    /// Anything else, or a mix: each level's asset script class (`built-in` for none), sorted, each once.
45    Other(Vec<String>),
46}
47
48/// How many bone influences a vertex may have under a quality level (`QualitySettings`' `skinWeights`: 1, 2, 4 or
49/// 255 for unlimited).
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum SkinWeights {
52    /// At most this many influences per vertex (1, 2 or 4).
53    Bones(u32),
54    /// Every influence a mesh stores.
55    Unlimited,
56}
57
58impl SkinWeights {
59    /// The setting `value` as stored.
60    pub fn from_setting(value: i64) -> Result<SkinWeights> {
61        match value {
62            1 => Ok(SkinWeights::Bones(1)),
63            2 => Ok(SkinWeights::Bones(2)),
64            4 => Ok(SkinWeights::Bones(4)),
65            255 => Ok(SkinWeights::Unlimited),
66            v => Err(Error::Unsupported(format!("skinWeights {v}"))),
67        }
68    }
69}
70
71impl std::fmt::Display for SkinWeights {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            SkinWeights::Bones(1) => write!(f, "1 bone"),
75            SkinWeights::Bones(n) => write!(f, "{n} bones"),
76            SkinWeights::Unlimited => write!(f, "unlimited"),
77        }
78    }
79}
80
81/// A quality level of the player ([`Player::quality_levels`]): its name, the bone influences a vertex may have and
82/// its shadow settings (`shadows`: 0 disabled, 1 hard shadows only, 2 all; the distance shadows are drawn to; the
83/// built-in pipeline's cascade count, which a Universal Render Pipeline asset replaces with its own).
84#[derive(Clone, Debug, PartialEq)]
85pub struct QualityLevel {
86    /// The level's name.
87    pub name: String,
88    /// The bone influences a vertex may have (`skinWeights`).
89    pub skin_weights: SkinWeights,
90    /// `shadows`: 0 disabled, 1 hard shadows only, 2 all.
91    pub shadows: i64,
92    /// `shadowDistance`: how far from the camera shadows are drawn.
93    pub shadow_distance: f32,
94    /// `shadowCascades`: the built-in pipeline's cascade count.
95    pub shadow_cascades: i64,
96}
97
98/// A scene of the build ([`Player::scenes`]).
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct BuildScene {
101    /// Its place in the build's scene list.
102    pub index: usize,
103    /// The file it is stored in (`level<index>`).
104    pub file: String,
105    /// The path the editor knew it by (`Assets/Scenes/Main Mountain Scene.unity`), as stored.
106    pub path: String,
107    /// The scene's name: its path's file name without `.unity`.
108    pub name: String,
109}
110
111/// Which of a bundle's serialized files a player reads.
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113pub enum NodeSelection<'a> {
114    /// Every serialized node of the bundle.
115    All,
116    /// The nodes with these paths, and every serialized node of the listed bundles that their external files name
117    /// (transitively): the files a level needs, not the other levels of its bundle.
118    Closure(&'a [&'a str]),
119}
120
121/// A bundle to read into a player, with the files wanted from it.
122#[derive(Clone, Copy, Debug)]
123pub struct BundleSpec<'a> {
124    /// The bundle file.
125    pub path: &'a Path,
126    /// Which of its serialized files to read.
127    pub nodes: NodeSelection<'a>,
128}
129
130/// A Unity player's data folder, read: every SerializedFile in it (and in its `Resources` folder), the serialized
131/// files of the bundles it was opened with ([`Player::open_with_bundles`]), and the type tree package that decodes
132/// their objects. Objects are named by [`ObjRef`] and decoded on demand.
133#[derive(Debug)]
134pub struct Player {
135    /// The data folder.
136    pub dir: PathBuf,
137    /// By lower-case file name: `level1`, `sharedassets1.assets`, `unity default resources`, ...; a bundled file by
138    /// the last segment of its node's path (`buildplayer-level_5.sharedassets`).
139    pub files: BTreeMap<String, SerializedFile>,
140    /// The folder each loose file is in (its streamed resources are beside it); a bundled file has none.
141    pub folders: BTreeMap<String, PathBuf>,
142    /// The bundles files were read from, in the order given.
143    pub bundles: Vec<Arc<Bundle>>,
144    package: TypeTreePackage,
145    trees: Mutex<BTreeMap<(String, i32), std::sync::Arc<TypeTree>>>,
146}
147
148fn file_key(path: &str) -> String {
149    path.replace('\\', "/")
150        .rsplit('/')
151        .next()
152        .unwrap_or(path)
153        .to_lowercase()
154}
155
156impl Player {
157    /// The serialized files of `dir` (the `<Game>_Data` folder) and of its `Resources` folder, read with `package`'s
158    /// class layouts. Files of other kinds (resources, bundles, configs) are skipped.
159    ///
160    /// A file whose header is a SerializedFile's of a version this crate does not read is refused, not left out: a
161    /// player with such a file would otherwise read as one without it. Two files the player would name alike (they
162    /// differ by case, or one is in the data folder and one in `Resources`) are refused too.
163    pub fn open(dir: &Path, package: TypeTreePackage) -> Result<Self> {
164        Self::open_with_bundles(dir, package, &[])
165    }
166
167    /// As [`Player::open`], with the serialized files of `bundles` as well: a Big Walk style `data.unity3d` that holds
168    /// the player's own files, or a level's AssetBundle beside the loose ones. A bundled file is named like a loose one,
169    /// by the lower-cased last segment of its node's path, and is refused the same way when another file has the name.
170    /// Its objects are read from the bundle by range as they are asked for, so a selection of a bundle costs the
171    /// metadata of its files. A file that stores its type trees decodes with them; others with the package.
172    pub fn open_with_bundles(dir: &Path, package: TypeTreePackage, bundles: &[BundleSpec]) -> Result<Self> {
173        let mut files = BTreeMap::new();
174        let mut folders = BTreeMap::new();
175        let mut paths_of: BTreeMap<String, PathBuf> = BTreeMap::new();
176        for d in [dir.to_path_buf(), dir.join("Resources")] {
177            let Ok(rd) = std::fs::read_dir(&d) else { continue };
178            let mut paths: Vec<PathBuf> = rd.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
179            paths.sort();
180            for p in paths {
181                let bytes = std::fs::read(&p).map_err(|source| Error::Io {
182                    path: p.clone(),
183                    source,
184                })?;
185                match SerializedFile::container_version(&bytes) {
186                    None => continue,
187                    Some(v) if !crate::serialized::VERSIONS.contains(&v) => {
188                        return Err(Error::Unsupported(format!(
189                            "{}: SerializedFile version {v} (read: {}..={})",
190                            p.display(),
191                            crate::serialized::VERSIONS.start(),
192                            crate::serialized::VERSIONS.end()
193                        )));
194                    }
195                    Some(_) => {}
196                }
197                let name = p
198                    .file_name()
199                    .map(|n| n.to_string_lossy().to_lowercase())
200                    .unwrap_or_default();
201                if let Some(first) = paths_of.insert(name.clone(), p.clone()) {
202                    return Err(invalid(format!(
203                        "{} and {} are both the player's file {name:?}",
204                        first.display(),
205                        p.display()
206                    )));
207                }
208                let f = SerializedFile::parse_with(bytes, Some(&package))
209                    .map_err(|e| invalid(format!("{}: {e}", p.display())))?;
210                folders.insert(name.clone(), d.clone());
211                files.insert(name, f);
212            }
213        }
214        let opened = bundles
215            .iter()
216            .map(|spec| Bundle::open(spec.path).map(Arc::new))
217            .collect::<Result<Vec<_>>>()?;
218        // every serialized node of the bundles by the name a pointer gives it
219        let mut by_key: BTreeMap<String, Vec<(usize, &str)>> = BTreeMap::new();
220        for (i, b) in opened.iter().enumerate() {
221            for n in b.nodes().iter().filter(|n| n.is_serialized()) {
222                by_key
223                    .entry(file_key(&n.path))
224                    .or_default()
225                    .push((i, n.path.as_str()));
226            }
227        }
228        let mut pending: Vec<String> = Vec::new();
229        for (spec, b) in bundles.iter().zip(&opened) {
230            match spec.nodes {
231                NodeSelection::All => pending.extend(
232                    b.nodes()
233                        .iter()
234                        .filter(|n| n.is_serialized())
235                        .map(|n| file_key(&n.path)),
236                ),
237                NodeSelection::Closure(paths) => {
238                    for path in paths {
239                        let n = b.node(path).filter(|n| n.is_serialized()).ok_or_else(|| {
240                            invalid(format!("{}: no serialized node {path:?}", spec.path.display()))
241                        })?;
242                        pending.push(file_key(&n.path));
243                    }
244                }
245            }
246        }
247        // a file read brings the files its pointers name, where the listed bundles hold them
248        let mut seen: BTreeSet<String> = BTreeSet::new();
249        while let Some(key) = pending.pop() {
250            if !seen.insert(key.clone()) {
251                continue;
252            }
253            let Some(candidates) = by_key.get(&key) else {
254                continue;
255            };
256            if let Some(loose) = paths_of.get(&key) {
257                return Err(invalid(format!(
258                    "{} and {candidates:?} are both the player's file {key:?}",
259                    loose.display()
260                )));
261            }
262            let [(b, path)] = candidates.as_slice() else {
263                return Err(invalid(format!(
264                    "{} bundled files are all the player's file {key:?}: {candidates:?}",
265                    candidates.len()
266                )));
267            };
268            let (b, path) = (*b, *path);
269            let bundle = opened.get(b).ok_or_else(|| invalid("a bundle went missing"))?;
270            let f = SerializedFile::from_bundle(bundle, path, Some(&package))?;
271            pending.extend(f.externals.iter().map(|e| file_key(&e.path)));
272            files.insert(key, f);
273        }
274        Ok(Player {
275            dir: dir.to_path_buf(),
276            files,
277            folders,
278            bundles: opened,
279            package,
280            trees: Mutex::new(BTreeMap::new()),
281        })
282    }
283
284    /// The serialized file called `name` (lower case), or an error naming it.
285    pub fn file(&self, name: &str) -> Result<&SerializedFile> {
286        self.files
287            .get(name)
288            .ok_or_else(|| invalid(format!("no serialized file {name:?} in the player")))
289    }
290
291    /// The class id of an object (`None`: no such object).
292    pub fn class_id(&self, r: &ObjRef) -> Option<i32> {
293        self.files.get(&r.file)?.object(r.path_id).map(|o| o.class_id)
294    }
295
296    /// The class name of an object: the root of the type tree its file stores for the object's type, else the type tree
297    /// package's name for its class id.
298    pub fn class_name(&self, r: &ObjRef) -> Option<&str> {
299        let f = self.files.get(&r.file)?;
300        let o = f.object(r.path_id)?;
301        match f.tree_of(o).and_then(|t| t.root()) {
302            Some(root) => Some(&*root.ty),
303            None => self.package.class_name(o.class_id, f.unity),
304        }
305    }
306
307    /// The type tree that decodes `o` of `f`: the one the file stores for the object's type; the package's for a file
308    /// that stores none, and for a MonoBehaviour, whose stored tree holds its script's fields as well (see
309    /// [`Player::script_fields`]) where the package's holds the base fields every MonoBehaviour has.
310    fn tree(&self, file: &str, f: &SerializedFile, o: &ObjectInfo) -> Result<Arc<TypeTree>> {
311        let stored = f.tree_of(o);
312        if o.class_id != MONO_BEHAVIOUR
313            && let Some(t) = stored
314        {
315            return Ok(t.clone());
316        }
317        let key = (f.unity_version.clone(), o.class_id);
318        // an entry is inserted whole, so a cache left by a thread that panicked is still valid
319        let mut cache = self
320            .trees
321            .lock()
322            .unwrap_or_else(std::sync::PoisonError::into_inner);
323        if let Some(t) = cache.get(&key) {
324            return Ok(t.clone());
325        }
326        match self.package.type_tree(o.class_id, f.unity) {
327            Ok(t) => {
328                let t = Arc::new(t);
329                cache.insert(key, t.clone());
330                Ok(t)
331            }
332            // the package lacks the class for this Unity version, the file does not
333            Err(_) if stored.is_some() => stored.cloned().ok_or_else(|| invalid("a type tree went missing")),
334            Err(e) => Err(invalid(format!("{file}: {e}"))),
335        }
336    }
337
338    /// Decode an object (its type tree must cover it; a MonoBehaviour's base fields only).
339    pub fn decode(&self, r: &ObjRef) -> Result<Value> {
340        let f = self.file(&r.file)?;
341        let o = f
342            .object(r.path_id)
343            .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
344        let d = f.decode_with(o, &*self.tree(&r.file, f, o)?)?;
345        if !d.covered(o.class_id) {
346            return Err(invalid(format!(
347                "{}/{} (class {}): {} bytes after its type tree",
348                r.file, r.path_id, o.class_id, d.rest
349            )));
350        }
351        Ok(d.value)
352    }
353
354    /// A MonoBehaviour decoded whole, its script's fields with its base fields, by the type tree its file stores for
355    /// its script's type. `None` for a file that stores no type trees (a player's: the script's fields are
356    /// [`Player::behaviour`]'s bytes there) and for an object of another class. The tree must cover the object.
357    pub fn script_fields(&self, r: &ObjRef) -> Result<Option<Value>> {
358        let f = self.file(&r.file)?;
359        let o = f
360            .object(r.path_id)
361            .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
362        let Some(tree) = f.tree_of(o).filter(|_| o.class_id == MONO_BEHAVIOUR) else {
363            return Ok(None);
364        };
365        let d = f.decode_with(o, tree)?;
366        if d.rest != 0 {
367            return Err(invalid(format!(
368                "{}/{} (class {}): {} bytes after its stored type tree",
369                r.file, r.path_id, o.class_id, d.rest
370            )));
371        }
372        Ok(Some(d.value))
373    }
374
375    /// The field `name` of the object's root struct alone ([`TypeTree::decode_field`]): `None` when its class has no
376    /// such field. The object need not decode whole, and a field stored early (`m_Name`) is read without the rest.
377    pub fn field(&self, r: &ObjRef, name: &str) -> Result<Option<Value>> {
378        let f = self.file(&r.file)?;
379        let o = f
380            .object(r.path_id)
381            .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
382        self.tree(&r.file, f, o)?
383            .decode_field(&f.bytes(o)?, f.big_endian, name)
384            .map_err(|e| invalid(format!("{}/{} (class {}): {e}", r.file, r.path_id, o.class_id)))
385    }
386
387    /// The bytes of a streamed resource (`.resS`, `.resource`) that an object of `file` names: from the file beside the
388    /// loose file, or from the node of its bundle that the path's last segment names.
389    pub fn read_stream(&self, file: &str, info: &StreamingInfo) -> Result<Vec<u8>> {
390        if let Some((bundle, _)) = self.file(file)?.bundle() {
391            return info.read_bundled(bundle);
392        }
393        let dir = self
394            .folders
395            .get(file)
396            .ok_or_else(|| invalid(format!("no folder for {file}")))?;
397        info.read(dir)
398    }
399
400    /// The scenes of the build, in `BuildSettings`' order: scene `i` is the file `level<i>`. `Ok(empty)` for a player
401    /// without a `globalgamemanagers` or a `BuildSettings` (a player is never read for scenes it does not list).
402    pub fn scenes(&self) -> Result<Vec<BuildScene>> {
403        const FILE: &str = "globalgamemanagers";
404        let Some(f) = self.files.get(FILE) else {
405            return Ok(Vec::new());
406        };
407        let mut found = None;
408        for o in &f.objects {
409            let r = ObjRef {
410                file: FILE.into(),
411                path_id: o.path_id,
412            };
413            if self.class_name(&r) == Some("BuildSettings") {
414                if found.is_some() {
415                    return Err(invalid(format!("{FILE}: more than one BuildSettings")));
416                }
417                found = Some(r);
418            }
419        }
420        let Some(r) = found else {
421            return Ok(Vec::new());
422        };
423        let list = self
424            .field(&r, "scenes")?
425            .ok_or_else(|| invalid(format!("{FILE}: BuildSettings without a scene list")))?;
426        let Value::Array(Array::Values(paths)) = list else {
427            return Err(invalid(format!("{FILE}: BuildSettings' scenes is not a list")));
428        };
429        paths
430            .iter()
431            .enumerate()
432            .map(|(index, p)| {
433                let path = p
434                    .as_str()
435                    .ok_or_else(|| invalid(format!("{FILE}: 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    /// A MonoBehaviour's base fields and its script's serialized bytes, which a stripped build has no type tree for.
454    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    /// The pipeline asset each quality level draws with, each with the level's name: its own (`customRenderPipeline`),
481    /// else GraphicsSettings' default (`m_CustomRenderPipeline`); one level without a name when the player has no
482    /// QualitySettings levels. `None` when the player has no GraphicsSettings.
483    #[allow(clippy::type_complexity)]
484    fn pipeline_assets(&self) -> Result<Option<Vec<(Option<String>, Option<ObjRef>)>>> {
485        const FILE: &str = "globalgamemanagers";
486        let Some(f) = self.files.get(FILE) else {
487            return Ok(None);
488        };
489        let one = |class: &str| -> Result<Option<Value>> {
490            let mut found = None;
491            for o in &f.objects {
492                let r = ObjRef {
493                    file: FILE.into(),
494                    path_id: o.path_id,
495                };
496                if self.class_name(&r) == Some(class) {
497                    if found.is_some() {
498                        return Err(invalid(format!("{FILE}: more than one {class}")));
499                    }
500                    found = Some(self.decode(&r)?);
501                }
502            }
503            Ok(found)
504        };
505        let Some(graphics) = one("GraphicsSettings")? else {
506            return Ok(None);
507        };
508        let asset = |pptr: Option<&Value>| -> Result<Option<ObjRef>> {
509            Ok(pptr.map(|p| self.resolve(FILE, p)).transpose()?.flatten())
510        };
511        let default = asset(graphics.get("m_CustomRenderPipeline"))?;
512        let mut levels = Vec::new();
513        if let Some(Value::Array(crate::value::Array::Values(qs))) = one("QualitySettings")?
514            .as_ref()
515            .and_then(|q| q.get("m_QualitySettings"))
516        {
517            for q in qs {
518                let name = q.get("name").and_then(Value::as_str).map(str::to_owned);
519                levels.push((
520                    name,
521                    asset(q.get("customRenderPipeline"))?.or_else(|| default.clone()),
522                ));
523            }
524        }
525        if levels.is_empty() {
526            levels.push((None, default));
527        }
528        Ok(Some(levels))
529    }
530
531    /// A pipeline asset's script class (`namespace.class`).
532    fn pipeline_class(&self, asset: &ObjRef) -> Result<String> {
533        let (base, _) = self.behaviour(asset)?;
534        let script = base
535            .get("m_Script")
536            .map(|p| self.resolve(&asset.file, p))
537            .transpose()?
538            .flatten()
539            .ok_or_else(|| {
540                invalid(format!(
541                    "{}/{}: pipeline asset without a script",
542                    asset.file, asset.path_id
543                ))
544            })?;
545        let s = self.decode(&script)?;
546        let field = |k: &str| s.get(k).and_then(Value::as_str).unwrap_or("");
547        Ok(match field("m_Namespace") {
548            "" => field("m_ClassName").to_owned(),
549            ns => format!("{ns}.{}", field("m_ClassName")),
550        })
551    }
552
553    /// The player's [`RenderPipeline`], from `globalgamemanagers`' GraphicsSettings and QualitySettings; `None` when
554    /// the player has no GraphicsSettings.
555    pub fn render_pipeline(&self) -> Result<Option<RenderPipeline>> {
556        let Some(levels) = self.pipeline_assets()? else {
557            return Ok(None);
558        };
559        let levels: Vec<Option<String>> = levels
560            .iter()
561            .map(|(_, asset)| asset.as_ref().map(|a| self.pipeline_class(a)).transpose())
562            .collect::<Result<_>>()?;
563        Ok(Some(if levels.iter().all(Option::is_none) {
564            RenderPipeline::BuiltIn
565        } else if levels.iter().all(|l| l.as_deref() == Some(URP_ASSET)) {
566            RenderPipeline::Universal
567        } else {
568            let mut names: Vec<String> = levels
569                .into_iter()
570                .map(|l| l.unwrap_or_else(|| "built-in".into()))
571                .collect();
572            names.sort();
573            names.dedup();
574            RenderPipeline::Other(names)
575        }))
576    }
577
578    /// The Universal Render Pipeline asset the quality level `quality` draws with (every level's when none is named
579    /// and they all draw with one); `Ok(None)` when the level (or the levels) draw with no URP asset, or with more
580    /// than one where no level is named. An unknown level name is an error.
581    pub fn urp_asset(&self, quality: Option<&str>) -> Result<Option<ObjRef>> {
582        let Some(levels) = self.pipeline_assets()? else {
583            return Ok(None);
584        };
585        let chosen: Vec<&Option<ObjRef>> = match quality {
586            Some(name) => vec![
587                levels
588                    .iter()
589                    .find(|(n, _)| n.as_deref() == Some(name))
590                    .map(|(_, a)| a)
591                    .ok_or_else(|| invalid(format!("no quality level named {name}")))?,
592            ],
593            None => levels.iter().map(|(_, a)| a).collect(),
594        };
595        let mut found: Option<&ObjRef> = None;
596        for asset in chosen {
597            let Some(asset) = asset else { return Ok(None) };
598            if self.pipeline_class(asset)? != URP_ASSET {
599                return Ok(None);
600            }
601            match found {
602                Some(f) if f != asset => return Ok(None),
603                _ => found = Some(asset),
604            }
605        }
606        Ok(found.cloned())
607    }
608
609    /// The quality levels of `globalgamemanagers`' QualitySettings, in order, each with its name and the skin weights
610    /// it allows; none when the player has no QualitySettings. Which level is current at run time is not data a
611    /// consumer can read (see [`Player::build_quality`]), so a consumer states the level it wants where levels differ.
612    pub fn quality_levels(&self) -> Result<Vec<QualityLevel>> {
613        match self.quality_settings()? {
614            Some(settings) => levels_of(&settings),
615            None => Ok(Vec::new()),
616        }
617    }
618
619    /// The name of the level the build was made with: the level `m_CurrentQuality` of the player's QualitySettings
620    /// indexes (the three players read so far hold the top or only level). It is what the build states, not what the
621    /// game runs at: a game may change its quality level at run time. `None` when the player has no QualitySettings or
622    /// states no `m_CurrentQuality`; an index outside the levels is an error. (The per-platform default
623    /// `m_PerPlatformDefaultQuality` is not in a player: SkateBIRD, Sledding Game and PEAK carry none.)
624    pub fn build_quality(&self) -> Result<Option<String>> {
625        let Some(settings) = self.quality_settings()? else {
626            return Ok(None);
627        };
628        current_level(&settings, &levels_of(&settings)?)
629    }
630
631    /// The decoded QualitySettings of `globalgamemanagers`, `None` when the player has none.
632    fn quality_settings(&self) -> Result<Option<Value>> {
633        const FILE: &str = "globalgamemanagers";
634        let Some(f) = self.files.get(FILE) else {
635            return Ok(None);
636        };
637        let mut found = None;
638        for o in &f.objects {
639            let r = ObjRef {
640                file: FILE.into(),
641                path_id: o.path_id,
642            };
643            if self.class_name(&r) == Some("QualitySettings") {
644                if found.is_some() {
645                    return Err(invalid(format!("{FILE}: more than one QualitySettings")));
646                }
647                found = Some(self.decode(&r)?);
648            }
649        }
650        Ok(found)
651    }
652
653    /// The object a `PPtr` (`m_FileID`, `m_PathID`) found in `file` points at; `None` for a null pointer.
654    pub fn resolve(&self, file: &str, pptr: &Value) -> Result<Option<ObjRef>> {
655        let fid = pptr
656            .get("m_FileID")
657            .and_then(Value::as_i64)
658            .ok_or_else(|| invalid("PPtr without m_FileID"))?;
659        let pid = pptr
660            .get("m_PathID")
661            .and_then(Value::as_i64)
662            .ok_or_else(|| invalid("PPtr without m_PathID"))?;
663        if pid == 0 {
664            return Ok(None);
665        }
666        let target = if fid == 0 {
667            file.to_owned()
668        } else {
669            let f = self.file(file)?;
670            let ext = fid
671                .checked_sub(1)
672                .and_then(|i| usize::try_from(i).ok())
673                .and_then(|i| f.externals.get(i))
674                .ok_or_else(|| {
675                    invalid(format!(
676                        "{file}: PPtr file id {fid} of {} externals",
677                        f.externals.len()
678                    ))
679                })?;
680            file_key(&ext.path)
681        };
682        Ok(Some(ObjRef {
683            file: target,
684            path_id: pid,
685        }))
686    }
687}
688
689/// The quality levels a decoded QualitySettings lists, in order.
690fn levels_of(settings: &Value) -> Result<Vec<QualityLevel>> {
691    const FILE: &str = "globalgamemanagers";
692    let levels = match settings.get("m_QualitySettings") {
693        Some(Value::Array(Array::Values(levels))) => levels.as_slice(),
694        Some(Value::Array(a)) if a.is_empty() => &[],
695        _ => {
696            return Err(invalid(format!(
697                "{FILE}: QualitySettings without m_QualitySettings"
698            )));
699        }
700    };
701    levels
702        .iter()
703        .map(|q| {
704            let name = q
705                .get("name")
706                .and_then(Value::as_str)
707                .ok_or_else(|| invalid("a quality level without a name"))?
708                .to_owned();
709            let weights = q
710                .get("skinWeights")
711                .and_then(Value::as_i64)
712                .ok_or_else(|| invalid(format!("quality level {name}: no skinWeights")))?;
713            let field = |k: &str| -> Result<&Value> {
714                q.get(k)
715                    .ok_or_else(|| invalid(format!("quality level {name}: no {k}")))
716            };
717            Ok(QualityLevel {
718                skin_weights: SkinWeights::from_setting(weights)?,
719                shadows: field("shadows")?
720                    .as_i64()
721                    .ok_or_else(|| invalid(format!("quality level {name}: shadows is not a number")))?,
722                shadow_distance: field("shadowDistance")?.as_f32().ok_or_else(|| {
723                    invalid(format!("quality level {name}: shadowDistance is not a number"))
724                })?,
725                shadow_cascades: field("shadowCascades")?.as_i64().ok_or_else(|| {
726                    invalid(format!("quality level {name}: shadowCascades is not a number"))
727                })?,
728                name,
729            })
730        })
731        .collect()
732}
733
734/// The name of the level a decoded QualitySettings' `m_CurrentQuality` indexes among `levels`, `None` where it states none.
735fn current_level(settings: &Value, levels: &[QualityLevel]) -> Result<Option<String>> {
736    let Some(current) = settings.get("m_CurrentQuality") else {
737        return Ok(None);
738    };
739    let index = current
740        .as_i64()
741        .ok_or_else(|| invalid("QualitySettings: m_CurrentQuality is not a number"))?;
742    usize::try_from(index)
743        .ok()
744        .and_then(|i| levels.get(i))
745        .map(|level| Some(level.name.clone()))
746        .ok_or_else(|| {
747            invalid(format!(
748                "QualitySettings: m_CurrentQuality {index} is not one of its {} levels",
749                levels.len()
750            ))
751        })
752}
753
754#[cfg(test)]
755mod tests {
756    use std::sync::Arc;
757
758    use super::*;
759
760    fn level(name: &str) -> QualityLevel {
761        QualityLevel {
762            name: name.to_owned(),
763            skin_weights: SkinWeights::from_setting(4).expect("a skin weights setting"),
764            shadows: 2,
765            shadow_distance: 50.0,
766            shadow_cascades: 4,
767        }
768    }
769
770    fn settings(current: Option<Value>) -> Value {
771        Value::Struct(
772            current
773                .map(|v| (Arc::<str>::from("m_CurrentQuality"), v))
774                .into_iter()
775                .collect(),
776        )
777    }
778
779    /// The level the build was made with is the one `m_CurrentQuality` indexes; a player that states none has none, and
780    /// an index outside the levels is refused, not clamped.
781    #[test]
782    fn the_build_quality_is_the_level_m_current_quality_indexes() {
783        let levels = [level("Low"), level("Mid"), level("High")];
784        let name = |v| current_level(&settings(Some(v)), &levels);
785        assert_eq!(name(Value::Int(2)).unwrap().as_deref(), Some("High"));
786        assert_eq!(name(Value::UInt(0)).unwrap().as_deref(), Some("Low"));
787        assert_eq!(current_level(&settings(None), &levels).unwrap(), None);
788        for out in [Value::Int(3), Value::Int(-1), Value::UInt(99)] {
789            let error = name(out).unwrap_err().to_string();
790            assert!(error.contains("m_CurrentQuality"), "{error}");
791        }
792        assert!(name(Value::String("2".into())).is_err());
793        assert!(current_level(&settings(Some(Value::Int(0))), &[]).is_err());
794    }
795
796    /// A SerializedFile of `version` with no objects, laid out as tests/serialized_headers.rs lays them out: 21 keeps
797    /// its sizes in the 20 byte header, later ones in the 48 byte header.
798    fn empty_file(version: u32) -> Vec<u8> {
799        let mut metadata = b"2019.4.20f1\0".to_vec();
800        metadata.extend_from_slice(&19_i32.to_le_bytes());
801        metadata.push(0);
802        // no types, objects, scripts, externals or reference types
803        metadata.extend_from_slice(&[0; 20]);
804        metadata.push(0);
805        let header = if version < 22 { 20 } else { 48 };
806        let size = header + metadata.len();
807        let mut out = vec![0; header];
808        out[8..12].copy_from_slice(&version.to_be_bytes());
809        if version < 22 {
810            out[0..4].copy_from_slice(&(metadata.len() as u32).to_be_bytes());
811            out[4..8].copy_from_slice(&(size as u32).to_be_bytes());
812            out[12..16].copy_from_slice(&(size as u32).to_be_bytes());
813        } else {
814            out[20..24].copy_from_slice(&(metadata.len() as u32).to_be_bytes());
815            out[24..32].copy_from_slice(&(size as u64).to_be_bytes());
816            out[32..40].copy_from_slice(&(size as u64).to_be_bytes());
817        }
818        out.extend(metadata);
819        out
820    }
821
822    fn scratch(name: &str) -> PathBuf {
823        let dir = std::env::temp_dir().join(format!("unity-formats-{name}-{}", std::process::id()));
824        let _ = std::fs::remove_dir_all(&dir);
825        std::fs::create_dir_all(dir.join("Resources")).unwrap();
826        dir
827    }
828
829    /// A file of a container version the crate does not read is refused with its path and version, not left out of the
830    /// player; files that are no SerializedFile are still skipped.
831    #[test]
832    fn a_file_of_an_unread_version_is_refused_not_absent() {
833        for version in [20, 23] {
834            let dir = scratch(&format!("version{version}"));
835            std::fs::write(dir.join("level0"), empty_file(21)).unwrap();
836            std::fs::write(dir.join("sharedassets0.assets.resS"), [7u8; 100]).unwrap();
837            assert_eq!(
838                Player::open(&dir, TypeTreePackage::empty()).unwrap().files.len(),
839                1
840            );
841            std::fs::write(dir.join("level1"), empty_file(version)).unwrap();
842            let e = Player::open(&dir, TypeTreePackage::empty()).unwrap_err();
843            assert!(matches!(e, Error::Unsupported(_)), "{e}");
844            let e = e.to_string();
845            assert!(
846                e.contains("level1") && e.contains(&format!("version {version}")),
847                "{e}"
848            );
849            std::fs::remove_dir_all(&dir).unwrap();
850        }
851    }
852
853    /// Two files the player names alike are refused, naming both, instead of the later replacing the earlier.
854    #[test]
855    fn two_files_with_one_name_are_refused() {
856        let dir = scratch("duplicate");
857        std::fs::write(dir.join("resources.assets"), empty_file(21)).unwrap();
858        std::fs::write(dir.join("Resources").join("Resources.assets"), empty_file(22)).unwrap();
859        let e = Player::open(&dir, TypeTreePackage::empty())
860            .unwrap_err()
861            .to_string();
862        assert!(e.contains("both the player's file \"resources.assets\""), "{e}");
863        std::fs::remove_dir_all(&dir).unwrap();
864    }
865}