1#![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#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
23pub struct ObjRef {
24 pub file: String,
26 pub path_id: i64,
28}
29
30pub const URP_ASSET: &str = "UnityEngine.Rendering.Universal.UniversalRenderPipelineAsset";
32
33#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
37pub enum RenderPipeline {
38 BuiltIn,
40 Universal,
42 Other(Vec<String>),
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum SkinWeights {
50 Bones(u32),
52 Unlimited,
54}
55
56impl SkinWeights {
57 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#[derive(Clone, Debug, PartialEq)]
83pub struct QualityLevel {
84 pub name: String,
86 pub skin_weights: SkinWeights,
88 pub shadows: i64,
90 pub shadow_distance: f32,
92 pub shadow_cascades: i64,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct BuildScene {
99 pub index: usize,
101 pub file: String,
103 pub path: String,
105 pub name: String,
107}
108
109#[derive(Debug)]
112pub struct Player {
113 pub dir: PathBuf,
115 pub files: BTreeMap<String, SerializedFile>,
117 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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
542fn 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
587fn 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 #[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 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 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 #[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 #[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}