Skip to content

Mono: expose a class's vtable, and resolve a class from an object - #200

Open
Janzert wants to merge 2 commits into
LiveSplit:masterfrom
Janzert:mono-class-handles
Open

Janzert wants to merge 2 commits into
LiveSplit:masterfrom
Janzert:mono-class-handles

Conversation

@Janzert

@Janzert Janzert commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

Two accessors on mono::Class, for games where the object of interest cannot be reached by walking static fields. Games built around dependency injection often have no static roots at all, which leaves UnityPointer inapplicable. In Timberborn, the game this came from, every static field in every game assembly is a constant, an enum member or a key. The way in is to scan the heap for the instance of a class, and these make that possible.

This replaces #157, which was written against the Mono code before it moved into managed/ and no longer applies. Rebuilt on the new walk, both are now mostly exposure of what the crate already does internally.

Class::get_vtable

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 what a scan can match on.

The Mono arm of Runtime::static_table already resolved the vtable before walking on to the static table. That prefix is now MonoRuntime::vtable_address, which static_table calls, so the diff is an extraction rather than new lookup code.

Class::of_object

The other direction: an object found by scanning resolves to a Class that can then be read by field name. It is a public face for Walk::object_class, which the scene manager already uses to name components.

This is the only route to a generic instantiation. HashSet<string> and List<Foo> 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. Every instance carries a pointer to its own though.

One gotcha that cost me some debugging, so it's in the doc comment as well: Mono fills a class's field table in lazily, and for an inflated generic nothing necessarily has. 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. The identical lookup succeeded against 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.

Tests

One test each in mono/walk_tests.rs, on the existing hand-laid fixture:

  • vtables_resolve_through_the_runtime_info: GameManager's vtable is the one its statics are found through, and a class with no runtime info has none.
  • objects_resolve_to_their_class: the live GameManager instance resolves to a class whose points field reads back 777, and an object with a null vtable pointer resolves to nothing.

The #157 versions of both have run against a shipping Unity 6000.3 game, on Windows and under Proton on Linux, in an auto splitter that depends on them: get_vtable to locate services by heap scan, and of_object to resolve List<T> and HashSet<string> fields by name, checked against known element counts. These versions have since run a full session of the same game, now on Unity 6000.5, under Proton.

cargo test --all-features, cargo fmt --check and the three wasm builds pass; clippy reports nothing new over master.

🤖 Generated with Claude Code

Janzert and others added 2 commits October 15, 2026 03:12
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 <noreply@anthropic.com>
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<string> and
List<Foo> 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 <noreply@anthropic.com>
Janzert added a commit to Janzert/timberborn_autosplitter that referenced this pull request Oct 17, 2026
#157 was written against the Mono code before upstream moved it into
`managed/`, and no longer applied. It is closed, and #200 replaces it
from the `mono-class-handles` branch, with a test per accessor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant