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