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]