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 file that holds a player's manager objects (its BuildSettings, GraphicsSettings, QualitySettings and
33/// PlayerSettings).
34const MANAGERS: &str = "globalgamemanagers";
35
36/// The script class of Universal Render Pipeline's asset (`m_Namespace.m_ClassName` of its MonoScript).
37pub const URP_ASSET: &str = "UnityEngine.Rendering.Universal.UniversalRenderPipelineAsset";
38
39/// The render pipeline a player draws with: GraphicsSettings' default pipeline asset (`m_CustomRenderPipeline`), or a
40/// quality level's own (`customRenderPipeline`) where it sets one. Which quality level runs is the user's choice, so
41/// the pipeline is known only when every level gives the same kind.
42#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
43pub enum RenderPipeline {
44    /// No pipeline asset for any quality level: Unity's built-in renderer.
45    BuiltIn,
46    /// A Universal Render Pipeline asset ([`URP_ASSET`]) for every quality level.
47    Universal,
48    /// Anything else, or a mix: each level's asset script class (`built-in` for none), sorted, each once.
49    Other(Vec<String>),
50}
51
52/// How many bone influences a vertex may have under a quality level (`QualitySettings`' `skinWeights`: 1, 2, 4 or
53/// 255 for unlimited).
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum SkinWeights {
56    /// At most this many influences per vertex (1, 2 or 4).
57    Bones(u32),
58    /// Every influence a mesh stores.
59    Unlimited,
60}
61
62impl SkinWeights {
63    /// The setting `value` as stored.
64    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/// A quality level of the player ([`Player::quality_levels`]): its name, the bone influences a vertex may have and
86/// its shadow settings (`shadows`: 0 disabled, 1 hard shadows only, 2 all; the distance shadows are drawn to; the
87/// built-in pipeline's cascade count, which a Universal Render Pipeline asset replaces with its own).
88#[derive(Clone, Debug, PartialEq)]
89pub struct QualityLevel {
90    /// The level's name.
91    pub name: String,
92    /// The bone influences a vertex may have (`skinWeights`).
93    pub skin_weights: SkinWeights,
94    /// `shadows`: 0 disabled, 1 hard shadows only, 2 all.
95    pub shadows: i64,
96    /// `shadowDistance`: how far from the camera shadows are drawn.
97    pub shadow_distance: f32,
98    /// `shadowCascades`: the built-in pipeline's cascade count.
99    pub shadow_cascades: i64,
100}
101
102/// The colour space a player renders in (PlayerSettings' `m_ActiveColorSpace`, a project setting the build stores): in
103/// `Linear` the engine converts colour properties from their gamma-encoded storage to linear before a program reads
104/// them and samples sRGB textures as linear; in `Gamma` it hands both to programs as stored.
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
106pub enum ColourSpace {
107    /// `m_ActiveColorSpace` 0.
108    Gamma,
109    /// `m_ActiveColorSpace` 1.
110    Linear,
111}
112
113/// A scene of the build ([`Player::scenes`]).
114#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct BuildScene {
116    /// Its place in the build's scene list.
117    pub index: usize,
118    /// The file it is stored in (`level<index>`).
119    pub file: String,
120    /// The path the editor knew it by (`Assets/Scenes/Main Mountain Scene.unity`), as stored.
121    pub path: String,
122    /// The scene's name: its path's file name without `.unity`.
123    pub name: String,
124}
125
126/// Which of a bundle's serialized files a player reads.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum NodeSelection<'a> {
129    /// Every serialized node of the bundle.
130    All,
131    /// The nodes with these paths, and every serialized node of the listed bundles that their external files name
132    /// (transitively): the files a level needs, not the other levels of its bundle.
133    Closure(&'a [&'a str]),
134}
135
136/// A bundle to read into a player, with the files wanted from it.
137#[derive(Clone, Copy, Debug)]
138pub struct BundleSpec<'a> {
139    /// The bundle file.
140    pub path: &'a Path,
141    /// Which of its serialized files to read.
142    pub nodes: NodeSelection<'a>,
143}
144
145/// A Unity player's data folder, read: every SerializedFile in it (and in its `Resources` folder), the serialized
146/// files of the bundles it was opened with ([`Player::open_with_bundles`]), and the type tree package that decodes
147/// their objects. Objects are named by [`ObjRef`] and decoded on demand.
148#[derive(Debug)]
149pub struct Player {
150    /// The data folder.
151    pub dir: PathBuf,
152    /// By lower-case file name: `level1`, `sharedassets1.assets`, `unity default resources`, ...; a bundled file by
153    /// the last segment of its node's path (`buildplayer-level_5.sharedassets`).
154    pub files: BTreeMap<String, SerializedFile>,
155    /// The folder each loose file is in (its streamed resources are beside it); a bundled file has none.
156    pub folders: BTreeMap<String, PathBuf>,
157    /// The bundles files were read from, in the order given.
158    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    /// The serialized files of `dir` (the `<Game>_Data` folder) and of its `Resources` folder, read with `package`'s
173    /// class layouts. Files of other kinds (resources, bundles, configs) are skipped.
174    ///
175    /// A file whose header is a SerializedFile's of a version this crate does not read is refused, not left out: a
176    /// player with such a file would otherwise read as one without it. Two files the player would name alike (they
177    /// differ by case, or one is in the data folder and one in `Resources`) are refused too.
178    pub fn open(dir: &Path, package: TypeTreePackage) -> Result<Self> {
179        Self::open_with_bundles(dir, package, &[])
180    }
181
182    /// As [`Player::open`], with the serialized files of `bundles` as well: a Big Walk style `data.unity3d` that holds
183    /// the player's own files, or a level's AssetBundle beside the loose ones. A bundled file is named like a loose one,
184    /// by the lower-cased last segment of its node's path, and is refused the same way when another file has the name.
185    /// Its objects are read from the bundle by range as they are asked for, so a selection of a bundle costs the
186    /// metadata of its files. A file that stores its type trees decodes with them; others with the package.
187    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        // every serialized node of the bundles by the name a pointer gives it
234        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        // a file read brings the files its pointers name, where the listed bundles hold them
263        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    /// The serialized file called `name` (lower case), or an error naming it.
300    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    /// The class id of an object (`None`: no such object).
307    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    /// The class name of an object: the root of the type tree its file stores for the object's type, else the type tree
312    /// package's name for its class id.
313    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    /// The type tree that decodes `o` of `f`: the one the file stores for the object's type; the package's for a file
323    /// that stores none, and for a MonoBehaviour, whose stored tree holds its script's fields as well (see
324    /// [`Player::script_fields`]) where the package's holds the base fields every MonoBehaviour has.
325    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        // an entry is inserted whole, so a cache left by a thread that panicked is still valid
334        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            // the package lacks the class for this Unity version, the file does not
348            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    /// Decode an object (its type tree must cover it; a MonoBehaviour's base fields only).
354    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    /// A MonoBehaviour decoded whole, its script's fields with its base fields, by the type tree its file stores for
370    /// its script's type. `None` for a file that stores no type trees (a player's: the script's fields are
371    /// [`Player::behaviour`]'s bytes there) and for an object of another class. The tree must cover the object.
372    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    /// The field `name` of the object's root struct alone ([`TypeTree::decode_field`]): `None` when its class has no
391    /// such field. The object need not decode whole, and a field stored early (`m_Name`) is read without the rest.
392    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    /// The bytes of a streamed resource (`.resS`, `.resource`) that an object of `file` names: from the file beside the
403    /// loose file, or from the node of its bundle that the path's last segment names.
404    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    /// The scenes of the build, in `BuildSettings`' order: scene `i` is the file `level<i>`. `Ok(empty)` for a player
416    /// without a `globalgamemanagers` or a `BuildSettings` (a player is never read for scenes it does not list).
417    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    /// 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        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    /// A pipeline asset's script class (`namespace.class`).
513    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    /// The player's [`RenderPipeline`], from `globalgamemanagers`' GraphicsSettings and QualitySettings; `None` when
535    /// the player has no GraphicsSettings.
536    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    /// The Universal Render Pipeline asset the quality level `quality` draws with (every level's when none is named
560    /// and they all draw with one); `Ok(None)` when the level (or the levels) draw with no URP asset, or with more
561    /// than one where no level is named. An unknown level name is an error.
562    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    /// The quality levels of `globalgamemanagers`' QualitySettings, in order, each with its name and the skin weights
591    /// it allows; none when the player has no QualitySettings. Which level is current at run time is not data a
592    /// consumer can read (see [`Player::build_quality`]), so a consumer states the level it wants where levels differ.
593    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    /// The name of the level the build was made with: the level `m_CurrentQuality` of the player's QualitySettings
601    /// indexes (the three players read so far hold the top or only level). It is what the build states, not what the
602    /// game runs at: a game may change its quality level at run time. `None` when the player has no QualitySettings or
603    /// states no `m_CurrentQuality`; an index outside the levels is an error. (The per-platform default
604    /// `m_PerPlatformDefaultQuality` is not in a player: SkateBIRD, Sledding Game and PEAK carry none.)
605    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    /// The colour space the player renders in: its PlayerSettings' `m_ActiveColorSpace` ([`ColourSpace`]). `None` when
613    /// the player has no PlayerSettings; a value other than 0 or 1 (the editor's -1, uninitialised) is an error.
614    ///
615    /// The field is read alone ([`Player::field`]), not the object whole: for the Unity 6000.3 players read so far
616    /// (Sledding Game, PEAK, Big Walk) the type tree package's PlayerSettings layout covers 4 bytes fewer than the
617    /// build stores, the shortfall UnityPy's layouts read too, while the fields before the colour space decode alike.
618    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    /// The decoded QualitySettings of `globalgamemanagers`, `None` when the player has none.
627    fn quality_settings(&self) -> Result<Option<Value>> {
628        self.manager("QualitySettings")
629    }
630
631    /// The one object of the manager class `class` (GraphicsSettings, QualitySettings) in
632    /// `globalgamemanagers`, decoded; `None` when the player has no such file or object. Two of one class are an error.
633    fn manager(&self, class: &str) -> Result<Option<Value>> {
634        self.manager_object(class)?.map(|r| self.decode(&r)).transpose()
635    }
636
637    /// The one object of the manager class `class` in `globalgamemanagers` ([`Player::manager`]), not decoded.
638    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    /// The object a `PPtr` (`m_FileID`, `m_PathID`) found in `file` points at; `None` for a null pointer.
659    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
694/// The quality levels a decoded QualitySettings lists, in order.
695fn 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
738/// The colour space PlayerSettings' `m_ActiveColorSpace` states (0 gamma, 1 linear); `None`: the field is absent.
739fn 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
750/// The name of the level a decoded QualitySettings' `m_CurrentQuality` indexes among `levels`, `None` where it states none.
751fn 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    /// The level the build was made with is the one `m_CurrentQuality` indexes; a player that states none has none, and
796    /// an index outside the levels is refused, not clamped.
797    #[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    /// `m_ActiveColorSpace` 0 is gamma and 1 linear; the editor's uninitialised -1, any other number, a value that is no
813    /// number and a PlayerSettings without the field are refused, not read as either.
814    #[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    /// A SerializedFile of `version` with no objects, laid out as tests/serialized_headers.rs lays them out: 21 keeps
828    /// its sizes in the 20 byte header, later ones in the 48 byte header.
829    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        // no types, objects, scripts, externals or reference types
834        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    /// A file of a container version the crate does not read is refused with its path and version, not left out of the
861    /// player; files that are no SerializedFile are still skipped.
862    #[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    /// Two files the player names alike are refused, naming both, instead of the later replacing the earlier.
885    #[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}