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