diff --git a/src/file_format/elf.rs b/src/file_format/elf.rs index 179bfed..917e925 100644 --- a/src/file_format/elf.rs +++ b/src/file_format/elf.rs @@ -1074,7 +1074,7 @@ struct SymTab64 { /// /// By using this function, the user must be aware of the following limitations: /// - Only allocatable symbols and symbols used by the dynamic linker are exported -/// (.symtab is not loaded in memory at runtime) +/// (.symtab is not loaded in memory at runtime) /// - Only 64-bit ELFs are supported (an empty iterator will be returned for 32-bit ELFs) pub fn symbols( process: &Process, @@ -1157,3 +1157,210 @@ pub fn symbols( }) .fuse() } + +/// The GNU build ID of an ELF module, read from its `NT_GNU_BUILD_ID` note. +/// The linker derives it from the built binary, so it names one exact build. +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +pub struct BuildId { + bytes: [u8; 32], + len: u8, +} + +impl BuildId { + /// The bytes of the build ID. + pub fn as_bytes(&self) -> &[u8] { + &self.bytes[..self.len as usize] + } +} + +impl fmt::Debug for BuildId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.as_bytes() { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +/// Reads the GNU build ID from the notes of the ELF module starting at the +/// given address. Returns [`None`] if the module carries no such note, which +/// not every module does. Only little-endian ELFs are supported. +pub fn build_id(process: &Process, module_address: Address) -> Option { + #[derive(Debug, Copy, Clone, Pod, Zeroable)] + #[repr(C)] + struct NoteHeader { + n_namesz: u32, + n_descsz: u32, + n_type: u32, + } + + const NT_GNU_BUILD_ID: u32 = 3; + + let header = process.read::
(module_address).ok()?; + let info = Info::parse(bytemuck::bytes_of(&header))?; + + if info.endian != Endian::Little { + return None; + } + + let (e_phoff, e_phentsize, e_phnum) = if info.bitness.is_64() { + let header = process.read::(module_address).ok()?; + (header.e_phoff, header.e_phentsize as u64, header.e_phnum) + } else { + let header = process.read::(module_address).ok()?; + ( + header.e_phoff as u64, + header.e_phentsize as u64, + header.e_phnum, + ) + }; + + (0..e_phnum).find_map(|index| { + let at = module_address + e_phoff + e_phentsize.wrapping_mul(index as u64); + + let (p_type, p_vaddr, p_filesz) = if info.bitness.is_64() { + let program_header = process.read::(at).ok()?; + ( + program_header.p_type, + program_header.p_vaddr, + program_header.p_filesz, + ) + } else { + let program_header = process.read::(at).ok()?; + ( + program_header.p_type, + program_header.p_vaddr as u64, + program_header.p_filesz as u64, + ) + }; + + if SegmentType(p_type) != SegmentType::PT_NOTE { + return None; + } + + // A note is its header, the name, then the data, the latter two padded + // to four bytes. + let segment = module_address + p_vaddr; + let mut offset = 0; + // The walk is bounded so corrupt sizes can't turn it into a scan. + for _ in 0..0x10 { + if offset + size_of::() as u64 > p_filesz { + return None; + } + + let note = process.read::(segment + offset).ok()?; + let name = offset + size_of::() as u64; + let desc = name + (note.n_namesz as u64).next_multiple_of(4); + + if note.n_type == NT_GNU_BUILD_ID + && note.n_namesz == 4 + && (1..=32).contains(¬e.n_descsz) + && desc + note.n_descsz as u64 <= p_filesz + && process.read::<[u8; 4]>(segment + name).ok()? == *b"GNU\0" + { + let mut bytes = [0; 32]; + process + .read_into_buf(segment + desc, &mut bytes[..note.n_descsz as usize]) + .ok()?; + return Some(BuildId { + bytes, + len: note.n_descsz as u8, + }); + } + + offset = desc + (note.n_descsz as u64).next_multiple_of(4); + } + + None + }) +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::build_id; + use crate::runtime::mock::with_process; + + use std::{format, vec, vec::Vec}; + + const BASE: u64 = 0x7F12_3450_0000; + + // The build ID of the 2019.1 mono runtime, a sha1 the linker computed. + const BUILD_ID: [u8; 20] = [ + 0xE6, 0xAA, 0x00, 0x0A, 0x9A, 0x52, 0x01, 0x63, 0x57, 0x43, 0xC6, 0xA1, 0xB2, 0x78, 0x1E, + 0x05, 0x7D, 0x65, 0x1D, 0xAD, + ]; + + fn put(image: &mut [u8], at: usize, bytes: &[u8]) { + image[at..at + bytes.len()].copy_from_slice(bytes); + } + + // Builds a minimal mapped ELF by hand from the spec, so the walk is + // checked against the format rather than against itself: the header, a + // load and a note program header, and a note segment holding an ABI tag + // note followed by the build ID note. + fn image(wide: bool) -> Vec { + let mut image = vec![0; 0x400]; + put(&mut image, 0x00, b"\x7fELF"); + image[0x04] = if wide { 2 } else { 1 }; + image[0x05] = 1; + image[0x06] = 1; + put(&mut image, 0x10, &3_u16.to_le_bytes()); + if wide { + put(&mut image, 0x20, &0x40_u64.to_le_bytes()); + put(&mut image, 0x36, &56_u16.to_le_bytes()); + put(&mut image, 0x38, &2_u16.to_le_bytes()); + put(&mut image, 0x40, &1_u32.to_le_bytes()); + put(&mut image, 0x78, &4_u32.to_le_bytes()); + put(&mut image, 0x88, &0x200_u64.to_le_bytes()); + put(&mut image, 0x98, &0x44_u64.to_le_bytes()); + } else { + put(&mut image, 0x1C, &0x34_u32.to_le_bytes()); + put(&mut image, 0x2A, &32_u16.to_le_bytes()); + put(&mut image, 0x2C, &2_u16.to_le_bytes()); + put(&mut image, 0x34, &1_u32.to_le_bytes()); + put(&mut image, 0x54, &4_u32.to_le_bytes()); + put(&mut image, 0x5C, &0x200_u32.to_le_bytes()); + put(&mut image, 0x64, &0x44_u32.to_le_bytes()); + } + put(&mut image, 0x200, &4_u32.to_le_bytes()); + put(&mut image, 0x204, &16_u32.to_le_bytes()); + put(&mut image, 0x208, &1_u32.to_le_bytes()); + put(&mut image, 0x20C, b"GNU\0"); + put(&mut image, 0x220, &4_u32.to_le_bytes()); + put(&mut image, 0x224, &20_u32.to_le_bytes()); + put(&mut image, 0x228, &3_u32.to_le_bytes()); + put(&mut image, 0x22C, b"GNU\0"); + put(&mut image, 0x230, &BUILD_ID); + image + } + + #[test] + fn reads_the_build_id_from_a_mapped_image() { + for wide in [true, false] { + with_process(&[(BASE, &image(wide))], |process| { + let build_id = build_id(process, BASE.into()).unwrap(); + assert_eq!(build_id.as_bytes(), BUILD_ID); + }); + } + } + + #[test] + fn renders_the_id_as_hex() { + with_process(&[(BASE, &image(true))], |process| { + let build_id = build_id(process, BASE.into()).unwrap(); + assert_eq!( + format!("{build_id:?}"), + "e6aa000a9a5201635743c6a1b2781e057d651dad", + ); + }); + } + + #[test] + fn answers_nothing_without_a_note_segment() { + let mut image = image(true); + put(&mut image, 0x78, &0_u32.to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert!(build_id(process, BASE.into()).is_none()); + }); + } +} diff --git a/src/file_format/macho.rs b/src/file_format/macho.rs index 147a24c..c19abad 100644 --- a/src/file_format/macho.rs +++ b/src/file_format/macho.rs @@ -1,11 +1,15 @@ //! Support for parsing Mach-O format +use core::{fmt, mem}; + #[cfg(feature = "alloc")] use core::iter::FusedIterator; #[cfg(feature = "alloc")] use alloc::collections::BTreeMap; +use bytemuck::{Pod, Zeroable}; + #[cfg(feature = "alloc")] use crate::{string::ArrayCString, Error}; use crate::{Address, PointerSize, Process}; @@ -36,11 +40,8 @@ fn scan_macho_page(process: &Process, range: (Address, u64)) -> Option
let first_page = addr + distance_to_page; for i in 0..((len - distance_to_page) / PAGE_SIZE) { let a = first_page + (i * PAGE_SIZE); - match process.read::(a) { - Ok(MH_MAGIC_64 | MH_CIGAM_64 | MH_MAGIC_32 | MH_CIGAM_32) => { - return Some(a); - } - _ => (), + if let Ok(MH_MAGIC_64 | MH_CIGAM_64 | MH_MAGIC_32 | MH_CIGAM_32) = process.read::(a) { + return Some(a); } } None @@ -71,6 +72,8 @@ fn scan_macho_pages( // Constants for the cmd field of load commands, the type // https://opensource.apple.com/source/xnu/xnu-4570.71.2/EXTERNAL_HEADERS/mach-o/loader.h.auto.html +/// the uuid +const LC_UUID: u32 = 0x1b; /// link-edit stab symbol table info #[cfg(feature = "alloc")] const LC_SYMTAB: u32 = 0x2; @@ -78,6 +81,77 @@ const LC_SYMTAB: u32 = 0x2; #[cfg(feature = "alloc")] const LC_SEGMENT_64: u32 = 0x19; +/// The UUID of a Mach-O module, from its `LC_UUID` load command. The linker +/// derives it from the built binary, so it names one exact build. +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +pub struct Uuid { + /// The bytes of the UUID. + pub bytes: [u8; 16], +} + +impl fmt::Debug for Uuid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, byte) in self.bytes.iter().enumerate() { + if let 4 | 6 | 8 | 10 = i { + f.write_str("-")?; + } + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +/// Reads the UUID from the load commands of the Mach-O module in the given +/// range. Returns [`None`] if the module carries no `LC_UUID` command. +pub fn uuid(process: &Process, range: (Address, u64)) -> Option { + #[derive(Debug, Copy, Clone, Zeroable, Pod)] + #[repr(C)] + struct MachHeader { + magic: u32, + cputype: u32, + cpusubtype: u32, + filetype: u32, + ncmds: u32, + sizeofcmds: u32, + flags: u32, + } + + #[derive(Debug, Copy, Clone, Zeroable, Pod)] + #[repr(C)] + struct LoadCommand { + cmd: u32, + cmdsize: u32, + } + + let page = scan_macho_page(process, range)?; + let header = process.read::(page).ok()?; + + // The 64-bit header ends with one reserved field the 32-bit one lacks. + let commands = page + + match header.magic { + MH_MAGIC_64 => mem::size_of::() + mem::size_of::(), + MH_MAGIC_32 => mem::size_of::(), + _ => return None, + } as u64; + + let mut offset = 0; + // The walk is bounded so a corrupt command count can't turn it into a scan. + for _ in 0..header.ncmds.min(0x40) { + let command = process.read::(commands + offset).ok()?; + + if command.cmd == LC_UUID { + return process + .read::<[u8; 16]>(commands + offset + mem::size_of::() as u64) + .ok() + .map(|bytes| Uuid { bytes }); + } + + offset += command.cmdsize as u64; + } + + None +} + #[cfg(feature = "alloc")] struct MachOFormatOffsets { number_of_commands: u32, @@ -205,3 +279,67 @@ fn fileoff_to_vmaddr(map: &BTreeMap, fileoff: u64) -> u64 { .map(|(&k, &v)| v + fileoff - k) .unwrap_or(fileoff) } + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::uuid; + use crate::runtime::mock::with_process; + + use std::{format, vec, vec::Vec}; + + const BASE: u64 = 0x1_0000_0000; + + // The UUID of the 2019.4 mono runtime shipped with a real mac player. + const UUID: [u8; 16] = [ + 0xE7, 0x42, 0x0B, 0xC7, 0xA2, 0x6B, 0x33, 0xFA, 0xB5, 0xCD, 0x41, 0xCA, 0xD7, 0xD4, 0x61, + 0x4C, + ]; + + fn put(image: &mut [u8], at: usize, bytes: &[u8]) { + image[at..at + bytes.len()].copy_from_slice(bytes); + } + + // Builds a minimal mapped Mach-O by hand from the loader header, so the + // walk is checked against the format rather than against itself: the + // header, a segment command, and the uuid command. + fn image(wide: bool) -> Vec { + let mut image = vec![0; 0x1000]; + let magic: u32 = if wide { 0xFEEDFACF } else { 0xFEEDFACE }; + put(&mut image, 0x00, &magic.to_le_bytes()); + put(&mut image, 0x10, &2_u32.to_le_bytes()); + let commands = if wide { 0x20 } else { 0x1C }; + put(&mut image, commands, &0x19_u32.to_le_bytes()); + put(&mut image, commands + 0x4, &0x48_u32.to_le_bytes()); + put(&mut image, commands + 0x48, &0x1B_u32.to_le_bytes()); + put(&mut image, commands + 0x4C, &24_u32.to_le_bytes()); + put(&mut image, commands + 0x50, &UUID); + image + } + + #[test] + fn reads_the_uuid_from_a_mapped_image() { + for wide in [true, false] { + with_process(&[(BASE, &image(wide))], |process| { + let uuid = uuid(process, (BASE.into(), 0x1000)).unwrap(); + assert_eq!(uuid.bytes, UUID); + }); + } + } + + #[test] + fn renders_the_uuid_canonically() { + with_process(&[(BASE, &image(true))], |process| { + let uuid = uuid(process, (BASE.into(), 0x1000)).unwrap(); + assert_eq!(format!("{uuid:?}"), "e7420bc7-a26b-33fa-b5cd-41cad7d4614c"); + }); + } + + #[test] + fn answers_nothing_without_a_uuid_command() { + let mut image = image(true); + put(&mut image, 0x20 + 0x48, &0_u32.to_le_bytes()); + with_process(&[(BASE, &image)], |process| { + assert!(uuid(process, (BASE.into(), 0x1000)).is_none()); + }); + } +} diff --git a/src/file_format/pe.rs b/src/file_format/pe.rs index 46f63cb..b5bda8c 100644 --- a/src/file_format/pe.rs +++ b/src/file_format/pe.rs @@ -95,6 +95,94 @@ struct OptionalCOFFHeader { // There's more but those vary depending on whether it's PE or PE+. } +// The magic at the head of the optional header decides between the PE32 and +// PE32+ layouts. +const OPTIONAL_HEADER_MAGIC_PE32: u16 = 0x10B; +const OPTIONAL_HEADER_MAGIC_PE32_PLUS: u16 = 0x20B; + +/// An entry of the data directory array at the end of the optional header, +/// naming where one of the image's tables lives. +#[derive(Debug, Copy, Clone, Zeroable, Pod)] +#[repr(C)] +struct DataDirectory { + virtual_address: u32, + size: u32, +} + +/// The full PE32 optional header. +#[derive(Debug, Copy, Clone, Zeroable, Pod)] +#[repr(C)] +struct OptionalHeader32 { + magic: u16, + major_linker_version: u8, + minor_linker_version: u8, + size_of_code: u32, + size_of_initialized_data: u32, + size_of_uninitialized_data: u32, + address_of_entry_point: u32, + base_of_code: u32, + base_of_data: u32, + image_base: u32, + section_alignment: u32, + file_alignment: u32, + major_operating_system_version: u16, + minor_operating_system_version: u16, + major_image_version: u16, + minor_image_version: u16, + major_subsystem_version: u16, + minor_subsystem_version: u16, + win32_version_value: u32, + size_of_image: u32, + size_of_headers: u32, + checksum: u32, + subsystem: u16, + dll_characteristics: u16, + size_of_stack_reserve: u32, + size_of_stack_commit: u32, + size_of_heap_reserve: u32, + size_of_heap_commit: u32, + loader_flags: u32, + number_of_rva_and_sizes: u32, + data_directories: [DataDirectory; 16], +} + +/// The full PE32+ optional header, which drops `base_of_data` and widens the +/// image base and the stack and heap sizes. +#[derive(Debug, Copy, Clone, Zeroable, Pod)] +#[repr(C)] +struct OptionalHeader64 { + magic: u16, + major_linker_version: u8, + minor_linker_version: u8, + size_of_code: u32, + size_of_initialized_data: u32, + size_of_uninitialized_data: u32, + address_of_entry_point: u32, + base_of_code: u32, + image_base: u64, + section_alignment: u32, + file_alignment: u32, + major_operating_system_version: u16, + minor_operating_system_version: u16, + major_image_version: u16, + minor_image_version: u16, + major_subsystem_version: u16, + minor_subsystem_version: u16, + win32_version_value: u32, + size_of_image: u32, + size_of_headers: u32, + checksum: u32, + subsystem: u16, + dll_characteristics: u16, + size_of_stack_reserve: u64, + size_of_stack_commit: u64, + size_of_heap_reserve: u64, + size_of_heap_commit: u64, + loader_flags: u32, + number_of_rva_and_sizes: u32, + data_directories: [DataDirectory; 16], +} + #[derive(Debug, Copy, Clone, Zeroable, Pod, Default)] #[repr(C)] struct ExportedSymbolsTableDef { @@ -484,3 +572,207 @@ impl FileVersion { .map(|val| val.file_version) } } + +/// The identity of the debug information of a PE module, as recorded in the +/// module's CodeView debug directory entry. Every build of a module gets a +/// fresh identity, so it names one exact binary: symbol servers key their +/// downloads on the GUID and age pair. +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +pub struct DebugId { + /// The GUID of the debug information, in the byte order it is stored in. + pub guid: [u8; 16], + /// The number of times the debug information was written out. + pub age: u32, +} + +impl DebugId { + /// Reads the debug identity from the CodeView entry of the debug directory + /// of the PE module starting at the specified memory address. Returns + /// `None` if the module has no debug directory, no CodeView entry, or + /// debug information in a format older than PDB 7.0. + pub fn read(process: &Process, module_address: impl Into
) -> Option { + #[repr(C)] + #[derive(Debug, Copy, Clone, Zeroable, Pod)] + struct DebugDirectoryEntry { + characteristics: u32, + time_date_stamp: u32, + major_version: u16, + minor_version: u16, + debug_type: u32, + size_of_data: u32, + address_of_raw_data: u32, + pointer_to_raw_data: u32, + } + + #[repr(C)] + #[derive(Debug, Copy, Clone, Zeroable, Pod)] + struct CodeView70 { + signature: [u8; 4], + guid: [u8; 16], + age: u32, + } + + const IMAGE_DIRECTORY_ENTRY_DEBUG: usize = 6; + const IMAGE_DEBUG_TYPE_CODEVIEW: u32 = 2; + + let address: Address = module_address.into(); + + let (coff_header, coff_header_address) = read_coff_header(process, address)?; + + let optional_header_address = coff_header_address + mem::size_of::() as u64; + + let (optional_header_size, directory) = + match process.read::(optional_header_address).ok()? { + OPTIONAL_HEADER_MAGIC_PE32 => ( + mem::size_of::(), + process + .read::(optional_header_address) + .ok()? + .data_directories[IMAGE_DIRECTORY_ENTRY_DEBUG], + ), + OPTIONAL_HEADER_MAGIC_PE32_PLUS => ( + mem::size_of::(), + process + .read::(optional_header_address) + .ok()? + .data_directories[IMAGE_DIRECTORY_ENTRY_DEBUG], + ), + _ => return None, + }; + + if (coff_header.size_of_optional_header as usize) < optional_header_size { + return None; + } + + let directory = Some(directory) + .filter(|directory| directory.virtual_address != 0 && directory.size != 0)?; + + let entries = directory.size as usize / mem::size_of::(); + + // The walk is bounded so a corrupt entry count can't turn it into a scan. + (0..entries.min(0x10)).find_map(|i| { + let entry = process + .read::( + address + + directory.virtual_address + + (i * mem::size_of::()) as u64, + ) + .ok() + .filter(|entry| { + entry.debug_type == IMAGE_DEBUG_TYPE_CODEVIEW && entry.address_of_raw_data != 0 + })?; + + process + .read::(address + entry.address_of_raw_data) + .ok() + .filter(|codeview| codeview.signature == *b"RSDS") + .map(|codeview| Self { + guid: codeview.guid, + age: codeview.age, + }) + }) + } +} + +impl fmt::Debug for DebugId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // The first three fields of the GUID are stored little-endian and + // render big-endian in the canonical form. + let [a0, a1, a2, a3, b0, b1, c0, c1, d0, d1, d2, d3, d4, d5, d6, d7] = self.guid; + write!( + f, + "{:08x}-{:04x}-{:04x}-{d0:02x}{d1:02x}-{d2:02x}{d3:02x}{d4:02x}{d5:02x}{d6:02x}{d7:02x} (age {})", + u32::from_le_bytes([a0, a1, a2, a3]), + u16::from_le_bytes([b0, b1]), + u16::from_le_bytes([c0, c1]), + self.age, + ) + } +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::DebugId; + use crate::runtime::mock::with_process; + + use std::{vec, vec::Vec}; + + const BASE: u64 = 0x7FF6_1000_0000; + + // The 2019.4 mono runtime's GUID, stored as CodeView stores it: the first + // three fields little-endian, the rest in order. + const GUID: [u8; 16] = [ + 0xC7, 0xAA, 0x10, 0x77, 0x5A, 0x31, 0x30, 0x4D, 0xA7, 0x7A, 0x08, 0x07, 0x29, 0x69, 0x66, + 0xF6, + ]; + + fn put(image: &mut [u8], at: usize, bytes: &[u8]) { + image[at..at + bytes.len()].copy_from_slice(bytes); + } + + // Builds a minimal mapped PE image by hand from the spec, so the walk is + // checked against the format rather than against itself: headers at the + // base, a two-entry debug directory with the CodeView entry second, and + // the PDB 7.0 record it points at. + fn image(wide: bool) -> Vec { + let mut image = vec![0; 0x400]; + put(&mut image, 0x00, b"MZ"); + put(&mut image, 0x3C, &0x80_u32.to_le_bytes()); + put(&mut image, 0x80, b"PE\0\0"); + let size_of_optional_header: u16 = if wide { 0xF0 } else { 0xE0 }; + put(&mut image, 0x94, &size_of_optional_header.to_le_bytes()); + let magic: u16 = if wide { 0x20B } else { 0x10B }; + put(&mut image, 0x98, &magic.to_le_bytes()); + let debug_dd_at = 0x98 + if wide { 0xA0 } else { 0x90 }; + put(&mut image, debug_dd_at, &0x200_u32.to_le_bytes()); + put(&mut image, debug_dd_at + 4, &(2 * 28_u32).to_le_bytes()); + // Entry 0 is POGO data, entry 1 the CodeView record. + put(&mut image, 0x200 + 0xC, &13_u32.to_le_bytes()); + put(&mut image, 0x21C + 0xC, &2_u32.to_le_bytes()); + put(&mut image, 0x21C + 0x10, &0x30_u32.to_le_bytes()); + put(&mut image, 0x21C + 0x14, &0x300_u32.to_le_bytes()); + put(&mut image, 0x300, b"RSDS"); + put(&mut image, 0x304, &GUID); + put(&mut image, 0x314, &1_u32.to_le_bytes()); + put(&mut image, 0x318, b"mono-2.0-bdwgc.pdb\0"); + image + } + + #[test] + fn reads_the_debug_id_from_a_mapped_image() { + for wide in [true, false] { + with_process(&[(BASE, &image(wide))], |process| { + let debug_id = DebugId::read(process, BASE).unwrap(); + assert_eq!(debug_id.guid, GUID); + assert_eq!(debug_id.age, 1); + }); + } + } + + #[test] + fn renders_the_guid_canonically() { + let debug_id = DebugId { guid: GUID, age: 1 }; + assert_eq!( + std::format!("{debug_id:?}"), + "7710aac7-315a-4d30-a77a-0807296966f6 (age 1)", + ); + } + + #[test] + fn answers_nothing_without_a_debug_directory() { + let mut image = image(true); + put(&mut image, 0x98 + 0xA0, &[0; 8]); + with_process(&[(BASE, &image)], |process| { + assert!(DebugId::read(process, BASE).is_none()); + }); + } + + #[test] + fn answers_nothing_for_debug_information_older_than_pdb_70() { + let mut image = image(true); + put(&mut image, 0x300, b"NB10"); + with_process(&[(BASE, &image)], |process| { + assert!(DebugId::read(process, BASE).is_none()); + }); + } +} diff --git a/src/game_engine/unity/il2cpp/assembly.rs b/src/game_engine/unity/il2cpp/assembly.rs deleted file mode 100644 index b683bc4..0000000 --- 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 0000000..3bf64e2 --- /dev/null +++ b/src/game_engine/unity/il2cpp/builds.rs @@ -0,0 +1,822 @@ +//! Known IL2CPP builds: exact metadata layouts, named by the version of the +//! game's `global-metadata.dat` and the Unity version that shipped it, paired +//! with the offsets measured from their symbols. + +use super::offsets::{ + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, IL2CPPOffsets, ImageOffsets, + TypeOffsets, +}; +use super::Version; +use crate::PointerSize; + +/// One exact IL2CPP layout and the offsets measured from it. +pub(super) struct Build { + pub(super) metadata: u32, + pub(super) unity: (u16, u16), + pub(super) pointer_size: PointerSize, + pub(super) version: Version, + pub(super) offsets: IL2CPPOffsets, +} + +/// Looks up the newest known build at or below the given identity. Unlike a +/// mono runtime, `GameAssembly.dll` is compiled per game, so no identity names +/// one binary: a build declares the version it applies from, and an identity +/// below the oldest known build answers nothing. +pub(super) fn find( + metadata: u32, + unity: (u16, u16), + pointer_size: PointerSize, +) -> Option<&'static Build> { + BUILDS + .iter() + .rev() + .filter(|build| build.pointer_size == pointer_size) + .find(|build| (build.metadata, build.unity) <= (metadata, unity)) +} + +// The table reads from the oldest metadata to the newest. +static BUILDS: &[Build] = &[ + // Unity 2018.4.36f1, metadata version 24, x64. + // Offsets from the player's own GameAssembly pdb, scans matched against the symbols they resolve to. + // The class has no unity_user_data yet, so everything past it sits eight bytes lower than in 2019.4. + Build { + metadata: 24, + unity: (2018, 4), + 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 2019.4.41f2 and 2020.1.18f1, metadata version 24, x64. + // Offsets from each player's own GameAssembly pdb, scans matched against the symbols they resolve to. + Build { + metadata: 24, + unity: (2019, 4), + 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.18, metadata version 24, x64, measured at release and master. + Build { + metadata: 24, + unity: (2020, 1), + 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.18, metadata version 24, x86, measured at release and master. + Build { + metadata: 24, + unity: (2020, 1), + 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, scans matched against the symbols they resolve to. + // Metadata 29 spans 2021.3 to 2023.1 and the class is not the same across it: 2023.1 inserted + // stack_slot_size after instance_size, which puts field_count at 0x120 here and 0x124 there. Every + // other offset the walk reads is identical, and neither 2023.1's assemblies scan nor its type table + // scan reaches its symbol on these binaries, so this version carries a set of its own. + Build { + metadata: 29, + unity: (2021, 3), + 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. Verified live against the fixture player. + // Offsets from the player's own GameAssembly pdb, scans matched against the symbols they resolve to. + // x86 code names a global outright rather than by a displacement, so every scan resolves absolute. + // Metadata 29 reaches 2023.1, which moves field_count a word on and carries an entry of its own. + Build { + metadata: 29, + unity: (2021, 3), + 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 2023.1.22f1, metadata version 29, x64. + // Offsets from the player's own GameAssembly pdb, scans matched against the symbols they resolve to. + Build { + metadata: 29, + unity: (2023, 1), + 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. Verified live against the fixture player. + // Offsets from the player's own GameAssembly pdb, scans matched against the symbols they resolve to. + // x86 code names a global outright rather than by a displacement, so every scan resolves absolute. + // Metadata 29 spans 2021.3 to 2023.1 and the class is not the same across it, at either width: + // 2023.1 inserted stack_slot_size after instance_size, which puts field_count at 0xA8 on 2021.3 and + // 0xAC here, the same one word move the 64 bit pair carries at 0x120 and 0x124. Every other offset + // the walk reads is identical, measured member for member off both players' own pdbs, and every + // scan below reaches its global on this version unchanged, which is what lets this entry be the + // 2021.3 one with a single number moved rather than a set of its own. + Build { + metadata: 29, + unity: (2023, 1), + 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.12, metadata version 31, x64, measured at master and release. + Build { + metadata: 31, + unity: (6000, 2), + 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.12, metadata version 31, x86, measured at release and master. + Build { + metadata: 31, + unity: (6000, 2), + 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, scans matched against the symbols they resolve to. + // Unity 6 numbers its metadata apart from what came before: 6000.3 is 39 where 2023.1 was 29, and + // 6000.5 is 107. The layout does not follow that numbering, and this one is 2023.1's rather than + // 6000.5's, `static_fields` having moved to 0xA0 only in the later one. + Build { + metadata: 39, + unity: (6000, 3), + 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, scans matched against the symbols they resolve to + // and held to both configurations of the 32 bit player. + Build { + metadata: 39, + unity: (6000, 3), + 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.8f1, metadata version 107, x64. + // Offsets from the editor's own libil2cpp pdb, scans matched against the symbols they resolve to. + Build { + metadata: 107, + unity: (6000, 5), + 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.8f1, metadata version 107, x86. + // Offsets from the player's own GameAssembly pdb, scans matched against the symbols they resolve to + // and held to both configurations of the 32 bit player. + // Neither neighbour narrowed: the assembly stride is 110's where the generic class is 39's, and + // `static_fields` sits between the two at 0x50, so this width was measured rather than inferred. + Build { + metadata: 107, + unity: (6000, 5), + 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, cross checked against the editor's own + // libil2cpp headers, which agree member for member. Four of them differ from 6000.3's, that era + // carrying members this one has dropped, so nothing here is that entry carried forward. + Build { + metadata: 110, + unity: (6000, 7), + 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. Three of them differ from 6000.3's at this width, + // the same three that differ at 64 bits, so this is measured rather than that entry narrowed: + // `static_fields` sits earlier, the assembly stride is wider, and the generic class is shorter. + // A Master player exists at this width too, and of the three roots only the assemblies table is + // reached differently there, so that one alone carries a Master shape beside its release ones. + Build { + metadata: 110, + unity: (6000, 7), + 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::super::offsets::IL2CPPOffsets; + use super::super::Version; + use super::{find, 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))); + } + + #[test] + fn finds_exact_builds() { + let build = find(39, (6000, 3), PointerSize::Bit64).unwrap(); + assert_eq!(build.metadata, 39); + assert_eq!(build.unity, (6000, 3)); + + let narrow = find(39, (6000, 3), PointerSize::Bit32).unwrap(); + assert_eq!(narrow.metadata, 39); + assert_eq!(narrow.pointer_size, PointerSize::Bit32); + } + + #[test] + fn unmeasured_identities_answer_the_newest_build_below() { + let build = find(29, (2022, 1), PointerSize::Bit64).unwrap(); + assert_eq!((build.metadata, build.unity), (29, (2021, 3))); + + let build = find(35, (6000, 0), PointerSize::Bit64).unwrap(); + assert_eq!((build.metadata, build.unity), (31, (6000, 2))); + + let build = find(200, (7000, 0), PointerSize::Bit64).unwrap(); + assert_eq!((build.metadata, build.unity), (110, (6000, 7))); + } + + #[test] + fn identities_below_the_oldest_build_answer_nothing() { + assert!(find(16, (5, 6), PointerSize::Bit64).is_none()); + assert!(find(24, (2018, 4), PointerSize::Bit32).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 shipped table for 2022.2 and later keeps static_fields at 0xB8; + // 6000.5 measures 0xA0 and 6000.7 measures 0x98 with a smaller + // field_count. The entries keep what was measured. + #[test] + fn unity_6000_5_builds_diverge_from_their_version_table_on_statics() { + let build = find(107, (6000, 5), PointerSize::Bit64).unwrap(); + assert!(matches!(build.version, Version::V2022)); + assert_eq!(build.offsets.class.static_fields, 0xA0); + + let build = find(110, (6000, 7), PointerSize::Bit64).unwrap(); + assert_eq!(build.offsets.class.static_fields, 0x98); + assert_eq!(build.offsets.class.field_count, 0x11C); + } +} diff --git a/src/game_engine/unity/il2cpp/class.rs b/src/game_engine/unity/il2cpp/class.rs index 4e1ba9c..c42fb82 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 0000000..750cc09 --- /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 d9a5587..0000000 --- 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 eeed4b9..0741b8b 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 0699539..df48d99 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,13 +39,49 @@ 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 known build, its measured + /// offsets are used directly. Otherwise 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. 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 identity = Self::identity(process); + + if let Some((metadata, unity)) = identity { + if let Some(build) = builds::find(metadata, unity, pointer_size) { + if let Some(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, + )); + return Some(module); + } + } + } + let version = Version::detect(process)?; - Self::attach(process, version) + let module = Self::attach(process, version)?; + + match identity { + Some((metadata, unity)) if builds::find(metadata, unity, pointer_size).is_none() => { + print_limited::<128>(&format_args!( + "unknown il2cpp build: metadata {metadata}, unity {}.{}", + unity.0, unity.1, + )); + } + _ => {} + } + + Some(module) } /// Tries attaching to a Unity game that is using the IL2CPP backend with @@ -43,15 +89,51 @@ 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)?; + Self::attach_with(process, il2cpp_module, pointer_size, version, offsets) + } + + 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)) + } + + /// What identifies the game's IL2CPP layout: the version of its mapped + /// `global-metadata.dat` and the Unity version stamped on the player. + fn identity(process: &Process) -> Option<(u32, (u16, u16))> { + let metadata = Self::metadata_version(process)?; + + let unity_player = process.get_module_address("UnityPlayer.dll").ok()?; + let file_version = pe::FileVersion::read(process, unity_player)?; + + Some(( + metadata, + (file_version.major_version, file_version.minor_version), + )) + } + + /// Reads the version of the game's metadata off the mapped + /// `global-metadata.dat`, which heads with a sanity value and 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()?; + // The version numbers run small, the renumbered 6000 line reaching + // 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: Address = { const ASSEMBLIES: Signature<12> = Signature::new("75 ?? 48 8B 1D ?? ?? ?? ?? 48 3B 1D"); ASSEMBLIES @@ -97,30 +179,44 @@ 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) - }; - - (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 }) - }) + 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 +226,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 +243,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 +401,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 a402513..a1f1df0 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 6dd1ab6..cfd2bcc 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 0000000..ed934dd --- /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 409fedf..b3a29ef 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 0000000..6e490ef --- /dev/null +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -0,0 +1,399 @@ +//! 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); +} + +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 0000000..55ef411 --- /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 0000000..0a513d8 --- /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 0000000..0dffb42 --- /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 0000000..73e9289 --- /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 0000000..92de895 --- /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 0000000..3856407 --- /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 baa3bfa..1e22145 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 fe17e62..0000000 --- a/src/game_engine/unity/mono/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(|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 new file mode 100644 index 0000000..6e8f7f3 --- /dev/null +++ b/src/game_engine/unity/mono/builds.rs @@ -0,0 +1,1457 @@ +//! Known mono builds: exact runtime binaries, named by the identity of their +//! debug information, paired with the offsets measured from their symbols. + +use super::offsets::{ + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, + ImageOffsets, MonoOffsets, MonoVTableOffsets, TypeOffsets, +}; +use super::Version; +use crate::{file_format::pe::DebugId, PointerSize}; + +/// One exact mono runtime binary and the offsets measured from it. +pub(super) struct Build { + pub(super) guid: [u8; 16], + pub(super) pointer_size: PointerSize, + pub(super) version: Version, + pub(super) offsets: MonoOffsets, +} + +/// Looks the module's exact build up by the GUID of its debug information. +pub(super) fn find(debug_id: &DebugId) -> Option<&'static Build> { + BUILDS + .binary_search_by(|build| build.guid.cmp(&debug_id.guid)) + .ok() + .map(|index| &BUILDS[index]) +} + +/// Parses a canonical GUID into the byte order the debug directory stores it +/// in: the first three fields are little-endian. +const fn guid(canonical: &str) -> [u8; 16] { + const fn hex(byte: u8) -> u8 { + match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + _ => panic!("The GUID is not lowercase hex."), + } + } + + let canonical = canonical.as_bytes(); + assert!( + canonical.len() == 36 + && canonical[8] == b'-' + && canonical[13] == b'-' + && canonical[18] == b'-' + && canonical[23] == b'-', + "The GUID is not in its canonical form.", + ); + + let mut parsed = [0; 16]; + let mut index = 0; + let mut at = 0; + while index < 16 { + if canonical[at] == b'-' { + at += 1; + continue; + } + parsed[index] = (hex(canonical[at]) << 4) | hex(canonical[at + 1]); + index += 1; + at += 2; + } + + [ + parsed[3], parsed[2], parsed[1], parsed[0], parsed[5], parsed[4], parsed[7], parsed[6], + parsed[8], parsed[9], parsed[10], parsed[11], parsed[12], parsed[13], parsed[14], + parsed[15], + ] +} + +// The table is sorted by guid. For mono.dll builds the statics path reads +// MonoVTable.data through vtable_size and never reads v_table.vtable, so those +// builds leave it 0. +static BUILDS: &[Build] = &[ + // Unity 2017.4.40f1, mono-2.0-bdwgc.dll (net_4_6), x86. + // No x86 PDB exists for this binary, so these are the x64 layouts reread at 32 bit rules. + // Written by hand: derive-mono answers nothing without symbols. + Build { + guid: guid("54fe0c31-c851-4749-baa5-7699d1279165"), + pointer_size: PointerSize::Bit32, + version: Version::V2, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x44, + }, + image: ImageOffsets { + assembly_name: Some(0x18), + class_cache: 0x354, + }, + hash_table: HashTableOffsets { + size: 0xc, + 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, + fields: 0x60, + runtime_info: 0x84, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x28 }, + }, + }, + // Unity 6000.5.8, mono-2.0-bdwgc.dll, x86. + Build { + guid: guid("eb6b6239-5624-487c-a84e-d7f0a7335670"), + pointer_size: PointerSize::Bit32, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x48, + }, + image: ImageOffsets { + assembly_name: Some(0x1c), + class_cache: 0x35c, + }, + hash_table: HashTableOffsets { + size: 0xc, + 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, + fields: 0x60, + runtime_info: 0x7c, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x2c }, + }, + }, + // Unity 2017.4.40, mono-2.0-bdwgc.dll (net_4_6), x64. + // Layouts read from mono-2.0-bdwgc.pdb, this binary's own symbols being held nowhere. + Build { + guid: guid("2f7a3442-3c29-424d-8a46-8cc59237ed89"), + pointer_size: PointerSize::Bit64, + version: Version::V2, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x60, + }, + image: ImageOffsets { + assembly_name: Some(0x28), + class_cache: 0x4c0, + }, + hash_table: HashTableOffsets { + size: 0x18, + 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, + fields: 0x98, + runtime_info: 0xd0, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x40 }, + }, + }, + // Unity 2021.3.11, mono-2.0-bdwgc.dll, x64. + Build { + guid: guid("1d994642-9a41-4a6a-84be-f55f9cff8f57"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x60, + }, + image: ImageOffsets { + assembly_name: Some(0x30), + class_cache: 0x4d0, + }, + hash_table: HashTableOffsets { + size: 0x18, + 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, + fields: 0x98, + runtime_info: 0xd0, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, + }, + }, + // Unity 2018.4.36, mono-2.0-bdwgc.dll, x64. + Build { + guid: guid("f469c84e-5b81-4c42-8c3f-72ad629f99cb"), + pointer_size: PointerSize::Bit64, + version: Version::V2, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x60, + }, + image: ImageOffsets { + assembly_name: Some(0x28), + class_cache: 0x4c0, + }, + hash_table: HashTableOffsets { + size: 0x18, + 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, + fields: 0x98, + runtime_info: 0xd0, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x40 }, + }, + }, + // Unity 2018.4.36, mono.dll, x64. + Build { + guid: guid("487fa150-59b5-4a18-8fed-964001db1b82"), + pointer_size: PointerSize::Bit64, + version: Version::V1Cattrs, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x58, + }, + image: ImageOffsets { + assembly_name: Some(0x28), + class_cache: 0x3d0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: None, + instance_size: Some(0x1c), + parent: 0x30, + nested_in: Some(0x38), + name: 0x50, + namespace: 0x58, + vtable_size: 0x18, + fields: 0xb0, + runtime_info: 0x100, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x0 }, + }, + }, + // Unity 6000.5.8, mono-2.0-bdwgc.dll, x64. + Build { + guid: guid("4f356e63-5da8-496c-8bb8-aaf2a0b1f364"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x60, + }, + image: ImageOffsets { + assembly_name: Some(0x30), + class_cache: 0x4d0, + }, + hash_table: HashTableOffsets { + size: 0x18, + 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, + fields: 0x98, + runtime_info: 0xd0, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, + }, + }, + // Unity 6000.2.12, mono-2.0-bdwgc.dll, x64. + Build { + guid: guid("018d6f65-a658-4607-93eb-2518f5018226"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x60, + }, + image: ImageOffsets { + assembly_name: Some(0x30), + class_cache: 0x4d0, + }, + hash_table: HashTableOffsets { + size: 0x18, + 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, + fields: 0x98, + runtime_info: 0xd0, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, + }, + }, + // Unity 6000.7.0, mono-2.0-bdwgc.dll, x64. + Build { + guid: guid("49c1826a-d1b9-442e-8388-4509b7c91395"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x60, + }, + image: ImageOffsets { + assembly_name: Some(0x30), + class_cache: 0x4d0, + }, + hash_table: HashTableOffsets { + size: 0x18, + 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, + fields: 0x98, + runtime_info: 0xd0, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, + }, + }, + // Unity 6000.3.21, mono-2.0-bdwgc.dll, x64. + Build { + guid: guid("1ac99f6b-fd3a-4dc0-93e7-782ca1b4be7d"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x60, + }, + image: ImageOffsets { + assembly_name: Some(0x30), + class_cache: 0x4d0, + }, + hash_table: HashTableOffsets { + size: 0x18, + 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, + fields: 0x98, + runtime_info: 0xd0, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, + }, + }, + // Unity 2018.4.36, mono.dll, x86. + Build { + guid: guid("c3c97c70-f490-4462-a27d-b4103d2aca1f"), + pointer_size: PointerSize::Bit32, + version: Version::V1Cattrs, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x40, + }, + image: ImageOffsets { + assembly_name: Some(0x18), + class_cache: 0x2a0, + }, + hash_table: HashTableOffsets { + size: 0xc, + table: 0x14, + }, + class: ClassOffsets { + class_kind: None, + instance_size: Some(0x10), + parent: 0x24, + nested_in: Some(0x28), + name: 0x34, + namespace: 0x38, + vtable_size: 0xc, + fields: 0x78, + runtime_info: 0xa8, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x0 }, + }, + }, + // Unity 5.6.7, mono.dll, x64. + // Layouts read from mono.pdb, this binary's own symbols being held nowhere. + Build { + guid: guid("924a8172-8d25-496f-b684-20c9f04d4f92"), + pointer_size: PointerSize::Bit64, + version: Version::V1, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x58, + }, + image: ImageOffsets { + assembly_name: Some(0x28), + class_cache: 0x3d0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: None, + instance_size: Some(0x1c), + parent: 0x30, + nested_in: Some(0x38), + name: 0x48, + namespace: 0x50, + vtable_size: 0x18, + fields: 0xa8, + runtime_info: 0xf8, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x0 }, + }, + }, + // Unity 2020.1.18, mono-2.0-bdwgc.dll, x86. + Build { + guid: guid("984e5687-3dd9-4d72-8e88-552c6810430d"), + pointer_size: PointerSize::Bit32, + version: Version::V2, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x44, + }, + image: ImageOffsets { + assembly_name: Some(0x18), + class_cache: 0x354, + }, + hash_table: HashTableOffsets { + size: 0xc, + 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, + fields: 0x60, + runtime_info: 0x84, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x28 }, + }, + }, + // Unity 2020.1.18, mono-2.0-bdwgc.dll, x64. + Build { + guid: guid("0b5f7f89-7937-4300-9c3b-a1ec2c75e06e"), + pointer_size: PointerSize::Bit64, + version: Version::V2, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x60, + }, + image: ImageOffsets { + assembly_name: Some(0x28), + class_cache: 0x4c0, + }, + hash_table: HashTableOffsets { + size: 0x18, + 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, + fields: 0x98, + runtime_info: 0xd0, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x40 }, + }, + }, + // Unity 2017.4.40, mono.dll, x64. + Build { + guid: guid("c1c35e9c-fd72-4ebf-af5e-e7c932e2865d"), + pointer_size: PointerSize::Bit64, + version: Version::V1Cattrs, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x58, + }, + image: ImageOffsets { + assembly_name: Some(0x28), + class_cache: 0x3d0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: None, + instance_size: Some(0x1c), + parent: 0x30, + nested_in: Some(0x38), + name: 0x50, + namespace: 0x58, + vtable_size: 0x18, + fields: 0xb0, + runtime_info: 0x100, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x0 }, + }, + }, + // Unity 6000.3.21, mono-2.0-bdwgc.dll, x86. + Build { + guid: guid("44e461a2-1832-413d-afb1-3fe613634de3"), + pointer_size: PointerSize::Bit32, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x48, + }, + image: ImageOffsets { + assembly_name: Some(0x1c), + class_cache: 0x35c, + }, + hash_table: HashTableOffsets { + size: 0xc, + 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, + fields: 0x60, + runtime_info: 0x7c, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x2c }, + }, + }, + // Unity 2017.4.40, mono.dll, x86. + Build { + guid: guid("d45555b8-4783-4fba-9eeb-f830cb655d89"), + pointer_size: PointerSize::Bit32, + version: Version::V1Cattrs, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x40, + }, + image: ImageOffsets { + assembly_name: Some(0x18), + class_cache: 0x2a0, + }, + hash_table: HashTableOffsets { + size: 0xc, + table: 0x14, + }, + class: ClassOffsets { + class_kind: None, + instance_size: Some(0x10), + parent: 0x24, + nested_in: Some(0x28), + name: 0x34, + namespace: 0x38, + vtable_size: 0xc, + fields: 0x78, + runtime_info: 0xa8, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x0 }, + }, + }, + // Unity 6000.7.0, mono-2.0-bdwgc.dll, x86. + Build { + guid: guid("8e2fbcbc-d64d-4993-a733-a489d7a90b2b"), + pointer_size: PointerSize::Bit32, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x48, + }, + image: ImageOffsets { + assembly_name: Some(0x1c), + class_cache: 0x35c, + }, + hash_table: HashTableOffsets { + size: 0xc, + 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, + fields: 0x60, + runtime_info: 0x7c, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x2c }, + }, + }, + // Unity 2023.1.22, mono-2.0-bdwgc.dll, x64. + Build { + guid: guid("4aac62be-dfea-4610-91fc-8a1b6c768935"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x60, + }, + image: ImageOffsets { + assembly_name: Some(0x30), + class_cache: 0x4d0, + }, + hash_table: HashTableOffsets { + size: 0x18, + 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, + fields: 0x98, + runtime_info: 0xd0, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, + }, + }, + // Unity 2019.4.41, mono-2.0-bdwgc.dll, x64. + Build { + guid: guid("7710aac7-315a-4d30-a77a-0807296966f6"), + pointer_size: PointerSize::Bit64, + version: Version::V2, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x60, + }, + image: ImageOffsets { + assembly_name: Some(0x28), + class_cache: 0x4c0, + }, + hash_table: HashTableOffsets { + size: 0x18, + 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, + fields: 0x98, + runtime_info: 0xd0, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x40 }, + }, + }, + // Unity 2019.4.41, mono-2.0-bdwgc.dll, x86. + Build { + guid: guid("998210ce-aee9-4d0b-a225-9c529815fc78"), + pointer_size: PointerSize::Bit32, + version: Version::V2, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x44, + }, + image: ImageOffsets { + assembly_name: Some(0x18), + class_cache: 0x354, + }, + hash_table: HashTableOffsets { + size: 0xc, + 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, + fields: 0x60, + runtime_info: 0x84, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x28 }, + }, + }, + // Unity 5.6.7, mono.dll, x86. + // No x86 PDB exists for this binary, so these are the x64 layouts reread at 32 bit rules. + // Written by hand: derive-mono answers nothing without symbols. + Build { + guid: guid("064ccfd8-ab0c-4a5b-b33d-7a59b8eafbab"), + pointer_size: PointerSize::Bit32, + version: Version::V1, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x40, + }, + image: ImageOffsets { + assembly_name: Some(0x18), + class_cache: 0x2a0, + }, + hash_table: HashTableOffsets { + size: 0xc, + table: 0x14, + }, + class: ClassOffsets { + class_kind: None, + instance_size: Some(0x10), + parent: 0x24, + nested_in: Some(0x28), + name: 0x30, + namespace: 0x34, + vtable_size: 0xc, + fields: 0x74, + runtime_info: 0xa4, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x0 }, + }, + }, + // Unity 2018.4.36, mono-2.0-bdwgc.dll, x86. + Build { + guid: guid("7059c7da-c870-4870-951d-758ba588a378"), + pointer_size: PointerSize::Bit32, + version: Version::V2, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x44, + }, + image: ImageOffsets { + assembly_name: Some(0x18), + class_cache: 0x354, + }, + hash_table: HashTableOffsets { + size: 0xc, + 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, + fields: 0x60, + runtime_info: 0x84, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x28 }, + }, + }, + // Unity 2021.3.11, mono-2.0-bdwgc.dll, x86. + Build { + guid: guid("51a376db-5854-4c34-925f-acb714c49e65"), + pointer_size: PointerSize::Bit32, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x48, + }, + image: ImageOffsets { + assembly_name: Some(0x1c), + class_cache: 0x35c, + }, + hash_table: HashTableOffsets { + size: 0xc, + 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, + fields: 0x60, + runtime_info: 0x7c, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x2c }, + }, + }, + // Unity 6000.2.12, mono-2.0-bdwgc.dll, x86. + Build { + guid: guid("9fd463e5-f21d-49da-8e5d-67d03349843a"), + pointer_size: PointerSize::Bit32, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x48, + }, + image: ImageOffsets { + assembly_name: Some(0x1c), + class_cache: 0x35c, + }, + hash_table: HashTableOffsets { + size: 0xc, + 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, + fields: 0x60, + runtime_info: 0x7c, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x2c }, + }, + }, + // Unity 2023.1.22, mono-2.0-bdwgc.dll, x86. + Build { + guid: guid("347d7ee9-ca67-435d-be75-237735403a3d"), + pointer_size: PointerSize::Bit32, + version: Version::V3, + offsets: MonoOffsets { + assembly: AssemblyOffsets { + aname: None, + image: 0x48, + }, + image: ImageOffsets { + assembly_name: Some(0x1c), + class_cache: 0x35c, + }, + hash_table: HashTableOffsets { + size: 0xc, + 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, + fields: 0x60, + runtime_info: 0x7c, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x2c }, + }, + }, +]; + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::super::{BinaryFormat, Version}; + use super::{find, guid, MonoOffsets, BUILDS}; + use crate::file_format::pe::DebugId; + use crate::PointerSize; + + // The 2019.4 mono runtime's GUID as the debug directory stores it, the + // same anchor the pe tests read out of a mapped image. + const STORED: [u8; 16] = [ + 0xC7, 0xAA, 0x10, 0x77, 0x5A, 0x31, 0x30, 0x4D, 0xA7, 0x7A, 0x08, 0x07, 0x29, 0x69, 0x66, + 0xF6, + ]; + + #[test] + fn parses_canonical_guids_into_storage_order() { + assert_eq!(guid("7710aac7-315a-4d30-a77a-0807296966f6"), STORED); + } + + #[test] + fn table_is_sorted_and_unique() { + assert!(BUILDS.windows(2).all(|pair| pair[0].guid < pair[1].guid)); + } + + #[test] + fn finds_known_builds() { + let build = find(&DebugId { + guid: STORED, + age: 1, + }) + .unwrap(); + assert_eq!(build.pointer_size, PointerSize::Bit64); + assert!(matches!(build.version, Version::V2)); + + assert!(find(&DebugId { + guid: [0; 16], + age: 1, + }) + .is_none()); + } + + // The 2019.4 build lays out like the table its version selects, so the + // measured entry must agree with the shipped one on every member both + // carry. + #[test] + fn the_2019_4_build_matches_its_version_table() { + let build = find(&DebugId { + guid: STORED, + age: 1, + }) + .unwrap(); + let table = MonoOffsets::new(Version::V2, PointerSize::Bit64, BinaryFormat::PE).unwrap(); + + assert_eq!(build.offsets.assembly.image, table.assembly.image); + assert_eq!(build.offsets.image.class_cache, table.image.class_cache); + assert_eq!(build.offsets.hash_table.size, table.hash_table.size); + assert_eq!(build.offsets.hash_table.table, table.hash_table.table); + assert_eq!(build.offsets.class.parent, table.class.parent); + assert_eq!(build.offsets.class.name, table.class.name); + assert_eq!(build.offsets.class.namespace, table.class.namespace); + assert_eq!(build.offsets.class.vtable_size, table.class.vtable_size); + assert_eq!(build.offsets.class.fields, table.class.fields); + assert_eq!(build.offsets.class.runtime_info, table.class.runtime_info); + assert_eq!(build.offsets.class.field_count, table.class.field_count); + assert_eq!( + build.offsets.class.next_class_cache, + table.class.next_class_cache, + ); + assert_eq!(build.offsets.field.name, table.field.name); + assert_eq!(build.offsets.field.offset, table.field.offset); + assert_eq!(build.offsets.field.alignment, table.field.alignment); + assert_eq!(build.offsets.v_table.vtable, table.v_table.vtable); + } + + // 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_)); + } + } + + // The shipped table for 2021.2 and later x64 puts the vtable at 0x40; + // every measured build of that stretch puts it at 0x48. The entries keep + // what was measured. + #[test] + fn modern_x64_builds_diverge_from_their_version_table_on_the_vtable() { + let diverging = BUILDS + .iter() + .filter(|build| { + matches!(build.version, Version::V3) && build.pointer_size == PointerSize::Bit64 + }) + .count(); + assert!(diverging > 0); + assert!(BUILDS + .iter() + .filter(|build| { + matches!(build.version, Version::V3) && build.pointer_size == PointerSize::Bit64 + }) + .all(|build| build.offsets.v_table.vtable == 0x48)); + } +} diff --git a/src/game_engine/unity/mono/class.rs b/src/game_engine/unity/mono/class.rs index 2ffe93c..a07bb6d 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 0000000..4b529fd --- /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 af08822..0000000 --- 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 d6ca1ae..51faa25 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 56b44f4..c3e9a58 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -3,30 +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; -use assembly::Assembly; +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::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 { @@ -38,12 +46,47 @@ pub struct Module { impl Module { /// Tries attaching 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 - /// [`attach`](Self::attach) instead. + /// If the mono runtime is a known build, its measured offsets are used + /// directly. Otherwise this function automatically detects the + /// [Mono 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 (module_range, format) = Self::find_runtime_module(process)?; + let pointer_size = Self::pointer_size(process, module_range, format)?; + + let debug_id = match format { + BinaryFormat::PE => pe::DebugId::read(process, module_range.0), + _ => None, + }; + + if let Some(debug_id) = &debug_id { + if let Some(build) = + builds::find(debug_id).filter(|build| build.pointer_size == pointer_size) + { + if let Some(module) = Self::attach_with( + process, + module_range, + format, + pointer_size, + build.version, + &build.offsets, + ) { + print_limited::<128>(&format_args!("known mono build: {debug_id:?}")); + return Some(module); + } + } + } + let version = Version::detect(process)?; - Self::attach(process, version) + let module = Self::attach(process, version)?; + + if let Some(debug_id) = debug_id { + if builds::find(&debug_id).is_none() { + print_limited::<128>(&format_args!("unknown mono build: {debug_id:?}")); + } + } + + Some(module) } /// Tries attaching to a Unity game that is using the standard Mono backend @@ -51,7 +94,22 @@ 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 (module_range, format) = [ + let (module_range, format) = Self::find_runtime_module(process)?; + let pointer_size = Self::pointer_size(process, module_range, format)?; + let offsets = MonoOffsets::new(version, pointer_size, format)?; + + Self::attach_with( + process, + module_range, + format, + pointer_size, + version, + offsets, + ) + } + + fn find_runtime_module(process: &Process) -> Option<((Address, u64), BinaryFormat)> { + [ ("mono.dll", BinaryFormat::PE), ("libmono.so", BinaryFormat::ELF), #[cfg(feature = "alloc")] @@ -62,20 +120,33 @@ impl Module { ("libmonobdwgc-2.0.dylib", BinaryFormat::MachO), ] .into_iter() - .find_map(|(name, format)| Some((process.get_module_range(name).ok()?, format)))?; - - let (mono_module, _) = module_range; + .find_map(|(name, format)| Some((process.get_module_range(name).ok()?, format))) + } - let pointer_size = match format { - BinaryFormat::PE => pe::MachineType::read(process, mono_module)?.pointer_size()?, - BinaryFormat::ELF => elf::pointer_size(process, mono_module)?, + fn pointer_size( + process: &Process, + module_range: (Address, u64), + format: BinaryFormat, + ) -> Option { + match format { + BinaryFormat::PE => pe::MachineType::read(process, module_range.0)?.pointer_size(), + BinaryFormat::ELF => elf::pointer_size(process, module_range.0), #[cfg(feature = "alloc")] - BinaryFormat::MachO => macho::pointer_size(process, module_range)?, + BinaryFormat::MachO => macho::pointer_size(process, module_range), #[allow(unreachable_patterns)] - _ => return None, - }; + _ => None, + } + } - let offsets = MonoOffsets::new(version, pointer_size, format)?; + fn attach_with( + process: &Process, + module_range: (Address, u64), + format: BinaryFormat, + pointer_size: PointerSize, + version: Version, + offsets: &'static MonoOffsets, + ) -> Option { + let (mono_module, _) = module_range; let root_domain_function_address = match format { BinaryFormat::PE => { @@ -202,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. @@ -234,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 @@ -253,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 @@ -304,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 dbfb5e5..1ab29a2 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, } @@ -19,17 +21,22 @@ impl MonoOffsets { match (format, version, pointer_size) { (BinaryFormat::PE, Version::V3, PointerSize::Bit64) => Some(&Self { assembly: AssemblyOffsets { - aname: 0x10, + aname: Some(0x10), image: 0x60, }, - image: ImageOffsets { class_cache: 0x4D0 }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4D0, + }, hash_table: HashTableOffsets { size: 0x18, table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1B), // 2021.3 through 6000.7 + instance_size: Some(0x1C), // 2021.3 through 6000.7 parent: 0x30, - image: 0x40, + nested_in: Some(0x38), // 2021.3 through 6000.7 name: 0x48, namespace: 0x50, vtable_size: 0x5C, @@ -38,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, @@ -47,17 +63,22 @@ impl MonoOffsets { }), (BinaryFormat::PE, Version::V3, PointerSize::Bit32) => Some(&Self { assembly: AssemblyOffsets { - aname: 0x8, + aname: Some(0x8), image: 0x48, }, - image: ImageOffsets { class_cache: 0x35C }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x35C, + }, hash_table: HashTableOffsets { size: 0x0C, table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xF), // 2021.3 through 6000.7 + instance_size: Some(0x10), // 2021.3 through 6000.7 parent: 0x20, - image: 0x28, + nested_in: Some(0x24), // 2021.3 through 6000.7 name: 0x2C, namespace: 0x30, vtable_size: 0x38, @@ -66,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, @@ -75,17 +105,22 @@ impl MonoOffsets { }), (BinaryFormat::PE, Version::V2, PointerSize::Bit64) => Some(&Self { assembly: AssemblyOffsets { - aname: 0x10, + aname: Some(0x10), image: 0x60, }, - image: ImageOffsets { class_cache: 0x4C0 }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4C0, + }, hash_table: HashTableOffsets { size: 0x18, table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2A), // 2017.4 through 2020.1 + instance_size: Some(0x1C), // 2017.4 through 2020.1 parent: 0x30, - image: 0x40, + nested_in: Some(0x38), // 2017.4 through 2020.1 name: 0x48, namespace: 0x50, vtable_size: 0x5C, @@ -94,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, @@ -103,17 +147,22 @@ impl MonoOffsets { }), (BinaryFormat::PE, Version::V2, PointerSize::Bit32) => Some(&Self { assembly: AssemblyOffsets { - aname: 0x8, + aname: Some(0x8), image: 0x44, }, - image: ImageOffsets { class_cache: 0x354 }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x354, + }, hash_table: HashTableOffsets { size: 0x0C, table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1E), // 2017.4 through 2020.1 + instance_size: Some(0x10), // 2017.4 through 2020.1 parent: 0x20, - image: 0x28, + nested_in: Some(0x24), // 2017.4 through 2020.1 name: 0x2C, namespace: 0x30, vtable_size: 0x38, @@ -122,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, @@ -131,17 +189,22 @@ impl MonoOffsets { }), (BinaryFormat::PE, Version::V1Cattrs, PointerSize::Bit64) => Some(&Self { assembly: AssemblyOffsets { - aname: 0x10, + aname: Some(0x10), image: 0x58, }, - image: ImageOffsets { class_cache: 0x3D0 }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x3D0, + }, hash_table: HashTableOffsets { size: 0x18, table: 0x20, }, class: ClassOffsets { + class_kind: None, + instance_size: Some(0x1C), // 2017.4, 2018.4 parent: 0x30, - image: 0x48, + nested_in: None, name: 0x50, namespace: 0x58, vtable_size: 0x18, @@ -150,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, @@ -159,17 +231,22 @@ impl MonoOffsets { }), (BinaryFormat::PE, Version::V1Cattrs, PointerSize::Bit32) => Some(&Self { assembly: AssemblyOffsets { - aname: 0x8, + aname: Some(0x8), image: 0x40, }, - image: ImageOffsets { class_cache: 0x2A0 }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x2A0, + }, hash_table: HashTableOffsets { size: 0xC, table: 0x14, }, class: ClassOffsets { + class_kind: None, + instance_size: Some(0x10), // 2017.4, 2018.4 parent: 0x24, - image: 0x30, + nested_in: None, name: 0x34, namespace: 0x38, vtable_size: 0xC, @@ -178,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, @@ -187,17 +273,22 @@ impl MonoOffsets { }), (BinaryFormat::PE, Version::V1, PointerSize::Bit64) => Some(&Self { assembly: AssemblyOffsets { - aname: 0x10, + aname: Some(0x10), image: 0x58, }, - image: ImageOffsets { class_cache: 0x3D0 }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x3D0, + }, hash_table: HashTableOffsets { size: 0x18, table: 0x20, }, class: ClassOffsets { + class_kind: None, + instance_size: None, parent: 0x30, - image: 0x40, + nested_in: Some(0x38), // 5.6 through 2018.4 name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -206,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, @@ -215,17 +315,22 @@ impl MonoOffsets { }), (BinaryFormat::PE, Version::V1, PointerSize::Bit32) => Some(&Self { assembly: AssemblyOffsets { - aname: 0x8, + aname: Some(0x8), image: 0x40, }, - image: ImageOffsets { class_cache: 0x2A0 }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x2A0, + }, hash_table: HashTableOffsets { size: 0xC, table: 0x14, }, class: ClassOffsets { + class_kind: None, + instance_size: None, parent: 0x24, - image: 0x2C, + nested_in: Some(0x28), // 5.6 through 2018.4 name: 0x30, namespace: 0x34, vtable_size: 0xC, @@ -234,136 +339,212 @@ 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, }, v_table: MonoVTableOffsets { vtable: 0x28 }, }), - (BinaryFormat::ELF | BinaryFormat::MachO, Version::V3, PointerSize::Bit64) => Some(&Self { - assembly: AssemblyOffsets { - aname: 0x10, - image: 0x60, - }, - image: ImageOffsets { class_cache: 0x4D0 }, - hash_table: HashTableOffsets { - size: 0x18, - table: 0x20, - }, - class: ClassOffsets { - parent: 0x28, - image: 0x38, - name: 0x40, - namespace: 0x48, - vtable_size: 0x54, - fields: 0x90, - runtime_info: 0xC8, - field_count: 0xF8, - next_class_cache: 0x100, - }, - field: FieldInfoOffsets { - name: 0x8, - offset: 0x18, - alignment: 0x20, - }, - v_table: MonoVTableOffsets { vtable: 0x48 }, - }), - (BinaryFormat::ELF | BinaryFormat::MachO, Version::V2, PointerSize::Bit64) => Some(&Self { - assembly: AssemblyOffsets { - aname: 0x10, - image: 0x60, - }, - image: ImageOffsets { class_cache: 0x4C0 }, - hash_table: HashTableOffsets { - size: 0x18, - table: 0x20, - }, - class: ClassOffsets { - parent: 0x28, - image: 0x38, - name: 0x40, - namespace: 0x48, - vtable_size: 0x54, - fields: 0x90, - runtime_info: 0xC8, - field_count: 0xF8, - next_class_cache: 0x100, - }, - field: FieldInfoOffsets { - name: 0x8, - offset: 0x18, - alignment: 0x20, - }, - v_table: MonoVTableOffsets { vtable: 0x40 }, - }), - (BinaryFormat::ELF | BinaryFormat::MachO, Version::V1Cattrs, PointerSize::Bit64) => Some(&Self { - assembly: AssemblyOffsets { - aname: 0x10, - image: 0x58, - }, - image: ImageOffsets { class_cache: 0x3D0 }, - hash_table: HashTableOffsets { - size: 0x18, - table: 0x20, - }, - class: ClassOffsets { - parent: 0x28, - image: 0x40, - name: 0x48, - namespace: 0x50, - vtable_size: 0x18, - fields: 0xA8, - runtime_info: 0xF8, - field_count: 0x94, - next_class_cache: 0x100, - }, - field: FieldInfoOffsets { - name: 0x8, - offset: 0x18, - alignment: 0x20, - }, - v_table: MonoVTableOffsets { vtable: 0x48 }, - }), - (BinaryFormat::ELF | BinaryFormat::MachO, Version::V1, PointerSize::Bit64) => Some(&Self { - assembly: AssemblyOffsets { - aname: 0x10, - image: 0x58, - }, - image: ImageOffsets { class_cache: 0x3D0 }, - hash_table: HashTableOffsets { - size: 0x18, - table: 0x20, - }, - class: ClassOffsets { - parent: 0x28, - image: 0x38, - name: 0x40, - namespace: 0x48, - vtable_size: 0x18, - fields: 0xA0, - runtime_info: 0xF0, - field_count: 0x8C, - next_class_cache: 0xF8, - }, - field: FieldInfoOffsets { - name: 0x8, - offset: 0x18, - alignment: 0x20, - }, - v_table: MonoVTableOffsets { vtable: 0x48 }, - }), + (BinaryFormat::ELF | BinaryFormat::MachO, Version::V3, PointerSize::Bit64) => { + Some(&Self { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x60, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4D0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: None, + instance_size: None, + parent: 0x28, + nested_in: None, + name: 0x40, + namespace: 0x48, + vtable_size: 0x54, + fields: 0x90, + runtime_info: 0xC8, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, + }) + } + (BinaryFormat::ELF | BinaryFormat::MachO, Version::V2, PointerSize::Bit64) => { + Some(&Self { + 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: 0x28, + nested_in: None, + name: 0x40, + namespace: 0x48, + vtable_size: 0x54, + fields: 0x90, + runtime_info: 0xC8, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x40 }, + }) + } + (BinaryFormat::ELF | BinaryFormat::MachO, Version::V1Cattrs, PointerSize::Bit64) => { + Some(&Self { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x58, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x3D0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: None, + instance_size: None, + parent: 0x28, + nested_in: None, + name: 0x48, + namespace: 0x50, + vtable_size: 0x18, + fields: 0xA8, + runtime_info: 0xF8, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, + }) + } + (BinaryFormat::ELF | BinaryFormat::MachO, Version::V1, PointerSize::Bit64) => { + Some(&Self { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x58, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x3D0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: None, + instance_size: None, + parent: 0x28, + nested_in: None, + name: 0x40, + namespace: 0x48, + vtable_size: 0x18, + fields: 0xA0, + runtime_info: 0xF0, + 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, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, + }) + } _ => None, } } } pub(super) struct AssemblyOffsets { - pub(super) aname: u8, + pub(super) aname: Option, // Either this or ImageOffsets::assembly_name locates the name + pub(super) image: u8, } pub(super) struct ImageOffsets { + pub(super) assembly_name: Option, // Either this or AssemblyOffsets::aname locates the name + pub(super) class_cache: u16, } @@ -373,9 +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, - #[allow(unused)] - pub(super) image: u8, // Unused for now, kept in the struct for future use + 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 @@ -385,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 8b75de7..771c470 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 0000000..4f634bd --- /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 0000000..f9a6c41 --- /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/lib.rs b/src/lib.rs index e178811..403a41f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -127,6 +127,9 @@ #[cfg(feature = "alloc")] extern crate alloc; +#[cfg(all(test, not(target_family = "wasm")))] +extern crate std; + mod primitives; mod runtime; diff --git a/src/runtime/mock.rs b/src/runtime/mock.rs new file mode 100644 index 0000000..cd54a97 --- /dev/null +++ b/src/runtime/mock.rs @@ -0,0 +1,122 @@ +//! 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, + future::Future, + num::NonZeroU64, + task::{Context, Poll, Waker}, +}; + +use std::vec::Vec; + +use crate::Process; + +std::thread_local! { + static MEMORY: 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 { + MEMORY.with(|memory| { + *memory.borrow_mut() = regions + .iter() + .map(|&(address, bytes)| (address, bytes.to_vec())) + .collect(); + }); + let process = Process::attach("mock").expect("the mock always attaches"); + test(&process) +} + +/// 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) +} + +#[no_mangle] +extern "C" fn process_detach(_process: u64) {} + +#[no_mangle] +extern "C" fn process_get_memory_range_count(_process: u64) -> 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| { + memory.borrow().iter().any(|(start, bytes)| { + let Some(offset) = address.checked_sub(*start) else { + return false; + }; + let Ok(offset) = usize::try_from(offset) else { + return false; + }; + if !offset + .checked_add(buf_len) + .is_some_and(|end| end <= bytes.len()) + { + return false; + } + // SAFETY: The runtime layer passes a buffer valid for buf_len + // bytes, and the range is checked to lie inside the region. + unsafe { + core::ptr::copy_nonoverlapping(bytes.as_ptr().add(offset), buf_ptr, buf_len); + } + true + }) + }) +} + +#[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() { + with_process(&[(0x1000, &[1, 2, 3, 4])], |process| { + assert_eq!(process.read::(0x1000_u64).unwrap(), 0x04030201); + assert_eq!(process.read::<[u8; 2]>(0x1002_u64).unwrap(), [3, 4]); + assert!(process.read::(0x0FFF_u64).is_err()); + assert!(process.read::(0x1001_u64).is_err()); + }); + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 21164a1..742dbec 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -5,6 +5,8 @@ mod memory_range; mod process; mod sys; +#[cfg(all(test, not(target_family = "wasm")))] +pub(crate) mod mock; pub mod settings; pub mod timer;