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, 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#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
25pub struct ObjRef {
26 pub file: String,
28 pub path_id: i64,
30}
31
32pub const URP_ASSET: &str = "UnityEngine.Rendering.Universal.UniversalRenderPipelineAsset";
34
35#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
39pub enum RenderPipeline {
40 BuiltIn,
42 Universal,
44 Other(Vec<String>),
46}
47
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum SkinWeights {
52 Bones(u32),
54 Unlimited,
56}
57
58impl SkinWeights {
59 pub fn from_setting(value: i64) -> Result<SkinWeights> {
61 match value {
62 1 => Ok(SkinWeights::Bones(1)),
63 2 => Ok(SkinWeights::Bones(2)),
64 4 => Ok(SkinWeights::Bones(4)),
65 255 => Ok(SkinWeights::Unlimited),
66 v => Err(Error::Unsupported(format!("skinWeights {v}"))),
67 }
68 }
69}
70
71impl std::fmt::Display for SkinWeights {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 match self {
74 SkinWeights::Bones(1) => write!(f, "1 bone"),
75 SkinWeights::Bones(n) => write!(f, "{n} bones"),
76 SkinWeights::Unlimited => write!(f, "unlimited"),
77 }
78 }
79}
80
81#[derive(Clone, Debug, PartialEq)]
85pub struct QualityLevel {
86 pub name: String,
88 pub skin_weights: SkinWeights,
90 pub shadows: i64,
92 pub shadow_distance: f32,
94 pub shadow_cascades: i64,
96}
97
98#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct BuildScene {
101 pub index: usize,
103 pub file: String,
105 pub path: String,
107 pub name: String,
109}
110
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113pub enum NodeSelection<'a> {
114 All,
116 Closure(&'a [&'a str]),
119}
120
121#[derive(Clone, Copy, Debug)]
123pub struct BundleSpec<'a> {
124 pub path: &'a Path,
126 pub nodes: NodeSelection<'a>,
128}
129
130#[derive(Debug)]
134pub struct Player {
135 pub dir: PathBuf,
137 pub files: BTreeMap<String, SerializedFile>,
140 pub folders: BTreeMap<String, PathBuf>,
142 pub bundles: Vec<Arc<Bundle>>,
144 package: TypeTreePackage,
145 trees: Mutex<BTreeMap<(String, i32), std::sync::Arc<TypeTree>>>,
146}
147
148fn file_key(path: &str) -> String {
149 path.replace('\\', "/")
150 .rsplit('/')
151 .next()
152 .unwrap_or(path)
153 .to_lowercase()
154}
155
156impl Player {
157 pub fn open(dir: &Path, package: TypeTreePackage) -> Result<Self> {
164 Self::open_with_bundles(dir, package, &[])
165 }
166
167 pub fn open_with_bundles(dir: &Path, package: TypeTreePackage, bundles: &[BundleSpec]) -> Result<Self> {
173 let mut files = BTreeMap::new();
174 let mut folders = BTreeMap::new();
175 let mut paths_of: BTreeMap<String, PathBuf> = BTreeMap::new();
176 for d in [dir.to_path_buf(), dir.join("Resources")] {
177 let Ok(rd) = std::fs::read_dir(&d) else { continue };
178 let mut paths: Vec<PathBuf> = rd.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect();
179 paths.sort();
180 for p in paths {
181 let bytes = std::fs::read(&p).map_err(|source| Error::Io {
182 path: p.clone(),
183 source,
184 })?;
185 match SerializedFile::container_version(&bytes) {
186 None => continue,
187 Some(v) if !crate::serialized::VERSIONS.contains(&v) => {
188 return Err(Error::Unsupported(format!(
189 "{}: SerializedFile version {v} (read: {}..={})",
190 p.display(),
191 crate::serialized::VERSIONS.start(),
192 crate::serialized::VERSIONS.end()
193 )));
194 }
195 Some(_) => {}
196 }
197 let name = p
198 .file_name()
199 .map(|n| n.to_string_lossy().to_lowercase())
200 .unwrap_or_default();
201 if let Some(first) = paths_of.insert(name.clone(), p.clone()) {
202 return Err(invalid(format!(
203 "{} and {} are both the player's file {name:?}",
204 first.display(),
205 p.display()
206 )));
207 }
208 let f = SerializedFile::parse_with(bytes, Some(&package))
209 .map_err(|e| invalid(format!("{}: {e}", p.display())))?;
210 folders.insert(name.clone(), d.clone());
211 files.insert(name, f);
212 }
213 }
214 let opened = bundles
215 .iter()
216 .map(|spec| Bundle::open(spec.path).map(Arc::new))
217 .collect::<Result<Vec<_>>>()?;
218 let mut by_key: BTreeMap<String, Vec<(usize, &str)>> = BTreeMap::new();
220 for (i, b) in opened.iter().enumerate() {
221 for n in b.nodes().iter().filter(|n| n.is_serialized()) {
222 by_key
223 .entry(file_key(&n.path))
224 .or_default()
225 .push((i, n.path.as_str()));
226 }
227 }
228 let mut pending: Vec<String> = Vec::new();
229 for (spec, b) in bundles.iter().zip(&opened) {
230 match spec.nodes {
231 NodeSelection::All => pending.extend(
232 b.nodes()
233 .iter()
234 .filter(|n| n.is_serialized())
235 .map(|n| file_key(&n.path)),
236 ),
237 NodeSelection::Closure(paths) => {
238 for path in paths {
239 let n = b.node(path).filter(|n| n.is_serialized()).ok_or_else(|| {
240 invalid(format!("{}: no serialized node {path:?}", spec.path.display()))
241 })?;
242 pending.push(file_key(&n.path));
243 }
244 }
245 }
246 }
247 let mut seen: BTreeSet<String> = BTreeSet::new();
249 while let Some(key) = pending.pop() {
250 if !seen.insert(key.clone()) {
251 continue;
252 }
253 let Some(candidates) = by_key.get(&key) else {
254 continue;
255 };
256 if let Some(loose) = paths_of.get(&key) {
257 return Err(invalid(format!(
258 "{} and {candidates:?} are both the player's file {key:?}",
259 loose.display()
260 )));
261 }
262 let [(b, path)] = candidates.as_slice() else {
263 return Err(invalid(format!(
264 "{} bundled files are all the player's file {key:?}: {candidates:?}",
265 candidates.len()
266 )));
267 };
268 let (b, path) = (*b, *path);
269 let bundle = opened.get(b).ok_or_else(|| invalid("a bundle went missing"))?;
270 let f = SerializedFile::from_bundle(bundle, path, Some(&package))?;
271 pending.extend(f.externals.iter().map(|e| file_key(&e.path)));
272 files.insert(key, f);
273 }
274 Ok(Player {
275 dir: dir.to_path_buf(),
276 files,
277 folders,
278 bundles: opened,
279 package,
280 trees: Mutex::new(BTreeMap::new()),
281 })
282 }
283
284 pub fn file(&self, name: &str) -> Result<&SerializedFile> {
286 self.files
287 .get(name)
288 .ok_or_else(|| invalid(format!("no serialized file {name:?} in the player")))
289 }
290
291 pub fn class_id(&self, r: &ObjRef) -> Option<i32> {
293 self.files.get(&r.file)?.object(r.path_id).map(|o| o.class_id)
294 }
295
296 pub fn class_name(&self, r: &ObjRef) -> Option<&str> {
299 let f = self.files.get(&r.file)?;
300 let o = f.object(r.path_id)?;
301 match f.tree_of(o).and_then(|t| t.root()) {
302 Some(root) => Some(&*root.ty),
303 None => self.package.class_name(o.class_id, f.unity),
304 }
305 }
306
307 fn tree(&self, file: &str, f: &SerializedFile, o: &ObjectInfo) -> Result<Arc<TypeTree>> {
311 let stored = f.tree_of(o);
312 if o.class_id != MONO_BEHAVIOUR
313 && let Some(t) = stored
314 {
315 return Ok(t.clone());
316 }
317 let key = (f.unity_version.clone(), o.class_id);
318 let mut cache = self
320 .trees
321 .lock()
322 .unwrap_or_else(std::sync::PoisonError::into_inner);
323 if let Some(t) = cache.get(&key) {
324 return Ok(t.clone());
325 }
326 match self.package.type_tree(o.class_id, f.unity) {
327 Ok(t) => {
328 let t = Arc::new(t);
329 cache.insert(key, t.clone());
330 Ok(t)
331 }
332 Err(_) if stored.is_some() => stored.cloned().ok_or_else(|| invalid("a type tree went missing")),
334 Err(e) => Err(invalid(format!("{file}: {e}"))),
335 }
336 }
337
338 pub fn decode(&self, r: &ObjRef) -> Result<Value> {
340 let f = self.file(&r.file)?;
341 let o = f
342 .object(r.path_id)
343 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
344 let d = f.decode_with(o, &*self.tree(&r.file, f, o)?)?;
345 if !d.covered(o.class_id) {
346 return Err(invalid(format!(
347 "{}/{} (class {}): {} bytes after its type tree",
348 r.file, r.path_id, o.class_id, d.rest
349 )));
350 }
351 Ok(d.value)
352 }
353
354 pub fn script_fields(&self, r: &ObjRef) -> Result<Option<Value>> {
358 let f = self.file(&r.file)?;
359 let o = f
360 .object(r.path_id)
361 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
362 let Some(tree) = f.tree_of(o).filter(|_| o.class_id == MONO_BEHAVIOUR) else {
363 return Ok(None);
364 };
365 let d = f.decode_with(o, tree)?;
366 if d.rest != 0 {
367 return Err(invalid(format!(
368 "{}/{} (class {}): {} bytes after its stored type tree",
369 r.file, r.path_id, o.class_id, d.rest
370 )));
371 }
372 Ok(Some(d.value))
373 }
374
375 pub fn field(&self, r: &ObjRef, name: &str) -> Result<Option<Value>> {
378 let f = self.file(&r.file)?;
379 let o = f
380 .object(r.path_id)
381 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
382 self.tree(&r.file, f, o)?
383 .decode_field(&f.bytes(o)?, f.big_endian, name)
384 .map_err(|e| invalid(format!("{}/{} (class {}): {e}", r.file, r.path_id, o.class_id)))
385 }
386
387 pub fn read_stream(&self, file: &str, info: &StreamingInfo) -> Result<Vec<u8>> {
390 if let Some((bundle, _)) = self.file(file)?.bundle() {
391 return info.read_bundled(bundle);
392 }
393 let dir = self
394 .folders
395 .get(file)
396 .ok_or_else(|| invalid(format!("no folder for {file}")))?;
397 info.read(dir)
398 }
399
400 pub fn scenes(&self) -> Result<Vec<BuildScene>> {
403 const FILE: &str = "globalgamemanagers";
404 let Some(f) = self.files.get(FILE) else {
405 return Ok(Vec::new());
406 };
407 let mut found = None;
408 for o in &f.objects {
409 let r = ObjRef {
410 file: FILE.into(),
411 path_id: o.path_id,
412 };
413 if self.class_name(&r) == Some("BuildSettings") {
414 if found.is_some() {
415 return Err(invalid(format!("{FILE}: more than one BuildSettings")));
416 }
417 found = Some(r);
418 }
419 }
420 let Some(r) = found else {
421 return Ok(Vec::new());
422 };
423 let list = self
424 .field(&r, "scenes")?
425 .ok_or_else(|| invalid(format!("{FILE}: BuildSettings without a scene list")))?;
426 let Value::Array(Array::Values(paths)) = list else {
427 return Err(invalid(format!("{FILE}: BuildSettings' scenes is not a list")));
428 };
429 paths
430 .iter()
431 .enumerate()
432 .map(|(index, p)| {
433 let path = p
434 .as_str()
435 .ok_or_else(|| invalid(format!("{FILE}: scene {index} is not a path")))?
436 .to_owned();
437 let name = path
438 .rsplit('/')
439 .next()
440 .unwrap_or(&path)
441 .trim_end_matches(".unity")
442 .to_owned();
443 Ok(BuildScene {
444 index,
445 file: format!("level{index}"),
446 path,
447 name,
448 })
449 })
450 .collect()
451 }
452
453 pub fn behaviour(&self, r: &ObjRef) -> Result<(Value, Vec<u8>)> {
455 let f = self.file(&r.file)?;
456 let o = f
457 .object(r.path_id)
458 .ok_or_else(|| invalid(format!("{}: no object {}", r.file, r.path_id)))?;
459 if o.class_id != crate::serialized::MONO_BEHAVIOUR {
460 return Err(invalid(format!("{}/{}: not a MonoBehaviour", r.file, r.path_id)));
461 }
462 let d = f.decode_with(o, &*self.tree(&r.file, f, o)?)?;
463 let bytes = f.bytes(o)?;
464 let script = bytes
465 .len()
466 .checked_sub(d.rest)
467 .and_then(|from| bytes.get(from..))
468 .ok_or_else(|| {
469 invalid(format!(
470 "{}/{}: {} bytes left of {}",
471 r.file,
472 r.path_id,
473 d.rest,
474 bytes.len()
475 ))
476 })?;
477 Ok((d.value, script.to_vec()))
478 }
479
480 #[allow(clippy::type_complexity)]
484 fn pipeline_assets(&self) -> Result<Option<Vec<(Option<String>, Option<ObjRef>)>>> {
485 const FILE: &str = "globalgamemanagers";
486 let Some(f) = self.files.get(FILE) else {
487 return Ok(None);
488 };
489 let one = |class: &str| -> Result<Option<Value>> {
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(class) {
497 if found.is_some() {
498 return Err(invalid(format!("{FILE}: more than one {class}")));
499 }
500 found = Some(self.decode(&r)?);
501 }
502 }
503 Ok(found)
504 };
505 let Some(graphics) = one("GraphicsSettings")? else {
506 return Ok(None);
507 };
508 let asset = |pptr: Option<&Value>| -> Result<Option<ObjRef>> {
509 Ok(pptr.map(|p| self.resolve(FILE, p)).transpose()?.flatten())
510 };
511 let default = asset(graphics.get("m_CustomRenderPipeline"))?;
512 let mut levels = Vec::new();
513 if let Some(Value::Array(crate::value::Array::Values(qs))) = one("QualitySettings")?
514 .as_ref()
515 .and_then(|q| q.get("m_QualitySettings"))
516 {
517 for q in qs {
518 let name = q.get("name").and_then(Value::as_str).map(str::to_owned);
519 levels.push((
520 name,
521 asset(q.get("customRenderPipeline"))?.or_else(|| default.clone()),
522 ));
523 }
524 }
525 if levels.is_empty() {
526 levels.push((None, default));
527 }
528 Ok(Some(levels))
529 }
530
531 fn pipeline_class(&self, asset: &ObjRef) -> Result<String> {
533 let (base, _) = self.behaviour(asset)?;
534 let script = base
535 .get("m_Script")
536 .map(|p| self.resolve(&asset.file, p))
537 .transpose()?
538 .flatten()
539 .ok_or_else(|| {
540 invalid(format!(
541 "{}/{}: pipeline asset without a script",
542 asset.file, asset.path_id
543 ))
544 })?;
545 let s = self.decode(&script)?;
546 let field = |k: &str| s.get(k).and_then(Value::as_str).unwrap_or("");
547 Ok(match field("m_Namespace") {
548 "" => field("m_ClassName").to_owned(),
549 ns => format!("{ns}.{}", field("m_ClassName")),
550 })
551 }
552
553 pub fn render_pipeline(&self) -> Result<Option<RenderPipeline>> {
556 let Some(levels) = self.pipeline_assets()? else {
557 return Ok(None);
558 };
559 let levels: Vec<Option<String>> = levels
560 .iter()
561 .map(|(_, asset)| asset.as_ref().map(|a| self.pipeline_class(a)).transpose())
562 .collect::<Result<_>>()?;
563 Ok(Some(if levels.iter().all(Option::is_none) {
564 RenderPipeline::BuiltIn
565 } else if levels.iter().all(|l| l.as_deref() == Some(URP_ASSET)) {
566 RenderPipeline::Universal
567 } else {
568 let mut names: Vec<String> = levels
569 .into_iter()
570 .map(|l| l.unwrap_or_else(|| "built-in".into()))
571 .collect();
572 names.sort();
573 names.dedup();
574 RenderPipeline::Other(names)
575 }))
576 }
577
578 pub fn urp_asset(&self, quality: Option<&str>) -> Result<Option<ObjRef>> {
582 let Some(levels) = self.pipeline_assets()? else {
583 return Ok(None);
584 };
585 let chosen: Vec<&Option<ObjRef>> = match quality {
586 Some(name) => vec![
587 levels
588 .iter()
589 .find(|(n, _)| n.as_deref() == Some(name))
590 .map(|(_, a)| a)
591 .ok_or_else(|| invalid(format!("no quality level named {name}")))?,
592 ],
593 None => levels.iter().map(|(_, a)| a).collect(),
594 };
595 let mut found: Option<&ObjRef> = None;
596 for asset in chosen {
597 let Some(asset) = asset else { return Ok(None) };
598 if self.pipeline_class(asset)? != URP_ASSET {
599 return Ok(None);
600 }
601 match found {
602 Some(f) if f != asset => return Ok(None),
603 _ => found = Some(asset),
604 }
605 }
606 Ok(found.cloned())
607 }
608
609 pub fn quality_levels(&self) -> Result<Vec<QualityLevel>> {
613 match self.quality_settings()? {
614 Some(settings) => levels_of(&settings),
615 None => Ok(Vec::new()),
616 }
617 }
618
619 pub fn build_quality(&self) -> Result<Option<String>> {
625 let Some(settings) = self.quality_settings()? else {
626 return Ok(None);
627 };
628 current_level(&settings, &levels_of(&settings)?)
629 }
630
631 fn quality_settings(&self) -> Result<Option<Value>> {
633 const FILE: &str = "globalgamemanagers";
634 let Some(f) = self.files.get(FILE) else {
635 return Ok(None);
636 };
637 let mut found = None;
638 for o in &f.objects {
639 let r = ObjRef {
640 file: FILE.into(),
641 path_id: o.path_id,
642 };
643 if self.class_name(&r) == Some("QualitySettings") {
644 if found.is_some() {
645 return Err(invalid(format!("{FILE}: more than one QualitySettings")));
646 }
647 found = Some(self.decode(&r)?);
648 }
649 }
650 Ok(found)
651 }
652
653 pub fn resolve(&self, file: &str, pptr: &Value) -> Result<Option<ObjRef>> {
655 let fid = pptr
656 .get("m_FileID")
657 .and_then(Value::as_i64)
658 .ok_or_else(|| invalid("PPtr without m_FileID"))?;
659 let pid = pptr
660 .get("m_PathID")
661 .and_then(Value::as_i64)
662 .ok_or_else(|| invalid("PPtr without m_PathID"))?;
663 if pid == 0 {
664 return Ok(None);
665 }
666 let target = if fid == 0 {
667 file.to_owned()
668 } else {
669 let f = self.file(file)?;
670 let ext = fid
671 .checked_sub(1)
672 .and_then(|i| usize::try_from(i).ok())
673 .and_then(|i| f.externals.get(i))
674 .ok_or_else(|| {
675 invalid(format!(
676 "{file}: PPtr file id {fid} of {} externals",
677 f.externals.len()
678 ))
679 })?;
680 file_key(&ext.path)
681 };
682 Ok(Some(ObjRef {
683 file: target,
684 path_id: pid,
685 }))
686 }
687}
688
689fn levels_of(settings: &Value) -> Result<Vec<QualityLevel>> {
691 const FILE: &str = "globalgamemanagers";
692 let levels = match settings.get("m_QualitySettings") {
693 Some(Value::Array(Array::Values(levels))) => levels.as_slice(),
694 Some(Value::Array(a)) if a.is_empty() => &[],
695 _ => {
696 return Err(invalid(format!(
697 "{FILE}: QualitySettings without m_QualitySettings"
698 )));
699 }
700 };
701 levels
702 .iter()
703 .map(|q| {
704 let name = q
705 .get("name")
706 .and_then(Value::as_str)
707 .ok_or_else(|| invalid("a quality level without a name"))?
708 .to_owned();
709 let weights = q
710 .get("skinWeights")
711 .and_then(Value::as_i64)
712 .ok_or_else(|| invalid(format!("quality level {name}: no skinWeights")))?;
713 let field = |k: &str| -> Result<&Value> {
714 q.get(k)
715 .ok_or_else(|| invalid(format!("quality level {name}: no {k}")))
716 };
717 Ok(QualityLevel {
718 skin_weights: SkinWeights::from_setting(weights)?,
719 shadows: field("shadows")?
720 .as_i64()
721 .ok_or_else(|| invalid(format!("quality level {name}: shadows is not a number")))?,
722 shadow_distance: field("shadowDistance")?.as_f32().ok_or_else(|| {
723 invalid(format!("quality level {name}: shadowDistance is not a number"))
724 })?,
725 shadow_cascades: field("shadowCascades")?.as_i64().ok_or_else(|| {
726 invalid(format!("quality level {name}: shadowCascades is not a number"))
727 })?,
728 name,
729 })
730 })
731 .collect()
732}
733
734fn current_level(settings: &Value, levels: &[QualityLevel]) -> Result<Option<String>> {
736 let Some(current) = settings.get("m_CurrentQuality") else {
737 return Ok(None);
738 };
739 let index = current
740 .as_i64()
741 .ok_or_else(|| invalid("QualitySettings: m_CurrentQuality is not a number"))?;
742 usize::try_from(index)
743 .ok()
744 .and_then(|i| levels.get(i))
745 .map(|level| Some(level.name.clone()))
746 .ok_or_else(|| {
747 invalid(format!(
748 "QualitySettings: m_CurrentQuality {index} is not one of its {} levels",
749 levels.len()
750 ))
751 })
752}
753
754#[cfg(test)]
755mod tests {
756 use std::sync::Arc;
757
758 use super::*;
759
760 fn level(name: &str) -> QualityLevel {
761 QualityLevel {
762 name: name.to_owned(),
763 skin_weights: SkinWeights::from_setting(4).expect("a skin weights setting"),
764 shadows: 2,
765 shadow_distance: 50.0,
766 shadow_cascades: 4,
767 }
768 }
769
770 fn settings(current: Option<Value>) -> Value {
771 Value::Struct(
772 current
773 .map(|v| (Arc::<str>::from("m_CurrentQuality"), v))
774 .into_iter()
775 .collect(),
776 )
777 }
778
779 #[test]
782 fn the_build_quality_is_the_level_m_current_quality_indexes() {
783 let levels = [level("Low"), level("Mid"), level("High")];
784 let name = |v| current_level(&settings(Some(v)), &levels);
785 assert_eq!(name(Value::Int(2)).unwrap().as_deref(), Some("High"));
786 assert_eq!(name(Value::UInt(0)).unwrap().as_deref(), Some("Low"));
787 assert_eq!(current_level(&settings(None), &levels).unwrap(), None);
788 for out in [Value::Int(3), Value::Int(-1), Value::UInt(99)] {
789 let error = name(out).unwrap_err().to_string();
790 assert!(error.contains("m_CurrentQuality"), "{error}");
791 }
792 assert!(name(Value::String("2".into())).is_err());
793 assert!(current_level(&settings(Some(Value::Int(0))), &[]).is_err());
794 }
795
796 fn empty_file(version: u32) -> Vec<u8> {
799 let mut metadata = b"2019.4.20f1\0".to_vec();
800 metadata.extend_from_slice(&19_i32.to_le_bytes());
801 metadata.push(0);
802 metadata.extend_from_slice(&[0; 20]);
804 metadata.push(0);
805 let header = if version < 22 { 20 } else { 48 };
806 let size = header + metadata.len();
807 let mut out = vec![0; header];
808 out[8..12].copy_from_slice(&version.to_be_bytes());
809 if version < 22 {
810 out[0..4].copy_from_slice(&(metadata.len() as u32).to_be_bytes());
811 out[4..8].copy_from_slice(&(size as u32).to_be_bytes());
812 out[12..16].copy_from_slice(&(size as u32).to_be_bytes());
813 } else {
814 out[20..24].copy_from_slice(&(metadata.len() as u32).to_be_bytes());
815 out[24..32].copy_from_slice(&(size as u64).to_be_bytes());
816 out[32..40].copy_from_slice(&(size as u64).to_be_bytes());
817 }
818 out.extend(metadata);
819 out
820 }
821
822 fn scratch(name: &str) -> PathBuf {
823 let dir = std::env::temp_dir().join(format!("unity-formats-{name}-{}", std::process::id()));
824 let _ = std::fs::remove_dir_all(&dir);
825 std::fs::create_dir_all(dir.join("Resources")).unwrap();
826 dir
827 }
828
829 #[test]
832 fn a_file_of_an_unread_version_is_refused_not_absent() {
833 for version in [20, 23] {
834 let dir = scratch(&format!("version{version}"));
835 std::fs::write(dir.join("level0"), empty_file(21)).unwrap();
836 std::fs::write(dir.join("sharedassets0.assets.resS"), [7u8; 100]).unwrap();
837 assert_eq!(
838 Player::open(&dir, TypeTreePackage::empty()).unwrap().files.len(),
839 1
840 );
841 std::fs::write(dir.join("level1"), empty_file(version)).unwrap();
842 let e = Player::open(&dir, TypeTreePackage::empty()).unwrap_err();
843 assert!(matches!(e, Error::Unsupported(_)), "{e}");
844 let e = e.to_string();
845 assert!(
846 e.contains("level1") && e.contains(&format!("version {version}")),
847 "{e}"
848 );
849 std::fs::remove_dir_all(&dir).unwrap();
850 }
851 }
852
853 #[test]
855 fn two_files_with_one_name_are_refused() {
856 let dir = scratch("duplicate");
857 std::fs::write(dir.join("resources.assets"), empty_file(21)).unwrap();
858 std::fs::write(dir.join("Resources").join("Resources.assets"), empty_file(22)).unwrap();
859 let e = Player::open(&dir, TypeTreePackage::empty())
860 .unwrap_err()
861 .to_string();
862 assert!(e.contains("both the player's file \"resources.assets\""), "{e}");
863 std::fs::remove_dir_all(&dir).unwrap();
864 }
865}