From d764a058f7426cbe1ba1167801c64be603455aa6 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 07:58:33 +0200 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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)