Skip to content

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

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

Mono: expose a class's vtable, and resolve a class from an object#157
Janzert wants to merge 2 commits into
LiveSplit:masterfrom
Janzert:mono-class-vtable

Conversation

@Janzert

@Janzert Janzert commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Two accessors on mono::Class, and a small dedup that falls out of the first.

Both exist to support 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.

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.

This is less new code than it looks. get_static_table_pointer already resolved the vtable and then walked on to the static table, so the new method is that prefix, and get_static_table_pointer now calls it.

Class::of_object

The other direction: an object's vtable points back at its class, so an object found by scanning can be resolved 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. 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.

Notes for review

  • of_object reads MonoVTable::klass at offset 0 rather than adding it to the version tables. It is the first member in every version Mono has shipped, and every other entry in MonoOffsets genuinely varies by version, so a constant-zero field in all twelve tables looked like more noise than documentation. Happy to put it in the table if you would rather.
  • No tests: both need a live process, and the crate has no harness for that. They have instead been exercised against a shipping Unity 6000.3.6f1 game, natively on Windows and under Proton on Linux, in an auto splitter that depends on all three paths:
    • get_vtable — locating services by scanning the heap for instances of their class, which is the only way in on this game.
    • of_object — resolving List<T> and HashSet<string> fields by name, checked against known-correct element counts rather than merely not failing. The splitter has a layout fallback for the lazy-field-table case above, and it logs when taken; it was never taken when testing this PR, so the name resolution itself is confirmed rather than masked.
    • the deduped get_static_table_pointer — both of the splitter's static field reads. One yields a value it prints (0.5 in-game hours = 9.6s), so a wrong vtable would surface as a wrong number rather than pass quietly; it matched the pre-refactor value exactly. The other decides whether a scene load is a new game or a restored save, and correctly distinguished the two.

🤖 Code and comments initially written with Claude Code

Janzert and others added 2 commits September 2, 2026 04:13
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 <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 Sep 2, 2026
LiveSplit/asr#157, from mono-class-vtable on the fork.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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