diff --git a/src/game_engine/unity/il2cpp/assembly.rs b/src/game_engine/unity/il2cpp/assembly.rs deleted file mode 100644 index b683bc4d..00000000 --- a/src/game_engine/unity/il2cpp/assembly.rs +++ /dev/null @@ -1,33 +0,0 @@ -use super::{Image, Module}; -use crate::{string::ArrayCString, Address, Error, Process}; - -#[derive(Copy, Clone)] -pub(super) struct Assembly { - pub(super) assembly: Address, -} - -impl Assembly { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer( - self.assembly + module.offsets.assembly.aname, - module.pointer_size, - ) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_image(&self, process: &Process, module: &Module) -> Option { - process - .read_pointer( - self.assembly + module.offsets.assembly.image, - module.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null()) - .map(|image| Image { image }) - } -} diff --git a/src/game_engine/unity/il2cpp/builds.rs b/src/game_engine/unity/il2cpp/builds.rs new file mode 100644 index 00000000..028cf043 --- /dev/null +++ b/src/game_engine/unity/il2cpp/builds.rs @@ -0,0 +1,1078 @@ +//! Known IL2CPP builds. Each entry is one measured player. The version of +//! its `global-metadata.dat` and the full Unity version say which player. +//! The offsets come from that player's `GameAssembly.pdb`. + +use super::offsets::{ + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, IL2CPPOffsets, ImageOffsets, + TypeOffsets, +}; +use super::Version; +use crate::PointerSize; + +/// One measured IL2CPP player and the offsets from its PDB. +pub(super) struct Build { + pub(super) metadata: u32, + pub(super) unity: (u16, u16, u16, u16), + pub(super) pointer_size: PointerSize, + pub(super) version: Version, + pub(super) offsets: IL2CPPOffsets, +} + +/// Finds the build measured on this exact metadata version, Unity version +/// and width. `GameAssembly.dll` is compiled per game, so the Unity version +/// stands in for the binary. The Unity version is the four parts of +/// `UnityPlayer.dll`'s file version, and the last part tells `f1` from `f2` +/// and an alpha from its release. A player nobody measured gets no answer. +pub(super) fn find( + metadata: u32, + unity: (u16, u16, u16, u16), + pointer_size: PointerSize, +) -> Option<&'static Build> { + BUILDS.iter().find(|build| { + build.metadata == metadata && build.unity == unity && build.pointer_size == pointer_size + }) +} + +/// Whether some build was measured on this Unity version at this width, +/// whatever its metadata says. +pub(super) fn measured(unity: (u16, u16, u16, u16), pointer_size: PointerSize) -> bool { + BUILDS + .iter() + .any(|build| build.unity == unity && build.pointer_size == pointer_size) +} + +// The table reads from the oldest player to the newest. +static BUILDS: &[Build] = &[ + // Unity 2018.4.36f1, metadata version 24, x64. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 24, + unity: (2018, 4, 36, 54151), + pointer_size: PointerSize::Bit64, + version: Version::Base, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x8), + type_count: 0x1c, + metadata_handle: 0x18, + }, + class: ClassOffsets { + name: 0x10, + namespace: 0x18, + declaring_type: Some(0x50), + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + instance_size: Some(0xec), + field_count: 0x114, + }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x8), + offset: 0x18, + struct_size: 0x20, + }, + }, + }, + // Unity 2018.4.36f1, metadata version 24, x86. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 24, + unity: (2018, 4, 36, 54151), + pointer_size: PointerSize::Bit32, + version: Version::Base, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x4), + type_count: 0x10, + metadata_handle: 0xc, + }, + class: ClassOffsets { + name: 0x8, + namespace: 0xc, + declaring_type: Some(0x28), + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + instance_size: Some(0x84), + field_count: 0xac, + }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x4), + offset: 0xc, + struct_size: 0x14, + }, + }, + }, + // Unity 2019.4.41f2, metadata version 24, x64. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 24, + unity: (2019, 4, 41, 9172), + pointer_size: PointerSize::Bit64, + version: Version::V2019, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x8), + type_count: 0x1c, + metadata_handle: 0x18, + }, + class: ClassOffsets { + name: 0x10, + namespace: 0x18, + declaring_type: Some(0x50), + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + instance_size: Some(0xf4), + field_count: 0x11c, + }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x8), + offset: 0x18, + struct_size: 0x20, + }, + }, + }, + // Unity 2019.4.41f2, metadata version 24, x86. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 24, + unity: (2019, 4, 41, 9172), + pointer_size: PointerSize::Bit32, + version: Version::V2019, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x4), + type_count: 0x10, + metadata_handle: 0xc, + }, + class: ClassOffsets { + name: 0x8, + namespace: 0xc, + declaring_type: Some(0x28), + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + instance_size: Some(0x80), + field_count: 0xa8, + }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x4), + offset: 0xc, + struct_size: 0x14, + }, + }, + }, + // Unity 2020.1.18f1, metadata version 24, x64. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 24, + unity: (2020, 1, 18, 38512), + pointer_size: PointerSize::Bit64, + version: Version::V2019, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x8), + type_count: 0x1c, + metadata_handle: 0x18, + }, + class: ClassOffsets { + name: 0x10, + namespace: 0x18, + declaring_type: Some(0x50), + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + instance_size: Some(0xf4), + field_count: 0x11c, + }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x8), + offset: 0x18, + struct_size: 0x20, + }, + }, + }, + // Unity 2020.1.18f1, metadata version 24, x86. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 24, + unity: (2020, 1, 18, 38512), + pointer_size: PointerSize::Bit32, + version: Version::V2019, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x4), + type_count: 0x10, + metadata_handle: 0xc, + }, + class: ClassOffsets { + name: 0x8, + namespace: 0xc, + declaring_type: Some(0x28), + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + instance_size: Some(0x80), + field_count: 0xa8, + }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x4), + offset: 0xc, + struct_size: 0x14, + }, + }, + }, + // Unity 2021.3.11f1, metadata version 29, x64. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 29, + unity: (2021, 3, 11, 23713), + pointer_size: PointerSize::Bit64, + version: Version::V2020, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x8), + type_count: 0x18, + metadata_handle: 0x28, + }, + class: ClassOffsets { + name: 0x10, + namespace: 0x18, + declaring_type: Some(0x50), + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + instance_size: Some(0xf8), + field_count: 0x120, + }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x8), + offset: 0x18, + struct_size: 0x20, + }, + }, + }, + // Unity 2021.3.11f1, metadata version 29, x86. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 29, + unity: (2021, 3, 11, 23713), + pointer_size: PointerSize::Bit32, + version: Version::V2020, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x4), + type_count: 0xc, + metadata_handle: 0x18, + }, + class: ClassOffsets { + name: 0x8, + namespace: 0xc, + declaring_type: Some(0x28), + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + instance_size: Some(0x80), + field_count: 0xa8, + }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x4), + offset: 0xc, + struct_size: 0x14, + }, + }, + }, + // Unity 2022.3.0f1, metadata version 29, x64. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 29, + unity: (2022, 3, 0, 4507), + pointer_size: PointerSize::Bit64, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x8), + type_count: 0x18, + metadata_handle: 0x28, + }, + class: ClassOffsets { + name: 0x10, + namespace: 0x18, + declaring_type: Some(0x50), + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + instance_size: Some(0xf8), + field_count: 0x124, + }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x8), + offset: 0x18, + struct_size: 0x20, + }, + }, + }, + // Unity 2022.3.0f1, metadata version 29, x86. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 29, + unity: (2022, 3, 0, 4507), + pointer_size: PointerSize::Bit32, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x4), + type_count: 0xc, + metadata_handle: 0x18, + }, + class: ClassOffsets { + name: 0x8, + namespace: 0xc, + declaring_type: Some(0x28), + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + instance_size: Some(0x80), + field_count: 0xac, + }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x4), + offset: 0xc, + struct_size: 0x14, + }, + }, + }, + // Unity 2023.1.0f1, metadata version 29, x64. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 29, + unity: (2023, 1, 0, 2298), + pointer_size: PointerSize::Bit64, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x8), + type_count: 0x18, + metadata_handle: 0x28, + }, + class: ClassOffsets { + name: 0x10, + namespace: 0x18, + declaring_type: Some(0x50), + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + instance_size: Some(0xf8), + field_count: 0x124, + }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x8), + offset: 0x18, + struct_size: 0x20, + }, + }, + }, + // Unity 2023.1.0f1, metadata version 29, x86. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 29, + unity: (2023, 1, 0, 2298), + pointer_size: PointerSize::Bit32, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x4), + type_count: 0xc, + metadata_handle: 0x18, + }, + class: ClassOffsets { + name: 0x8, + namespace: 0xc, + declaring_type: Some(0x28), + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + instance_size: Some(0x80), + field_count: 0xac, + }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x4), + offset: 0xc, + struct_size: 0x14, + }, + }, + }, + // Unity 2023.1.22f1, metadata version 29, x64. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 29, + unity: (2023, 1, 22, 16744), + pointer_size: PointerSize::Bit64, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x8), + type_count: 0x18, + metadata_handle: 0x28, + }, + class: ClassOffsets { + name: 0x10, + namespace: 0x18, + declaring_type: Some(0x50), + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + instance_size: Some(0xf8), + field_count: 0x124, + }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x8), + offset: 0x18, + struct_size: 0x20, + }, + }, + }, + // Unity 2023.1.22f1, metadata version 29, x86. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 29, + unity: (2023, 1, 22, 16744), + pointer_size: PointerSize::Bit32, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x4), + type_count: 0xc, + metadata_handle: 0x18, + }, + class: ClassOffsets { + name: 0x8, + namespace: 0xc, + declaring_type: Some(0x28), + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + instance_size: Some(0x80), + field_count: 0xac, + }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x4), + offset: 0xc, + struct_size: 0x14, + }, + }, + }, + // Unity 6000.2.12f1, metadata version 31, x64. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 31, + unity: (6000, 2, 12, 40285), + pointer_size: PointerSize::Bit64, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x8), + type_count: 0x18, + metadata_handle: 0x28, + }, + class: ClassOffsets { + name: 0x10, + namespace: 0x18, + declaring_type: Some(0x50), + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + instance_size: Some(0xf8), + field_count: 0x124, + }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x8), + offset: 0x18, + struct_size: 0x20, + }, + }, + }, + // Unity 6000.2.12f1, metadata version 31, x86. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 31, + unity: (6000, 2, 12, 40285), + pointer_size: PointerSize::Bit32, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x4), + type_count: 0xc, + metadata_handle: 0x18, + }, + class: ClassOffsets { + name: 0x8, + namespace: 0xc, + declaring_type: Some(0x28), + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + instance_size: Some(0x80), + field_count: 0xac, + }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x4), + offset: 0xc, + struct_size: 0x14, + }, + }, + }, + // Unity 6000.3.21f1, metadata version 39, x64. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 39, + unity: (6000, 3, 21, 9777), + pointer_size: PointerSize::Bit64, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x8), + type_count: 0x18, + metadata_handle: 0x28, + }, + class: ClassOffsets { + name: 0x10, + namespace: 0x18, + declaring_type: Some(0x50), + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + instance_size: Some(0xf8), + field_count: 0x124, + }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x8), + offset: 0x18, + struct_size: 0x20, + }, + }, + }, + // Unity 6000.3.21f1, metadata version 39, x86. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 39, + unity: (6000, 3, 21, 9777), + pointer_size: PointerSize::Bit32, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x4), + type_count: 0xc, + metadata_handle: 0x18, + }, + class: ClassOffsets { + name: 0x8, + namespace: 0xc, + declaring_type: Some(0x28), + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + instance_size: Some(0x80), + field_count: 0xac, + }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x4), + offset: 0xc, + struct_size: 0x14, + }, + }, + }, + // Unity 6000.5.10f1, metadata version 107, x64. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 107, + unity: (6000, 5, 10, 54518), + pointer_size: PointerSize::Bit64, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x8), + type_count: 0x18, + metadata_handle: 0x28, + }, + class: ClassOffsets { + name: 0x10, + namespace: 0x18, + declaring_type: Some(0x50), + parent: 0x58, + fields: 0x80, + static_fields: 0xa0, + instance_size: Some(0xf8), + field_count: 0x124, + }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x8), + offset: 0x18, + struct_size: 0x20, + }, + }, + }, + // Unity 6000.5.10f1, metadata version 107, x86. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 107, + unity: (6000, 5, 10, 54518), + pointer_size: PointerSize::Bit32, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x4), + type_count: 0xc, + metadata_handle: 0x18, + }, + class: ClassOffsets { + name: 0x8, + namespace: 0xc, + declaring_type: Some(0x28), + parent: 0x2c, + fields: 0x40, + static_fields: 0x50, + instance_size: Some(0x80), + field_count: 0xac, + }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x4), + offset: 0xc, + struct_size: 0x14, + }, + }, + }, + // Unity 6000.7.0a3, metadata version 110, x64. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 110, + unity: (6000, 7, 0, 5476), + pointer_size: PointerSize::Bit64, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x8), + type_count: 0x18, + metadata_handle: 0x28, + }, + class: ClassOffsets { + name: 0x10, + namespace: 0x18, + declaring_type: Some(0x50), + parent: 0x58, + fields: 0x80, + static_fields: 0x98, + instance_size: Some(0xf0), + field_count: 0x11c, + }, + generic: GenericOffsets { + cached_class: Some(0x10), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x8), + offset: 0x18, + struct_size: 0x20, + }, + }, + }, + // Unity 6000.7.0a3, metadata version 110, x86. + // Offsets from the player's own GameAssembly.pdb. + Build { + metadata: 110, + unity: (6000, 7, 0, 5476), + pointer_size: PointerSize::Bit32, + version: Version::V2022, + offsets: IL2CPPOffsets { + assembly: AssemblyOffsets { + image: 0x0, + aname: None, + }, + image: ImageOffsets { + assembly_name: Some(0x4), + type_count: 0xc, + metadata_handle: 0x18, + }, + class: ClassOffsets { + name: 0x8, + namespace: 0xc, + declaring_type: Some(0x28), + parent: 0x2c, + fields: 0x40, + static_fields: 0x4c, + instance_size: Some(0x80), + field_count: 0xac, + }, + generic: GenericOffsets { + cached_class: Some(0x8), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, + field: FieldInfoOffsets { + name: 0x0, + type_: Some(0x4), + offset: 0xc, + struct_size: 0x14, + }, + }, + }, +]; + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::{find, IL2CPPOffsets, BUILDS}; + use crate::PointerSize; + + #[test] + fn table_reads_oldest_to_newest() { + assert!(BUILDS + .windows(2) + .all(|pair| (pair[0].metadata, pair[0].unity) <= (pair[1].metadata, pair[1].unity))); + } + + const UNITY_6000_3: (u16, u16, u16, u16) = (6000, 3, 21, 9777); + + // Every player was measured at both widths, so each version in the + // table has an x64 entry and an x86 entry. + #[test] + fn every_version_is_measured_at_both_widths() { + for build in BUILDS { + let other = match build.pointer_size { + PointerSize::Bit64 => PointerSize::Bit32, + _ => PointerSize::Bit64, + }; + assert!( + find(build.metadata, build.unity, other).is_some(), + "{:?} at {:?} has no twin", + build.unity, + other + ); + } + } + + #[test] + fn finds_exact_builds() { + let build = find(39, UNITY_6000_3, PointerSize::Bit64).unwrap(); + assert_eq!(build.metadata, 39); + assert_eq!(build.unity, UNITY_6000_3); + + let narrow = find(39, UNITY_6000_3, PointerSize::Bit32).unwrap(); + assert_eq!(narrow.metadata, 39); + assert_eq!(narrow.pointer_size, PointerSize::Bit32); + } + + // A build is one measured player. An identity off it in any part, the + // metadata version, any part of the Unity version, or the width, is a + // player nobody measured and gets no answer. + #[test] + fn identities_off_a_measured_build_answer_nothing() { + let (major, minor, patch, build) = UNITY_6000_3; + assert!(find(40, UNITY_6000_3, PointerSize::Bit64).is_none()); + assert!(find(39, (major, minor, patch, build + 1), PointerSize::Bit64).is_none()); + assert!(find(39, (major, minor, patch + 1, build), PointerSize::Bit64).is_none()); + assert!(find(39, (major, minor + 1, patch, build), PointerSize::Bit64).is_none()); + assert!(find(200, (7000, 0, 0, 0), PointerSize::Bit64).is_none()); + assert!(find(16, (5, 6, 7, 0), PointerSize::Bit64).is_none()); + } + + // A version table's value for any of the grown members must match every + // measured build it stands in for, or say nothing. + #[test] + fn version_tables_never_contradict_a_measured_build() { + fn agrees(table: Option, measured: Option) -> bool { + table.is_none() || table == measured + } + + for build in BUILDS { + let Some(table) = IL2CPPOffsets::new(build.version, build.pointer_size) else { + continue; + }; + assert!(agrees( + table.class.declaring_type, + build.offsets.class.declaring_type + )); + assert!(agrees( + table.class.instance_size, + build.offsets.class.instance_size + )); + assert!(agrees( + table.generic.cached_class, + build.offsets.generic.cached_class + )); + assert!(agrees(table.type_words.data, build.offsets.type_words.data)); + assert!(agrees(table.type_words.kind, build.offsets.type_words.kind)); + assert!(agrees(table.field.type_, build.offsets.field.type_)); + } + } + + // The version table for 6000.5 and 6000.7 puts static_fields where 2022.3 + // had it. Both measured players put it lower, and 6000.7 moves field_count + // too. + #[test] + fn unity_6000_5_builds_diverge_from_their_version_table_on_statics() { + for (metadata, unity, static_fields) in [ + (107, (6000, 5, 10, 54518), 0xA0), + (110, (6000, 7, 0, 5476), 0x98), + ] { + let build = find(metadata, unity, PointerSize::Bit64).unwrap(); + let table = IL2CPPOffsets::new(build.version, build.pointer_size).unwrap(); + assert_eq!(build.offsets.class.static_fields, static_fields); + assert_ne!(build.offsets.class.static_fields, table.class.static_fields); + } + + let build = find(110, (6000, 7, 0, 5476), PointerSize::Bit64).unwrap(); + let table = IL2CPPOffsets::new(build.version, build.pointer_size).unwrap(); + assert_ne!(build.offsets.class.field_count, table.class.field_count); + } +} diff --git a/src/game_engine/unity/il2cpp/class.rs b/src/game_engine/unity/il2cpp/class.rs index 4e1ba9c4..c42fb821 100644 --- a/src/game_engine/unity/il2cpp/class.rs +++ b/src/game_engine/unity/il2cpp/class.rs @@ -1,93 +1,17 @@ -use core::iter::{self, FusedIterator}; - -use super::{super::get_backing_name, Field, Module, CSTR}; -use crate::{future::retry, string::ArrayCString, Address, Error, Process}; +use super::super::managed::ClassRef; +use super::Module; +use crate::{future::retry, Address, Process}; #[cfg(feature = "derive")] pub use asr_derive::Il2cppClass as Class; -/// A .NET class that is part of an [`Image`](Image). +/// A .NET class that is part of an [`Image`](super::Image). #[derive(Copy, Clone)] pub struct Class { pub(super) class: Address, } impl Class { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer(self.class + module.offsets.class.name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_name_space( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer( - self.class + module.offsets.class.namespace, - module.pointer_size, - ) - .and_then(|addr| process.read(addr)) - } - - fn fields<'a>( - &'a self, - process: &'a Process, - module: &'a Module, - ) -> impl FusedIterator + 'a { - let mut this_class = Some(*self); - - iter::from_fn(move || { - let class = this_class?; - - if class - .get_name::(process, module) - .ok()? - .matches("Object") - || class - .get_name_space::(process, module) - .ok()? - .matches("UnityEngine") - { - return None; - } - - // Prepare for next iteration - this_class = class.get_parent(process, module); - - let field_count = process - .read::(class.class + module.offsets.class.field_count) - .ok() - .filter(|&val| val != u16::MAX) - .unwrap_or_default() as u64; - - let fields = match field_count { - 0 => None, - _ => process - .read_pointer( - class.class + module.offsets.class.fields, - module.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null()), - }; - - Some((0..field_count).filter_map(move |i| { - fields.map(|fields| Field { - field: fields + i.wrapping_mul(module.offsets.field.struct_size as _), - }) - })) - }) - .flatten() - .fuse() - } - /// Tries to find a field with the specified name in the class. This returns /// the offset of the field from the start of an instance of the class. If /// it's a static field, the offset will be from the start of the static @@ -98,20 +22,10 @@ impl Class { module: &Module, field_name: &str, ) -> Option { - self.fields(process, module) - .find(|field| { - field.get_name::(process, module).is_ok_and(|name| { - // If the name matches, return immediately - name.matches(field_name) - - // BackingField pattern: k__BackingField - || name.validate_utf8() - .ok() - .and_then(|name| get_backing_name(name)) - .is_some_and(|name| name == field_name) - }) - }) - .and_then(|field| field.get_offset(process, module)) + module + .walk() + .find_field_offset(process, ClassRef::new(self.class), field_name) + .map(|(_, offset)| offset) } /// Tries to find the address of a static instance of the class based on its @@ -122,44 +36,38 @@ impl Class { module: &Module, field_name: &str, ) -> Address { - let static_table = self.wait_get_static_table(process, module).await; - let field_offset = self - .wait_get_field_offset(process, module, field_name) - .await; - let singleton_location = static_table + field_offset; - + // The field's offset measures into the static table of whichever + // class declares it, which a climb may find on a parent. retry(|| { + let walk = module.walk(); + let (class, offset) = + walk.find_field_offset(process, ClassRef::new(self.class), field_name)?; + let static_table = walk.static_table(process, class)?; + process - .read_pointer(singleton_location, module.pointer_size) + .read_pointer(static_table + offset, module.pointer_size) .ok() .filter(|val| !val.is_null()) }) .await } - fn get_static_table_pointer(&self, module: &Module) -> Address { - self.class + module.offsets.class.static_fields - } - /// Returns the address of the static table of the class. This contains the /// values of all the static fields. pub fn get_static_table(&self, process: &Process, module: &Module) -> Option
{ - process - .read_pointer(self.get_static_table_pointer(module), module.pointer_size) - .ok() - .filter(|val| !val.is_null()) + module + .walk() + .static_table(process, ClassRef::new(self.class)) } /// Tries to find the parent class. pub fn get_parent(&self, process: &Process, module: &Module) -> Option { - process - .read_pointer( - self.class + module.offsets.class.parent, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()) - .map(|class| Class { class }) + module + .walk() + .parent(process, ClassRef::new(self.class)) + .map(|class| Class { + class: class.address, + }) } /// Tries to find a field with the specified name in the class. This returns diff --git a/src/game_engine/unity/il2cpp/collections_tests.rs b/src/game_engine/unity/il2cpp/collections_tests.rs new file mode 100644 index 00000000..750cc09a --- /dev/null +++ b/src/game_engine/unity/il2cpp/collections_tests.rs @@ -0,0 +1,159 @@ +//! Tests pinning dictionary resolution over hand-laid class metadata at the +//! literal offsets of the Unity 2019.4 IL2CPP runtime. The entry class is a +//! generic instance reached through the instantiation's cached class, which +//! is the route every corlib dictionary takes. + +use super::{IL2CPPOffsets, Module, Version}; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x60_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +// One field entry: the name heads it, the Il2CppType pointer sits at 0x8, +// the offset at 0x18. +fn field(image: &mut [u8], at: u64, name: u64, type_: u64, offset: i32) { + ptr(image, at, name); + if type_ != 0 { + ptr(image, at + 0x8, type_); + } + put(image, at + 0x18, &offset.to_le_bytes()); +} + +fn image(version: Version) -> Vec { + let field_count_at = match version { + Version::V2019 => 0x11C, + _ => 0x124, + }; + let mut i = vec![0; 0x2000]; + + let strings = [ + (0x1800, "_buckets"), + (0x1840, "_entries"), + (0x1880, "_count"), + (0x18C0, "_freeCount"), + (0x1900, "hashCode"), + (0x1940, "next"), + (0x1980, "key"), + (0x19C0, "value"), + ]; + for (at, text) in strings { + put(&mut i, at, text.as_bytes()); + } + + // The slot, the dictionary object heading with its class, and the class's + // four fields: field_count at +0x11C, fields behind +0x80. + ptr(&mut i, 0x0, BASE + 0x100); + ptr(&mut i, 0x100, BASE + 0x200); + put(&mut i, 0x200 + field_count_at, &4_u16.to_le_bytes()); + ptr(&mut i, 0x200 + 0x80, BASE + 0x340); + field(&mut i, 0x340, BASE + 0x1800, 0, 0x10); + field(&mut i, 0x360, BASE + 0x1840, BASE + 0x500, 0x18); + field(&mut i, 0x380, BASE + 0x1880, 0, 0x20); + field(&mut i, 0x3A0, BASE + 0x18C0, 0, 0x24); + + // The entries field's type: a SzArray whose data is the element's own + // type, a generic instance whose descriptor caches the entry class. + ptr(&mut i, 0x500, BASE + 0x550); + put(&mut i, 0x50A, &[0x1D]); // SzArray + ptr(&mut i, 0x550, BASE + 0x580); + put(&mut i, 0x55A, &[0x15]); // GenericInst + ptr(&mut i, 0x580 + 0x18, BASE + 0x600); // descriptor: cached_class + + // The entry class: instance_size at +0xF4, field_count at +0x11C, four + // members at their boxed-frame offsets. + put(&mut i, 0x600 + 0xF4, &0x20_i32.to_le_bytes()); + put(&mut i, 0x600 + field_count_at, &4_u16.to_le_bytes()); + ptr(&mut i, 0x600 + 0x80, BASE + 0x740); + field(&mut i, 0x740, BASE + 0x1900, 0, 0x10); + field(&mut i, 0x760, BASE + 0x1940, 0, 0x14); + field(&mut i, 0x780, BASE + 0x1980, 0, 0x18); + field(&mut i, 0x7A0, BASE + 0x19C0, 0, 0x1C); + + // The dictionary's live state: three counted entries over a four-slot + // backing, the middle one freed, so two pairs are live. + ptr(&mut i, 0x118, BASE + 0x800); + put(&mut i, 0x120, &3_i32.to_le_bytes()); + put(&mut i, 0x124, &1_i32.to_le_bytes()); + put(&mut i, 0x818, &4_u32.to_le_bytes()); + for (index, entry) in [(1111, -1, 10, 100), (-1, -1, 99, 999), (2222, -1, 20, 200)] + .into_iter() + .enumerate() + { + let at = 0x820 + 0x10 * index as u64; + let (hash, next, key, value): (i32, i32, i32, i32) = entry; + for (word, value) in [hash, next, key, value].into_iter().enumerate() { + put(&mut i, at + 4 * word as u64, &value.to_le_bytes()); + } + } + + i +} + +fn module(version: Version) -> Module { + Module { + assemblies: Address::new(BASE), + type_info_definition_table: Address::new(BASE + 0x40), + version, + offsets: IL2CPPOffsets::new(version, PointerSize::Bit64).unwrap(), + pointer_size: PointerSize::Bit64, + } +} + +fn on_fixture(version: Version, test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image(version))], |process| { + test(process, &module(version)); + }); +} + +#[test] +fn dictionaries_resolve_through_the_cached_class() { + on_fixture(Version::V2019, |process, module| { + let offsets = module + .get_dictionary_offsets(process, Address::new(BASE)) + .unwrap(); + assert_eq!(offsets.entries, 0x18); + assert_eq!(offsets.count, 0x20); + assert_eq!(offsets.free_count, 0x24); + assert_eq!(offsets.layout.stride, 0x10); + assert_eq!(offsets.layout.hash, 0x0); + assert_eq!(offsets.layout.next, 0x4); + assert_eq!(offsets.layout.key, 0x8); + assert_eq!(offsets.layout.value, 0xC); + }); +} + +// The 2022.2-and-later fallback table carries no cached class, because the +// measured builds inside that stretch disagree. A fallback attach misses +// cleanly; known builds carry their own value. +#[test] +fn dictionaries_read_their_live_pairs() { + on_fixture(Version::V2019, |process, module| { + let slot = Address::new(BASE); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + let pairs = module + .read_dictionary::(process, offsets, slot) + .unwrap(); + assert_eq!(pairs.as_slice(), [(10, 100), (20, 200)]); + }); +} + +#[test] +fn fallback_tables_without_a_cached_class_answer_nothing() { + on_fixture(Version::V2022, |process, module| { + assert!(module + .get_dictionary_offsets(process, Address::new(BASE)) + .is_none()); + }); +} diff --git a/src/game_engine/unity/il2cpp/field.rs b/src/game_engine/unity/il2cpp/field.rs deleted file mode 100644 index d9a5587d..00000000 --- a/src/game_engine/unity/il2cpp/field.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::{string::ArrayCString, Address, Error, Process}; - -use super::Module; - -#[derive(Copy, Clone)] -pub(super) struct Field { - pub(super) field: Address, -} - -impl Field { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer(self.field + module.offsets.field.name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_offset(&self, process: &Process, module: &Module) -> Option { - process.read(self.field + module.offsets.field.offset).ok() - } -} diff --git a/src/game_engine/unity/il2cpp/image.rs b/src/game_engine/unity/il2cpp/image.rs index eeed4b9d..0741b8b0 100644 --- a/src/game_engine/unity/il2cpp/image.rs +++ b/src/game_engine/unity/il2cpp/image.rs @@ -1,5 +1,5 @@ -use super::CSTR; -use super::{Class, Module, Version}; +use super::super::managed::{slot, ImageRef}; +use super::{Class, Module}; use crate::{future::retry, Address, Process}; /// An image is a .NET DLL that is loaded by the game. The `Assembly-CSharp` @@ -16,48 +16,18 @@ impl Image { process: &'a Process, module: &'a Module, ) -> impl DoubleEndedIterator + 'a { - let type_count = process - .read::(self.image + module.offsets.image.type_count) - .unwrap_or_default() as u64; - - let metadata_ptr = match (type_count, module.version) { - (0, _) => Address::NULL, - (_, Version::Base | Version::V2019) => { - self.image + module.offsets.image.matadata_handle - } - (_, _) => process - .read_pointer( - self.image + module.offsets.image.matadata_handle, - module.pointer_size, - ) - .unwrap_or_default(), - }; - - let metadata_handle = match metadata_ptr { - Address::NULL => 0, - handle => process.read::(handle).unwrap_or_default(), - }; - - let type_info_definition_table = match metadata_ptr { - Address::NULL => Address::NULL, - _ => process - .read_pointer(module.type_info_definition_table, module.pointer_size) - .unwrap_or_default(), - }; - - let ptr = match type_info_definition_table { - Address::NULL => Address::NULL, - _ => { - type_info_definition_table + module.size_of_ptr().wrapping_mul(metadata_handle as _) - } - }; - - (0..type_count).filter_map(move |i| { + let walk = module.walk(); + let pointer_size = walk.pointer_size; + // The runtime built by an IL2CPP module always answers the slot form. + let (slots, count) = walk + .runtime + .classes(process, pointer_size, ImageRef::new(self.image)) + .slots() + .unwrap_or((Address::NULL, 0)); + + (0..count).filter_map(move |i| { process - .read_pointer( - ptr + module.size_of_ptr().wrapping_mul(i), - module.pointer_size, - ) + .read_pointer(slot(slots, pointer_size, i), pointer_size) .ok() .filter(|val| !val.is_null()) .map(|class| Class { class }) @@ -66,23 +36,12 @@ impl Image { /// Tries to find the specified [.NET class](struct@Class) in the image. pub fn get_class(&self, process: &Process, module: &Module, class_name: &str) -> Option { - let name_space_index = class_name.rfind('.'); - - self.classes(process, module).find(|class| { - class.get_name::(process, module).is_ok_and(|name| { - if let Some(name_space_index) = name_space_index { - let class_name_space = &class_name[..name_space_index]; - let class_name = &class_name[name_space_index + 1..]; - - name.matches(class_name) - && class - .get_name_space::(process, module) - .is_ok_and(|name_space| name_space.matches(class_name_space)) - } else { - name.matches(class_name) - } + module + .walk() + .find_class(process, ImageRef::new(self.image), class_name) + .map(|class| Class { + class: class.address, }) - }) } /// Tries to find the specified [.NET class](struct@Class) in the image. diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 06995397..44028306 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -1,23 +1,33 @@ //! Support for attaching to Unity games that are using the IL2CPP backend. -use crate::{file_format::pe, future::retry, signature::Signature, Address, PointerSize, Process}; +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; -mod assembly; -use assembly::Assembly; +use crate::{ + file_format::pe, future::retry, print_limited, signature::Signature, string::ArrayWString, + Address, Error, PointerSize, Process, +}; + +mod builds; mod image; pub use image::Image; mod class; pub use class::Class; -mod field; -use field::Field; mod version; pub use version::Version; mod pointer; pub use pointer::UnityPointer; mod offsets; use offsets::IL2CPPOffsets; +#[cfg(all(test, not(target_family = "wasm")))] +mod collections_tests; +#[cfg(all(test, not(target_family = "wasm")))] +mod readers_tests; +#[cfg(all(test, not(target_family = "wasm")))] +mod walk_tests; -use super::CSTR; +use super::managed; +pub use super::managed::{DictionaryOffsets, ListOffsets}; /// Represents access to a Unity game that is using the IL2CPP backend. pub struct Module { @@ -29,11 +39,46 @@ pub struct Module { } impl Module { - /// Tries attaching to a Unity game that is using the IL2CPP backend. This - /// function automatically detects the [IL2CPP version](Version). If you - /// know the version in advance or it fails detecting it, use - /// [`attach`](Self::attach) instead. + /// Tries attaching to a Unity game that is using the IL2CPP backend. If + /// the game's metadata and Unity versions name a measured build, this + /// function uses that build's offsets. Otherwise this function detects + /// the [IL2CPP version](Version). If you know the version in advance or + /// it fails detecting it, use [`attach`](Self::attach) instead. pub fn attach_auto_detect(process: &Process) -> Option { + let il2cpp_module = Self::find_runtime_module(process)?; + let pointer_size = pe::MachineType::read(process, il2cpp_module.0)?.pointer_size()?; + + let unity = Self::unity_version(process); + let metadata = Self::metadata_version(process); + + match (unity, metadata) { + (Some(unity), Some(metadata)) => { + if let Some(build) = builds::find(metadata, unity, pointer_size) { + let module = Self::attach_with( + process, + il2cpp_module, + pointer_size, + build.version, + &build.offsets, + )?; + print_limited::<128>(&format_args!( + "known il2cpp build: metadata {metadata}, unity {}.{}.{}.{}", + unity.0, unity.1, unity.2, unity.3, + )); + return Some(module); + } + print_limited::<128>(&format_args!( + "unknown il2cpp build: metadata {metadata}, unity {}.{}.{}.{}", + unity.0, unity.1, unity.2, unity.3, + )); + } + // The game maps its metadata after GameAssembly.dll. A player + // with a measured build waits for it, since attaching now would + // take the version table's offsets and keep them. + (Some(unity), None) if builds::measured(unity, pointer_size) => return None, + _ => {} + } + let version = Version::detect(process)?; Self::attach(process, version) } @@ -43,49 +88,54 @@ impl Module { /// correct for this function to work. If you don't know the version in /// advance, use [`attach_auto_detect`](Self::attach_auto_detect) instead. pub fn attach(process: &Process, version: Version) -> Option { - let il2cpp_module = { - let address = process.get_module_address("GameAssembly.dll").ok()?; - let size = pe::read_size_of_image(process, address)? as u64; - (address, size) - }; - + let il2cpp_module = Self::find_runtime_module(process)?; let pointer_size = pe::MachineType::read(process, il2cpp_module.0)?.pointer_size()?; let offsets = IL2CPPOffsets::new(version, pointer_size)?; - let assemblies: Address = { - const ASSEMBLIES: Signature<12> = Signature::new("75 ?? 48 8B 1D ?? ?? ?? ?? 48 3B 1D"); - ASSEMBLIES - .scan_process_range(process, il2cpp_module) - .map(|addr| addr + 5) - .and_then(|addr| Some(addr + 0x4 + process.read::(addr).ok()?))? - }; + Self::attach_with(process, il2cpp_module, pointer_size, version, offsets) + } - let type_info_definition_table: Address = { - const GLOBAL_METADATA: Signature<20> = - Signature::new("67 6C 6F 62 61 6C 2D 6D 65 74 61 64 61 74 61 2E 64 61 74 00"); - let s_metadata = GLOBAL_METADATA.scan_process_range(process, il2cpp_module)?; - - const LEA: Signature<3> = Signature::new("48 8D 0D"); - let lea: Address = LEA - .scan_iter(process, il2cpp_module) - .map(|addr| addr + 3) - .find(|&addr| { - let Ok(offset) = process.read::(addr) else { - return false; - }; - - addr + 0x4 + offset == s_metadata - })?; - - const SHR: Signature<3> = Signature::new("48 C1 E9"); - let shr: Address = SHR - .scan_process_range(process, (lea, 0x200)) - .map(|addr| addr + 3)?; - - const RAX: Signature<3> = Signature::new("48 89 05"); - RAX.scan_process_range(process, (shr, 0x100)) - .map(|addr| addr + 3) - .and_then(|addr| Some(addr + 0x4 + process.read::(addr).ok()?))? + fn find_runtime_module(process: &Process) -> Option<(Address, u64)> { + let address = process.get_module_address("GameAssembly.dll").ok()?; + let size = pe::read_size_of_image(process, address)? as u64; + Some((address, size)) + } + + /// Reads the Unity version stamped on the player, all four parts of + /// `UnityPlayer.dll`'s file version. + fn unity_version(process: &Process) -> Option<(u16, u16, u16, u16)> { + let unity_player = process.get_module_address("UnityPlayer.dll").ok()?; + let file_version = pe::FileVersion::read(process, unity_player)?; + Some(( + file_version.major_version, + file_version.minor_version, + file_version.build_part, + file_version.private_part, + )) + } + + /// Reads the version of the game's metadata. The mapped + /// `global-metadata.dat` starts with a sanity value, then the version. + fn metadata_version(process: &Process) -> Option { + process.memory_ranges().find_map(|range| { + let [sanity, version] = process.read::<[u32; 2]>(range.address().ok()?).ok()?; + // Versions are small numbers. Unity 6 renumbered them and reaches + // the low hundreds. + (sanity == 0xFAB1_1BAF && (16..=999).contains(&version)).then_some(version) + }) + } + + fn attach_with( + process: &Process, + il2cpp_module: (Address, u64), + pointer_size: PointerSize, + version: Version, + offsets: &'static IL2CPPOffsets, + ) -> Option { + let (assemblies, type_info_definition_table) = match pointer_size { + PointerSize::Bit64 => Self::globals_x64(process, il2cpp_module)?, + PointerSize::Bit32 => Self::globals_x86(process, il2cpp_module)?, + _ => return None, }; Some(Self { @@ -97,30 +147,163 @@ impl Module { }) } - fn assemblies<'a>( - &'a self, - process: &'a Process, - ) -> impl DoubleEndedIterator + 'a { - let (assemblies, nr_of_assemblies): (Address, u64) = { - let [first, limit] = process - .read::<[u64; 2]>(self.assemblies) - .unwrap_or_default(); - let count = limit - .saturating_sub(first) - .saturating_div(self.size_of_ptr()); - (Address::new(first), count) - }; + /// Finds the assemblies vector and the type-info table in x64 code. Each + /// is given as a displacement from the next instruction. + fn globals_x64(process: &Process, il2cpp_module: (Address, u64)) -> Option<(Address, Address)> { + let displaced = |addr: Address| Some(addr + 0x4 + process.read::(addr).ok()?); - (0..nr_of_assemblies).filter_map(move |i| { - process - .read_pointer( - assemblies + self.size_of_ptr().wrapping_mul(i), - self.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null()) - .map(|assembly| Assembly { assembly }) - }) + // jne; mov rbx, [begin]; cmp rbx, [end]. The end of a vector sits one + // pointer past its begin. + const ASSEMBLIES: Signature<16> = + Signature::new("75 ?? 48 8B 1D ?? ?? ?? ?? 48 3B 1D ?? ?? ?? ??"); + let assemblies = ASSEMBLIES + .scan_iter(process, il2cpp_module) + .find_map(|addr| { + let begin = displaced(addr + 5)?; + (displaced(addr + 12)? == begin + 8u64).then_some(begin) + })?; + + let s_metadata = Self::metadata_name(process, il2cpp_module)?; + + // lea rcx, [name] + const LEA: Signature<7> = Signature::new("48 8D 0D ?? ?? ?? ??"); + let lea: Address = LEA + .scan_iter(process, il2cpp_module) + .map(|addr| addr + 3) + .find(|&addr| displaced(addr) == Some(s_metadata))?; + + // shr rcx, imm8, then mov [table], rax + const SHR: Signature<3> = Signature::new("48 C1 E9"); + let shr: Address = SHR + .scan_process_range(process, Self::within(il2cpp_module, lea, 0x200)) + .map(|addr| addr + 3)?; + + const RAX: Signature<7> = Signature::new("48 89 05 ?? ?? ?? ??"); + let table = RAX + .scan_process_range(process, Self::within(il2cpp_module, shr, 0x100)) + .map(|addr| addr + 3) + .and_then(displaced)?; + + Self::inside(il2cpp_module, assemblies, table) + } + + /// Finds the assemblies vector and the type-info table in x86 code. Each + /// is given as an absolute address. + fn globals_x86(process: &Process, il2cpp_module: (Address, u64)) -> Option<(Address, Address)> { + let absolute = |addr: Address| Some(Address::new(process.read::(addr).ok()? as u64)); + + // jne; mov esi, [begin]; sub edi, ecx; cmp esi, [end]. The end of a + // vector sits one pointer past its begin. + const ASSEMBLIES: Signature<16> = + Signature::new("75 ?? 8B 35 ?? ?? ?? ?? 2B F9 3B 35 ?? ?? ?? ??"); + let assemblies = ASSEMBLIES + .scan_iter(process, il2cpp_module) + .find_map(|addr| { + let begin = absolute(addr + 4)?; + (absolute(addr + 12)? == begin + 4u64).then_some(begin) + })?; + + let s_metadata = Self::metadata_name(process, il2cpp_module)?; + + // push offset name; call + const PUSH: Signature<6> = Signature::new("68 ?? ?? ?? ?? E8"); + let push: Address = PUSH + .scan_iter(process, il2cpp_module) + .map(|addr| addr + 1) + .find(|&addr| absolute(addr) == Some(s_metadata))?; + + // The table is the first store after the name. Three shapes store + // it. Through Unity 6000.2 the count is a byte size, so a shift + // divides it first, and some of those builds reload ecx after the + // call. From 6000.3 the count comes straight from the header, at + // 0xF4. + const DIVIDED: Signature<14> = Signature::new("C1 EA ?? 52 E8 ?? ?? ?? ?? A3 ?? ?? ?? ??"); + const DIVIDED_RELOAD: Signature<20> = + Signature::new("C1 EA ?? 52 E8 ?? ?? ?? ?? 8B 0D ?? ?? ?? ?? A3 ?? ?? ?? ??"); + const PUSHED: Signature<16> = + Signature::new("FF B0 F4 00 00 00 E8 ?? ?? ?? ?? A3 ?? ?? ?? ??"); + let window = Self::within(il2cpp_module, push, 0x400); + let store = [ + DIVIDED + .scan_process_range(process, window) + .map(|addr| addr + 10), + DIVIDED_RELOAD + .scan_process_range(process, window) + .map(|addr| addr + 16), + PUSHED + .scan_process_range(process, window) + .map(|addr| addr + 12), + ] + .into_iter() + .flatten() + .min()?; + let table = absolute(store)?; + + Self::inside(il2cpp_module, assemblies, table) + } + + /// Finds the string `global-metadata.dat` in the module. + fn metadata_name(process: &Process, il2cpp_module: (Address, u64)) -> Option
{ + const GLOBAL_METADATA: Signature<20> = + Signature::new("67 6C 6F 62 61 6C 2D 6D 65 74 61 64 61 74 61 2E 64 61 74 00"); + GLOBAL_METADATA.scan_process_range(process, il2cpp_module) + } + + /// The range from `start` for up to `len` bytes, cut at the module's end. + fn within(module: (Address, u64), start: Address, len: u64) -> (Address, u64) { + let end = module.0.value().saturating_add(module.1); + (start, len.min(end.saturating_sub(start.value()))) + } + + /// The two globals, once both lie inside the module. + fn inside( + module: (Address, u64), + assemblies: Address, + table: Address, + ) -> Option<(Address, Address)> { + let end = module.0.value().saturating_add(module.1); + let holds = |addr: Address| (module.0.value()..end).contains(&addr.value()); + (holds(assemblies) && holds(table)).then_some((assemblies, table)) + } + + fn walk(&self) -> managed::Walk { + managed::Walk { + runtime: managed::Runtime::Il2Cpp(managed::Il2CppRuntime { + assemblies: self.assemblies, + type_info_definition_table: self.type_info_definition_table, + type_count: self.offsets.image.type_count.into(), + metadata_handle: self.offsets.image.metadata_handle.into(), + handle_is_inline: matches!(self.version, Version::Base | Version::V2019), + field_count: self.offsets.class.field_count, + static_fields: self.offsets.class.static_fields.into(), + cached_class: self.offsets.generic.cached_class, + type_data: self.offsets.type_words.data, + type_kind: self.offsets.type_words.kind, + }), + offsets: managed::WalkOffsets { + assembly: managed::AssemblyOffsets { + name_in_image: self.offsets.image.assembly_name.map(u16::from), + name_in_assembly: self.offsets.assembly.aname.map(u16::from), + image: self.offsets.assembly.image.into(), + }, + class: managed::ClassOffsets { + name: self.offsets.class.name.into(), + namespace: self.offsets.class.namespace.into(), + parent: self.offsets.class.parent.into(), + declaring: self.offsets.class.declaring_type, + instance_size: self.offsets.class.instance_size, + fields: self.offsets.class.fields.into(), + }, + field: managed::FieldOffsets { + name: self.offsets.field.name.into(), + type_: self.offsets.field.type_, + offset: self.offsets.field.offset.into(), + stride: self.offsets.field.struct_size.into(), + }, + }, + stop: managed::ClimbStop::UNITY, + pointer_size: self.pointer_size, + } } /// Looks for the specified binary [image](Image) inside the target process. @@ -130,13 +313,11 @@ impl Module { /// [`get_default_image`](Self::get_default_image) function is a shorthand /// for this function that accesses the `Assembly-CSharp` [image](Image). pub fn get_image(&self, process: &Process, assembly_name: &str) -> Option { - self.assemblies(process) - .find(|assembly| { - assembly - .get_name::(process, self) - .is_ok_and(|name| name.matches(assembly_name)) + self.walk() + .find_image(process, assembly_name) + .map(|image| Image { + image: image.address, }) - .and_then(|assembly| assembly.get_image(process, self)) } /// Looks for the `Assembly-CSharp` binary [image](Image) inside the target @@ -149,6 +330,113 @@ impl Module { self.get_image(process, "Assembly-CSharp") } + /// Reads a managed string through the reference stored at the given + /// address, such as the end of a pointer path or a slot in a static + /// table. The string carries its own character count, so no length is + /// passed; `N` bounds how many UTF-16 characters the returned buffer + /// holds, and a string claiming more than that fails rather than + /// truncates, as do a negative count and a null reference. A string + /// containing an interior nul character reads in full but compares up to + /// the nul. + pub fn read_string( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_string(process, self.pointer_size, at) + } + + /// Reads a managed array of value elements through the reference stored + /// at the given address. The array carries its own length, so no count + /// is passed; `N` bounds how many elements the returned vector holds, + /// and an array claiming more than that fails rather than truncates, as + /// does a null reference. The element type is the caller's claim and has + /// to match the target's own element layout: a managed `char` is a + /// `u16` here, a `bool` a single byte, and Rust's `char` and `usize` + /// never match. Reference elements have no portable claim. + pub fn read_array( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_array(process, self.pointer_size, at) + } + + /// Resolves where a `List` keeps its backing array and live count, off + /// the class the list object at the given address names as its own. The + /// answer is a small `Copy` value worth storing, like a field offset: + /// resolution walks the class's fields, where the read itself is a + /// handful of reads. An object whose class is not a list misses. + pub fn get_list_offsets(&self, process: &Process, at: Address) -> Option { + let object = process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + self.walk().list_offsets(process, object) + } + + /// Resolves where a `Dictionary` keeps its backing entries and live + /// counts, and how one entry lays out, off the class the dictionary + /// object at the given address names as its own. The answer is a small + /// `Copy` value worth storing, like a field offset: resolution walks + /// class metadata, where the read itself is a handful of reads. An + /// object whose class is not this dictionary shape, and a target still + /// starting up, both miss. + pub fn get_dictionary_offsets( + &self, + process: &Process, + at: Address, + ) -> Option { + let object = process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + self.walk().dictionary_offsets(process, object) + } + + /// Reads a managed `Dictionary`'s live pairs through the reference + /// stored at the given address, with the offsets + /// [`get_dictionary_offsets`](Self::get_dictionary_offsets) resolved. + /// `N` bounds the live pairs, never the counted entries or the backing + /// capacity; freed entries are skipped by their marks, and a live tally + /// that cannot balance against the counts fails rather than answering + /// wrong pairs. The key and value types are the caller's claims, as + /// with [`read_array`](Self::read_array), refused where a claim + /// outgrows the room its member has inside one entry. + pub fn read_dictionary( + &self, + process: &Process, + offsets: DictionaryOffsets, + at: Address, + ) -> Result, Error> { + managed::read_dictionary(process, self.pointer_size, offsets, at) + } + + /// Returns the pointer size the target runs at, which is what a caller + /// claims reference-width values with. + pub fn get_pointer_size(&self) -> PointerSize { + self.pointer_size + } + + /// Reads a managed `List` of value elements through the reference stored + /// at the given address, with the offsets + /// [`get_list_offsets`](Self::get_list_offsets) resolved. The list's + /// live count is read, never its backing capacity; `N` bounds the + /// count, and a count past the buffer or past the backing array's own + /// length fails rather than truncates, as does a null reference. The + /// element type is the caller's claim, as with + /// [`read_array`](Self::read_array). + pub fn read_list( + &self, + process: &Process, + offsets: ListOffsets, + at: Address, + ) -> Result, Error> { + managed::read_list(process, self.pointer_size, offsets, at) + } + /// Attaches to a Unity game that is using the IL2CPP backend. This function /// automatically detects the [IL2CPP version](Version). If you know the /// version in advance or it fails detecting it, use @@ -200,8 +488,49 @@ impl Module { retry(|| self.get_default_image(process)).await } - #[inline] - const fn size_of_ptr(&self) -> u64 { - self.pointer_size as u64 + /// Resolves where a `List` keeps its backing array and live count, off + /// the class the list object at the given address names as its own. + /// + /// This is the `await`able version of the + /// [`get_list_offsets`](Self::get_list_offsets) function, yielding back + /// to the runtime between each try. + pub async fn wait_get_list_offsets(&self, process: &Process, at: Address) -> ListOffsets { + retry(|| self.get_list_offsets(process, at)).await + } + + /// Resolves where a `Dictionary` keeps its backing entries and live + /// counts, and how one entry lays out, off the class the dictionary + /// object at the given address names as its own. + /// + /// This is the `await`able version of the + /// [`get_dictionary_offsets`](Self::get_dictionary_offsets) function, + /// yielding back to the runtime between each try. + pub async fn wait_get_dictionary_offsets( + &self, + process: &Process, + at: Address, + ) -> DictionaryOffsets { + retry(|| self.get_dictionary_offsets(process, at)).await + } +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::Module; + use crate::runtime::mock::with_process; + + #[test] + fn reads_the_metadata_version_off_the_mapped_file() { + let mapped = [0xAF_u8, 0x1B, 0xB1, 0xFA, 39, 0, 0, 0]; + // The sanity value with nothing sane behind it must not answer. + let stray = [0xAF_u8, 0x1B, 0xB1, 0xFA, 0, 0, 0, 0]; + + with_process(&[(0x10000, &stray), (0x20000, &mapped)], |process| { + assert_eq!(Module::metadata_version(process), Some(39)); + }); + + with_process(&[(0x10000, &stray)], |process| { + assert!(Module::metadata_version(process).is_none()); + }); } } diff --git a/src/game_engine/unity/il2cpp/offsets.rs b/src/game_engine/unity/il2cpp/offsets.rs index a402513a..a1f1df05 100644 --- a/src/game_engine/unity/il2cpp/offsets.rs +++ b/src/game_engine/unity/il2cpp/offsets.rs @@ -4,6 +4,8 @@ pub(super) struct IL2CPPOffsets { pub(super) assembly: AssemblyOffsets, pub(super) image: ImageOffsets, pub(super) class: ClassOffsets, + pub(super) generic: GenericOffsets, + pub(super) type_words: TypeOffsets, pub(super) field: FieldInfoOffsets, } @@ -14,22 +16,31 @@ impl IL2CPPOffsets { Version::V2022 => &Self { assembly: AssemblyOffsets { image: 0x0, - aname: 0x18, + aname: Some(0x18), }, image: ImageOffsets { + assembly_name: None, type_count: 0x18, - matadata_handle: 0x28, + metadata_handle: 0x28, }, class: ClassOffsets { name: 0x10, namespace: 0x18, + declaring_type: Some(0x50), // 2023.1 through 6000.7 parent: 0x58, fields: 0x80, static_fields: 0xB8, + instance_size: None, field_count: 0x124, }, + generic: GenericOffsets { cached_class: None }, + type_words: TypeOffsets { + data: Some(0x0), // 2023.1 through 6000.7 + kind: Some(0xA), // 2023.1 through 6000.7 + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), // 2023.1 through 6000.7 offset: 0x18, struct_size: 0x20, }, @@ -37,22 +48,31 @@ impl IL2CPPOffsets { Version::V2020 => &Self { assembly: AssemblyOffsets { image: 0x0, - aname: 0x18, + aname: Some(0x18), }, image: ImageOffsets { + assembly_name: None, type_count: 0x18, - matadata_handle: 0x28, + metadata_handle: 0x28, }, class: ClassOffsets { name: 0x10, namespace: 0x18, + declaring_type: None, parent: 0x58, fields: 0x80, static_fields: 0xB8, + instance_size: None, field_count: 0x120, }, + generic: GenericOffsets { cached_class: None }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { name: 0x0, + type_: None, offset: 0x18, struct_size: 0x20, }, @@ -60,22 +80,33 @@ impl IL2CPPOffsets { Version::V2019 => &Self { assembly: AssemblyOffsets { image: 0x0, - aname: 0x18, + aname: Some(0x18), }, image: ImageOffsets { + assembly_name: None, type_count: 0x1C, - matadata_handle: 0x18, + metadata_handle: 0x18, }, class: ClassOffsets { name: 0x10, namespace: 0x18, + declaring_type: Some(0x50), // 2019.4, 2020.1 parent: 0x58, fields: 0x80, static_fields: 0xB8, + instance_size: Some(0xF4), // 2019.4, 2020.1 field_count: 0x11C, }, + generic: GenericOffsets { + cached_class: Some(0x18), // 2019.4, 2020.1 + }, + type_words: TypeOffsets { + data: Some(0x0), // 2019.4, 2020.1 + kind: Some(0xA), // 2019.4, 2020.1 + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), // 2019.4, 2020.1 offset: 0x18, struct_size: 0x20, }, @@ -83,22 +114,31 @@ impl IL2CPPOffsets { Version::Base => &Self { assembly: AssemblyOffsets { image: 0x0, - aname: 0x18, + aname: Some(0x18), }, image: ImageOffsets { + assembly_name: None, type_count: 0x1C, - matadata_handle: 0x18, + metadata_handle: 0x18, }, class: ClassOffsets { name: 0x10, namespace: 0x18, + declaring_type: None, parent: 0x58, fields: 0x80, static_fields: 0xB8, + instance_size: None, field_count: 0x114, }, + generic: GenericOffsets { cached_class: None }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { name: 0x0, + type_: None, offset: 0x18, struct_size: 0x20, }, @@ -111,25 +151,41 @@ impl IL2CPPOffsets { pub(super) struct AssemblyOffsets { pub(super) image: u8, - pub(super) aname: u8, + pub(super) aname: Option, // Either this or ImageOffsets::assembly_name locates the name } pub(super) struct ImageOffsets { + pub(super) assembly_name: Option, // Either this or AssemblyOffsets::aname locates the name pub(super) type_count: u8, - pub(super) matadata_handle: u8, + pub(super) metadata_handle: u8, } pub(super) struct ClassOffsets { pub(super) name: u8, pub(super) namespace: u8, + pub(super) declaring_type: Option, // Where a class keeps the one declaring it pub(super) parent: u8, pub(super) fields: u8, pub(super) static_fields: u8, + pub(super) instance_size: Option, // What one instance occupies, boxed header included pub(super) field_count: u16, } +// Il2CppGenericClass keeps the class an instantiation resolved to. +pub(super) struct GenericOffsets { + pub(super) cached_class: Option, +} + +// Il2CppType's own words: the data pointer and the element kind byte. +pub(super) struct TypeOffsets { + pub(super) data: Option, + pub(super) kind: Option, +} + pub(super) struct FieldInfoOffsets { pub(super) name: u8, + pub(super) type_: Option, // Where a field keeps its Il2CppType + pub(super) offset: u8, pub(super) struct_size: u8, } diff --git a/src/game_engine/unity/il2cpp/pointer.rs b/src/game_engine/unity/il2cpp/pointer.rs index 6dd1ab6d..cfd2bcc3 100644 --- a/src/game_engine/unity/il2cpp/pointer.rs +++ b/src/game_engine/unity/il2cpp/pointer.rs @@ -1,24 +1,11 @@ -use bytemuck::CheckedBitPattern; - -use super::{Class, Image, Module}; +use super::super::managed::{ImageRef, PointerPath}; +use super::{Image, Module}; use crate::{Address, Error, Process}; -use core::{array, cell::RefCell}; +use bytemuck::CheckedBitPattern; /// An IL2CPP-specific implementation for automatic pointer path resolution pub struct UnityPointer { - inner: RefCell>, -} - -struct UnityPointerInternal { - base_address: Address, - offsets: [u32; CAP], - resolved_offsets: usize, - - starting_class_name: &'static str, - starting_class: Option, - nr_of_parents: usize, - fields: [&'static str; CAP], - depth: usize, + path: PointerPath, } impl UnityPointer { @@ -29,110 +16,11 @@ impl UnityPointer { /// If a higher number of offsets is provided, the pointer path will be truncated /// according to the value of `CAP`. pub fn new(class_name: &'static str, nr_of_parents: usize, fields: &[&'static str]) -> Self { - let named_fields = { - let mut iter = fields.iter(); - array::from_fn(|_| iter.next().copied().unwrap_or_default()) - }; - Self { - inner: RefCell::new(UnityPointerInternal { - base_address: Address::NULL, - offsets: [0; CAP], - resolved_offsets: 0, - starting_class_name: class_name, - starting_class: None, - nr_of_parents, - fields: named_fields, - depth: fields.len().min(CAP), - }), + path: PointerPath::new(class_name, nr_of_parents, fields), } } - /// Tries to resolve the pointer path for the `IL2CPP` class specified - fn find_offsets(&self, process: &Process, module: &Module, image: &Image) -> Result<(), Error> { - let mut inner = self.inner.borrow_mut(); - - // If the pointer path has already been found, there's no need to continue - if inner.resolved_offsets == inner.depth { - return Ok(()); - } - - // Logic: the starting class can be recovered with the get_class() function, - // and parent class can be recovered if needed. However, this is a VERY - // intensive process because it involves looping through all the main classes - // in the game. For this reason, once the class is found, we want to store it - // into the cache, where it can be recovered if this function need to be run again - // (for example if a previous attempt at pointer path resolution failed) - let starting_class = match inner.starting_class { - Some(starting_class) => starting_class, - _ => { - let mut class = image - .get_class(process, module, inner.starting_class_name) - .ok_or(Error {})?; - - for _ in 0..inner.nr_of_parents { - class = class.get_parent(process, module).ok_or(Error {})?; - } - - inner.starting_class = Some(class); - class - } - }; - - // Recovering the address of the static table is not very CPU intensive, - // but it might be worth caching it as well - if inner.base_address.is_null() { - inner.base_address = starting_class - .get_static_table(process, module) - .ok_or(Error {})?; - }; - - // If we already resolved some offsets, we need to traverse them again starting from the base address - // of the static table in order to recalculate the address of the farthest object we can reach. - // If no offsets have been resolved yet, we just need to read the base address instead. - let mut current_object = { - let mut addr = inner.base_address; - for &i in &inner.offsets[..inner.resolved_offsets] { - addr = process.read_pointer(addr + i, module.pointer_size)?; - } - addr - }; - - // We keep track of the already resolved offsets in order to skip resolving them again - for i in inner.resolved_offsets..inner.depth { - let offset_from_string = match inner.fields[i].strip_prefix("0x") { - Some(rem) => u32::from_str_radix(rem, 16).ok(), - _ => inner.fields[i].parse().ok(), - }; - - let current_offset = match offset_from_string { - Some(offset) => offset as _, - _ => { - let current_class = match i { - 0 => starting_class, - _ => process - .read_pointer(current_object, module.pointer_size) - .ok() - .filter(|val| !val.is_null()) - .map(|class| Class { class }) - .ok_or(Error {})?, - }; - - current_class - .get_field_offset(process, module, inner.fields[i]) - .ok_or(Error {})? - } - }; - - inner.offsets[i] = current_offset as _; - inner.resolved_offsets += 1; - - current_object = - process.read_pointer(current_object + current_offset, module.pointer_size)?; - } - Ok(()) - } - /// Dereferences the pointer path, returning the memory address of the value of interest pub fn deref_offsets( &self, @@ -140,14 +28,8 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - self.find_offsets(process, module, image)?; - let inner = self.inner.borrow(); - let mut address = inner.base_address; - let (&last, path) = inner.offsets[..inner.depth].split_last().ok_or(Error {})?; - for &offset in path { - address = process.read_pointer(address + offset, module.pointer_size)?; - } - Ok(address + last) + self.path + .deref_offsets(process, &module.walk(), ImageRef::new(image.image)) } /// Dereferences the pointer path, returning the value stored at the final memory address @@ -157,6 +39,7 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - process.read(self.deref_offsets(process, module, image)?) + self.path + .deref(process, &module.walk(), ImageRef::new(image.image)) } } diff --git a/src/game_engine/unity/il2cpp/readers_tests.rs b/src/game_engine/unity/il2cpp/readers_tests.rs new file mode 100644 index 00000000..ed934dd9 --- /dev/null +++ b/src/game_engine/unity/il2cpp/readers_tests.rs @@ -0,0 +1,87 @@ +//! Parity tests for the managed readers through the IL2CPP module. The +//! implementation is shared, so this pins the mirror surface and the one +//! behavior whose rationale is IL2CPP's: the full-width length judgment. + +use super::{IL2CPPOffsets, Module, Version}; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x40_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +fn image() -> Vec { + let mut i = vec![0; 0x1000]; + + ptr(&mut i, 0x0, BASE + 0x100); + put(&mut i, 0x100 + 0x10, &4_i32.to_le_bytes()); + for (index, unit) in "Loop".encode_utf16().enumerate() { + put(&mut i, 0x100 + 0x14 + 2 * index as u64, &unit.to_le_bytes()); + } + + // An array whose length slot carries garbage above the low u32. IL2CPP's + // length really is pointer-sized, so the whole slot judges the claim. + ptr(&mut i, 0x8, BASE + 0x200); + put(&mut i, 0x200 + 0x18, &0x1_0000_0003_u64.to_le_bytes()); + + ptr(&mut i, 0x10, BASE + 0x300); + put(&mut i, 0x300 + 0x18, &2_u64.to_le_bytes()); + put(&mut i, 0x300 + 0x20, &11_u32.to_le_bytes()); + put(&mut i, 0x300 + 0x24, &22_u32.to_le_bytes()); + + i +} + +fn on_fixture(test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image())], |process| { + let module = Module { + assemblies: Address::new(BASE), + type_info_definition_table: Address::new(BASE + 0x10), + version: Version::V2022, + offsets: IL2CPPOffsets::new(Version::V2022, PointerSize::Bit64).unwrap(), + pointer_size: PointerSize::Bit64, + }; + test(process, &module); + }); +} + +#[test] +fn strings_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_string::<8>(process, Address::new(BASE)) + .unwrap(); + assert!(read.matches_str("Loop")); + }); +} + +#[test] +fn arrays_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_array::(process, Address::new(BASE + 0x10)) + .unwrap(); + assert_eq!(read.as_slice(), [11, 22]); + }); +} + +// A length whose low u32 reads small but whose full width does not is +// garbage, not a small array. An i32 read here would wrongly succeed. +#[test] +fn array_lengths_judge_at_full_width() { + on_fixture(|process, module| { + assert!(module + .read_array::(process, Address::new(BASE + 0x8)) + .is_err()); + }); +} diff --git a/src/game_engine/unity/il2cpp/version.rs b/src/game_engine/unity/il2cpp/version.rs index 409fedfe..b3a29efb 100644 --- a/src/game_engine/unity/il2cpp/version.rs +++ b/src/game_engine/unity/il2cpp/version.rs @@ -27,7 +27,7 @@ impl Version { let file_version = pe::FileVersion::read(process, unity_module)?; return Some( - if file_version.major_version > 2023 + if file_version.major_version > 2022 || (file_version.major_version == 2022 && file_version.minor_version >= 2) { Self::V2022 diff --git a/src/game_engine/unity/il2cpp/walk_tests.rs b/src/game_engine/unity/il2cpp/walk_tests.rs new file mode 100644 index 00000000..7be33a7e --- /dev/null +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -0,0 +1,825 @@ +//! Tests pinning the walk's behavior over a hand-laid image of IL2CPP's +//! structures, one fixture per lineage: the older one keeps its metadata +//! handle inline in the image, the newer one behind a pointer. The offsets are +//! the literal numbers of the Unity 2019.4 and 6000.3 layouts, copied by hand, +//! so the walk is checked against the layout rather than against itself. + +use super::{IL2CPPOffsets, Module, UnityPointer, Version}; +use crate::runtime::mock::{poll_once, with_process}; +use crate::{Address, PointerSize, Process}; + +use core::task::Poll; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x20_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +// A player as `attach_auto_detect` sees it: `GameAssembly.dll` with a PE +// header and the x64 code that points at both globals, `UnityPlayer.dll` with a +// PE header and a version resource, and the mapped metadata file when it is +// there yet. +struct Player { + unity: (u16, u16, u16, u16), + metadata: Option, +} + +const GAME_ASSEMBLY: u64 = 0x1_8000_0000; +const UNITY_PLAYER: u64 = 0x1900_0000; +const METADATA: u64 = 0x2000_0000; + +impl Player { + fn attach(&self) -> Option { + let game_assembly = Self::game_assembly(); + let unity_player = Self::unity_player(self.unity); + let metadata = self.metadata.map(|version| { + let mut i = vec![0; 0x100]; + put(&mut i, 0x0, &0xFAB1_1BAF_u32.to_le_bytes()); + put(&mut i, 0x4, &version.to_le_bytes()); + i + }); + let mut regions = vec![ + (GAME_ASSEMBLY, &game_assembly[..]), + (UNITY_PLAYER, &unity_player[..]), + ]; + if let Some(metadata) = &metadata { + regions.push((METADATA, &metadata[..])); + } + crate::runtime::mock::with_modules( + ®ions, + &[ + ("GameAssembly.dll", GAME_ASSEMBLY, 0x1000), + ("UnityPlayer.dll", UNITY_PLAYER, 0x1000), + ], + Module::attach_auto_detect, + ) + } + + // A PE32+ header for x64, with the image size and, when asked, the + // resource directory pointing at `at`. + fn pe_header(i: &mut [u8], resources_at: Option) { + put(i, 0x00, b"MZ"); + put(i, 0x3C, &0x80_u32.to_le_bytes()); + put(i, 0x80, b"PE\0\0"); + put(i, 0x84, &0x8664_u16.to_le_bytes()); // machine: x64 + put(i, 0x94, &0xF0_u16.to_le_bytes()); // size of optional header + put(i, 0x98, &0x20B_u16.to_le_bytes()); // PE32+ + put(i, 0x98 + 0x38, &0x1000_u32.to_le_bytes()); // size of image + if let Some(at) = resources_at { + put(i, 0x98 + 0x80, &at.to_le_bytes()); + put(i, 0x98 + 0x84, &0x100_u32.to_le_bytes()); + } + } + + fn game_assembly() -> vec::Vec { + let mut i = vec![0; 0x1000]; + Self::pe_header(&mut i, None); + let rel = |from: u64, to: u64| ((to as i64 - (from + 4) as i64) as i32).to_le_bytes(); + let base = GAME_ASSEMBLY; + // jne; mov rbx, [begin]; cmp rbx, [end] + put(&mut i, 0x300, &[0x75, 0xF9, 0x48, 0x8B, 0x1D]); + put(&mut i, 0x305, &rel(base + 0x305, base + 0x900)); + put(&mut i, 0x309, &[0x48, 0x3B, 0x1D]); + put(&mut i, 0x30C, &rel(base + 0x30C, base + 0x908)); + put(&mut i, 0x400, b"global-metadata.dat\0"); + // lea rcx, [name]; shr rcx, 6; mov [table], rax + put(&mut i, 0x500, &[0x48, 0x8D, 0x0D]); + put(&mut i, 0x503, &rel(base + 0x503, base + 0x400)); + put(&mut i, 0x580, &[0x48, 0xC1, 0xE9, 0x06]); + put(&mut i, 0x5A0, &[0x48, 0x89, 0x05]); + put(&mut i, 0x5A3, &rel(base + 0x5A3, base + 0x910)); + i + } + + // The version resource, the way the resource directory lays it: the + // type directory holds RT_VERSION, which holds one language, which holds + // the data entry pointing at VS_VERSIONINFO. + fn unity_player(unity: (u16, u16, u16, u16)) -> vec::Vec { + let mut i = vec![0; 0x1000]; + Self::pe_header(&mut i, Some(0x400)); + let entry = |i: &mut [u8], at: u64, id: u32, offset: u32| { + put(i, at, &id.to_le_bytes()); + put(i, at + 4, &offset.to_le_bytes()); + }; + // root: one id entry, RT_VERSION (0x10), a directory at +0x20 + put(&mut i, 0x400 + 0xE, &1_u16.to_le_bytes()); + entry(&mut i, 0x410, 0x10, 0x8000_0020); + // type directory: one directory entry at +0x40 + put(&mut i, 0x420 + 0xE, &1_u16.to_le_bytes()); + entry(&mut i, 0x430, 1, 0x8000_0040); + // language directory: one data entry at +0x60 + put(&mut i, 0x440 + 0xE, &1_u16.to_le_bytes()); + entry(&mut i, 0x450, 0x409, 0x60); + // the data entry names VS_VERSIONINFO at 0x600 + put(&mut i, 0x460, &0x600_u32.to_le_bytes()); + // VS_FIXEDFILEINFO sits 0x28 in + put(&mut i, 0x628, &0xFEEF_04BD_u32.to_le_bytes()); + put(&mut i, 0x630, &unity.1.to_le_bytes()); + put(&mut i, 0x632, &unity.0.to_le_bytes()); + put(&mut i, 0x634, &unity.3.to_le_bytes()); + put(&mut i, 0x636, &unity.2.to_le_bytes()); + i + } +} + +const MEASURED_6000_5: (u16, u16, u16, u16) = (6000, 5, 10, 54518); + +// A game on a measured player, with its metadata mapped, attaches with the +// offsets measured on that player. +#[test] +fn attach_auto_detect_uses_a_measured_build() { + let module = Player { + unity: MEASURED_6000_5, + metadata: Some(107), + } + .attach() + .unwrap(); + assert_eq!(module.assemblies, Address::new(GAME_ASSEMBLY + 0x900)); + assert_eq!( + module.type_info_definition_table, + Address::new(GAME_ASSEMBLY + 0x910) + ); + assert_eq!(module.offsets.class.static_fields, 0xA0); +} + +// The metadata file is mapped after `GameAssembly.dll`. Until it is, a game +// on a measured player has to wait rather than attach with the version +// table's offsets and keep them. +#[test] +fn attach_auto_detect_waits_for_the_metadata_of_a_measured_player() { + let module = Player { + unity: MEASURED_6000_5, + metadata: None, + } + .attach(); + assert!(module.is_none()); +} + +// A player nobody measured takes the version table, whether or not its +// metadata is mapped yet. +#[test] +fn attach_auto_detect_falls_back_for_an_unmeasured_player() { + for metadata in [None, Some(107)] { + let module = Player { + unity: (6000, 5, 11, 1), + metadata, + } + .attach() + .unwrap(); + assert_eq!(module.offsets.class.static_fields, 0xB8); + } +} + +// A measured player whose metadata says another version is not that build. +#[test] +fn attach_auto_detect_falls_back_when_the_metadata_disagrees() { + let module = Player { + unity: MEASURED_6000_5, + metadata: Some(110), + } + .attach() + .unwrap(); + assert_eq!(module.offsets.class.static_fields, 0xB8); +} + +// The x86 code that points at both globals. The assemblies loop reads the vector's +// begin and end by absolute address. The table store follows the string that +// names the metadata file, and has three shapes across Unity versions, so the +// image takes the stores to lay. +struct X86Image { + stores: vec::Vec<(u64, &'static [u8], u64, u64)>, + end_operand: u64, +} + +const DIVIDED: &[u8] = &[0xC1, 0xEA, 0x06, 0x52, 0xE8, 0, 0, 0, 0, 0xA3]; +const DIVIDED_RELOAD: &[u8] = &[ + 0xC1, 0xEA, 0x05, 0x52, 0xE8, 0, 0, 0, 0, 0x8B, 0x0D, 0, 0, 0, 0, 0xA3, +]; +const PUSHED: &[u8] = &[0xFF, 0xB0, 0xF4, 0, 0, 0, 0xE8, 0, 0, 0, 0, 0xA3]; + +impl X86Image { + fn with_store(store: &'static [u8], operand_at: u64) -> Self { + Self { + stores: vec![(0x480, store, operand_at, BASE + 0x910)], + end_operand: BASE + 0x904, + } + } + + fn lay(&self) -> vec::Vec { + let mut i = vec![0; 0x2000]; + let abs = |target: u64| (target as u32).to_le_bytes(); + + // jne; mov esi, [begin]; sub edi, ecx; cmp esi, [end] + put(&mut i, 0x100, &[0x75, 0xF9, 0x8B, 0x35]); + put(&mut i, 0x104, &abs(BASE + 0x900)); + put(&mut i, 0x108, &[0x2B, 0xF9, 0x3B, 0x35]); + put(&mut i, 0x10C, &abs(self.end_operand)); + + // push offset "global-metadata.dat"; call + put(&mut i, 0x300, b"global-metadata.dat\0"); + put(&mut i, 0x400, &[0x68]); + put(&mut i, 0x401, &abs(BASE + 0x300)); + put(&mut i, 0x405, &[0xE8, 0, 0, 0, 0]); + + for &(at, store, operand_at, target) in &self.stores { + put(&mut i, at, store); + put(&mut i, at + operand_at, &abs(target)); + } + i + } +} + +fn x86_image(store: &'static [u8], operand_at: u64) -> vec::Vec { + X86Image::with_store(store, operand_at).lay() +} + +// The scan reads no offsets, and the version tables carry no 32 bit arm, so +// the x64 offsets stand in. The module is the first 0x1000 bytes of the +// image, so the image can hold bytes past the module's end. +fn attach_x86(process: &crate::Process) -> Option { + Module::attach_with( + process, + (Address::new(BASE), 0x1000), + PointerSize::Bit32, + Version::V2022, + IL2CPPOffsets::new(Version::V2022, PointerSize::Bit64).unwrap(), + ) +} + +// shr edx, 6; push edx; call; mov [table], eax +#[test] +fn x86_globals_resolve_through_a_divided_count() { + let i = x86_image(DIVIDED, 10); + with_process(&[(BASE, &i)], |process| { + let module = attach_x86(process).unwrap(); + assert_eq!(module.assemblies, Address::new(BASE + 0x900)); + assert_eq!( + module.type_info_definition_table, + Address::new(BASE + 0x910) + ); + }); +} + +// shr edx, 5; push edx; call; mov ecx, [x]; mov [table], eax +#[test] +fn x86_globals_resolve_through_a_divided_count_and_a_reload() { + let i = x86_image(DIVIDED_RELOAD, 16); + with_process(&[(BASE, &i)], |process| { + let module = attach_x86(process).unwrap(); + assert_eq!( + module.type_info_definition_table, + Address::new(BASE + 0x910) + ); + }); +} + +// push dword [eax + 0xF4]; call; mov [table], eax +#[test] +fn x86_globals_resolve_through_a_pushed_count() { + let i = x86_image(PUSHED, 12); + with_process(&[(BASE, &i)], |process| { + let module = attach_x86(process).unwrap(); + assert_eq!( + module.type_info_definition_table, + Address::new(BASE + 0x910) + ); + }); +} + +// The table is the first store after the name, whatever its shape. A later +// store of another shape, like the next table's, must not win. +#[test] +fn x86_table_store_is_the_first_of_any_shape_after_the_name() { + let image = X86Image { + stores: vec![ + (0x480, DIVIDED, 10, BASE + 0x910), + (0x4A0, DIVIDED_RELOAD, 16, BASE + 0x920), + ], + end_operand: BASE + 0x904, + }; + let i = image.lay(); + with_process(&[(BASE, &i)], |process| { + let module = attach_x86(process).unwrap(); + assert_eq!( + module.type_info_definition_table, + Address::new(BASE + 0x910) + ); + }); +} + +// A vector's end sits one pointer past its begin. A loop over some other +// pair is not the assemblies. +#[test] +fn x86_assemblies_scan_wants_the_end_beside_the_begin() { + let image = X86Image { + stores: vec![(0x480, DIVIDED, 10, BASE + 0x910)], + end_operand: BASE + 0x930, + }; + let i = image.lay(); + with_process(&[(BASE, &i)], |process| { + assert!(attach_x86(process).is_none()); + }); +} + +// A global the code points at outside the module is not this module's. +#[test] +fn x86_globals_outside_the_module_are_refused() { + let image = X86Image { + stores: vec![(0x480, DIVIDED, 10, BASE + 0x1910)], + end_operand: BASE + 0x904, + }; + let i = image.lay(); + with_process(&[(BASE, &i)], |process| { + assert!(attach_x86(process).is_none()); + }); +} + +// The window after the name stops at the module's end, even when memory +// goes on past it. +#[test] +fn x86_store_scan_stops_at_the_module_end() { + let image = X86Image { + stores: vec![(0x1080, DIVIDED, 10, BASE + 0x910)], + end_operand: BASE + 0x904, + }; + let mut i = image.lay(); + // Move the name push to the module's last page, so the window reaches + // past the end. + put(&mut i, 0x400, &[0; 10]); + put(&mut i, 0xF80, &[0x68]); + put(&mut i, 0xF81, &(BASE as u32 + 0x300).to_le_bytes()); + put(&mut i, 0xF85, &[0xE8, 0, 0, 0, 0]); + with_process(&[(BASE, &i)], |process| { + assert!(attach_x86(process).is_none()); + }); +} + +// The x64 code that points at both globals, with displacements from the next +// instruction. +#[test] +fn x64_globals_resolve_from_a_mapped_image() { + let mut i = vec![0; 0x1000]; + let rel = |from: u64, to: u64| ((to as i64 - (from + 4) as i64) as i32).to_le_bytes(); + + // jne; mov rbx, [begin]; cmp rbx, [end] + put(&mut i, 0x100, &[0x75, 0xF9, 0x48, 0x8B, 0x1D]); + put(&mut i, 0x105, &rel(BASE + 0x105, BASE + 0x900)); + put(&mut i, 0x109, &[0x48, 0x3B, 0x1D]); + put(&mut i, 0x10C, &rel(BASE + 0x10C, BASE + 0x908)); + + put(&mut i, 0x300, b"global-metadata.dat\0"); + // lea rcx, [name]; ... shr rcx, 6; ... mov [table], rax + put(&mut i, 0x400, &[0x48, 0x8D, 0x0D]); + put(&mut i, 0x403, &rel(BASE + 0x403, BASE + 0x300)); + put(&mut i, 0x480, &[0x48, 0xC1, 0xE9, 0x06]); + put(&mut i, 0x4A0, &[0x48, 0x89, 0x05]); + put(&mut i, 0x4A3, &rel(BASE + 0x4A3, BASE + 0x910)); + + with_process(&[(BASE, &i)], |process| { + let module = Module::attach_with( + process, + (Address::new(BASE), 0x1000), + PointerSize::Bit64, + Version::V2022, + IL2CPPOffsets::new(Version::V2022, PointerSize::Bit64).unwrap(), + ) + .unwrap(); + assert_eq!(module.assemblies, Address::new(BASE + 0x900)); + assert_eq!( + module.type_info_definition_table, + Address::new(BASE + 0x910) + ); + }); +} + +// The 64 bit scanner reads displacements, so it must not accept x86 code. +#[test] +fn x64_scanner_refuses_an_x86_image() { + let i = x86_image(DIVIDED, 10); + with_process(&[(BASE, &i)], |process| { + assert!(Module::attach_with( + process, + (Address::new(BASE), 0x1000), + PointerSize::Bit64, + Version::V2022, + IL2CPPOffsets::new(Version::V2022, PointerSize::Bit64).unwrap(), + ) + .is_none()); + }); +} + +// Every measured build names an assembly through its image, at the image's +// own name field, where the version tables read it off the assembly. +#[test] +fn assembly_names_resolve_through_the_image() { + let build = super::builds::find(107, (6000, 5, 10, 54518), PointerSize::Bit64).unwrap(); + let offsets = &build.offsets; + assert!(offsets.assembly.aname.is_none()); + let name_at = offsets.image.assembly_name.unwrap() as u64; + + let mut i = vec![0; 0x1000]; + let ptr = |i: &mut [u8], at: u64, target: u64| { + put(i, at, &target.to_le_bytes()); + }; + put(&mut i, 0x800, b"Assembly-CSharp"); + ptr(&mut i, 0x0, BASE + 0x40); // the vector's begin + ptr(&mut i, 0x8, BASE + 0x48); // and end, one assembly along + ptr(&mut i, 0x40, BASE + 0x80); + ptr(&mut i, 0x80, BASE + 0x100); // Il2CppAssembly.image + ptr(&mut i, 0x100 + name_at, BASE + 0x800); // Il2CppImage.nameNoExt + + with_process(&[(BASE, &i)], |process| { + let module = Module { + assemblies: Address::new(BASE), + type_info_definition_table: Address::new(BASE + 0x10), + version: Version::V2022, + offsets, + pointer_size: PointerSize::Bit64, + }; + let image = module.get_default_image(process).unwrap(); + assert_eq!(image.image, Address::new(BASE + 0x100)); + }); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +// The target's structures, hand-laid: the assemblies vector, the type info +// definition table sliced by the image's handle, a parent chain reaching a +// UnityEngine class, a static table, and a live object heading with its class. +fn image(version: Version) -> Vec { + let (type_count_at, handle_at, field_count_at) = match version { + Version::V2019 => (0x1C, 0x18, 0x11C), + Version::V2020 => (0x18, 0x28, 0x120), + _ => (0x18, 0x28, 0x124), + }; + + let mut i = vec![0; 0x4000]; + + let strings = [ + (0x2000, "mscorlib"), + (0x2080, "Assembly-CSharp"), + (0x2100, "GameManager"), + (0x2180, "Game"), + (0x2200, "points"), + (0x2280, "Enemy"), + (0x2300, "hp"), + (0x2380, "Boss"), + (0x2400, "phase"), + (0x2480, "MonoBehaviour"), + (0x2500, "UnityEngine"), + (0x2580, "hidden"), + (0x2600, "instance"), + (0x2700, "Outer"), + (0x2780, "Inner"), + (0x2800, "spawner"), + (0x2880, "_items"), + (0x2900, "_size"), + ]; + for (at, text) in strings { + put(&mut i, at, text.as_bytes()); + } + + // The assemblies vector: begin and end of an array of assembly pointers. + ptr(&mut i, 0x0, BASE + 0x40); + ptr(&mut i, 0x8, BASE + 0x50); + ptr(&mut i, 0x40, BASE + 0x80); + ptr(&mut i, 0x48, BASE + 0xC0); + + // Il2CppAssembly: the image at 0x0, the name at 0x18. + ptr(&mut i, 0x80, BASE + 0x140); + ptr(&mut i, 0x80 + 0x18, BASE + 0x2000); + ptr(&mut i, 0xC0, BASE + 0x300); + ptr(&mut i, 0xC0 + 0x18, BASE + 0x2080); + + // The default image: three classes, reached through the handle. The older + // lineage stores the handle inline where the newer one points at it. + put(&mut i, 0x300 + type_count_at, &5_u32.to_le_bytes()); + match version { + Version::V2019 => put(&mut i, 0x300 + handle_at, &5_u32.to_le_bytes()), + _ => { + ptr(&mut i, 0x300 + handle_at, BASE + 0x400); + put(&mut i, 0x400, &5_u32.to_le_bytes()); + } + } + + // The type info definition table global, and the image's slice of it. + ptr(&mut i, 0x10, BASE + 0x480); + ptr(&mut i, 0x480 + 8 * 5, BASE + 0x600); + ptr(&mut i, 0x480 + 8 * 6, BASE + 0x800); + ptr(&mut i, 0x480 + 8 * 7, BASE + 0xA00); + ptr(&mut i, 0x480 + 8 * 8, BASE + 0x1200); + ptr(&mut i, 0x480 + 8 * 9, BASE + 0x1400); + + // Il2CppClass: name 0x10, namespace 0x18, parent 0x58, fields 0x80, + // static_fields 0xB8, field_count where the lineage keeps it. Field + // entries stride 0x20 with the name at 0x0 and the offset at 0x18. + + // GameManager, deriving from MonoBehaviour, with a static slot and an + // instance field. + let game_manager = 0x600; + ptr(&mut i, game_manager + 0x10, BASE + 0x2100); + ptr(&mut i, game_manager + 0x18, BASE + 0x2180); + ptr(&mut i, game_manager + 0x58, BASE + 0xC00); + ptr(&mut i, game_manager + 0x80, BASE + 0xE00); + ptr(&mut i, game_manager + 0xB8, BASE + 0xF40); + put(&mut i, game_manager + field_count_at, &2_u16.to_le_bytes()); + ptr(&mut i, 0xE00, BASE + 0x2600); // instance + put(&mut i, 0xE00 + 0x18, &0_i32.to_le_bytes()); + ptr(&mut i, 0xE20, BASE + 0x2200); // points + put(&mut i, 0xE20 + 0x18, &0x20_i32.to_le_bytes()); + + // Enemy with an instance field and a static slot, and Boss deriving from + // it. + let enemy = 0x800; + ptr(&mut i, enemy + 0x10, BASE + 0x2280); + ptr(&mut i, enemy + 0x18, BASE + 0x2180); + ptr(&mut i, enemy + 0x80, BASE + 0xE80); + ptr(&mut i, enemy + 0xB8, BASE + 0xFC0); + put(&mut i, enemy + field_count_at, &2_u16.to_le_bytes()); + ptr(&mut i, 0xE80, BASE + 0x2300); // hp + put(&mut i, 0xE80 + 0x18, &0x10_i32.to_le_bytes()); + ptr(&mut i, 0xEA0, BASE + 0x2800); // spawner + put(&mut i, 0xEA0 + 0x18, &0x8_i32.to_le_bytes()); + + let boss = 0xA00; + ptr(&mut i, boss + 0x10, BASE + 0x2380); + ptr(&mut i, boss + 0x18, BASE + 0x2180); + ptr(&mut i, boss + 0x58, BASE + enemy); + ptr(&mut i, boss + 0x80, BASE + 0xEC0); + ptr(&mut i, boss + 0xB8, BASE + 0x1000); + put(&mut i, boss + field_count_at, &1_u16.to_le_bytes()); + ptr(&mut i, 0xEC0, BASE + 0x2400); // phase + put(&mut i, 0xEC0 + 0x18, &0x18_i32.to_le_bytes()); + + // MonoBehaviour in UnityEngine, holding a field the climb must never + // reach. + let mono_behaviour = 0xC00; + ptr(&mut i, mono_behaviour + 0x10, BASE + 0x2480); + ptr(&mut i, mono_behaviour + 0x18, BASE + 0x2500); + ptr(&mut i, mono_behaviour + 0x80, BASE + 0xF00); + put( + &mut i, + mono_behaviour + field_count_at, + &1_u16.to_le_bytes(), + ); + ptr(&mut i, 0xF00, BASE + 0x2580); // hidden + put(&mut i, 0xF00 + 0x18, &0x30_i32.to_le_bytes()); + + // Outer in Game, enclosing Inner, whose own namespace is empty and whose + // declaring type points back out. + let outer = 0x1200; + ptr(&mut i, outer + 0x10, BASE + 0x2700); + ptr(&mut i, outer + 0x18, BASE + 0x2180); + let inner = 0x1400; + ptr(&mut i, inner + 0x10, BASE + 0x2780); + ptr(&mut i, inner + 0x18, BASE + 0x27F0); + ptr(&mut i, inner + 0x50, BASE + outer); + + // GameManager's statics hold the live instance, which heads with its + // class. + ptr(&mut i, 0xF40, BASE + 0xF80); + ptr(&mut i, 0xF80, BASE + game_manager); + put(&mut i, 0xF80 + 0x20, &888_u32.to_le_bytes()); + + // Enemy's statics hold the spawner instance. Boss carries a table of its + // own, empty at that offset, so only the declaring class's table answers. + ptr(&mut i, 0xFC0 + 0x8, BASE + 0x1080); + + // A List: its class carries corlib's field names, its live object heads + // with the class and holds a backing array longer than the live count. + let list_class = 0x1600; + ptr(&mut i, list_class + 0x80, BASE + 0x1780); + put(&mut i, list_class + field_count_at, &2_u16.to_le_bytes()); + ptr(&mut i, 0x1780, BASE + 0x2880); // _items + put(&mut i, 0x1780 + 0x18, &0x10_i32.to_le_bytes()); + ptr(&mut i, 0x17A0, BASE + 0x2900); // _size + put(&mut i, 0x17A0 + 0x18, &0x18_i32.to_le_bytes()); + + ptr(&mut i, 0x1800, BASE + list_class); // the list object heads with its class + ptr(&mut i, 0x1800 + 0x10, BASE + 0x1900); + put(&mut i, 0x1800 + 0x18, &2_i32.to_le_bytes()); + put(&mut i, 0x1900 + 0x18, &4_u32.to_le_bytes()); // the backing's capacity + for (index, value) in [11_u32, 22, 100, 100].into_iter().enumerate() { + put( + &mut i, + 0x1900 + 0x20 + 4 * index as u64, + &value.to_le_bytes(), + ); + } + + ptr(&mut i, 0x18, BASE + 0x1800); // the slot holding the reference + + i +} + +fn module(version: Version) -> Module { + Module { + assemblies: Address::new(BASE), + type_info_definition_table: Address::new(BASE + 0x10), + version, + offsets: IL2CPPOffsets::new(version, PointerSize::Bit64).unwrap(), + pointer_size: PointerSize::Bit64, + } +} + +fn on_fixture(version: Version, test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image(version))], |process| { + test(process, &module(version)); + }); +} + +#[test] +fn images_resolve_by_name_in_both_lineages() { + for version in [Version::V2019, Version::V2022] { + on_fixture(version, |process, module| { + assert!(module.get_default_image(process).is_some()); + assert!(module.get_image(process, "mscorlib").is_some()); + assert!(module.get_image(process, "Assembly-DoesNotExist").is_none()); + }); + } +} + +#[test] +fn classes_resolve_by_name_and_namespace() { + for version in [Version::V2019, Version::V2022] { + on_fixture(version, |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image.get_class(process, module, "GameManager").is_some()); + assert!(image.get_class(process, module, "Game.Boss").is_some()); + assert!(image.get_class(process, module, "Wrong.Boss").is_none()); + assert!(image.get_class(process, module, "Nothing").is_none()); + assert_eq!(image.classes(process, module).count(), 5); + }); + } +} + +#[test] +fn field_offsets_resolve_declared_and_inherited() { + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + let game_manager = image.get_class(process, module, "GameManager").unwrap(); + assert_eq!( + game_manager.get_field_offset(process, module, "points"), + Some(0x20), + ); + + let boss = image.get_class(process, module, "Boss").unwrap(); + assert_eq!(boss.get_field_offset(process, module, "phase"), Some(0x18)); + assert_eq!(boss.get_field_offset(process, module, "hp"), Some(0x10)); + }); +} + +#[test] +fn nested_classes_resolve_by_their_written_name() { + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image + .get_class(process, module, "Game.Outer+Inner") + .is_some()); + assert!(image + .get_class(process, module, "Game.Outer+Missing") + .is_none()); + assert!(image + .get_class(process, module, "Wrong.Outer+Inner") + .is_none()); + assert!(image + .get_class(process, module, "Game.Enemy+Inner") + .is_none()); + }); +} + +// V2020's table never measured where a class keeps its declaring type, so a +// nested lookup on it must miss cleanly rather than answer with whichever +// class carries the leaf name. +#[test] +fn nested_lookups_without_a_measured_offset_answer_nothing() { + on_fixture(Version::V2020, |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image + .get_class(process, module, "Game.Outer+Inner") + .is_none()); + assert!(image.get_class(process, module, "GameManager").is_some()); + }); +} + +// The climb stops at UnityEngine's namespace, so an engine field never +// resolves. +#[test] +fn field_climbs_stop_at_the_engine() { + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + let game_manager = image.get_class(process, module, "GameManager").unwrap(); + assert!(game_manager + .get_field_offset(process, module, "hidden") + .is_none()); + }); +} + +#[test] +fn statics_resolve_from_the_class() { + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + let game_manager = image.get_class(process, module, "GameManager").unwrap(); + assert_eq!( + game_manager.get_static_table(process, module), + Some(Address::new(BASE + 0xF40)), + ); + }); +} + +// A static field found on a parent measures into the parent's own static +// table, not the table of the class the lookup started at. +#[test] +fn static_instances_resolve_through_the_declaring_class() { + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + let boss = image.get_class(process, module, "Boss").unwrap(); + assert_eq!( + poll_once(boss.wait_get_static_instance(process, module, "spawner")), + Poll::Ready(Address::new(BASE + 0x1080)), + ); + + let pointer = UnityPointer::<1>::new("Boss", 0, &["spawner"]); + assert_eq!( + pointer.deref::(process, module, &image).unwrap(), + BASE + 0x1080, + ); + }); +} + +// A list's backing array and live count resolve off the list object's own +// class, and the read returns the live count's elements, never the backing +// capacity's. +#[test] +fn lists_resolve_through_their_own_class() { + on_fixture(Version::V2022, |process, module| { + let at = Address::new(BASE + 0x18); + let offsets = module.get_list_offsets(process, at).unwrap(); + let read = module.read_list::(process, offsets, at).unwrap(); + assert_eq!(read.as_slice(), [11, 22]); + }); +} + +// The whole pointer path: the static root, the instance behind it, and a field +// resolved against the object's own class read off its head. +#[test] +fn pointers_dereference_through_a_static_root() { + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + let pointer = UnityPointer::<2>::new("GameManager", 0, &["instance", "points"]); + assert_eq!(pointer.deref::(process, module, &image).unwrap(), 888); + }); +} + +// The public shapes the carve must not change. +#[test] +fn public_types_keep_their_properties() { + fn is_copy() {} + fn double_ended<'a>( + iter: impl DoubleEndedIterator + 'a, + ) -> impl DoubleEndedIterator + 'a { + iter + } + + is_copy::(); + is_copy::(); + + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + let _ = double_ended(image.classes(process, module)); + }); +} + +// A 32 bit target lays the assemblies vector and its pointers at four bytes. +#[test] +fn images_resolve_on_32_bit_targets() { + let mut i = vec![0; 0x1000]; + let narrow = |i: &mut [u8], at: u64, target: u64| { + put(i, at, &(target as u32).to_le_bytes()); + }; + + put(&mut i, 0x800, b"Assembly-CSharp"); + narrow(&mut i, 0x0, BASE + 0x40); // the vector's begin + narrow(&mut i, 0x4, BASE + 0x44); // and end, one assembly along + narrow(&mut i, 0x40, BASE + 0x80); + narrow(&mut i, 0x80, BASE + 0x100); // Il2CppAssembly.image + narrow(&mut i, 0x80 + 0x18, BASE + 0x800); // Il2CppAssembly.aname + + with_process(&[(BASE, &i)], |process| { + let module = Module { + assemblies: Address::new(BASE), + type_info_definition_table: Address::new(BASE + 0x10), + version: Version::V2022, + offsets: IL2CPPOffsets::new(Version::V2022, PointerSize::Bit64).unwrap(), + pointer_size: PointerSize::Bit32, + }; + assert!(module.get_default_image(process).is_some()); + }); +} diff --git a/src/game_engine/unity/managed/cursor.rs b/src/game_engine/unity/managed/cursor.rs new file mode 100644 index 00000000..55ef411f --- /dev/null +++ b/src/game_engine/unity/managed/cursor.rs @@ -0,0 +1,283 @@ +use super::runtime::{Il2CppRuntime, MonoRuntime}; +use super::{slot, ClassRef}; +use crate::{Address, Address32, Address64, PointerSize, Process}; + +/// Walks the runtime's loaded assemblies. +pub struct Assemblies<'a> { + process: &'a Process, + pointer_size: PointerSize, + state: AssembliesState, +} + +enum AssembliesState { + /// The glib list: each node carries the assembly and the next node. + Mono { node: Option
}, + /// The vector: a slice of assembly pointers. + Il2Cpp { + base: Address, + count: u64, + index: u64, + }, +} + +impl<'a> Assemblies<'a> { + pub(super) fn mono( + process: &'a Process, + pointer_size: PointerSize, + mono: &MonoRuntime, + ) -> Self { + Self { + process, + pointer_size, + state: AssembliesState::Mono { + node: process + .read_pointer(mono.assemblies, pointer_size) + .ok() + .filter(|address| !address.is_null()), + }, + } + } + + pub(super) fn il2cpp( + process: &'a Process, + pointer_size: PointerSize, + il2cpp: &Il2CppRuntime, + ) -> Self { + let first = process + .read_pointer(il2cpp.assemblies, pointer_size) + .unwrap_or_default(); + let limit = process + .read_pointer(il2cpp.assemblies + pointer_size as u64, pointer_size) + .unwrap_or_default(); + + Self { + process, + pointer_size, + state: AssembliesState::Il2Cpp { + base: first, + count: limit.value().saturating_sub(first.value()) / pointer_size as u64, + index: 0, + }, + } + } +} + +impl Iterator for Assemblies<'_> { + type Item = Address; + + fn next(&mut self) -> Option
{ + match &mut self.state { + AssembliesState::Mono { node } => { + let at = (*node)?; + + let [data, next]: [Address; 2] = match self.pointer_size { + PointerSize::Bit64 => self + .process + .read::<[Address64; 2]>(at) + .ok()? + .map(|address| address.into()), + _ => self + .process + .read::<[Address32; 2]>(at) + .ok()? + .map(|address| address.into()), + }; + + *node = Some(next); + + Some(data) + } + AssembliesState::Il2Cpp { base, count, index } => loop { + if index >= count { + return None; + } + + let at = slot(*base, self.pointer_size, *index); + *index += 1; + + if let Some(assembly) = self + .process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + { + return Some(assembly); + } + }, + } + } +} + +/// Walks the classes an image holds. +pub struct Classes<'a> { + process: &'a Process, + pointer_size: PointerSize, + state: ClassesState, +} + +enum ClassesState { + /// The image's hash table: a bucket array whose entries chain through the + /// classes themselves. + Mono { + table: Address, + // The size the runtime stores is signed, and the walk has always taken + // it as a count wholesale, garbage included. + size: u64, + bucket: u64, + chain: Option
, + next_class_cache: u16, + }, + /// The image's slice of the type info definition table. + Il2Cpp { + slots: Address, + count: u64, + index: u64, + }, +} + +impl<'a> Classes<'a> { + pub(super) fn mono( + process: &'a Process, + pointer_size: PointerSize, + mono: &MonoRuntime, + image: super::ImageRef, + ) -> Self { + let cache = image.address + mono.class_cache; + + let size = process + .read::(cache + mono.hash_table_size) + .unwrap_or_default() as u64; + + let table = match size { + 0 => Address::NULL, + _ => process + .read_pointer(cache + mono.hash_table_table, pointer_size) + .unwrap_or_default(), + }; + + Self { + process, + pointer_size, + state: ClassesState::Mono { + table, + size, + bucket: 0, + chain: None, + next_class_cache: mono.next_class_cache, + }, + } + } + + pub(super) fn il2cpp( + process: &'a Process, + pointer_size: PointerSize, + il2cpp: &Il2CppRuntime, + image: super::ImageRef, + ) -> Self { + let count = process + .read::(image.address + il2cpp.type_count) + .unwrap_or_default() as u64; + + let metadata = match (count, il2cpp.handle_is_inline) { + (0, _) => Address::NULL, + (_, true) => image.address + il2cpp.metadata_handle, + (_, false) => process + .read_pointer(image.address + il2cpp.metadata_handle, pointer_size) + .unwrap_or_default(), + }; + + let handle = match metadata { + Address::NULL => 0, + at => process.read::(at).unwrap_or_default(), + }; + + let table = match metadata { + Address::NULL => Address::NULL, + _ => process + .read_pointer(il2cpp.type_info_definition_table, pointer_size) + .unwrap_or_default(), + }; + + let slots = match table { + Address::NULL => Address::NULL, + _ => slot(table, pointer_size, handle as u64), + }; + + Self { + process, + pointer_size, + state: ClassesState::Il2Cpp { + slots, + count, + index: 0, + }, + } + } + + /// The slot array and its length, for the caller that iterates the slots + /// itself. + pub const fn slots(&self) -> Option<(Address, u64)> { + match &self.state { + ClassesState::Il2Cpp { slots, count, .. } => Some((*slots, *count)), + ClassesState::Mono { .. } => None, + } + } +} + +impl Iterator for Classes<'_> { + type Item = ClassRef; + + fn next(&mut self) -> Option { + match &mut self.state { + ClassesState::Mono { + table, + size, + bucket, + chain, + next_class_cache, + } => loop { + if let Some(class) = *chain { + *chain = self + .process + .read_pointer(class + *next_class_cache, self.pointer_size) + .ok() + .filter(|address| !address.is_null()); + + return Some(ClassRef::new(class)); + } + + if table.is_null() || bucket >= size { + return None; + } + + *chain = self + .process + .read_pointer(slot(*table, self.pointer_size, *bucket), self.pointer_size) + .ok() + .filter(|address| !address.is_null()); + *bucket += 1; + }, + ClassesState::Il2Cpp { + slots, + count, + index, + } => loop { + if index >= count { + return None; + } + + let at = slot(*slots, self.pointer_size, *index); + *index += 1; + + if let Some(class) = self + .process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + { + return Some(ClassRef::new(class)); + } + }, + } + } +} diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs new file mode 100644 index 00000000..0a513d85 --- /dev/null +++ b/src/game_engine/unity/managed/mod.rs @@ -0,0 +1,132 @@ +//! The walk over a managed runtime's metadata, shared by the runtimes that lay +//! their classes and fields out the same way. +//! +//! What the runtimes genuinely disagree on is behind [`Runtime`]: where the +//! images live, where an image keeps its classes, how a class counts its +//! fields, where its statics sit, and how a live object names its class. Below +//! that, the walk is written once. + +mod cursor; +mod pointer; +mod readers; +mod runtime; +mod walk; + +pub use cursor::{Assemblies, Classes}; +pub use pointer::PointerPath; +pub use readers::{ + read_array, read_dictionary, read_list, read_string, DictionaryOffsets, EntryLayout, + ListOffsets, +}; +pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; +pub use walk::Walk; + +use crate::{string::ArrayCString, Address, PointerSize, Process}; + +/// The offsets the shared walk reads, copied out of whichever runtime's own +/// offsets built it, so the walk reads plain numbers without knowing whose +/// sections they came from. +pub struct WalkOffsets { + pub assembly: AssemblyOffsets, + pub class: ClassOffsets, + pub field: FieldOffsets, +} + +/// Where an assembly keeps its image, and where its name is: on the assembly +/// itself, or through the image, whichever the offsets carry. +pub struct AssemblyOffsets { + pub name_in_image: Option, + pub name_in_assembly: Option, + pub image: u16, +} + +/// Where a class keeps its names, its parent, its field array, and, when +/// they were measured, the class it is nested in and what one instance +/// occupies. +pub struct ClassOffsets { + pub name: u16, + pub namespace: u16, + pub parent: u16, + pub declaring: Option, + pub instance_size: Option, + pub fields: u16, +} + +/// Where a field entry keeps its name, its type, and its offset, and the +/// size of one entry in a class's field array, whatever each runtime's own +/// offsets call it. +pub struct FieldOffsets { + pub name: u16, + pub type_: Option, + pub offset: u16, + pub stride: u16, +} + +/// The names a walk stops climbing at when either answers, which is engine +/// policy rather than anything the runtime says: the engine's own classes +/// carry fields a game never declares. +pub struct ClimbStop { + pub class: &'static str, + pub namespace: &'static str, +} + +impl ClimbStop { + /// Unity's own base classes. + pub const UNITY: Self = Self { + class: "Object", + namespace: "UnityEngine", + }; +} + +/// A class, named by where the runtime keeps it. +#[derive(Copy, Clone)] +pub struct ClassRef { + pub address: Address, +} + +impl ClassRef { + pub const fn new(address: Address) -> Self { + Self { address } + } +} + +/// A field, named by where the runtime keeps it. +#[derive(Copy, Clone)] +pub struct FieldRef { + pub address: Address, +} + +impl FieldRef { + pub const fn new(address: Address) -> Self { + Self { address } + } +} + +/// An image, named by where the runtime keeps it. +#[derive(Copy, Clone)] +pub struct ImageRef { + pub address: Address, +} + +impl ImageRef { + pub const fn new(address: Address) -> Self { + Self { address } + } +} + +/// The address of a pointer-sized slot in an array of them. +pub fn slot(base: Address, pointer_size: PointerSize, index: u64) -> Address { + base + (pointer_size as u64).wrapping_mul(index) +} + +/// Reads a name the runtime stores behind a pointer. +pub fn read_name( + process: &Process, + pointer_size: PointerSize, + at: Address, +) -> Option> { + process + .read_pointer(at, pointer_size) + .and_then(|address| process.read(address)) + .ok() +} diff --git a/src/game_engine/unity/managed/pointer.rs b/src/game_engine/unity/managed/pointer.rs new file mode 100644 index 00000000..0dffb423 --- /dev/null +++ b/src/game_engine/unity/managed/pointer.rs @@ -0,0 +1,153 @@ +use super::{ClassRef, ImageRef, Walk}; +use crate::{Address, Error, Process}; +use bytemuck::CheckedBitPattern; +use core::{array, cell::RefCell}; + +/// The pointer path resolution both backends' `UnityPointer` types share: a +/// static root found by class name, then fields resolved by name or written as +/// literal offsets, remembered across calls so a failed resolution resumes +/// where it left off. +pub struct PointerPath { + inner: RefCell>, +} + +struct PointerPathInternal { + base_address: Address, + offsets: [u32; CAP], + resolved_offsets: usize, + + starting_class_name: &'static str, + starting_class: Option, + nr_of_parents: usize, + fields: [&'static str; CAP], + depth: usize, +} + +impl PointerPath { + pub fn new(class_name: &'static str, nr_of_parents: usize, fields: &[&'static str]) -> Self { + let named_fields: [&str; CAP] = + array::from_fn(|i| fields.get(i).copied().unwrap_or_default()); + + Self { + inner: RefCell::new(PointerPathInternal { + base_address: Address::NULL, + offsets: [0; CAP], + resolved_offsets: 0, + starting_class_name: class_name, + starting_class: None, + nr_of_parents, + fields: named_fields, + depth: fields.len().min(CAP), + }), + } + } + + /// Tries to resolve the pointer path, resuming behind whatever resolved on + /// an earlier call. Finding the starting class walks every class the image + /// holds, so it is remembered the first time it answers. + fn find_offsets(&self, process: &Process, walk: &Walk, image: ImageRef) -> Result<(), Error> { + let mut inner = self.inner.borrow_mut(); + + if inner.resolved_offsets == inner.depth { + return Ok(()); + } + + let starting_class = match inner.starting_class { + Some(starting_class) => starting_class, + _ => { + let mut class = walk + .find_class(process, image, inner.starting_class_name) + .ok_or(Error {})?; + + for _ in 0..inner.nr_of_parents { + class = walk.parent(process, class).ok_or(Error {})?; + } + + inner.starting_class = Some(class); + class + } + }; + + let parse = |field: &str| match field.strip_prefix("0x") { + Some(rem) => u32::from_str_radix(rem, 16).ok(), + _ => field.parse().ok(), + }; + + // The root field and the base table resolve together: the root's + // offset measures into the static table of whichever class declares + // it, which a climb may find on a parent. + if inner.resolved_offsets == 0 { + let (declaring, offset) = match parse(inner.fields[0]) { + Some(offset) => (starting_class, offset), + _ => walk + .find_field_offset(process, starting_class, inner.fields[0]) + .ok_or(Error {})?, + }; + + inner.base_address = walk.static_table(process, declaring).ok_or(Error {})?; + inner.offsets[0] = offset; + inner.resolved_offsets = 1; + } + + // Whatever resolved already is walked again from the base, which is + // what recovers the farthest object the resolution reached. + let mut current_object = { + let mut address = inner.base_address; + for &offset in &inner.offsets[..inner.resolved_offsets] { + address = process.read_pointer(address + offset, walk.pointer_size)?; + } + address + }; + + for i in inner.resolved_offsets..inner.depth { + let current_offset = match parse(inner.fields[i]) { + Some(offset) => offset, + _ => { + let current_class = + walk.object_class(process, current_object).ok_or(Error {})?; + + walk.find_field_offset(process, current_class, inner.fields[i]) + .ok_or(Error {})? + .1 + } + }; + + inner.offsets[i] = current_offset; + inner.resolved_offsets += 1; + + current_object = + process.read_pointer(current_object + current_offset, walk.pointer_size)?; + } + + Ok(()) + } + + /// Dereferences the pointer path, returning the memory address of the + /// value of interest. + pub fn deref_offsets( + &self, + process: &Process, + walk: &Walk, + image: ImageRef, + ) -> Result { + self.find_offsets(process, walk, image)?; + let inner = self.inner.borrow(); + let mut address = inner.base_address; + let (&last, path) = inner.offsets[..inner.depth].split_last().ok_or(Error {})?; + for &offset in path { + address = process.read_pointer(address + offset, walk.pointer_size)?; + } + Ok(address + last) + } + + /// Dereferences the pointer path, returning the value stored at the final + /// memory address. + pub fn deref( + &self, + process: &Process, + walk: &Walk, + image: ImageRef, + ) -> Result { + process.read(self.deref_offsets(process, walk, image)?) + } +} diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs new file mode 100644 index 00000000..73e9289a --- /dev/null +++ b/src/game_engine/unity/managed/readers.rs @@ -0,0 +1,280 @@ +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; +use core::mem::{size_of, MaybeUninit}; + +use crate::{string::ArrayWString, Address, Error, PointerSize, Process}; + +/// How many bytes a managed object's two header words occupy. The readers +/// skip them; nothing in them is read. +const fn object_header(pointer_size: PointerSize) -> u64 { + 2 * pointer_size as u64 +} + +/// Reads a managed string through the reference stored at the given address. +/// The layout is runtime ABI, shared by both runtimes at both widths: the +/// character count as an i32 past the object header, the UTF-16 characters +/// inline behind it. +pub fn read_string( + process: &Process, + pointer_size: PointerSize, + at: Address, +) -> Result, Error> { + let object = process + .read_pointer(at, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let header = object_header(pointer_size); + let count = process.read::(object + header)?; + + // The buffer size is the bound past which a claimed count is nonsense: a + // torn or garbage read claims billions, and something has to refuse + // before reading it. Refusal, never truncation. + let count = usize::try_from(count) + .ok() + .filter(|&count| count <= N) + .ok_or(Error {})?; + + let mut string = ArrayWString::new(); + let characters = &mut bytemuck::bytes_of_mut(&mut string)[..2 * count]; + process.read_into_slice(object + header + 4, characters)?; + + Ok(string) +} + +/// Where a list keeps its backing array and live count, resolved once off +/// the list's own class and held by the caller, so the per-tick read costs +/// reads rather than a metadata walk. +#[derive(Copy, Clone)] +pub struct ListOffsets { + pub(crate) items: u32, + pub(crate) size: u32, +} + +/// The most bytes one entry may span: past this a claimed layout is +/// garbage, and at most this many are read per chunk. +pub(crate) const ENTRY_SCRATCH: usize = 1024; + +/// How one dictionary entry lays out, measured from the entry's own start: +/// the stored hash, the chain link, and the key and value slots. +#[derive(Copy, Clone)] +pub struct EntryLayout { + pub(crate) stride: u32, + pub(crate) hash: u32, + pub(crate) next: u32, + pub(crate) key: u32, + pub(crate) value: u32, +} + +/// Where a dictionary keeps its backing entries and live counts, and how +/// one entry lays out, resolved once off the dictionary's own class and +/// held by the caller, so the per-tick read costs reads rather than a +/// metadata walk. +#[derive(Copy, Clone)] +pub struct DictionaryOffsets { + pub(crate) entries: u32, + pub(crate) count: u32, + pub(crate) free_count: u32, + pub(crate) layout: EntryLayout, +} + +/// Reads a managed list's live elements through the reference stored at the +/// given address, with the offsets a resolution handed out earlier. The +/// count is judged by the buffer, the backing array's capacity is not, and +/// a count past the backing's own length is a torn resize and refuses. +pub fn read_list( + process: &Process, + pointer_size: PointerSize, + offsets: ListOffsets, + at: Address, +) -> Result, Error> { + let object = process + .read_pointer(at, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let size = process.read::(object + offsets.size)?; + let size = usize::try_from(size) + .ok() + .filter(|&size| size <= N) + .ok_or(Error {})?; + + let items = process + .read_pointer(object + offsets.items, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let header = object_header(pointer_size); + let backing = process + .read_pointer(items + header + pointer_size as u64, pointer_size)? + .value(); + if usize::try_from(backing) + .ok() + .filter(|&backing| size <= backing) + .is_none() + { + return Err(Error {}); + } + + let mut elements = [const { MaybeUninit::::uninit() }; N]; + let elements = process.read_into_uninit_slice( + items + header + 2 * pointer_size as u64, + &mut elements[..size], + )?; + + let mut out = ArrayVec::new(); + out.try_extend_from_slice(elements).map_err(|_| Error {})?; + Ok(out) +} + +/// The most entries a dictionary may claim: a torn header must not buy a +/// scan proportional to whatever it says. +const MOST_ENTRIES: u32 = 1 << 20; + +/// The freed-entry marks: some runtimes write the mark over the stored +/// hash, others link the chain word below the empty value. +fn freed(hash: u32, next: i32) -> bool { + hash == u32::MAX || next < -1 +} + +/// What a member may span before it runs into the member behind it, or the +/// entry's end. +fn room(layout: &EntryLayout, member: u32) -> u32 { + [layout.hash, layout.next, layout.key, layout.value] + .into_iter() + .filter(|&other| other > member) + .min() + .unwrap_or(layout.stride) + - member +} + +/// Reads a managed dictionary's live pairs through the reference stored at +/// the given address, with the offsets a resolution handed out earlier. +/// The buffer judges the live pairs, never the counted entries or the +/// backing capacity; freed entries are skipped by their marks, and a live +/// tally that cannot balance against the counts fails rather than +/// answering wrong pairs. The key and value types are the caller's claims, +/// refused where a claim outgrows its member's room. +pub fn read_dictionary( + process: &Process, + pointer_size: PointerSize, + offsets: DictionaryOffsets, + at: Address, +) -> Result, Error> { + let layout = &offsets.layout; + if size_of::() as u32 > room(layout, layout.key) + || size_of::() as u32 > room(layout, layout.value) + { + return Err(Error {}); + } + + let object = process + .read_pointer(at, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let count = process.read::(object + offsets.count)?; + let free = process.read::(object + offsets.free_count)?; + let (count, free) = match (u32::try_from(count), u32::try_from(free)) { + (Ok(count), Ok(free)) if free <= count && count <= MOST_ENTRIES => (count, free), + _ => return Err(Error {}), + }; + let live = (count - free) as usize; + if live > N { + return Err(Error {}); + } + + let entries = process + .read_pointer(object + offsets.entries, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let header = object_header(pointer_size); + let backing = process + .read_pointer(entries + header + pointer_size as u64, pointer_size)? + .value(); + if u64::from(count) > backing { + return Err(Error {}); + } + + // The entries bulk-read in chunks of the scratch, each entry judged by + // its marks and its pair lifted out element-wise. + let elements = entries + header + 2 * pointer_size as u64; + let stride = layout.stride as usize; + let per_chunk = ENTRY_SCRATCH / stride; + let mut scratch = [0; ENTRY_SCRATCH]; + + let mut out = ArrayVec::new(); + let mut index = 0; + while index < count as usize { + let taken = per_chunk.min(count as usize - index); + let bytes = &mut scratch[..taken * stride]; + process.read_into_slice(elements + (index * stride) as u64, bytes)?; + + for entry in bytes.chunks_exact(stride) { + let at = |member: u32, len: usize| &entry[member as usize..member as usize + len]; + let hash = u32::from_le_bytes(at(layout.hash, 4).try_into().expect("four bytes")); + let next = i32::from_le_bytes(at(layout.next, 4).try_into().expect("four bytes")); + if freed(hash, next) { + continue; + } + + let key = bytemuck::checked::try_pod_read_unaligned(at(layout.key, size_of::())) + .map_err(|_| Error {})?; + let value = bytemuck::checked::try_pod_read_unaligned(at(layout.value, size_of::())) + .map_err(|_| Error {})?; + out.try_push((key, value)).map_err(|_| Error {})?; + } + + index += taken; + } + + if out.len() != live { + return Err(Error {}); + } + Ok(out) +} + +/// Reads a managed array of value elements through the reference stored at +/// the given address. The layout is runtime ABI: past the object header sit +/// the bounds word, the length, and the elements inline. +pub fn read_array( + process: &Process, + pointer_size: PointerSize, + at: Address, +) -> Result, Error> { + let object = process + .read_pointer(at, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let header = object_header(pointer_size); + + // The length judges at pointer width before any narrowing, so garbage + // that a narrower read would truncate small still refuses. Only IL2CPP's + // length is truly pointer-sized; 64-bit mono stores a u32 whose zeroed + // padding reads the same value through the wide slot. + let length = process + .read_pointer(object + header + pointer_size as u64, pointer_size)? + .value(); + let length = usize::try_from(length) + .ok() + .filter(|&length| length <= N) + .ok_or(Error {})?; + + let mut elements = [const { MaybeUninit::::uninit() }; N]; + let elements = process.read_into_uninit_slice( + object + header + 2 * pointer_size as u64, + &mut elements[..length], + )?; + + let mut out = ArrayVec::new(); + out.try_extend_from_slice(elements).map_err(|_| Error {})?; + Ok(out) +} diff --git a/src/game_engine/unity/managed/runtime.rs b/src/game_engine/unity/managed/runtime.rs new file mode 100644 index 00000000..92de8957 --- /dev/null +++ b/src/game_engine/unity/managed/runtime.rs @@ -0,0 +1,283 @@ +use super::{Assemblies, ClassRef, Classes, ImageRef}; +use crate::{Address, PointerSize, Process}; + +/// What the runtimes genuinely disagree on. Matching exhaustively is the point: +/// a runtime added later is a compile error at every place the two differ, +/// rather than a silent fall through to whichever arm came first. +pub enum Runtime { + Mono(MonoRuntime), + Il2Cpp(Il2CppRuntime), +} + +/// The low bits of the class kind byte, whose value 3 marks a generic +/// instance. +const CLASS_KIND_MASK: u8 = 0x7; +const GENERIC_INSTANCE_KIND: u8 = 3; + +/// The element kinds a type's kind byte carries that the type route walks +/// through: a single-dimensional array's data leads on toward its element, +/// and a generic instance's data is the instantiation descriptor. +const SZARRAY: u8 = 0x1D; +const GENERIC_INSTANCE: u8 = 0x15; + +/// Whether a type of this element kind names a class at all. End, Void, +/// Ptr, ByRef, Var, multidimensional Array, FnPtr, and MVar do not. +const fn names_a_class(kind: u8) -> bool { + !matches!(kind, 0x00 | 0x01 | 0x0F | 0x10 | 0x13 | 0x14 | 0x1B | 0x1E) +} + +/// Mono keeps its assemblies in a glib list, its classes in each image's hash +/// table, and its statics behind the class's vtable. +pub struct MonoRuntime { + pub assemblies: Address, + pub class_cache: u16, + pub hash_table_size: u16, + pub hash_table_table: u16, + pub next_class_cache: u16, + pub field_count: u16, + pub class_kind: Option, + pub generic_class: Option, + pub container_class: Option, + pub type_data: Option, + pub type_kind: Option, + pub runtime_info: u16, + pub vtable_size: u16, + pub vtable: u16, + /// The older runtime keeps the static data in the vtable's own data slot, + /// where the newer one stores it past the vtable's method pointer array. + pub statics_in_vtable_data: bool, +} + +/// IL2CPP keeps its assemblies in a vector, its classes in a table its images +/// slice into, and its statics on the class itself. +pub struct Il2CppRuntime { + pub assemblies: Address, + pub type_info_definition_table: Address, + pub type_count: u16, + pub metadata_handle: u16, + /// The older lineage keeps the handle inline in the image, where the newer + /// one keeps a pointer to it. + pub handle_is_inline: bool, + pub field_count: u16, + pub static_fields: u16, + pub cached_class: Option, + pub type_data: Option, + pub type_kind: Option, +} + +impl MonoRuntime { + // The class whose count slot holds this class's count: a generic instance + // carries the inflated fields itself but no count, so the definition it + // was made from answers, reached through the instantiation descriptor. + fn counted_class( + &self, + process: &Process, + pointer_size: PointerSize, + class: ClassRef, + ) -> ClassRef { + let (Some(class_kind), Some(generic_class), Some(container_class)) = + (self.class_kind, self.generic_class, self.container_class) + else { + return class; + }; + + let kind = process + .read::(class.address + class_kind) + .unwrap_or_default(); + if kind & CLASS_KIND_MASK != GENERIC_INSTANCE_KIND { + return class; + } + + process + .read_pointer(class.address + generic_class, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .and_then(|descriptor| { + process + .read_pointer(descriptor + container_class, pointer_size) + .ok() + }) + .filter(|address| !address.is_null()) + .map_or(class, ClassRef::new) + } +} + +impl Runtime { + /// Walks the assemblies the target has loaded. + pub fn assemblies<'a>( + &self, + process: &'a Process, + pointer_size: PointerSize, + ) -> Assemblies<'a> { + match self { + Self::Mono(mono) => Assemblies::mono(process, pointer_size, mono), + Self::Il2Cpp(il2cpp) => Assemblies::il2cpp(process, pointer_size, il2cpp), + } + } + + /// Walks the classes an image holds. + pub fn classes<'a>( + &self, + process: &'a Process, + pointer_size: PointerSize, + image: ImageRef, + ) -> Classes<'a> { + match self { + Self::Mono(mono) => Classes::mono(process, pointer_size, mono, image), + Self::Il2Cpp(il2cpp) => Classes::il2cpp(process, pointer_size, il2cpp, image), + } + } + + /// Reads how many fields a class declares. + pub fn field_count( + &self, + process: &Process, + pointer_size: PointerSize, + class: ClassRef, + ) -> u64 { + match self { + Self::Mono(mono) => process + .read::( + mono.counted_class(process, pointer_size, class).address + mono.field_count, + ) + .ok() + .filter(|&count| count > 0) + .unwrap_or_default() as u64, + // A generic definition stores u16::MAX here; no real class + // declares that many fields. + Self::Il2Cpp(il2cpp) => process + .read::(class.address + il2cpp.field_count) + .ok() + .filter(|&count| count != u16::MAX) + .unwrap_or_default() as u64, + } + } + + /// Reads the address a class's static field offsets are measured from. + pub fn static_table( + &self, + process: &Process, + pointer_size: PointerSize, + class: ClassRef, + ) -> Option
{ + let slot = match self { + Self::Mono(mono) => { + let runtime_info = process + .read_pointer(class.address + mono.runtime_info, pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + let vtables = process + .read_pointer(runtime_info + pointer_size as u64, pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + if mono.statics_in_vtable_data { + vtables + mono.vtable_size + } else { + let vtable_size = process.read::(class.address + mono.vtable_size).ok()?; + + vtables + mono.vtable + (pointer_size as u64).wrapping_mul(vtable_size as u64) + } + } + Self::Il2Cpp(il2cpp) => class.address + il2cpp.static_fields, + }; + + process + .read_pointer(slot, pointer_size) + .ok() + .filter(|address| !address.is_null()) + } + + /// Resolves the class a field's type names, for the kinds a collection's + /// backing field presents: an array of a class the runtimes already + /// inflated. Kinds that name no class, and the table-resolved plain + /// definitions IL2CPP keeps behind an index or a handle, answer nothing. + pub fn class_from_type( + &self, + process: &Process, + pointer_size: PointerSize, + type_address: Address, + ) -> Option { + match self { + // Mono's data names the class itself, an array's element class + // included. A generic instance's data is the instantiation + // descriptor rather than a class, and no collections trace + // presents one here: the array hop already landed on the + // inflated class. + Self::Mono(mono) => { + let (data, kind) = (mono.type_data?, mono.type_kind?); + let kind = process.read::(type_address + kind).ok()?; + if !names_a_class(kind) || kind == GENERIC_INSTANCE { + return None; + } + + process + .read_pointer(type_address + data, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .map(ClassRef::new) + } + // IL2CPP's array data is the element's own type, walked onward; + // a generic instance's descriptor caches the class it resolved + // to. Deeper array nesting than this is garbage. + Self::Il2Cpp(il2cpp) => { + let (data, kind, cached) = + (il2cpp.type_data?, il2cpp.type_kind?, il2cpp.cached_class?); + + let mut at = type_address; + for _ in 0..8 { + let element = process.read::(at + kind).ok()?; + if !names_a_class(element) { + return None; + } + + let data = process + .read_pointer(at + data, pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + match element { + SZARRAY => at = data, + GENERIC_INSTANCE => { + return process + .read_pointer(data + cached, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .map(ClassRef::new) + } + _ => return None, + } + } + + None + } + } + } + + /// Reads the class a live object belongs to, which is how a polymorphic + /// field's runtime type is found. + pub fn object_class( + &self, + process: &Process, + pointer_size: PointerSize, + object: Address, + ) -> Option { + let address = process + .read_pointer(object, pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + // Mono reaches the class through the object's vtable, where IL2CPP + // heads the object with it. + let address = match self { + Self::Mono(_) => process + .read_pointer(address, pointer_size) + .ok() + .filter(|address| !address.is_null())?, + Self::Il2Cpp(_) => address, + }; + + Some(ClassRef::new(address)) + } +} diff --git a/src/game_engine/unity/managed/walk.rs b/src/game_engine/unity/managed/walk.rs new file mode 100644 index 00000000..38564074 --- /dev/null +++ b/src/game_engine/unity/managed/walk.rs @@ -0,0 +1,433 @@ +use super::super::{get_backing_name, CSTR}; +use super::readers::ENTRY_SCRATCH; +use super::{ + ClassRef, ClimbStop, DictionaryOffsets, EntryLayout, FieldRef, ImageRef, ListOffsets, Runtime, + WalkOffsets, +}; +use crate::{string::ArrayCString, Address, PointerSize, Process}; + +/// The walk itself: everything both runtimes lay out the same way, written +/// once against the operations [`Runtime`] supplies. An adapter builds one per +/// call from what its module holds, so nothing here is stored anywhere. +pub struct Walk { + pub runtime: Runtime, + pub offsets: WalkOffsets, + pub stop: ClimbStop, + pub pointer_size: PointerSize, +} + +impl Walk { + /// Reads an assembly's name, off the assembly itself or through its image, + /// whichever the offsets carry. + pub fn assembly_name( + &self, + process: &Process, + assembly: Address, + ) -> Option> { + let assembly_offsets = &self.offsets.assembly; + + let at = match ( + assembly_offsets.name_in_image, + assembly_offsets.name_in_assembly, + ) { + (Some(name), _) => self.assembly_image(process, assembly)?.address + name, + (_, Some(name)) => assembly + name, + _ => return None, + }; + + super::read_name(process, self.pointer_size, at) + } + + /// Reads the image an assembly carries. + pub fn assembly_image(&self, process: &Process, assembly: Address) -> Option { + process + .read_pointer(assembly + self.offsets.assembly.image, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + .map(ImageRef::new) + } + + pub fn class_name( + &self, + process: &Process, + class: ClassRef, + ) -> Option> { + super::read_name( + process, + self.pointer_size, + class.address + self.offsets.class.name, + ) + } + + pub fn class_namespace( + &self, + process: &Process, + class: ClassRef, + ) -> Option> { + super::read_name( + process, + self.pointer_size, + class.address + self.offsets.class.namespace, + ) + } + + /// Resolves a loaded image by its assembly name. + pub fn find_image(&self, process: &Process, name: &str) -> Option { + self.runtime + .assemblies(process, self.pointer_size) + .find(|&assembly| { + self.assembly_name::(process, assembly) + .is_some_and(|read| read.matches(name)) + }) + .and_then(|assembly| self.assembly_image(process, assembly)) + } + + /// Resolves a class by name, with the namespace split off at the last dot + /// when one is written. A nested class is written the way .NET writes it, + /// `Outer+Inner`: the runtime stores the innermost name bare, so the leaf + /// is what the lookup matches on, and the written enclosure is checked by + /// climbing. + pub fn find_class( + &self, + process: &Process, + image: ImageRef, + class_name: &str, + ) -> Option { + if let Some(plus) = class_name.find('+') { + let name_space_index = class_name[..plus].rfind('.'); + let (name_space, nested) = match name_space_index { + Some(index) => (&class_name[..index], &class_name[index + 1..]), + None => ("", class_name), + }; + + // Never measured where a class keeps its enclosing class means the + // written enclosure cannot be checked, and an unchecked leaf match + // would be a guess. + let declaring = self.offsets.class.declaring?; + let leaf = nested.rsplit('+').next()?; + + return self + .runtime + .classes(process, self.pointer_size, image) + .find(|&class| { + self.class_name::(process, class) + .is_some_and(|name| name.matches(leaf)) + && self.encloses(process, class, nested, name_space, declaring) + }); + } + + let name_space_index = class_name.rfind('.'); + + self.runtime + .classes(process, self.pointer_size, image) + .find(|&class| { + self.class_name::(process, class).is_some_and(|name| { + if let Some(name_space_index) = name_space_index { + let class_name_space = &class_name[..name_space_index]; + let class_name = &class_name[name_space_index + 1..]; + + name.matches(class_name) + && self + .class_namespace::(process, class) + .is_some_and(|name_space| name_space.matches(class_name_space)) + } else { + name.matches(class_name) + } + }) + }) + } + + // Whether a class whose own name matched the leaf is the one the written + // name meant: each step out has to be the part written before it, the + // outermost has to be enclosed by nothing, and the namespace belongs to + // the outermost, the leaf's own being empty when nested. + fn encloses( + &self, + process: &Process, + class: ClassRef, + nested: &str, + name_space: &str, + declaring: u16, + ) -> bool { + let enclosing = |class: ClassRef| { + process + .read_pointer(class.address + declaring, self.pointer_size) + .ok() + }; + + let mut outer = class; + for part in nested.rsplit('+').skip(1) { + let Some(address) = enclosing(outer).filter(|address| !address.is_null()) else { + return false; + }; + outer = ClassRef::new(address); + + if !self + .class_name::(process, outer) + .is_some_and(|name| name.matches(part)) + { + return false; + } + } + + if !enclosing(outer).is_some_and(|address| address.is_null()) { + return false; + } + + self.class_namespace::(process, outer) + .is_some_and(|read| read.matches(name_space)) + } + + /// Resolves the parent class. + pub fn parent(&self, process: &Process, class: ClassRef) -> Option { + process + .read_pointer(class.address + self.offsets.class.parent, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + .map(ClassRef::new) + } + + /// Resolves a field by name, climbing the parent chain until either stop + /// name answers, matching the written name or its backing field. Hands + /// back the class the field was found on as well as the offset, since a + /// static field's offset measures into that class's own static table. + pub fn find_field_offset( + &self, + process: &Process, + class: ClassRef, + field_name: &str, + ) -> Option<(ClassRef, u32)> { + let mut this_class = Some(class); + + loop { + let class = this_class?; + + if self + .class_name::(process, class)? + .matches(self.stop.class) + || self + .class_namespace::(process, class)? + .matches(self.stop.namespace) + { + return None; + } + + this_class = self.parent(process, class); + + let field_count = self.runtime.field_count(process, self.pointer_size, class); + + let fields = match field_count { + 0 => None, + _ => process + .read_pointer(class.address + self.offsets.class.fields, self.pointer_size) + .ok() + .filter(|address| !address.is_null()), + }; + + let Some(fields) = fields else { + continue; + }; + + for index in 0..field_count { + let field = + FieldRef::new(fields + index.wrapping_mul(self.offsets.field.stride as u64)); + + let matched = self.field_name::(process, field).is_some_and(|name| { + name.matches(field_name) + || name + .validate_utf8() + .ok() + .and_then(get_backing_name) + .is_some_and(|name| name == field_name) + }); + + if matched { + return Some((class, self.field_offset(process, field)?)); + } + } + } + } + + fn field_name( + &self, + process: &Process, + field: FieldRef, + ) -> Option> { + super::read_name( + process, + self.pointer_size, + field.address + self.offsets.field.name, + ) + } + + fn field_offset(&self, process: &Process, field: FieldRef) -> Option { + process.read(field.address + self.offsets.field.offset).ok() + } + + /// Resolves where a list keeps its backing array and live count, off the + /// list object's own class. Corlib names both fields the same across + /// every generation the offsets tables cover; a class naming either + /// differently is not a list and misses cleanly. + pub fn list_offsets(&self, process: &Process, object: Address) -> Option { + let class = self.object_class(process, object)?; + + let field_count = self.runtime.field_count(process, self.pointer_size, class); + let fields = process + .read_pointer(class.address + self.offsets.class.fields, self.pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + let mut items = None; + let mut size = None; + for index in 0..field_count { + let field = + FieldRef::new(fields + index.wrapping_mul(self.offsets.field.stride as u64)); + + let Some(name) = self.field_name::(process, field) else { + continue; + }; + + if name.matches("_items") { + items = self.field_offset(process, field); + } else if name.matches("_size") { + size = self.field_offset(process, field); + } + } + + Some(ListOffsets { + items: items?, + size: size?, + }) + } + + /// Resolves where a dictionary keeps its backing entries and live + /// counts, and how one entry lays out, off the dictionary object's own + /// class. Both corlib naming generations answer; the buckets field has + /// to exist for the shape to be this one, though nothing here reads it. + /// The parallel-arrays shape the oldest corlib used carries other names + /// and misses cleanly. + pub fn dictionary_offsets( + &self, + process: &Process, + object: Address, + ) -> Option { + let class = self.object_class(process, object)?; + + let mut found = [None; 4]; + self.each_own_field(process, class, |name, field, offset| { + let generations = [ + ["_buckets", "_entries", "_count", "_freeCount"], + ["buckets", "entries", "count", "freeCount"], + ]; + for generation in generations { + for (slot, member) in generation.into_iter().enumerate() { + if name.matches(member) { + found[slot] = Some((field, offset)); + } + } + } + }); + let (Some(_), Some(entries), Some(count), Some(free_count)) = + (found[0], found[1], found[2], found[3]) + else { + return None; + }; + + // The entry class arrives through the entries field's own type, and + // its instance size, boxed header removed, is the entry stride. + let entry_type = process + .read_pointer( + entries.0.address + self.offsets.field.type_?, + self.pointer_size, + ) + .ok() + .filter(|address| !address.is_null())?; + let entry_class = self + .runtime + .class_from_type(process, self.pointer_size, entry_type)?; + + let header = 2 * self.pointer_size as u32; + let size = process + .read::(entry_class.address + self.offsets.class.instance_size?) + .ok()?; + let stride = u32::try_from(size) + .ok()? + .checked_sub(header) + .filter(|&stride| stride > 0 && stride as usize <= ENTRY_SCRATCH)?; + + // The members' offsets are recorded as if boxed; folding them by the + // header measures them from the entry's own start. + let mut members = [None; 4]; + self.each_own_field(process, entry_class, |name, _, offset| { + for (slot, member) in ["hashCode", "next", "key", "value"].into_iter().enumerate() { + if name.matches(member) { + members[slot] = offset.checked_sub(header); + } + } + }); + let (Some(hash), Some(next), Some(key), Some(value)) = + (members[0], members[1], members[2], members[3]) + else { + return None; + }; + + let ends_inside = |member: u32| member.checked_add(4).is_some_and(|end| end <= stride); + let holds = ends_inside(hash) && ends_inside(next) && key < stride && value < stride; + holds.then_some(DictionaryOffsets { + entries: entries.1, + count: count.1, + free_count: free_count.1, + layout: EntryLayout { + stride, + hash, + next, + key, + value, + }, + }) + } + + // Hands every field the class itself declares to the callback, with its + // name and instance offset. Unreadable entries are skipped, the way the + // field climb skips them. + fn each_own_field( + &self, + process: &Process, + class: ClassRef, + mut each: impl FnMut(&ArrayCString, FieldRef, u32), + ) { + let field_count = self.runtime.field_count(process, self.pointer_size, class); + let Some(fields) = process + .read_pointer(class.address + self.offsets.class.fields, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + else { + return; + }; + + for index in 0..field_count { + let field = + FieldRef::new(fields + index.wrapping_mul(self.offsets.field.stride as u64)); + + let Some(name) = self.field_name::(process, field) else { + continue; + }; + let Some(offset) = self.field_offset(process, field) else { + continue; + }; + + each(&name, field, offset); + } + } + + /// Reads the address a class's static field offsets are measured from. + pub fn static_table(&self, process: &Process, class: ClassRef) -> Option
{ + self.runtime.static_table(process, self.pointer_size, class) + } + + /// Reads the class a live object belongs to. + pub fn object_class(&self, process: &Process, object: Address) -> Option { + self.runtime + .object_class(process, self.pointer_size, object) + } +} diff --git a/src/game_engine/unity/mod.rs b/src/game_engine/unity/mod.rs index baa3bfa3..1e221459 100644 --- a/src/game_engine/unity/mod.rs +++ b/src/game_engine/unity/mod.rs @@ -84,6 +84,7 @@ // https://github.com/CryZe/lunistice-auto-splitter/blob/b8c01031991783f7b41044099ee69edd54514dba/asr-dotnet/src/lib.rs pub mod il2cpp; +mod managed; pub mod mono; pub mod scene_manager; diff --git a/src/game_engine/unity/mono/assembly.rs b/src/game_engine/unity/mono/assembly.rs deleted file mode 100644 index 12b6f129..00000000 --- a/src/game_engine/unity/mono/assembly.rs +++ /dev/null @@ -1,41 +0,0 @@ -use super::{Image, Module}; -use crate::{string::ArrayCString, Address, Error, Process}; - -#[derive(Copy, Clone)] -pub(super) struct Assembly { - pub(super) assembly: Address, -} - -impl Assembly { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - let name = match ( - module.offsets.image.assembly_name, - module.offsets.assembly.aname, - ) { - (Some(assembly_name), _) => { - self.get_image(process, module).ok_or(Error {})?.image + assembly_name - } - (_, Some(aname)) => self.assembly + aname, - _ => return Err(Error {}), - }; - - process - .read_pointer(name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_image(&self, process: &Process, module: &Module) -> Option { - process - .read_pointer( - self.assembly + module.offsets.assembly.image, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()) - .map(|image| Image { image }) - } -} diff --git a/src/game_engine/unity/mono/builds.rs b/src/game_engine/unity/mono/builds.rs index c50733c7..e36c96bb 100644 --- a/src/game_engine/unity/mono/builds.rs +++ b/src/game_engine/unity/mono/builds.rs @@ -2,8 +2,8 @@ //! PDB says which binary. The offsets come from that PDB. use super::offsets::{ - AssemblyOffsets, ClassOffsets, FieldInfoOffsets, HashTableOffsets, ImageOffsets, MonoOffsets, - MonoVTableOffsets, + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, + ImageOffsets, MonoOffsets, MonoVTableOffsets, TypeOffsets, }; use super::Version; use crate::{file_format::pe::DebugId, PointerSize}; @@ -101,7 +101,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -110,7 +113,16 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -137,7 +149,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -146,7 +161,16 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -175,7 +199,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -184,7 +211,16 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -211,7 +247,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -220,7 +259,16 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -247,7 +295,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -256,7 +307,16 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -283,7 +343,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: None, + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x50, namespace: 0x58, vtable_size: 0x18, @@ -292,7 +355,16 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -319,7 +391,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -328,7 +403,16 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -355,7 +439,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -364,7 +451,16 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -391,7 +487,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -400,7 +499,16 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -427,7 +535,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -436,7 +547,16 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -463,7 +583,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: None, + instance_size: Some(0x10), parent: 0x24, + nested_in: Some(0x28), name: 0x34, namespace: 0x38, vtable_size: 0xc, @@ -472,7 +595,16 @@ static BUILDS: &[Build] = &[ field_count: 0x68, next_class_cache: 0xac, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -501,7 +633,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: None, + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -510,7 +645,16 @@ static BUILDS: &[Build] = &[ field_count: 0x94, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -537,7 +681,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -546,7 +693,16 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -573,7 +729,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -582,7 +741,16 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -609,7 +777,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: None, + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x50, namespace: 0x58, vtable_size: 0x18, @@ -618,7 +789,16 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -645,7 +825,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -654,7 +837,16 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -681,7 +873,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: None, + instance_size: Some(0x10), parent: 0x24, + nested_in: Some(0x28), name: 0x34, namespace: 0x38, vtable_size: 0xc, @@ -690,7 +885,16 @@ static BUILDS: &[Build] = &[ field_count: 0x68, next_class_cache: 0xac, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -717,7 +921,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -726,7 +933,16 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -753,7 +969,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -762,7 +981,16 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -789,7 +1017,10 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -798,7 +1029,16 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -825,7 +1065,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -834,7 +1077,16 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -863,7 +1115,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: None, + instance_size: Some(0x10), parent: 0x24, + nested_in: Some(0x28), name: 0x30, namespace: 0x34, vtable_size: 0xc, @@ -872,7 +1127,16 @@ static BUILDS: &[Build] = &[ field_count: 0x64, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -899,7 +1163,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -908,7 +1175,16 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -935,7 +1211,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -944,7 +1223,16 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -971,7 +1259,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -980,7 +1271,16 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -1007,7 +1307,10 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -1016,7 +1319,16 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -1079,6 +1391,42 @@ mod tests { .is_none()); } + // A version table's value for any of the grown members must match every + // measured build it stands in for, or say nothing. + #[test] + fn version_tables_never_contradict_a_measured_build() { + fn agrees(table: Option, measured: Option) -> bool { + table.is_none() || table == measured + } + + for build in BUILDS { + let Some(table) = MonoOffsets::new(build.version, build.pointer_size, BinaryFormat::PE) + else { + continue; + }; + assert!(agrees(table.class.nested_in, build.offsets.class.nested_in)); + assert!(agrees( + table.class.class_kind, + build.offsets.class.class_kind + )); + assert!(agrees( + table.generic.generic_class, + build.offsets.generic.generic_class + )); + assert!(agrees( + table.generic.container_class, + build.offsets.generic.container_class + )); + assert!(agrees( + table.class.instance_size, + build.offsets.class.instance_size + )); + assert!(agrees(table.type_words.data, build.offsets.type_words.data)); + assert!(agrees(table.type_words.kind, build.offsets.type_words.kind)); + assert!(agrees(table.field.type_, build.offsets.field.type_)); + } + } + // Every build has the layout its version table describes. The one // difference is where the assembly name lives, and mono.dll builds never // read the vtable slot. diff --git a/src/game_engine/unity/mono/class.rs b/src/game_engine/unity/mono/class.rs index 2ffe93c6..a07bb6db 100644 --- a/src/game_engine/unity/mono/class.rs +++ b/src/game_engine/unity/mono/class.rs @@ -1,92 +1,17 @@ -use core::iter::{self, FusedIterator}; - -use super::{super::get_backing_name, Field, Module, Version, CSTR}; -use crate::{future::retry, string::ArrayCString, Address, Error, Process}; +use super::super::managed::ClassRef; +use super::Module; +use crate::{future::retry, Address, Process}; #[cfg(feature = "derive")] pub use asr_derive::MonoClass as Class; -/// A .NET class that is part of an [`Image`](Image). +/// A .NET class that is part of an [`Image`](super::Image). #[derive(Copy, Clone)] pub struct Class { pub(super) class: Address, } impl Class { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer(self.class + module.offsets.class.name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_name_space( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer( - self.class + module.offsets.class.namespace, - module.pointer_size, - ) - .and_then(|addr| process.read(addr)) - } - - fn fields<'a>( - &'a self, - process: &'a Process, - module: &'a Module, - ) -> impl FusedIterator + 'a { - let mut this_class = Some(*self); - - iter::from_fn(move || { - let class = this_class?; - - if class - .get_name::(process, module) - .ok()? - .matches("Object") - || class - .get_name_space::(process, module) - .ok()? - .matches("UnityEngine") - { - return None; - } - - // Prepare for next iteration - this_class = class.get_parent(process, module); - - let field_count = process - .read::(class.class + module.offsets.class.field_count) - .ok() - .filter(|&val| val > 0) - .unwrap_or_default(); - - let fields = match field_count { - 0 => None, - _ => process - .read_pointer( - class.class + module.offsets.class.fields, - module.pointer_size, - ) - .ok(), - }; - - Some((0..field_count as u64).filter_map(move |i| { - fields.map(|fields| Field { - field: fields + i.wrapping_mul(module.offsets.field.alignment as u64), - }) - })) - }) - .flatten() - .fuse() - } - /// Tries to find the offset for a field with the specified name in the class. /// If it's a static field, the offset will be from the start of the static /// table. @@ -96,20 +21,10 @@ impl Class { module: &Module, field_name: &str, ) -> Option { - self.fields(process, module) - .find(|field| { - field.get_name::(process, module).is_ok_and(|name| { - // If the name matches, return immediately - name.matches(field_name) - - // BackingField pattern: k__BackingField - || name.validate_utf8() - .ok() - .and_then(|name| get_backing_name(name)) - .is_some_and(|name| name == field_name) - }) - }) - .and_then(|field| field.get_offset(process, module)) + module + .walk() + .find_field_offset(process, ClassRef::new(self.class), field_name) + .map(|(_, offset)| offset) } /// Tries to find the address of a static instance of the class based on its @@ -120,72 +35,38 @@ impl Class { module: &Module, field_name: &str, ) -> Address { - let static_table = self.wait_get_static_table(process, module).await; - let field_offset = self - .wait_get_field_offset(process, module, field_name) - .await; - let singleton_location = static_table + field_offset; - + // The field's offset measures into the static table of whichever + // class declares it, which a climb may find on a parent. retry(|| { + let walk = module.walk(); + let (class, offset) = + walk.find_field_offset(process, ClassRef::new(self.class), field_name)?; + let static_table = walk.static_table(process, class)?; + process - .read_pointer(singleton_location, module.pointer_size) + .read_pointer(static_table + offset, module.pointer_size) .ok() .filter(|addr| !addr.is_null()) }) .await } - fn get_static_table_pointer(&self, process: &Process, module: &Module) -> Option
{ - let runtime_info = process - .read_pointer( - self.class + module.offsets.class.runtime_info, - module.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null())?; - - let mut vtables = process - .read_pointer(runtime_info + module.size_of_ptr(), module.pointer_size) - .ok() - .filter(|addr| !addr.is_null())?; - - // Mono V1 behaves differently when it comes to recover the static table - match module.version { - Version::V1 | Version::V1Cattrs => Some(vtables + module.offsets.class.vtable_size), - _ => { - vtables = vtables + module.offsets.v_table.vtable; - - let vtable_size = process - .read::(self.class + module.offsets.class.vtable_size) - .ok()?; - - Some(vtables + module.size_of_ptr().wrapping_mul(vtable_size as u64)) - } - } - } - /// Returns the address of the static table of the class. This contains the /// values of all the static fields. pub fn get_static_table(&self, process: &Process, module: &Module) -> Option
{ - process - .read_pointer( - self.get_static_table_pointer(process, module)?, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()) + module + .walk() + .static_table(process, ClassRef::new(self.class)) } /// Tries to find the parent class. pub fn get_parent(&self, process: &Process, module: &Module) -> Option { - process - .read_pointer( - self.class + module.offsets.class.parent, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()) - .map(|class| Class { class }) + module + .walk() + .parent(process, ClassRef::new(self.class)) + .map(|class| Class { + class: class.address, + }) } /// Tries to find a field with the specified name in the class. This returns diff --git a/src/game_engine/unity/mono/collections_tests.rs b/src/game_engine/unity/mono/collections_tests.rs new file mode 100644 index 00000000..4b529fd5 --- /dev/null +++ b/src/game_engine/unity/mono/collections_tests.rs @@ -0,0 +1,283 @@ +//! Tests pinning dictionary resolution over hand-laid class metadata at the +//! literal offsets of the Unity 2019.4 x64 runtime. Resolution starts at a +//! live object and walks classes and fields only, so the fixtures stay their +//! own small blobs. + +use super::{BinaryFormat, Module, MonoOffsets, Version}; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x50_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +// One field entry: the MonoType pointer heads it, the name sits at 0x8, the +// offset at 0x18. +fn field(image: &mut [u8], at: u64, type_: u64, name: u64, offset: i32) { + if type_ != 0 { + ptr(image, at, type_); + } + ptr(image, at + 0x8, name); + put(image, at + 0x18, &offset.to_le_bytes()); +} + +// A dictionary class: field_count at +0x100, fields behind +0x98. The names +// arrive in the caller's order with their instance offsets. +fn dictionary_class(image: &mut [u8], class: u64, fields: u64, names: [u64; 4], entries_type: u64) { + put(image, class + 0x100, &4_i32.to_le_bytes()); + ptr(image, class + 0x98, BASE + fields); + field(image, fields, 0, names[0], 0x10); // the buckets array + field(image, fields + 0x20, entries_type, names[1], 0x18); + field(image, fields + 0x40, 0, names[2], 0x20); + field(image, fields + 0x60, 0, names[3], 0x24); +} + +// An entry class: instance_size at +0x1C, four members at their boxed-frame +// offsets. +fn entry_class(image: &mut [u8], class: u64, fields: u64, size: i32, strings: [u64; 4]) { + put(image, class + 0x1C, &size.to_le_bytes()); + put(image, class + 0x100, &4_i32.to_le_bytes()); + ptr(image, class + 0x98, BASE + fields); + field(image, fields, 0, strings[0], 0x10); + field(image, fields + 0x20, 0, strings[1], 0x14); + field(image, fields + 0x40, 0, strings[2], 0x18); + field(image, fields + 0x60, 0, strings[3], 0x1C); +} + +// An object heading with its vtable, whose own head is the class. +fn object(image: &mut [u8], at: u64, vtable: u64, class: u64) { + ptr(image, at, BASE + vtable); + ptr(image, vtable, BASE + class); +} + +fn image() -> Vec { + let mut i = vec![0; 0x2800]; + + let strings = [ + (0x2000, "_buckets"), + (0x2040, "_entries"), + (0x2080, "_count"), + (0x20C0, "_freeCount"), + (0x2100, "hashCode"), + (0x2140, "next"), + (0x2180, "key"), + (0x21C0, "value"), + (0x2200, "buckets"), + (0x2240, "entries"), + (0x2280, "count"), + (0x22C0, "freeCount"), + (0x2300, "table"), + (0x2340, "linkSlots"), + (0x2380, "keySlots"), + (0x23C0, "valueSlots"), + ]; + for (at, text) in strings { + put(&mut i, at, text.as_bytes()); + } + let modern = [BASE + 0x2000, BASE + 0x2040, BASE + 0x2080, BASE + 0x20C0]; + let framework = [BASE + 0x2200, BASE + 0x2240, BASE + 0x2280, BASE + 0x22C0]; + let members = [BASE + 0x2100, BASE + 0x2140, BASE + 0x2180, BASE + 0x21C0]; + + // The slots holding the references. + ptr(&mut i, 0x0, BASE + 0x100); + ptr(&mut i, 0x8, BASE + 0x180); + ptr(&mut i, 0x10, BASE + 0xB00); + ptr(&mut i, 0x18, BASE + 0xB80); + ptr(&mut i, 0x20, BASE + 0x1A00); + + // The healthy dictionary in the modern naming generation: its entries + // field's type is a SzArray whose data names the entry class directly. + object(&mut i, 0x100, 0x140, 0x200); + dictionary_class(&mut i, 0x200, 0x340, modern, BASE + 0x500); + ptr(&mut i, 0x500, BASE + 0x600); // type data: the entry class + put(&mut i, 0x50A, &[0x1D]); // SzArray + entry_class(&mut i, 0x600, 0x740, 0x20, members); + + // The framework naming generation shares the entry class. + object(&mut i, 0x180, 0x1C0, 0x900); + dictionary_class(&mut i, 0x900, 0xA40, framework, BASE + 0x500); + + // A dictionary whose entry class has no instance size yet: not ready, + // answers nothing. + object(&mut i, 0xB00, 0xB40, 0xC00); + dictionary_class(&mut i, 0xC00, 0xD40, modern, BASE + 0xF00); + ptr(&mut i, 0xF00, BASE + 0x1600); + put(&mut i, 0xF0A, &[0x1D]); + entry_class(&mut i, 0x1600, 0x1740, 0, members); + + // A dictionary whose entry layout cannot hold its members: the stride is + // eight bytes and the key sits at its end. + object(&mut i, 0xB80, 0xBC0, 0x1000); + dictionary_class(&mut i, 0x1000, 0x1140, modern, BASE + 0x1300); + ptr(&mut i, 0x1300, BASE + 0x1800); + put(&mut i, 0x130A, &[0x1D]); + entry_class(&mut i, 0x1800, 0x1940, 0x18, members); + + // The old corlib's parallel-arrays shape: its names answer, ours do not. + object(&mut i, 0x1A00, 0x1A40, 0x1B00); + let parallel = [BASE + 0x2300, BASE + 0x2340, BASE + 0x2380, BASE + 0x23C0]; + dictionary_class(&mut i, 0x1B00, 0x1C40, parallel, 0); + + // The healthy dictionary's live state: three counted entries over a + // four-slot backing, the middle one freed, so two pairs are live. + ptr(&mut i, 0x118, BASE + 0x1D00); + put(&mut i, 0x120, &3_i32.to_le_bytes()); + put(&mut i, 0x124, &1_i32.to_le_bytes()); + put(&mut i, 0x1D18, &4_u32.to_le_bytes()); + entry(&mut i, 0x1D20, 1111, -1, 10, 100); + entry(&mut i, 0x1D30, -1, -1, 99, 999); // freed: the hash carries the mark + entry(&mut i, 0x1D40, 2222, -1, 20, 200); + entry(&mut i, 0x1D50, 0, 0, 0, 0); + + // The framework dictionary claims three live entries but holds two: its + // tally cannot balance. + ptr(&mut i, 0x198, BASE + 0x1E00); + put(&mut i, 0x1A0, &3_i32.to_le_bytes()); + put(&mut i, 0x1A4, &0_i32.to_le_bytes()); + put(&mut i, 0x1E18, &4_u32.to_le_bytes()); + entry(&mut i, 0x1E20, 1111, -1, 1, 2); + entry(&mut i, 0x1E30, -1, -1, 0, 0); + entry(&mut i, 0x1E40, 2222, -1, 3, 4); + entry(&mut i, 0x1E50, 0, 0, 0, 0); + + i +} + +// One live or freed entry at the fixture's 16-byte stride: the stored hash, +// the chain link, and an i32 key and value. +fn entry(image: &mut [u8], at: u64, hash: i32, next: i32, key: i32, value: i32) { + for (index, word) in [hash, next, key, value].into_iter().enumerate() { + put(image, at + 4 * index as u64, &word.to_le_bytes()); + } +} + +fn module() -> Module { + Module { + assemblies: Address::new(BASE), + version: Version::V2, + offsets: MonoOffsets::new(Version::V2, PointerSize::Bit64, BinaryFormat::PE).unwrap(), + pointer_size: PointerSize::Bit64, + } +} + +fn on_fixture(test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image())], |process| { + test(process, &module()); + }); +} + +#[test] +fn dictionaries_resolve_in_both_naming_generations() { + on_fixture(|process, module| { + for at in [BASE, BASE + 0x8] { + let slot = Address::new(at); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + assert_eq!(offsets.entries, 0x18); + assert_eq!(offsets.count, 0x20); + assert_eq!(offsets.free_count, 0x24); + assert_eq!(offsets.layout.stride, 0x10); + assert_eq!(offsets.layout.hash, 0x0); + assert_eq!(offsets.layout.next, 0x4); + assert_eq!(offsets.layout.key, 0x8); + assert_eq!(offsets.layout.value, 0xC); + } + }); +} + +// An entry class with no instance size yet is a target still starting up: +// resolution answers nothing rather than baking a zero stride. +#[test] +fn half_initialized_entry_classes_answer_nothing() { + on_fixture(|process, module| { + assert!(module + .get_dictionary_offsets(process, Address::new(BASE + 0x10)) + .is_none()); + }); +} + +// A layout whose members cannot sit inside its stride is refused whole. +#[test] +fn scrambled_entry_layouts_answer_nothing() { + on_fixture(|process, module| { + assert!(module + .get_dictionary_offsets(process, Address::new(BASE + 0x18)) + .is_none()); + }); +} + +// The old corlib's parallel-arrays dictionary is not this shape and misses +// cleanly rather than resolving to wrong offsets. +#[test] +fn parallel_shape_names_answer_nothing() { + on_fixture(|process, module| { + assert!(module + .get_dictionary_offsets(process, Address::new(BASE + 0x20)) + .is_none()); + }); +} + +// The read returns exactly the live pairs: the counted entries minus the +// freed one, in entry order. +#[test] +fn dictionaries_read_their_live_pairs() { + on_fixture(|process, module| { + let slot = Address::new(BASE); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + let pairs = module + .read_dictionary::(process, offsets, slot) + .unwrap(); + assert_eq!(pairs.as_slice(), [(10, 100), (20, 200)]); + }); +} + +// The buffer judges the live pairs, never the counted entries or the +// backing capacity. +#[test] +fn read_buffers_judge_live_pairs() { + on_fixture(|process, module| { + let slot = Address::new(BASE); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + assert!(module + .read_dictionary::(process, offsets, slot) + .is_ok()); + assert!(module + .read_dictionary::(process, offsets, slot) + .is_err()); + }); +} + +// A claimed element size past its member's room would read a sibling's +// bytes; the read refuses instead. +#[test] +fn oversized_element_claims_refuse() { + on_fixture(|process, module| { + let slot = Address::new(BASE); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + assert!(module + .read_dictionary::(process, offsets, slot) + .is_err()); + }); +} + +// A live tally that cannot balance against the counts is a torn or lying +// dictionary, and fails rather than answering wrong pairs. +#[test] +fn unbalanced_tallies_refuse() { + on_fixture(|process, module| { + let slot = Address::new(BASE + 0x8); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + assert!(module + .read_dictionary::(process, offsets, slot) + .is_err()); + }); +} diff --git a/src/game_engine/unity/mono/field.rs b/src/game_engine/unity/mono/field.rs deleted file mode 100644 index af088227..00000000 --- a/src/game_engine/unity/mono/field.rs +++ /dev/null @@ -1,23 +0,0 @@ -use super::Module; -use crate::{string::ArrayCString, Address, Error, Process}; - -#[derive(Copy, Clone)] -pub(super) struct Field { - pub(super) field: Address, -} - -impl Field { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer(self.field + module.offsets.field.name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_offset(&self, process: &Process, module: &Module) -> Option { - process.read(self.field + module.offsets.field.offset).ok() - } -} diff --git a/src/game_engine/unity/mono/image.rs b/src/game_engine/unity/mono/image.rs index d6ca1ae5..51faa256 100644 --- a/src/game_engine/unity/mono/image.rs +++ b/src/game_engine/unity/mono/image.rs @@ -1,9 +1,8 @@ -use core::iter::{self, FusedIterator}; +use core::iter::FusedIterator; -use super::CSTR; +use super::super::managed::ImageRef; use super::{Class, Module}; -use crate::future::retry; -use crate::{Address, Process}; +use crate::{future::retry, Address, Process}; /// An image is a .NET DLL that is loaded by the game. The `Assembly-CSharp` /// image is the main game assembly, and contains all the game logic. @@ -19,69 +18,24 @@ impl Image { process: &'a Process, module: &'a Module, ) -> impl FusedIterator + 'a { - let class_cache_size = process - .read::( - self.image + module.offsets.image.class_cache + module.offsets.hash_table.size, - ) - .unwrap_or_default() as _; + let walk = module.walk(); - let table_addr = match class_cache_size { - 0 => Address::NULL, - _ => process - .read_pointer( - self.image + module.offsets.image.class_cache + module.offsets.hash_table.table, - module.pointer_size, - ) - .unwrap_or_default(), - }; - - (0..class_cache_size).flat_map(move |i| { - let mut table = match table_addr { - Address::NULL => None, - addr => process - .read_pointer( - addr + module.size_of_ptr().wrapping_mul(i), - module.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null()), - }; - - iter::from_fn(move || { - let class = table?; - table = process - .read_pointer( - class + module.offsets.class.next_class_cache, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()); - - Some(Class { class }) + walk.runtime + .classes(process, walk.pointer_size, ImageRef::new(self.image)) + .map(|class| Class { + class: class.address, }) .fuse() - }) } /// Tries to find the specified [.NET class](struct@Class) in the image. pub fn get_class(&self, process: &Process, module: &Module, class_name: &str) -> Option { - let name_space_index = class_name.rfind('.'); - - self.classes(process, module).find(|class| { - class.get_name::(process, module).is_ok_and(|name| { - if let Some(name_space_index) = name_space_index { - let class_name_space = &class_name[..name_space_index]; - let class_name = &class_name[name_space_index + 1..]; - - name.matches(class_name) - && class - .get_name_space::(process, module) - .is_ok_and(|name_space| name_space.matches(class_name_space)) - } else { - name.matches(class_name) - } + module + .walk() + .find_class(process, ImageRef::new(self.image), class_name) + .map(|class| Class { + class: class.address, }) - }) } /// Tries to find the specified [.NET class](struct@Class) in the image. diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index 6e01955d..78427aea 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -3,32 +3,38 @@ #[cfg(feature = "alloc")] use crate::file_format::macho; +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; + use crate::{ file_format::{elf, pe}, future::retry, print_limited, signature::Signature, - Address, Address32, Address64, PointerSize, Process, + string::ArrayWString, + Address, Address32, Error, PointerSize, Process, }; -use core::iter::{self, FusedIterator}; -mod assembly; mod builds; -use assembly::Assembly; mod image; pub use image::Image; mod class; pub use class::Class; -mod field; -use field::Field; mod version; pub use version::Version; mod pointer; pub use pointer::UnityPointer; mod offsets; use offsets::MonoOffsets; +#[cfg(all(test, not(target_family = "wasm")))] +mod collections_tests; +#[cfg(all(test, not(target_family = "wasm")))] +mod readers_tests; +#[cfg(all(test, not(target_family = "wasm")))] +mod walk_tests; -use super::{BinaryFormat, CSTR}; +pub use super::managed::{DictionaryOffsets, ListOffsets}; +use super::{managed, BinaryFormat}; /// Represents access to a Unity game that is using the standard Mono backend. pub struct Module { @@ -267,29 +273,49 @@ impl Module { self.pointer_size } - fn assemblies<'a>(&'a self, process: &'a Process) -> impl FusedIterator + 'a { - let mut assembly = process - .read_pointer(self.assemblies, self.pointer_size) - .ok() - .filter(|val| !val.is_null()); - - iter::from_fn(move || { - let [data, next_assembly]: [Address; 2] = match self.pointer_size { - PointerSize::Bit64 => process - .read::<[Address64; 2]>(assembly?) - .ok()? - .map(|item| item.into()), - _ => process - .read::<[Address32; 2]>(assembly?) - .ok()? - .map(|item| item.into()), - }; - - assembly = Some(next_assembly); - - Some(Assembly { assembly: data }) - }) - .fuse() + fn walk(&self) -> managed::Walk { + managed::Walk { + runtime: managed::Runtime::Mono(managed::MonoRuntime { + assemblies: self.assemblies, + class_cache: self.offsets.image.class_cache, + hash_table_size: self.offsets.hash_table.size.into(), + hash_table_table: self.offsets.hash_table.table.into(), + next_class_cache: self.offsets.class.next_class_cache, + field_count: self.offsets.class.field_count, + class_kind: self.offsets.class.class_kind, + generic_class: self.offsets.generic.generic_class, + container_class: self.offsets.generic.container_class, + type_data: self.offsets.type_words.data, + type_kind: self.offsets.type_words.kind, + runtime_info: self.offsets.class.runtime_info, + vtable_size: self.offsets.class.vtable_size.into(), + vtable: self.offsets.v_table.vtable.into(), + statics_in_vtable_data: matches!(self.version, Version::V1 | Version::V1Cattrs), + }), + offsets: managed::WalkOffsets { + assembly: managed::AssemblyOffsets { + name_in_image: self.offsets.image.assembly_name.map(u16::from), + name_in_assembly: self.offsets.assembly.aname.map(u16::from), + image: self.offsets.assembly.image.into(), + }, + class: managed::ClassOffsets { + name: self.offsets.class.name.into(), + namespace: self.offsets.class.namespace.into(), + parent: self.offsets.class.parent.into(), + declaring: self.offsets.class.nested_in, + instance_size: self.offsets.class.instance_size, + fields: self.offsets.class.fields.into(), + }, + field: managed::FieldOffsets { + name: self.offsets.field.name.into(), + type_: self.offsets.field.type_, + offset: self.offsets.field.offset.into(), + stride: self.offsets.field.alignment.into(), + }, + }, + stop: managed::ClimbStop::UNITY, + pointer_size: self.pointer_size, + } } /// Looks for the specified binary [image](Image) inside the target process. @@ -299,13 +325,11 @@ impl Module { /// [`get_default_image`](Self::get_default_image) function is a shorthand /// for this function that accesses the `Assembly-CSharp` [image](Image). pub fn get_image(&self, process: &Process, assembly_name: &str) -> Option { - self.assemblies(process) - .find(|assembly| { - assembly - .get_name::(process, self) - .is_ok_and(|name| name.matches(assembly_name)) + self.walk() + .find_image(process, assembly_name) + .map(|image| Image { + image: image.address, }) - .and_then(|assembly| assembly.get_image(process, self)) } /// Looks for the `Assembly-CSharp` binary [image](Image) inside the target @@ -318,6 +342,107 @@ impl Module { self.get_image(process, "Assembly-CSharp") } + /// Reads a managed string through the reference stored at the given + /// address, such as the end of a pointer path or a slot in a static + /// table. The string carries its own character count, so no length is + /// passed; `N` bounds how many UTF-16 characters the returned buffer + /// holds, and a string claiming more than that fails rather than + /// truncates, as do a negative count and a null reference. A string + /// containing an interior nul character reads in full but compares up to + /// the nul. + pub fn read_string( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_string(process, self.pointer_size, at) + } + + /// Reads a managed array of value elements through the reference stored + /// at the given address. The array carries its own length, so no count + /// is passed; `N` bounds how many elements the returned vector holds, + /// and an array claiming more than that fails rather than truncates, as + /// does a null reference. The element type is the caller's claim and has + /// to match the target's own element layout: a managed `char` is a + /// `u16` here, a `bool` a single byte, and Rust's `char` and `usize` + /// never match. Reference elements have no portable claim. + pub fn read_array( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_array(process, self.pointer_size, at) + } + + /// Resolves where a `List` keeps its backing array and live count, off + /// the class the list object at the given address names as its own. The + /// answer is a small `Copy` value worth storing, like a field offset: + /// resolution walks the class's fields, where the read itself is a + /// handful of reads. An object whose class is not a list misses. + pub fn get_list_offsets(&self, process: &Process, at: Address) -> Option { + let object = process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + self.walk().list_offsets(process, object) + } + + /// Resolves where a `Dictionary` keeps its backing entries and live + /// counts, and how one entry lays out, off the class the dictionary + /// object at the given address names as its own. The answer is a small + /// `Copy` value worth storing, like a field offset: resolution walks + /// class metadata, where the read itself is a handful of reads. An + /// object whose class is not this dictionary shape, and a target still + /// starting up, both miss. + pub fn get_dictionary_offsets( + &self, + process: &Process, + at: Address, + ) -> Option { + let object = process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + self.walk().dictionary_offsets(process, object) + } + + /// Reads a managed `Dictionary`'s live pairs through the reference + /// stored at the given address, with the offsets + /// [`get_dictionary_offsets`](Self::get_dictionary_offsets) resolved. + /// `N` bounds the live pairs, never the counted entries or the backing + /// capacity; freed entries are skipped by their marks, and a live tally + /// that cannot balance against the counts fails rather than answering + /// wrong pairs. The key and value types are the caller's claims, as + /// with [`read_array`](Self::read_array), refused where a claim + /// outgrows the room its member has inside one entry. + pub fn read_dictionary( + &self, + process: &Process, + offsets: DictionaryOffsets, + at: Address, + ) -> Result, Error> { + managed::read_dictionary(process, self.pointer_size, offsets, at) + } + + /// Reads a managed `List` of value elements through the reference stored + /// at the given address, with the offsets + /// [`get_list_offsets`](Self::get_list_offsets) resolved. The list's + /// live count is read, never its backing capacity; `N` bounds the + /// count, and a count past the buffer or past the backing array's own + /// length fails rather than truncates, as does a null reference. The + /// element type is the caller's claim, as with + /// [`read_array`](Self::read_array). + pub fn read_list( + &self, + process: &Process, + offsets: ListOffsets, + at: Address, + ) -> Result, Error> { + managed::read_list(process, self.pointer_size, offsets, at) + } + /// Attaches to a Unity game that is using the standard Mono backend. This /// function automatically detects the [Mono version](Version). If you /// know the version in advance or it fails detecting it, use @@ -369,8 +494,28 @@ impl Module { retry(|| self.get_default_image(process)).await } - #[inline] - const fn size_of_ptr(&self) -> u64 { - self.pointer_size as u64 + /// Resolves where a `List` keeps its backing array and live count, off + /// the class the list object at the given address names as its own. + /// + /// This is the `await`able version of the + /// [`get_list_offsets`](Self::get_list_offsets) function, yielding back + /// to the runtime between each try. + pub async fn wait_get_list_offsets(&self, process: &Process, at: Address) -> ListOffsets { + retry(|| self.get_list_offsets(process, at)).await + } + + /// Resolves where a `Dictionary` keeps its backing entries and live + /// counts, and how one entry lays out, off the class the dictionary + /// object at the given address names as its own. + /// + /// This is the `await`able version of the + /// [`get_dictionary_offsets`](Self::get_dictionary_offsets) function, + /// yielding back to the runtime between each try. + pub async fn wait_get_dictionary_offsets( + &self, + process: &Process, + at: Address, + ) -> DictionaryOffsets { + retry(|| self.get_dictionary_offsets(process, at)).await } } diff --git a/src/game_engine/unity/mono/offsets.rs b/src/game_engine/unity/mono/offsets.rs index f8210a05..1ab29a2a 100644 --- a/src/game_engine/unity/mono/offsets.rs +++ b/src/game_engine/unity/mono/offsets.rs @@ -6,6 +6,8 @@ pub(super) struct MonoOffsets { pub(super) image: ImageOffsets, pub(super) hash_table: HashTableOffsets, pub(super) class: ClassOffsets, + pub(super) generic: GenericOffsets, + pub(super) type_words: TypeOffsets, pub(super) field: FieldInfoOffsets, pub(super) v_table: MonoVTableOffsets, } @@ -31,7 +33,10 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1B), // 2021.3 through 6000.7 + instance_size: Some(0x1C), // 2021.3 through 6000.7 parent: 0x30, + nested_in: Some(0x38), // 2021.3 through 6000.7 name: 0x48, namespace: 0x50, vtable_size: 0x5C, @@ -40,7 +45,16 @@ impl MonoOffsets { field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xF0), // 2021.3 through 6000.7 + container_class: Some(0x0), // 2021.3 through 6000.7 + }, + type_words: TypeOffsets { + data: Some(0x0), // 2021.3 through 6000.7 + kind: Some(0xA), // 2021.3 through 6000.7 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2021.3 through 6000.7 name: 0x8, offset: 0x18, alignment: 0x20, @@ -61,7 +75,10 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xF), // 2021.3 through 6000.7 + instance_size: Some(0x10), // 2021.3 through 6000.7 parent: 0x20, + nested_in: Some(0x24), // 2021.3 through 6000.7 name: 0x2C, namespace: 0x30, vtable_size: 0x38, @@ -70,7 +87,16 @@ impl MonoOffsets { field_count: 0x9C, next_class_cache: 0xA0, }, + generic: GenericOffsets { + generic_class: Some(0x8C), // 2021.3 through 6000.7 + container_class: Some(0x0), // 2021.3 through 6000.7 + }, + type_words: TypeOffsets { + data: Some(0x0), // 2021.3 through 6000.7 + kind: Some(0x6), // 2021.3 through 6000.7 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2021.3 through 6000.7 name: 0x4, offset: 0xC, alignment: 0x10, @@ -91,7 +117,10 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2A), // 2017.4 through 2020.1 + instance_size: Some(0x1C), // 2017.4 through 2020.1 parent: 0x30, + nested_in: Some(0x38), // 2017.4 through 2020.1 name: 0x48, namespace: 0x50, vtable_size: 0x5C, @@ -100,7 +129,16 @@ impl MonoOffsets { field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xF0), // 2017.4 through 2020.1 + container_class: Some(0x0), // 2017.4 through 2020.1 + }, + type_words: TypeOffsets { + data: Some(0x0), // 2017.4 through 2020.1 + kind: Some(0xA), // 2017.4 through 2020.1 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2017.4 through 2020.1 name: 0x8, offset: 0x18, alignment: 0x20, @@ -121,7 +159,10 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1E), // 2017.4 through 2020.1 + instance_size: Some(0x10), // 2017.4 through 2020.1 parent: 0x20, + nested_in: Some(0x24), // 2017.4 through 2020.1 name: 0x2C, namespace: 0x30, vtable_size: 0x38, @@ -130,7 +171,16 @@ impl MonoOffsets { field_count: 0xA4, next_class_cache: 0xA8, }, + generic: GenericOffsets { + generic_class: Some(0x94), // 2017.4 through 2020.1 + container_class: Some(0x0), // 2017.4 through 2020.1 + }, + type_words: TypeOffsets { + data: Some(0x0), // 2017.4 through 2020.1 + kind: Some(0x6), // 2017.4 through 2020.1 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2017.4 through 2020.1 name: 0x4, offset: 0xC, alignment: 0x10, @@ -151,7 +201,10 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, + instance_size: Some(0x1C), // 2017.4, 2018.4 parent: 0x30, + nested_in: None, name: 0x50, namespace: 0x58, vtable_size: 0x18, @@ -160,7 +213,16 @@ impl MonoOffsets { field_count: 0x9C, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: Some(0x0), // 2017.4, 2018.4 + kind: Some(0xA), // 2017.4, 2018.4 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2017.4, 2018.4 name: 0x8, offset: 0x18, alignment: 0x20, @@ -181,7 +243,10 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: None, + instance_size: Some(0x10), // 2017.4, 2018.4 parent: 0x24, + nested_in: None, name: 0x34, namespace: 0x38, vtable_size: 0xC, @@ -190,7 +255,16 @@ impl MonoOffsets { field_count: 0x68, next_class_cache: 0xAC, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: Some(0x0), // 2017.4, 2018.4 + kind: Some(0x6), // 2017.4, 2018.4 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2017.4, 2018.4 name: 0x4, offset: 0xC, alignment: 0x10, @@ -211,7 +285,10 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, + instance_size: None, parent: 0x30, + nested_in: Some(0x38), // 5.6 through 2018.4 name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -220,7 +297,16 @@ impl MonoOffsets { field_count: 0x94, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -241,7 +327,10 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: None, + instance_size: None, parent: 0x24, + nested_in: Some(0x28), // 5.6 through 2018.4 name: 0x30, namespace: 0x34, vtable_size: 0xC, @@ -250,7 +339,16 @@ impl MonoOffsets { field_count: 0x64, next_class_cache: 0xA8, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x4, offset: 0xC, alignment: 0x10, @@ -272,7 +370,10 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, + instance_size: None, parent: 0x28, + nested_in: None, name: 0x40, namespace: 0x48, vtable_size: 0x54, @@ -281,7 +382,16 @@ impl MonoOffsets { field_count: 0xF8, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -304,7 +414,10 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, + instance_size: None, parent: 0x28, + nested_in: None, name: 0x40, namespace: 0x48, vtable_size: 0x54, @@ -313,7 +426,16 @@ impl MonoOffsets { field_count: 0xF8, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -336,7 +458,10 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, + instance_size: None, parent: 0x28, + nested_in: None, name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -345,7 +470,16 @@ impl MonoOffsets { field_count: 0x94, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -368,7 +502,10 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, + instance_size: None, parent: 0x28, + nested_in: None, name: 0x40, namespace: 0x48, vtable_size: 0x18, @@ -377,7 +514,16 @@ impl MonoOffsets { field_count: 0x8C, next_class_cache: 0xF8, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -408,7 +554,10 @@ pub(super) struct HashTableOffsets { } pub(super) struct ClassOffsets { + pub(super) class_kind: Option, // The byte whose low bits say what kind of class it is + pub(super) instance_size: Option, // What one instance occupies, boxed header included pub(super) parent: u8, + pub(super) nested_in: Option, // Where a class keeps the one it is nested in pub(super) name: u8, pub(super) namespace: u8, pub(super) vtable_size: u8, // On mono V1 and V1_cattrs, this offset represents MonoVTable.data @@ -418,7 +567,21 @@ pub(super) struct ClassOffsets { pub(super) next_class_cache: u16, } +// MonoType's own words: the data pointer and the element kind byte. +pub(super) struct TypeOffsets { + pub(super) data: Option, + pub(super) kind: Option, +} + +// MonoClassGenericInst keeps the instantiation descriptor, whose container is +// the generic definition the instance was made from. +pub(super) struct GenericOffsets { + pub(super) generic_class: Option, + pub(super) container_class: Option, +} + pub(super) struct FieldInfoOffsets { + pub(super) type_: Option, // Where a field keeps its MonoType pub(super) name: u8, pub(super) offset: u8, pub(super) alignment: u8, diff --git a/src/game_engine/unity/mono/pointer.rs b/src/game_engine/unity/mono/pointer.rs index 8b75de7b..771c4703 100644 --- a/src/game_engine/unity/mono/pointer.rs +++ b/src/game_engine/unity/mono/pointer.rs @@ -1,23 +1,11 @@ -use super::{Class, Image, Module}; +use super::super::managed::{ImageRef, PointerPath}; +use super::{Image, Module}; use crate::{Address, Error, Process}; use bytemuck::CheckedBitPattern; -use core::{array, cell::RefCell}; /// A Mono-specific implementation for automatic pointer path resolution pub struct UnityPointer { - inner: RefCell>, -} - -struct UnityPointerInternal { - base_address: Address, - offsets: [u32; CAP], - resolved_offsets: usize, - - starting_class_name: &'static str, - starting_class: Option, - nr_of_parents: usize, - fields: [&'static str; CAP], - depth: usize, + path: PointerPath, } impl UnityPointer { @@ -28,113 +16,11 @@ impl UnityPointer { /// If a higher number of offsets is provided, the pointer path will be truncated /// according to the value of `CAP`. pub fn new(class_name: &'static str, nr_of_parents: usize, fields: &[&'static str]) -> Self { - let named_fields: [&str; CAP] = { - let mut iter = fields.iter(); - array::from_fn(|_| iter.next().copied().unwrap_or_default()) - }; - Self { - inner: RefCell::new(UnityPointerInternal { - base_address: Address::NULL, - offsets: [0; CAP], - resolved_offsets: 0, - starting_class_name: class_name, - starting_class: None, - nr_of_parents, - fields: named_fields, - depth: fields.len().min(CAP), - }), + path: PointerPath::new(class_name, nr_of_parents, fields), } } - /// Tries to resolve the pointer path for the `Mono` class specified - fn find_offsets(&self, process: &Process, module: &Module, image: &Image) -> Result<(), Error> { - let mut inner = self.inner.borrow_mut(); - - // If the pointer path has already been found, there's no need to continue - if inner.resolved_offsets == inner.depth { - return Ok(()); - } - - // Logic: the starting class can be recovered with the get_class() function, - // and parent class can be recovered if needed. However, this is a VERY - // intensive process because it involves looping through all the main classes - // in the game. For this reason, once the class is found, we want to store it - // into the cache, where it can be recovered if this function need to be run again - // (for example if a previous attempt at pointer path resolution failed) - let starting_class = match inner.starting_class { - Some(starting_class) => starting_class, - _ => { - let mut class = image - .get_class(process, module, inner.starting_class_name) - .ok_or(Error {})?; - - for _ in 0..inner.nr_of_parents { - class = class.get_parent(process, module).ok_or(Error {})?; - } - - inner.starting_class = Some(class); - class - } - }; - - // Recovering the address of the static table is not very CPU intensive, - // but it might be worth caching it as well - if inner.base_address.is_null() { - inner.base_address = starting_class - .get_static_table(process, module) - .ok_or(Error {})?; - }; - - // If we already resolved some offsets, we need to traverse them again starting from the base address - // of the static table in order to recalculate the address of the farthest object we can reach. - // If no offsets have been resolved yet, we just need to read the base address instead. - let mut current_object = { - let mut addr = inner.base_address; - for &i in &inner.offsets[..inner.resolved_offsets] { - addr = process.read_pointer(addr + i, module.pointer_size)?; - } - addr - }; - - // We keep track of the already resolved offsets in order to skip resolving them again - for i in inner.resolved_offsets..inner.depth { - let offset_from_string = match inner.fields[i].strip_prefix("0x") { - Some(rem) => u32::from_str_radix(rem, 16).ok(), - _ => inner.fields[i].parse().ok(), - }; - - let current_offset = match offset_from_string { - Some(offset) => offset as _, - _ => { - let current_class = match i { - 0 => starting_class, - _ => process - .read_pointer(current_object, module.pointer_size) - .ok() - .filter(|val| !val.is_null()) - .and_then(|addr| process.read_pointer(addr, module.pointer_size).ok()) - .filter(|val| !val.is_null()) - .map(|class| Class { class }) - .ok_or(Error {})?, - }; - - current_class - .get_field_offset(process, module, inner.fields[i]) - .ok_or(Error {})? - } - }; - - inner.offsets[i] = current_offset as _; - inner.resolved_offsets += 1; - - current_object = - process.read_pointer(current_object + current_offset, module.pointer_size)?; - } - - Ok(()) - } - /// Dereferences the pointer path, returning the memory address of the value of interest pub fn deref_offsets( &self, @@ -142,14 +28,8 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - self.find_offsets(process, module, image)?; - let inner = self.inner.borrow(); - let mut address = inner.base_address; - let (&last, path) = inner.offsets[..inner.depth].split_last().ok_or(Error {})?; - for &offset in path { - address = process.read_pointer(address + offset, module.pointer_size)?; - } - Ok(address + last) + self.path + .deref_offsets(process, &module.walk(), ImageRef::new(image.image)) } /// Dereferences the pointer path, returning the value stored at the final memory address @@ -159,6 +39,7 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - process.read(self.deref_offsets(process, module, image)?) + self.path + .deref(process, &module.walk(), ImageRef::new(image.image)) } } diff --git a/src/game_engine/unity/mono/readers_tests.rs b/src/game_engine/unity/mono/readers_tests.rs new file mode 100644 index 00000000..4f634bdc --- /dev/null +++ b/src/game_engine/unity/mono/readers_tests.rs @@ -0,0 +1,171 @@ +//! Tests pinning the managed readers over hand-laid string and array +//! objects. The readers are pointer-size ABI, not walk work, so the fixtures +//! are tiny blobs rather than the walk's class fixtures. + +use super::{BinaryFormat, Module, MonoOffsets, Version}; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x30_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +fn utf16(image: &mut [u8], at: u64, text: &str) { + for (index, unit) in text.encode_utf16().enumerate() { + put(image, at + 2 * index as u64, &unit.to_le_bytes()); + } +} + +// String and array objects at the 64-bit layout: two header words, then the +// string's i32 character count and inline UTF-16 characters, or the array's +// bounds word, length, and inline elements. Each slot at the front holds one +// reference the readers dereference. +fn image() -> Vec { + let mut i = vec![0; 0x1000]; + + ptr(&mut i, 0x0, BASE + 0x100); // a healthy string + ptr(&mut i, 0x8, BASE + 0x200); // one claiming more than a buffer holds + ptr(&mut i, 0x10, BASE + 0x300); // one claiming a negative count + ptr(&mut i, 0x18, 0); // a null reference + ptr(&mut i, 0x20, BASE + 0x400); // a healthy i32 array + ptr(&mut i, 0x28, BASE + 0x500); // an array claiming more than a buffer holds + ptr(&mut i, 0x30, BASE + 0x600); // a u16 array + + put(&mut i, 0x100 + 0x10, &9_i32.to_le_bytes()); + utf16(&mut i, 0x100 + 0x14, "Chapter 3"); + + put(&mut i, 0x200 + 0x10, &64_i32.to_le_bytes()); + put(&mut i, 0x300 + 0x10, &(-1_i32).to_le_bytes()); + + // Mono stores the length as a u32 the allocator's zeroing pads to the + // pointer-wide slot the reader judges, which is what these bytes lay. + put(&mut i, 0x400 + 0x18, &3_u32.to_le_bytes()); + for (index, value) in [7_i32, 8, 9].into_iter().enumerate() { + put( + &mut i, + 0x400 + 0x20 + 4 * index as u64, + &value.to_le_bytes(), + ); + } + + put(&mut i, 0x500 + 0x18, &64_u32.to_le_bytes()); + + put(&mut i, 0x600 + 0x18, &5_u32.to_le_bytes()); + utf16(&mut i, 0x600 + 0x20, "melon"); + + i +} + +fn module(pointer_size: PointerSize) -> Module { + Module { + assemblies: Address::new(BASE), + version: Version::V2, + offsets: MonoOffsets::new(Version::V2, pointer_size, BinaryFormat::PE).unwrap(), + pointer_size, + } +} + +fn on_fixture(test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image())], |process| { + test(process, &module(PointerSize::Bit64)); + }); +} + +#[test] +fn strings_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_string::<16>(process, Address::new(BASE)) + .unwrap(); + assert!(read.matches_str("Chapter 3")); + }); +} + +// The buffer size is the bound past which a claimed count is nonsense: a +// torn read claims billions, and refusing beats truncating. +#[test] +fn string_counts_past_the_buffer_refuse() { + on_fixture(|process, module| { + assert!(module + .read_string::<16>(process, Address::new(BASE + 0x8)) + .is_err()); + assert!(module + .read_string::<16>(process, Address::new(BASE + 0x10)) + .is_err()); + assert!(module + .read_string::<16>(process, Address::new(BASE + 0x18)) + .is_err()); + }); +} + +#[test] +fn arrays_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_array::(process, Address::new(BASE + 0x20)) + .unwrap(); + assert_eq!(read.as_slice(), [7, 8, 9]); + }); +} + +// The element type is the caller's claim; a u16 claim strides a managed +// char array correctly. +#[test] +fn array_elements_stride_by_their_claimed_type() { + on_fixture(|process, module| { + let read = module + .read_array::(process, Address::new(BASE + 0x30)) + .unwrap(); + let melon: Vec = "melon".encode_utf16().collect(); + assert_eq!(read.as_slice(), melon); + }); +} + +#[test] +fn array_lengths_past_the_buffer_refuse() { + on_fixture(|process, module| { + assert!(module + .read_array::(process, Address::new(BASE + 0x28)) + .is_err()); + assert!(module + .read_array::(process, Address::new(BASE + 0x18)) + .is_err()); + }); +} + +// The 32-bit layout halves the header, the reference width, and the length +// slot. +#[test] +fn readers_resolve_on_32_bit_targets() { + let mut i = vec![0; 0x1000]; + put(&mut i, 0x0, &((BASE + 0x100) as u32).to_le_bytes()); + put(&mut i, 0x100 + 0x8, &5_i32.to_le_bytes()); + utf16(&mut i, 0x100 + 0xC, "Ridge"); + put(&mut i, 0x8, &((BASE + 0x200) as u32).to_le_bytes()); + put(&mut i, 0x200 + 0xC, &2_u32.to_le_bytes()); + put(&mut i, 0x200 + 0x10, &21_i32.to_le_bytes()); + put(&mut i, 0x200 + 0x14, &22_i32.to_le_bytes()); + + with_process(&[(BASE, &i)], |process| { + let module = module(PointerSize::Bit32); + let read = module + .read_string::<8>(process, Address::new(BASE)) + .unwrap(); + assert!(read.matches_str("Ridge")); + + let read = module + .read_array::(process, Address::new(BASE + 0x8)) + .unwrap(); + assert_eq!(read.as_slice(), [21, 22]); + }); +} diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs new file mode 100644 index 00000000..f9a6c416 --- /dev/null +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -0,0 +1,529 @@ +//! Tests pinning the walk's behavior over a hand-laid image of mono's +//! structures. The fixture is written at the literal offsets of the Unity +//! 2019.4 x64 runtime, copied by hand, so the walk is checked against the +//! layout rather than against itself. + +use super::offsets::{ + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, + ImageOffsets, MonoVTableOffsets, TypeOffsets, +}; +use super::{builds, BinaryFormat, Module, MonoOffsets, UnityPointer, Version}; +use crate::file_format::pe::DebugId; +use crate::runtime::mock::{poll_once, with_process}; +use crate::{Address, PointerSize, Process}; + +use core::task::Poll; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x10_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +// The target's structures, hand-laid. Two assemblies whose GList the walk +// follows, a class cache of two buckets with one chained class, a parent chain +// reaching a UnityEngine class, static tables reachable through the vtables, +// and a live object carrying its class through its vtable. +fn image() -> Vec { + let mut i = vec![0; 0x4000]; + + // Strings, each 0x80 apart so a 128-byte name read stays in bounds. + let strings = [ + (0x2000, "mscorlib"), + (0x2080, "Assembly-CSharp"), + (0x2100, "GameManager"), + (0x2180, "Game"), + (0x2200, "points"), + (0x2280, "k__BackingField"), + (0x2300, "Enemy"), + (0x2380, "hp"), + (0x2400, "Boss"), + (0x2480, "phase"), + (0x2500, "MonoBehaviour"), + (0x2580, "UnityEngine"), + (0x2600, "hidden"), + (0x2680, "instance"), + (0x2700, "Outer"), + (0x2780, "Inner"), + (0x2800, "spawner"), + (0x2880, "_items"), + (0x2900, "_size"), + (0x2980, "_version"), + (0x2B00, "Inventory"), + (0x2B80, "items"), + ]; + for (at, text) in strings { + put(&mut i, at, text.as_bytes()); + } + + // The loaded-assemblies global and its GList: mscorlib first, then the + // default image. + ptr(&mut i, 0x0, BASE + 0x10); + ptr(&mut i, 0x10, BASE + 0x40); // node 1: data + ptr(&mut i, 0x18, BASE + 0x20); // node 1: next + ptr(&mut i, 0x20, BASE + 0xC0); // node 2: data + ptr(&mut i, 0x28, 0); // node 2: next + + // MonoAssembly: the name at 0x10 (the aname route reads the pointer that + // heads MonoAssemblyName), the image at 0x60. + ptr(&mut i, 0x40 + 0x10, BASE + 0x2000); + ptr(&mut i, 0x40 + 0x60, BASE + 0x140); + ptr(&mut i, 0xC0 + 0x10, BASE + 0x2080); + ptr(&mut i, 0xC0 + 0x60, BASE + 0x640); + + // MonoImage: assembly_name at 0x28, class_cache at 0x4C0 with the hash + // table's size at +0x18 and bucket array at +0x20. mscorlib's image stays + // empty; the default image holds two buckets and three classes. + ptr(&mut i, 0x140 + 0x28, BASE + 0x2000); + ptr(&mut i, 0x640 + 0x28, BASE + 0x2080); + put(&mut i, 0x640 + 0x4C0 + 0x18, &2_i32.to_le_bytes()); + ptr(&mut i, 0x640 + 0x4C0 + 0x20, BASE + 0xB40); + ptr(&mut i, 0xB40, BASE + 0xC00); // bucket 0: GameManager + ptr(&mut i, 0xB48, BASE + 0xE00); // bucket 1: Enemy, chaining to Boss (kept) + + // MonoClass: parent 0x30, name 0x48, namespace 0x50, vtable_size 0x5C, + // fields 0x98, runtime_info 0xD0, field_count 0x100, next_class_cache + // 0x108. Field entries stride 0x20 with the name at 0x8 and the offset at + // 0x18. + + // GameManager, deriving from MonoBehaviour, with an instance field, a + // backing field, and a static slot at the head of its field list. + let game_manager = 0xC00; + ptr(&mut i, game_manager + 0x30, BASE + 0x1200); + ptr(&mut i, game_manager + 0x48, BASE + 0x2100); + ptr(&mut i, game_manager + 0x50, BASE + 0x2180); + put(&mut i, game_manager + 0x5C, &5_i32.to_le_bytes()); + ptr(&mut i, game_manager + 0x98, BASE + 0x1400); + ptr(&mut i, game_manager + 0xD0, BASE + 0x1600); + put(&mut i, game_manager + 0x100, &3_i32.to_le_bytes()); + ptr(&mut i, game_manager + 0x108, BASE + 0x1B00); + ptr(&mut i, 0x1400 + 0x8, BASE + 0x2680); // instance + put(&mut i, 0x1400 + 0x18, &0_i32.to_le_bytes()); + ptr(&mut i, 0x1420 + 0x8, BASE + 0x2200); // points + put(&mut i, 0x1420 + 0x18, &0x20_i32.to_le_bytes()); + ptr(&mut i, 0x1440 + 0x8, BASE + 0x2280); // k__BackingField + put(&mut i, 0x1440 + 0x18, &0x24_i32.to_le_bytes()); + + // Enemy, with an instance field and a static slot, and Boss chained + // behind it in the bucket. + let enemy = 0xE00; + ptr(&mut i, enemy + 0x48, BASE + 0x2300); + ptr(&mut i, enemy + 0x50, BASE + 0x2180); + put(&mut i, enemy + 0x5C, &1_i32.to_le_bytes()); + ptr(&mut i, enemy + 0x98, BASE + 0x1500); + ptr(&mut i, enemy + 0xD0, BASE + 0x1620); + put(&mut i, enemy + 0x100, &2_i32.to_le_bytes()); + ptr(&mut i, enemy + 0x108, BASE + 0x1000); + ptr(&mut i, 0x1500 + 0x8, BASE + 0x2380); // hp + put(&mut i, 0x1500 + 0x18, &0x10_i32.to_le_bytes()); + ptr(&mut i, 0x1520 + 0x8, BASE + 0x2800); // spawner + put(&mut i, 0x1520 + 0x18, &0x8_i32.to_le_bytes()); + + // Boss, deriving from Enemy, with one field of its own. + let boss = 0x1000; + ptr(&mut i, boss + 0x30, BASE + enemy); + ptr(&mut i, boss + 0x48, BASE + 0x2400); + ptr(&mut i, boss + 0x50, BASE + 0x2180); + put(&mut i, boss + 0x5C, &2_i32.to_le_bytes()); + ptr(&mut i, boss + 0x98, BASE + 0x1540); + ptr(&mut i, boss + 0xD0, BASE + 0x1650); + put(&mut i, boss + 0x100, &1_i32.to_le_bytes()); + ptr(&mut i, 0x1540 + 0x8, BASE + 0x2480); // phase + put(&mut i, 0x1540 + 0x18, &0x18_i32.to_le_bytes()); + + // MonoBehaviour in UnityEngine, holding a field the climb must never + // reach. + let mono_behaviour = 0x1200; + ptr(&mut i, mono_behaviour + 0x48, BASE + 0x2500); + ptr(&mut i, mono_behaviour + 0x50, BASE + 0x2580); + ptr(&mut i, mono_behaviour + 0x98, BASE + 0x1580); + put(&mut i, mono_behaviour + 0x100, &1_i32.to_le_bytes()); + ptr(&mut i, 0x1580 + 0x8, BASE + 0x2600); // hidden + put(&mut i, 0x1580 + 0x18, &0x30_i32.to_le_bytes()); + + // Outer in Game, enclosing Inner, whose own namespace is empty and whose + // nested_in points back out. + let outer = 0x1B00; + ptr(&mut i, outer + 0x48, BASE + 0x2700); + ptr(&mut i, outer + 0x50, BASE + 0x2180); + ptr(&mut i, outer + 0x108, BASE + 0x1D00); + let inner = 0x1D00; + ptr(&mut i, inner + 0x48, BASE + 0x2780); + ptr(&mut i, inner + 0x50, BASE + 0x27F0); + ptr(&mut i, inner + 0x38, BASE + outer); + ptr(&mut i, inner + 0x108, BASE + 0x2D00); + + // Inventory, a generic instance: its class kind's low bits read 3, its own + // field count slot holds nothing, and the count lives on the definition + // reached through the instantiation descriptor. The inflated field array + // is the instance's own. + let inventory = 0x2D00; + ptr(&mut i, inventory + 0x48, BASE + 0x2B00); + ptr(&mut i, inventory + 0x50, BASE + 0x2180); + put(&mut i, inventory + 0x2A, &3_u8.to_le_bytes()); + ptr(&mut i, inventory + 0x98, BASE + 0x3400); + ptr(&mut i, inventory + 0xF0, BASE + 0x3000); + ptr(&mut i, 0x3000, BASE + 0x3100); // descriptor: container_class at 0x0 + put(&mut i, 0x3100 + 0x100, &1_i32.to_le_bytes()); // the definition's count + ptr(&mut i, 0x3400 + 0x8, BASE + 0x2B80); // items + put(&mut i, 0x3400 + 0x18, &0x28_i32.to_le_bytes()); + + // GameManager's statics: runtime_info to the domain vtable, whose static + // slot sits past five method pointers, holding the static table. The + // table's first slot is the live instance. + ptr(&mut i, 0x1600 + 0x8, BASE + 0x1700); + ptr(&mut i, 0x1700 + 0x40 + 8 * 5, BASE + 0x1800); + ptr(&mut i, 0x1800, BASE + 0x1900); + + // Enemy's statics, holding the spawner instance. Boss carries a table of + // its own, empty at that offset, so only the declaring class's table + // answers. + ptr(&mut i, 0x1620 + 0x8, BASE + 0x1780); + ptr(&mut i, 0x1780 + 0x40 + 8, BASE + 0x1880); + ptr(&mut i, 0x1880 + 0x8, BASE + 0x1980); + ptr(&mut i, 0x1650 + 0x8, BASE + 0x1A40); + ptr(&mut i, 0x1A40 + 0x40 + 8 * 2, BASE + 0x1AC0); + + // The instance object: its vtable heads it, and the vtable's own head is + // the class. The points field holds a recognizable value. + ptr(&mut i, 0x1900, BASE + 0x1A00); + ptr(&mut i, 0x1A00, BASE + game_manager); + put(&mut i, 0x1900 + 0x20, &777_u32.to_le_bytes()); + + // A List: its class is a generic instance like Inventory, its fields are + // corlib's own three, and its live object holds a backing array longer + // than the live count. A second object claims a count past the backing. + let list_class = 0x3600; + put(&mut i, list_class + 0x2A, &3_u8.to_le_bytes()); + ptr(&mut i, list_class + 0x98, BASE + 0x3800); + ptr(&mut i, list_class + 0xF0, BASE + 0x3D00); + ptr(&mut i, 0x3D00, BASE + 0x3D40); // descriptor: container_class at 0x0 + put(&mut i, 0x3D40 + 0x100, &3_i32.to_le_bytes()); // the definition's count + ptr(&mut i, 0x3800 + 0x8, BASE + 0x2880); // _items + put(&mut i, 0x3800 + 0x18, &0x10_i32.to_le_bytes()); + ptr(&mut i, 0x3820 + 0x8, BASE + 0x2900); // _size + put(&mut i, 0x3820 + 0x18, &0x18_i32.to_le_bytes()); + ptr(&mut i, 0x3840 + 0x8, BASE + 0x2980); // _version + put(&mut i, 0x3840 + 0x18, &0x1C_i32.to_le_bytes()); + + ptr(&mut i, 0x3900, BASE + 0x3950); // the list object heads with its vtable + ptr(&mut i, 0x3950, BASE + list_class); + ptr(&mut i, 0x3900 + 0x10, BASE + 0x3A00); + put(&mut i, 0x3900 + 0x18, &3_i32.to_le_bytes()); + put(&mut i, 0x3A00 + 0x18, &8_u32.to_le_bytes()); // the backing's capacity + for (index, value) in [5_i32, 6, 7, 100, 100, 100, 100, 100] + .into_iter() + .enumerate() + { + put( + &mut i, + 0x3A00 + 0x20 + 4 * index as u64, + &value.to_le_bytes(), + ); + } + + ptr(&mut i, 0x3B00, BASE + 0x3950); // the torn list shares the class + ptr(&mut i, 0x3B00 + 0x10, BASE + 0x3A00); + put(&mut i, 0x3B00 + 0x18, &99_i32.to_le_bytes()); + + ptr(&mut i, 0x3C00, BASE + 0x3C50); // an Inventory object, not a list + ptr(&mut i, 0x3C50, BASE + 0x2D00); + + // The slots holding the three references. + ptr(&mut i, 0x3F00, BASE + 0x3900); + ptr(&mut i, 0x3F08, BASE + 0x3B00); + ptr(&mut i, 0x3F10, BASE + 0x3C00); + + i +} + +fn module(offsets: &'static MonoOffsets) -> Module { + Module { + assemblies: Address::new(BASE), + version: Version::V2, + offsets, + pointer_size: PointerSize::Bit64, + } +} + +fn era() -> &'static MonoOffsets { + MonoOffsets::new(Version::V2, PointerSize::Bit64, BinaryFormat::PE).unwrap() +} + +fn measured() -> &'static MonoOffsets { + // The 2019.4 x64 build the fixture is laid at. + let stored = [ + 0xC7, 0xAA, 0x10, 0x77, 0x5A, 0x31, 0x30, 0x4D, 0xA7, 0x7A, 0x08, 0x07, 0x29, 0x69, 0x66, + 0xF6, + ]; + &builds::find(&DebugId { + guid: stored, + age: 1, + }) + .unwrap() + .offsets +} + +fn on_fixture(offsets: &'static MonoOffsets, test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image())], |process| { + test(process, &module(offsets)); + }); +} + +#[test] +fn images_resolve_by_name_through_both_routes() { + for offsets in [era(), measured()] { + on_fixture(offsets, |process, module| { + assert!(module.get_default_image(process).is_some()); + assert!(module.get_image(process, "mscorlib").is_some()); + assert!(module.get_image(process, "Assembly-DoesNotExist").is_none()); + }); + } +} + +#[test] +fn classes_resolve_by_name_and_namespace() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image.get_class(process, module, "GameManager").is_some()); + assert!(image.get_class(process, module, "Game.Boss").is_some()); + assert!(image.get_class(process, module, "Wrong.Boss").is_none()); + assert!(image.get_class(process, module, "Nothing").is_none()); + assert_eq!(image.classes(process, module).count(), 6); + }); +} + +#[test] +fn field_offsets_resolve_declared_inherited_and_backing() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + let game_manager = image.get_class(process, module, "GameManager").unwrap(); + assert_eq!( + game_manager.get_field_offset(process, module, "points"), + Some(0x20), + ); + assert_eq!( + game_manager.get_field_offset(process, module, "Health"), + Some(0x24), + ); + + let boss = image.get_class(process, module, "Boss").unwrap(); + assert_eq!(boss.get_field_offset(process, module, "phase"), Some(0x18)); + assert_eq!(boss.get_field_offset(process, module, "hp"), Some(0x10)); + }); +} + +#[test] +fn nested_classes_resolve_by_their_written_name() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image + .get_class(process, module, "Game.Outer+Inner") + .is_some()); + assert!(image + .get_class(process, module, "Game.Outer+Missing") + .is_none()); + assert!(image + .get_class(process, module, "Wrong.Outer+Inner") + .is_none()); + assert!(image + .get_class(process, module, "Game.Enemy+Inner") + .is_none()); + }); +} + +// Offsets that never measured where a class keeps its enclosing class must +// miss cleanly rather than answer with whichever class carries the leaf name. +#[test] +fn nested_lookups_without_a_measured_offset_answer_nothing() { + static UNMEASURED: MonoOffsets = MonoOffsets { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x60, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4C0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: None, + instance_size: None, + parent: 0x30, + nested_in: None, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5C, + fields: 0x98, + runtime_info: 0xD0, + field_count: 0x100, + next_class_cache: 0x108, + }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: None, + kind: None, + }, + field: FieldInfoOffsets { + type_: None, + name: 0x8, + offset: 0x18, + alignment: 0x20, + }, + v_table: MonoVTableOffsets { vtable: 0x40 }, + }; + + on_fixture(&UNMEASURED, |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image + .get_class(process, module, "Game.Outer+Inner") + .is_none()); + assert!(image.get_class(process, module, "GameManager").is_some()); + + let inventory = image.get_class(process, module, "Inventory").unwrap(); + assert!(inventory + .get_field_offset(process, module, "items") + .is_none()); + }); +} + +// A generic instance declares no count of its own; the definition it was made +// from holds it, and the inflated fields are the instance's. +#[test] +fn generic_field_counts_resolve_through_the_definition() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + let inventory = image.get_class(process, module, "Inventory").unwrap(); + assert_eq!( + inventory.get_field_offset(process, module, "items"), + Some(0x28), + ); + }); +} + +// The climb stops at UnityEngine's namespace, so an engine field never +// resolves. +#[test] +fn field_climbs_stop_at_the_engine() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + let game_manager = image.get_class(process, module, "GameManager").unwrap(); + assert!(game_manager + .get_field_offset(process, module, "hidden") + .is_none()); + }); +} + +#[test] +fn statics_resolve_through_the_vtable() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + let game_manager = image.get_class(process, module, "GameManager").unwrap(); + assert_eq!( + game_manager.get_static_table(process, module), + Some(Address::new(BASE + 0x1800)), + ); + + let outer = image.get_class(process, module, "Game.Outer").unwrap(); + assert!(outer.get_static_table(process, module).is_none()); + }); +} + +// A static field found on a parent measures into the parent's own static +// table, not the table of the class the lookup started at. +#[test] +fn static_instances_resolve_through_the_declaring_class() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + let boss = image.get_class(process, module, "Boss").unwrap(); + assert_eq!( + poll_once(boss.wait_get_static_instance(process, module, "spawner")), + Poll::Ready(Address::new(BASE + 0x1980)), + ); + + let pointer = UnityPointer::<1>::new("Boss", 0, &["spawner"]); + assert_eq!( + pointer.deref::(process, module, &image).unwrap(), + BASE + 0x1980, + ); + }); +} + +// A list's backing array and live count resolve off the list object's own +// class, and the read returns the live count's elements, never the backing +// capacity's, which the buffer size does not judge. +#[test] +fn lists_resolve_through_their_own_class() { + on_fixture(era(), |process, module| { + let at = Address::new(BASE + 0x3F00); + let offsets = module.get_list_offsets(process, at).unwrap(); + let read = module.read_list::(process, offsets, at).unwrap(); + assert_eq!(read.as_slice(), [5, 6, 7]); + }); +} + +// A count past the backing array's own length is a torn resize, not a long +// list. +#[test] +fn list_counts_past_their_backing_refuse() { + on_fixture(era(), |process, module| { + let at = Address::new(BASE + 0x3F08); + let offsets = module.get_list_offsets(process, at).unwrap(); + assert!(module.read_list::(process, offsets, at).is_err()); + }); +} + +// An object whose class does not carry corlib's names is not a list, and +// misses cleanly rather than answering with whatever offsets exist. +#[test] +fn objects_that_are_not_lists_answer_nothing() { + on_fixture(era(), |process, module| { + assert!(module + .get_list_offsets(process, Address::new(BASE + 0x3F10)) + .is_none()); + }); +} + +// The whole pointer path: the static root, the instance behind it, and a field +// resolved against the object's own class read through its vtable. +#[test] +fn pointers_dereference_through_a_static_root() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + let pointer = UnityPointer::<2>::new("GameManager", 0, &["instance", "points"]); + assert_eq!(pointer.deref::(process, module, &image).unwrap(), 777,); + }); +} + +// The public shapes the carve must not change. +#[test] +fn public_types_keep_their_properties() { + fn is_copy() {} + fn fused<'a>( + iter: impl core::iter::FusedIterator + 'a, + ) -> impl core::iter::FusedIterator + 'a { + iter + } + + is_copy::(); + is_copy::(); + + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + let _ = fused(image.classes(process, module)); + }); +} diff --git a/src/runtime/mock.rs b/src/runtime/mock.rs index baf18e9a..cc11df24 100644 --- a/src/runtime/mock.rs +++ b/src/runtime/mock.rs @@ -1,29 +1,95 @@ //! A fake host for tests: definitions of the wasm imports the runtime layer //! links against, backed by in-memory images so readers can run on the host. -use core::{cell::RefCell, num::NonZeroU64}; +use core::{ + cell::RefCell, + future::Future, + num::NonZeroU64, + task::{Context, Poll, Waker}, +}; -use std::vec::Vec; +use std::{ + string::{String, ToString}, + vec::Vec, +}; use crate::Process; std::thread_local! { static MEMORY: RefCell)>> = const { RefCell::new(Vec::new()) }; + static MODULES: RefCell> = const { RefCell::new(Vec::new()) }; } /// Runs a test against a process whose memory holds the given regions, each an /// address and the bytes starting there. Reads outside every region fail. pub fn with_process(regions: &[(u64, &[u8])], test: impl FnOnce(&Process) -> R) -> R { + with_modules(regions, &[], test) +} + +/// Runs a test against a process whose memory holds the given regions and +/// whose loaded modules are the given names, each with an address and a size. +pub fn with_modules( + regions: &[(u64, &[u8])], + modules: &[(&str, u64, u64)], + test: impl FnOnce(&Process) -> R, +) -> R { MEMORY.with(|memory| { *memory.borrow_mut() = regions .iter() .map(|&(address, bytes)| (address, bytes.to_vec())) .collect(); }); + MODULES.with(|held| { + *held.borrow_mut() = modules + .iter() + .map(|&(name, address, size)| (name.to_string(), address, size)) + .collect(); + }); let process = Process::attach("mock").expect("the mock always attaches"); test(&process) } +fn module(name_ptr: *const u8, name_len: usize) -> Option<(u64, u64)> { + // SAFETY: The runtime layer passes a pointer to name_len bytes of UTF-8. + let name = + unsafe { core::str::from_utf8_unchecked(core::slice::from_raw_parts(name_ptr, name_len)) }; + MODULES.with(|held| { + held.borrow() + .iter() + .find(|(held_name, _, _)| held_name == name) + .map(|&(_, address, size)| (address, size)) + }) +} + +/// The host's log. Tests have no need to see it. +#[no_mangle] +const extern "C" fn runtime_print_message(_text_ptr: *const u8, _text_len: usize) {} + +#[no_mangle] +extern "C" fn process_get_module_address( + _process: u64, + name_ptr: *const u8, + name_len: usize, +) -> Option { + NonZeroU64::new(module(name_ptr, name_len)?.0) +} + +#[no_mangle] +extern "C" fn process_get_module_size( + _process: u64, + name_ptr: *const u8, + name_len: usize, +) -> Option { + NonZeroU64::new(module(name_ptr, name_len)?.1) +} + +/// Polls a future a single time. The mock host answers everything +/// synchronously, so a future either resolves on its first poll or sits on a +/// condition the fixture never satisfies. +pub fn poll_once(future: F) -> Poll { + core::pin::pin!(future).poll(&mut Context::from_waker(Waker::noop())) +} + #[no_mangle] extern "C" fn process_attach(_name_ptr: *const u8, _name_len: usize) -> Option { NonZeroU64::new(1) @@ -32,6 +98,27 @@ extern "C" fn process_attach(_name_ptr: *const u8, _name_len: usize) -> Option Option { + MEMORY.with(|memory| NonZeroU64::new(memory.borrow().len() as u64)) +} + +#[no_mangle] +extern "C" fn process_get_memory_range_address(_process: u64, idx: u64) -> Option { + MEMORY.with(|memory| { + let memory = memory.borrow(); + NonZeroU64::new(memory.get(idx as usize)?.0) + }) +} + +#[no_mangle] +extern "C" fn process_get_memory_range_size(_process: u64, idx: u64) -> Option { + MEMORY.with(|memory| { + let memory = memory.borrow(); + NonZeroU64::new(memory.get(idx as usize)?.1.len() as u64) + }) +} + #[no_mangle] extern "C" fn process_read(_process: u64, address: u64, buf_ptr: *mut u8, buf_len: usize) -> bool { MEMORY.with(|memory| { @@ -61,6 +148,21 @@ extern "C" fn process_read(_process: u64, address: u64, buf_ptr: *mut u8, buf_le #[cfg(test)] mod tests { use super::with_process; + use crate::Address; + + #[test] + fn ranges_mirror_the_regions() { + with_process(&[(0x1000, &[1, 2]), (0x4000, &[3, 4, 5])], |process| { + let mut ranges = process.memory_ranges(); + let range = ranges.next().unwrap(); + assert_eq!(range.address().unwrap(), Address::new(0x1000)); + assert_eq!(range.size().unwrap(), 2); + let range = ranges.next().unwrap(); + assert_eq!(range.address().unwrap(), Address::new(0x4000)); + assert_eq!(range.size().unwrap(), 3); + assert!(ranges.next().is_none()); + }); + } #[test] fn reads_come_from_the_regions() {