diff --git a/src/game_engine/unity/managed/runtime.rs b/src/game_engine/unity/managed/runtime.rs index 51fe0f3a..17580075 100644 --- a/src/game_engine/unity/managed/runtime.rs +++ b/src/game_engine/unity/managed/runtime.rs @@ -76,6 +76,25 @@ pub struct Il2CppRuntime { } impl MonoRuntime { + /// Reads the address of a class's vtable in the first domain, which every + /// instance of the class begins with a pointer to. + pub fn vtable_address( + &self, + process: &Process, + pointer_size: PointerSize, + class: ClassRef, + ) -> Option
{ + let runtime_info = process + .read_pointer(class.address + self.runtime_info, pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + process + .read_pointer(runtime_info + pointer_size as u64, pointer_size) + .ok() + .filter(|address| !address.is_null()) + } + // 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. @@ -174,15 +193,7 @@ impl Runtime { ) -> 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())?; + let vtables = mono.vtable_address(process, pointer_size, class)?; if mono.statics_in_vtable_data { vtables + mono.vtable_size diff --git a/src/game_engine/unity/mono/class.rs b/src/game_engine/unity/mono/class.rs index a07bb6db..08d0175d 100644 --- a/src/game_engine/unity/mono/class.rs +++ b/src/game_engine/unity/mono/class.rs @@ -1,4 +1,4 @@ -use super::super::managed::ClassRef; +use super::super::managed::{self, ClassRef}; use super::Module; use crate::{future::retry, Address, Process}; @@ -51,6 +51,55 @@ impl Class { .await } + /// Reads the class of an object found in memory. + /// + /// Every managed object begins with a pointer to its class's vtable, and a + /// vtable begins with a pointer to its class, so an object can be resolved + /// back to a [`Class`] whose fields are then available by name. + /// + /// This is the only way to reach a generic instantiation such as + /// `HashSet`. Each instantiation is a distinct class with its own + /// field offsets, and none of them can be looked up in an + /// [`Image`](super::Image) by name -- but any instance points at its own. + /// + /// # Inflated generics may have no field names + /// + /// Mono fills a class's field table in lazily, and for an inflated generic + /// nothing necessarily has. [`get_field_offset`](Self::get_field_offset) + /// can therefore return [`None`] for every field of a class resolved this + /// way, against an object that is plainly an instance of it -- and the + /// same lookup may succeed against another process running the same build, + /// so it is runtime state rather than anything a version check could + /// predict. Code that must not fail on a collection needs a fallback to + /// the known layout, guarded by a check that the object agrees with it. + pub fn of_object(process: &Process, module: &Module, object: Address) -> Option { + module + .walk() + .object_class(process, object) + .map(|class| Class { + class: class.address, + }) + } + + /// Returns the address of this class's `MonoVTable` in the first domain. + /// + /// Every managed object begins with a pointer to the vtable of its class, + /// so this doubles as an identity handle for the class: an object at + /// `addr` is an instance of this exact class if and only if the pointer at + /// `addr` equals this value. + /// + /// This is useful for games where the object of interest cannot be reached + /// by walking static fields. Games built around constructor-injection + /// dependency injection often have no static roots at all, which makes + /// [`UnityPointer`](super::UnityPointer) inapplicable, and the only way to + /// find a service is to scan the heap for the instance of its class. + pub fn get_vtable(&self, process: &Process, module: &Module) -> Option
{ + let managed::Runtime::Mono(mono) = module.walk().runtime else { + return None; + }; + mono.vtable_address(process, module.pointer_size, ClassRef::new(self.class)) + } + /// 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
{ diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index 5c6c3178..433c740e 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -7,7 +7,7 @@ use super::offsets::{ AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, ImageOffsets, MonoVTableOffsets, TypeOffsets, }; -use super::{profiles, Library, Module, Profile, UnityPointer}; +use super::{profiles, Class, Library, Module, Profile, UnityPointer}; use crate::runtime::mock::{poll_once, with_process}; use crate::{Address, PointerSize, Process}; @@ -571,6 +571,37 @@ fn statics_resolve_through_the_vtable() { }); } +// The vtable is the one the statics were found through, and a class whose +// runtime info is unset has none. +#[test] +fn vtables_resolve_through_the_runtime_info() { + on_fixture(measured(), |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_vtable(process, module), + Some(Address::new(BASE + 0x1700)), + ); + + let outer = image.get_class(process, module, "Game.Outer").unwrap(); + assert!(outer.get_vtable(process, module).is_none()); + }); +} + +// A live object resolves to a class whose fields read by name, and an object +// whose vtable pointer is null resolves to nothing. +#[test] +fn objects_resolve_to_their_class() { + on_fixture(measured(), |process, module| { + let object = Address::new(BASE + 0x1900); + let class = Class::of_object(process, module, object).unwrap(); + let points = class.get_field_offset(process, module, "points").unwrap(); + assert_eq!(process.read::(object + points).ok(), Some(777)); + + assert!(Class::of_object(process, module, Address::new(BASE + 0x1908)).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]