From 4fb6690279a37f783e3feccf04e5954de6904a75 Mon Sep 17 00:00:00 2001 From: janzert Date: Wed, 2 Sep 2026 02:59:28 -0400 Subject: [PATCH 1/2] Expose a Mono class's vtable address A managed object begins with a pointer to the vtable of its class, so the vtable address doubles as an identity handle: an object is an instance of this exact class if and only if its first word equals it. That is the only handle available when the object cannot be reached by walking static fields. Games built around constructor-injection dependency injection often have no static roots at all -- every static being a constant, an enum member or a key -- which leaves UnityPointer inapplicable and scanning the heap for a class's instance the only way in. The lookup is not new code so much as code that was already here: the Mono arm of Runtime::static_table resolved the vtable and then walked on to the static table, so it now calls this and keeps only the part that is its own. Co-Authored-By: Claude Opus 5 --- src/game_engine/unity/managed/runtime.rs | 29 ++++++++++++++++-------- src/game_engine/unity/mono/class.rs | 21 ++++++++++++++++- src/game_engine/unity/mono/walk_tests.rs | 19 +++++++++++++++- 3 files changed, 58 insertions(+), 11 deletions(-) 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..ecb237f6 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,25 @@ impl Class { .await } + /// 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..6a198963 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,23 @@ 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 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] From 2ab3b9fcf3d028de4cf1750f09a04edf64870bf3 Mon Sep 17 00:00:00 2001 From: janzert Date: Wed, 2 Sep 2026 02:59:57 -0400 Subject: [PATCH 2/2] Resolve the class of a Mono object found in memory An object begins with a pointer to its class's vtable and a vtable begins with a pointer to its class, so an object found by scanning can be resolved back to a Class and read by field name. This is the only route to a generic instantiation. HashSet and List are each a distinct class with their own field offsets, and none of them appears in an Image's class cache under a name that can be looked up -- but every instance carries a pointer to its own. Documented with the trap that comes with it: Mono fills a class's field table in lazily, and for an inflated generic nothing necessarily has, so get_field_offset can return None for every field of a class resolved this way against an object that is plainly an instance of it. Measured against a live game, where the identical lookup succeeded in a second process running the same build, so it is runtime state and no version check can predict it. Callers that must not fail on a collection need a fallback to the known layout. Co-Authored-By: Claude Opus 5 --- src/game_engine/unity/mono/class.rs | 30 ++++++++++++++++++++++++ src/game_engine/unity/mono/walk_tests.rs | 14 +++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/game_engine/unity/mono/class.rs b/src/game_engine/unity/mono/class.rs index ecb237f6..08d0175d 100644 --- a/src/game_engine/unity/mono/class.rs +++ b/src/game_engine/unity/mono/class.rs @@ -51,6 +51,36 @@ 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, diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index 6a198963..433c740e 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -588,6 +588,20 @@ fn vtables_resolve_through_the_runtime_info() { }); } +// 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]