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