unity-formats 0.1.0

Reads Unity games' data files: SerializedFiles, scenes, meshes, textures, shader programs and Animator data
Documentation
//! The SerializedFile container: what `globalgamemanagers`, `*.assets` and `levelN` are.
//!
//! Layout (container versions 21 and 22): a big-endian header followed by metadata in the file's
//! endianness: Unity version, target platform, whether type trees are embedded, the types (class id, stripped,
//! script index, hashes), the object table (path id, data offset, size, type index), script references, external
//! files, reference types and a user string. Objects' bytes follow at the data offset.
//! Version 21 stores metadata size, file size and data offset as 32-bit header fields, followed by an
//! endianness byte and three reserved bytes; metadata starts at byte 20. Version 22 repeats those sizes
//! after the legacy fields as 32/64/64-bit numbers plus eight reserved bytes; metadata starts at byte 48.
//! Object offsets are unsigned 32-bit in version 21 and signed 64-bit in version 22.
//!
//! Only versions verified against real data are read; others are refused, as are embedded type trees (player
//! builds strip them; decode with a [`TypeTreePackage`] instead).

use std::collections::BTreeMap;

use crate::reader::Reader;
use crate::tpk::TypeTreePackage;
use crate::typetree::TypeTree;
use crate::value::Value;
use crate::version::UnityVersion;
use crate::{Error, Result, invalid};

/// Container versions read (verified on real files).
pub const VERSIONS: std::ops::RangeInclusive<u32> = 21..=22;

/// The MonoBehaviour class: its type tree covers the base fields; the script's own fields follow.
pub const MONO_BEHAVIOUR: i32 = 114;

/// One entry of a file's type table: the class the objects of this type are of, as the file declares it.
#[derive(Clone, Debug, PartialEq)]
pub struct SerializedType {
    /// Unity's class id (1 GameObject, 4 Transform, 114 MonoBehaviour, ...).
    pub class_id: i32,
    /// Whether the build stripped the class's type tree.
    pub stripped: bool,
    /// Index into the script references (-1: none).
    pub script_type_index: i16,
    /// The script's 16-byte hash: present for a MonoBehaviour type, and for a reference type whose script index is
    /// not -1.
    pub script_id: Option<[u8; 16]>,
    /// The 16-byte hash the file stores for the class's layout.
    pub old_type_hash: [u8; 16],
}

/// One entry of the object table.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ObjectInfo {
    /// The object's id within its file; unique per file.
    pub path_id: i64,
    /// The object's bytes: offset in the file and size.
    pub offset: u64,
    /// The object's size in bytes.
    pub size: u32,
    /// Index into [`SerializedFile::types`].
    pub type_index: u32,
    /// The class id of the object's type.
    pub class_id: i32,
}

/// Another file this one's pointers can name (file id = index + 1).
#[derive(Clone, Debug, PartialEq)]
pub struct External {
    /// The external file's 16-byte GUID as stored.
    pub guid: [u8; 16],
    /// The file's kind as stored (`type`).
    pub kind: i32,
    /// The external file's path as stored; [`crate::player::Player::resolve`] names the file by its last path segment,
    /// lower-cased.
    pub path: String,
}

/// A decoded object: its value, and the bytes after what its type tree covers.
#[derive(Clone, Debug, PartialEq)]
pub struct Decoded {
    /// The decoded object.
    pub value: Value,
    /// How many of the object's bytes lie after what the type tree covers.
    pub rest: usize,
}

impl Decoded {
    /// Whether the tree covered the object: every byte, or a MonoBehaviour's base fields (its script's fields follow,
    /// and a stripped build has no type trees for them).
    pub fn covered(&self, class_id: i32) -> bool {
        self.rest == 0 || class_id == MONO_BEHAVIOUR
    }
}

/// A parsed SerializedFile: its header fields, type table, object table, script references and external files, with
/// the file's bytes kept so objects can be read from it.
#[derive(Clone, Debug)]
pub struct SerializedFile {
    /// The container version (21 or 22: see [`VERSIONS`]).
    pub version: u32,
    /// The Unity version string the file was built with (`2019.4.20f1`).
    pub unity_version: String,
    /// [`SerializedFile::unity_version`], parsed.
    pub unity: UnityVersion,
    /// Unity's `BuildTarget` number the file was built for.
    pub target_platform: i32,
    /// Whether the metadata and the objects' bytes are big-endian.
    pub big_endian: bool,
    /// The type table.
    pub types: Vec<SerializedType>,
    /// The object table, in file order.
    pub objects: Vec<ObjectInfo>,
    /// Script references: (file index, path id).
    pub script_types: Vec<(i32, i64)>,
    /// The files this file's pointers can name (file id = index + 1).
    pub externals: Vec<External>,
    /// The table of types of managed references (`[SerializeReference]`).
    pub ref_types: Vec<SerializedType>,
    /// The user string at the end of the metadata.
    pub user_information: String,
    data: Vec<u8>,
    by_path_id: BTreeMap<i64, usize>,
}

fn hash(r: &mut Reader) -> Result<[u8; 16]> {
    Ok(r.take(16)?.try_into().expect("16 bytes"))
}

fn count(r: &mut Reader, what: &str) -> Result<usize> {
    let n = r.i32()?;
    let n = usize::try_from(n).map_err(|_| invalid(format!("negative {what} count {n}")))?;
    if n > r.remaining() {
        return Err(invalid(format!("{what} count {n} larger than the metadata")));
    }
    Ok(n)
}

fn serialized_type(r: &mut Reader, reference: bool) -> Result<SerializedType> {
    let class_id = r.i32()?;
    let stripped = r.bool()?;
    let script_type_index = r.i16()?;
    let script_id = if (reference && script_type_index >= 0) || (!reference && class_id == MONO_BEHAVIOUR) {
        Some(hash(r)?)
    } else {
        None
    };
    let old_type_hash = hash(r)?;
    Ok(SerializedType {
        class_id,
        stripped,
        script_type_index,
        script_id,
        old_type_hash,
    })
}

impl SerializedFile {
    /// Whether `bytes` start like a SerializedFile of a supported version (to tell them from other files).
    pub fn looks_like(bytes: &[u8]) -> bool {
        let be = |at: usize| -> Option<u32> {
            Some(u32::from_be_bytes(
                bytes.get(at..at.checked_add(4)?)?.try_into().ok()?,
            ))
        };
        let Ok(len) = u64::try_from(bytes.len()) else {
            return false;
        };
        let check = || -> Option<bool> {
            Some(match be(8)? {
                21 => {
                    let end = 20u64.checked_add(u64::from(be(0)?))?;
                    let (size, offset) = (u64::from(be(4)?), u64::from(be(12)?));
                    be(0)? > 0 && size == len && end <= offset && offset <= size
                }
                22 => len >= 48 && be(0)? == 0,
                _ => false,
            })
        };
        check().unwrap_or(false)
    }

    /// The container version of `bytes` when they are a SerializedFile of any version, supported or not: a supported
    /// version by its field alone (a damaged file of it is for [`SerializedFile::parse`] to refuse), another version
    /// only when the header's own size fields agree with `bytes.len()`, so that other files (streamed resources,
    /// configs) are not taken for one. `None` for bytes that are no SerializedFile.
    pub fn container_version(bytes: &[u8]) -> Option<u32> {
        let be = |at: usize| -> Option<u32> {
            Some(u32::from_be_bytes(
                bytes.get(at..at.checked_add(4)?)?.try_into().ok()?,
            ))
        };
        let len = u64::try_from(bytes.len()).ok()?;
        let version = be(8)?;
        if VERSIONS.contains(&version) {
            return Some(version);
        }
        let consistent = if version < *VERSIONS.start() {
            // the legacy header: metadata size, file size, version, data offset
            version > 0 && u64::from(be(4)?) == len && u64::from(be(12)?) <= len
        } else {
            // the header of 22 and later: the legacy fields are zero, the file size follows the metadata size
            be(0)? == 0
                && be(4)? == 0
                && be(12)? == 0
                && u64::from_be_bytes(bytes.get(24..32)?.try_into().ok()?) == len
        };
        consistent.then_some(version)
    }

    /// Parse the SerializedFile `data`. The header's sizes must match `data`, the object table must lie inside it and
    /// the metadata must end exactly where the header says; a version outside [`VERSIONS`], a file with embedded type
    /// trees, a duplicate path id and every length that runs past the bytes are refused with an [`Error`] that names
    /// what was found.
    pub fn parse(data: Vec<u8>) -> Result<Self> {
        let mut h = Reader::new(&data, true);
        let legacy_metadata = h.u32()?;
        let legacy_size = h.u32()?;
        let version = h.u32()?;
        let legacy_offset = h.u32()?;
        if !VERSIONS.contains(&version) {
            return Err(Error::Unsupported(format!(
                "SerializedFile version {version} (read: {}..={})",
                VERSIONS.start(),
                VERSIONS.end()
            )));
        }
        let big_endian = h.bool()?;
        h.take(3)?;
        let (metadata_size, file_size, data_offset) = if version == 22 {
            let metadata_size = h.u32()?;
            let file_size = h.u64()?;
            let data_offset = h.u64()?;
            let _reserved = h.u64()?;
            (metadata_size, file_size, data_offset)
        } else {
            (legacy_metadata, u64::from(legacy_size), u64::from(legacy_offset))
        };
        if file_size != data.len() as u64 {
            return Err(invalid(format!(
                "header file size {file_size}, file has {} bytes",
                data.len()
            )));
        }
        let meta_end = h
            .pos
            .checked_add(metadata_size as usize)
            .ok_or_else(|| invalid("metadata size overflows its byte range"))?;
        if meta_end as u64 > data_offset || data_offset > file_size {
            return Err(invalid(format!(
                "metadata ends at {meta_end}, data at {data_offset}, file size {file_size}"
            )));
        }
        let metadata = data.get(..meta_end).ok_or_else(|| {
            invalid(format!(
                "metadata ends at {meta_end}, past the file's {} bytes",
                data.len()
            ))
        })?;
        let mut r = Reader::new(metadata, big_endian);
        r.pos = h.pos;
        let unity_version = r.cstr()?;
        let unity = UnityVersion::parse(&unity_version)?;
        let target_platform = r.i32()?;
        if r.bool()? {
            return Err(Error::Unsupported(
                "embedded type trees (decode stripped player files with a type tree package)".into(),
            ));
        }
        let types = (0..count(&mut r, "type")?)
            .map(|_| serialized_type(&mut r, false))
            .collect::<Result<Vec<_>>>()?;
        let mut objects = Vec::new();
        for _ in 0..count(&mut r, "object")? {
            r.align(4)?;
            let path_id = r.i64()?;
            let start = if version == 21 {
                i64::from(r.u32()?)
            } else {
                r.i64()?
            };
            let size = r.u32()?;
            let type_index = r.u32()?;
            let ty = types.get(type_index as usize).ok_or_else(|| {
                invalid(format!(
                    "object {path_id}: type index {type_index} of {}",
                    types.len()
                ))
            })?;
            let offset = u64::try_from(start)
                .ok()
                .and_then(|s| s.checked_add(data_offset))
                .filter(|&o| o.checked_add(u64::from(size)).is_some_and(|end| end <= file_size))
                .ok_or_else(|| invalid(format!("object {path_id}: bytes {start}+{size} outside the file")))?;
            objects.push(ObjectInfo {
                path_id,
                offset,
                size,
                type_index,
                class_id: ty.class_id,
            });
        }
        let mut script_types = Vec::new();
        for _ in 0..count(&mut r, "script")? {
            let file = r.i32()?;
            r.align(4)?;
            script_types.push((file, r.i64()?));
        }
        let mut externals = Vec::new();
        for _ in 0..count(&mut r, "external")? {
            let _empty = r.cstr()?;
            let guid = hash(&mut r)?;
            let kind = r.i32()?;
            let path = r.cstr()?;
            externals.push(External { guid, kind, path });
        }
        let ref_types = (0..count(&mut r, "reference type")?)
            .map(|_| serialized_type(&mut r, true))
            .collect::<Result<Vec<_>>>()?;
        let user_information = r.cstr()?;
        if r.pos != meta_end {
            return Err(invalid(format!(
                "metadata read to {}, header says it ends at {meta_end}",
                r.pos
            )));
        }
        let mut by_path_id = BTreeMap::new();
        for (i, o) in objects.iter().enumerate() {
            if by_path_id.insert(o.path_id, i).is_some() {
                return Err(invalid(format!("path id {} twice", o.path_id)));
            }
        }
        Ok(SerializedFile {
            version,
            unity_version,
            unity,
            target_platform,
            big_endian,
            types,
            objects,
            script_types,
            externals,
            ref_types,
            user_information,
            data,
            by_path_id,
        })
    }

    /// The object with this path id, if the file has one.
    pub fn object(&self, path_id: i64) -> Option<&ObjectInfo> {
        self.by_path_id.get(&path_id).and_then(|&i| self.objects.get(i))
    }

    /// The bytes of an object of this file. `o` should come from this file's own [`SerializedFile::objects`]: an
    /// [`ObjectInfo`] of another file that names bytes outside this one is an error naming the object and its range.
    pub fn bytes(&self, o: &ObjectInfo) -> Result<&[u8]> {
        let outside = || {
            invalid(format!(
                "object {}: bytes {}+{} are outside the file's {} bytes",
                o.path_id,
                o.offset,
                o.size,
                self.data.len()
            ))
        };
        let start = usize::try_from(o.offset).map_err(|_| outside())?;
        let end = start
            .checked_add(usize::try_from(o.size).map_err(|_| outside())?)
            .ok_or_else(outside)?;
        self.data.get(start..end).ok_or_else(outside)
    }

    /// Decode an object's bytes from its start with `tree` (its class's type tree), reporting the bytes after what the
    /// tree covers in [`Decoded::rest`] (see [`Decoded::covered`]).
    pub fn decode_with(&self, o: &ObjectInfo, tree: &TypeTree) -> Result<Decoded> {
        let bytes = self.bytes(o)?;
        let (value, used) = tree
            .decode(bytes, self.big_endian)
            .map_err(|e| invalid(format!("object {} (class {}): {e}", o.path_id, o.class_id)))?;
        let rest = bytes.len().checked_sub(used).ok_or_else(|| {
            invalid(format!(
                "object {} (class {}): decoded {used} of {} bytes",
                o.path_id,
                o.class_id,
                bytes.len()
            ))
        })?;
        Ok(Decoded { value, rest })
    }

    /// Decode an object with its class's type tree from `package`. The tree must cover the object's bytes, but for a
    /// MonoBehaviour, whose script fields follow its base fields.
    pub fn decode(&self, o: &ObjectInfo, package: &TypeTreePackage) -> Result<Decoded> {
        let tree = package.type_tree(o.class_id, self.unity)?;
        let d = self.decode_with(o, &tree)?;
        if !d.covered(o.class_id) {
            return Err(invalid(format!(
                "object {} (class {}): {} bytes after its type tree",
                o.path_id, o.class_id, d.rest
            )));
        }
        Ok(d)
    }
}