From 7b9f547a8d8ee184932b3c47df86dc7677665816 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: get_static_table_pointer 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/mono/class.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/game_engine/unity/mono/class.rs b/src/game_engine/unity/mono/class.rs index 2ffe93c..676158a 100644 --- a/src/game_engine/unity/mono/class.rs +++ b/src/game_engine/unity/mono/class.rs @@ -135,7 +135,19 @@ impl Class { .await } - fn get_static_table_pointer(&self, process: &Process, module: &Module) -> Option
{ + /// 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 runtime_info = process .read_pointer( self.class + module.offsets.class.runtime_info, @@ -144,10 +156,14 @@ impl Class { .ok() .filter(|addr| !addr.is_null())?; - let mut vtables = process + process .read_pointer(runtime_info + module.size_of_ptr(), module.pointer_size) .ok() - .filter(|addr| !addr.is_null())?; + .filter(|addr| !addr.is_null()) + } + + fn get_static_table_pointer(&self, process: &Process, module: &Module) -> Option
{ + let mut vtables = self.get_vtable(process, module)?; // Mono V1 behaves differently when it comes to recover the static table match module.version { From f970d5fe292712ae18212bffd6c69a746378c283 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 | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/game_engine/unity/mono/class.rs b/src/game_engine/unity/mono/class.rs index 676158a..2d302eb 100644 --- a/src/game_engine/unity/mono/class.rs +++ b/src/game_engine/unity/mono/class.rs @@ -135,6 +135,43 @@ 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 { + // `MonoVTable::klass` is the first member in every version Mono has + // shipped, so unlike the other offsets in this module it needs no + // version table. It is the same invariant an object's own header + // relies on. + let vtable = process + .read_pointer(object, module.pointer_size) + .ok() + .filter(|addr| !addr.is_null())?; + let class = process + .read_pointer(vtable, module.pointer_size) + .ok() + .filter(|addr| !addr.is_null())?; + Some(Self { class }) + } + /// 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,