From d764a058f7426cbe1ba1167801c64be603455aa6 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 07:58:33 +0200 Subject: [PATCH 01/24] add mock host for tests --- src/lib.rs | 3 ++ src/runtime/mock.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++ src/runtime/mod.rs | 2 ++ 3 files changed, 79 insertions(+) create mode 100644 src/runtime/mock.rs diff --git a/src/lib.rs b/src/lib.rs index e178811c..403a41fa 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 00000000..baf18e9a --- /dev/null +++ b/src/runtime/mock.rs @@ -0,0 +1,74 @@ +//! A fake host for tests: definitions of the wasm imports the runtime layer +//! links against, backed by in-memory images so readers can run on the host. + +use core::{cell::RefCell, num::NonZeroU64}; + +use 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) +} + +#[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_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; + + #[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 21164a14..742dbec2 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; From 41cf503d37b2772e2ea7915cd24e3bfc57a85bf6 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 07:59:17 +0200 Subject: [PATCH 02/24] add pe debug id read --- src/file_format/pe.rs | 292 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 292 insertions(+) diff --git a/src/file_format/pe.rs b/src/file_format/pe.rs index 46f63cb1..b5bda8ca 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()); + }); + } +} From cdd85fe97cd35048cd2cb794a7e1a6587689ae58 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 08:05:00 +0200 Subject: [PATCH 03/24] add elf build id read --- src/file_format/elf.rs | 209 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 1 deletion(-) diff --git a/src/file_format/elf.rs b/src/file_format/elf.rs index 179bfede..917e9256 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()); + }); + } +} From 7fedd82e2238c3c6c9363a2782262bffb802ba00 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 08:06:05 +0200 Subject: [PATCH 04/24] add mach-o uuid read --- src/file_format/macho.rs | 148 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 5 deletions(-) diff --git a/src/file_format/macho.rs b/src/file_format/macho.rs index 147a24c6..c19abad2 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()); + }); + } +} From 57d2fba86568a9dc3f41fb36fa7cff2f43220319 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 16:02:24 +0200 Subject: [PATCH 05/24] fix metadata handle typo --- src/game_engine/unity/il2cpp/image.rs | 4 ++-- src/game_engine/unity/il2cpp/offsets.rs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/game_engine/unity/il2cpp/image.rs b/src/game_engine/unity/il2cpp/image.rs index eeed4b9d..970f995e 100644 --- a/src/game_engine/unity/il2cpp/image.rs +++ b/src/game_engine/unity/il2cpp/image.rs @@ -23,11 +23,11 @@ impl Image { let metadata_ptr = match (type_count, module.version) { (0, _) => Address::NULL, (_, Version::Base | Version::V2019) => { - self.image + module.offsets.image.matadata_handle + self.image + module.offsets.image.metadata_handle } (_, _) => process .read_pointer( - self.image + module.offsets.image.matadata_handle, + self.image + module.offsets.image.metadata_handle, module.pointer_size, ) .unwrap_or_default(), diff --git a/src/game_engine/unity/il2cpp/offsets.rs b/src/game_engine/unity/il2cpp/offsets.rs index a402513a..f1e660f8 100644 --- a/src/game_engine/unity/il2cpp/offsets.rs +++ b/src/game_engine/unity/il2cpp/offsets.rs @@ -18,7 +18,7 @@ impl IL2CPPOffsets { }, image: ImageOffsets { type_count: 0x18, - matadata_handle: 0x28, + metadata_handle: 0x28, }, class: ClassOffsets { name: 0x10, @@ -41,7 +41,7 @@ impl IL2CPPOffsets { }, image: ImageOffsets { type_count: 0x18, - matadata_handle: 0x28, + metadata_handle: 0x28, }, class: ClassOffsets { name: 0x10, @@ -64,7 +64,7 @@ impl IL2CPPOffsets { }, image: ImageOffsets { type_count: 0x1C, - matadata_handle: 0x18, + metadata_handle: 0x18, }, class: ClassOffsets { name: 0x10, @@ -87,7 +87,7 @@ impl IL2CPPOffsets { }, image: ImageOffsets { type_count: 0x1C, - matadata_handle: 0x18, + metadata_handle: 0x18, }, class: ClassOffsets { name: 0x10, @@ -116,7 +116,7 @@ pub(super) struct AssemblyOffsets { pub(super) struct ImageOffsets { pub(super) type_count: u8, - pub(super) matadata_handle: u8, + pub(super) metadata_handle: u8, } pub(super) struct ClassOffsets { From 1b384a0d90465294b5ca74e8f5943b59b26c1150 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 16:06:18 +0200 Subject: [PATCH 06/24] add memory ranges to mock host --- src/runtime/mock.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/runtime/mock.rs b/src/runtime/mock.rs index baf18e9a..9cb049ef 100644 --- a/src/runtime/mock.rs +++ b/src/runtime/mock.rs @@ -32,6 +32,27 @@ extern "C" fn process_attach(_name_ptr: *const u8, _name_len: usize) -> Option Option { + MEMORY.with(|memory| NonZeroU64::new(memory.borrow().len() as u64)) +} + +#[no_mangle] +extern "C" fn process_get_memory_range_address(_process: u64, idx: u64) -> Option { + MEMORY.with(|memory| { + let memory = memory.borrow(); + NonZeroU64::new(memory.get(idx as usize)?.0) + }) +} + +#[no_mangle] +extern "C" fn process_get_memory_range_size(_process: u64, idx: u64) -> Option { + MEMORY.with(|memory| { + let memory = memory.borrow(); + NonZeroU64::new(memory.get(idx as usize)?.1.len() as u64) + }) +} + #[no_mangle] extern "C" fn process_read(_process: u64, address: u64, buf_ptr: *mut u8, buf_len: usize) -> bool { MEMORY.with(|memory| { @@ -61,6 +82,21 @@ extern "C" fn process_read(_process: u64, address: u64, buf_ptr: *mut u8, buf_le #[cfg(test)] mod tests { use super::with_process; + use crate::Address; + + #[test] + fn ranges_mirror_the_regions() { + with_process(&[(0x1000, &[1, 2]), (0x4000, &[3, 4, 5])], |process| { + let mut ranges = process.memory_ranges(); + let range = ranges.next().unwrap(); + assert_eq!(range.address().unwrap(), Address::new(0x1000)); + assert_eq!(range.size().unwrap(), 2); + let range = ranges.next().unwrap(); + assert_eq!(range.address().unwrap(), Address::new(0x4000)); + assert_eq!(range.size().unwrap(), 3); + assert!(ranges.next().is_none()); + }); + } #[test] fn reads_come_from_the_regions() { From a69bac89eef5d3f4ff4256b8c5e96b352fef57ed Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 16:13:00 +0200 Subject: [PATCH 07/24] add image route for assembly names --- src/game_engine/unity/il2cpp/assembly.rs | 16 ++++++++++++---- src/game_engine/unity/il2cpp/offsets.rs | 15 ++++++++++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/game_engine/unity/il2cpp/assembly.rs b/src/game_engine/unity/il2cpp/assembly.rs index b683bc4d..28f65842 100644 --- a/src/game_engine/unity/il2cpp/assembly.rs +++ b/src/game_engine/unity/il2cpp/assembly.rs @@ -12,11 +12,19 @@ impl Assembly { process: &Process, module: &Module, ) -> Result, Error> { + let name = match ( + module.offsets.image.assembly_name, + module.offsets.assembly.aname, + ) { + (Some(assembly_name), _) => { + self.get_image(process, module).ok_or(Error {})?.image + assembly_name + } + (_, Some(aname)) => self.assembly + aname, + _ => return Err(Error {}), + }; + process - .read_pointer( - self.assembly + module.offsets.assembly.aname, - module.pointer_size, - ) + .read_pointer(name, module.pointer_size) .and_then(|addr| process.read(addr)) } diff --git a/src/game_engine/unity/il2cpp/offsets.rs b/src/game_engine/unity/il2cpp/offsets.rs index f1e660f8..f4155f39 100644 --- a/src/game_engine/unity/il2cpp/offsets.rs +++ b/src/game_engine/unity/il2cpp/offsets.rs @@ -14,9 +14,10 @@ impl IL2CPPOffsets { Version::V2022 => &Self { assembly: AssemblyOffsets { image: 0x0, - aname: 0x18, + aname: Some(0x18), }, image: ImageOffsets { + assembly_name: None, type_count: 0x18, metadata_handle: 0x28, }, @@ -37,9 +38,10 @@ impl IL2CPPOffsets { Version::V2020 => &Self { assembly: AssemblyOffsets { image: 0x0, - aname: 0x18, + aname: Some(0x18), }, image: ImageOffsets { + assembly_name: None, type_count: 0x18, metadata_handle: 0x28, }, @@ -60,9 +62,10 @@ impl IL2CPPOffsets { Version::V2019 => &Self { assembly: AssemblyOffsets { image: 0x0, - aname: 0x18, + aname: Some(0x18), }, image: ImageOffsets { + assembly_name: None, type_count: 0x1C, metadata_handle: 0x18, }, @@ -83,9 +86,10 @@ impl IL2CPPOffsets { Version::Base => &Self { assembly: AssemblyOffsets { image: 0x0, - aname: 0x18, + aname: Some(0x18), }, image: ImageOffsets { + assembly_name: None, type_count: 0x1C, metadata_handle: 0x18, }, @@ -111,10 +115,11 @@ 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) metadata_handle: u8, } From 3ed274eaa5aa43d902b1ee7cf2a651b2fc5d7254 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 16:38:30 +0200 Subject: [PATCH 08/24] fix il2cpp version detection for unity 2023 --- src/game_engine/unity/il2cpp/version.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/game_engine/unity/il2cpp/version.rs b/src/game_engine/unity/il2cpp/version.rs index 409fedfe..b3a29efb 100644 --- a/src/game_engine/unity/il2cpp/version.rs +++ b/src/game_engine/unity/il2cpp/version.rs @@ -27,7 +27,7 @@ impl Version { let file_version = pe::FileVersion::read(process, unity_module)?; return Some( - if file_version.major_version > 2023 + if file_version.major_version > 2022 || (file_version.major_version == 2022 && file_version.minor_version >= 2) { Self::V2022 From 881c1261af52fdee7d9c24ac2c35029b0997e485 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 16:58:55 +0200 Subject: [PATCH 09/24] add known il2cpp builds --- src/game_engine/unity/il2cpp/builds.rs | 630 +++++++++++++++++++++++++ src/game_engine/unity/il2cpp/mod.rs | 121 ++++- 2 files changed, 739 insertions(+), 12 deletions(-) create mode 100644 src/game_engine/unity/il2cpp/builds.rs diff --git a/src/game_engine/unity/il2cpp/builds.rs b/src/game_engine/unity/il2cpp/builds.rs new file mode 100644 index 00000000..b4141702 --- /dev/null +++ b/src/game_engine/unity/il2cpp/builds.rs @@ -0,0 +1,630 @@ +//! 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, IL2CPPOffsets, ImageOffsets, +}; +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, + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + field_count: 0x114, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + field_count: 0x11c, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + field_count: 0x11c, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + field_count: 0xa8, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + field_count: 0x120, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + field_count: 0xa8, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + field_count: 0x124, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + field_count: 0xac, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + field_count: 0x124, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + field_count: 0xac, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x58, + fields: 0x80, + static_fields: 0xb8, + field_count: 0x124, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x2c, + fields: 0x40, + static_fields: 0x5c, + field_count: 0xac, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x58, + fields: 0x80, + static_fields: 0xa0, + field_count: 0x124, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x2c, + fields: 0x40, + static_fields: 0x50, + field_count: 0xac, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x58, + fields: 0x80, + static_fields: 0x98, + field_count: 0x11c, + }, + field: FieldInfoOffsets { + name: 0x0, + 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, + parent: 0x2c, + fields: 0x40, + static_fields: 0x4c, + field_count: 0xac, + }, + field: FieldInfoOffsets { + name: 0x0, + offset: 0xc, + struct_size: 0x14, + }, + }, + }, +]; + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + 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()); + } + + // 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/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 06995397..aff9321f 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -1,9 +1,13 @@ //! 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 crate::{ + file_format::pe, future::retry, print_limited, signature::Signature, Address, PointerSize, + Process, +}; mod assembly; use assembly::Assembly; +mod builds; mod image; pub use image::Image; mod class; @@ -29,13 +33,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 +83,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 @@ -205,3 +281,24 @@ impl Module { self.pointer_size as u64 } } + +#[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()); + }); + } +} From 994659113f5e85b57116b4d2c854ca09f05c1c2e Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 19:50:15 +0200 Subject: [PATCH 10/24] fix assembly walk reads for 32 bit targets --- src/game_engine/unity/il2cpp/mod.rs | 14 +++++--- src/game_engine/unity/il2cpp/walk_tests.rs | 41 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 src/game_engine/unity/il2cpp/walk_tests.rs diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index aff9321f..f93de530 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -20,6 +20,8 @@ mod pointer; pub use pointer::UnityPointer; mod offsets; use offsets::IL2CPPOffsets; +#[cfg(all(test, not(target_family = "wasm")))] +mod walk_tests; use super::CSTR; @@ -178,13 +180,17 @@ impl Module { process: &'a Process, ) -> impl DoubleEndedIterator + 'a { let (assemblies, nr_of_assemblies): (Address, u64) = { - let [first, limit] = process - .read::<[u64; 2]>(self.assemblies) + let first = process + .read_pointer(self.assemblies, self.pointer_size) + .unwrap_or_default(); + let limit = process + .read_pointer(self.assemblies + self.size_of_ptr(), self.pointer_size) .unwrap_or_default(); let count = limit - .saturating_sub(first) + .value() + .saturating_sub(first.value()) .saturating_div(self.size_of_ptr()); - (Address::new(first), count) + (first, count) }; (0..nr_of_assemblies).filter_map(move |i| { diff --git a/src/game_engine/unity/il2cpp/walk_tests.rs b/src/game_engine/unity/il2cpp/walk_tests.rs new file mode 100644 index 00000000..bdc15ed3 --- /dev/null +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -0,0 +1,41 @@ +//! Tests over a hand-laid image of IL2CPP's structures. + +use super::{IL2CPPOffsets, Module, Version}; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize}; + +use std::vec; + +const BASE: u64 = 0x20_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +// A 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 ptr = |i: &mut [u8], at: u64, target: u64| { + put(i, at, &(target as u32).to_le_bytes()); + }; + + put(&mut i, 0x800, b"Assembly-CSharp"); + ptr(&mut i, 0x0, BASE + 0x40); // the vector's begin + ptr(&mut i, 0x4, BASE + 0x44); // and end, one assembly along + ptr(&mut i, 0x40, BASE + 0x80); + ptr(&mut i, 0x80, BASE + 0x100); // Il2CppAssembly.image + ptr(&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()); + }); +} From 36033e74e1b966e21494aae69a97def22f69c420 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 08:58:39 +0200 Subject: [PATCH 11/24] cut unread class image offset --- src/game_engine/unity/mono/offsets.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/game_engine/unity/mono/offsets.rs b/src/game_engine/unity/mono/offsets.rs index dbfb5e58..70881912 100644 --- a/src/game_engine/unity/mono/offsets.rs +++ b/src/game_engine/unity/mono/offsets.rs @@ -29,7 +29,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, - image: 0x40, name: 0x48, namespace: 0x50, vtable_size: 0x5C, @@ -57,7 +56,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x20, - image: 0x28, name: 0x2C, namespace: 0x30, vtable_size: 0x38, @@ -85,7 +83,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, - image: 0x40, name: 0x48, namespace: 0x50, vtable_size: 0x5C, @@ -113,7 +110,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x20, - image: 0x28, name: 0x2C, namespace: 0x30, vtable_size: 0x38, @@ -141,7 +137,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, - image: 0x48, name: 0x50, namespace: 0x58, vtable_size: 0x18, @@ -169,7 +164,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x24, - image: 0x30, name: 0x34, namespace: 0x38, vtable_size: 0xC, @@ -197,7 +191,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, - image: 0x40, name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -225,7 +218,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x24, - image: 0x2C, name: 0x30, namespace: 0x34, vtable_size: 0xC, @@ -253,7 +245,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, - image: 0x38, name: 0x40, namespace: 0x48, vtable_size: 0x54, @@ -281,7 +272,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, - image: 0x38, name: 0x40, namespace: 0x48, vtable_size: 0x54, @@ -309,7 +299,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, - image: 0x40, name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -337,7 +326,6 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, - image: 0x38, name: 0x40, namespace: 0x48, vtable_size: 0x18, @@ -374,8 +362,6 @@ pub(super) struct HashTableOffsets { pub(super) struct ClassOffsets { pub(super) parent: u8, - #[allow(unused)] - pub(super) image: u8, // Unused for now, kept in the struct for future use pub(super) name: u8, pub(super) namespace: u8, pub(super) vtable_size: u8, // On mono V1 and V1_cattrs, this offset represents MonoVTable.data From 0fb98ba29c4b9ab684f6583336512638d1249bf7 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 09:07:58 +0200 Subject: [PATCH 12/24] add image route for assembly names --- src/game_engine/unity/mono/assembly.rs | 16 +- src/game_engine/unity/mono/offsets.rs | 297 ++++++++++++++----------- 2 files changed, 184 insertions(+), 129 deletions(-) diff --git a/src/game_engine/unity/mono/assembly.rs b/src/game_engine/unity/mono/assembly.rs index fe17e628..12b6f129 100644 --- a/src/game_engine/unity/mono/assembly.rs +++ b/src/game_engine/unity/mono/assembly.rs @@ -12,11 +12,19 @@ impl Assembly { process: &Process, module: &Module, ) -> Result, Error> { + let name = match ( + module.offsets.image.assembly_name, + module.offsets.assembly.aname, + ) { + (Some(assembly_name), _) => { + self.get_image(process, module).ok_or(Error {})?.image + assembly_name + } + (_, Some(aname)) => self.assembly + aname, + _ => return Err(Error {}), + }; + process - .read_pointer( - self.assembly + module.offsets.assembly.aname, - module.pointer_size, - ) + .read_pointer(name, module.pointer_size) .and_then(|addr| process.read(addr)) } diff --git a/src/game_engine/unity/mono/offsets.rs b/src/game_engine/unity/mono/offsets.rs index 70881912..f8210a05 100644 --- a/src/game_engine/unity/mono/offsets.rs +++ b/src/game_engine/unity/mono/offsets.rs @@ -19,10 +19,13 @@ 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, @@ -46,10 +49,13 @@ 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, @@ -73,10 +79,13 @@ 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, @@ -100,10 +109,13 @@ 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, @@ -127,10 +139,13 @@ 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, @@ -154,10 +169,13 @@ 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, @@ -181,10 +199,13 @@ 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, @@ -208,10 +229,13 @@ 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, @@ -233,125 +257,148 @@ impl MonoOffsets { }, 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, - 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, - 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, - 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, - 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 { + parent: 0x28, + 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: Some(0x10), + image: 0x60, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4C0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + parent: 0x28, + 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: Some(0x10), + image: 0x58, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x3D0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + parent: 0x28, + 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: Some(0x10), + image: 0x58, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x3D0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + parent: 0x28, + 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 }, + }) + } _ => 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, } From 60b56161dbd05fdbc69dde163ed589781eb6d3d1 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 09:13:29 +0200 Subject: [PATCH 13/24] add known mono builds --- src/game_engine/unity/mono/builds.rs | 1109 ++++++++++++++++++++++++++ src/game_engine/unity/mono/mod.rs | 95 ++- 2 files changed, 1189 insertions(+), 15 deletions(-) create mode 100644 src/game_engine/unity/mono/builds.rs diff --git a/src/game_engine/unity/mono/builds.rs b/src/game_engine/unity/mono/builds.rs new file mode 100644 index 00000000..a3d7101b --- /dev/null +++ b/src/game_engine/unity/mono/builds.rs @@ -0,0 +1,1109 @@ +//! 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, HashTableOffsets, ImageOffsets, MonoOffsets, + MonoVTableOffsets, +}; +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 { + parent: 0x20, + name: 0x2c, + namespace: 0x30, + vtable_size: 0x38, + fields: 0x60, + runtime_info: 0x84, + field_count: 0xa4, + next_class_cache: 0xa8, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x20, + name: 0x2c, + namespace: 0x30, + vtable_size: 0x38, + fields: 0x60, + runtime_info: 0x7c, + field_count: 0x9c, + next_class_cache: 0xa0, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5c, + fields: 0x98, + runtime_info: 0xd0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5c, + fields: 0x98, + runtime_info: 0xd0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5c, + fields: 0x98, + runtime_info: 0xd0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x50, + namespace: 0x58, + vtable_size: 0x18, + fields: 0xb0, + runtime_info: 0x100, + field_count: 0x9c, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5c, + fields: 0x98, + runtime_info: 0xd0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5c, + fields: 0x98, + runtime_info: 0xd0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5c, + fields: 0x98, + runtime_info: 0xd0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5c, + fields: 0x98, + runtime_info: 0xd0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x24, + name: 0x34, + namespace: 0x38, + vtable_size: 0xc, + fields: 0x78, + runtime_info: 0xa8, + field_count: 0x68, + next_class_cache: 0xac, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + 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: 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 { + parent: 0x20, + name: 0x2c, + namespace: 0x30, + vtable_size: 0x38, + fields: 0x60, + runtime_info: 0x84, + field_count: 0xa4, + next_class_cache: 0xa8, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5c, + fields: 0x98, + runtime_info: 0xd0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x50, + namespace: 0x58, + vtable_size: 0x18, + fields: 0xb0, + runtime_info: 0x100, + field_count: 0x9c, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x20, + name: 0x2c, + namespace: 0x30, + vtable_size: 0x38, + fields: 0x60, + runtime_info: 0x7c, + field_count: 0x9c, + next_class_cache: 0xa0, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x24, + name: 0x34, + namespace: 0x38, + vtable_size: 0xc, + fields: 0x78, + runtime_info: 0xa8, + field_count: 0x68, + next_class_cache: 0xac, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x20, + name: 0x2c, + namespace: 0x30, + vtable_size: 0x38, + fields: 0x60, + runtime_info: 0x7c, + field_count: 0x9c, + next_class_cache: 0xa0, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5c, + fields: 0x98, + runtime_info: 0xd0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x30, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5c, + fields: 0x98, + runtime_info: 0xd0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x20, + name: 0x2c, + namespace: 0x30, + vtable_size: 0x38, + fields: 0x60, + runtime_info: 0x84, + field_count: 0xa4, + next_class_cache: 0xa8, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x24, + name: 0x30, + namespace: 0x34, + vtable_size: 0xc, + fields: 0x74, + runtime_info: 0xa4, + field_count: 0x64, + next_class_cache: 0xa8, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x20, + name: 0x2c, + namespace: 0x30, + vtable_size: 0x38, + fields: 0x60, + runtime_info: 0x84, + field_count: 0xa4, + next_class_cache: 0xa8, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x20, + name: 0x2c, + namespace: 0x30, + vtable_size: 0x38, + fields: 0x60, + runtime_info: 0x7c, + field_count: 0x9c, + next_class_cache: 0xa0, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x20, + name: 0x2c, + namespace: 0x30, + vtable_size: 0x38, + fields: 0x60, + runtime_info: 0x7c, + field_count: 0x9c, + next_class_cache: 0xa0, + }, + field: FieldInfoOffsets { + 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 { + parent: 0x20, + name: 0x2c, + namespace: 0x30, + vtable_size: 0x38, + fields: 0x60, + runtime_info: 0x7c, + field_count: 0x9c, + next_class_cache: 0xa0, + }, + field: FieldInfoOffsets { + 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); + } + + // 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/mod.rs b/src/game_engine/unity/mono/mod.rs index 56b44f45..715b0ade 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -6,12 +6,14 @@ use crate::file_format::macho; use crate::{ file_format::{elf, pe}, future::retry, + print_limited, signature::Signature, Address, Address32, Address64, PointerSize, Process, }; use core::iter::{self, FusedIterator}; mod assembly; +mod builds; use assembly::Assembly; mod image; pub use image::Image; @@ -38,12 +40,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 +88,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 +114,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 => { From 92a49f5f222b8ac6021235369d9813fbfe07c670 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 19:41:54 +0200 Subject: [PATCH 14/24] add walk parity tests --- src/game_engine/unity/il2cpp/walk_tests.rs | 257 ++++++++++++++++++- src/game_engine/unity/mono/mod.rs | 2 + src/game_engine/unity/mono/walk_tests.rs | 278 +++++++++++++++++++++ 3 files changed, 528 insertions(+), 9 deletions(-) create mode 100644 src/game_engine/unity/mono/walk_tests.rs diff --git a/src/game_engine/unity/il2cpp/walk_tests.rs b/src/game_engine/unity/il2cpp/walk_tests.rs index bdc15ed3..29d89416 100644 --- a/src/game_engine/unity/il2cpp/walk_tests.rs +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -1,10 +1,15 @@ -//! Tests over a hand-laid image of IL2CPP's structures. +//! 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, Version}; +use super::{IL2CPPOffsets, Module, UnityPointer, Version}; use crate::runtime::mock::with_process; -use crate::{Address, PointerSize}; +use crate::{Address, PointerSize, Process}; use std::vec; +use std::vec::Vec; const BASE: u64 = 0x20_0000; @@ -13,20 +18,254 @@ fn put(image: &mut [u8], at: u64, bytes: &[u8]) { 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), + _ => (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"), + ]; + 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, &3_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); + + // 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, 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); + put(&mut i, enemy + field_count_at, &1_u16.to_le_bytes()); + ptr(&mut i, 0xE80, BASE + 0x2300); // hp + put(&mut i, 0xE80 + 0x18, &0x10_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); + 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()); + + // 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()); + + 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(), 3); + }); + } +} + +#[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)); + }); +} + +// 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)), + ); + }); +} + +// 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 ptr = |i: &mut [u8], at: u64, target: u64| { + 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"); - ptr(&mut i, 0x0, BASE + 0x40); // the vector's begin - ptr(&mut i, 0x4, BASE + 0x44); // and end, one assembly along - ptr(&mut i, 0x40, BASE + 0x80); - ptr(&mut i, 0x80, BASE + 0x100); // Il2CppAssembly.image - ptr(&mut i, 0x80 + 0x18, BASE + 0x800); // Il2CppAssembly.aname + 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 { diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index 715b0ade..df4c9477 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -27,6 +27,8 @@ mod pointer; pub use pointer::UnityPointer; mod offsets; use offsets::MonoOffsets; +#[cfg(all(test, not(target_family = "wasm")))] +mod walk_tests; use super::{BinaryFormat, CSTR}; diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs new file mode 100644 index 00000000..736c01df --- /dev/null +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -0,0 +1,278 @@ +//! 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::{builds, BinaryFormat, Module, MonoOffsets, UnityPointer, Version}; +use crate::file_format::pe::DebugId; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +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, a static table reachable through the vtable, +// 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"), + ]; + 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, 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 one field 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); + ptr(&mut i, enemy + 0x98, BASE + 0x1500); + put(&mut i, enemy + 0x100, &1_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()); + + // 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); + ptr(&mut i, boss + 0x98, BASE + 0x1540); + 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()); + + // 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); + + // 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()); + + 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(), 3); + }); +} + +#[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)); + }); +} + +// 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 boss = image.get_class(process, module, "Boss").unwrap(); + assert!(boss.get_static_table(process, module).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)); + }); +} From 217ab04ad97fad32e8b7a589c3e144fc60ee3d89 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 21:29:54 +0200 Subject: [PATCH 15/24] carve shared walk out of both backends --- src/game_engine/unity/il2cpp/assembly.rs | 41 ---- src/game_engine/unity/il2cpp/class.rs | 127 ++-------- src/game_engine/unity/il2cpp/field.rs | 24 -- src/game_engine/unity/il2cpp/image.rs | 77 ++---- src/game_engine/unity/il2cpp/mod.rs | 81 +++---- src/game_engine/unity/il2cpp/pointer.rs | 135 +---------- src/game_engine/unity/managed/cursor.rs | 283 +++++++++++++++++++++++ src/game_engine/unity/managed/mod.rs | 121 ++++++++++ src/game_engine/unity/managed/pointer.rs | 143 ++++++++++++ src/game_engine/unity/managed/runtime.rs | 148 ++++++++++++ src/game_engine/unity/managed/walk.rs | 206 +++++++++++++++++ src/game_engine/unity/mod.rs | 1 + src/game_engine/unity/mono/assembly.rs | 41 ---- src/game_engine/unity/mono/class.rs | 154 ++---------- src/game_engine/unity/mono/field.rs | 23 -- src/game_engine/unity/mono/image.rs | 72 ++---- src/game_engine/unity/mono/mod.rs | 82 +++---- src/game_engine/unity/mono/pointer.rs | 135 +---------- 18 files changed, 1062 insertions(+), 832 deletions(-) delete mode 100644 src/game_engine/unity/il2cpp/assembly.rs delete mode 100644 src/game_engine/unity/il2cpp/field.rs create mode 100644 src/game_engine/unity/managed/cursor.rs create mode 100644 src/game_engine/unity/managed/mod.rs create mode 100644 src/game_engine/unity/managed/pointer.rs create mode 100644 src/game_engine/unity/managed/runtime.rs create mode 100644 src/game_engine/unity/managed/walk.rs delete mode 100644 src/game_engine/unity/mono/assembly.rs delete mode 100644 src/game_engine/unity/mono/field.rs diff --git a/src/game_engine/unity/il2cpp/assembly.rs b/src/game_engine/unity/il2cpp/assembly.rs deleted file mode 100644 index 28f65842..00000000 --- a/src/game_engine/unity/il2cpp/assembly.rs +++ /dev/null @@ -1,41 +0,0 @@ -use super::{Image, Module}; -use crate::{string::ArrayCString, Address, Error, Process}; - -#[derive(Copy, Clone)] -pub(super) struct Assembly { - pub(super) assembly: Address, -} - -impl Assembly { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - let name = match ( - module.offsets.image.assembly_name, - module.offsets.assembly.aname, - ) { - (Some(assembly_name), _) => { - self.get_image(process, module).ok_or(Error {})?.image + assembly_name - } - (_, Some(aname)) => self.assembly + aname, - _ => return Err(Error {}), - }; - - process - .read_pointer(name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_image(&self, process: &Process, module: &Module) -> Option { - process - .read_pointer( - self.assembly + module.offsets.assembly.image, - module.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null()) - .map(|image| Image { image }) - } -} diff --git a/src/game_engine/unity/il2cpp/class.rs b/src/game_engine/unity/il2cpp/class.rs index 4e1ba9c4..2358971a 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 @@ -137,29 +51,22 @@ impl Class { .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/field.rs b/src/game_engine/unity/il2cpp/field.rs deleted file mode 100644 index d9a5587d..00000000 --- a/src/game_engine/unity/il2cpp/field.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::{string::ArrayCString, Address, Error, Process}; - -use super::Module; - -#[derive(Copy, Clone)] -pub(super) struct Field { - pub(super) field: Address, -} - -impl Field { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer(self.field + module.offsets.field.name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_offset(&self, process: &Process, module: &Module) -> Option { - process.read(self.field + module.offsets.field.offset).ok() - } -} diff --git a/src/game_engine/unity/il2cpp/image.rs b/src/game_engine/unity/il2cpp/image.rs index 970f995e..0741b8b0 100644 --- a/src/game_engine/unity/il2cpp/image.rs +++ b/src/game_engine/unity/il2cpp/image.rs @@ -1,5 +1,5 @@ -use super::CSTR; -use super::{Class, Module, Version}; +use super::super::managed::{slot, ImageRef}; +use super::{Class, Module}; use crate::{future::retry, Address, Process}; /// An image is a .NET DLL that is loaded by the game. The `Assembly-CSharp` @@ -16,48 +16,18 @@ impl Image { process: &'a Process, module: &'a Module, ) -> impl DoubleEndedIterator + 'a { - let type_count = process - .read::(self.image + module.offsets.image.type_count) - .unwrap_or_default() as u64; - - let metadata_ptr = match (type_count, module.version) { - (0, _) => Address::NULL, - (_, Version::Base | Version::V2019) => { - self.image + module.offsets.image.metadata_handle - } - (_, _) => process - .read_pointer( - self.image + module.offsets.image.metadata_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 f93de530..4c5cacc2 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -5,15 +5,11 @@ use crate::{ Process, }; -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; @@ -23,7 +19,7 @@ use offsets::IL2CPPOffsets; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; -use super::CSTR; +use super::managed; /// Represents access to a Unity game that is using the IL2CPP backend. pub struct Module { @@ -175,34 +171,38 @@ impl Module { }) } - fn assemblies<'a>( - &'a self, - process: &'a Process, - ) -> impl DoubleEndedIterator + 'a { - let (assemblies, nr_of_assemblies): (Address, u64) = { - let first = process - .read_pointer(self.assemblies, self.pointer_size) - .unwrap_or_default(); - let limit = process - .read_pointer(self.assemblies + self.size_of_ptr(), self.pointer_size) - .unwrap_or_default(); - let count = limit - .value() - .saturating_sub(first.value()) - .saturating_div(self.size_of_ptr()); - (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(), + }), + 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(), + fields: self.offsets.class.fields.into(), + }, + field: managed::FieldOffsets { + name: self.offsets.field.name.into(), + 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. @@ -212,13 +212,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 @@ -281,11 +279,6 @@ impl Module { pub async fn wait_get_default_image(&self, process: &Process) -> Image { retry(|| self.get_default_image(process)).await } - - #[inline] - const fn size_of_ptr(&self) -> u64 { - self.pointer_size as u64 - } } #[cfg(all(test, not(target_family = "wasm")))] diff --git a/src/game_engine/unity/il2cpp/pointer.rs b/src/game_engine/unity/il2cpp/pointer.rs index 6dd1ab6d..cfd2bcc3 100644 --- a/src/game_engine/unity/il2cpp/pointer.rs +++ b/src/game_engine/unity/il2cpp/pointer.rs @@ -1,24 +1,11 @@ -use bytemuck::CheckedBitPattern; - -use super::{Class, Image, Module}; +use super::super::managed::{ImageRef, PointerPath}; +use super::{Image, Module}; use crate::{Address, Error, Process}; -use core::{array, cell::RefCell}; +use bytemuck::CheckedBitPattern; /// An IL2CPP-specific implementation for automatic pointer path resolution pub struct UnityPointer { - inner: RefCell>, -} - -struct UnityPointerInternal { - base_address: Address, - offsets: [u32; CAP], - resolved_offsets: usize, - - starting_class_name: &'static str, - starting_class: Option, - nr_of_parents: usize, - fields: [&'static str; CAP], - depth: usize, + path: PointerPath, } impl UnityPointer { @@ -29,110 +16,11 @@ impl UnityPointer { /// If a higher number of offsets is provided, the pointer path will be truncated /// according to the value of `CAP`. pub fn new(class_name: &'static str, nr_of_parents: usize, fields: &[&'static str]) -> Self { - let named_fields = { - let mut iter = fields.iter(); - array::from_fn(|_| iter.next().copied().unwrap_or_default()) - }; - Self { - inner: RefCell::new(UnityPointerInternal { - base_address: Address::NULL, - offsets: [0; CAP], - resolved_offsets: 0, - starting_class_name: class_name, - starting_class: None, - nr_of_parents, - fields: named_fields, - depth: fields.len().min(CAP), - }), + path: PointerPath::new(class_name, nr_of_parents, fields), } } - /// Tries to resolve the pointer path for the `IL2CPP` class specified - fn find_offsets(&self, process: &Process, module: &Module, image: &Image) -> Result<(), Error> { - let mut inner = self.inner.borrow_mut(); - - // If the pointer path has already been found, there's no need to continue - if inner.resolved_offsets == inner.depth { - return Ok(()); - } - - // Logic: the starting class can be recovered with the get_class() function, - // and parent class can be recovered if needed. However, this is a VERY - // intensive process because it involves looping through all the main classes - // in the game. For this reason, once the class is found, we want to store it - // into the cache, where it can be recovered if this function need to be run again - // (for example if a previous attempt at pointer path resolution failed) - let starting_class = match inner.starting_class { - Some(starting_class) => starting_class, - _ => { - let mut class = image - .get_class(process, module, inner.starting_class_name) - .ok_or(Error {})?; - - for _ in 0..inner.nr_of_parents { - class = class.get_parent(process, module).ok_or(Error {})?; - } - - inner.starting_class = Some(class); - class - } - }; - - // Recovering the address of the static table is not very CPU intensive, - // but it might be worth caching it as well - if inner.base_address.is_null() { - inner.base_address = starting_class - .get_static_table(process, module) - .ok_or(Error {})?; - }; - - // If we already resolved some offsets, we need to traverse them again starting from the base address - // of the static table in order to recalculate the address of the farthest object we can reach. - // If no offsets have been resolved yet, we just need to read the base address instead. - let mut current_object = { - let mut addr = inner.base_address; - for &i in &inner.offsets[..inner.resolved_offsets] { - addr = process.read_pointer(addr + i, module.pointer_size)?; - } - addr - }; - - // We keep track of the already resolved offsets in order to skip resolving them again - for i in inner.resolved_offsets..inner.depth { - let offset_from_string = match inner.fields[i].strip_prefix("0x") { - Some(rem) => u32::from_str_radix(rem, 16).ok(), - _ => inner.fields[i].parse().ok(), - }; - - let current_offset = match offset_from_string { - Some(offset) => offset as _, - _ => { - let current_class = match i { - 0 => starting_class, - _ => process - .read_pointer(current_object, module.pointer_size) - .ok() - .filter(|val| !val.is_null()) - .map(|class| Class { class }) - .ok_or(Error {})?, - }; - - current_class - .get_field_offset(process, module, inner.fields[i]) - .ok_or(Error {})? - } - }; - - inner.offsets[i] = current_offset as _; - inner.resolved_offsets += 1; - - current_object = - process.read_pointer(current_object + current_offset, module.pointer_size)?; - } - Ok(()) - } - /// Dereferences the pointer path, returning the memory address of the value of interest pub fn deref_offsets( &self, @@ -140,14 +28,8 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - self.find_offsets(process, module, image)?; - let inner = self.inner.borrow(); - let mut address = inner.base_address; - let (&last, path) = inner.offsets[..inner.depth].split_last().ok_or(Error {})?; - for &offset in path { - address = process.read_pointer(address + offset, module.pointer_size)?; - } - Ok(address + last) + self.path + .deref_offsets(process, &module.walk(), ImageRef::new(image.image)) } /// Dereferences the pointer path, returning the value stored at the final memory address @@ -157,6 +39,7 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - process.read(self.deref_offsets(process, module, image)?) + self.path + .deref(process, &module.walk(), ImageRef::new(image.image)) } } diff --git a/src/game_engine/unity/managed/cursor.rs b/src/game_engine/unity/managed/cursor.rs new file mode 100644 index 00000000..55ef411f --- /dev/null +++ b/src/game_engine/unity/managed/cursor.rs @@ -0,0 +1,283 @@ +use super::runtime::{Il2CppRuntime, MonoRuntime}; +use super::{slot, ClassRef}; +use crate::{Address, Address32, Address64, PointerSize, Process}; + +/// Walks the runtime's loaded assemblies. +pub struct Assemblies<'a> { + process: &'a Process, + pointer_size: PointerSize, + state: AssembliesState, +} + +enum AssembliesState { + /// The glib list: each node carries the assembly and the next node. + Mono { node: Option
}, + /// The vector: a slice of assembly pointers. + Il2Cpp { + base: Address, + count: u64, + index: u64, + }, +} + +impl<'a> Assemblies<'a> { + pub(super) fn mono( + process: &'a Process, + pointer_size: PointerSize, + mono: &MonoRuntime, + ) -> Self { + Self { + process, + pointer_size, + state: AssembliesState::Mono { + node: process + .read_pointer(mono.assemblies, pointer_size) + .ok() + .filter(|address| !address.is_null()), + }, + } + } + + pub(super) fn il2cpp( + process: &'a Process, + pointer_size: PointerSize, + il2cpp: &Il2CppRuntime, + ) -> Self { + let first = process + .read_pointer(il2cpp.assemblies, pointer_size) + .unwrap_or_default(); + let limit = process + .read_pointer(il2cpp.assemblies + pointer_size as u64, pointer_size) + .unwrap_or_default(); + + Self { + process, + pointer_size, + state: AssembliesState::Il2Cpp { + base: first, + count: limit.value().saturating_sub(first.value()) / pointer_size as u64, + index: 0, + }, + } + } +} + +impl Iterator for Assemblies<'_> { + type Item = Address; + + fn next(&mut self) -> Option
{ + match &mut self.state { + AssembliesState::Mono { node } => { + let at = (*node)?; + + let [data, next]: [Address; 2] = match self.pointer_size { + PointerSize::Bit64 => self + .process + .read::<[Address64; 2]>(at) + .ok()? + .map(|address| address.into()), + _ => self + .process + .read::<[Address32; 2]>(at) + .ok()? + .map(|address| address.into()), + }; + + *node = Some(next); + + Some(data) + } + AssembliesState::Il2Cpp { base, count, index } => loop { + if index >= count { + return None; + } + + let at = slot(*base, self.pointer_size, *index); + *index += 1; + + if let Some(assembly) = self + .process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + { + return Some(assembly); + } + }, + } + } +} + +/// Walks the classes an image holds. +pub struct Classes<'a> { + process: &'a Process, + pointer_size: PointerSize, + state: ClassesState, +} + +enum ClassesState { + /// The image's hash table: a bucket array whose entries chain through the + /// classes themselves. + Mono { + table: Address, + // The size the runtime stores is signed, and the walk has always taken + // it as a count wholesale, garbage included. + size: u64, + bucket: u64, + chain: Option
, + next_class_cache: u16, + }, + /// The image's slice of the type info definition table. + Il2Cpp { + slots: Address, + count: u64, + index: u64, + }, +} + +impl<'a> Classes<'a> { + pub(super) fn mono( + process: &'a Process, + pointer_size: PointerSize, + mono: &MonoRuntime, + image: super::ImageRef, + ) -> Self { + let cache = image.address + mono.class_cache; + + let size = process + .read::(cache + mono.hash_table_size) + .unwrap_or_default() as u64; + + let table = match size { + 0 => Address::NULL, + _ => process + .read_pointer(cache + mono.hash_table_table, pointer_size) + .unwrap_or_default(), + }; + + Self { + process, + pointer_size, + state: ClassesState::Mono { + table, + size, + bucket: 0, + chain: None, + next_class_cache: mono.next_class_cache, + }, + } + } + + pub(super) fn il2cpp( + process: &'a Process, + pointer_size: PointerSize, + il2cpp: &Il2CppRuntime, + image: super::ImageRef, + ) -> Self { + let count = process + .read::(image.address + il2cpp.type_count) + .unwrap_or_default() as u64; + + let metadata = match (count, il2cpp.handle_is_inline) { + (0, _) => Address::NULL, + (_, true) => image.address + il2cpp.metadata_handle, + (_, false) => process + .read_pointer(image.address + il2cpp.metadata_handle, pointer_size) + .unwrap_or_default(), + }; + + let handle = match metadata { + Address::NULL => 0, + at => process.read::(at).unwrap_or_default(), + }; + + let table = match metadata { + Address::NULL => Address::NULL, + _ => process + .read_pointer(il2cpp.type_info_definition_table, pointer_size) + .unwrap_or_default(), + }; + + let slots = match table { + Address::NULL => Address::NULL, + _ => slot(table, pointer_size, handle as u64), + }; + + Self { + process, + pointer_size, + state: ClassesState::Il2Cpp { + slots, + count, + index: 0, + }, + } + } + + /// The slot array and its length, for the caller that iterates the slots + /// itself. + pub const fn slots(&self) -> Option<(Address, u64)> { + match &self.state { + ClassesState::Il2Cpp { slots, count, .. } => Some((*slots, *count)), + ClassesState::Mono { .. } => None, + } + } +} + +impl Iterator for Classes<'_> { + type Item = ClassRef; + + fn next(&mut self) -> Option { + match &mut self.state { + ClassesState::Mono { + table, + size, + bucket, + chain, + next_class_cache, + } => loop { + if let Some(class) = *chain { + *chain = self + .process + .read_pointer(class + *next_class_cache, self.pointer_size) + .ok() + .filter(|address| !address.is_null()); + + return Some(ClassRef::new(class)); + } + + if table.is_null() || bucket >= size { + return None; + } + + *chain = self + .process + .read_pointer(slot(*table, self.pointer_size, *bucket), self.pointer_size) + .ok() + .filter(|address| !address.is_null()); + *bucket += 1; + }, + ClassesState::Il2Cpp { + slots, + count, + index, + } => loop { + if index >= count { + return None; + } + + let at = slot(*slots, self.pointer_size, *index); + *index += 1; + + if let Some(class) = self + .process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + { + return Some(ClassRef::new(class)); + } + }, + } + } +} diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs new file mode 100644 index 00000000..f15e36f2 --- /dev/null +++ b/src/game_engine/unity/managed/mod.rs @@ -0,0 +1,121 @@ +//! 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 runtime; +mod walk; + +pub use cursor::{Assemblies, Classes}; +pub use pointer::PointerPath; +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, and its field array. +pub struct ClassOffsets { + pub name: u16, + pub namespace: u16, + pub parent: u16, + pub fields: u16, +} + +/// Where a field entry keeps its name and 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 offset: u16, + pub stride: u16, +} + +/// The names a walk stops climbing at when either answers, which is engine +/// policy rather than anything the runtime says: the engine's own classes +/// carry fields a game never declares. +pub struct ClimbStop { + pub class: &'static str, + pub namespace: &'static str, +} + +impl ClimbStop { + /// Unity's own base classes. + pub const UNITY: Self = Self { + class: "Object", + namespace: "UnityEngine", + }; +} + +/// A class, named by where the runtime keeps it. +#[derive(Copy, Clone)] +pub struct ClassRef { + pub address: Address, +} + +impl ClassRef { + pub const fn new(address: Address) -> Self { + Self { address } + } +} + +/// A field, named by where the runtime keeps it. +#[derive(Copy, Clone)] +pub struct FieldRef { + pub address: Address, +} + +impl FieldRef { + pub const fn new(address: Address) -> Self { + Self { address } + } +} + +/// An image, named by where the runtime keeps it. +#[derive(Copy, Clone)] +pub struct ImageRef { + pub address: Address, +} + +impl ImageRef { + pub const fn new(address: Address) -> Self { + Self { address } + } +} + +/// The address of a pointer-sized slot in an array of them. +pub fn slot(base: Address, pointer_size: PointerSize, index: u64) -> Address { + base + (pointer_size as u64).wrapping_mul(index) +} + +/// Reads a name the runtime stores behind a pointer. +pub fn read_name( + process: &Process, + pointer_size: PointerSize, + at: Address, +) -> Option> { + process + .read_pointer(at, pointer_size) + .and_then(|address| process.read(address)) + .ok() +} diff --git a/src/game_engine/unity/managed/pointer.rs b/src/game_engine/unity/managed/pointer.rs new file mode 100644 index 00000000..2393284b --- /dev/null +++ b/src/game_engine/unity/managed/pointer.rs @@ -0,0 +1,143 @@ +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 + } + }; + + if inner.base_address.is_null() { + inner.base_address = walk.static_table(process, starting_class).ok_or(Error {})?; + } + + // 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 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, + _ => { + let current_class = match i { + 0 => starting_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/runtime.rs b/src/game_engine/unity/managed/runtime.rs new file mode 100644 index 00000000..f7fbf475 --- /dev/null +++ b/src/game_engine/unity/managed/runtime.rs @@ -0,0 +1,148 @@ +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), +} + +/// 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 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, +} + +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, class: ClassRef) -> u64 { + match self { + Self::Mono(mono) => process + .read::(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()) + } + + /// Reads the class a live object belongs to, which is how a polymorphic + /// field's runtime type is found. + pub fn object_class( + &self, + process: &Process, + pointer_size: PointerSize, + object: Address, + ) -> Option { + let address = process + .read_pointer(object, pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + // Mono reaches the class through the object's vtable, where IL2CPP + // heads the object with it. + let address = match self { + Self::Mono(_) => process + .read_pointer(address, pointer_size) + .ok() + .filter(|address| !address.is_null())?, + Self::Il2Cpp(_) => address, + }; + + Some(ClassRef::new(address)) + } +} diff --git a/src/game_engine/unity/managed/walk.rs b/src/game_engine/unity/managed/walk.rs new file mode 100644 index 00000000..eaf32de0 --- /dev/null +++ b/src/game_engine/unity/managed/walk.rs @@ -0,0 +1,206 @@ +use super::super::{get_backing_name, CSTR}; +use super::{ClassRef, ClimbStop, FieldRef, ImageRef, 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. + pub fn find_class( + &self, + process: &Process, + image: ImageRef, + class_name: &str, + ) -> Option { + 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) + } + }) + }) + } + + /// 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, 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() + } + + /// Reads the address a class's static field offsets are measured from. + pub fn static_table(&self, process: &Process, class: ClassRef) -> Option
{ + self.runtime.static_table(process, self.pointer_size, class) + } + + /// Reads the class a live object belongs to. + pub fn object_class(&self, process: &Process, object: Address) -> Option { + self.runtime + .object_class(process, self.pointer_size, object) + } +} diff --git a/src/game_engine/unity/mod.rs b/src/game_engine/unity/mod.rs index baa3bfa3..1e221459 100644 --- a/src/game_engine/unity/mod.rs +++ b/src/game_engine/unity/mod.rs @@ -84,6 +84,7 @@ // https://github.com/CryZe/lunistice-auto-splitter/blob/b8c01031991783f7b41044099ee69edd54514dba/asr-dotnet/src/lib.rs pub mod il2cpp; +mod managed; pub mod mono; pub mod scene_manager; diff --git a/src/game_engine/unity/mono/assembly.rs b/src/game_engine/unity/mono/assembly.rs deleted file mode 100644 index 12b6f129..00000000 --- a/src/game_engine/unity/mono/assembly.rs +++ /dev/null @@ -1,41 +0,0 @@ -use super::{Image, Module}; -use crate::{string::ArrayCString, Address, Error, Process}; - -#[derive(Copy, Clone)] -pub(super) struct Assembly { - pub(super) assembly: Address, -} - -impl Assembly { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - let name = match ( - module.offsets.image.assembly_name, - module.offsets.assembly.aname, - ) { - (Some(assembly_name), _) => { - self.get_image(process, module).ok_or(Error {})?.image + assembly_name - } - (_, Some(aname)) => self.assembly + aname, - _ => return Err(Error {}), - }; - - process - .read_pointer(name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_image(&self, process: &Process, module: &Module) -> Option { - process - .read_pointer( - self.assembly + module.offsets.assembly.image, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()) - .map(|image| Image { image }) - } -} diff --git a/src/game_engine/unity/mono/class.rs b/src/game_engine/unity/mono/class.rs index 2ffe93c6..d958b840 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 @@ -135,57 +50,22 @@ impl Class { .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/field.rs b/src/game_engine/unity/mono/field.rs deleted file mode 100644 index af088227..00000000 --- a/src/game_engine/unity/mono/field.rs +++ /dev/null @@ -1,23 +0,0 @@ -use super::Module; -use crate::{string::ArrayCString, Address, Error, Process}; - -#[derive(Copy, Clone)] -pub(super) struct Field { - pub(super) field: Address, -} - -impl Field { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer(self.field + module.offsets.field.name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_offset(&self, process: &Process, module: &Module) -> Option { - process.read(self.field + module.offsets.field.offset).ok() - } -} diff --git a/src/game_engine/unity/mono/image.rs b/src/game_engine/unity/mono/image.rs index d6ca1ae5..51faa256 100644 --- a/src/game_engine/unity/mono/image.rs +++ b/src/game_engine/unity/mono/image.rs @@ -1,9 +1,8 @@ -use core::iter::{self, FusedIterator}; +use core::iter::FusedIterator; -use super::CSTR; +use super::super::managed::ImageRef; use super::{Class, Module}; -use crate::future::retry; -use crate::{Address, Process}; +use crate::{future::retry, Address, Process}; /// An image is a .NET DLL that is loaded by the game. The `Assembly-CSharp` /// image is the main game assembly, and contains all the game logic. @@ -19,69 +18,24 @@ impl Image { process: &'a Process, module: &'a Module, ) -> impl FusedIterator + 'a { - let class_cache_size = process - .read::( - self.image + module.offsets.image.class_cache + module.offsets.hash_table.size, - ) - .unwrap_or_default() as _; + let walk = module.walk(); - let table_addr = match class_cache_size { - 0 => Address::NULL, - _ => process - .read_pointer( - self.image + module.offsets.image.class_cache + module.offsets.hash_table.table, - module.pointer_size, - ) - .unwrap_or_default(), - }; - - (0..class_cache_size).flat_map(move |i| { - let mut table = match table_addr { - Address::NULL => None, - addr => process - .read_pointer( - addr + module.size_of_ptr().wrapping_mul(i), - module.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null()), - }; - - iter::from_fn(move || { - let class = table?; - table = process - .read_pointer( - class + module.offsets.class.next_class_cache, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()); - - Some(Class { class }) + walk.runtime + .classes(process, walk.pointer_size, ImageRef::new(self.image)) + .map(|class| Class { + class: class.address, }) .fuse() - }) } /// Tries to find the specified [.NET class](struct@Class) in the image. pub fn get_class(&self, process: &Process, module: &Module, class_name: &str) -> Option { - let name_space_index = class_name.rfind('.'); - - self.classes(process, module).find(|class| { - class.get_name::(process, module).is_ok_and(|name| { - if let Some(name_space_index) = name_space_index { - let class_name_space = &class_name[..name_space_index]; - let class_name = &class_name[name_space_index + 1..]; - - name.matches(class_name) - && class - .get_name_space::(process, module) - .is_ok_and(|name_space| name_space.matches(class_name_space)) - } else { - name.matches(class_name) - } + module + .walk() + .find_class(process, ImageRef::new(self.image), class_name) + .map(|class| Class { + class: class.address, }) - }) } /// Tries to find the specified [.NET class](struct@Class) in the image. diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index df4c9477..f8119fb9 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -8,19 +8,14 @@ use crate::{ future::retry, print_limited, signature::Signature, - Address, Address32, Address64, PointerSize, Process, + Address, Address32, PointerSize, Process, }; -use core::iter::{self, FusedIterator}; -mod assembly; mod builds; -use assembly::Assembly; mod image; pub use image::Image; mod class; pub use class::Class; -mod field; -use field::Field; mod version; pub use version::Version; mod pointer; @@ -30,7 +25,7 @@ use offsets::MonoOffsets; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; -use super::{BinaryFormat, CSTR}; +use super::{managed, BinaryFormat}; /// Represents access to a Unity game that is using the standard Mono backend. pub struct Module { @@ -269,29 +264,41 @@ 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, + 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(), + fields: self.offsets.class.fields.into(), + }, + field: managed::FieldOffsets { + name: self.offsets.field.name.into(), + 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. @@ -301,13 +308,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 @@ -370,9 +375,4 @@ impl Module { pub async fn wait_get_default_image(&self, process: &Process) -> Image { retry(|| self.get_default_image(process)).await } - - #[inline] - const fn size_of_ptr(&self) -> u64 { - self.pointer_size as u64 - } } diff --git a/src/game_engine/unity/mono/pointer.rs b/src/game_engine/unity/mono/pointer.rs index 8b75de7b..771c4703 100644 --- a/src/game_engine/unity/mono/pointer.rs +++ b/src/game_engine/unity/mono/pointer.rs @@ -1,23 +1,11 @@ -use super::{Class, Image, Module}; +use super::super::managed::{ImageRef, PointerPath}; +use super::{Image, Module}; use crate::{Address, Error, Process}; use bytemuck::CheckedBitPattern; -use core::{array, cell::RefCell}; /// A Mono-specific implementation for automatic pointer path resolution pub struct UnityPointer { - inner: RefCell>, -} - -struct UnityPointerInternal { - base_address: Address, - offsets: [u32; CAP], - resolved_offsets: usize, - - starting_class_name: &'static str, - starting_class: Option, - nr_of_parents: usize, - fields: [&'static str; CAP], - depth: usize, + path: PointerPath, } impl UnityPointer { @@ -28,113 +16,11 @@ impl UnityPointer { /// If a higher number of offsets is provided, the pointer path will be truncated /// according to the value of `CAP`. pub fn new(class_name: &'static str, nr_of_parents: usize, fields: &[&'static str]) -> Self { - let named_fields: [&str; CAP] = { - let mut iter = fields.iter(); - array::from_fn(|_| iter.next().copied().unwrap_or_default()) - }; - Self { - inner: RefCell::new(UnityPointerInternal { - base_address: Address::NULL, - offsets: [0; CAP], - resolved_offsets: 0, - starting_class_name: class_name, - starting_class: None, - nr_of_parents, - fields: named_fields, - depth: fields.len().min(CAP), - }), + path: PointerPath::new(class_name, nr_of_parents, fields), } } - /// Tries to resolve the pointer path for the `Mono` class specified - fn find_offsets(&self, process: &Process, module: &Module, image: &Image) -> Result<(), Error> { - let mut inner = self.inner.borrow_mut(); - - // If the pointer path has already been found, there's no need to continue - if inner.resolved_offsets == inner.depth { - return Ok(()); - } - - // Logic: the starting class can be recovered with the get_class() function, - // and parent class can be recovered if needed. However, this is a VERY - // intensive process because it involves looping through all the main classes - // in the game. For this reason, once the class is found, we want to store it - // into the cache, where it can be recovered if this function need to be run again - // (for example if a previous attempt at pointer path resolution failed) - let starting_class = match inner.starting_class { - Some(starting_class) => starting_class, - _ => { - let mut class = image - .get_class(process, module, inner.starting_class_name) - .ok_or(Error {})?; - - for _ in 0..inner.nr_of_parents { - class = class.get_parent(process, module).ok_or(Error {})?; - } - - inner.starting_class = Some(class); - class - } - }; - - // Recovering the address of the static table is not very CPU intensive, - // but it might be worth caching it as well - if inner.base_address.is_null() { - inner.base_address = starting_class - .get_static_table(process, module) - .ok_or(Error {})?; - }; - - // If we already resolved some offsets, we need to traverse them again starting from the base address - // of the static table in order to recalculate the address of the farthest object we can reach. - // If no offsets have been resolved yet, we just need to read the base address instead. - let mut current_object = { - let mut addr = inner.base_address; - for &i in &inner.offsets[..inner.resolved_offsets] { - addr = process.read_pointer(addr + i, module.pointer_size)?; - } - addr - }; - - // We keep track of the already resolved offsets in order to skip resolving them again - for i in inner.resolved_offsets..inner.depth { - let offset_from_string = match inner.fields[i].strip_prefix("0x") { - Some(rem) => u32::from_str_radix(rem, 16).ok(), - _ => inner.fields[i].parse().ok(), - }; - - let current_offset = match offset_from_string { - Some(offset) => offset as _, - _ => { - let current_class = match i { - 0 => starting_class, - _ => process - .read_pointer(current_object, module.pointer_size) - .ok() - .filter(|val| !val.is_null()) - .and_then(|addr| process.read_pointer(addr, module.pointer_size).ok()) - .filter(|val| !val.is_null()) - .map(|class| Class { class }) - .ok_or(Error {})?, - }; - - current_class - .get_field_offset(process, module, inner.fields[i]) - .ok_or(Error {})? - } - }; - - inner.offsets[i] = current_offset as _; - inner.resolved_offsets += 1; - - current_object = - process.read_pointer(current_object + current_offset, module.pointer_size)?; - } - - Ok(()) - } - /// Dereferences the pointer path, returning the memory address of the value of interest pub fn deref_offsets( &self, @@ -142,14 +28,8 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - self.find_offsets(process, module, image)?; - let inner = self.inner.borrow(); - let mut address = inner.base_address; - let (&last, path) = inner.offsets[..inner.depth].split_last().ok_or(Error {})?; - for &offset in path { - address = process.read_pointer(address + offset, module.pointer_size)?; - } - Ok(address + last) + self.path + .deref_offsets(process, &module.walk(), ImageRef::new(image.image)) } /// Dereferences the pointer path, returning the value stored at the final memory address @@ -159,6 +39,7 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - process.read(self.deref_offsets(process, module, image)?) + self.path + .deref(process, &module.walk(), ImageRef::new(image.image)) } } From 5fbec195730dff4c20baebe8a783d25d5f3448b9 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 21:43:23 +0200 Subject: [PATCH 16/24] add nested class lookup --- src/game_engine/unity/il2cpp/builds.rs | 32 +++++++++ src/game_engine/unity/il2cpp/mod.rs | 1 + src/game_engine/unity/il2cpp/offsets.rs | 5 ++ src/game_engine/unity/il2cpp/walk_tests.rs | 52 +++++++++++++- src/game_engine/unity/managed/mod.rs | 4 +- src/game_engine/unity/managed/walk.rs | 69 +++++++++++++++++- src/game_engine/unity/mono/builds.rs | 42 +++++++++++ src/game_engine/unity/mono/mod.rs | 1 + src/game_engine/unity/mono/offsets.rs | 13 ++++ src/game_engine/unity/mono/walk_tests.rs | 84 +++++++++++++++++++++- 10 files changed, 298 insertions(+), 5 deletions(-) diff --git a/src/game_engine/unity/il2cpp/builds.rs b/src/game_engine/unity/il2cpp/builds.rs index b4141702..818b8bcd 100644 --- a/src/game_engine/unity/il2cpp/builds.rs +++ b/src/game_engine/unity/il2cpp/builds.rs @@ -57,6 +57,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x114, @@ -89,6 +90,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x11c, @@ -120,6 +122,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x11c, @@ -151,6 +154,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xa8, @@ -187,6 +191,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x120, @@ -221,6 +226,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xa8, @@ -253,6 +259,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -292,6 +299,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -323,6 +331,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -354,6 +363,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -389,6 +399,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -422,6 +433,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -454,6 +466,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xa0, field_count: 0x124, @@ -489,6 +502,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x50, field_count: 0xac, @@ -523,6 +537,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0x98, field_count: 0x11c, @@ -559,6 +574,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x4c, field_count: 0xac, @@ -574,6 +590,7 @@ static BUILDS: &[Build] = &[ #[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; @@ -614,6 +631,21 @@ mod tests { assert!(find(24, (2018, 4), PointerSize::Bit32).is_none()); } + // A version table's value for where a class keeps its declaring type + // must match every measured build it stands in for, or say nothing. + #[test] + fn version_tables_never_contradict_a_measured_build_on_nesting() { + for build in BUILDS { + let Some(table) = IL2CPPOffsets::new(build.version, build.pointer_size) else { + continue; + }; + assert!( + table.class.declaring_type.is_none() + || table.class.declaring_type == build.offsets.class.declaring_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. diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 4c5cacc2..1860e9ef 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -192,6 +192,7 @@ impl Module { name: self.offsets.class.name.into(), namespace: self.offsets.class.namespace.into(), parent: self.offsets.class.parent.into(), + declaring: self.offsets.class.declaring_type, fields: self.offsets.class.fields.into(), }, field: managed::FieldOffsets { diff --git a/src/game_engine/unity/il2cpp/offsets.rs b/src/game_engine/unity/il2cpp/offsets.rs index f4155f39..62552d9c 100644 --- a/src/game_engine/unity/il2cpp/offsets.rs +++ b/src/game_engine/unity/il2cpp/offsets.rs @@ -25,6 +25,7 @@ impl IL2CPPOffsets { name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), // 2023.1 through 6000.7 fields: 0x80, static_fields: 0xB8, field_count: 0x124, @@ -49,6 +50,7 @@ impl IL2CPPOffsets { name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: None, fields: 0x80, static_fields: 0xB8, field_count: 0x120, @@ -73,6 +75,7 @@ impl IL2CPPOffsets { name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), // 2019.4, 2020.1 fields: 0x80, static_fields: 0xB8, field_count: 0x11C, @@ -97,6 +100,7 @@ impl IL2CPPOffsets { name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: None, fields: 0x80, static_fields: 0xB8, field_count: 0x114, @@ -128,6 +132,7 @@ pub(super) struct ClassOffsets { pub(super) name: u8, pub(super) namespace: u8, pub(super) parent: u8, + pub(super) declaring_type: Option, // Where a class keeps the one declaring it pub(super) fields: u8, pub(super) static_fields: u8, pub(super) field_count: u16, diff --git a/src/game_engine/unity/il2cpp/walk_tests.rs b/src/game_engine/unity/il2cpp/walk_tests.rs index 29d89416..423e8847 100644 --- a/src/game_engine/unity/il2cpp/walk_tests.rs +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -28,6 +28,7 @@ fn ptr(image: &mut [u8], at: u64, target: u64) { 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), }; @@ -47,6 +48,8 @@ fn image(version: Version) -> Vec { (0x2500, "UnityEngine"), (0x2580, "hidden"), (0x2600, "instance"), + (0x2700, "Outer"), + (0x2780, "Inner"), ]; for (at, text) in strings { put(&mut i, at, text.as_bytes()); @@ -66,7 +69,7 @@ fn image(version: Version) -> Vec { // 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, &3_u32.to_le_bytes()); + 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()), _ => { @@ -80,6 +83,8 @@ fn image(version: Version) -> Vec { 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 @@ -131,6 +136,16 @@ fn image(version: Version) -> Vec { 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); @@ -176,7 +191,7 @@ fn classes_resolve_by_name_and_namespace() { 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(), 3); + assert_eq!(image.classes(process, module).count(), 5); }); } } @@ -197,6 +212,39 @@ fn field_offsets_resolve_declared_and_inherited() { }); } +#[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] diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index f15e36f2..2d12b034 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -35,11 +35,13 @@ pub struct AssemblyOffsets { pub image: u16, } -/// Where a class keeps its names, its parent, and its field array. +/// Where a class keeps its names, its parent, its field array, and, when it +/// was measured, the class it is nested in. pub struct ClassOffsets { pub name: u16, pub namespace: u16, pub parent: u16, + pub declaring: Option, pub fields: u16, } diff --git a/src/game_engine/unity/managed/walk.rs b/src/game_engine/unity/managed/walk.rs index eaf32de0..7db61586 100644 --- a/src/game_engine/unity/managed/walk.rs +++ b/src/game_engine/unity/managed/walk.rs @@ -79,13 +79,39 @@ impl Walk { } /// Resolves a class by name, with the namespace split off at the last dot - /// when one is written. + /// 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 @@ -107,6 +133,47 @@ impl Walk { }) } + // 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 diff --git a/src/game_engine/unity/mono/builds.rs b/src/game_engine/unity/mono/builds.rs index a3d7101b..71e7fd41 100644 --- a/src/game_engine/unity/mono/builds.rs +++ b/src/game_engine/unity/mono/builds.rs @@ -91,6 +91,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -127,6 +128,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -164,6 +166,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -200,6 +203,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -236,6 +240,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -272,6 +277,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x50, namespace: 0x58, vtable_size: 0x18, @@ -308,6 +314,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -344,6 +351,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -380,6 +388,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -416,6 +425,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -452,6 +462,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x24, + nested_in: Some(0x28), name: 0x34, namespace: 0x38, vtable_size: 0xc, @@ -489,6 +500,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -525,6 +537,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -561,6 +574,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -597,6 +611,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x50, namespace: 0x58, vtable_size: 0x18, @@ -633,6 +648,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -669,6 +685,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x24, + nested_in: Some(0x28), name: 0x34, namespace: 0x38, vtable_size: 0xc, @@ -705,6 +722,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -741,6 +759,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -777,6 +796,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -813,6 +833,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -851,6 +872,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x24, + nested_in: Some(0x28), name: 0x30, namespace: 0x34, vtable_size: 0xc, @@ -887,6 +909,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -923,6 +946,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -959,6 +983,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -995,6 +1020,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -1087,6 +1113,22 @@ mod tests { assert_eq!(build.offsets.v_table.vtable, table.v_table.vtable); } + // A version table's value for where a class keeps its enclosing class + // must match every measured build it stands in for, or say nothing. + #[test] + fn version_tables_never_contradict_a_measured_build_on_nesting() { + for build in BUILDS { + let Some(table) = MonoOffsets::new(build.version, build.pointer_size, BinaryFormat::PE) + else { + continue; + }; + assert!( + table.class.nested_in.is_none() + || table.class.nested_in == build.offsets.class.nested_in + ); + } + } + // 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. diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index f8119fb9..a7a7223b 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -288,6 +288,7 @@ impl Module { name: self.offsets.class.name.into(), namespace: self.offsets.class.namespace.into(), parent: self.offsets.class.parent.into(), + declaring: self.offsets.class.nested_in, fields: self.offsets.class.fields.into(), }, field: managed::FieldOffsets { diff --git a/src/game_engine/unity/mono/offsets.rs b/src/game_engine/unity/mono/offsets.rs index f8210a05..195a354c 100644 --- a/src/game_engine/unity/mono/offsets.rs +++ b/src/game_engine/unity/mono/offsets.rs @@ -32,6 +32,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), // 2021.3 through 6000.7 name: 0x48, namespace: 0x50, vtable_size: 0x5C, @@ -62,6 +63,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), // 2021.3 through 6000.7 name: 0x2C, namespace: 0x30, vtable_size: 0x38, @@ -92,6 +94,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), // 2017.4 through 2020.1 name: 0x48, namespace: 0x50, vtable_size: 0x5C, @@ -122,6 +125,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), // 2017.4 through 2020.1 name: 0x2C, namespace: 0x30, vtable_size: 0x38, @@ -152,6 +156,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, + nested_in: None, name: 0x50, namespace: 0x58, vtable_size: 0x18, @@ -182,6 +187,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x24, + nested_in: None, name: 0x34, namespace: 0x38, vtable_size: 0xC, @@ -212,6 +218,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), // 5.6 through 2018.4 name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -242,6 +249,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x24, + nested_in: Some(0x28), // 5.6 through 2018.4 name: 0x30, namespace: 0x34, vtable_size: 0xC, @@ -273,6 +281,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, + nested_in: None, name: 0x40, namespace: 0x48, vtable_size: 0x54, @@ -305,6 +314,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, + nested_in: None, name: 0x40, namespace: 0x48, vtable_size: 0x54, @@ -337,6 +347,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, + nested_in: None, name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -369,6 +380,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, + nested_in: None, name: 0x40, namespace: 0x48, vtable_size: 0x18, @@ -409,6 +421,7 @@ pub(super) struct HashTableOffsets { pub(super) struct ClassOffsets { pub(super) parent: u8, + pub(super) nested_in: Option, // Where a class keeps the one it is nested in pub(super) name: u8, pub(super) namespace: u8, pub(super) vtable_size: u8, // On mono V1 and V1_cattrs, this offset represents MonoVTable.data diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index 736c01df..cac815cc 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -3,6 +3,10 @@ //! 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, HashTableOffsets, ImageOffsets, + MonoVTableOffsets, +}; use super::{builds, BinaryFormat, Module, MonoOffsets, UnityPointer, Version}; use crate::file_format::pe::DebugId; use crate::runtime::mock::with_process; @@ -45,6 +49,8 @@ fn image() -> Vec { (0x2580, "UnityEngine"), (0x2600, "hidden"), (0x2680, "instance"), + (0x2700, "Outer"), + (0x2780, "Inner"), ]; for (at, text) in strings { put(&mut i, at, text.as_bytes()); @@ -90,6 +96,7 @@ fn image() -> Vec { 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 @@ -127,6 +134,17 @@ fn image() -> Vec { 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); + // 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. @@ -195,7 +213,7 @@ fn classes_resolve_by_name_and_namespace() { 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(), 3); + assert_eq!(image.classes(process, module).count(), 5); }); } @@ -219,6 +237,70 @@ fn field_offsets_resolve_declared_inherited_and_backing() { }); } +#[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 { + parent: 0x30, + nested_in: None, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5C, + fields: 0x98, + runtime_info: 0xD0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + 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()); + }); +} + // The climb stops at UnityEngine's namespace, so an engine field never // resolves. #[test] From 97f5d6f57b9189a1069d936044881176ec883321 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 22:49:25 +0200 Subject: [PATCH 17/24] add definition route for generic field counts --- src/game_engine/unity/il2cpp/builds.rs | 2 +- src/game_engine/unity/managed/runtime.rs | 56 +++++++- src/game_engine/unity/managed/walk.rs | 2 +- src/game_engine/unity/mono/builds.rs | 161 +++++++++++++++++++++-- src/game_engine/unity/mono/mod.rs | 3 + src/game_engine/unity/mono/offsets.rs | 69 ++++++++++ src/game_engine/unity/mono/walk_tests.rs | 48 ++++++- 7 files changed, 325 insertions(+), 16 deletions(-) diff --git a/src/game_engine/unity/il2cpp/builds.rs b/src/game_engine/unity/il2cpp/builds.rs index 818b8bcd..5d9eaefd 100644 --- a/src/game_engine/unity/il2cpp/builds.rs +++ b/src/game_engine/unity/il2cpp/builds.rs @@ -634,7 +634,7 @@ mod tests { // A version table's value for where a class keeps its declaring type // must match every measured build it stands in for, or say nothing. #[test] - fn version_tables_never_contradict_a_measured_build_on_nesting() { + fn version_tables_never_contradict_a_measured_build() { for build in BUILDS { let Some(table) = IL2CPPOffsets::new(build.version, build.pointer_size) else { continue; diff --git a/src/game_engine/unity/managed/runtime.rs b/src/game_engine/unity/managed/runtime.rs index f7fbf475..1a42145c 100644 --- a/src/game_engine/unity/managed/runtime.rs +++ b/src/game_engine/unity/managed/runtime.rs @@ -9,6 +9,11 @@ pub enum Runtime { 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; + /// 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 { @@ -18,6 +23,9 @@ pub struct MonoRuntime { 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 runtime_info: u16, pub vtable_size: u16, pub vtable: u16, @@ -40,6 +48,43 @@ pub struct Il2CppRuntime { pub static_fields: u16, } +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>( @@ -67,10 +112,17 @@ impl Runtime { } /// Reads how many fields a class declares. - pub fn field_count(&self, process: &Process, class: ClassRef) -> u64 { + pub fn field_count( + &self, + process: &Process, + pointer_size: PointerSize, + class: ClassRef, + ) -> u64 { match self { Self::Mono(mono) => process - .read::(class.address + mono.field_count) + .read::( + mono.counted_class(process, pointer_size, class).address + mono.field_count, + ) .ok() .filter(|&count| count > 0) .unwrap_or_default() as u64, diff --git a/src/game_engine/unity/managed/walk.rs b/src/game_engine/unity/managed/walk.rs index 7db61586..f02db6d3 100644 --- a/src/game_engine/unity/managed/walk.rs +++ b/src/game_engine/unity/managed/walk.rs @@ -210,7 +210,7 @@ impl Walk { this_class = self.parent(process, class); - let field_count = self.runtime.field_count(process, class); + let field_count = self.runtime.field_count(process, self.pointer_size, class); let fields = match field_count { 0 => None, diff --git a/src/game_engine/unity/mono/builds.rs b/src/game_engine/unity/mono/builds.rs index 71e7fd41..f2e1583d 100644 --- a/src/game_engine/unity/mono/builds.rs +++ b/src/game_engine/unity/mono/builds.rs @@ -2,8 +2,8 @@ //! debug information, paired with the offsets measured from their symbols. use super::offsets::{ - AssemblyOffsets, ClassOffsets, FieldInfoOffsets, HashTableOffsets, ImageOffsets, MonoOffsets, - MonoVTableOffsets, + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, + ImageOffsets, MonoOffsets, MonoVTableOffsets, }; use super::Version; use crate::{file_format::pe::DebugId, PointerSize}; @@ -90,6 +90,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -100,6 +101,10 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -127,6 +132,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -137,6 +143,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -165,6 +175,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -175,6 +186,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -202,6 +217,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -212,6 +228,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -239,6 +259,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -249,6 +270,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -276,6 +301,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: Some(0x38), name: 0x50, @@ -286,6 +312,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -313,6 +343,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -323,6 +354,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -350,6 +385,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -360,6 +396,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -387,6 +427,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -397,6 +438,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -424,6 +469,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -434,6 +480,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -461,6 +511,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: None, parent: 0x24, nested_in: Some(0x28), name: 0x34, @@ -471,6 +522,10 @@ static BUILDS: &[Build] = &[ field_count: 0x68, next_class_cache: 0xac, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -499,6 +554,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -509,6 +565,10 @@ static BUILDS: &[Build] = &[ field_count: 0x94, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -536,6 +596,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -546,6 +607,10 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -573,6 +638,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -583,6 +649,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -610,6 +680,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: Some(0x38), name: 0x50, @@ -620,6 +691,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -647,6 +722,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -657,6 +733,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -684,6 +764,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: None, parent: 0x24, nested_in: Some(0x28), name: 0x34, @@ -694,6 +775,10 @@ static BUILDS: &[Build] = &[ field_count: 0x68, next_class_cache: 0xac, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -721,6 +806,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -731,6 +817,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -758,6 +848,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -768,6 +859,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -795,6 +890,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -805,6 +901,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -832,6 +932,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -842,6 +943,10 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -871,6 +976,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: None, parent: 0x24, nested_in: Some(0x28), name: 0x30, @@ -881,6 +987,10 @@ static BUILDS: &[Build] = &[ field_count: 0x64, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -908,6 +1018,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -918,6 +1029,10 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -945,6 +1060,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -955,6 +1071,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -982,6 +1102,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -992,6 +1113,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -1019,6 +1144,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -1029,6 +1155,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -1113,19 +1243,32 @@ mod tests { assert_eq!(build.offsets.v_table.vtable, table.v_table.vtable); } - // A version table's value for where a class keeps its enclosing class - // must match every measured build it stands in for, or say nothing. + // 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_on_nesting() { + 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!( - table.class.nested_in.is_none() - || table.class.nested_in == build.offsets.class.nested_in - ); + 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 + )); } } diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index a7a7223b..f4f9161b 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -273,6 +273,9 @@ impl Module { 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, runtime_info: self.offsets.class.runtime_info, vtable_size: self.offsets.class.vtable_size.into(), vtable: self.offsets.v_table.vtable.into(), diff --git a/src/game_engine/unity/mono/offsets.rs b/src/game_engine/unity/mono/offsets.rs index 195a354c..bfee22cf 100644 --- a/src/game_engine/unity/mono/offsets.rs +++ b/src/game_engine/unity/mono/offsets.rs @@ -6,6 +6,7 @@ pub(super) struct MonoOffsets { pub(super) image: ImageOffsets, pub(super) hash_table: HashTableOffsets, pub(super) class: ClassOffsets, + pub(super) generic: GenericOffsets, pub(super) field: FieldInfoOffsets, pub(super) v_table: MonoVTableOffsets, } @@ -31,6 +32,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1B), // 2021.3 through 6000.7 parent: 0x30, nested_in: Some(0x38), // 2021.3 through 6000.7 name: 0x48, @@ -41,6 +43,10 @@ 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 + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -62,6 +68,7 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xF), // 2021.3 through 6000.7 parent: 0x20, nested_in: Some(0x24), // 2021.3 through 6000.7 name: 0x2C, @@ -72,6 +79,10 @@ 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 + }, field: FieldInfoOffsets { name: 0x4, offset: 0xC, @@ -93,6 +104,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2A), // 2017.4 through 2020.1 parent: 0x30, nested_in: Some(0x38), // 2017.4 through 2020.1 name: 0x48, @@ -103,6 +115,10 @@ 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 + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -124,6 +140,7 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1E), // 2017.4 through 2020.1 parent: 0x20, nested_in: Some(0x24), // 2017.4 through 2020.1 name: 0x2C, @@ -134,6 +151,10 @@ 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 + }, field: FieldInfoOffsets { name: 0x4, offset: 0xC, @@ -155,6 +176,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: None, name: 0x50, @@ -165,6 +187,10 @@ impl MonoOffsets { field_count: 0x9C, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -186,6 +212,7 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: None, parent: 0x24, nested_in: None, name: 0x34, @@ -196,6 +223,10 @@ impl MonoOffsets { field_count: 0x68, next_class_cache: 0xAC, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x4, offset: 0xC, @@ -217,6 +248,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: Some(0x38), // 5.6 through 2018.4 name: 0x48, @@ -227,6 +259,10 @@ impl MonoOffsets { field_count: 0x94, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -248,6 +284,7 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: None, parent: 0x24, nested_in: Some(0x28), // 5.6 through 2018.4 name: 0x30, @@ -258,6 +295,10 @@ impl MonoOffsets { field_count: 0x64, next_class_cache: 0xA8, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x4, offset: 0xC, @@ -280,6 +321,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x28, nested_in: None, name: 0x40, @@ -290,6 +332,10 @@ impl MonoOffsets { field_count: 0xF8, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -313,6 +359,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x28, nested_in: None, name: 0x40, @@ -323,6 +370,10 @@ impl MonoOffsets { field_count: 0xF8, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -346,6 +397,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x28, nested_in: None, name: 0x48, @@ -356,6 +408,10 @@ impl MonoOffsets { field_count: 0x94, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -379,6 +435,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x28, nested_in: None, name: 0x40, @@ -389,6 +446,10 @@ impl MonoOffsets { field_count: 0x8C, next_class_cache: 0xF8, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -420,6 +481,7 @@ 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) parent: u8, pub(super) nested_in: Option, // Where a class keeps the one it is nested in pub(super) name: u8, @@ -431,6 +493,13 @@ pub(super) struct ClassOffsets { pub(super) next_class_cache: u16, } +// 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) name: u8, pub(super) offset: u8, diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index cac815cc..308c4d0c 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -4,8 +4,8 @@ //! layout rather than against itself. use super::offsets::{ - AssemblyOffsets, ClassOffsets, FieldInfoOffsets, HashTableOffsets, ImageOffsets, - MonoVTableOffsets, + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, + ImageOffsets, MonoVTableOffsets, }; use super::{builds, BinaryFormat, Module, MonoOffsets, UnityPointer, Version}; use crate::file_format::pe::DebugId; @@ -51,6 +51,8 @@ fn image() -> Vec { (0x2680, "instance"), (0x2700, "Outer"), (0x2780, "Inner"), + (0x2B00, "Inventory"), + (0x2B80, "items"), ]; for (at, text) in strings { put(&mut i, at, text.as_bytes()); @@ -144,6 +146,22 @@ fn image() -> Vec { 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 @@ -213,7 +231,7 @@ fn classes_resolve_by_name_and_namespace() { 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); + assert_eq!(image.classes(process, module).count(), 6); }); } @@ -274,6 +292,7 @@ fn nested_lookups_without_a_measured_offset_answer_nothing() { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: None, name: 0x48, @@ -284,6 +303,10 @@ fn nested_lookups_without_a_measured_offset_answer_nothing() { field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -298,6 +321,25 @@ fn nested_lookups_without_a_measured_offset_answer_nothing() { .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), + ); }); } From 6f7ca6119747b39c7aba9730291f548ec810ac2f Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 23:07:15 +0200 Subject: [PATCH 18/24] move declaring type offset before parent --- src/game_engine/unity/il2cpp/builds.rs | 32 ++++++++++++------------- src/game_engine/unity/il2cpp/offsets.rs | 10 ++++---- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/game_engine/unity/il2cpp/builds.rs b/src/game_engine/unity/il2cpp/builds.rs index 5d9eaefd..4911d393 100644 --- a/src/game_engine/unity/il2cpp/builds.rs +++ b/src/game_engine/unity/il2cpp/builds.rs @@ -56,8 +56,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x114, @@ -89,8 +89,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x11c, @@ -121,8 +121,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x11c, @@ -153,8 +153,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xa8, @@ -190,8 +190,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x120, @@ -225,8 +225,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xa8, @@ -258,8 +258,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -298,8 +298,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -330,8 +330,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -362,8 +362,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -398,8 +398,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -432,8 +432,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -465,8 +465,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xa0, field_count: 0x124, @@ -501,8 +501,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x50, field_count: 0xac, @@ -536,8 +536,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0x98, field_count: 0x11c, @@ -573,8 +573,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x4c, field_count: 0xac, diff --git a/src/game_engine/unity/il2cpp/offsets.rs b/src/game_engine/unity/il2cpp/offsets.rs index 62552d9c..a82acf6f 100644 --- a/src/game_engine/unity/il2cpp/offsets.rs +++ b/src/game_engine/unity/il2cpp/offsets.rs @@ -24,8 +24,8 @@ impl IL2CPPOffsets { class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), // 2023.1 through 6000.7 + parent: 0x58, fields: 0x80, static_fields: 0xB8, field_count: 0x124, @@ -49,8 +49,8 @@ impl IL2CPPOffsets { class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: None, + parent: 0x58, fields: 0x80, static_fields: 0xB8, field_count: 0x120, @@ -74,8 +74,8 @@ impl IL2CPPOffsets { class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), // 2019.4, 2020.1 + parent: 0x58, fields: 0x80, static_fields: 0xB8, field_count: 0x11C, @@ -99,8 +99,8 @@ impl IL2CPPOffsets { class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: None, + parent: 0x58, fields: 0x80, static_fields: 0xB8, field_count: 0x114, @@ -131,8 +131,8 @@ pub(super) struct ImageOffsets { pub(super) struct ClassOffsets { pub(super) name: u8, pub(super) namespace: u8, - pub(super) parent: 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) field_count: u16, From 9464835f9a8e3821d3b706351418eb1cb97ada27 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 23:30:39 +0200 Subject: [PATCH 19/24] fix static reads for inherited fields --- src/game_engine/unity/il2cpp/class.rs | 15 +++---- src/game_engine/unity/il2cpp/walk_tests.rs | 38 +++++++++++++++-- src/game_engine/unity/managed/pointer.rs | 34 +++++++++------ src/game_engine/unity/mono/class.rs | 15 +++---- src/game_engine/unity/mono/walk_tests.rs | 49 +++++++++++++++++++--- src/runtime/mock.rs | 14 ++++++- 6 files changed, 130 insertions(+), 35 deletions(-) diff --git a/src/game_engine/unity/il2cpp/class.rs b/src/game_engine/unity/il2cpp/class.rs index 2358971a..c42fb821 100644 --- a/src/game_engine/unity/il2cpp/class.rs +++ b/src/game_engine/unity/il2cpp/class.rs @@ -36,15 +36,16 @@ 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()) }) diff --git a/src/game_engine/unity/il2cpp/walk_tests.rs b/src/game_engine/unity/il2cpp/walk_tests.rs index 423e8847..c6834f77 100644 --- a/src/game_engine/unity/il2cpp/walk_tests.rs +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -5,9 +5,11 @@ //! so the walk is checked against the layout rather than against itself. use super::{IL2CPPOffsets, Module, UnityPointer, Version}; -use crate::runtime::mock::with_process; +use crate::runtime::mock::{poll_once, with_process}; use crate::{Address, PointerSize, Process}; +use core::task::Poll; + use std::vec; use std::vec::Vec; @@ -50,6 +52,7 @@ fn image(version: Version) -> Vec { (0x2600, "instance"), (0x2700, "Outer"), (0x2780, "Inner"), + (0x2800, "spawner"), ]; for (at, text) in strings { put(&mut i, at, text.as_bytes()); @@ -104,20 +107,25 @@ fn image(version: Version) -> Vec { ptr(&mut i, 0xE20, BASE + 0x2200); // points put(&mut i, 0xE20 + 0x18, &0x20_i32.to_le_bytes()); - // Enemy, and Boss deriving from it. + // 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); - put(&mut i, enemy + field_count_at, &1_u16.to_le_bytes()); + 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()); @@ -152,6 +160,10 @@ fn image(version: Version) -> Vec { 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); + i } @@ -270,6 +282,26 @@ fn statics_resolve_from_the_class() { }); } +// 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, + ); + }); +} + // 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] diff --git a/src/game_engine/unity/managed/pointer.rs b/src/game_engine/unity/managed/pointer.rs index 2393284b..0dffb423 100644 --- a/src/game_engine/unity/managed/pointer.rs +++ b/src/game_engine/unity/managed/pointer.rs @@ -68,8 +68,25 @@ impl PointerPath { } }; - if inner.base_address.is_null() { - inner.base_address = walk.static_table(process, starting_class).ok_or(Error {})?; + 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 @@ -83,18 +100,11 @@ impl PointerPath { }; 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 { + let current_offset = match parse(inner.fields[i]) { Some(offset) => offset, _ => { - let current_class = match i { - 0 => starting_class, - _ => walk.object_class(process, current_object).ok_or(Error {})?, - }; + 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 {})? diff --git a/src/game_engine/unity/mono/class.rs b/src/game_engine/unity/mono/class.rs index d958b840..a07bb6db 100644 --- a/src/game_engine/unity/mono/class.rs +++ b/src/game_engine/unity/mono/class.rs @@ -35,15 +35,16 @@ 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()) }) diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index 308c4d0c..e6bd9571 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -9,9 +9,11 @@ use super::offsets::{ }; use super::{builds, BinaryFormat, Module, MonoOffsets, UnityPointer, Version}; use crate::file_format::pe::DebugId; -use crate::runtime::mock::with_process; +use crate::runtime::mock::{poll_once, with_process}; use crate::{Address, PointerSize, Process}; +use core::task::Poll; + use std::vec; use std::vec::Vec; @@ -28,7 +30,7 @@ fn ptr(image: &mut [u8], at: u64, target: u64) { // 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, a static table reachable through the vtable, +// 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]; @@ -51,6 +53,7 @@ fn image() -> Vec { (0x2680, "instance"), (0x2700, "Outer"), (0x2780, "Inner"), + (0x2800, "spawner"), (0x2B00, "Inventory"), (0x2B80, "items"), ]; @@ -106,22 +109,29 @@ fn image() -> Vec { ptr(&mut i, 0x1440 + 0x8, BASE + 0x2280); // k__BackingField put(&mut i, 0x1440 + 0x18, &0x24_i32.to_le_bytes()); - // Enemy, with one field and Boss chained behind it in the bucket. + // 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); - put(&mut i, enemy + 0x100, &1_i32.to_le_bytes()); + 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()); @@ -170,6 +180,15 @@ fn image() -> Vec { 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); @@ -366,8 +385,28 @@ fn statics_resolve_through_the_vtable() { 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!(boss.get_static_table(process, module).is_none()); + 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, + ); }); } diff --git a/src/runtime/mock.rs b/src/runtime/mock.rs index 9cb049ef..cd54a975 100644 --- a/src/runtime/mock.rs +++ b/src/runtime/mock.rs @@ -1,7 +1,12 @@ //! A fake host for tests: definitions of the wasm imports the runtime layer //! links against, backed by in-memory images so readers can run on the host. -use core::{cell::RefCell, num::NonZeroU64}; +use core::{ + cell::RefCell, + future::Future, + num::NonZeroU64, + task::{Context, Poll, Waker}, +}; use std::vec::Vec; @@ -24,6 +29,13 @@ pub fn with_process(regions: &[(u64, &[u8])], test: impl FnOnce(&Process) -> 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) From 290c9d005ea77fb52659db74447f4103bf6b64cf Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 03:05:27 +0200 Subject: [PATCH 20/24] add managed string reads --- src/game_engine/unity/il2cpp/mod.rs | 22 +++- src/game_engine/unity/il2cpp/readers_tests.rs | 56 +++++++++ src/game_engine/unity/managed/mod.rs | 2 + src/game_engine/unity/managed/readers.rs | 40 +++++++ src/game_engine/unity/mono/mod.rs | 21 +++- src/game_engine/unity/mono/readers_tests.rs | 106 ++++++++++++++++++ 6 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 src/game_engine/unity/il2cpp/readers_tests.rs create mode 100644 src/game_engine/unity/managed/readers.rs create mode 100644 src/game_engine/unity/mono/readers_tests.rs diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 1860e9ef..0956c952 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -1,8 +1,8 @@ //! Support for attaching to Unity games that are using the IL2CPP backend. use crate::{ - file_format::pe, future::retry, print_limited, signature::Signature, Address, PointerSize, - Process, + file_format::pe, future::retry, print_limited, signature::Signature, string::ArrayWString, + Address, Error, PointerSize, Process, }; mod builds; @@ -17,6 +17,8 @@ pub use pointer::UnityPointer; mod offsets; use offsets::IL2CPPOffsets; #[cfg(all(test, not(target_family = "wasm")))] +mod readers_tests; +#[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; use super::managed; @@ -230,6 +232,22 @@ 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) + } + /// 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 diff --git a/src/game_engine/unity/il2cpp/readers_tests.rs b/src/game_engine/unity/il2cpp/readers_tests.rs new file mode 100644 index 00000000..99f7724d --- /dev/null +++ b/src/game_engine/unity/il2cpp/readers_tests.rs @@ -0,0 +1,56 @@ +//! 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()); + } + + 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")); + }); +} diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 2d12b034..0d3637ea 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -8,11 +8,13 @@ mod cursor; mod pointer; +mod readers; mod runtime; mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; +pub use readers::read_string; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; pub use walk::Walk; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs new file mode 100644 index 00000000..1cb614de --- /dev/null +++ b/src/game_engine/unity/managed/readers.rs @@ -0,0 +1,40 @@ +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) +} diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index f4f9161b..a4d0fd82 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -8,7 +8,8 @@ use crate::{ future::retry, print_limited, signature::Signature, - Address, Address32, PointerSize, Process, + string::ArrayWString, + Address, Address32, Error, PointerSize, Process, }; mod builds; @@ -23,6 +24,8 @@ pub use pointer::UnityPointer; mod offsets; use offsets::MonoOffsets; #[cfg(all(test, not(target_family = "wasm")))] +mod readers_tests; +#[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; use super::{managed, BinaryFormat}; @@ -329,6 +332,22 @@ 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) + } + /// 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 diff --git a/src/game_engine/unity/mono/readers_tests.rs b/src/game_engine/unity/mono/readers_tests.rs new file mode 100644 index 00000000..67494d4e --- /dev/null +++ b/src/game_engine/unity/mono/readers_tests.rs @@ -0,0 +1,106 @@ +//! Tests pinning the managed readers over hand-laid string 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 objects at the 64-bit layout: two header words, an i32 character +// count, the UTF-16 characters inline. 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 + + 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()); + + 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()); + }); +} + +// The 32-bit layout halves the header and the reference width. +#[test] +fn strings_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"); + + 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")); + }); +} From 15e557ec5b51936bc0c77e12e383af6bae84119a Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 03:09:11 +0200 Subject: [PATCH 21/24] add managed array reads --- src/game_engine/unity/il2cpp/mod.rs | 19 +++++ src/game_engine/unity/il2cpp/readers_tests.rs | 31 ++++++++ src/game_engine/unity/managed/mod.rs | 2 +- src/game_engine/unity/managed/readers.rs | 43 ++++++++++ src/game_engine/unity/mono/mod.rs | 19 +++++ src/game_engine/unity/mono/readers_tests.rs | 79 +++++++++++++++++-- 6 files changed, 185 insertions(+), 8 deletions(-) diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 0956c952..a0518884 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -1,5 +1,8 @@ //! Support for attaching to Unity games that are using the IL2CPP backend. +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; + use crate::{ file_format::pe, future::retry, print_limited, signature::Signature, string::ArrayWString, Address, Error, PointerSize, Process, @@ -248,6 +251,22 @@ impl Module { 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) + } + /// 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 diff --git a/src/game_engine/unity/il2cpp/readers_tests.rs b/src/game_engine/unity/il2cpp/readers_tests.rs index 99f7724d..ed934dd9 100644 --- a/src/game_engine/unity/il2cpp/readers_tests.rs +++ b/src/game_engine/unity/il2cpp/readers_tests.rs @@ -29,6 +29,16 @@ fn image() -> Vec { 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 } @@ -54,3 +64,24 @@ fn strings_resolve_through_their_reference() { 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/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 0d3637ea..5451c814 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -14,7 +14,7 @@ mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; -pub use readers::read_string; +pub use readers::{read_array, read_string}; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; pub use walk::Walk; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs index 1cb614de..043c5efc 100644 --- a/src/game_engine/unity/managed/readers.rs +++ b/src/game_engine/unity/managed/readers.rs @@ -1,3 +1,7 @@ +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; +use core::mem::MaybeUninit; + use crate::{string::ArrayWString, Address, Error, PointerSize, Process}; /// How many bytes a managed object's two header words occupy. The readers @@ -38,3 +42,42 @@ pub fn read_string( Ok(string) } + +/// 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/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index a4d0fd82..7a7442a4 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -3,6 +3,9 @@ #[cfg(feature = "alloc")] use crate::file_format::macho; +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; + use crate::{ file_format::{elf, pe}, future::retry, @@ -348,6 +351,22 @@ impl Module { 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) + } + /// 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 diff --git a/src/game_engine/unity/mono/readers_tests.rs b/src/game_engine/unity/mono/readers_tests.rs index 67494d4e..4f634bdc 100644 --- a/src/game_engine/unity/mono/readers_tests.rs +++ b/src/game_engine/unity/mono/readers_tests.rs @@ -1,6 +1,6 @@ -//! Tests pinning the managed readers over hand-laid string objects. The -//! readers are pointer-size ABI, not walk work, so the fixtures are tiny -//! blobs rather than the walk's class fixtures. +//! 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; @@ -26,8 +26,9 @@ fn utf16(image: &mut [u8], at: u64, text: &str) { } } -// String objects at the 64-bit layout: two header words, an i32 character -// count, the UTF-16 characters inline. Each slot at the front holds one +// 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]; @@ -36,6 +37,9 @@ fn image() -> Vec { 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"); @@ -43,6 +47,22 @@ fn image() -> Vec { 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 } @@ -88,13 +108,53 @@ fn string_counts_past_the_buffer_refuse() { }); } -// The 32-bit layout halves the header and the reference width. #[test] -fn strings_resolve_on_32_bit_targets() { +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); @@ -102,5 +162,10 @@ fn strings_resolve_on_32_bit_targets() { .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]); }); } From 30a4f08f9eed2ea5c8da46e3fbd862747749ee45 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 11:02:01 +0200 Subject: [PATCH 22/24] add managed list reads --- src/game_engine/unity/il2cpp/mod.rs | 42 +++++++++++ src/game_engine/unity/il2cpp/walk_tests.rs | 39 ++++++++++ src/game_engine/unity/managed/mod.rs | 2 +- src/game_engine/unity/managed/readers.rs | 60 ++++++++++++++++ src/game_engine/unity/managed/walk.rs | 38 +++++++++- src/game_engine/unity/mono/mod.rs | 42 +++++++++++ src/game_engine/unity/mono/walk_tests.rs | 82 ++++++++++++++++++++++ 7 files changed, 303 insertions(+), 2 deletions(-) diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index a0518884..de8c44ae 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -25,6 +25,7 @@ mod readers_tests; mod walk_tests; use super::managed; +pub use super::managed::ListOffsets; /// Represents access to a Unity game that is using the IL2CPP backend. pub struct Module { @@ -267,6 +268,37 @@ impl Module { 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) + } + + /// 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 @@ -317,6 +349,16 @@ impl Module { pub async fn wait_get_default_image(&self, process: &Process) -> Image { retry(|| self.get_default_image(process)).await } + + /// 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 + } } #[cfg(all(test, not(target_family = "wasm")))] diff --git a/src/game_engine/unity/il2cpp/walk_tests.rs b/src/game_engine/unity/il2cpp/walk_tests.rs index c6834f77..6e490ef3 100644 --- a/src/game_engine/unity/il2cpp/walk_tests.rs +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -53,6 +53,8 @@ fn image(version: Version) -> Vec { (0x2700, "Outer"), (0x2780, "Inner"), (0x2800, "spawner"), + (0x2880, "_items"), + (0x2900, "_size"), ]; for (at, text) in strings { put(&mut i, at, text.as_bytes()); @@ -164,6 +166,30 @@ fn image(version: Version) -> Vec { // 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 } @@ -302,6 +328,19 @@ fn static_instances_resolve_through_the_declaring_class() { }); } +// 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] diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 5451c814..4e6e9df5 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -14,7 +14,7 @@ mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; -pub use readers::{read_array, read_string}; +pub use readers::{read_array, read_list, read_string, ListOffsets}; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; pub use walk::Walk; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs index 043c5efc..2f23254f 100644 --- a/src/game_engine/unity/managed/readers.rs +++ b/src/game_engine/unity/managed/readers.rs @@ -43,6 +43,66 @@ pub fn read_string( 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, +} + +/// 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) +} + /// 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. diff --git a/src/game_engine/unity/managed/walk.rs b/src/game_engine/unity/managed/walk.rs index f02db6d3..f6dbda55 100644 --- a/src/game_engine/unity/managed/walk.rs +++ b/src/game_engine/unity/managed/walk.rs @@ -1,5 +1,5 @@ use super::super::{get_backing_name, CSTR}; -use super::{ClassRef, ClimbStop, FieldRef, ImageRef, Runtime, WalkOffsets}; +use super::{ClassRef, ClimbStop, FieldRef, ImageRef, ListOffsets, Runtime, WalkOffsets}; use crate::{string::ArrayCString, Address, PointerSize, Process}; /// The walk itself: everything both runtimes lay out the same way, written @@ -260,6 +260,42 @@ impl Walk { 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?, + }) + } + /// 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) diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index 7a7442a4..b2a4768d 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -31,6 +31,7 @@ mod readers_tests; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; +pub use super::managed::ListOffsets; use super::{managed, BinaryFormat}; /// Represents access to a Unity game that is using the standard Mono backend. @@ -367,6 +368,37 @@ impl Module { 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) + } + + /// 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 @@ -417,4 +449,14 @@ impl Module { pub async fn wait_get_default_image(&self, process: &Process) -> Image { retry(|| self.get_default_image(process)).await } + + /// 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 + } } diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index e6bd9571..60368385 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -54,6 +54,9 @@ fn image() -> Vec { (0x2700, "Outer"), (0x2780, "Inner"), (0x2800, "spawner"), + (0x2880, "_items"), + (0x2900, "_size"), + (0x2980, "_version"), (0x2B00, "Inventory"), (0x2B80, "items"), ]; @@ -195,6 +198,50 @@ fn image() -> Vec { 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 } @@ -410,6 +457,41 @@ fn static_instances_resolve_through_the_declaring_class() { }); } +// 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] From 58a79a222d69ccc431699faa52805f677fbfb4a5 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 17:00:41 +0200 Subject: [PATCH 23/24] add dictionary offset resolution --- src/game_engine/unity/il2cpp/builds.rs | 174 +++++++++++++++- .../unity/il2cpp/collections_tests.rs | 130 ++++++++++++ src/game_engine/unity/il2cpp/mod.rs | 50 ++++- src/game_engine/unity/il2cpp/offsets.rs | 46 ++++ src/game_engine/unity/managed/mod.rs | 16 +- src/game_engine/unity/managed/readers.rs | 27 +++ src/game_engine/unity/managed/runtime.rs | 83 ++++++++ src/game_engine/unity/managed/walk.rs | 126 ++++++++++- src/game_engine/unity/mono/builds.rs | 165 ++++++++++++++- .../unity/mono/collections_tests.rs | 197 ++++++++++++++++++ src/game_engine/unity/mono/mod.rs | 43 +++- src/game_engine/unity/mono/offsets.rs | 89 +++++++- src/game_engine/unity/mono/walk_tests.rs | 8 +- 13 files changed, 1133 insertions(+), 21 deletions(-) create mode 100644 src/game_engine/unity/il2cpp/collections_tests.rs create mode 100644 src/game_engine/unity/mono/collections_tests.rs diff --git a/src/game_engine/unity/il2cpp/builds.rs b/src/game_engine/unity/il2cpp/builds.rs index 4911d393..3bf64e28 100644 --- a/src/game_engine/unity/il2cpp/builds.rs +++ b/src/game_engine/unity/il2cpp/builds.rs @@ -3,7 +3,8 @@ //! with the offsets measured from their symbols. use super::offsets::{ - AssemblyOffsets, ClassOffsets, FieldInfoOffsets, IL2CPPOffsets, ImageOffsets, + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, IL2CPPOffsets, ImageOffsets, + TypeOffsets, }; use super::Version; use crate::PointerSize; @@ -60,10 +61,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -93,10 +103,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -125,10 +144,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -157,10 +185,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -194,10 +231,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -229,10 +275,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -262,10 +317,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -302,10 +366,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -334,10 +407,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -366,10 +448,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -402,10 +493,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -436,10 +536,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -469,10 +578,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -505,10 +623,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -540,10 +667,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -577,10 +713,19 @@ static BUILDS: &[Build] = &[ 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, }, @@ -631,18 +776,33 @@ mod tests { assert!(find(24, (2018, 4), PointerSize::Bit32).is_none()); } - // A version table's value for where a class keeps its declaring type - // must match every measured build it stands in for, or say nothing. + // 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!( - table.class.declaring_type.is_none() - || table.class.declaring_type == build.offsets.class.declaring_type - ); + 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_)); } } diff --git a/src/game_engine/unity/il2cpp/collections_tests.rs b/src/game_engine/unity/il2cpp/collections_tests.rs new file mode 100644 index 00000000..ceb90308 --- /dev/null +++ b/src/game_engine/unity/il2cpp/collections_tests.rs @@ -0,0 +1,130 @@ +//! 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); + + 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 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/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index de8c44ae..c1e99c58 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -20,12 +20,14 @@ 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::managed; -pub use super::managed::ListOffsets; +pub use super::managed::{DictionaryOffsets, ListOffsets}; /// Represents access to a Unity game that is using the IL2CPP backend. pub struct Module { @@ -187,6 +189,9 @@ impl Module { 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 { @@ -199,10 +204,12 @@ impl Module { 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(), }, @@ -282,6 +289,32 @@ impl Module { 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) + } + + /// 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 @@ -359,6 +392,21 @@ impl Module { 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")))] diff --git a/src/game_engine/unity/il2cpp/offsets.rs b/src/game_engine/unity/il2cpp/offsets.rs index a82acf6f..a1f1df05 100644 --- a/src/game_engine/unity/il2cpp/offsets.rs +++ b/src/game_engine/unity/il2cpp/offsets.rs @@ -4,6 +4,8 @@ pub(super) struct IL2CPPOffsets { pub(super) assembly: AssemblyOffsets, pub(super) image: ImageOffsets, pub(super) class: ClassOffsets, + pub(super) generic: GenericOffsets, + pub(super) type_words: TypeOffsets, pub(super) field: FieldInfoOffsets, } @@ -28,10 +30,17 @@ impl IL2CPPOffsets { 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, }, @@ -53,10 +62,17 @@ impl IL2CPPOffsets { 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, }, @@ -78,10 +94,19 @@ impl IL2CPPOffsets { 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, }, @@ -103,10 +128,17 @@ impl IL2CPPOffsets { 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, }, @@ -135,11 +167,25 @@ pub(super) struct ClassOffsets { 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/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 4e6e9df5..07978e0e 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -14,7 +14,9 @@ mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; -pub use readers::{read_array, read_list, read_string, ListOffsets}; +pub use readers::{ + read_array, read_list, read_string, DictionaryOffsets, EntryLayout, ListOffsets, +}; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; pub use walk::Walk; @@ -37,20 +39,24 @@ pub struct AssemblyOffsets { pub image: u16, } -/// Where a class keeps its names, its parent, its field array, and, when it -/// was measured, the class it is nested in. +/// 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 and offset, and the size of one entry in -/// a class's field array, whatever each runtime's own offsets call it. +/// 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, } diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs index 2f23254f..d3f548dd 100644 --- a/src/game_engine/unity/managed/readers.rs +++ b/src/game_engine/unity/managed/readers.rs @@ -52,6 +52,33 @@ pub struct ListOffsets { 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 diff --git a/src/game_engine/unity/managed/runtime.rs b/src/game_engine/unity/managed/runtime.rs index 1a42145c..92de8957 100644 --- a/src/game_engine/unity/managed/runtime.rs +++ b/src/game_engine/unity/managed/runtime.rs @@ -14,6 +14,18 @@ pub enum Runtime { 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 { @@ -26,6 +38,8 @@ pub struct MonoRuntime { 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, @@ -46,6 +60,9 @@ pub struct Il2CppRuntime { 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 { @@ -172,6 +189,72 @@ impl Runtime { .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( diff --git a/src/game_engine/unity/managed/walk.rs b/src/game_engine/unity/managed/walk.rs index f6dbda55..38564074 100644 --- a/src/game_engine/unity/managed/walk.rs +++ b/src/game_engine/unity/managed/walk.rs @@ -1,5 +1,9 @@ use super::super::{get_backing_name, CSTR}; -use super::{ClassRef, ClimbStop, FieldRef, ImageRef, ListOffsets, Runtime, WalkOffsets}; +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 @@ -296,6 +300,126 @@ impl Walk { }) } + /// 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) diff --git a/src/game_engine/unity/mono/builds.rs b/src/game_engine/unity/mono/builds.rs index f2e1583d..6e8f7f30 100644 --- a/src/game_engine/unity/mono/builds.rs +++ b/src/game_engine/unity/mono/builds.rs @@ -3,7 +3,7 @@ use super::offsets::{ AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, - ImageOffsets, MonoOffsets, MonoVTableOffsets, + ImageOffsets, MonoOffsets, MonoVTableOffsets, TypeOffsets, }; use super::Version; use crate::{file_format::pe::DebugId, PointerSize}; @@ -91,6 +91,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -105,7 +106,12 @@ static BUILDS: &[Build] = &[ 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, @@ -133,6 +139,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -147,7 +154,12 @@ static BUILDS: &[Build] = &[ 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, @@ -176,6 +188,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -190,7 +203,12 @@ static BUILDS: &[Build] = &[ 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, @@ -218,6 +236,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -232,7 +251,12 @@ static BUILDS: &[Build] = &[ 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, @@ -260,6 +284,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -274,7 +299,12 @@ static BUILDS: &[Build] = &[ 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, @@ -302,6 +332,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x50, @@ -316,7 +347,12 @@ static BUILDS: &[Build] = &[ 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, @@ -344,6 +380,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -358,7 +395,12 @@ static BUILDS: &[Build] = &[ 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, @@ -386,6 +428,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -400,7 +443,12 @@ static BUILDS: &[Build] = &[ 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, @@ -428,6 +476,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -442,7 +491,12 @@ static BUILDS: &[Build] = &[ 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, @@ -470,6 +524,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -484,7 +539,12 @@ static BUILDS: &[Build] = &[ 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, @@ -512,6 +572,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x10), parent: 0x24, nested_in: Some(0x28), name: 0x34, @@ -526,7 +587,12 @@ static BUILDS: &[Build] = &[ 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, @@ -555,6 +621,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -569,7 +636,12 @@ static BUILDS: &[Build] = &[ 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, @@ -597,6 +669,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -611,7 +684,12 @@ static BUILDS: &[Build] = &[ 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, @@ -639,6 +717,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -653,7 +732,12 @@ static BUILDS: &[Build] = &[ 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, @@ -681,6 +765,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x50, @@ -695,7 +780,12 @@ static BUILDS: &[Build] = &[ 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, @@ -723,6 +813,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -737,7 +828,12 @@ static BUILDS: &[Build] = &[ 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, @@ -765,6 +861,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x10), parent: 0x24, nested_in: Some(0x28), name: 0x34, @@ -779,7 +876,12 @@ static BUILDS: &[Build] = &[ 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, @@ -807,6 +909,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -821,7 +924,12 @@ static BUILDS: &[Build] = &[ 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, @@ -849,6 +957,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -863,7 +972,12 @@ static BUILDS: &[Build] = &[ 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, @@ -891,6 +1005,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -905,7 +1020,12 @@ static BUILDS: &[Build] = &[ 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, @@ -933,6 +1053,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -947,7 +1068,12 @@ static BUILDS: &[Build] = &[ 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, @@ -977,6 +1103,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x10), parent: 0x24, nested_in: Some(0x28), name: 0x30, @@ -991,7 +1118,12 @@ static BUILDS: &[Build] = &[ 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, @@ -1019,6 +1151,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -1033,7 +1166,12 @@ static BUILDS: &[Build] = &[ 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, @@ -1061,6 +1199,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -1075,7 +1214,12 @@ static BUILDS: &[Build] = &[ 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, @@ -1103,6 +1247,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -1117,7 +1262,12 @@ static BUILDS: &[Build] = &[ 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, @@ -1145,6 +1295,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -1159,7 +1310,12 @@ static BUILDS: &[Build] = &[ 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, @@ -1269,6 +1425,13 @@ mod tests { 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_)); } } diff --git a/src/game_engine/unity/mono/collections_tests.rs b/src/game_engine/unity/mono/collections_tests.rs new file mode 100644 index 00000000..afed3c13 --- /dev/null +++ b/src/game_engine/unity/mono/collections_tests.rs @@ -0,0 +1,197 @@ +//! 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); + + i +} + +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()); + }); +} diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index b2a4768d..4b539468 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -27,11 +27,13 @@ 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; -pub use super::managed::ListOffsets; +pub use super::managed::{DictionaryOffsets, ListOffsets}; use super::{managed, BinaryFormat}; /// Represents access to a Unity game that is using the standard Mono backend. @@ -283,6 +285,8 @@ impl Module { 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(), @@ -299,10 +303,12 @@ impl Module { 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(), }, @@ -382,6 +388,26 @@ impl Module { 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 `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 @@ -459,4 +485,19 @@ impl Module { 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 bfee22cf..1ab29a2a 100644 --- a/src/game_engine/unity/mono/offsets.rs +++ b/src/game_engine/unity/mono/offsets.rs @@ -7,6 +7,7 @@ pub(super) struct MonoOffsets { 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, } @@ -32,7 +33,8 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { - class_kind: Some(0x1B), // 2021.3 through 6000.7 + class_kind: Some(0x1B), // 2021.3 through 6000.7 + instance_size: Some(0x1C), // 2021.3 through 6000.7 parent: 0x30, nested_in: Some(0x38), // 2021.3 through 6000.7 name: 0x48, @@ -47,7 +49,12 @@ impl MonoOffsets { 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, @@ -68,7 +75,8 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { - class_kind: Some(0xF), // 2021.3 through 6000.7 + class_kind: Some(0xF), // 2021.3 through 6000.7 + instance_size: Some(0x10), // 2021.3 through 6000.7 parent: 0x20, nested_in: Some(0x24), // 2021.3 through 6000.7 name: 0x2C, @@ -83,7 +91,12 @@ impl MonoOffsets { 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, @@ -104,7 +117,8 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { - class_kind: Some(0x2A), // 2017.4 through 2020.1 + class_kind: Some(0x2A), // 2017.4 through 2020.1 + instance_size: Some(0x1C), // 2017.4 through 2020.1 parent: 0x30, nested_in: Some(0x38), // 2017.4 through 2020.1 name: 0x48, @@ -119,7 +133,12 @@ impl MonoOffsets { 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, @@ -140,7 +159,8 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { - class_kind: Some(0x1E), // 2017.4 through 2020.1 + class_kind: Some(0x1E), // 2017.4 through 2020.1 + instance_size: Some(0x10), // 2017.4 through 2020.1 parent: 0x20, nested_in: Some(0x24), // 2017.4 through 2020.1 name: 0x2C, @@ -155,7 +175,12 @@ impl MonoOffsets { 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, @@ -177,6 +202,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x1C), // 2017.4, 2018.4 parent: 0x30, nested_in: None, name: 0x50, @@ -191,7 +217,12 @@ impl MonoOffsets { 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, @@ -213,6 +244,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x10), // 2017.4, 2018.4 parent: 0x24, nested_in: None, name: 0x34, @@ -227,7 +259,12 @@ impl MonoOffsets { 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, @@ -249,6 +286,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x30, nested_in: Some(0x38), // 5.6 through 2018.4 name: 0x48, @@ -263,7 +301,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -285,6 +328,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x24, nested_in: Some(0x28), // 5.6 through 2018.4 name: 0x30, @@ -299,7 +343,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x4, offset: 0xC, alignment: 0x10, @@ -322,6 +371,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x28, nested_in: None, name: 0x40, @@ -336,7 +386,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -360,6 +415,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x28, nested_in: None, name: 0x40, @@ -374,7 +430,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -398,6 +459,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x28, nested_in: None, name: 0x48, @@ -412,7 +474,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -436,6 +503,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x28, nested_in: None, name: 0x40, @@ -450,7 +518,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -482,6 +555,7 @@ pub(super) struct HashTableOffsets { pub(super) struct ClassOffsets { pub(super) class_kind: Option, // The byte whose low bits say what kind of class it is + pub(super) instance_size: Option, // What one instance occupies, boxed header included pub(super) parent: u8, pub(super) nested_in: Option, // Where a class keeps the one it is nested in pub(super) name: u8, @@ -493,6 +567,12 @@ 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 { @@ -501,6 +581,7 @@ pub(super) struct GenericOffsets { } 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/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index 60368385..f9a6c416 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -5,7 +5,7 @@ use super::offsets::{ AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, - ImageOffsets, MonoVTableOffsets, + ImageOffsets, MonoVTableOffsets, TypeOffsets, }; use super::{builds, BinaryFormat, Module, MonoOffsets, UnityPointer, Version}; use crate::file_format::pe::DebugId; @@ -359,6 +359,7 @@ fn nested_lookups_without_a_measured_offset_answer_nothing() { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x30, nested_in: None, name: 0x48, @@ -373,7 +374,12 @@ fn nested_lookups_without_a_measured_offset_answer_nothing() { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, From fd6bb49e15c6c04a53047d7dd48fc0b9561ef61c Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 17:09:27 +0200 Subject: [PATCH 24/24] add dictionary reads --- .../unity/il2cpp/collections_tests.rs | 29 +++++ src/game_engine/unity/il2cpp/mod.rs | 18 +++ src/game_engine/unity/managed/mod.rs | 3 +- src/game_engine/unity/managed/readers.rs | 112 +++++++++++++++++- .../unity/mono/collections_tests.rs | 86 ++++++++++++++ src/game_engine/unity/mono/mod.rs | 18 +++ 6 files changed, 264 insertions(+), 2 deletions(-) diff --git a/src/game_engine/unity/il2cpp/collections_tests.rs b/src/game_engine/unity/il2cpp/collections_tests.rs index ceb90308..750cc09a 100644 --- a/src/game_engine/unity/il2cpp/collections_tests.rs +++ b/src/game_engine/unity/il2cpp/collections_tests.rs @@ -81,6 +81,23 @@ fn image(version: Version) -> Vec { 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 } @@ -120,6 +137,18 @@ fn dictionaries_resolve_through_the_cached_class() { // 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| { diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index c1e99c58..df48d99b 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -309,6 +309,24 @@ impl Module { 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 { diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 07978e0e..0a513d85 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -15,7 +15,8 @@ mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; pub use readers::{ - read_array, read_list, read_string, DictionaryOffsets, EntryLayout, ListOffsets, + read_array, read_dictionary, read_list, read_string, DictionaryOffsets, EntryLayout, + ListOffsets, }; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; pub use walk::Walk; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs index d3f548dd..73e9289a 100644 --- a/src/game_engine/unity/managed/readers.rs +++ b/src/game_engine/unity/managed/readers.rs @@ -1,6 +1,6 @@ use arrayvec::ArrayVec; use bytemuck::CheckedBitPattern; -use core::mem::MaybeUninit; +use core::mem::{size_of, MaybeUninit}; use crate::{string::ArrayWString, Address, Error, PointerSize, Process}; @@ -130,6 +130,116 @@ pub fn read_list( 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. diff --git a/src/game_engine/unity/mono/collections_tests.rs b/src/game_engine/unity/mono/collections_tests.rs index afed3c13..4b529fd5 100644 --- a/src/game_engine/unity/mono/collections_tests.rs +++ b/src/game_engine/unity/mono/collections_tests.rs @@ -128,9 +128,39 @@ fn image() -> Vec { 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), @@ -195,3 +225,59 @@ fn parallel_shape_names_answer_nothing() { .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/mod.rs b/src/game_engine/unity/mono/mod.rs index 4b539468..c3e9a585 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -408,6 +408,24 @@ impl Module { 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