Mono: expose a class's vtable, and resolve a class from an object - #157
Open
Janzert wants to merge 2 commits into
Open
Mono: expose a class's vtable, and resolve a class from an object#157Janzert wants to merge 2 commits into
Janzert wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
UnityPointerinapplicable. 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_vtableA 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_pointeralready resolved the vtable and then walked on to the static table, so the new method is that prefix, andget_static_table_pointernow calls it.Class::of_objectThe other direction: an object's vtable points back at its class, so an object found by scanning can be resolved to a
Classand read by field name.This is the only route to a generic instantiation.
HashSet<string>andList<Foo>are each a distinct class with their own field offsets, and none of them appears in anImage'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_offsetcan returnNonefor 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_objectreadsMonoVTable::klassat 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 inMonoOffsetsgenuinely 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.get_vtable— locating services by scanning the heap for instances of their class, which is the only way in on this game.of_object— resolvingList<T>andHashSet<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.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