From cb47ec88e1a211525df9579b8d4b910f90700fed Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 19:41:54 +0200 Subject: [PATCH 01/11] add walk parity tests --- src/game_engine/unity/il2cpp/walk_tests.rs | 257 ++++++++++++++++++- src/game_engine/unity/mono/mod.rs | 2 + src/game_engine/unity/mono/walk_tests.rs | 278 +++++++++++++++++++++ 3 files changed, 528 insertions(+), 9 deletions(-) create mode 100644 src/game_engine/unity/mono/walk_tests.rs diff --git a/src/game_engine/unity/il2cpp/walk_tests.rs b/src/game_engine/unity/il2cpp/walk_tests.rs index c94ae41..afb3f7d 100644 --- a/src/game_engine/unity/il2cpp/walk_tests.rs +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -1,10 +1,15 @@ -//! Tests over a hand-laid image of IL2CPP's structures. +//! Tests pinning the walk's behavior over a hand-laid image of IL2CPP's +//! structures, one fixture per lineage: the older one keeps its metadata +//! handle inline in the image, the newer one behind a pointer. The offsets are +//! the literal numbers of the Unity 2019.4 and 6000.3 layouts, copied by hand, +//! so the walk is checked against the layout rather than against itself. -use super::{IL2CPPOffsets, Module, Version}; +use super::{IL2CPPOffsets, Module, UnityPointer, Version}; use crate::runtime::mock::with_process; -use crate::{Address, PointerSize}; +use crate::{Address, PointerSize, Process}; use std::vec; +use std::vec::Vec; const BASE: u64 = 0x20_0000; @@ -439,20 +444,254 @@ fn assembly_names_resolve_through_the_image() { }); } +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +// The target's structures, hand-laid: the assemblies vector, the type info +// definition table sliced by the image's handle, a parent chain reaching a +// UnityEngine class, a static table, and a live object heading with its class. +fn image(version: Version) -> Vec { + let (type_count_at, handle_at, field_count_at) = match version { + Version::V2019 => (0x1C, 0x18, 0x11C), + _ => (0x18, 0x28, 0x124), + }; + + let mut i = vec![0; 0x4000]; + + let strings = [ + (0x2000, "mscorlib"), + (0x2080, "Assembly-CSharp"), + (0x2100, "GameManager"), + (0x2180, "Game"), + (0x2200, "points"), + (0x2280, "Enemy"), + (0x2300, "hp"), + (0x2380, "Boss"), + (0x2400, "phase"), + (0x2480, "MonoBehaviour"), + (0x2500, "UnityEngine"), + (0x2580, "hidden"), + (0x2600, "instance"), + ]; + for (at, text) in strings { + put(&mut i, at, text.as_bytes()); + } + + // The assemblies vector: begin and end of an array of assembly pointers. + ptr(&mut i, 0x0, BASE + 0x40); + ptr(&mut i, 0x8, BASE + 0x50); + ptr(&mut i, 0x40, BASE + 0x80); + ptr(&mut i, 0x48, BASE + 0xC0); + + // Il2CppAssembly: the image at 0x0, the name at 0x18. + ptr(&mut i, 0x80, BASE + 0x140); + ptr(&mut i, 0x80 + 0x18, BASE + 0x2000); + ptr(&mut i, 0xC0, BASE + 0x300); + ptr(&mut i, 0xC0 + 0x18, BASE + 0x2080); + + // The default image: three classes, reached through the handle. The older + // lineage stores the handle inline where the newer one points at it. + put(&mut i, 0x300 + type_count_at, &3_u32.to_le_bytes()); + match version { + Version::V2019 => put(&mut i, 0x300 + handle_at, &5_u32.to_le_bytes()), + _ => { + ptr(&mut i, 0x300 + handle_at, BASE + 0x400); + put(&mut i, 0x400, &5_u32.to_le_bytes()); + } + } + + // The type info definition table global, and the image's slice of it. + ptr(&mut i, 0x10, BASE + 0x480); + ptr(&mut i, 0x480 + 8 * 5, BASE + 0x600); + ptr(&mut i, 0x480 + 8 * 6, BASE + 0x800); + ptr(&mut i, 0x480 + 8 * 7, BASE + 0xA00); + + // Il2CppClass: name 0x10, namespace 0x18, parent 0x58, fields 0x80, + // static_fields 0xB8, field_count where the lineage keeps it. Field + // entries stride 0x20 with the name at 0x0 and the offset at 0x18. + + // GameManager, deriving from MonoBehaviour, with a static slot and an + // instance field. + let game_manager = 0x600; + ptr(&mut i, game_manager + 0x10, BASE + 0x2100); + ptr(&mut i, game_manager + 0x18, BASE + 0x2180); + ptr(&mut i, game_manager + 0x58, BASE + 0xC00); + ptr(&mut i, game_manager + 0x80, BASE + 0xE00); + ptr(&mut i, game_manager + 0xB8, BASE + 0xF40); + put(&mut i, game_manager + field_count_at, &2_u16.to_le_bytes()); + ptr(&mut i, 0xE00, BASE + 0x2600); // instance + put(&mut i, 0xE00 + 0x18, &0_i32.to_le_bytes()); + ptr(&mut i, 0xE20, BASE + 0x2200); // points + put(&mut i, 0xE20 + 0x18, &0x20_i32.to_le_bytes()); + + // Enemy, and Boss deriving from it. + let enemy = 0x800; + ptr(&mut i, enemy + 0x10, BASE + 0x2280); + ptr(&mut i, enemy + 0x18, BASE + 0x2180); + ptr(&mut i, enemy + 0x80, BASE + 0xE80); + put(&mut i, enemy + field_count_at, &1_u16.to_le_bytes()); + ptr(&mut i, 0xE80, BASE + 0x2300); // hp + put(&mut i, 0xE80 + 0x18, &0x10_i32.to_le_bytes()); + + let boss = 0xA00; + ptr(&mut i, boss + 0x10, BASE + 0x2380); + ptr(&mut i, boss + 0x18, BASE + 0x2180); + ptr(&mut i, boss + 0x58, BASE + enemy); + ptr(&mut i, boss + 0x80, BASE + 0xEC0); + put(&mut i, boss + field_count_at, &1_u16.to_le_bytes()); + ptr(&mut i, 0xEC0, BASE + 0x2400); // phase + put(&mut i, 0xEC0 + 0x18, &0x18_i32.to_le_bytes()); + + // MonoBehaviour in UnityEngine, holding a field the climb must never + // reach. + let mono_behaviour = 0xC00; + ptr(&mut i, mono_behaviour + 0x10, BASE + 0x2480); + ptr(&mut i, mono_behaviour + 0x18, BASE + 0x2500); + ptr(&mut i, mono_behaviour + 0x80, BASE + 0xF00); + put( + &mut i, + mono_behaviour + field_count_at, + &1_u16.to_le_bytes(), + ); + ptr(&mut i, 0xF00, BASE + 0x2580); // hidden + put(&mut i, 0xF00 + 0x18, &0x30_i32.to_le_bytes()); + + // GameManager's statics hold the live instance, which heads with its + // class. + ptr(&mut i, 0xF40, BASE + 0xF80); + ptr(&mut i, 0xF80, BASE + game_manager); + put(&mut i, 0xF80 + 0x20, &888_u32.to_le_bytes()); + + i +} + +fn module(version: Version) -> Module { + Module { + assemblies: Address::new(BASE), + type_info_definition_table: Address::new(BASE + 0x10), + version, + offsets: IL2CPPOffsets::new(version, PointerSize::Bit64).unwrap(), + pointer_size: PointerSize::Bit64, + } +} + +fn on_fixture(version: Version, test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image(version))], |process| { + test(process, &module(version)); + }); +} + +#[test] +fn images_resolve_by_name_in_both_lineages() { + for version in [Version::V2019, Version::V2022] { + on_fixture(version, |process, module| { + assert!(module.get_default_image(process).is_some()); + assert!(module.get_image(process, "mscorlib").is_some()); + assert!(module.get_image(process, "Assembly-DoesNotExist").is_none()); + }); + } +} + +#[test] +fn classes_resolve_by_name_and_namespace() { + for version in [Version::V2019, Version::V2022] { + on_fixture(version, |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image.get_class(process, module, "GameManager").is_some()); + assert!(image.get_class(process, module, "Game.Boss").is_some()); + assert!(image.get_class(process, module, "Wrong.Boss").is_none()); + assert!(image.get_class(process, module, "Nothing").is_none()); + assert_eq!(image.classes(process, module).count(), 3); + }); + } +} + +#[test] +fn field_offsets_resolve_declared_and_inherited() { + on_fixture(Version::V2022, |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_field_offset(process, module, "points"), + Some(0x20), + ); + + let boss = image.get_class(process, module, "Boss").unwrap(); + assert_eq!(boss.get_field_offset(process, module, "phase"), Some(0x18)); + assert_eq!(boss.get_field_offset(process, module, "hp"), Some(0x10)); + }); +} + +// The climb stops at UnityEngine's namespace, so an engine field never +// resolves. +#[test] +fn field_climbs_stop_at_the_engine() { + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + let game_manager = image.get_class(process, module, "GameManager").unwrap(); + assert!(game_manager + .get_field_offset(process, module, "hidden") + .is_none()); + }); +} + +#[test] +fn statics_resolve_from_the_class() { + on_fixture(Version::V2022, |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_static_table(process, module), + Some(Address::new(BASE + 0xF40)), + ); + }); +} + +// The whole pointer path: the static root, the instance behind it, and a field +// resolved against the object's own class read off its head. +#[test] +fn pointers_dereference_through_a_static_root() { + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + let pointer = UnityPointer::<2>::new("GameManager", 0, &["instance", "points"]); + assert_eq!(pointer.deref::(process, module, &image).unwrap(), 888); + }); +} + +// The public shapes the carve must not change. +#[test] +fn public_types_keep_their_properties() { + fn is_copy() {} + fn double_ended<'a>( + iter: impl DoubleEndedIterator + 'a, + ) -> impl DoubleEndedIterator + 'a { + iter + } + + is_copy::(); + is_copy::(); + + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + let _ = double_ended(image.classes(process, module)); + }); +} + // A 32 bit target lays the assemblies vector and its pointers at four bytes. #[test] fn images_resolve_on_32_bit_targets() { let mut i = vec![0; 0x1000]; - let ptr = |i: &mut [u8], at: u64, target: u64| { + let narrow = |i: &mut [u8], at: u64, target: u64| { put(i, at, &(target as u32).to_le_bytes()); }; put(&mut i, 0x800, b"Assembly-CSharp"); - ptr(&mut i, 0x0, BASE + 0x40); // the vector's begin - ptr(&mut i, 0x4, BASE + 0x44); // and end, one assembly along - ptr(&mut i, 0x40, BASE + 0x80); - ptr(&mut i, 0x80, BASE + 0x100); // Il2CppAssembly.image - ptr(&mut i, 0x80 + 0x18, BASE + 0x800); // Il2CppAssembly.aname + narrow(&mut i, 0x0, BASE + 0x40); // the vector's begin + narrow(&mut i, 0x4, BASE + 0x44); // and end, one assembly along + narrow(&mut i, 0x40, BASE + 0x80); + narrow(&mut i, 0x80, BASE + 0x100); // Il2CppAssembly.image + narrow(&mut i, 0x80 + 0x18, BASE + 0x800); // Il2CppAssembly.aname with_process(&[(BASE, &i)], |process| { let module = Module { diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index 6e01955..7783ba6 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -27,6 +27,8 @@ mod pointer; pub use pointer::UnityPointer; mod offsets; use offsets::MonoOffsets; +#[cfg(all(test, not(target_family = "wasm")))] +mod walk_tests; use super::{BinaryFormat, CSTR}; diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs new file mode 100644 index 0000000..736c01d --- /dev/null +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -0,0 +1,278 @@ +//! Tests pinning the walk's behavior over a hand-laid image of mono's +//! structures. The fixture is written at the literal offsets of the Unity +//! 2019.4 x64 runtime, copied by hand, so the walk is checked against the +//! layout rather than against itself. + +use super::{builds, BinaryFormat, Module, MonoOffsets, UnityPointer, Version}; +use crate::file_format::pe::DebugId; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x10_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +// The target's structures, hand-laid. Two assemblies whose GList the walk +// follows, a class cache of two buckets with one chained class, a parent chain +// reaching a UnityEngine class, a static table reachable through the vtable, +// and a live object carrying its class through its vtable. +fn image() -> Vec { + let mut i = vec![0; 0x4000]; + + // Strings, each 0x80 apart so a 128-byte name read stays in bounds. + let strings = [ + (0x2000, "mscorlib"), + (0x2080, "Assembly-CSharp"), + (0x2100, "GameManager"), + (0x2180, "Game"), + (0x2200, "points"), + (0x2280, "k__BackingField"), + (0x2300, "Enemy"), + (0x2380, "hp"), + (0x2400, "Boss"), + (0x2480, "phase"), + (0x2500, "MonoBehaviour"), + (0x2580, "UnityEngine"), + (0x2600, "hidden"), + (0x2680, "instance"), + ]; + for (at, text) in strings { + put(&mut i, at, text.as_bytes()); + } + + // The loaded-assemblies global and its GList: mscorlib first, then the + // default image. + ptr(&mut i, 0x0, BASE + 0x10); + ptr(&mut i, 0x10, BASE + 0x40); // node 1: data + ptr(&mut i, 0x18, BASE + 0x20); // node 1: next + ptr(&mut i, 0x20, BASE + 0xC0); // node 2: data + ptr(&mut i, 0x28, 0); // node 2: next + + // MonoAssembly: the name at 0x10 (the aname route reads the pointer that + // heads MonoAssemblyName), the image at 0x60. + ptr(&mut i, 0x40 + 0x10, BASE + 0x2000); + ptr(&mut i, 0x40 + 0x60, BASE + 0x140); + ptr(&mut i, 0xC0 + 0x10, BASE + 0x2080); + ptr(&mut i, 0xC0 + 0x60, BASE + 0x640); + + // MonoImage: assembly_name at 0x28, class_cache at 0x4C0 with the hash + // table's size at +0x18 and bucket array at +0x20. mscorlib's image stays + // empty; the default image holds two buckets and three classes. + ptr(&mut i, 0x140 + 0x28, BASE + 0x2000); + ptr(&mut i, 0x640 + 0x28, BASE + 0x2080); + put(&mut i, 0x640 + 0x4C0 + 0x18, &2_i32.to_le_bytes()); + ptr(&mut i, 0x640 + 0x4C0 + 0x20, BASE + 0xB40); + ptr(&mut i, 0xB40, BASE + 0xC00); // bucket 0: GameManager + ptr(&mut i, 0xB48, BASE + 0xE00); // bucket 1: Enemy, chaining to Boss (kept) + + // MonoClass: parent 0x30, name 0x48, namespace 0x50, vtable_size 0x5C, + // fields 0x98, runtime_info 0xD0, field_count 0x100, next_class_cache + // 0x108. Field entries stride 0x20 with the name at 0x8 and the offset at + // 0x18. + + // GameManager, deriving from MonoBehaviour, with an instance field, a + // backing field, and a static slot at the head of its field list. + let game_manager = 0xC00; + ptr(&mut i, game_manager + 0x30, BASE + 0x1200); + ptr(&mut i, game_manager + 0x48, BASE + 0x2100); + ptr(&mut i, game_manager + 0x50, BASE + 0x2180); + put(&mut i, game_manager + 0x5C, &5_i32.to_le_bytes()); + ptr(&mut i, game_manager + 0x98, BASE + 0x1400); + ptr(&mut i, game_manager + 0xD0, BASE + 0x1600); + put(&mut i, game_manager + 0x100, &3_i32.to_le_bytes()); + ptr(&mut i, 0x1400 + 0x8, BASE + 0x2680); // instance + put(&mut i, 0x1400 + 0x18, &0_i32.to_le_bytes()); + ptr(&mut i, 0x1420 + 0x8, BASE + 0x2200); // points + put(&mut i, 0x1420 + 0x18, &0x20_i32.to_le_bytes()); + ptr(&mut i, 0x1440 + 0x8, BASE + 0x2280); // k__BackingField + put(&mut i, 0x1440 + 0x18, &0x24_i32.to_le_bytes()); + + // Enemy, with one field and Boss chained behind it in the bucket. + let enemy = 0xE00; + ptr(&mut i, enemy + 0x48, BASE + 0x2300); + ptr(&mut i, enemy + 0x50, BASE + 0x2180); + ptr(&mut i, enemy + 0x98, BASE + 0x1500); + put(&mut i, enemy + 0x100, &1_i32.to_le_bytes()); + ptr(&mut i, enemy + 0x108, BASE + 0x1000); + ptr(&mut i, 0x1500 + 0x8, BASE + 0x2380); // hp + put(&mut i, 0x1500 + 0x18, &0x10_i32.to_le_bytes()); + + // Boss, deriving from Enemy, with one field of its own. + let boss = 0x1000; + ptr(&mut i, boss + 0x30, BASE + enemy); + ptr(&mut i, boss + 0x48, BASE + 0x2400); + ptr(&mut i, boss + 0x50, BASE + 0x2180); + ptr(&mut i, boss + 0x98, BASE + 0x1540); + put(&mut i, boss + 0x100, &1_i32.to_le_bytes()); + ptr(&mut i, 0x1540 + 0x8, BASE + 0x2480); // phase + put(&mut i, 0x1540 + 0x18, &0x18_i32.to_le_bytes()); + + // MonoBehaviour in UnityEngine, holding a field the climb must never + // reach. + let mono_behaviour = 0x1200; + ptr(&mut i, mono_behaviour + 0x48, BASE + 0x2500); + ptr(&mut i, mono_behaviour + 0x50, BASE + 0x2580); + ptr(&mut i, mono_behaviour + 0x98, BASE + 0x1580); + put(&mut i, mono_behaviour + 0x100, &1_i32.to_le_bytes()); + ptr(&mut i, 0x1580 + 0x8, BASE + 0x2600); // hidden + put(&mut i, 0x1580 + 0x18, &0x30_i32.to_le_bytes()); + + // GameManager's statics: runtime_info to the domain vtable, whose static + // slot sits past five method pointers, holding the static table. The + // table's first slot is the live instance. + ptr(&mut i, 0x1600 + 0x8, BASE + 0x1700); + ptr(&mut i, 0x1700 + 0x40 + 8 * 5, BASE + 0x1800); + ptr(&mut i, 0x1800, BASE + 0x1900); + + // The instance object: its vtable heads it, and the vtable's own head is + // the class. The points field holds a recognizable value. + ptr(&mut i, 0x1900, BASE + 0x1A00); + ptr(&mut i, 0x1A00, BASE + game_manager); + put(&mut i, 0x1900 + 0x20, &777_u32.to_le_bytes()); + + i +} + +fn module(offsets: &'static MonoOffsets) -> Module { + Module { + assemblies: Address::new(BASE), + version: Version::V2, + offsets, + pointer_size: PointerSize::Bit64, + } +} + +fn era() -> &'static MonoOffsets { + MonoOffsets::new(Version::V2, PointerSize::Bit64, BinaryFormat::PE).unwrap() +} + +fn measured() -> &'static MonoOffsets { + // The 2019.4 x64 build the fixture is laid at. + let stored = [ + 0xC7, 0xAA, 0x10, 0x77, 0x5A, 0x31, 0x30, 0x4D, 0xA7, 0x7A, 0x08, 0x07, 0x29, 0x69, 0x66, + 0xF6, + ]; + &builds::find(&DebugId { + guid: stored, + age: 1, + }) + .unwrap() + .offsets +} + +fn on_fixture(offsets: &'static MonoOffsets, test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image())], |process| { + test(process, &module(offsets)); + }); +} + +#[test] +fn images_resolve_by_name_through_both_routes() { + for offsets in [era(), measured()] { + on_fixture(offsets, |process, module| { + assert!(module.get_default_image(process).is_some()); + assert!(module.get_image(process, "mscorlib").is_some()); + assert!(module.get_image(process, "Assembly-DoesNotExist").is_none()); + }); + } +} + +#[test] +fn classes_resolve_by_name_and_namespace() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image.get_class(process, module, "GameManager").is_some()); + assert!(image.get_class(process, module, "Game.Boss").is_some()); + assert!(image.get_class(process, module, "Wrong.Boss").is_none()); + assert!(image.get_class(process, module, "Nothing").is_none()); + assert_eq!(image.classes(process, module).count(), 3); + }); +} + +#[test] +fn field_offsets_resolve_declared_inherited_and_backing() { + on_fixture(era(), |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_field_offset(process, module, "points"), + Some(0x20), + ); + assert_eq!( + game_manager.get_field_offset(process, module, "Health"), + Some(0x24), + ); + + let boss = image.get_class(process, module, "Boss").unwrap(); + assert_eq!(boss.get_field_offset(process, module, "phase"), Some(0x18)); + assert_eq!(boss.get_field_offset(process, module, "hp"), Some(0x10)); + }); +} + +// The climb stops at UnityEngine's namespace, so an engine field never +// resolves. +#[test] +fn field_climbs_stop_at_the_engine() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + let game_manager = image.get_class(process, module, "GameManager").unwrap(); + assert!(game_manager + .get_field_offset(process, module, "hidden") + .is_none()); + }); +} + +#[test] +fn statics_resolve_through_the_vtable() { + on_fixture(era(), |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_static_table(process, module), + Some(Address::new(BASE + 0x1800)), + ); + + let boss = image.get_class(process, module, "Boss").unwrap(); + assert!(boss.get_static_table(process, module).is_none()); + }); +} + +// The whole pointer path: the static root, the instance behind it, and a field +// resolved against the object's own class read through its vtable. +#[test] +fn pointers_dereference_through_a_static_root() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + let pointer = UnityPointer::<2>::new("GameManager", 0, &["instance", "points"]); + assert_eq!(pointer.deref::(process, module, &image).unwrap(), 777,); + }); +} + +// The public shapes the carve must not change. +#[test] +fn public_types_keep_their_properties() { + fn is_copy() {} + fn fused<'a>( + iter: impl core::iter::FusedIterator + 'a, + ) -> impl core::iter::FusedIterator + 'a { + iter + } + + is_copy::(); + is_copy::(); + + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + let _ = fused(image.classes(process, module)); + }); +} From 31ada87470e0e4374951dd45683a05747c012bda Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 21:29:54 +0200 Subject: [PATCH 02/11] carve shared walk out of both backends --- src/game_engine/unity/il2cpp/assembly.rs | 41 ---- src/game_engine/unity/il2cpp/class.rs | 127 ++-------- src/game_engine/unity/il2cpp/field.rs | 24 -- src/game_engine/unity/il2cpp/image.rs | 77 ++---- src/game_engine/unity/il2cpp/mod.rs | 81 +++---- src/game_engine/unity/il2cpp/pointer.rs | 135 +---------- src/game_engine/unity/managed/cursor.rs | 283 +++++++++++++++++++++++ src/game_engine/unity/managed/mod.rs | 121 ++++++++++ src/game_engine/unity/managed/pointer.rs | 143 ++++++++++++ src/game_engine/unity/managed/runtime.rs | 148 ++++++++++++ src/game_engine/unity/managed/walk.rs | 206 +++++++++++++++++ src/game_engine/unity/mod.rs | 1 + src/game_engine/unity/mono/assembly.rs | 41 ---- src/game_engine/unity/mono/class.rs | 154 ++---------- src/game_engine/unity/mono/field.rs | 23 -- src/game_engine/unity/mono/image.rs | 72 ++---- src/game_engine/unity/mono/mod.rs | 82 +++---- src/game_engine/unity/mono/pointer.rs | 135 +---------- 18 files changed, 1062 insertions(+), 832 deletions(-) delete mode 100644 src/game_engine/unity/il2cpp/assembly.rs delete mode 100644 src/game_engine/unity/il2cpp/field.rs create mode 100644 src/game_engine/unity/managed/cursor.rs create mode 100644 src/game_engine/unity/managed/mod.rs create mode 100644 src/game_engine/unity/managed/pointer.rs create mode 100644 src/game_engine/unity/managed/runtime.rs create mode 100644 src/game_engine/unity/managed/walk.rs delete mode 100644 src/game_engine/unity/mono/assembly.rs delete mode 100644 src/game_engine/unity/mono/field.rs diff --git a/src/game_engine/unity/il2cpp/assembly.rs b/src/game_engine/unity/il2cpp/assembly.rs deleted file mode 100644 index 28f6584..0000000 --- a/src/game_engine/unity/il2cpp/assembly.rs +++ /dev/null @@ -1,41 +0,0 @@ -use super::{Image, Module}; -use crate::{string::ArrayCString, Address, Error, Process}; - -#[derive(Copy, Clone)] -pub(super) struct Assembly { - pub(super) assembly: Address, -} - -impl Assembly { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - let name = match ( - module.offsets.image.assembly_name, - module.offsets.assembly.aname, - ) { - (Some(assembly_name), _) => { - self.get_image(process, module).ok_or(Error {})?.image + assembly_name - } - (_, Some(aname)) => self.assembly + aname, - _ => return Err(Error {}), - }; - - process - .read_pointer(name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_image(&self, process: &Process, module: &Module) -> Option { - process - .read_pointer( - self.assembly + module.offsets.assembly.image, - module.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null()) - .map(|image| Image { image }) - } -} diff --git a/src/game_engine/unity/il2cpp/class.rs b/src/game_engine/unity/il2cpp/class.rs index 4e1ba9c..2358971 100644 --- a/src/game_engine/unity/il2cpp/class.rs +++ b/src/game_engine/unity/il2cpp/class.rs @@ -1,93 +1,17 @@ -use core::iter::{self, FusedIterator}; - -use super::{super::get_backing_name, Field, Module, CSTR}; -use crate::{future::retry, string::ArrayCString, Address, Error, Process}; +use super::super::managed::ClassRef; +use super::Module; +use crate::{future::retry, Address, Process}; #[cfg(feature = "derive")] pub use asr_derive::Il2cppClass as Class; -/// A .NET class that is part of an [`Image`](Image). +/// A .NET class that is part of an [`Image`](super::Image). #[derive(Copy, Clone)] pub struct Class { pub(super) class: Address, } impl Class { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer(self.class + module.offsets.class.name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_name_space( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer( - self.class + module.offsets.class.namespace, - module.pointer_size, - ) - .and_then(|addr| process.read(addr)) - } - - fn fields<'a>( - &'a self, - process: &'a Process, - module: &'a Module, - ) -> impl FusedIterator + 'a { - let mut this_class = Some(*self); - - iter::from_fn(move || { - let class = this_class?; - - if class - .get_name::(process, module) - .ok()? - .matches("Object") - || class - .get_name_space::(process, module) - .ok()? - .matches("UnityEngine") - { - return None; - } - - // Prepare for next iteration - this_class = class.get_parent(process, module); - - let field_count = process - .read::(class.class + module.offsets.class.field_count) - .ok() - .filter(|&val| val != u16::MAX) - .unwrap_or_default() as u64; - - let fields = match field_count { - 0 => None, - _ => process - .read_pointer( - class.class + module.offsets.class.fields, - module.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null()), - }; - - Some((0..field_count).filter_map(move |i| { - fields.map(|fields| Field { - field: fields + i.wrapping_mul(module.offsets.field.struct_size as _), - }) - })) - }) - .flatten() - .fuse() - } - /// Tries to find a field with the specified name in the class. This returns /// the offset of the field from the start of an instance of the class. If /// it's a static field, the offset will be from the start of the static @@ -98,20 +22,10 @@ impl Class { module: &Module, field_name: &str, ) -> Option { - self.fields(process, module) - .find(|field| { - field.get_name::(process, module).is_ok_and(|name| { - // If the name matches, return immediately - name.matches(field_name) - - // BackingField pattern: k__BackingField - || name.validate_utf8() - .ok() - .and_then(|name| get_backing_name(name)) - .is_some_and(|name| name == field_name) - }) - }) - .and_then(|field| field.get_offset(process, module)) + module + .walk() + .find_field_offset(process, ClassRef::new(self.class), field_name) + .map(|(_, offset)| offset) } /// Tries to find the address of a static instance of the class based on its @@ -137,29 +51,22 @@ impl Class { .await } - fn get_static_table_pointer(&self, module: &Module) -> Address { - self.class + module.offsets.class.static_fields - } - /// 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
{ - process - .read_pointer(self.get_static_table_pointer(module), module.pointer_size) - .ok() - .filter(|val| !val.is_null()) + module + .walk() + .static_table(process, ClassRef::new(self.class)) } /// Tries to find the parent class. pub fn get_parent(&self, process: &Process, module: &Module) -> Option { - process - .read_pointer( - self.class + module.offsets.class.parent, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()) - .map(|class| Class { class }) + module + .walk() + .parent(process, ClassRef::new(self.class)) + .map(|class| Class { + class: class.address, + }) } /// Tries to find a field with the specified name in the class. This returns diff --git a/src/game_engine/unity/il2cpp/field.rs b/src/game_engine/unity/il2cpp/field.rs deleted file mode 100644 index d9a5587..0000000 --- a/src/game_engine/unity/il2cpp/field.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::{string::ArrayCString, Address, Error, Process}; - -use super::Module; - -#[derive(Copy, Clone)] -pub(super) struct Field { - pub(super) field: Address, -} - -impl Field { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer(self.field + module.offsets.field.name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_offset(&self, process: &Process, module: &Module) -> Option { - process.read(self.field + module.offsets.field.offset).ok() - } -} diff --git a/src/game_engine/unity/il2cpp/image.rs b/src/game_engine/unity/il2cpp/image.rs index 970f995..0741b8b 100644 --- a/src/game_engine/unity/il2cpp/image.rs +++ b/src/game_engine/unity/il2cpp/image.rs @@ -1,5 +1,5 @@ -use super::CSTR; -use super::{Class, Module, Version}; +use super::super::managed::{slot, ImageRef}; +use super::{Class, Module}; use crate::{future::retry, Address, Process}; /// An image is a .NET DLL that is loaded by the game. The `Assembly-CSharp` @@ -16,48 +16,18 @@ impl Image { process: &'a Process, module: &'a Module, ) -> impl DoubleEndedIterator + 'a { - let type_count = process - .read::(self.image + module.offsets.image.type_count) - .unwrap_or_default() as u64; - - let metadata_ptr = match (type_count, module.version) { - (0, _) => Address::NULL, - (_, Version::Base | Version::V2019) => { - self.image + module.offsets.image.metadata_handle - } - (_, _) => process - .read_pointer( - self.image + module.offsets.image.metadata_handle, - module.pointer_size, - ) - .unwrap_or_default(), - }; - - let metadata_handle = match metadata_ptr { - Address::NULL => 0, - handle => process.read::(handle).unwrap_or_default(), - }; - - let type_info_definition_table = match metadata_ptr { - Address::NULL => Address::NULL, - _ => process - .read_pointer(module.type_info_definition_table, module.pointer_size) - .unwrap_or_default(), - }; - - let ptr = match type_info_definition_table { - Address::NULL => Address::NULL, - _ => { - type_info_definition_table + module.size_of_ptr().wrapping_mul(metadata_handle as _) - } - }; - - (0..type_count).filter_map(move |i| { + let walk = module.walk(); + let pointer_size = walk.pointer_size; + // The runtime built by an IL2CPP module always answers the slot form. + let (slots, count) = walk + .runtime + .classes(process, pointer_size, ImageRef::new(self.image)) + .slots() + .unwrap_or((Address::NULL, 0)); + + (0..count).filter_map(move |i| { process - .read_pointer( - ptr + module.size_of_ptr().wrapping_mul(i), - module.pointer_size, - ) + .read_pointer(slot(slots, pointer_size, i), pointer_size) .ok() .filter(|val| !val.is_null()) .map(|class| Class { class }) @@ -66,23 +36,12 @@ impl Image { /// Tries to find the specified [.NET class](struct@Class) in the image. pub fn get_class(&self, process: &Process, module: &Module, class_name: &str) -> Option { - let name_space_index = class_name.rfind('.'); - - self.classes(process, module).find(|class| { - class.get_name::(process, module).is_ok_and(|name| { - if let Some(name_space_index) = name_space_index { - let class_name_space = &class_name[..name_space_index]; - let class_name = &class_name[name_space_index + 1..]; - - name.matches(class_name) - && class - .get_name_space::(process, module) - .is_ok_and(|name_space| name_space.matches(class_name_space)) - } else { - name.matches(class_name) - } + module + .walk() + .find_class(process, ImageRef::new(self.image), class_name) + .map(|class| Class { + class: class.address, }) - }) } /// Tries to find the specified [.NET class](struct@Class) in the image. diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index afa7463..933fcb5 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -5,15 +5,11 @@ use crate::{ Process, }; -mod assembly; -use assembly::Assembly; mod builds; mod image; pub use image::Image; mod class; pub use class::Class; -mod field; -use field::Field; mod version; pub use version::Version; mod pointer; @@ -23,7 +19,7 @@ use offsets::IL2CPPOffsets; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; -use super::CSTR; +use super::managed; /// Represents access to a Unity game that is using the IL2CPP backend. pub struct Module { @@ -262,34 +258,38 @@ impl Module { (holds(assemblies) && holds(table)).then_some((assemblies, table)) } - fn assemblies<'a>( - &'a self, - process: &'a Process, - ) -> impl DoubleEndedIterator + 'a { - let (assemblies, nr_of_assemblies): (Address, u64) = { - let first = process - .read_pointer(self.assemblies, self.pointer_size) - .unwrap_or_default(); - let limit = process - .read_pointer(self.assemblies + self.size_of_ptr(), self.pointer_size) - .unwrap_or_default(); - let count = limit - .value() - .saturating_sub(first.value()) - .saturating_div(self.size_of_ptr()); - (first, count) - }; - - (0..nr_of_assemblies).filter_map(move |i| { - process - .read_pointer( - assemblies + self.size_of_ptr().wrapping_mul(i), - self.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null()) - .map(|assembly| Assembly { assembly }) - }) + fn walk(&self) -> managed::Walk { + managed::Walk { + runtime: managed::Runtime::Il2Cpp(managed::Il2CppRuntime { + assemblies: self.assemblies, + type_info_definition_table: self.type_info_definition_table, + type_count: self.offsets.image.type_count.into(), + metadata_handle: self.offsets.image.metadata_handle.into(), + handle_is_inline: matches!(self.version, Version::Base | Version::V2019), + field_count: self.offsets.class.field_count, + static_fields: self.offsets.class.static_fields.into(), + }), + offsets: managed::WalkOffsets { + assembly: managed::AssemblyOffsets { + name_in_image: self.offsets.image.assembly_name.map(u16::from), + name_in_assembly: self.offsets.assembly.aname.map(u16::from), + image: self.offsets.assembly.image.into(), + }, + class: managed::ClassOffsets { + name: self.offsets.class.name.into(), + namespace: self.offsets.class.namespace.into(), + parent: self.offsets.class.parent.into(), + fields: self.offsets.class.fields.into(), + }, + field: managed::FieldOffsets { + name: self.offsets.field.name.into(), + offset: self.offsets.field.offset.into(), + stride: self.offsets.field.struct_size.into(), + }, + }, + stop: managed::ClimbStop::UNITY, + pointer_size: self.pointer_size, + } } /// Looks for the specified binary [image](Image) inside the target process. @@ -299,13 +299,11 @@ impl Module { /// [`get_default_image`](Self::get_default_image) function is a shorthand /// for this function that accesses the `Assembly-CSharp` [image](Image). pub fn get_image(&self, process: &Process, assembly_name: &str) -> Option { - self.assemblies(process) - .find(|assembly| { - assembly - .get_name::(process, self) - .is_ok_and(|name| name.matches(assembly_name)) + self.walk() + .find_image(process, assembly_name) + .map(|image| Image { + image: image.address, }) - .and_then(|assembly| assembly.get_image(process, self)) } /// Looks for the `Assembly-CSharp` binary [image](Image) inside the target @@ -368,11 +366,6 @@ impl Module { pub async fn wait_get_default_image(&self, process: &Process) -> Image { retry(|| self.get_default_image(process)).await } - - #[inline] - const fn size_of_ptr(&self) -> u64 { - self.pointer_size as u64 - } } #[cfg(all(test, not(target_family = "wasm")))] diff --git a/src/game_engine/unity/il2cpp/pointer.rs b/src/game_engine/unity/il2cpp/pointer.rs index 6dd1ab6..cfd2bcc 100644 --- a/src/game_engine/unity/il2cpp/pointer.rs +++ b/src/game_engine/unity/il2cpp/pointer.rs @@ -1,24 +1,11 @@ -use bytemuck::CheckedBitPattern; - -use super::{Class, Image, Module}; +use super::super::managed::{ImageRef, PointerPath}; +use super::{Image, Module}; use crate::{Address, Error, Process}; -use core::{array, cell::RefCell}; +use bytemuck::CheckedBitPattern; /// An IL2CPP-specific implementation for automatic pointer path resolution pub struct UnityPointer { - inner: RefCell>, -} - -struct UnityPointerInternal { - base_address: Address, - offsets: [u32; CAP], - resolved_offsets: usize, - - starting_class_name: &'static str, - starting_class: Option, - nr_of_parents: usize, - fields: [&'static str; CAP], - depth: usize, + path: PointerPath, } impl UnityPointer { @@ -29,110 +16,11 @@ impl UnityPointer { /// If a higher number of offsets is provided, the pointer path will be truncated /// according to the value of `CAP`. pub fn new(class_name: &'static str, nr_of_parents: usize, fields: &[&'static str]) -> Self { - let named_fields = { - let mut iter = fields.iter(); - array::from_fn(|_| iter.next().copied().unwrap_or_default()) - }; - Self { - inner: RefCell::new(UnityPointerInternal { - base_address: Address::NULL, - offsets: [0; CAP], - resolved_offsets: 0, - starting_class_name: class_name, - starting_class: None, - nr_of_parents, - fields: named_fields, - depth: fields.len().min(CAP), - }), + path: PointerPath::new(class_name, nr_of_parents, fields), } } - /// Tries to resolve the pointer path for the `IL2CPP` class specified - fn find_offsets(&self, process: &Process, module: &Module, image: &Image) -> Result<(), Error> { - let mut inner = self.inner.borrow_mut(); - - // If the pointer path has already been found, there's no need to continue - if inner.resolved_offsets == inner.depth { - return Ok(()); - } - - // Logic: the starting class can be recovered with the get_class() function, - // and parent class can be recovered if needed. However, this is a VERY - // intensive process because it involves looping through all the main classes - // in the game. For this reason, once the class is found, we want to store it - // into the cache, where it can be recovered if this function need to be run again - // (for example if a previous attempt at pointer path resolution failed) - let starting_class = match inner.starting_class { - Some(starting_class) => starting_class, - _ => { - let mut class = image - .get_class(process, module, inner.starting_class_name) - .ok_or(Error {})?; - - for _ in 0..inner.nr_of_parents { - class = class.get_parent(process, module).ok_or(Error {})?; - } - - inner.starting_class = Some(class); - class - } - }; - - // Recovering the address of the static table is not very CPU intensive, - // but it might be worth caching it as well - if inner.base_address.is_null() { - inner.base_address = starting_class - .get_static_table(process, module) - .ok_or(Error {})?; - }; - - // If we already resolved some offsets, we need to traverse them again starting from the base address - // of the static table in order to recalculate the address of the farthest object we can reach. - // If no offsets have been resolved yet, we just need to read the base address instead. - let mut current_object = { - let mut addr = inner.base_address; - for &i in &inner.offsets[..inner.resolved_offsets] { - addr = process.read_pointer(addr + i, module.pointer_size)?; - } - addr - }; - - // We keep track of the already resolved offsets in order to skip resolving them again - for i in inner.resolved_offsets..inner.depth { - let offset_from_string = match inner.fields[i].strip_prefix("0x") { - Some(rem) => u32::from_str_radix(rem, 16).ok(), - _ => inner.fields[i].parse().ok(), - }; - - let current_offset = match offset_from_string { - Some(offset) => offset as _, - _ => { - let current_class = match i { - 0 => starting_class, - _ => process - .read_pointer(current_object, module.pointer_size) - .ok() - .filter(|val| !val.is_null()) - .map(|class| Class { class }) - .ok_or(Error {})?, - }; - - current_class - .get_field_offset(process, module, inner.fields[i]) - .ok_or(Error {})? - } - }; - - inner.offsets[i] = current_offset as _; - inner.resolved_offsets += 1; - - current_object = - process.read_pointer(current_object + current_offset, module.pointer_size)?; - } - Ok(()) - } - /// Dereferences the pointer path, returning the memory address of the value of interest pub fn deref_offsets( &self, @@ -140,14 +28,8 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - self.find_offsets(process, module, image)?; - let inner = self.inner.borrow(); - let mut address = inner.base_address; - let (&last, path) = inner.offsets[..inner.depth].split_last().ok_or(Error {})?; - for &offset in path { - address = process.read_pointer(address + offset, module.pointer_size)?; - } - Ok(address + last) + self.path + .deref_offsets(process, &module.walk(), ImageRef::new(image.image)) } /// Dereferences the pointer path, returning the value stored at the final memory address @@ -157,6 +39,7 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - process.read(self.deref_offsets(process, module, image)?) + self.path + .deref(process, &module.walk(), ImageRef::new(image.image)) } } diff --git a/src/game_engine/unity/managed/cursor.rs b/src/game_engine/unity/managed/cursor.rs new file mode 100644 index 0000000..55ef411 --- /dev/null +++ b/src/game_engine/unity/managed/cursor.rs @@ -0,0 +1,283 @@ +use super::runtime::{Il2CppRuntime, MonoRuntime}; +use super::{slot, ClassRef}; +use crate::{Address, Address32, Address64, PointerSize, Process}; + +/// Walks the runtime's loaded assemblies. +pub struct Assemblies<'a> { + process: &'a Process, + pointer_size: PointerSize, + state: AssembliesState, +} + +enum AssembliesState { + /// The glib list: each node carries the assembly and the next node. + Mono { node: Option
}, + /// The vector: a slice of assembly pointers. + Il2Cpp { + base: Address, + count: u64, + index: u64, + }, +} + +impl<'a> Assemblies<'a> { + pub(super) fn mono( + process: &'a Process, + pointer_size: PointerSize, + mono: &MonoRuntime, + ) -> Self { + Self { + process, + pointer_size, + state: AssembliesState::Mono { + node: process + .read_pointer(mono.assemblies, pointer_size) + .ok() + .filter(|address| !address.is_null()), + }, + } + } + + pub(super) fn il2cpp( + process: &'a Process, + pointer_size: PointerSize, + il2cpp: &Il2CppRuntime, + ) -> Self { + let first = process + .read_pointer(il2cpp.assemblies, pointer_size) + .unwrap_or_default(); + let limit = process + .read_pointer(il2cpp.assemblies + pointer_size as u64, pointer_size) + .unwrap_or_default(); + + Self { + process, + pointer_size, + state: AssembliesState::Il2Cpp { + base: first, + count: limit.value().saturating_sub(first.value()) / pointer_size as u64, + index: 0, + }, + } + } +} + +impl Iterator for Assemblies<'_> { + type Item = Address; + + fn next(&mut self) -> Option
{ + match &mut self.state { + AssembliesState::Mono { node } => { + let at = (*node)?; + + let [data, next]: [Address; 2] = match self.pointer_size { + PointerSize::Bit64 => self + .process + .read::<[Address64; 2]>(at) + .ok()? + .map(|address| address.into()), + _ => self + .process + .read::<[Address32; 2]>(at) + .ok()? + .map(|address| address.into()), + }; + + *node = Some(next); + + Some(data) + } + AssembliesState::Il2Cpp { base, count, index } => loop { + if index >= count { + return None; + } + + let at = slot(*base, self.pointer_size, *index); + *index += 1; + + if let Some(assembly) = self + .process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + { + return Some(assembly); + } + }, + } + } +} + +/// Walks the classes an image holds. +pub struct Classes<'a> { + process: &'a Process, + pointer_size: PointerSize, + state: ClassesState, +} + +enum ClassesState { + /// The image's hash table: a bucket array whose entries chain through the + /// classes themselves. + Mono { + table: Address, + // The size the runtime stores is signed, and the walk has always taken + // it as a count wholesale, garbage included. + size: u64, + bucket: u64, + chain: Option
, + next_class_cache: u16, + }, + /// The image's slice of the type info definition table. + Il2Cpp { + slots: Address, + count: u64, + index: u64, + }, +} + +impl<'a> Classes<'a> { + pub(super) fn mono( + process: &'a Process, + pointer_size: PointerSize, + mono: &MonoRuntime, + image: super::ImageRef, + ) -> Self { + let cache = image.address + mono.class_cache; + + let size = process + .read::(cache + mono.hash_table_size) + .unwrap_or_default() as u64; + + let table = match size { + 0 => Address::NULL, + _ => process + .read_pointer(cache + mono.hash_table_table, pointer_size) + .unwrap_or_default(), + }; + + Self { + process, + pointer_size, + state: ClassesState::Mono { + table, + size, + bucket: 0, + chain: None, + next_class_cache: mono.next_class_cache, + }, + } + } + + pub(super) fn il2cpp( + process: &'a Process, + pointer_size: PointerSize, + il2cpp: &Il2CppRuntime, + image: super::ImageRef, + ) -> Self { + let count = process + .read::(image.address + il2cpp.type_count) + .unwrap_or_default() as u64; + + let metadata = match (count, il2cpp.handle_is_inline) { + (0, _) => Address::NULL, + (_, true) => image.address + il2cpp.metadata_handle, + (_, false) => process + .read_pointer(image.address + il2cpp.metadata_handle, pointer_size) + .unwrap_or_default(), + }; + + let handle = match metadata { + Address::NULL => 0, + at => process.read::(at).unwrap_or_default(), + }; + + let table = match metadata { + Address::NULL => Address::NULL, + _ => process + .read_pointer(il2cpp.type_info_definition_table, pointer_size) + .unwrap_or_default(), + }; + + let slots = match table { + Address::NULL => Address::NULL, + _ => slot(table, pointer_size, handle as u64), + }; + + Self { + process, + pointer_size, + state: ClassesState::Il2Cpp { + slots, + count, + index: 0, + }, + } + } + + /// The slot array and its length, for the caller that iterates the slots + /// itself. + pub const fn slots(&self) -> Option<(Address, u64)> { + match &self.state { + ClassesState::Il2Cpp { slots, count, .. } => Some((*slots, *count)), + ClassesState::Mono { .. } => None, + } + } +} + +impl Iterator for Classes<'_> { + type Item = ClassRef; + + fn next(&mut self) -> Option { + match &mut self.state { + ClassesState::Mono { + table, + size, + bucket, + chain, + next_class_cache, + } => loop { + if let Some(class) = *chain { + *chain = self + .process + .read_pointer(class + *next_class_cache, self.pointer_size) + .ok() + .filter(|address| !address.is_null()); + + return Some(ClassRef::new(class)); + } + + if table.is_null() || bucket >= size { + return None; + } + + *chain = self + .process + .read_pointer(slot(*table, self.pointer_size, *bucket), self.pointer_size) + .ok() + .filter(|address| !address.is_null()); + *bucket += 1; + }, + ClassesState::Il2Cpp { + slots, + count, + index, + } => loop { + if index >= count { + return None; + } + + let at = slot(*slots, self.pointer_size, *index); + *index += 1; + + if let Some(class) = self + .process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + { + return Some(ClassRef::new(class)); + } + }, + } + } +} diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs new file mode 100644 index 0000000..f15e36f --- /dev/null +++ b/src/game_engine/unity/managed/mod.rs @@ -0,0 +1,121 @@ +//! The walk over a managed runtime's metadata, shared by the runtimes that lay +//! their classes and fields out the same way. +//! +//! What the runtimes genuinely disagree on is behind [`Runtime`]: where the +//! images live, where an image keeps its classes, how a class counts its +//! fields, where its statics sit, and how a live object names its class. Below +//! that, the walk is written once. + +mod cursor; +mod pointer; +mod runtime; +mod walk; + +pub use cursor::{Assemblies, Classes}; +pub use pointer::PointerPath; +pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; +pub use walk::Walk; + +use crate::{string::ArrayCString, Address, PointerSize, Process}; + +/// The offsets the shared walk reads, copied out of whichever runtime's own +/// offsets built it, so the walk reads plain numbers without knowing whose +/// sections they came from. +pub struct WalkOffsets { + pub assembly: AssemblyOffsets, + pub class: ClassOffsets, + pub field: FieldOffsets, +} + +/// Where an assembly keeps its image, and where its name is: on the assembly +/// itself, or through the image, whichever the offsets carry. +pub struct AssemblyOffsets { + pub name_in_image: Option, + pub name_in_assembly: Option, + pub image: u16, +} + +/// Where a class keeps its names, its parent, and its field array. +pub struct ClassOffsets { + pub name: u16, + pub namespace: u16, + pub parent: u16, + pub fields: u16, +} + +/// Where a field entry keeps its name and offset, and the size of one entry in +/// a class's field array, whatever each runtime's own offsets call it. +pub struct FieldOffsets { + pub name: u16, + pub offset: u16, + pub stride: u16, +} + +/// The names a walk stops climbing at when either answers, which is engine +/// policy rather than anything the runtime says: the engine's own classes +/// carry fields a game never declares. +pub struct ClimbStop { + pub class: &'static str, + pub namespace: &'static str, +} + +impl ClimbStop { + /// Unity's own base classes. + pub const UNITY: Self = Self { + class: "Object", + namespace: "UnityEngine", + }; +} + +/// A class, named by where the runtime keeps it. +#[derive(Copy, Clone)] +pub struct ClassRef { + pub address: Address, +} + +impl ClassRef { + pub const fn new(address: Address) -> Self { + Self { address } + } +} + +/// A field, named by where the runtime keeps it. +#[derive(Copy, Clone)] +pub struct FieldRef { + pub address: Address, +} + +impl FieldRef { + pub const fn new(address: Address) -> Self { + Self { address } + } +} + +/// An image, named by where the runtime keeps it. +#[derive(Copy, Clone)] +pub struct ImageRef { + pub address: Address, +} + +impl ImageRef { + pub const fn new(address: Address) -> Self { + Self { address } + } +} + +/// The address of a pointer-sized slot in an array of them. +pub fn slot(base: Address, pointer_size: PointerSize, index: u64) -> Address { + base + (pointer_size as u64).wrapping_mul(index) +} + +/// Reads a name the runtime stores behind a pointer. +pub fn read_name( + process: &Process, + pointer_size: PointerSize, + at: Address, +) -> Option> { + process + .read_pointer(at, pointer_size) + .and_then(|address| process.read(address)) + .ok() +} diff --git a/src/game_engine/unity/managed/pointer.rs b/src/game_engine/unity/managed/pointer.rs new file mode 100644 index 0000000..2393284 --- /dev/null +++ b/src/game_engine/unity/managed/pointer.rs @@ -0,0 +1,143 @@ +use super::{ClassRef, ImageRef, Walk}; +use crate::{Address, Error, Process}; +use bytemuck::CheckedBitPattern; +use core::{array, cell::RefCell}; + +/// The pointer path resolution both backends' `UnityPointer` types share: a +/// static root found by class name, then fields resolved by name or written as +/// literal offsets, remembered across calls so a failed resolution resumes +/// where it left off. +pub struct PointerPath { + inner: RefCell>, +} + +struct PointerPathInternal { + base_address: Address, + offsets: [u32; CAP], + resolved_offsets: usize, + + starting_class_name: &'static str, + starting_class: Option, + nr_of_parents: usize, + fields: [&'static str; CAP], + depth: usize, +} + +impl PointerPath { + pub fn new(class_name: &'static str, nr_of_parents: usize, fields: &[&'static str]) -> Self { + let named_fields: [&str; CAP] = + array::from_fn(|i| fields.get(i).copied().unwrap_or_default()); + + Self { + inner: RefCell::new(PointerPathInternal { + base_address: Address::NULL, + offsets: [0; CAP], + resolved_offsets: 0, + starting_class_name: class_name, + starting_class: None, + nr_of_parents, + fields: named_fields, + depth: fields.len().min(CAP), + }), + } + } + + /// Tries to resolve the pointer path, resuming behind whatever resolved on + /// an earlier call. Finding the starting class walks every class the image + /// holds, so it is remembered the first time it answers. + fn find_offsets(&self, process: &Process, walk: &Walk, image: ImageRef) -> Result<(), Error> { + let mut inner = self.inner.borrow_mut(); + + if inner.resolved_offsets == inner.depth { + return Ok(()); + } + + let starting_class = match inner.starting_class { + Some(starting_class) => starting_class, + _ => { + let mut class = walk + .find_class(process, image, inner.starting_class_name) + .ok_or(Error {})?; + + for _ in 0..inner.nr_of_parents { + class = walk.parent(process, class).ok_or(Error {})?; + } + + inner.starting_class = Some(class); + class + } + }; + + if inner.base_address.is_null() { + inner.base_address = walk.static_table(process, starting_class).ok_or(Error {})?; + } + + // Whatever resolved already is walked again from the base, which is + // what recovers the farthest object the resolution reached. + let mut current_object = { + let mut address = inner.base_address; + for &offset in &inner.offsets[..inner.resolved_offsets] { + address = process.read_pointer(address + offset, walk.pointer_size)?; + } + address + }; + + for i in inner.resolved_offsets..inner.depth { + let offset_from_string = match inner.fields[i].strip_prefix("0x") { + Some(rem) => u32::from_str_radix(rem, 16).ok(), + _ => inner.fields[i].parse().ok(), + }; + + let current_offset = match offset_from_string { + Some(offset) => offset, + _ => { + let current_class = match i { + 0 => starting_class, + _ => walk.object_class(process, current_object).ok_or(Error {})?, + }; + + walk.find_field_offset(process, current_class, inner.fields[i]) + .ok_or(Error {})? + .1 + } + }; + + inner.offsets[i] = current_offset; + inner.resolved_offsets += 1; + + current_object = + process.read_pointer(current_object + current_offset, walk.pointer_size)?; + } + + Ok(()) + } + + /// Dereferences the pointer path, returning the memory address of the + /// value of interest. + pub fn deref_offsets( + &self, + process: &Process, + walk: &Walk, + image: ImageRef, + ) -> Result { + self.find_offsets(process, walk, image)?; + let inner = self.inner.borrow(); + let mut address = inner.base_address; + let (&last, path) = inner.offsets[..inner.depth].split_last().ok_or(Error {})?; + for &offset in path { + address = process.read_pointer(address + offset, walk.pointer_size)?; + } + Ok(address + last) + } + + /// Dereferences the pointer path, returning the value stored at the final + /// memory address. + pub fn deref( + &self, + process: &Process, + walk: &Walk, + image: ImageRef, + ) -> Result { + process.read(self.deref_offsets(process, walk, image)?) + } +} diff --git a/src/game_engine/unity/managed/runtime.rs b/src/game_engine/unity/managed/runtime.rs new file mode 100644 index 0000000..f7fbf47 --- /dev/null +++ b/src/game_engine/unity/managed/runtime.rs @@ -0,0 +1,148 @@ +use super::{Assemblies, ClassRef, Classes, ImageRef}; +use crate::{Address, PointerSize, Process}; + +/// What the runtimes genuinely disagree on. Matching exhaustively is the point: +/// a runtime added later is a compile error at every place the two differ, +/// rather than a silent fall through to whichever arm came first. +pub enum Runtime { + Mono(MonoRuntime), + Il2Cpp(Il2CppRuntime), +} + +/// Mono keeps its assemblies in a glib list, its classes in each image's hash +/// table, and its statics behind the class's vtable. +pub struct MonoRuntime { + pub assemblies: Address, + pub class_cache: u16, + pub hash_table_size: u16, + pub hash_table_table: u16, + pub next_class_cache: u16, + pub field_count: u16, + pub runtime_info: u16, + pub vtable_size: u16, + pub vtable: u16, + /// The older runtime keeps the static data in the vtable's own data slot, + /// where the newer one stores it past the vtable's method pointer array. + pub statics_in_vtable_data: bool, +} + +/// IL2CPP keeps its assemblies in a vector, its classes in a table its images +/// slice into, and its statics on the class itself. +pub struct Il2CppRuntime { + pub assemblies: Address, + pub type_info_definition_table: Address, + pub type_count: u16, + pub metadata_handle: u16, + /// The older lineage keeps the handle inline in the image, where the newer + /// one keeps a pointer to it. + pub handle_is_inline: bool, + pub field_count: u16, + pub static_fields: u16, +} + +impl Runtime { + /// Walks the assemblies the target has loaded. + pub fn assemblies<'a>( + &self, + process: &'a Process, + pointer_size: PointerSize, + ) -> Assemblies<'a> { + match self { + Self::Mono(mono) => Assemblies::mono(process, pointer_size, mono), + Self::Il2Cpp(il2cpp) => Assemblies::il2cpp(process, pointer_size, il2cpp), + } + } + + /// Walks the classes an image holds. + pub fn classes<'a>( + &self, + process: &'a Process, + pointer_size: PointerSize, + image: ImageRef, + ) -> Classes<'a> { + match self { + Self::Mono(mono) => Classes::mono(process, pointer_size, mono, image), + Self::Il2Cpp(il2cpp) => Classes::il2cpp(process, pointer_size, il2cpp, image), + } + } + + /// Reads how many fields a class declares. + pub fn field_count(&self, process: &Process, class: ClassRef) -> u64 { + match self { + Self::Mono(mono) => process + .read::(class.address + mono.field_count) + .ok() + .filter(|&count| count > 0) + .unwrap_or_default() as u64, + // A generic definition stores u16::MAX here; no real class + // declares that many fields. + Self::Il2Cpp(il2cpp) => process + .read::(class.address + il2cpp.field_count) + .ok() + .filter(|&count| count != u16::MAX) + .unwrap_or_default() as u64, + } + } + + /// Reads the address a class's static field offsets are measured from. + pub fn static_table( + &self, + process: &Process, + pointer_size: PointerSize, + class: ClassRef, + ) -> 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())?; + + if mono.statics_in_vtable_data { + vtables + mono.vtable_size + } else { + let vtable_size = process.read::(class.address + mono.vtable_size).ok()?; + + vtables + mono.vtable + (pointer_size as u64).wrapping_mul(vtable_size as u64) + } + } + Self::Il2Cpp(il2cpp) => class.address + il2cpp.static_fields, + }; + + process + .read_pointer(slot, pointer_size) + .ok() + .filter(|address| !address.is_null()) + } + + /// Reads the class a live object belongs to, which is how a polymorphic + /// field's runtime type is found. + pub fn object_class( + &self, + process: &Process, + pointer_size: PointerSize, + object: Address, + ) -> Option { + let address = process + .read_pointer(object, pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + // Mono reaches the class through the object's vtable, where IL2CPP + // heads the object with it. + let address = match self { + Self::Mono(_) => process + .read_pointer(address, pointer_size) + .ok() + .filter(|address| !address.is_null())?, + Self::Il2Cpp(_) => address, + }; + + Some(ClassRef::new(address)) + } +} diff --git a/src/game_engine/unity/managed/walk.rs b/src/game_engine/unity/managed/walk.rs new file mode 100644 index 0000000..eaf32de --- /dev/null +++ b/src/game_engine/unity/managed/walk.rs @@ -0,0 +1,206 @@ +use super::super::{get_backing_name, CSTR}; +use super::{ClassRef, ClimbStop, FieldRef, ImageRef, Runtime, WalkOffsets}; +use crate::{string::ArrayCString, Address, PointerSize, Process}; + +/// The walk itself: everything both runtimes lay out the same way, written +/// once against the operations [`Runtime`] supplies. An adapter builds one per +/// call from what its module holds, so nothing here is stored anywhere. +pub struct Walk { + pub runtime: Runtime, + pub offsets: WalkOffsets, + pub stop: ClimbStop, + pub pointer_size: PointerSize, +} + +impl Walk { + /// Reads an assembly's name, off the assembly itself or through its image, + /// whichever the offsets carry. + pub fn assembly_name( + &self, + process: &Process, + assembly: Address, + ) -> Option> { + let assembly_offsets = &self.offsets.assembly; + + let at = match ( + assembly_offsets.name_in_image, + assembly_offsets.name_in_assembly, + ) { + (Some(name), _) => self.assembly_image(process, assembly)?.address + name, + (_, Some(name)) => assembly + name, + _ => return None, + }; + + super::read_name(process, self.pointer_size, at) + } + + /// Reads the image an assembly carries. + pub fn assembly_image(&self, process: &Process, assembly: Address) -> Option { + process + .read_pointer(assembly + self.offsets.assembly.image, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + .map(ImageRef::new) + } + + pub fn class_name( + &self, + process: &Process, + class: ClassRef, + ) -> Option> { + super::read_name( + process, + self.pointer_size, + class.address + self.offsets.class.name, + ) + } + + pub fn class_namespace( + &self, + process: &Process, + class: ClassRef, + ) -> Option> { + super::read_name( + process, + self.pointer_size, + class.address + self.offsets.class.namespace, + ) + } + + /// Resolves a loaded image by its assembly name. + pub fn find_image(&self, process: &Process, name: &str) -> Option { + self.runtime + .assemblies(process, self.pointer_size) + .find(|&assembly| { + self.assembly_name::(process, assembly) + .is_some_and(|read| read.matches(name)) + }) + .and_then(|assembly| self.assembly_image(process, assembly)) + } + + /// Resolves a class by name, with the namespace split off at the last dot + /// when one is written. + pub fn find_class( + &self, + process: &Process, + image: ImageRef, + class_name: &str, + ) -> Option { + let name_space_index = class_name.rfind('.'); + + self.runtime + .classes(process, self.pointer_size, image) + .find(|&class| { + self.class_name::(process, class).is_some_and(|name| { + if let Some(name_space_index) = name_space_index { + let class_name_space = &class_name[..name_space_index]; + let class_name = &class_name[name_space_index + 1..]; + + name.matches(class_name) + && self + .class_namespace::(process, class) + .is_some_and(|name_space| name_space.matches(class_name_space)) + } else { + name.matches(class_name) + } + }) + }) + } + + /// Resolves the parent class. + pub fn parent(&self, process: &Process, class: ClassRef) -> Option { + process + .read_pointer(class.address + self.offsets.class.parent, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + .map(ClassRef::new) + } + + /// Resolves a field by name, climbing the parent chain until either stop + /// name answers, matching the written name or its backing field. Hands + /// back the class the field was found on as well as the offset, since a + /// static field's offset measures into that class's own static table. + pub fn find_field_offset( + &self, + process: &Process, + class: ClassRef, + field_name: &str, + ) -> Option<(ClassRef, u32)> { + let mut this_class = Some(class); + + loop { + let class = this_class?; + + if self + .class_name::(process, class)? + .matches(self.stop.class) + || self + .class_namespace::(process, class)? + .matches(self.stop.namespace) + { + return None; + } + + this_class = self.parent(process, class); + + let field_count = self.runtime.field_count(process, class); + + let fields = match field_count { + 0 => None, + _ => process + .read_pointer(class.address + self.offsets.class.fields, self.pointer_size) + .ok() + .filter(|address| !address.is_null()), + }; + + let Some(fields) = fields else { + continue; + }; + + for index in 0..field_count { + let field = + FieldRef::new(fields + index.wrapping_mul(self.offsets.field.stride as u64)); + + let matched = self.field_name::(process, field).is_some_and(|name| { + name.matches(field_name) + || name + .validate_utf8() + .ok() + .and_then(get_backing_name) + .is_some_and(|name| name == field_name) + }); + + if matched { + return Some((class, self.field_offset(process, field)?)); + } + } + } + } + + fn field_name( + &self, + process: &Process, + field: FieldRef, + ) -> Option> { + super::read_name( + process, + self.pointer_size, + field.address + self.offsets.field.name, + ) + } + + fn field_offset(&self, process: &Process, field: FieldRef) -> Option { + process.read(field.address + self.offsets.field.offset).ok() + } + + /// Reads the address a class's static field offsets are measured from. + pub fn static_table(&self, process: &Process, class: ClassRef) -> Option
{ + self.runtime.static_table(process, self.pointer_size, class) + } + + /// Reads the class a live object belongs to. + pub fn object_class(&self, process: &Process, object: Address) -> Option { + self.runtime + .object_class(process, self.pointer_size, object) + } +} diff --git a/src/game_engine/unity/mod.rs b/src/game_engine/unity/mod.rs index baa3bfa..1e22145 100644 --- a/src/game_engine/unity/mod.rs +++ b/src/game_engine/unity/mod.rs @@ -84,6 +84,7 @@ // https://github.com/CryZe/lunistice-auto-splitter/blob/b8c01031991783f7b41044099ee69edd54514dba/asr-dotnet/src/lib.rs pub mod il2cpp; +mod managed; pub mod mono; pub mod scene_manager; diff --git a/src/game_engine/unity/mono/assembly.rs b/src/game_engine/unity/mono/assembly.rs deleted file mode 100644 index 12b6f12..0000000 --- a/src/game_engine/unity/mono/assembly.rs +++ /dev/null @@ -1,41 +0,0 @@ -use super::{Image, Module}; -use crate::{string::ArrayCString, Address, Error, Process}; - -#[derive(Copy, Clone)] -pub(super) struct Assembly { - pub(super) assembly: Address, -} - -impl Assembly { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - let name = match ( - module.offsets.image.assembly_name, - module.offsets.assembly.aname, - ) { - (Some(assembly_name), _) => { - self.get_image(process, module).ok_or(Error {})?.image + assembly_name - } - (_, Some(aname)) => self.assembly + aname, - _ => return Err(Error {}), - }; - - process - .read_pointer(name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_image(&self, process: &Process, module: &Module) -> Option { - process - .read_pointer( - self.assembly + module.offsets.assembly.image, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()) - .map(|image| Image { image }) - } -} diff --git a/src/game_engine/unity/mono/class.rs b/src/game_engine/unity/mono/class.rs index 2ffe93c..d958b84 100644 --- a/src/game_engine/unity/mono/class.rs +++ b/src/game_engine/unity/mono/class.rs @@ -1,92 +1,17 @@ -use core::iter::{self, FusedIterator}; - -use super::{super::get_backing_name, Field, Module, Version, CSTR}; -use crate::{future::retry, string::ArrayCString, Address, Error, Process}; +use super::super::managed::ClassRef; +use super::Module; +use crate::{future::retry, Address, Process}; #[cfg(feature = "derive")] pub use asr_derive::MonoClass as Class; -/// A .NET class that is part of an [`Image`](Image). +/// A .NET class that is part of an [`Image`](super::Image). #[derive(Copy, Clone)] pub struct Class { pub(super) class: Address, } impl Class { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer(self.class + module.offsets.class.name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_name_space( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer( - self.class + module.offsets.class.namespace, - module.pointer_size, - ) - .and_then(|addr| process.read(addr)) - } - - fn fields<'a>( - &'a self, - process: &'a Process, - module: &'a Module, - ) -> impl FusedIterator + 'a { - let mut this_class = Some(*self); - - iter::from_fn(move || { - let class = this_class?; - - if class - .get_name::(process, module) - .ok()? - .matches("Object") - || class - .get_name_space::(process, module) - .ok()? - .matches("UnityEngine") - { - return None; - } - - // Prepare for next iteration - this_class = class.get_parent(process, module); - - let field_count = process - .read::(class.class + module.offsets.class.field_count) - .ok() - .filter(|&val| val > 0) - .unwrap_or_default(); - - let fields = match field_count { - 0 => None, - _ => process - .read_pointer( - class.class + module.offsets.class.fields, - module.pointer_size, - ) - .ok(), - }; - - Some((0..field_count as u64).filter_map(move |i| { - fields.map(|fields| Field { - field: fields + i.wrapping_mul(module.offsets.field.alignment as u64), - }) - })) - }) - .flatten() - .fuse() - } - /// Tries to find the offset for a field with the specified name in the class. /// If it's a static field, the offset will be from the start of the static /// table. @@ -96,20 +21,10 @@ impl Class { module: &Module, field_name: &str, ) -> Option { - self.fields(process, module) - .find(|field| { - field.get_name::(process, module).is_ok_and(|name| { - // If the name matches, return immediately - name.matches(field_name) - - // BackingField pattern: k__BackingField - || name.validate_utf8() - .ok() - .and_then(|name| get_backing_name(name)) - .is_some_and(|name| name == field_name) - }) - }) - .and_then(|field| field.get_offset(process, module)) + module + .walk() + .find_field_offset(process, ClassRef::new(self.class), field_name) + .map(|(_, offset)| offset) } /// Tries to find the address of a static instance of the class based on its @@ -135,57 +50,22 @@ impl Class { .await } - fn get_static_table_pointer(&self, process: &Process, module: &Module) -> Option
{ - let runtime_info = process - .read_pointer( - self.class + module.offsets.class.runtime_info, - module.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null())?; - - let mut vtables = process - .read_pointer(runtime_info + module.size_of_ptr(), module.pointer_size) - .ok() - .filter(|addr| !addr.is_null())?; - - // Mono V1 behaves differently when it comes to recover the static table - match module.version { - Version::V1 | Version::V1Cattrs => Some(vtables + module.offsets.class.vtable_size), - _ => { - vtables = vtables + module.offsets.v_table.vtable; - - let vtable_size = process - .read::(self.class + module.offsets.class.vtable_size) - .ok()?; - - Some(vtables + module.size_of_ptr().wrapping_mul(vtable_size as u64)) - } - } - } - /// 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
{ - process - .read_pointer( - self.get_static_table_pointer(process, module)?, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()) + module + .walk() + .static_table(process, ClassRef::new(self.class)) } /// Tries to find the parent class. pub fn get_parent(&self, process: &Process, module: &Module) -> Option { - process - .read_pointer( - self.class + module.offsets.class.parent, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()) - .map(|class| Class { class }) + module + .walk() + .parent(process, ClassRef::new(self.class)) + .map(|class| Class { + class: class.address, + }) } /// Tries to find a field with the specified name in the class. This returns diff --git a/src/game_engine/unity/mono/field.rs b/src/game_engine/unity/mono/field.rs deleted file mode 100644 index af08822..0000000 --- a/src/game_engine/unity/mono/field.rs +++ /dev/null @@ -1,23 +0,0 @@ -use super::Module; -use crate::{string::ArrayCString, Address, Error, Process}; - -#[derive(Copy, Clone)] -pub(super) struct Field { - pub(super) field: Address, -} - -impl Field { - pub(super) fn get_name( - &self, - process: &Process, - module: &Module, - ) -> Result, Error> { - process - .read_pointer(self.field + module.offsets.field.name, module.pointer_size) - .and_then(|addr| process.read(addr)) - } - - pub(super) fn get_offset(&self, process: &Process, module: &Module) -> Option { - process.read(self.field + module.offsets.field.offset).ok() - } -} diff --git a/src/game_engine/unity/mono/image.rs b/src/game_engine/unity/mono/image.rs index d6ca1ae..51faa25 100644 --- a/src/game_engine/unity/mono/image.rs +++ b/src/game_engine/unity/mono/image.rs @@ -1,9 +1,8 @@ -use core::iter::{self, FusedIterator}; +use core::iter::FusedIterator; -use super::CSTR; +use super::super::managed::ImageRef; use super::{Class, Module}; -use crate::future::retry; -use crate::{Address, Process}; +use crate::{future::retry, Address, Process}; /// An image is a .NET DLL that is loaded by the game. The `Assembly-CSharp` /// image is the main game assembly, and contains all the game logic. @@ -19,69 +18,24 @@ impl Image { process: &'a Process, module: &'a Module, ) -> impl FusedIterator + 'a { - let class_cache_size = process - .read::( - self.image + module.offsets.image.class_cache + module.offsets.hash_table.size, - ) - .unwrap_or_default() as _; + let walk = module.walk(); - let table_addr = match class_cache_size { - 0 => Address::NULL, - _ => process - .read_pointer( - self.image + module.offsets.image.class_cache + module.offsets.hash_table.table, - module.pointer_size, - ) - .unwrap_or_default(), - }; - - (0..class_cache_size).flat_map(move |i| { - let mut table = match table_addr { - Address::NULL => None, - addr => process - .read_pointer( - addr + module.size_of_ptr().wrapping_mul(i), - module.pointer_size, - ) - .ok() - .filter(|addr| !addr.is_null()), - }; - - iter::from_fn(move || { - let class = table?; - table = process - .read_pointer( - class + module.offsets.class.next_class_cache, - module.pointer_size, - ) - .ok() - .filter(|val| !val.is_null()); - - Some(Class { class }) + walk.runtime + .classes(process, walk.pointer_size, ImageRef::new(self.image)) + .map(|class| Class { + class: class.address, }) .fuse() - }) } /// Tries to find the specified [.NET class](struct@Class) in the image. pub fn get_class(&self, process: &Process, module: &Module, class_name: &str) -> Option { - let name_space_index = class_name.rfind('.'); - - self.classes(process, module).find(|class| { - class.get_name::(process, module).is_ok_and(|name| { - if let Some(name_space_index) = name_space_index { - let class_name_space = &class_name[..name_space_index]; - let class_name = &class_name[name_space_index + 1..]; - - name.matches(class_name) - && class - .get_name_space::(process, module) - .is_ok_and(|name_space| name_space.matches(class_name_space)) - } else { - name.matches(class_name) - } + module + .walk() + .find_class(process, ImageRef::new(self.image), class_name) + .map(|class| Class { + class: class.address, }) - }) } /// Tries to find the specified [.NET class](struct@Class) in the image. diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index 7783ba6..ff5b0d4 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -8,19 +8,14 @@ use crate::{ future::retry, print_limited, signature::Signature, - Address, Address32, Address64, PointerSize, Process, + Address, Address32, PointerSize, Process, }; -use core::iter::{self, FusedIterator}; -mod assembly; mod builds; -use assembly::Assembly; mod image; pub use image::Image; mod class; pub use class::Class; -mod field; -use field::Field; mod version; pub use version::Version; mod pointer; @@ -30,7 +25,7 @@ use offsets::MonoOffsets; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; -use super::{BinaryFormat, CSTR}; +use super::{managed, BinaryFormat}; /// Represents access to a Unity game that is using the standard Mono backend. pub struct Module { @@ -269,29 +264,41 @@ impl Module { self.pointer_size } - fn assemblies<'a>(&'a self, process: &'a Process) -> impl FusedIterator + 'a { - let mut assembly = process - .read_pointer(self.assemblies, self.pointer_size) - .ok() - .filter(|val| !val.is_null()); - - iter::from_fn(move || { - let [data, next_assembly]: [Address; 2] = match self.pointer_size { - PointerSize::Bit64 => process - .read::<[Address64; 2]>(assembly?) - .ok()? - .map(|item| item.into()), - _ => process - .read::<[Address32; 2]>(assembly?) - .ok()? - .map(|item| item.into()), - }; - - assembly = Some(next_assembly); - - Some(Assembly { assembly: data }) - }) - .fuse() + fn walk(&self) -> managed::Walk { + managed::Walk { + runtime: managed::Runtime::Mono(managed::MonoRuntime { + assemblies: self.assemblies, + class_cache: self.offsets.image.class_cache, + hash_table_size: self.offsets.hash_table.size.into(), + hash_table_table: self.offsets.hash_table.table.into(), + next_class_cache: self.offsets.class.next_class_cache, + field_count: self.offsets.class.field_count, + runtime_info: self.offsets.class.runtime_info, + vtable_size: self.offsets.class.vtable_size.into(), + vtable: self.offsets.v_table.vtable.into(), + statics_in_vtable_data: matches!(self.version, Version::V1 | Version::V1Cattrs), + }), + offsets: managed::WalkOffsets { + assembly: managed::AssemblyOffsets { + name_in_image: self.offsets.image.assembly_name.map(u16::from), + name_in_assembly: self.offsets.assembly.aname.map(u16::from), + image: self.offsets.assembly.image.into(), + }, + class: managed::ClassOffsets { + name: self.offsets.class.name.into(), + namespace: self.offsets.class.namespace.into(), + parent: self.offsets.class.parent.into(), + fields: self.offsets.class.fields.into(), + }, + field: managed::FieldOffsets { + name: self.offsets.field.name.into(), + offset: self.offsets.field.offset.into(), + stride: self.offsets.field.alignment.into(), + }, + }, + stop: managed::ClimbStop::UNITY, + pointer_size: self.pointer_size, + } } /// Looks for the specified binary [image](Image) inside the target process. @@ -301,13 +308,11 @@ impl Module { /// [`get_default_image`](Self::get_default_image) function is a shorthand /// for this function that accesses the `Assembly-CSharp` [image](Image). pub fn get_image(&self, process: &Process, assembly_name: &str) -> Option { - self.assemblies(process) - .find(|assembly| { - assembly - .get_name::(process, self) - .is_ok_and(|name| name.matches(assembly_name)) + self.walk() + .find_image(process, assembly_name) + .map(|image| Image { + image: image.address, }) - .and_then(|assembly| assembly.get_image(process, self)) } /// Looks for the `Assembly-CSharp` binary [image](Image) inside the target @@ -370,9 +375,4 @@ impl Module { pub async fn wait_get_default_image(&self, process: &Process) -> Image { retry(|| self.get_default_image(process)).await } - - #[inline] - const fn size_of_ptr(&self) -> u64 { - self.pointer_size as u64 - } } diff --git a/src/game_engine/unity/mono/pointer.rs b/src/game_engine/unity/mono/pointer.rs index 8b75de7..771c470 100644 --- a/src/game_engine/unity/mono/pointer.rs +++ b/src/game_engine/unity/mono/pointer.rs @@ -1,23 +1,11 @@ -use super::{Class, Image, Module}; +use super::super::managed::{ImageRef, PointerPath}; +use super::{Image, Module}; use crate::{Address, Error, Process}; use bytemuck::CheckedBitPattern; -use core::{array, cell::RefCell}; /// A Mono-specific implementation for automatic pointer path resolution pub struct UnityPointer { - inner: RefCell>, -} - -struct UnityPointerInternal { - base_address: Address, - offsets: [u32; CAP], - resolved_offsets: usize, - - starting_class_name: &'static str, - starting_class: Option, - nr_of_parents: usize, - fields: [&'static str; CAP], - depth: usize, + path: PointerPath, } impl UnityPointer { @@ -28,113 +16,11 @@ impl UnityPointer { /// If a higher number of offsets is provided, the pointer path will be truncated /// according to the value of `CAP`. pub fn new(class_name: &'static str, nr_of_parents: usize, fields: &[&'static str]) -> Self { - let named_fields: [&str; CAP] = { - let mut iter = fields.iter(); - array::from_fn(|_| iter.next().copied().unwrap_or_default()) - }; - Self { - inner: RefCell::new(UnityPointerInternal { - base_address: Address::NULL, - offsets: [0; CAP], - resolved_offsets: 0, - starting_class_name: class_name, - starting_class: None, - nr_of_parents, - fields: named_fields, - depth: fields.len().min(CAP), - }), + path: PointerPath::new(class_name, nr_of_parents, fields), } } - /// Tries to resolve the pointer path for the `Mono` class specified - fn find_offsets(&self, process: &Process, module: &Module, image: &Image) -> Result<(), Error> { - let mut inner = self.inner.borrow_mut(); - - // If the pointer path has already been found, there's no need to continue - if inner.resolved_offsets == inner.depth { - return Ok(()); - } - - // Logic: the starting class can be recovered with the get_class() function, - // and parent class can be recovered if needed. However, this is a VERY - // intensive process because it involves looping through all the main classes - // in the game. For this reason, once the class is found, we want to store it - // into the cache, where it can be recovered if this function need to be run again - // (for example if a previous attempt at pointer path resolution failed) - let starting_class = match inner.starting_class { - Some(starting_class) => starting_class, - _ => { - let mut class = image - .get_class(process, module, inner.starting_class_name) - .ok_or(Error {})?; - - for _ in 0..inner.nr_of_parents { - class = class.get_parent(process, module).ok_or(Error {})?; - } - - inner.starting_class = Some(class); - class - } - }; - - // Recovering the address of the static table is not very CPU intensive, - // but it might be worth caching it as well - if inner.base_address.is_null() { - inner.base_address = starting_class - .get_static_table(process, module) - .ok_or(Error {})?; - }; - - // If we already resolved some offsets, we need to traverse them again starting from the base address - // of the static table in order to recalculate the address of the farthest object we can reach. - // If no offsets have been resolved yet, we just need to read the base address instead. - let mut current_object = { - let mut addr = inner.base_address; - for &i in &inner.offsets[..inner.resolved_offsets] { - addr = process.read_pointer(addr + i, module.pointer_size)?; - } - addr - }; - - // We keep track of the already resolved offsets in order to skip resolving them again - for i in inner.resolved_offsets..inner.depth { - let offset_from_string = match inner.fields[i].strip_prefix("0x") { - Some(rem) => u32::from_str_radix(rem, 16).ok(), - _ => inner.fields[i].parse().ok(), - }; - - let current_offset = match offset_from_string { - Some(offset) => offset as _, - _ => { - let current_class = match i { - 0 => starting_class, - _ => process - .read_pointer(current_object, module.pointer_size) - .ok() - .filter(|val| !val.is_null()) - .and_then(|addr| process.read_pointer(addr, module.pointer_size).ok()) - .filter(|val| !val.is_null()) - .map(|class| Class { class }) - .ok_or(Error {})?, - }; - - current_class - .get_field_offset(process, module, inner.fields[i]) - .ok_or(Error {})? - } - }; - - inner.offsets[i] = current_offset as _; - inner.resolved_offsets += 1; - - current_object = - process.read_pointer(current_object + current_offset, module.pointer_size)?; - } - - Ok(()) - } - /// Dereferences the pointer path, returning the memory address of the value of interest pub fn deref_offsets( &self, @@ -142,14 +28,8 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - self.find_offsets(process, module, image)?; - let inner = self.inner.borrow(); - let mut address = inner.base_address; - let (&last, path) = inner.offsets[..inner.depth].split_last().ok_or(Error {})?; - for &offset in path { - address = process.read_pointer(address + offset, module.pointer_size)?; - } - Ok(address + last) + self.path + .deref_offsets(process, &module.walk(), ImageRef::new(image.image)) } /// Dereferences the pointer path, returning the value stored at the final memory address @@ -159,6 +39,7 @@ impl UnityPointer { module: &Module, image: &Image, ) -> Result { - process.read(self.deref_offsets(process, module, image)?) + self.path + .deref(process, &module.walk(), ImageRef::new(image.image)) } } From e6f0c2951b680a4294cb016d8163fe1bb3ba3703 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 21:43:23 +0200 Subject: [PATCH 03/11] add nested class lookup --- src/game_engine/unity/il2cpp/builds.rs | 37 ++++++++++ src/game_engine/unity/il2cpp/mod.rs | 1 + src/game_engine/unity/il2cpp/offsets.rs | 5 ++ src/game_engine/unity/il2cpp/walk_tests.rs | 52 +++++++++++++- src/game_engine/unity/managed/mod.rs | 4 +- src/game_engine/unity/managed/walk.rs | 69 +++++++++++++++++- src/game_engine/unity/mono/builds.rs | 42 +++++++++++ src/game_engine/unity/mono/mod.rs | 1 + src/game_engine/unity/mono/offsets.rs | 13 ++++ src/game_engine/unity/mono/walk_tests.rs | 84 +++++++++++++++++++++- 10 files changed, 303 insertions(+), 5 deletions(-) diff --git a/src/game_engine/unity/il2cpp/builds.rs b/src/game_engine/unity/il2cpp/builds.rs index 80c9a62..459ed2e 100644 --- a/src/game_engine/unity/il2cpp/builds.rs +++ b/src/game_engine/unity/il2cpp/builds.rs @@ -63,6 +63,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x114, @@ -95,6 +96,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -127,6 +129,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x11c, @@ -159,6 +162,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xa8, @@ -191,6 +195,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x11c, @@ -223,6 +228,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xa8, @@ -255,6 +261,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x120, @@ -287,6 +294,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xa8, @@ -319,6 +327,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -351,6 +360,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -383,6 +393,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -415,6 +426,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -447,6 +459,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -479,6 +492,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -511,6 +525,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -543,6 +558,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -575,6 +591,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -607,6 +624,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -639,6 +657,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0xa0, field_count: 0x124, @@ -671,6 +690,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x50, field_count: 0xac, @@ -703,6 +723,7 @@ static BUILDS: &[Build] = &[ name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), fields: 0x80, static_fields: 0x98, field_count: 0x11c, @@ -735,6 +756,7 @@ static BUILDS: &[Build] = &[ name: 0x8, namespace: 0xc, parent: 0x2c, + declaring_type: Some(0x28), fields: 0x40, static_fields: 0x4c, field_count: 0xac, @@ -805,6 +827,21 @@ mod tests { assert!(find(16, (5, 6, 7, 0), PointerSize::Bit64).is_none()); } + // A version table's value for where a class keeps its declaring type + // must match every measured build it stands in for, or say nothing. + #[test] + fn version_tables_never_contradict_a_measured_build_on_nesting() { + for build in BUILDS { + let Some(table) = IL2CPPOffsets::new(build.version, build.pointer_size) else { + continue; + }; + assert!( + table.class.declaring_type.is_none() + || table.class.declaring_type == build.offsets.class.declaring_type + ); + } + } + // The version table for 6000.5 and 6000.7 puts static_fields where 2022.3 // had it. Both measured players put it lower, and 6000.7 moves field_count // too. diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 933fcb5..a724f93 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -279,6 +279,7 @@ impl Module { name: self.offsets.class.name.into(), namespace: self.offsets.class.namespace.into(), parent: self.offsets.class.parent.into(), + declaring: self.offsets.class.declaring_type, fields: self.offsets.class.fields.into(), }, field: managed::FieldOffsets { diff --git a/src/game_engine/unity/il2cpp/offsets.rs b/src/game_engine/unity/il2cpp/offsets.rs index f4155f3..62552d9 100644 --- a/src/game_engine/unity/il2cpp/offsets.rs +++ b/src/game_engine/unity/il2cpp/offsets.rs @@ -25,6 +25,7 @@ impl IL2CPPOffsets { name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), // 2023.1 through 6000.7 fields: 0x80, static_fields: 0xB8, field_count: 0x124, @@ -49,6 +50,7 @@ impl IL2CPPOffsets { name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: None, fields: 0x80, static_fields: 0xB8, field_count: 0x120, @@ -73,6 +75,7 @@ impl IL2CPPOffsets { name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: Some(0x50), // 2019.4, 2020.1 fields: 0x80, static_fields: 0xB8, field_count: 0x11C, @@ -97,6 +100,7 @@ impl IL2CPPOffsets { name: 0x10, namespace: 0x18, parent: 0x58, + declaring_type: None, fields: 0x80, static_fields: 0xB8, field_count: 0x114, @@ -128,6 +132,7 @@ pub(super) struct ClassOffsets { pub(super) name: u8, pub(super) namespace: u8, pub(super) parent: u8, + pub(super) declaring_type: Option, // Where a class keeps the one declaring it pub(super) fields: u8, pub(super) static_fields: u8, pub(super) field_count: u16, diff --git a/src/game_engine/unity/il2cpp/walk_tests.rs b/src/game_engine/unity/il2cpp/walk_tests.rs index afb3f7d..c3894ec 100644 --- a/src/game_engine/unity/il2cpp/walk_tests.rs +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -454,6 +454,7 @@ fn ptr(image: &mut [u8], at: u64, target: u64) { fn image(version: Version) -> Vec { let (type_count_at, handle_at, field_count_at) = match version { Version::V2019 => (0x1C, 0x18, 0x11C), + Version::V2020 => (0x18, 0x28, 0x120), _ => (0x18, 0x28, 0x124), }; @@ -473,6 +474,8 @@ fn image(version: Version) -> Vec { (0x2500, "UnityEngine"), (0x2580, "hidden"), (0x2600, "instance"), + (0x2700, "Outer"), + (0x2780, "Inner"), ]; for (at, text) in strings { put(&mut i, at, text.as_bytes()); @@ -492,7 +495,7 @@ fn image(version: Version) -> Vec { // The default image: three classes, reached through the handle. The older // lineage stores the handle inline where the newer one points at it. - put(&mut i, 0x300 + type_count_at, &3_u32.to_le_bytes()); + put(&mut i, 0x300 + type_count_at, &5_u32.to_le_bytes()); match version { Version::V2019 => put(&mut i, 0x300 + handle_at, &5_u32.to_le_bytes()), _ => { @@ -506,6 +509,8 @@ fn image(version: Version) -> Vec { ptr(&mut i, 0x480 + 8 * 5, BASE + 0x600); ptr(&mut i, 0x480 + 8 * 6, BASE + 0x800); ptr(&mut i, 0x480 + 8 * 7, BASE + 0xA00); + ptr(&mut i, 0x480 + 8 * 8, BASE + 0x1200); + ptr(&mut i, 0x480 + 8 * 9, BASE + 0x1400); // Il2CppClass: name 0x10, namespace 0x18, parent 0x58, fields 0x80, // static_fields 0xB8, field_count where the lineage keeps it. Field @@ -557,6 +562,16 @@ fn image(version: Version) -> Vec { ptr(&mut i, 0xF00, BASE + 0x2580); // hidden put(&mut i, 0xF00 + 0x18, &0x30_i32.to_le_bytes()); + // Outer in Game, enclosing Inner, whose own namespace is empty and whose + // declaring type points back out. + let outer = 0x1200; + ptr(&mut i, outer + 0x10, BASE + 0x2700); + ptr(&mut i, outer + 0x18, BASE + 0x2180); + let inner = 0x1400; + ptr(&mut i, inner + 0x10, BASE + 0x2780); + ptr(&mut i, inner + 0x18, BASE + 0x27F0); + ptr(&mut i, inner + 0x50, BASE + outer); + // GameManager's statics hold the live instance, which heads with its // class. ptr(&mut i, 0xF40, BASE + 0xF80); @@ -602,7 +617,7 @@ fn classes_resolve_by_name_and_namespace() { assert!(image.get_class(process, module, "Game.Boss").is_some()); assert!(image.get_class(process, module, "Wrong.Boss").is_none()); assert!(image.get_class(process, module, "Nothing").is_none()); - assert_eq!(image.classes(process, module).count(), 3); + assert_eq!(image.classes(process, module).count(), 5); }); } } @@ -623,6 +638,39 @@ fn field_offsets_resolve_declared_and_inherited() { }); } +#[test] +fn nested_classes_resolve_by_their_written_name() { + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image + .get_class(process, module, "Game.Outer+Inner") + .is_some()); + assert!(image + .get_class(process, module, "Game.Outer+Missing") + .is_none()); + assert!(image + .get_class(process, module, "Wrong.Outer+Inner") + .is_none()); + assert!(image + .get_class(process, module, "Game.Enemy+Inner") + .is_none()); + }); +} + +// V2020's table never measured where a class keeps its declaring type, so a +// nested lookup on it must miss cleanly rather than answer with whichever +// class carries the leaf name. +#[test] +fn nested_lookups_without_a_measured_offset_answer_nothing() { + on_fixture(Version::V2020, |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image + .get_class(process, module, "Game.Outer+Inner") + .is_none()); + assert!(image.get_class(process, module, "GameManager").is_some()); + }); +} + // The climb stops at UnityEngine's namespace, so an engine field never // resolves. #[test] diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index f15e36f..2d12b03 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -35,11 +35,13 @@ pub struct AssemblyOffsets { pub image: u16, } -/// Where a class keeps its names, its parent, and its field array. +/// Where a class keeps its names, its parent, its field array, and, when it +/// was measured, the class it is nested in. pub struct ClassOffsets { pub name: u16, pub namespace: u16, pub parent: u16, + pub declaring: Option, pub fields: u16, } diff --git a/src/game_engine/unity/managed/walk.rs b/src/game_engine/unity/managed/walk.rs index eaf32de..7db6158 100644 --- a/src/game_engine/unity/managed/walk.rs +++ b/src/game_engine/unity/managed/walk.rs @@ -79,13 +79,39 @@ impl Walk { } /// Resolves a class by name, with the namespace split off at the last dot - /// when one is written. + /// when one is written. A nested class is written the way .NET writes it, + /// `Outer+Inner`: the runtime stores the innermost name bare, so the leaf + /// is what the lookup matches on, and the written enclosure is checked by + /// climbing. pub fn find_class( &self, process: &Process, image: ImageRef, class_name: &str, ) -> Option { + if let Some(plus) = class_name.find('+') { + let name_space_index = class_name[..plus].rfind('.'); + let (name_space, nested) = match name_space_index { + Some(index) => (&class_name[..index], &class_name[index + 1..]), + None => ("", class_name), + }; + + // Never measured where a class keeps its enclosing class means the + // written enclosure cannot be checked, and an unchecked leaf match + // would be a guess. + let declaring = self.offsets.class.declaring?; + let leaf = nested.rsplit('+').next()?; + + return self + .runtime + .classes(process, self.pointer_size, image) + .find(|&class| { + self.class_name::(process, class) + .is_some_and(|name| name.matches(leaf)) + && self.encloses(process, class, nested, name_space, declaring) + }); + } + let name_space_index = class_name.rfind('.'); self.runtime @@ -107,6 +133,47 @@ impl Walk { }) } + // Whether a class whose own name matched the leaf is the one the written + // name meant: each step out has to be the part written before it, the + // outermost has to be enclosed by nothing, and the namespace belongs to + // the outermost, the leaf's own being empty when nested. + fn encloses( + &self, + process: &Process, + class: ClassRef, + nested: &str, + name_space: &str, + declaring: u16, + ) -> bool { + let enclosing = |class: ClassRef| { + process + .read_pointer(class.address + declaring, self.pointer_size) + .ok() + }; + + let mut outer = class; + for part in nested.rsplit('+').skip(1) { + let Some(address) = enclosing(outer).filter(|address| !address.is_null()) else { + return false; + }; + outer = ClassRef::new(address); + + if !self + .class_name::(process, outer) + .is_some_and(|name| name.matches(part)) + { + return false; + } + } + + if !enclosing(outer).is_some_and(|address| address.is_null()) { + return false; + } + + self.class_namespace::(process, outer) + .is_some_and(|read| read.matches(name_space)) + } + /// Resolves the parent class. pub fn parent(&self, process: &Process, class: ClassRef) -> Option { process diff --git a/src/game_engine/unity/mono/builds.rs b/src/game_engine/unity/mono/builds.rs index c50733c..27fef53 100644 --- a/src/game_engine/unity/mono/builds.rs +++ b/src/game_engine/unity/mono/builds.rs @@ -102,6 +102,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -138,6 +139,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -176,6 +178,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -212,6 +215,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -248,6 +252,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -284,6 +289,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x50, namespace: 0x58, vtable_size: 0x18, @@ -320,6 +326,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -356,6 +363,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -392,6 +400,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -428,6 +437,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -464,6 +474,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x24, + nested_in: Some(0x28), name: 0x34, namespace: 0x38, vtable_size: 0xc, @@ -502,6 +513,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -538,6 +550,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -574,6 +587,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -610,6 +624,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x50, namespace: 0x58, vtable_size: 0x18, @@ -646,6 +661,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -682,6 +698,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x24, + nested_in: Some(0x28), name: 0x34, namespace: 0x38, vtable_size: 0xc, @@ -718,6 +735,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -754,6 +772,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -790,6 +809,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), name: 0x48, namespace: 0x50, vtable_size: 0x5c, @@ -826,6 +846,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -864,6 +885,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x24, + nested_in: Some(0x28), name: 0x30, namespace: 0x34, vtable_size: 0xc, @@ -900,6 +922,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -936,6 +959,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -972,6 +996,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -1008,6 +1033,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), name: 0x2c, namespace: 0x30, vtable_size: 0x38, @@ -1079,6 +1105,22 @@ mod tests { .is_none()); } + // A version table's value for where a class keeps its enclosing class + // must match every measured build it stands in for, or say nothing. + #[test] + fn version_tables_never_contradict_a_measured_build_on_nesting() { + for build in BUILDS { + let Some(table) = MonoOffsets::new(build.version, build.pointer_size, BinaryFormat::PE) + else { + continue; + }; + assert!( + table.class.nested_in.is_none() + || table.class.nested_in == build.offsets.class.nested_in + ); + } + } + // Every build has the layout its version table describes. The one // difference is where the assembly name lives, and mono.dll builds never // read the vtable slot. diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index ff5b0d4..6c42b36 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -288,6 +288,7 @@ impl Module { name: self.offsets.class.name.into(), namespace: self.offsets.class.namespace.into(), parent: self.offsets.class.parent.into(), + declaring: self.offsets.class.nested_in, fields: self.offsets.class.fields.into(), }, field: managed::FieldOffsets { diff --git a/src/game_engine/unity/mono/offsets.rs b/src/game_engine/unity/mono/offsets.rs index f8210a0..195a354 100644 --- a/src/game_engine/unity/mono/offsets.rs +++ b/src/game_engine/unity/mono/offsets.rs @@ -32,6 +32,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), // 2021.3 through 6000.7 name: 0x48, namespace: 0x50, vtable_size: 0x5C, @@ -62,6 +63,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), // 2021.3 through 6000.7 name: 0x2C, namespace: 0x30, vtable_size: 0x38, @@ -92,6 +94,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), // 2017.4 through 2020.1 name: 0x48, namespace: 0x50, vtable_size: 0x5C, @@ -122,6 +125,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x20, + nested_in: Some(0x24), // 2017.4 through 2020.1 name: 0x2C, namespace: 0x30, vtable_size: 0x38, @@ -152,6 +156,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, + nested_in: None, name: 0x50, namespace: 0x58, vtable_size: 0x18, @@ -182,6 +187,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x24, + nested_in: None, name: 0x34, namespace: 0x38, vtable_size: 0xC, @@ -212,6 +218,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x30, + nested_in: Some(0x38), // 5.6 through 2018.4 name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -242,6 +249,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x24, + nested_in: Some(0x28), // 5.6 through 2018.4 name: 0x30, namespace: 0x34, vtable_size: 0xC, @@ -273,6 +281,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, + nested_in: None, name: 0x40, namespace: 0x48, vtable_size: 0x54, @@ -305,6 +314,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, + nested_in: None, name: 0x40, namespace: 0x48, vtable_size: 0x54, @@ -337,6 +347,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, + nested_in: None, name: 0x48, namespace: 0x50, vtable_size: 0x18, @@ -369,6 +380,7 @@ impl MonoOffsets { }, class: ClassOffsets { parent: 0x28, + nested_in: None, name: 0x40, namespace: 0x48, vtable_size: 0x18, @@ -409,6 +421,7 @@ pub(super) struct HashTableOffsets { pub(super) struct ClassOffsets { pub(super) parent: u8, + pub(super) nested_in: Option, // Where a class keeps the one it is nested in pub(super) name: u8, pub(super) namespace: u8, pub(super) vtable_size: u8, // On mono V1 and V1_cattrs, this offset represents MonoVTable.data diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index 736c01d..cac815c 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -3,6 +3,10 @@ //! 2019.4 x64 runtime, copied by hand, so the walk is checked against the //! layout rather than against itself. +use super::offsets::{ + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, HashTableOffsets, ImageOffsets, + MonoVTableOffsets, +}; use super::{builds, BinaryFormat, Module, MonoOffsets, UnityPointer, Version}; use crate::file_format::pe::DebugId; use crate::runtime::mock::with_process; @@ -45,6 +49,8 @@ fn image() -> Vec { (0x2580, "UnityEngine"), (0x2600, "hidden"), (0x2680, "instance"), + (0x2700, "Outer"), + (0x2780, "Inner"), ]; for (at, text) in strings { put(&mut i, at, text.as_bytes()); @@ -90,6 +96,7 @@ fn image() -> Vec { ptr(&mut i, game_manager + 0x98, BASE + 0x1400); ptr(&mut i, game_manager + 0xD0, BASE + 0x1600); put(&mut i, game_manager + 0x100, &3_i32.to_le_bytes()); + ptr(&mut i, game_manager + 0x108, BASE + 0x1B00); ptr(&mut i, 0x1400 + 0x8, BASE + 0x2680); // instance put(&mut i, 0x1400 + 0x18, &0_i32.to_le_bytes()); ptr(&mut i, 0x1420 + 0x8, BASE + 0x2200); // points @@ -127,6 +134,17 @@ fn image() -> Vec { ptr(&mut i, 0x1580 + 0x8, BASE + 0x2600); // hidden put(&mut i, 0x1580 + 0x18, &0x30_i32.to_le_bytes()); + // Outer in Game, enclosing Inner, whose own namespace is empty and whose + // nested_in points back out. + let outer = 0x1B00; + ptr(&mut i, outer + 0x48, BASE + 0x2700); + ptr(&mut i, outer + 0x50, BASE + 0x2180); + ptr(&mut i, outer + 0x108, BASE + 0x1D00); + let inner = 0x1D00; + ptr(&mut i, inner + 0x48, BASE + 0x2780); + ptr(&mut i, inner + 0x50, BASE + 0x27F0); + ptr(&mut i, inner + 0x38, BASE + outer); + // GameManager's statics: runtime_info to the domain vtable, whose static // slot sits past five method pointers, holding the static table. The // table's first slot is the live instance. @@ -195,7 +213,7 @@ fn classes_resolve_by_name_and_namespace() { assert!(image.get_class(process, module, "Game.Boss").is_some()); assert!(image.get_class(process, module, "Wrong.Boss").is_none()); assert!(image.get_class(process, module, "Nothing").is_none()); - assert_eq!(image.classes(process, module).count(), 3); + assert_eq!(image.classes(process, module).count(), 5); }); } @@ -219,6 +237,70 @@ fn field_offsets_resolve_declared_inherited_and_backing() { }); } +#[test] +fn nested_classes_resolve_by_their_written_name() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image + .get_class(process, module, "Game.Outer+Inner") + .is_some()); + assert!(image + .get_class(process, module, "Game.Outer+Missing") + .is_none()); + assert!(image + .get_class(process, module, "Wrong.Outer+Inner") + .is_none()); + assert!(image + .get_class(process, module, "Game.Enemy+Inner") + .is_none()); + }); +} + +// Offsets that never measured where a class keeps its enclosing class must +// miss cleanly rather than answer with whichever class carries the leaf name. +#[test] +fn nested_lookups_without_a_measured_offset_answer_nothing() { + static UNMEASURED: MonoOffsets = MonoOffsets { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x60, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4C0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + parent: 0x30, + nested_in: None, + name: 0x48, + namespace: 0x50, + vtable_size: 0x5C, + fields: 0x98, + runtime_info: 0xD0, + field_count: 0x100, + next_class_cache: 0x108, + }, + field: FieldInfoOffsets { + name: 0x8, + offset: 0x18, + alignment: 0x20, + }, + v_table: MonoVTableOffsets { vtable: 0x40 }, + }; + + on_fixture(&UNMEASURED, |process, module| { + let image = module.get_default_image(process).unwrap(); + assert!(image + .get_class(process, module, "Game.Outer+Inner") + .is_none()); + assert!(image.get_class(process, module, "GameManager").is_some()); + }); +} + // The climb stops at UnityEngine's namespace, so an engine field never // resolves. #[test] From 29cec4c76eac86d688a7119dff27dfd918c884a0 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 22:49:25 +0200 Subject: [PATCH 04/11] add definition route for generic field counts --- src/game_engine/unity/il2cpp/builds.rs | 2 +- src/game_engine/unity/managed/runtime.rs | 56 +++++++- src/game_engine/unity/managed/walk.rs | 2 +- src/game_engine/unity/mono/builds.rs | 161 +++++++++++++++++++++-- src/game_engine/unity/mono/mod.rs | 3 + src/game_engine/unity/mono/offsets.rs | 69 ++++++++++ src/game_engine/unity/mono/walk_tests.rs | 48 ++++++- 7 files changed, 325 insertions(+), 16 deletions(-) diff --git a/src/game_engine/unity/il2cpp/builds.rs b/src/game_engine/unity/il2cpp/builds.rs index 459ed2e..b94991c 100644 --- a/src/game_engine/unity/il2cpp/builds.rs +++ b/src/game_engine/unity/il2cpp/builds.rs @@ -830,7 +830,7 @@ mod tests { // A version table's value for where a class keeps its declaring type // must match every measured build it stands in for, or say nothing. #[test] - fn version_tables_never_contradict_a_measured_build_on_nesting() { + fn version_tables_never_contradict_a_measured_build() { for build in BUILDS { let Some(table) = IL2CPPOffsets::new(build.version, build.pointer_size) else { continue; diff --git a/src/game_engine/unity/managed/runtime.rs b/src/game_engine/unity/managed/runtime.rs index f7fbf47..1a42145 100644 --- a/src/game_engine/unity/managed/runtime.rs +++ b/src/game_engine/unity/managed/runtime.rs @@ -9,6 +9,11 @@ pub enum Runtime { Il2Cpp(Il2CppRuntime), } +/// The low bits of the class kind byte, whose value 3 marks a generic +/// instance. +const CLASS_KIND_MASK: u8 = 0x7; +const GENERIC_INSTANCE_KIND: u8 = 3; + /// Mono keeps its assemblies in a glib list, its classes in each image's hash /// table, and its statics behind the class's vtable. pub struct MonoRuntime { @@ -18,6 +23,9 @@ pub struct MonoRuntime { pub hash_table_table: u16, pub next_class_cache: u16, pub field_count: u16, + pub class_kind: Option, + pub generic_class: Option, + pub container_class: Option, pub runtime_info: u16, pub vtable_size: u16, pub vtable: u16, @@ -40,6 +48,43 @@ pub struct Il2CppRuntime { pub static_fields: u16, } +impl MonoRuntime { + // 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. + fn counted_class( + &self, + process: &Process, + pointer_size: PointerSize, + class: ClassRef, + ) -> ClassRef { + let (Some(class_kind), Some(generic_class), Some(container_class)) = + (self.class_kind, self.generic_class, self.container_class) + else { + return class; + }; + + let kind = process + .read::(class.address + class_kind) + .unwrap_or_default(); + if kind & CLASS_KIND_MASK != GENERIC_INSTANCE_KIND { + return class; + } + + process + .read_pointer(class.address + generic_class, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .and_then(|descriptor| { + process + .read_pointer(descriptor + container_class, pointer_size) + .ok() + }) + .filter(|address| !address.is_null()) + .map_or(class, ClassRef::new) + } +} + impl Runtime { /// Walks the assemblies the target has loaded. pub fn assemblies<'a>( @@ -67,10 +112,17 @@ impl Runtime { } /// Reads how many fields a class declares. - pub fn field_count(&self, process: &Process, class: ClassRef) -> u64 { + pub fn field_count( + &self, + process: &Process, + pointer_size: PointerSize, + class: ClassRef, + ) -> u64 { match self { Self::Mono(mono) => process - .read::(class.address + mono.field_count) + .read::( + mono.counted_class(process, pointer_size, class).address + mono.field_count, + ) .ok() .filter(|&count| count > 0) .unwrap_or_default() as u64, diff --git a/src/game_engine/unity/managed/walk.rs b/src/game_engine/unity/managed/walk.rs index 7db6158..f02db6d 100644 --- a/src/game_engine/unity/managed/walk.rs +++ b/src/game_engine/unity/managed/walk.rs @@ -210,7 +210,7 @@ impl Walk { this_class = self.parent(process, class); - let field_count = self.runtime.field_count(process, class); + let field_count = self.runtime.field_count(process, self.pointer_size, class); let fields = match field_count { 0 => None, diff --git a/src/game_engine/unity/mono/builds.rs b/src/game_engine/unity/mono/builds.rs index 27fef53..30e0281 100644 --- a/src/game_engine/unity/mono/builds.rs +++ b/src/game_engine/unity/mono/builds.rs @@ -2,8 +2,8 @@ //! PDB says which binary. The offsets come from that PDB. use super::offsets::{ - AssemblyOffsets, ClassOffsets, FieldInfoOffsets, HashTableOffsets, ImageOffsets, MonoOffsets, - MonoVTableOffsets, + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, + ImageOffsets, MonoOffsets, MonoVTableOffsets, }; use super::Version; use crate::{file_format::pe::DebugId, PointerSize}; @@ -101,6 +101,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -111,6 +112,10 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -138,6 +143,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -148,6 +154,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -177,6 +187,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -187,6 +198,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -214,6 +229,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -224,6 +240,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -251,6 +271,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -261,6 +282,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -288,6 +313,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: Some(0x38), name: 0x50, @@ -298,6 +324,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -325,6 +355,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -335,6 +366,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -362,6 +397,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -372,6 +408,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -399,6 +439,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -409,6 +450,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -436,6 +481,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -446,6 +492,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -473,6 +523,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: None, parent: 0x24, nested_in: Some(0x28), name: 0x34, @@ -483,6 +534,10 @@ static BUILDS: &[Build] = &[ field_count: 0x68, next_class_cache: 0xac, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -512,6 +567,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -522,6 +578,10 @@ static BUILDS: &[Build] = &[ field_count: 0x94, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -549,6 +609,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -559,6 +620,10 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -586,6 +651,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -596,6 +662,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -623,6 +693,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: Some(0x38), name: 0x50, @@ -633,6 +704,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -660,6 +735,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -670,6 +746,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -697,6 +777,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: None, parent: 0x24, nested_in: Some(0x28), name: 0x34, @@ -707,6 +788,10 @@ static BUILDS: &[Build] = &[ field_count: 0x68, next_class_cache: 0xac, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -734,6 +819,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -744,6 +830,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -771,6 +861,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1b), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -781,6 +872,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -808,6 +903,7 @@ static BUILDS: &[Build] = &[ table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2a), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -818,6 +914,10 @@ static BUILDS: &[Build] = &[ field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xf0), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -845,6 +945,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -855,6 +956,10 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -884,6 +989,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: None, parent: 0x24, nested_in: Some(0x28), name: 0x30, @@ -894,6 +1000,10 @@ static BUILDS: &[Build] = &[ field_count: 0x64, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -921,6 +1031,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1e), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -931,6 +1042,10 @@ static BUILDS: &[Build] = &[ field_count: 0xa4, next_class_cache: 0xa8, }, + generic: GenericOffsets { + generic_class: Some(0x94), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -958,6 +1073,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -968,6 +1084,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -995,6 +1115,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -1005,6 +1126,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -1032,6 +1157,7 @@ static BUILDS: &[Build] = &[ table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xf), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -1042,6 +1168,10 @@ static BUILDS: &[Build] = &[ field_count: 0x9c, next_class_cache: 0xa0, }, + generic: GenericOffsets { + generic_class: Some(0x8c), + container_class: Some(0x0), + }, field: FieldInfoOffsets { name: 0x4, offset: 0xc, @@ -1105,19 +1235,32 @@ mod tests { .is_none()); } - // A version table's value for where a class keeps its enclosing class - // must match every measured build it stands in for, or say nothing. + // A version table's value for any of the grown members must match every + // measured build it stands in for, or say nothing. #[test] - fn version_tables_never_contradict_a_measured_build_on_nesting() { + fn version_tables_never_contradict_a_measured_build() { + fn agrees(table: Option, measured: Option) -> bool { + table.is_none() || table == measured + } + for build in BUILDS { let Some(table) = MonoOffsets::new(build.version, build.pointer_size, BinaryFormat::PE) else { continue; }; - assert!( - table.class.nested_in.is_none() - || table.class.nested_in == build.offsets.class.nested_in - ); + assert!(agrees(table.class.nested_in, build.offsets.class.nested_in)); + assert!(agrees( + table.class.class_kind, + build.offsets.class.class_kind + )); + assert!(agrees( + table.generic.generic_class, + build.offsets.generic.generic_class + )); + assert!(agrees( + table.generic.container_class, + build.offsets.generic.container_class + )); } } diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index 6c42b36..e0c6185 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -273,6 +273,9 @@ impl Module { hash_table_table: self.offsets.hash_table.table.into(), next_class_cache: self.offsets.class.next_class_cache, field_count: self.offsets.class.field_count, + class_kind: self.offsets.class.class_kind, + generic_class: self.offsets.generic.generic_class, + container_class: self.offsets.generic.container_class, runtime_info: self.offsets.class.runtime_info, vtable_size: self.offsets.class.vtable_size.into(), vtable: self.offsets.v_table.vtable.into(), diff --git a/src/game_engine/unity/mono/offsets.rs b/src/game_engine/unity/mono/offsets.rs index 195a354..bfee22c 100644 --- a/src/game_engine/unity/mono/offsets.rs +++ b/src/game_engine/unity/mono/offsets.rs @@ -6,6 +6,7 @@ pub(super) struct MonoOffsets { pub(super) image: ImageOffsets, pub(super) hash_table: HashTableOffsets, pub(super) class: ClassOffsets, + pub(super) generic: GenericOffsets, pub(super) field: FieldInfoOffsets, pub(super) v_table: MonoVTableOffsets, } @@ -31,6 +32,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x1B), // 2021.3 through 6000.7 parent: 0x30, nested_in: Some(0x38), // 2021.3 through 6000.7 name: 0x48, @@ -41,6 +43,10 @@ impl MonoOffsets { field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xF0), // 2021.3 through 6000.7 + container_class: Some(0x0), // 2021.3 through 6000.7 + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -62,6 +68,7 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: Some(0xF), // 2021.3 through 6000.7 parent: 0x20, nested_in: Some(0x24), // 2021.3 through 6000.7 name: 0x2C, @@ -72,6 +79,10 @@ impl MonoOffsets { field_count: 0x9C, next_class_cache: 0xA0, }, + generic: GenericOffsets { + generic_class: Some(0x8C), // 2021.3 through 6000.7 + container_class: Some(0x0), // 2021.3 through 6000.7 + }, field: FieldInfoOffsets { name: 0x4, offset: 0xC, @@ -93,6 +104,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: Some(0x2A), // 2017.4 through 2020.1 parent: 0x30, nested_in: Some(0x38), // 2017.4 through 2020.1 name: 0x48, @@ -103,6 +115,10 @@ impl MonoOffsets { field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: Some(0xF0), // 2017.4 through 2020.1 + container_class: Some(0x0), // 2017.4 through 2020.1 + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -124,6 +140,7 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: Some(0x1E), // 2017.4 through 2020.1 parent: 0x20, nested_in: Some(0x24), // 2017.4 through 2020.1 name: 0x2C, @@ -134,6 +151,10 @@ impl MonoOffsets { field_count: 0xA4, next_class_cache: 0xA8, }, + generic: GenericOffsets { + generic_class: Some(0x94), // 2017.4 through 2020.1 + container_class: Some(0x0), // 2017.4 through 2020.1 + }, field: FieldInfoOffsets { name: 0x4, offset: 0xC, @@ -155,6 +176,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: None, name: 0x50, @@ -165,6 +187,10 @@ impl MonoOffsets { field_count: 0x9C, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -186,6 +212,7 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: None, parent: 0x24, nested_in: None, name: 0x34, @@ -196,6 +223,10 @@ impl MonoOffsets { field_count: 0x68, next_class_cache: 0xAC, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x4, offset: 0xC, @@ -217,6 +248,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: Some(0x38), // 5.6 through 2018.4 name: 0x48, @@ -227,6 +259,10 @@ impl MonoOffsets { field_count: 0x94, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -248,6 +284,7 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { + class_kind: None, parent: 0x24, nested_in: Some(0x28), // 5.6 through 2018.4 name: 0x30, @@ -258,6 +295,10 @@ impl MonoOffsets { field_count: 0x64, next_class_cache: 0xA8, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x4, offset: 0xC, @@ -280,6 +321,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x28, nested_in: None, name: 0x40, @@ -290,6 +332,10 @@ impl MonoOffsets { field_count: 0xF8, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -313,6 +359,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x28, nested_in: None, name: 0x40, @@ -323,6 +370,10 @@ impl MonoOffsets { field_count: 0xF8, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -346,6 +397,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x28, nested_in: None, name: 0x48, @@ -356,6 +408,10 @@ impl MonoOffsets { field_count: 0x94, next_class_cache: 0x100, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -379,6 +435,7 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x28, nested_in: None, name: 0x40, @@ -389,6 +446,10 @@ impl MonoOffsets { field_count: 0x8C, next_class_cache: 0xF8, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -420,6 +481,7 @@ pub(super) struct HashTableOffsets { } pub(super) struct ClassOffsets { + pub(super) class_kind: Option, // The byte whose low bits say what kind of class it is pub(super) parent: u8, pub(super) nested_in: Option, // Where a class keeps the one it is nested in pub(super) name: u8, @@ -431,6 +493,13 @@ pub(super) struct ClassOffsets { pub(super) next_class_cache: u16, } +// MonoClassGenericInst keeps the instantiation descriptor, whose container is +// the generic definition the instance was made from. +pub(super) struct GenericOffsets { + pub(super) generic_class: Option, + pub(super) container_class: Option, +} + pub(super) struct FieldInfoOffsets { pub(super) name: u8, pub(super) offset: u8, diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index cac815c..308c4d0 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -4,8 +4,8 @@ //! layout rather than against itself. use super::offsets::{ - AssemblyOffsets, ClassOffsets, FieldInfoOffsets, HashTableOffsets, ImageOffsets, - MonoVTableOffsets, + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, + ImageOffsets, MonoVTableOffsets, }; use super::{builds, BinaryFormat, Module, MonoOffsets, UnityPointer, Version}; use crate::file_format::pe::DebugId; @@ -51,6 +51,8 @@ fn image() -> Vec { (0x2680, "instance"), (0x2700, "Outer"), (0x2780, "Inner"), + (0x2B00, "Inventory"), + (0x2B80, "items"), ]; for (at, text) in strings { put(&mut i, at, text.as_bytes()); @@ -144,6 +146,22 @@ fn image() -> Vec { ptr(&mut i, inner + 0x48, BASE + 0x2780); ptr(&mut i, inner + 0x50, BASE + 0x27F0); ptr(&mut i, inner + 0x38, BASE + outer); + ptr(&mut i, inner + 0x108, BASE + 0x2D00); + + // Inventory, a generic instance: its class kind's low bits read 3, its own + // field count slot holds nothing, and the count lives on the definition + // reached through the instantiation descriptor. The inflated field array + // is the instance's own. + let inventory = 0x2D00; + ptr(&mut i, inventory + 0x48, BASE + 0x2B00); + ptr(&mut i, inventory + 0x50, BASE + 0x2180); + put(&mut i, inventory + 0x2A, &3_u8.to_le_bytes()); + ptr(&mut i, inventory + 0x98, BASE + 0x3400); + ptr(&mut i, inventory + 0xF0, BASE + 0x3000); + ptr(&mut i, 0x3000, BASE + 0x3100); // descriptor: container_class at 0x0 + put(&mut i, 0x3100 + 0x100, &1_i32.to_le_bytes()); // the definition's count + ptr(&mut i, 0x3400 + 0x8, BASE + 0x2B80); // items + put(&mut i, 0x3400 + 0x18, &0x28_i32.to_le_bytes()); // GameManager's statics: runtime_info to the domain vtable, whose static // slot sits past five method pointers, holding the static table. The @@ -213,7 +231,7 @@ fn classes_resolve_by_name_and_namespace() { assert!(image.get_class(process, module, "Game.Boss").is_some()); assert!(image.get_class(process, module, "Wrong.Boss").is_none()); assert!(image.get_class(process, module, "Nothing").is_none()); - assert_eq!(image.classes(process, module).count(), 5); + assert_eq!(image.classes(process, module).count(), 6); }); } @@ -274,6 +292,7 @@ fn nested_lookups_without_a_measured_offset_answer_nothing() { table: 0x20, }, class: ClassOffsets { + class_kind: None, parent: 0x30, nested_in: None, name: 0x48, @@ -284,6 +303,10 @@ fn nested_lookups_without_a_measured_offset_answer_nothing() { field_count: 0x100, next_class_cache: 0x108, }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, field: FieldInfoOffsets { name: 0x8, offset: 0x18, @@ -298,6 +321,25 @@ fn nested_lookups_without_a_measured_offset_answer_nothing() { .get_class(process, module, "Game.Outer+Inner") .is_none()); assert!(image.get_class(process, module, "GameManager").is_some()); + + let inventory = image.get_class(process, module, "Inventory").unwrap(); + assert!(inventory + .get_field_offset(process, module, "items") + .is_none()); + }); +} + +// A generic instance declares no count of its own; the definition it was made +// from holds it, and the inflated fields are the instance's. +#[test] +fn generic_field_counts_resolve_through_the_definition() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); + let inventory = image.get_class(process, module, "Inventory").unwrap(); + assert_eq!( + inventory.get_field_offset(process, module, "items"), + Some(0x28), + ); }); } From 26f364af08a8c07bfdabcc2f5023cc4dc4fb4bbd Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 23:07:15 +0200 Subject: [PATCH 05/11] move declaring type offset before parent --- src/game_engine/unity/il2cpp/builds.rs | 44 ++++++++++++------------- src/game_engine/unity/il2cpp/offsets.rs | 10 +++--- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/game_engine/unity/il2cpp/builds.rs b/src/game_engine/unity/il2cpp/builds.rs index b94991c..9b8c31c 100644 --- a/src/game_engine/unity/il2cpp/builds.rs +++ b/src/game_engine/unity/il2cpp/builds.rs @@ -62,8 +62,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x114, @@ -95,8 +95,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -128,8 +128,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x11c, @@ -161,8 +161,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xa8, @@ -194,8 +194,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x11c, @@ -227,8 +227,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xa8, @@ -260,8 +260,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x120, @@ -293,8 +293,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xa8, @@ -326,8 +326,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -359,8 +359,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -392,8 +392,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -425,8 +425,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -458,8 +458,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -491,8 +491,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -524,8 +524,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -557,8 +557,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -590,8 +590,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xb8, field_count: 0x124, @@ -623,8 +623,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x5c, field_count: 0xac, @@ -656,8 +656,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0xa0, field_count: 0x124, @@ -689,8 +689,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x50, field_count: 0xac, @@ -722,8 +722,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), + parent: 0x58, fields: 0x80, static_fields: 0x98, field_count: 0x11c, @@ -755,8 +755,8 @@ static BUILDS: &[Build] = &[ class: ClassOffsets { name: 0x8, namespace: 0xc, - parent: 0x2c, declaring_type: Some(0x28), + parent: 0x2c, fields: 0x40, static_fields: 0x4c, field_count: 0xac, diff --git a/src/game_engine/unity/il2cpp/offsets.rs b/src/game_engine/unity/il2cpp/offsets.rs index 62552d9..a82acf6 100644 --- a/src/game_engine/unity/il2cpp/offsets.rs +++ b/src/game_engine/unity/il2cpp/offsets.rs @@ -24,8 +24,8 @@ impl IL2CPPOffsets { class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), // 2023.1 through 6000.7 + parent: 0x58, fields: 0x80, static_fields: 0xB8, field_count: 0x124, @@ -49,8 +49,8 @@ impl IL2CPPOffsets { class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: None, + parent: 0x58, fields: 0x80, static_fields: 0xB8, field_count: 0x120, @@ -74,8 +74,8 @@ impl IL2CPPOffsets { class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: Some(0x50), // 2019.4, 2020.1 + parent: 0x58, fields: 0x80, static_fields: 0xB8, field_count: 0x11C, @@ -99,8 +99,8 @@ impl IL2CPPOffsets { class: ClassOffsets { name: 0x10, namespace: 0x18, - parent: 0x58, declaring_type: None, + parent: 0x58, fields: 0x80, static_fields: 0xB8, field_count: 0x114, @@ -131,8 +131,8 @@ pub(super) struct ImageOffsets { pub(super) struct ClassOffsets { pub(super) name: u8, pub(super) namespace: u8, - pub(super) parent: u8, pub(super) declaring_type: Option, // Where a class keeps the one declaring it + pub(super) parent: u8, pub(super) fields: u8, pub(super) static_fields: u8, pub(super) field_count: u16, From 9eb4f4ee173409d1087986e021a124e2851c8000 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 23:30:39 +0200 Subject: [PATCH 06/11] fix static reads for inherited fields --- src/game_engine/unity/il2cpp/class.rs | 15 +++---- src/game_engine/unity/il2cpp/walk_tests.rs | 38 +++++++++++++++-- src/game_engine/unity/managed/pointer.rs | 34 +++++++++------ src/game_engine/unity/mono/class.rs | 15 +++---- src/game_engine/unity/mono/walk_tests.rs | 49 +++++++++++++++++++--- src/runtime/mock.rs | 14 ++++++- 6 files changed, 130 insertions(+), 35 deletions(-) diff --git a/src/game_engine/unity/il2cpp/class.rs b/src/game_engine/unity/il2cpp/class.rs index 2358971..c42fb82 100644 --- a/src/game_engine/unity/il2cpp/class.rs +++ b/src/game_engine/unity/il2cpp/class.rs @@ -36,15 +36,16 @@ impl Class { module: &Module, field_name: &str, ) -> Address { - let static_table = self.wait_get_static_table(process, module).await; - let field_offset = self - .wait_get_field_offset(process, module, field_name) - .await; - let singleton_location = static_table + field_offset; - + // The field's offset measures into the static table of whichever + // class declares it, which a climb may find on a parent. retry(|| { + let walk = module.walk(); + let (class, offset) = + walk.find_field_offset(process, ClassRef::new(self.class), field_name)?; + let static_table = walk.static_table(process, class)?; + process - .read_pointer(singleton_location, module.pointer_size) + .read_pointer(static_table + offset, module.pointer_size) .ok() .filter(|val| !val.is_null()) }) diff --git a/src/game_engine/unity/il2cpp/walk_tests.rs b/src/game_engine/unity/il2cpp/walk_tests.rs index c3894ec..a3da7ed 100644 --- a/src/game_engine/unity/il2cpp/walk_tests.rs +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -5,9 +5,11 @@ //! so the walk is checked against the layout rather than against itself. use super::{IL2CPPOffsets, Module, UnityPointer, Version}; -use crate::runtime::mock::with_process; +use crate::runtime::mock::{poll_once, with_process}; use crate::{Address, PointerSize, Process}; +use core::task::Poll; + use std::vec; use std::vec::Vec; @@ -476,6 +478,7 @@ fn image(version: Version) -> Vec { (0x2600, "instance"), (0x2700, "Outer"), (0x2780, "Inner"), + (0x2800, "spawner"), ]; for (at, text) in strings { put(&mut i, at, text.as_bytes()); @@ -530,20 +533,25 @@ fn image(version: Version) -> Vec { ptr(&mut i, 0xE20, BASE + 0x2200); // points put(&mut i, 0xE20 + 0x18, &0x20_i32.to_le_bytes()); - // Enemy, and Boss deriving from it. + // Enemy with an instance field and a static slot, and Boss deriving from + // it. let enemy = 0x800; ptr(&mut i, enemy + 0x10, BASE + 0x2280); ptr(&mut i, enemy + 0x18, BASE + 0x2180); ptr(&mut i, enemy + 0x80, BASE + 0xE80); - put(&mut i, enemy + field_count_at, &1_u16.to_le_bytes()); + ptr(&mut i, enemy + 0xB8, BASE + 0xFC0); + put(&mut i, enemy + field_count_at, &2_u16.to_le_bytes()); ptr(&mut i, 0xE80, BASE + 0x2300); // hp put(&mut i, 0xE80 + 0x18, &0x10_i32.to_le_bytes()); + ptr(&mut i, 0xEA0, BASE + 0x2800); // spawner + put(&mut i, 0xEA0 + 0x18, &0x8_i32.to_le_bytes()); let boss = 0xA00; ptr(&mut i, boss + 0x10, BASE + 0x2380); ptr(&mut i, boss + 0x18, BASE + 0x2180); ptr(&mut i, boss + 0x58, BASE + enemy); ptr(&mut i, boss + 0x80, BASE + 0xEC0); + ptr(&mut i, boss + 0xB8, BASE + 0x1000); put(&mut i, boss + field_count_at, &1_u16.to_le_bytes()); ptr(&mut i, 0xEC0, BASE + 0x2400); // phase put(&mut i, 0xEC0 + 0x18, &0x18_i32.to_le_bytes()); @@ -578,6 +586,10 @@ fn image(version: Version) -> Vec { ptr(&mut i, 0xF80, BASE + game_manager); put(&mut i, 0xF80 + 0x20, &888_u32.to_le_bytes()); + // Enemy's statics hold the spawner instance. Boss carries a table of its + // own, empty at that offset, so only the declaring class's table answers. + ptr(&mut i, 0xFC0 + 0x8, BASE + 0x1080); + i } @@ -696,6 +708,26 @@ fn statics_resolve_from_the_class() { }); } +// 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] +fn static_instances_resolve_through_the_declaring_class() { + on_fixture(Version::V2022, |process, module| { + let image = module.get_default_image(process).unwrap(); + let boss = image.get_class(process, module, "Boss").unwrap(); + assert_eq!( + poll_once(boss.wait_get_static_instance(process, module, "spawner")), + Poll::Ready(Address::new(BASE + 0x1080)), + ); + + let pointer = UnityPointer::<1>::new("Boss", 0, &["spawner"]); + assert_eq!( + pointer.deref::(process, module, &image).unwrap(), + BASE + 0x1080, + ); + }); +} + // The whole pointer path: the static root, the instance behind it, and a field // resolved against the object's own class read off its head. #[test] diff --git a/src/game_engine/unity/managed/pointer.rs b/src/game_engine/unity/managed/pointer.rs index 2393284..0dffb42 100644 --- a/src/game_engine/unity/managed/pointer.rs +++ b/src/game_engine/unity/managed/pointer.rs @@ -68,8 +68,25 @@ impl PointerPath { } }; - if inner.base_address.is_null() { - inner.base_address = walk.static_table(process, starting_class).ok_or(Error {})?; + let parse = |field: &str| match field.strip_prefix("0x") { + Some(rem) => u32::from_str_radix(rem, 16).ok(), + _ => field.parse().ok(), + }; + + // The root field and the base table resolve together: the root's + // offset measures into the static table of whichever class declares + // it, which a climb may find on a parent. + if inner.resolved_offsets == 0 { + let (declaring, offset) = match parse(inner.fields[0]) { + Some(offset) => (starting_class, offset), + _ => walk + .find_field_offset(process, starting_class, inner.fields[0]) + .ok_or(Error {})?, + }; + + inner.base_address = walk.static_table(process, declaring).ok_or(Error {})?; + inner.offsets[0] = offset; + inner.resolved_offsets = 1; } // Whatever resolved already is walked again from the base, which is @@ -83,18 +100,11 @@ impl PointerPath { }; for i in inner.resolved_offsets..inner.depth { - let offset_from_string = match inner.fields[i].strip_prefix("0x") { - Some(rem) => u32::from_str_radix(rem, 16).ok(), - _ => inner.fields[i].parse().ok(), - }; - - let current_offset = match offset_from_string { + let current_offset = match parse(inner.fields[i]) { Some(offset) => offset, _ => { - let current_class = match i { - 0 => starting_class, - _ => walk.object_class(process, current_object).ok_or(Error {})?, - }; + let current_class = + walk.object_class(process, current_object).ok_or(Error {})?; walk.find_field_offset(process, current_class, inner.fields[i]) .ok_or(Error {})? diff --git a/src/game_engine/unity/mono/class.rs b/src/game_engine/unity/mono/class.rs index d958b84..a07bb6d 100644 --- a/src/game_engine/unity/mono/class.rs +++ b/src/game_engine/unity/mono/class.rs @@ -35,15 +35,16 @@ impl Class { module: &Module, field_name: &str, ) -> Address { - let static_table = self.wait_get_static_table(process, module).await; - let field_offset = self - .wait_get_field_offset(process, module, field_name) - .await; - let singleton_location = static_table + field_offset; - + // The field's offset measures into the static table of whichever + // class declares it, which a climb may find on a parent. retry(|| { + let walk = module.walk(); + let (class, offset) = + walk.find_field_offset(process, ClassRef::new(self.class), field_name)?; + let static_table = walk.static_table(process, class)?; + process - .read_pointer(singleton_location, module.pointer_size) + .read_pointer(static_table + offset, module.pointer_size) .ok() .filter(|addr| !addr.is_null()) }) diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index 308c4d0..e6bd957 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -9,9 +9,11 @@ use super::offsets::{ }; use super::{builds, BinaryFormat, Module, MonoOffsets, UnityPointer, Version}; use crate::file_format::pe::DebugId; -use crate::runtime::mock::with_process; +use crate::runtime::mock::{poll_once, with_process}; use crate::{Address, PointerSize, Process}; +use core::task::Poll; + use std::vec; use std::vec::Vec; @@ -28,7 +30,7 @@ fn ptr(image: &mut [u8], at: u64, target: u64) { // The target's structures, hand-laid. Two assemblies whose GList the walk // follows, a class cache of two buckets with one chained class, a parent chain -// reaching a UnityEngine class, a static table reachable through the vtable, +// reaching a UnityEngine class, static tables reachable through the vtables, // and a live object carrying its class through its vtable. fn image() -> Vec { let mut i = vec![0; 0x4000]; @@ -51,6 +53,7 @@ fn image() -> Vec { (0x2680, "instance"), (0x2700, "Outer"), (0x2780, "Inner"), + (0x2800, "spawner"), (0x2B00, "Inventory"), (0x2B80, "items"), ]; @@ -106,22 +109,29 @@ fn image() -> Vec { ptr(&mut i, 0x1440 + 0x8, BASE + 0x2280); // k__BackingField put(&mut i, 0x1440 + 0x18, &0x24_i32.to_le_bytes()); - // Enemy, with one field and Boss chained behind it in the bucket. + // Enemy, with an instance field and a static slot, and Boss chained + // behind it in the bucket. let enemy = 0xE00; ptr(&mut i, enemy + 0x48, BASE + 0x2300); ptr(&mut i, enemy + 0x50, BASE + 0x2180); + put(&mut i, enemy + 0x5C, &1_i32.to_le_bytes()); ptr(&mut i, enemy + 0x98, BASE + 0x1500); - put(&mut i, enemy + 0x100, &1_i32.to_le_bytes()); + ptr(&mut i, enemy + 0xD0, BASE + 0x1620); + put(&mut i, enemy + 0x100, &2_i32.to_le_bytes()); ptr(&mut i, enemy + 0x108, BASE + 0x1000); ptr(&mut i, 0x1500 + 0x8, BASE + 0x2380); // hp put(&mut i, 0x1500 + 0x18, &0x10_i32.to_le_bytes()); + ptr(&mut i, 0x1520 + 0x8, BASE + 0x2800); // spawner + put(&mut i, 0x1520 + 0x18, &0x8_i32.to_le_bytes()); // Boss, deriving from Enemy, with one field of its own. let boss = 0x1000; ptr(&mut i, boss + 0x30, BASE + enemy); ptr(&mut i, boss + 0x48, BASE + 0x2400); ptr(&mut i, boss + 0x50, BASE + 0x2180); + put(&mut i, boss + 0x5C, &2_i32.to_le_bytes()); ptr(&mut i, boss + 0x98, BASE + 0x1540); + ptr(&mut i, boss + 0xD0, BASE + 0x1650); put(&mut i, boss + 0x100, &1_i32.to_le_bytes()); ptr(&mut i, 0x1540 + 0x8, BASE + 0x2480); // phase put(&mut i, 0x1540 + 0x18, &0x18_i32.to_le_bytes()); @@ -170,6 +180,15 @@ fn image() -> Vec { ptr(&mut i, 0x1700 + 0x40 + 8 * 5, BASE + 0x1800); ptr(&mut i, 0x1800, BASE + 0x1900); + // Enemy's statics, holding the spawner instance. Boss carries a table of + // its own, empty at that offset, so only the declaring class's table + // answers. + ptr(&mut i, 0x1620 + 0x8, BASE + 0x1780); + ptr(&mut i, 0x1780 + 0x40 + 8, BASE + 0x1880); + ptr(&mut i, 0x1880 + 0x8, BASE + 0x1980); + ptr(&mut i, 0x1650 + 0x8, BASE + 0x1A40); + ptr(&mut i, 0x1A40 + 0x40 + 8 * 2, BASE + 0x1AC0); + // The instance object: its vtable heads it, and the vtable's own head is // the class. The points field holds a recognizable value. ptr(&mut i, 0x1900, BASE + 0x1A00); @@ -366,8 +385,28 @@ fn statics_resolve_through_the_vtable() { Some(Address::new(BASE + 0x1800)), ); + let outer = image.get_class(process, module, "Game.Outer").unwrap(); + assert!(outer.get_static_table(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] +fn static_instances_resolve_through_the_declaring_class() { + on_fixture(era(), |process, module| { + let image = module.get_default_image(process).unwrap(); let boss = image.get_class(process, module, "Boss").unwrap(); - assert!(boss.get_static_table(process, module).is_none()); + assert_eq!( + poll_once(boss.wait_get_static_instance(process, module, "spawner")), + Poll::Ready(Address::new(BASE + 0x1980)), + ); + + let pointer = UnityPointer::<1>::new("Boss", 0, &["spawner"]); + assert_eq!( + pointer.deref::(process, module, &image).unwrap(), + BASE + 0x1980, + ); }); } diff --git a/src/runtime/mock.rs b/src/runtime/mock.rs index 3bde879..cc11df2 100644 --- a/src/runtime/mock.rs +++ b/src/runtime/mock.rs @@ -1,7 +1,12 @@ //! A fake host for tests: definitions of the wasm imports the runtime layer //! links against, backed by in-memory images so readers can run on the host. -use core::{cell::RefCell, num::NonZeroU64}; +use core::{ + cell::RefCell, + future::Future, + num::NonZeroU64, + task::{Context, Poll, Waker}, +}; use std::{ string::{String, ToString}, @@ -78,6 +83,13 @@ extern "C" fn process_get_module_size( NonZeroU64::new(module(name_ptr, name_len)?.1) } +/// Polls a future a single time. The mock host answers everything +/// synchronously, so a future either resolves on its first poll or sits on a +/// condition the fixture never satisfies. +pub fn poll_once(future: F) -> Poll { + core::pin::pin!(future).poll(&mut Context::from_waker(Waker::noop())) +} + #[no_mangle] extern "C" fn process_attach(_name_ptr: *const u8, _name_len: usize) -> Option { NonZeroU64::new(1) From ccba537311107b7104693d9c8b033837c8c595b6 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 03:05:27 +0200 Subject: [PATCH 07/11] add managed string reads --- src/game_engine/unity/il2cpp/mod.rs | 22 +++- src/game_engine/unity/il2cpp/readers_tests.rs | 56 +++++++++ src/game_engine/unity/managed/mod.rs | 2 + src/game_engine/unity/managed/readers.rs | 40 +++++++ src/game_engine/unity/mono/mod.rs | 21 +++- src/game_engine/unity/mono/readers_tests.rs | 106 ++++++++++++++++++ 6 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 src/game_engine/unity/il2cpp/readers_tests.rs create mode 100644 src/game_engine/unity/managed/readers.rs create mode 100644 src/game_engine/unity/mono/readers_tests.rs diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index a724f93..8a3f4e4 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -1,8 +1,8 @@ //! Support for attaching to Unity games that are using the IL2CPP backend. use crate::{ - file_format::pe, future::retry, print_limited, signature::Signature, Address, PointerSize, - Process, + file_format::pe, future::retry, print_limited, signature::Signature, string::ArrayWString, + Address, Error, PointerSize, Process, }; mod builds; @@ -17,6 +17,8 @@ pub use pointer::UnityPointer; mod offsets; use offsets::IL2CPPOffsets; #[cfg(all(test, not(target_family = "wasm")))] +mod readers_tests; +#[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; use super::managed; @@ -317,6 +319,22 @@ impl Module { self.get_image(process, "Assembly-CSharp") } + /// Reads a managed string through the reference stored at the given + /// address, such as the end of a pointer path or a slot in a static + /// table. The string carries its own character count, so no length is + /// passed; `N` bounds how many UTF-16 characters the returned buffer + /// holds, and a string claiming more than that fails rather than + /// truncates, as do a negative count and a null reference. A string + /// containing an interior nul character reads in full but compares up to + /// the nul. + pub fn read_string( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_string(process, self.pointer_size, at) + } + /// Attaches to a Unity game that is using the IL2CPP backend. This function /// automatically detects the [IL2CPP version](Version). If you know the /// version in advance or it fails detecting it, use diff --git a/src/game_engine/unity/il2cpp/readers_tests.rs b/src/game_engine/unity/il2cpp/readers_tests.rs new file mode 100644 index 0000000..99f7724 --- /dev/null +++ b/src/game_engine/unity/il2cpp/readers_tests.rs @@ -0,0 +1,56 @@ +//! Parity tests for the managed readers through the IL2CPP module. The +//! implementation is shared, so this pins the mirror surface and the one +//! behavior whose rationale is IL2CPP's: the full-width length judgment. + +use super::{IL2CPPOffsets, Module, Version}; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x40_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +fn image() -> Vec { + let mut i = vec![0; 0x1000]; + + ptr(&mut i, 0x0, BASE + 0x100); + put(&mut i, 0x100 + 0x10, &4_i32.to_le_bytes()); + for (index, unit) in "Loop".encode_utf16().enumerate() { + put(&mut i, 0x100 + 0x14 + 2 * index as u64, &unit.to_le_bytes()); + } + + i +} + +fn on_fixture(test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image())], |process| { + let module = Module { + assemblies: Address::new(BASE), + type_info_definition_table: Address::new(BASE + 0x10), + version: Version::V2022, + offsets: IL2CPPOffsets::new(Version::V2022, PointerSize::Bit64).unwrap(), + pointer_size: PointerSize::Bit64, + }; + test(process, &module); + }); +} + +#[test] +fn strings_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_string::<8>(process, Address::new(BASE)) + .unwrap(); + assert!(read.matches_str("Loop")); + }); +} diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 2d12b03..0d3637e 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -8,11 +8,13 @@ mod cursor; mod pointer; +mod readers; mod runtime; mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; +pub use readers::read_string; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; pub use walk::Walk; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs new file mode 100644 index 0000000..1cb614d --- /dev/null +++ b/src/game_engine/unity/managed/readers.rs @@ -0,0 +1,40 @@ +use crate::{string::ArrayWString, Address, Error, PointerSize, Process}; + +/// How many bytes a managed object's two header words occupy. The readers +/// skip them; nothing in them is read. +const fn object_header(pointer_size: PointerSize) -> u64 { + 2 * pointer_size as u64 +} + +/// Reads a managed string through the reference stored at the given address. +/// The layout is runtime ABI, shared by both runtimes at both widths: the +/// character count as an i32 past the object header, the UTF-16 characters +/// inline behind it. +pub fn read_string( + process: &Process, + pointer_size: PointerSize, + at: Address, +) -> Result, Error> { + let object = process + .read_pointer(at, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let header = object_header(pointer_size); + let count = process.read::(object + header)?; + + // The buffer size is the bound past which a claimed count is nonsense: a + // torn or garbage read claims billions, and something has to refuse + // before reading it. Refusal, never truncation. + let count = usize::try_from(count) + .ok() + .filter(|&count| count <= N) + .ok_or(Error {})?; + + let mut string = ArrayWString::new(); + let characters = &mut bytemuck::bytes_of_mut(&mut string)[..2 * count]; + process.read_into_slice(object + header + 4, characters)?; + + Ok(string) +} diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index e0c6185..69bda89 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -8,7 +8,8 @@ use crate::{ future::retry, print_limited, signature::Signature, - Address, Address32, PointerSize, Process, + string::ArrayWString, + Address, Address32, Error, PointerSize, Process, }; mod builds; @@ -23,6 +24,8 @@ pub use pointer::UnityPointer; mod offsets; use offsets::MonoOffsets; #[cfg(all(test, not(target_family = "wasm")))] +mod readers_tests; +#[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; use super::{managed, BinaryFormat}; @@ -329,6 +332,22 @@ impl Module { self.get_image(process, "Assembly-CSharp") } + /// Reads a managed string through the reference stored at the given + /// address, such as the end of a pointer path or a slot in a static + /// table. The string carries its own character count, so no length is + /// passed; `N` bounds how many UTF-16 characters the returned buffer + /// holds, and a string claiming more than that fails rather than + /// truncates, as do a negative count and a null reference. A string + /// containing an interior nul character reads in full but compares up to + /// the nul. + pub fn read_string( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_string(process, self.pointer_size, at) + } + /// Attaches to a Unity game that is using the standard Mono backend. This /// function automatically detects the [Mono version](Version). If you /// know the version in advance or it fails detecting it, use diff --git a/src/game_engine/unity/mono/readers_tests.rs b/src/game_engine/unity/mono/readers_tests.rs new file mode 100644 index 0000000..67494d4 --- /dev/null +++ b/src/game_engine/unity/mono/readers_tests.rs @@ -0,0 +1,106 @@ +//! Tests pinning the managed readers over hand-laid string objects. The +//! readers are pointer-size ABI, not walk work, so the fixtures are tiny +//! blobs rather than the walk's class fixtures. + +use super::{BinaryFormat, Module, MonoOffsets, Version}; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x30_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +fn utf16(image: &mut [u8], at: u64, text: &str) { + for (index, unit) in text.encode_utf16().enumerate() { + put(image, at + 2 * index as u64, &unit.to_le_bytes()); + } +} + +// String objects at the 64-bit layout: two header words, an i32 character +// count, the UTF-16 characters inline. Each slot at the front holds one +// reference the readers dereference. +fn image() -> Vec { + let mut i = vec![0; 0x1000]; + + ptr(&mut i, 0x0, BASE + 0x100); // a healthy string + ptr(&mut i, 0x8, BASE + 0x200); // one claiming more than a buffer holds + ptr(&mut i, 0x10, BASE + 0x300); // one claiming a negative count + ptr(&mut i, 0x18, 0); // a null reference + + put(&mut i, 0x100 + 0x10, &9_i32.to_le_bytes()); + utf16(&mut i, 0x100 + 0x14, "Chapter 3"); + + put(&mut i, 0x200 + 0x10, &64_i32.to_le_bytes()); + put(&mut i, 0x300 + 0x10, &(-1_i32).to_le_bytes()); + + i +} + +fn module(pointer_size: PointerSize) -> Module { + Module { + assemblies: Address::new(BASE), + version: Version::V2, + offsets: MonoOffsets::new(Version::V2, pointer_size, BinaryFormat::PE).unwrap(), + pointer_size, + } +} + +fn on_fixture(test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image())], |process| { + test(process, &module(PointerSize::Bit64)); + }); +} + +#[test] +fn strings_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_string::<16>(process, Address::new(BASE)) + .unwrap(); + assert!(read.matches_str("Chapter 3")); + }); +} + +// The buffer size is the bound past which a claimed count is nonsense: a +// torn read claims billions, and refusing beats truncating. +#[test] +fn string_counts_past_the_buffer_refuse() { + on_fixture(|process, module| { + assert!(module + .read_string::<16>(process, Address::new(BASE + 0x8)) + .is_err()); + assert!(module + .read_string::<16>(process, Address::new(BASE + 0x10)) + .is_err()); + assert!(module + .read_string::<16>(process, Address::new(BASE + 0x18)) + .is_err()); + }); +} + +// The 32-bit layout halves the header and the reference width. +#[test] +fn strings_resolve_on_32_bit_targets() { + let mut i = vec![0; 0x1000]; + put(&mut i, 0x0, &((BASE + 0x100) as u32).to_le_bytes()); + put(&mut i, 0x100 + 0x8, &5_i32.to_le_bytes()); + utf16(&mut i, 0x100 + 0xC, "Ridge"); + + with_process(&[(BASE, &i)], |process| { + let module = module(PointerSize::Bit32); + let read = module + .read_string::<8>(process, Address::new(BASE)) + .unwrap(); + assert!(read.matches_str("Ridge")); + }); +} From 45b7f095ce08f792b20e57e95da3a58f1b529e52 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 03:09:11 +0200 Subject: [PATCH 08/11] add managed array reads --- src/game_engine/unity/il2cpp/mod.rs | 19 +++++ src/game_engine/unity/il2cpp/readers_tests.rs | 31 ++++++++ src/game_engine/unity/managed/mod.rs | 2 +- src/game_engine/unity/managed/readers.rs | 43 ++++++++++ src/game_engine/unity/mono/mod.rs | 19 +++++ src/game_engine/unity/mono/readers_tests.rs | 79 +++++++++++++++++-- 6 files changed, 185 insertions(+), 8 deletions(-) diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 8a3f4e4..501a8a5 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -1,5 +1,8 @@ //! Support for attaching to Unity games that are using the IL2CPP backend. +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; + use crate::{ file_format::pe, future::retry, print_limited, signature::Signature, string::ArrayWString, Address, Error, PointerSize, Process, @@ -335,6 +338,22 @@ impl Module { managed::read_string(process, self.pointer_size, at) } + /// Reads a managed array of value elements through the reference stored + /// at the given address. The array carries its own length, so no count + /// is passed; `N` bounds how many elements the returned vector holds, + /// and an array claiming more than that fails rather than truncates, as + /// does a null reference. The element type is the caller's claim and has + /// to match the target's own element layout: a managed `char` is a + /// `u16` here, a `bool` a single byte, and Rust's `char` and `usize` + /// never match. Reference elements have no portable claim. + pub fn read_array( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_array(process, self.pointer_size, at) + } + /// Attaches to a Unity game that is using the IL2CPP backend. This function /// automatically detects the [IL2CPP version](Version). If you know the /// version in advance or it fails detecting it, use diff --git a/src/game_engine/unity/il2cpp/readers_tests.rs b/src/game_engine/unity/il2cpp/readers_tests.rs index 99f7724..ed934dd 100644 --- a/src/game_engine/unity/il2cpp/readers_tests.rs +++ b/src/game_engine/unity/il2cpp/readers_tests.rs @@ -29,6 +29,16 @@ fn image() -> Vec { put(&mut i, 0x100 + 0x14 + 2 * index as u64, &unit.to_le_bytes()); } + // An array whose length slot carries garbage above the low u32. IL2CPP's + // length really is pointer-sized, so the whole slot judges the claim. + ptr(&mut i, 0x8, BASE + 0x200); + put(&mut i, 0x200 + 0x18, &0x1_0000_0003_u64.to_le_bytes()); + + ptr(&mut i, 0x10, BASE + 0x300); + put(&mut i, 0x300 + 0x18, &2_u64.to_le_bytes()); + put(&mut i, 0x300 + 0x20, &11_u32.to_le_bytes()); + put(&mut i, 0x300 + 0x24, &22_u32.to_le_bytes()); + i } @@ -54,3 +64,24 @@ fn strings_resolve_through_their_reference() { assert!(read.matches_str("Loop")); }); } + +#[test] +fn arrays_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_array::(process, Address::new(BASE + 0x10)) + .unwrap(); + assert_eq!(read.as_slice(), [11, 22]); + }); +} + +// A length whose low u32 reads small but whose full width does not is +// garbage, not a small array. An i32 read here would wrongly succeed. +#[test] +fn array_lengths_judge_at_full_width() { + on_fixture(|process, module| { + assert!(module + .read_array::(process, Address::new(BASE + 0x8)) + .is_err()); + }); +} diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 0d3637e..5451c81 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -14,7 +14,7 @@ mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; -pub use readers::read_string; +pub use readers::{read_array, read_string}; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; pub use walk::Walk; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs index 1cb614d..043c5ef 100644 --- a/src/game_engine/unity/managed/readers.rs +++ b/src/game_engine/unity/managed/readers.rs @@ -1,3 +1,7 @@ +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; +use core::mem::MaybeUninit; + use crate::{string::ArrayWString, Address, Error, PointerSize, Process}; /// How many bytes a managed object's two header words occupy. The readers @@ -38,3 +42,42 @@ pub fn read_string( Ok(string) } + +/// Reads a managed array of value elements through the reference stored at +/// the given address. The layout is runtime ABI: past the object header sit +/// the bounds word, the length, and the elements inline. +pub fn read_array( + process: &Process, + pointer_size: PointerSize, + at: Address, +) -> Result, Error> { + let object = process + .read_pointer(at, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let header = object_header(pointer_size); + + // The length judges at pointer width before any narrowing, so garbage + // that a narrower read would truncate small still refuses. Only IL2CPP's + // length is truly pointer-sized; 64-bit mono stores a u32 whose zeroed + // padding reads the same value through the wide slot. + let length = process + .read_pointer(object + header + pointer_size as u64, pointer_size)? + .value(); + let length = usize::try_from(length) + .ok() + .filter(|&length| length <= N) + .ok_or(Error {})?; + + let mut elements = [const { MaybeUninit::::uninit() }; N]; + let elements = process.read_into_uninit_slice( + object + header + 2 * pointer_size as u64, + &mut elements[..length], + )?; + + let mut out = ArrayVec::new(); + out.try_extend_from_slice(elements).map_err(|_| Error {})?; + Ok(out) +} diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index 69bda89..c386720 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -3,6 +3,9 @@ #[cfg(feature = "alloc")] use crate::file_format::macho; +use arrayvec::ArrayVec; +use bytemuck::CheckedBitPattern; + use crate::{ file_format::{elf, pe}, future::retry, @@ -348,6 +351,22 @@ impl Module { managed::read_string(process, self.pointer_size, at) } + /// Reads a managed array of value elements through the reference stored + /// at the given address. The array carries its own length, so no count + /// is passed; `N` bounds how many elements the returned vector holds, + /// and an array claiming more than that fails rather than truncates, as + /// does a null reference. The element type is the caller's claim and has + /// to match the target's own element layout: a managed `char` is a + /// `u16` here, a `bool` a single byte, and Rust's `char` and `usize` + /// never match. Reference elements have no portable claim. + pub fn read_array( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_array(process, self.pointer_size, at) + } + /// Attaches to a Unity game that is using the standard Mono backend. This /// function automatically detects the [Mono version](Version). If you /// know the version in advance or it fails detecting it, use diff --git a/src/game_engine/unity/mono/readers_tests.rs b/src/game_engine/unity/mono/readers_tests.rs index 67494d4..4f634bd 100644 --- a/src/game_engine/unity/mono/readers_tests.rs +++ b/src/game_engine/unity/mono/readers_tests.rs @@ -1,6 +1,6 @@ -//! Tests pinning the managed readers over hand-laid string objects. The -//! readers are pointer-size ABI, not walk work, so the fixtures are tiny -//! blobs rather than the walk's class fixtures. +//! Tests pinning the managed readers over hand-laid string and array +//! objects. The readers are pointer-size ABI, not walk work, so the fixtures +//! are tiny blobs rather than the walk's class fixtures. use super::{BinaryFormat, Module, MonoOffsets, Version}; use crate::runtime::mock::with_process; @@ -26,8 +26,9 @@ fn utf16(image: &mut [u8], at: u64, text: &str) { } } -// String objects at the 64-bit layout: two header words, an i32 character -// count, the UTF-16 characters inline. Each slot at the front holds one +// String and array objects at the 64-bit layout: two header words, then the +// string's i32 character count and inline UTF-16 characters, or the array's +// bounds word, length, and inline elements. Each slot at the front holds one // reference the readers dereference. fn image() -> Vec { let mut i = vec![0; 0x1000]; @@ -36,6 +37,9 @@ fn image() -> Vec { ptr(&mut i, 0x8, BASE + 0x200); // one claiming more than a buffer holds ptr(&mut i, 0x10, BASE + 0x300); // one claiming a negative count ptr(&mut i, 0x18, 0); // a null reference + ptr(&mut i, 0x20, BASE + 0x400); // a healthy i32 array + ptr(&mut i, 0x28, BASE + 0x500); // an array claiming more than a buffer holds + ptr(&mut i, 0x30, BASE + 0x600); // a u16 array put(&mut i, 0x100 + 0x10, &9_i32.to_le_bytes()); utf16(&mut i, 0x100 + 0x14, "Chapter 3"); @@ -43,6 +47,22 @@ fn image() -> Vec { put(&mut i, 0x200 + 0x10, &64_i32.to_le_bytes()); put(&mut i, 0x300 + 0x10, &(-1_i32).to_le_bytes()); + // Mono stores the length as a u32 the allocator's zeroing pads to the + // pointer-wide slot the reader judges, which is what these bytes lay. + put(&mut i, 0x400 + 0x18, &3_u32.to_le_bytes()); + for (index, value) in [7_i32, 8, 9].into_iter().enumerate() { + put( + &mut i, + 0x400 + 0x20 + 4 * index as u64, + &value.to_le_bytes(), + ); + } + + put(&mut i, 0x500 + 0x18, &64_u32.to_le_bytes()); + + put(&mut i, 0x600 + 0x18, &5_u32.to_le_bytes()); + utf16(&mut i, 0x600 + 0x20, "melon"); + i } @@ -88,13 +108,53 @@ fn string_counts_past_the_buffer_refuse() { }); } -// The 32-bit layout halves the header and the reference width. #[test] -fn strings_resolve_on_32_bit_targets() { +fn arrays_resolve_through_their_reference() { + on_fixture(|process, module| { + let read = module + .read_array::(process, Address::new(BASE + 0x20)) + .unwrap(); + assert_eq!(read.as_slice(), [7, 8, 9]); + }); +} + +// The element type is the caller's claim; a u16 claim strides a managed +// char array correctly. +#[test] +fn array_elements_stride_by_their_claimed_type() { + on_fixture(|process, module| { + let read = module + .read_array::(process, Address::new(BASE + 0x30)) + .unwrap(); + let melon: Vec = "melon".encode_utf16().collect(); + assert_eq!(read.as_slice(), melon); + }); +} + +#[test] +fn array_lengths_past_the_buffer_refuse() { + on_fixture(|process, module| { + assert!(module + .read_array::(process, Address::new(BASE + 0x28)) + .is_err()); + assert!(module + .read_array::(process, Address::new(BASE + 0x18)) + .is_err()); + }); +} + +// The 32-bit layout halves the header, the reference width, and the length +// slot. +#[test] +fn readers_resolve_on_32_bit_targets() { let mut i = vec![0; 0x1000]; put(&mut i, 0x0, &((BASE + 0x100) as u32).to_le_bytes()); put(&mut i, 0x100 + 0x8, &5_i32.to_le_bytes()); utf16(&mut i, 0x100 + 0xC, "Ridge"); + put(&mut i, 0x8, &((BASE + 0x200) as u32).to_le_bytes()); + put(&mut i, 0x200 + 0xC, &2_u32.to_le_bytes()); + put(&mut i, 0x200 + 0x10, &21_i32.to_le_bytes()); + put(&mut i, 0x200 + 0x14, &22_i32.to_le_bytes()); with_process(&[(BASE, &i)], |process| { let module = module(PointerSize::Bit32); @@ -102,5 +162,10 @@ fn strings_resolve_on_32_bit_targets() { .read_string::<8>(process, Address::new(BASE)) .unwrap(); assert!(read.matches_str("Ridge")); + + let read = module + .read_array::(process, Address::new(BASE + 0x8)) + .unwrap(); + assert_eq!(read.as_slice(), [21, 22]); }); } From 1ddfb4dc86c30f5c38ac6fdef4bdc48fb1cb60ee Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 11:02:01 +0200 Subject: [PATCH 09/11] add managed list reads --- src/game_engine/unity/il2cpp/mod.rs | 42 +++++++++++ src/game_engine/unity/il2cpp/walk_tests.rs | 39 ++++++++++ src/game_engine/unity/managed/mod.rs | 2 +- src/game_engine/unity/managed/readers.rs | 60 ++++++++++++++++ src/game_engine/unity/managed/walk.rs | 38 +++++++++- src/game_engine/unity/mono/mod.rs | 42 +++++++++++ src/game_engine/unity/mono/walk_tests.rs | 82 ++++++++++++++++++++++ 7 files changed, 303 insertions(+), 2 deletions(-) diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 501a8a5..eaeae50 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -25,6 +25,7 @@ mod readers_tests; mod walk_tests; use super::managed; +pub use super::managed::ListOffsets; /// Represents access to a Unity game that is using the IL2CPP backend. pub struct Module { @@ -354,6 +355,37 @@ impl Module { managed::read_array(process, self.pointer_size, at) } + /// Resolves where a `List` keeps its backing array and live count, off + /// the class the list object at the given address names as its own. The + /// answer is a small `Copy` value worth storing, like a field offset: + /// resolution walks the class's fields, where the read itself is a + /// handful of reads. An object whose class is not a list misses. + pub fn get_list_offsets(&self, process: &Process, at: Address) -> Option { + let object = process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + self.walk().list_offsets(process, object) + } + + /// Reads a managed `List` of value elements through the reference stored + /// at the given address, with the offsets + /// [`get_list_offsets`](Self::get_list_offsets) resolved. The list's + /// live count is read, never its backing capacity; `N` bounds the + /// count, and a count past the buffer or past the backing array's own + /// length fails rather than truncates, as does a null reference. The + /// element type is the caller's claim, as with + /// [`read_array`](Self::read_array). + pub fn read_list( + &self, + process: &Process, + offsets: ListOffsets, + at: Address, + ) -> Result, Error> { + managed::read_list(process, self.pointer_size, offsets, at) + } + /// Attaches to a Unity game that is using the IL2CPP backend. This function /// automatically detects the [IL2CPP version](Version). If you know the /// version in advance or it fails detecting it, use @@ -404,6 +436,16 @@ impl Module { pub async fn wait_get_default_image(&self, process: &Process) -> Image { retry(|| self.get_default_image(process)).await } + + /// Resolves where a `List` keeps its backing array and live count, off + /// the class the list object at the given address names as its own. + /// + /// This is the `await`able version of the + /// [`get_list_offsets`](Self::get_list_offsets) function, yielding back + /// to the runtime between each try. + pub async fn wait_get_list_offsets(&self, process: &Process, at: Address) -> ListOffsets { + retry(|| self.get_list_offsets(process, at)).await + } } #[cfg(all(test, not(target_family = "wasm")))] diff --git a/src/game_engine/unity/il2cpp/walk_tests.rs b/src/game_engine/unity/il2cpp/walk_tests.rs index a3da7ed..7be33a7 100644 --- a/src/game_engine/unity/il2cpp/walk_tests.rs +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -479,6 +479,8 @@ fn image(version: Version) -> Vec { (0x2700, "Outer"), (0x2780, "Inner"), (0x2800, "spawner"), + (0x2880, "_items"), + (0x2900, "_size"), ]; for (at, text) in strings { put(&mut i, at, text.as_bytes()); @@ -590,6 +592,30 @@ fn image(version: Version) -> Vec { // own, empty at that offset, so only the declaring class's table answers. ptr(&mut i, 0xFC0 + 0x8, BASE + 0x1080); + // A List: its class carries corlib's field names, its live object heads + // with the class and holds a backing array longer than the live count. + let list_class = 0x1600; + ptr(&mut i, list_class + 0x80, BASE + 0x1780); + put(&mut i, list_class + field_count_at, &2_u16.to_le_bytes()); + ptr(&mut i, 0x1780, BASE + 0x2880); // _items + put(&mut i, 0x1780 + 0x18, &0x10_i32.to_le_bytes()); + ptr(&mut i, 0x17A0, BASE + 0x2900); // _size + put(&mut i, 0x17A0 + 0x18, &0x18_i32.to_le_bytes()); + + ptr(&mut i, 0x1800, BASE + list_class); // the list object heads with its class + ptr(&mut i, 0x1800 + 0x10, BASE + 0x1900); + put(&mut i, 0x1800 + 0x18, &2_i32.to_le_bytes()); + put(&mut i, 0x1900 + 0x18, &4_u32.to_le_bytes()); // the backing's capacity + for (index, value) in [11_u32, 22, 100, 100].into_iter().enumerate() { + put( + &mut i, + 0x1900 + 0x20 + 4 * index as u64, + &value.to_le_bytes(), + ); + } + + ptr(&mut i, 0x18, BASE + 0x1800); // the slot holding the reference + i } @@ -728,6 +754,19 @@ fn static_instances_resolve_through_the_declaring_class() { }); } +// A list's backing array and live count resolve off the list object's own +// class, and the read returns the live count's elements, never the backing +// capacity's. +#[test] +fn lists_resolve_through_their_own_class() { + on_fixture(Version::V2022, |process, module| { + let at = Address::new(BASE + 0x18); + let offsets = module.get_list_offsets(process, at).unwrap(); + let read = module.read_list::(process, offsets, at).unwrap(); + assert_eq!(read.as_slice(), [11, 22]); + }); +} + // The whole pointer path: the static root, the instance behind it, and a field // resolved against the object's own class read off its head. #[test] diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 5451c81..4e6e9df 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -14,7 +14,7 @@ mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; -pub use readers::{read_array, read_string}; +pub use readers::{read_array, read_list, read_string, ListOffsets}; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; pub use walk::Walk; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs index 043c5ef..2f23254 100644 --- a/src/game_engine/unity/managed/readers.rs +++ b/src/game_engine/unity/managed/readers.rs @@ -43,6 +43,66 @@ pub fn read_string( Ok(string) } +/// Where a list keeps its backing array and live count, resolved once off +/// the list's own class and held by the caller, so the per-tick read costs +/// reads rather than a metadata walk. +#[derive(Copy, Clone)] +pub struct ListOffsets { + pub(crate) items: u32, + pub(crate) size: u32, +} + +/// Reads a managed list's live elements through the reference stored at the +/// given address, with the offsets a resolution handed out earlier. The +/// count is judged by the buffer, the backing array's capacity is not, and +/// a count past the backing's own length is a torn resize and refuses. +pub fn read_list( + process: &Process, + pointer_size: PointerSize, + offsets: ListOffsets, + at: Address, +) -> Result, Error> { + let object = process + .read_pointer(at, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let size = process.read::(object + offsets.size)?; + let size = usize::try_from(size) + .ok() + .filter(|&size| size <= N) + .ok_or(Error {})?; + + let items = process + .read_pointer(object + offsets.items, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let header = object_header(pointer_size); + let backing = process + .read_pointer(items + header + pointer_size as u64, pointer_size)? + .value(); + if usize::try_from(backing) + .ok() + .filter(|&backing| size <= backing) + .is_none() + { + return Err(Error {}); + } + + let mut elements = [const { MaybeUninit::::uninit() }; N]; + let elements = process.read_into_uninit_slice( + items + header + 2 * pointer_size as u64, + &mut elements[..size], + )?; + + let mut out = ArrayVec::new(); + out.try_extend_from_slice(elements).map_err(|_| Error {})?; + Ok(out) +} + /// Reads a managed array of value elements through the reference stored at /// the given address. The layout is runtime ABI: past the object header sit /// the bounds word, the length, and the elements inline. diff --git a/src/game_engine/unity/managed/walk.rs b/src/game_engine/unity/managed/walk.rs index f02db6d..f6dbda5 100644 --- a/src/game_engine/unity/managed/walk.rs +++ b/src/game_engine/unity/managed/walk.rs @@ -1,5 +1,5 @@ use super::super::{get_backing_name, CSTR}; -use super::{ClassRef, ClimbStop, FieldRef, ImageRef, Runtime, WalkOffsets}; +use super::{ClassRef, ClimbStop, FieldRef, ImageRef, ListOffsets, Runtime, WalkOffsets}; use crate::{string::ArrayCString, Address, PointerSize, Process}; /// The walk itself: everything both runtimes lay out the same way, written @@ -260,6 +260,42 @@ impl Walk { process.read(field.address + self.offsets.field.offset).ok() } + /// Resolves where a list keeps its backing array and live count, off the + /// list object's own class. Corlib names both fields the same across + /// every generation the offsets tables cover; a class naming either + /// differently is not a list and misses cleanly. + pub fn list_offsets(&self, process: &Process, object: Address) -> Option { + let class = self.object_class(process, object)?; + + let field_count = self.runtime.field_count(process, self.pointer_size, class); + let fields = process + .read_pointer(class.address + self.offsets.class.fields, self.pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + let mut items = None; + let mut size = None; + for index in 0..field_count { + let field = + FieldRef::new(fields + index.wrapping_mul(self.offsets.field.stride as u64)); + + let Some(name) = self.field_name::(process, field) else { + continue; + }; + + if name.matches("_items") { + items = self.field_offset(process, field); + } else if name.matches("_size") { + size = self.field_offset(process, field); + } + } + + Some(ListOffsets { + items: items?, + size: size?, + }) + } + /// Reads the address a class's static field offsets are measured from. pub fn static_table(&self, process: &Process, class: ClassRef) -> Option
{ self.runtime.static_table(process, self.pointer_size, class) diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index c386720..a4c2128 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -31,6 +31,7 @@ mod readers_tests; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; +pub use super::managed::ListOffsets; use super::{managed, BinaryFormat}; /// Represents access to a Unity game that is using the standard Mono backend. @@ -367,6 +368,37 @@ impl Module { managed::read_array(process, self.pointer_size, at) } + /// Resolves where a `List` keeps its backing array and live count, off + /// the class the list object at the given address names as its own. The + /// answer is a small `Copy` value worth storing, like a field offset: + /// resolution walks the class's fields, where the read itself is a + /// handful of reads. An object whose class is not a list misses. + pub fn get_list_offsets(&self, process: &Process, at: Address) -> Option { + let object = process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + self.walk().list_offsets(process, object) + } + + /// Reads a managed `List` of value elements through the reference stored + /// at the given address, with the offsets + /// [`get_list_offsets`](Self::get_list_offsets) resolved. The list's + /// live count is read, never its backing capacity; `N` bounds the + /// count, and a count past the buffer or past the backing array's own + /// length fails rather than truncates, as does a null reference. The + /// element type is the caller's claim, as with + /// [`read_array`](Self::read_array). + pub fn read_list( + &self, + process: &Process, + offsets: ListOffsets, + at: Address, + ) -> Result, Error> { + managed::read_list(process, self.pointer_size, offsets, at) + } + /// Attaches to a Unity game that is using the standard Mono backend. This /// function automatically detects the [Mono version](Version). If you /// know the version in advance or it fails detecting it, use @@ -417,4 +449,14 @@ impl Module { pub async fn wait_get_default_image(&self, process: &Process) -> Image { retry(|| self.get_default_image(process)).await } + + /// Resolves where a `List` keeps its backing array and live count, off + /// the class the list object at the given address names as its own. + /// + /// This is the `await`able version of the + /// [`get_list_offsets`](Self::get_list_offsets) function, yielding back + /// to the runtime between each try. + pub async fn wait_get_list_offsets(&self, process: &Process, at: Address) -> ListOffsets { + retry(|| self.get_list_offsets(process, at)).await + } } diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index e6bd957..6036838 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -54,6 +54,9 @@ fn image() -> Vec { (0x2700, "Outer"), (0x2780, "Inner"), (0x2800, "spawner"), + (0x2880, "_items"), + (0x2900, "_size"), + (0x2980, "_version"), (0x2B00, "Inventory"), (0x2B80, "items"), ]; @@ -195,6 +198,50 @@ fn image() -> Vec { ptr(&mut i, 0x1A00, BASE + game_manager); put(&mut i, 0x1900 + 0x20, &777_u32.to_le_bytes()); + // A List: its class is a generic instance like Inventory, its fields are + // corlib's own three, and its live object holds a backing array longer + // than the live count. A second object claims a count past the backing. + let list_class = 0x3600; + put(&mut i, list_class + 0x2A, &3_u8.to_le_bytes()); + ptr(&mut i, list_class + 0x98, BASE + 0x3800); + ptr(&mut i, list_class + 0xF0, BASE + 0x3D00); + ptr(&mut i, 0x3D00, BASE + 0x3D40); // descriptor: container_class at 0x0 + put(&mut i, 0x3D40 + 0x100, &3_i32.to_le_bytes()); // the definition's count + ptr(&mut i, 0x3800 + 0x8, BASE + 0x2880); // _items + put(&mut i, 0x3800 + 0x18, &0x10_i32.to_le_bytes()); + ptr(&mut i, 0x3820 + 0x8, BASE + 0x2900); // _size + put(&mut i, 0x3820 + 0x18, &0x18_i32.to_le_bytes()); + ptr(&mut i, 0x3840 + 0x8, BASE + 0x2980); // _version + put(&mut i, 0x3840 + 0x18, &0x1C_i32.to_le_bytes()); + + ptr(&mut i, 0x3900, BASE + 0x3950); // the list object heads with its vtable + ptr(&mut i, 0x3950, BASE + list_class); + ptr(&mut i, 0x3900 + 0x10, BASE + 0x3A00); + put(&mut i, 0x3900 + 0x18, &3_i32.to_le_bytes()); + put(&mut i, 0x3A00 + 0x18, &8_u32.to_le_bytes()); // the backing's capacity + for (index, value) in [5_i32, 6, 7, 100, 100, 100, 100, 100] + .into_iter() + .enumerate() + { + put( + &mut i, + 0x3A00 + 0x20 + 4 * index as u64, + &value.to_le_bytes(), + ); + } + + ptr(&mut i, 0x3B00, BASE + 0x3950); // the torn list shares the class + ptr(&mut i, 0x3B00 + 0x10, BASE + 0x3A00); + put(&mut i, 0x3B00 + 0x18, &99_i32.to_le_bytes()); + + ptr(&mut i, 0x3C00, BASE + 0x3C50); // an Inventory object, not a list + ptr(&mut i, 0x3C50, BASE + 0x2D00); + + // The slots holding the three references. + ptr(&mut i, 0x3F00, BASE + 0x3900); + ptr(&mut i, 0x3F08, BASE + 0x3B00); + ptr(&mut i, 0x3F10, BASE + 0x3C00); + i } @@ -410,6 +457,41 @@ fn static_instances_resolve_through_the_declaring_class() { }); } +// A list's backing array and live count resolve off the list object's own +// class, and the read returns the live count's elements, never the backing +// capacity's, which the buffer size does not judge. +#[test] +fn lists_resolve_through_their_own_class() { + on_fixture(era(), |process, module| { + let at = Address::new(BASE + 0x3F00); + let offsets = module.get_list_offsets(process, at).unwrap(); + let read = module.read_list::(process, offsets, at).unwrap(); + assert_eq!(read.as_slice(), [5, 6, 7]); + }); +} + +// A count past the backing array's own length is a torn resize, not a long +// list. +#[test] +fn list_counts_past_their_backing_refuse() { + on_fixture(era(), |process, module| { + let at = Address::new(BASE + 0x3F08); + let offsets = module.get_list_offsets(process, at).unwrap(); + assert!(module.read_list::(process, offsets, at).is_err()); + }); +} + +// An object whose class does not carry corlib's names is not a list, and +// misses cleanly rather than answering with whatever offsets exist. +#[test] +fn objects_that_are_not_lists_answer_nothing() { + on_fixture(era(), |process, module| { + assert!(module + .get_list_offsets(process, Address::new(BASE + 0x3F10)) + .is_none()); + }); +} + // The whole pointer path: the static root, the instance behind it, and a field // resolved against the object's own class read through its vtable. #[test] From e900add9b05710202bcc1183a06b087d8cf08819 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 17:00:41 +0200 Subject: [PATCH 10/11] add dictionary offset resolution --- src/game_engine/unity/il2cpp/builds.rs | 228 +++++++++++++++++- .../unity/il2cpp/collections_tests.rs | 130 ++++++++++ src/game_engine/unity/il2cpp/mod.rs | 50 +++- src/game_engine/unity/il2cpp/offsets.rs | 46 ++++ src/game_engine/unity/managed/mod.rs | 16 +- src/game_engine/unity/managed/readers.rs | 27 +++ src/game_engine/unity/managed/runtime.rs | 83 +++++++ src/game_engine/unity/managed/walk.rs | 126 +++++++++- src/game_engine/unity/mono/builds.rs | 165 ++++++++++++- .../unity/mono/collections_tests.rs | 197 +++++++++++++++ src/game_engine/unity/mono/mod.rs | 43 +++- src/game_engine/unity/mono/offsets.rs | 89 ++++++- src/game_engine/unity/mono/walk_tests.rs | 8 +- 13 files changed, 1187 insertions(+), 21 deletions(-) create mode 100644 src/game_engine/unity/il2cpp/collections_tests.rs create mode 100644 src/game_engine/unity/mono/collections_tests.rs diff --git a/src/game_engine/unity/il2cpp/builds.rs b/src/game_engine/unity/il2cpp/builds.rs index 9b8c31c..028cf04 100644 --- a/src/game_engine/unity/il2cpp/builds.rs +++ b/src/game_engine/unity/il2cpp/builds.rs @@ -3,7 +3,8 @@ //! The offsets come from that player's `GameAssembly.pdb`. use super::offsets::{ - AssemblyOffsets, ClassOffsets, FieldInfoOffsets, IL2CPPOffsets, ImageOffsets, + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, IL2CPPOffsets, ImageOffsets, + TypeOffsets, }; use super::Version; use crate::PointerSize; @@ -66,10 +67,19 @@ static BUILDS: &[Build] = &[ parent: 0x58, fields: 0x80, static_fields: 0xb8, + instance_size: Some(0xec), field_count: 0x114, }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), offset: 0x18, struct_size: 0x20, }, @@ -99,10 +109,19 @@ static BUILDS: &[Build] = &[ parent: 0x2c, fields: 0x40, static_fields: 0x5c, + instance_size: Some(0x84), field_count: 0xac, }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x4), offset: 0xc, struct_size: 0x14, }, @@ -132,10 +151,19 @@ static BUILDS: &[Build] = &[ parent: 0x58, fields: 0x80, static_fields: 0xb8, + instance_size: Some(0xf4), field_count: 0x11c, }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), offset: 0x18, struct_size: 0x20, }, @@ -165,10 +193,19 @@ static BUILDS: &[Build] = &[ parent: 0x2c, fields: 0x40, static_fields: 0x5c, + instance_size: Some(0x80), field_count: 0xa8, }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x4), offset: 0xc, struct_size: 0x14, }, @@ -198,10 +235,19 @@ static BUILDS: &[Build] = &[ parent: 0x58, fields: 0x80, static_fields: 0xb8, + instance_size: Some(0xf4), field_count: 0x11c, }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), offset: 0x18, struct_size: 0x20, }, @@ -231,10 +277,19 @@ static BUILDS: &[Build] = &[ parent: 0x2c, fields: 0x40, static_fields: 0x5c, + instance_size: Some(0x80), field_count: 0xa8, }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x4), offset: 0xc, struct_size: 0x14, }, @@ -264,10 +319,19 @@ static BUILDS: &[Build] = &[ parent: 0x58, fields: 0x80, static_fields: 0xb8, + instance_size: Some(0xf8), field_count: 0x120, }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), offset: 0x18, struct_size: 0x20, }, @@ -297,10 +361,19 @@ static BUILDS: &[Build] = &[ parent: 0x2c, fields: 0x40, static_fields: 0x5c, + instance_size: Some(0x80), field_count: 0xa8, }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x4), offset: 0xc, struct_size: 0x14, }, @@ -330,10 +403,19 @@ static BUILDS: &[Build] = &[ parent: 0x58, fields: 0x80, static_fields: 0xb8, + instance_size: Some(0xf8), field_count: 0x124, }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), offset: 0x18, struct_size: 0x20, }, @@ -363,10 +445,19 @@ static BUILDS: &[Build] = &[ parent: 0x2c, fields: 0x40, static_fields: 0x5c, + instance_size: Some(0x80), field_count: 0xac, }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x4), offset: 0xc, struct_size: 0x14, }, @@ -396,10 +487,19 @@ static BUILDS: &[Build] = &[ parent: 0x58, fields: 0x80, static_fields: 0xb8, + instance_size: Some(0xf8), field_count: 0x124, }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), offset: 0x18, struct_size: 0x20, }, @@ -429,10 +529,19 @@ static BUILDS: &[Build] = &[ parent: 0x2c, fields: 0x40, static_fields: 0x5c, + instance_size: Some(0x80), field_count: 0xac, }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x4), offset: 0xc, struct_size: 0x14, }, @@ -462,10 +571,19 @@ static BUILDS: &[Build] = &[ parent: 0x58, fields: 0x80, static_fields: 0xb8, + instance_size: Some(0xf8), field_count: 0x124, }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), offset: 0x18, struct_size: 0x20, }, @@ -495,10 +613,19 @@ static BUILDS: &[Build] = &[ parent: 0x2c, fields: 0x40, static_fields: 0x5c, + instance_size: Some(0x80), field_count: 0xac, }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x4), offset: 0xc, struct_size: 0x14, }, @@ -528,10 +655,19 @@ static BUILDS: &[Build] = &[ parent: 0x58, fields: 0x80, static_fields: 0xb8, + instance_size: Some(0xf8), field_count: 0x124, }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), offset: 0x18, struct_size: 0x20, }, @@ -561,10 +697,19 @@ static BUILDS: &[Build] = &[ parent: 0x2c, fields: 0x40, static_fields: 0x5c, + instance_size: Some(0x80), field_count: 0xac, }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x4), offset: 0xc, struct_size: 0x14, }, @@ -594,10 +739,19 @@ static BUILDS: &[Build] = &[ parent: 0x58, fields: 0x80, static_fields: 0xb8, + instance_size: Some(0xf8), field_count: 0x124, }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), offset: 0x18, struct_size: 0x20, }, @@ -627,10 +781,19 @@ static BUILDS: &[Build] = &[ parent: 0x2c, fields: 0x40, static_fields: 0x5c, + instance_size: Some(0x80), field_count: 0xac, }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x4), offset: 0xc, struct_size: 0x14, }, @@ -660,10 +823,19 @@ static BUILDS: &[Build] = &[ parent: 0x58, fields: 0x80, static_fields: 0xa0, + instance_size: Some(0xf8), field_count: 0x124, }, + generic: GenericOffsets { + cached_class: Some(0x18), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), offset: 0x18, struct_size: 0x20, }, @@ -693,10 +865,19 @@ static BUILDS: &[Build] = &[ parent: 0x2c, fields: 0x40, static_fields: 0x50, + instance_size: Some(0x80), field_count: 0xac, }, + generic: GenericOffsets { + cached_class: Some(0xc), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x4), offset: 0xc, struct_size: 0x14, }, @@ -726,10 +907,19 @@ static BUILDS: &[Build] = &[ parent: 0x58, fields: 0x80, static_fields: 0x98, + instance_size: Some(0xf0), field_count: 0x11c, }, + generic: GenericOffsets { + cached_class: Some(0x10), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), offset: 0x18, struct_size: 0x20, }, @@ -759,10 +949,19 @@ static BUILDS: &[Build] = &[ parent: 0x2c, fields: 0x40, static_fields: 0x4c, + instance_size: Some(0x80), field_count: 0xac, }, + generic: GenericOffsets { + cached_class: Some(0x8), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x4), offset: 0xc, struct_size: 0x14, }, @@ -827,18 +1026,33 @@ mod tests { assert!(find(16, (5, 6, 7, 0), PointerSize::Bit64).is_none()); } - // A version table's value for where a class keeps its declaring type - // must match every measured build it stands in for, or say nothing. + // A version table's value for any of the grown members must match every + // measured build it stands in for, or say nothing. #[test] fn version_tables_never_contradict_a_measured_build() { + fn agrees(table: Option, measured: Option) -> bool { + table.is_none() || table == measured + } + for build in BUILDS { let Some(table) = IL2CPPOffsets::new(build.version, build.pointer_size) else { continue; }; - assert!( - table.class.declaring_type.is_none() - || table.class.declaring_type == build.offsets.class.declaring_type - ); + assert!(agrees( + table.class.declaring_type, + build.offsets.class.declaring_type + )); + assert!(agrees( + table.class.instance_size, + build.offsets.class.instance_size + )); + assert!(agrees( + table.generic.cached_class, + build.offsets.generic.cached_class + )); + assert!(agrees(table.type_words.data, build.offsets.type_words.data)); + assert!(agrees(table.type_words.kind, build.offsets.type_words.kind)); + assert!(agrees(table.field.type_, build.offsets.field.type_)); } } diff --git a/src/game_engine/unity/il2cpp/collections_tests.rs b/src/game_engine/unity/il2cpp/collections_tests.rs new file mode 100644 index 0000000..ceb9030 --- /dev/null +++ b/src/game_engine/unity/il2cpp/collections_tests.rs @@ -0,0 +1,130 @@ +//! Tests pinning dictionary resolution over hand-laid class metadata at the +//! literal offsets of the Unity 2019.4 IL2CPP runtime. The entry class is a +//! generic instance reached through the instantiation's cached class, which +//! is the route every corlib dictionary takes. + +use super::{IL2CPPOffsets, Module, Version}; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x60_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +// One field entry: the name heads it, the Il2CppType pointer sits at 0x8, +// the offset at 0x18. +fn field(image: &mut [u8], at: u64, name: u64, type_: u64, offset: i32) { + ptr(image, at, name); + if type_ != 0 { + ptr(image, at + 0x8, type_); + } + put(image, at + 0x18, &offset.to_le_bytes()); +} + +fn image(version: Version) -> Vec { + let field_count_at = match version { + Version::V2019 => 0x11C, + _ => 0x124, + }; + let mut i = vec![0; 0x2000]; + + let strings = [ + (0x1800, "_buckets"), + (0x1840, "_entries"), + (0x1880, "_count"), + (0x18C0, "_freeCount"), + (0x1900, "hashCode"), + (0x1940, "next"), + (0x1980, "key"), + (0x19C0, "value"), + ]; + for (at, text) in strings { + put(&mut i, at, text.as_bytes()); + } + + // The slot, the dictionary object heading with its class, and the class's + // four fields: field_count at +0x11C, fields behind +0x80. + ptr(&mut i, 0x0, BASE + 0x100); + ptr(&mut i, 0x100, BASE + 0x200); + put(&mut i, 0x200 + field_count_at, &4_u16.to_le_bytes()); + ptr(&mut i, 0x200 + 0x80, BASE + 0x340); + field(&mut i, 0x340, BASE + 0x1800, 0, 0x10); + field(&mut i, 0x360, BASE + 0x1840, BASE + 0x500, 0x18); + field(&mut i, 0x380, BASE + 0x1880, 0, 0x20); + field(&mut i, 0x3A0, BASE + 0x18C0, 0, 0x24); + + // The entries field's type: a SzArray whose data is the element's own + // type, a generic instance whose descriptor caches the entry class. + ptr(&mut i, 0x500, BASE + 0x550); + put(&mut i, 0x50A, &[0x1D]); // SzArray + ptr(&mut i, 0x550, BASE + 0x580); + put(&mut i, 0x55A, &[0x15]); // GenericInst + ptr(&mut i, 0x580 + 0x18, BASE + 0x600); // descriptor: cached_class + + // The entry class: instance_size at +0xF4, field_count at +0x11C, four + // members at their boxed-frame offsets. + put(&mut i, 0x600 + 0xF4, &0x20_i32.to_le_bytes()); + put(&mut i, 0x600 + field_count_at, &4_u16.to_le_bytes()); + ptr(&mut i, 0x600 + 0x80, BASE + 0x740); + field(&mut i, 0x740, BASE + 0x1900, 0, 0x10); + field(&mut i, 0x760, BASE + 0x1940, 0, 0x14); + field(&mut i, 0x780, BASE + 0x1980, 0, 0x18); + field(&mut i, 0x7A0, BASE + 0x19C0, 0, 0x1C); + + i +} + +fn module(version: Version) -> Module { + Module { + assemblies: Address::new(BASE), + type_info_definition_table: Address::new(BASE + 0x40), + version, + offsets: IL2CPPOffsets::new(version, PointerSize::Bit64).unwrap(), + pointer_size: PointerSize::Bit64, + } +} + +fn on_fixture(version: Version, test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image(version))], |process| { + test(process, &module(version)); + }); +} + +#[test] +fn dictionaries_resolve_through_the_cached_class() { + on_fixture(Version::V2019, |process, module| { + let offsets = module + .get_dictionary_offsets(process, Address::new(BASE)) + .unwrap(); + assert_eq!(offsets.entries, 0x18); + assert_eq!(offsets.count, 0x20); + assert_eq!(offsets.free_count, 0x24); + assert_eq!(offsets.layout.stride, 0x10); + assert_eq!(offsets.layout.hash, 0x0); + assert_eq!(offsets.layout.next, 0x4); + assert_eq!(offsets.layout.key, 0x8); + assert_eq!(offsets.layout.value, 0xC); + }); +} + +// The 2022.2-and-later fallback table carries no cached class, because the +// measured builds inside that stretch disagree. A fallback attach misses +// cleanly; known builds carry their own value. +#[test] +fn fallback_tables_without_a_cached_class_answer_nothing() { + on_fixture(Version::V2022, |process, module| { + assert!(module + .get_dictionary_offsets(process, Address::new(BASE)) + .is_none()); + }); +} diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index eaeae50..48b7816 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -20,12 +20,14 @@ pub use pointer::UnityPointer; mod offsets; use offsets::IL2CPPOffsets; #[cfg(all(test, not(target_family = "wasm")))] +mod collections_tests; +#[cfg(all(test, not(target_family = "wasm")))] mod readers_tests; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; use super::managed; -pub use super::managed::ListOffsets; +pub use super::managed::{DictionaryOffsets, ListOffsets}; /// Represents access to a Unity game that is using the IL2CPP backend. pub struct Module { @@ -274,6 +276,9 @@ impl Module { handle_is_inline: matches!(self.version, Version::Base | Version::V2019), field_count: self.offsets.class.field_count, static_fields: self.offsets.class.static_fields.into(), + cached_class: self.offsets.generic.cached_class, + type_data: self.offsets.type_words.data, + type_kind: self.offsets.type_words.kind, }), offsets: managed::WalkOffsets { assembly: managed::AssemblyOffsets { @@ -286,10 +291,12 @@ impl Module { namespace: self.offsets.class.namespace.into(), parent: self.offsets.class.parent.into(), declaring: self.offsets.class.declaring_type, + instance_size: self.offsets.class.instance_size, fields: self.offsets.class.fields.into(), }, field: managed::FieldOffsets { name: self.offsets.field.name.into(), + type_: self.offsets.field.type_, offset: self.offsets.field.offset.into(), stride: self.offsets.field.struct_size.into(), }, @@ -369,6 +376,32 @@ impl Module { self.walk().list_offsets(process, object) } + /// Resolves where a `Dictionary` keeps its backing entries and live + /// counts, and how one entry lays out, off the class the dictionary + /// object at the given address names as its own. The answer is a small + /// `Copy` value worth storing, like a field offset: resolution walks + /// class metadata, where the read itself is a handful of reads. An + /// object whose class is not this dictionary shape, and a target still + /// starting up, both miss. + pub fn get_dictionary_offsets( + &self, + process: &Process, + at: Address, + ) -> Option { + let object = process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + self.walk().dictionary_offsets(process, object) + } + + /// Returns the pointer size the target runs at, which is what a caller + /// claims reference-width values with. + pub fn get_pointer_size(&self) -> PointerSize { + self.pointer_size + } + /// Reads a managed `List` of value elements through the reference stored /// at the given address, with the offsets /// [`get_list_offsets`](Self::get_list_offsets) resolved. The list's @@ -446,6 +479,21 @@ impl Module { pub async fn wait_get_list_offsets(&self, process: &Process, at: Address) -> ListOffsets { retry(|| self.get_list_offsets(process, at)).await } + + /// Resolves where a `Dictionary` keeps its backing entries and live + /// counts, and how one entry lays out, off the class the dictionary + /// object at the given address names as its own. + /// + /// This is the `await`able version of the + /// [`get_dictionary_offsets`](Self::get_dictionary_offsets) function, + /// yielding back to the runtime between each try. + pub async fn wait_get_dictionary_offsets( + &self, + process: &Process, + at: Address, + ) -> DictionaryOffsets { + retry(|| self.get_dictionary_offsets(process, at)).await + } } #[cfg(all(test, not(target_family = "wasm")))] diff --git a/src/game_engine/unity/il2cpp/offsets.rs b/src/game_engine/unity/il2cpp/offsets.rs index a82acf6..a1f1df0 100644 --- a/src/game_engine/unity/il2cpp/offsets.rs +++ b/src/game_engine/unity/il2cpp/offsets.rs @@ -4,6 +4,8 @@ pub(super) struct IL2CPPOffsets { pub(super) assembly: AssemblyOffsets, pub(super) image: ImageOffsets, pub(super) class: ClassOffsets, + pub(super) generic: GenericOffsets, + pub(super) type_words: TypeOffsets, pub(super) field: FieldInfoOffsets, } @@ -28,10 +30,17 @@ impl IL2CPPOffsets { parent: 0x58, fields: 0x80, static_fields: 0xB8, + instance_size: None, field_count: 0x124, }, + generic: GenericOffsets { cached_class: None }, + type_words: TypeOffsets { + data: Some(0x0), // 2023.1 through 6000.7 + kind: Some(0xA), // 2023.1 through 6000.7 + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), // 2023.1 through 6000.7 offset: 0x18, struct_size: 0x20, }, @@ -53,10 +62,17 @@ impl IL2CPPOffsets { parent: 0x58, fields: 0x80, static_fields: 0xB8, + instance_size: None, field_count: 0x120, }, + generic: GenericOffsets { cached_class: None }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { name: 0x0, + type_: None, offset: 0x18, struct_size: 0x20, }, @@ -78,10 +94,19 @@ impl IL2CPPOffsets { parent: 0x58, fields: 0x80, static_fields: 0xB8, + instance_size: Some(0xF4), // 2019.4, 2020.1 field_count: 0x11C, }, + generic: GenericOffsets { + cached_class: Some(0x18), // 2019.4, 2020.1 + }, + type_words: TypeOffsets { + data: Some(0x0), // 2019.4, 2020.1 + kind: Some(0xA), // 2019.4, 2020.1 + }, field: FieldInfoOffsets { name: 0x0, + type_: Some(0x8), // 2019.4, 2020.1 offset: 0x18, struct_size: 0x20, }, @@ -103,10 +128,17 @@ impl IL2CPPOffsets { parent: 0x58, fields: 0x80, static_fields: 0xB8, + instance_size: None, field_count: 0x114, }, + generic: GenericOffsets { cached_class: None }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { name: 0x0, + type_: None, offset: 0x18, struct_size: 0x20, }, @@ -135,11 +167,25 @@ pub(super) struct ClassOffsets { pub(super) parent: u8, pub(super) fields: u8, pub(super) static_fields: u8, + pub(super) instance_size: Option, // What one instance occupies, boxed header included pub(super) field_count: u16, } +// Il2CppGenericClass keeps the class an instantiation resolved to. +pub(super) struct GenericOffsets { + pub(super) cached_class: Option, +} + +// Il2CppType's own words: the data pointer and the element kind byte. +pub(super) struct TypeOffsets { + pub(super) data: Option, + pub(super) kind: Option, +} + pub(super) struct FieldInfoOffsets { pub(super) name: u8, + pub(super) type_: Option, // Where a field keeps its Il2CppType + pub(super) offset: u8, pub(super) struct_size: u8, } diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 4e6e9df..07978e0 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -14,7 +14,9 @@ mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; -pub use readers::{read_array, read_list, read_string, ListOffsets}; +pub use readers::{ + read_array, read_list, read_string, DictionaryOffsets, EntryLayout, ListOffsets, +}; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; pub use walk::Walk; @@ -37,20 +39,24 @@ pub struct AssemblyOffsets { pub image: u16, } -/// Where a class keeps its names, its parent, its field array, and, when it -/// was measured, the class it is nested in. +/// Where a class keeps its names, its parent, its field array, and, when +/// they were measured, the class it is nested in and what one instance +/// occupies. pub struct ClassOffsets { pub name: u16, pub namespace: u16, pub parent: u16, pub declaring: Option, + pub instance_size: Option, pub fields: u16, } -/// Where a field entry keeps its name and offset, and the size of one entry in -/// a class's field array, whatever each runtime's own offsets call it. +/// Where a field entry keeps its name, its type, and its offset, and the +/// size of one entry in a class's field array, whatever each runtime's own +/// offsets call it. pub struct FieldOffsets { pub name: u16, + pub type_: Option, pub offset: u16, pub stride: u16, } diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs index 2f23254..d3f548d 100644 --- a/src/game_engine/unity/managed/readers.rs +++ b/src/game_engine/unity/managed/readers.rs @@ -52,6 +52,33 @@ pub struct ListOffsets { pub(crate) size: u32, } +/// The most bytes one entry may span: past this a claimed layout is +/// garbage, and at most this many are read per chunk. +pub(crate) const ENTRY_SCRATCH: usize = 1024; + +/// How one dictionary entry lays out, measured from the entry's own start: +/// the stored hash, the chain link, and the key and value slots. +#[derive(Copy, Clone)] +pub struct EntryLayout { + pub(crate) stride: u32, + pub(crate) hash: u32, + pub(crate) next: u32, + pub(crate) key: u32, + pub(crate) value: u32, +} + +/// Where a dictionary keeps its backing entries and live counts, and how +/// one entry lays out, resolved once off the dictionary's own class and +/// held by the caller, so the per-tick read costs reads rather than a +/// metadata walk. +#[derive(Copy, Clone)] +pub struct DictionaryOffsets { + pub(crate) entries: u32, + pub(crate) count: u32, + pub(crate) free_count: u32, + pub(crate) layout: EntryLayout, +} + /// Reads a managed list's live elements through the reference stored at the /// given address, with the offsets a resolution handed out earlier. The /// count is judged by the buffer, the backing array's capacity is not, and diff --git a/src/game_engine/unity/managed/runtime.rs b/src/game_engine/unity/managed/runtime.rs index 1a42145..92de895 100644 --- a/src/game_engine/unity/managed/runtime.rs +++ b/src/game_engine/unity/managed/runtime.rs @@ -14,6 +14,18 @@ pub enum Runtime { const CLASS_KIND_MASK: u8 = 0x7; const GENERIC_INSTANCE_KIND: u8 = 3; +/// The element kinds a type's kind byte carries that the type route walks +/// through: a single-dimensional array's data leads on toward its element, +/// and a generic instance's data is the instantiation descriptor. +const SZARRAY: u8 = 0x1D; +const GENERIC_INSTANCE: u8 = 0x15; + +/// Whether a type of this element kind names a class at all. End, Void, +/// Ptr, ByRef, Var, multidimensional Array, FnPtr, and MVar do not. +const fn names_a_class(kind: u8) -> bool { + !matches!(kind, 0x00 | 0x01 | 0x0F | 0x10 | 0x13 | 0x14 | 0x1B | 0x1E) +} + /// Mono keeps its assemblies in a glib list, its classes in each image's hash /// table, and its statics behind the class's vtable. pub struct MonoRuntime { @@ -26,6 +38,8 @@ pub struct MonoRuntime { pub class_kind: Option, pub generic_class: Option, pub container_class: Option, + pub type_data: Option, + pub type_kind: Option, pub runtime_info: u16, pub vtable_size: u16, pub vtable: u16, @@ -46,6 +60,9 @@ pub struct Il2CppRuntime { pub handle_is_inline: bool, pub field_count: u16, pub static_fields: u16, + pub cached_class: Option, + pub type_data: Option, + pub type_kind: Option, } impl MonoRuntime { @@ -172,6 +189,72 @@ impl Runtime { .filter(|address| !address.is_null()) } + /// Resolves the class a field's type names, for the kinds a collection's + /// backing field presents: an array of a class the runtimes already + /// inflated. Kinds that name no class, and the table-resolved plain + /// definitions IL2CPP keeps behind an index or a handle, answer nothing. + pub fn class_from_type( + &self, + process: &Process, + pointer_size: PointerSize, + type_address: Address, + ) -> Option { + match self { + // Mono's data names the class itself, an array's element class + // included. A generic instance's data is the instantiation + // descriptor rather than a class, and no collections trace + // presents one here: the array hop already landed on the + // inflated class. + Self::Mono(mono) => { + let (data, kind) = (mono.type_data?, mono.type_kind?); + let kind = process.read::(type_address + kind).ok()?; + if !names_a_class(kind) || kind == GENERIC_INSTANCE { + return None; + } + + process + .read_pointer(type_address + data, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .map(ClassRef::new) + } + // IL2CPP's array data is the element's own type, walked onward; + // a generic instance's descriptor caches the class it resolved + // to. Deeper array nesting than this is garbage. + Self::Il2Cpp(il2cpp) => { + let (data, kind, cached) = + (il2cpp.type_data?, il2cpp.type_kind?, il2cpp.cached_class?); + + let mut at = type_address; + for _ in 0..8 { + let element = process.read::(at + kind).ok()?; + if !names_a_class(element) { + return None; + } + + let data = process + .read_pointer(at + data, pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + match element { + SZARRAY => at = data, + GENERIC_INSTANCE => { + return process + .read_pointer(data + cached, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .map(ClassRef::new) + } + _ => return None, + } + } + + None + } + } + } + /// Reads the class a live object belongs to, which is how a polymorphic /// field's runtime type is found. pub fn object_class( diff --git a/src/game_engine/unity/managed/walk.rs b/src/game_engine/unity/managed/walk.rs index f6dbda5..3856407 100644 --- a/src/game_engine/unity/managed/walk.rs +++ b/src/game_engine/unity/managed/walk.rs @@ -1,5 +1,9 @@ use super::super::{get_backing_name, CSTR}; -use super::{ClassRef, ClimbStop, FieldRef, ImageRef, ListOffsets, Runtime, WalkOffsets}; +use super::readers::ENTRY_SCRATCH; +use super::{ + ClassRef, ClimbStop, DictionaryOffsets, EntryLayout, FieldRef, ImageRef, ListOffsets, Runtime, + WalkOffsets, +}; use crate::{string::ArrayCString, Address, PointerSize, Process}; /// The walk itself: everything both runtimes lay out the same way, written @@ -296,6 +300,126 @@ impl Walk { }) } + /// Resolves where a dictionary keeps its backing entries and live + /// counts, and how one entry lays out, off the dictionary object's own + /// class. Both corlib naming generations answer; the buckets field has + /// to exist for the shape to be this one, though nothing here reads it. + /// The parallel-arrays shape the oldest corlib used carries other names + /// and misses cleanly. + pub fn dictionary_offsets( + &self, + process: &Process, + object: Address, + ) -> Option { + let class = self.object_class(process, object)?; + + let mut found = [None; 4]; + self.each_own_field(process, class, |name, field, offset| { + let generations = [ + ["_buckets", "_entries", "_count", "_freeCount"], + ["buckets", "entries", "count", "freeCount"], + ]; + for generation in generations { + for (slot, member) in generation.into_iter().enumerate() { + if name.matches(member) { + found[slot] = Some((field, offset)); + } + } + } + }); + let (Some(_), Some(entries), Some(count), Some(free_count)) = + (found[0], found[1], found[2], found[3]) + else { + return None; + }; + + // The entry class arrives through the entries field's own type, and + // its instance size, boxed header removed, is the entry stride. + let entry_type = process + .read_pointer( + entries.0.address + self.offsets.field.type_?, + self.pointer_size, + ) + .ok() + .filter(|address| !address.is_null())?; + let entry_class = self + .runtime + .class_from_type(process, self.pointer_size, entry_type)?; + + let header = 2 * self.pointer_size as u32; + let size = process + .read::(entry_class.address + self.offsets.class.instance_size?) + .ok()?; + let stride = u32::try_from(size) + .ok()? + .checked_sub(header) + .filter(|&stride| stride > 0 && stride as usize <= ENTRY_SCRATCH)?; + + // The members' offsets are recorded as if boxed; folding them by the + // header measures them from the entry's own start. + let mut members = [None; 4]; + self.each_own_field(process, entry_class, |name, _, offset| { + for (slot, member) in ["hashCode", "next", "key", "value"].into_iter().enumerate() { + if name.matches(member) { + members[slot] = offset.checked_sub(header); + } + } + }); + let (Some(hash), Some(next), Some(key), Some(value)) = + (members[0], members[1], members[2], members[3]) + else { + return None; + }; + + let ends_inside = |member: u32| member.checked_add(4).is_some_and(|end| end <= stride); + let holds = ends_inside(hash) && ends_inside(next) && key < stride && value < stride; + holds.then_some(DictionaryOffsets { + entries: entries.1, + count: count.1, + free_count: free_count.1, + layout: EntryLayout { + stride, + hash, + next, + key, + value, + }, + }) + } + + // Hands every field the class itself declares to the callback, with its + // name and instance offset. Unreadable entries are skipped, the way the + // field climb skips them. + fn each_own_field( + &self, + process: &Process, + class: ClassRef, + mut each: impl FnMut(&ArrayCString, FieldRef, u32), + ) { + let field_count = self.runtime.field_count(process, self.pointer_size, class); + let Some(fields) = process + .read_pointer(class.address + self.offsets.class.fields, self.pointer_size) + .ok() + .filter(|address| !address.is_null()) + else { + return; + }; + + for index in 0..field_count { + let field = + FieldRef::new(fields + index.wrapping_mul(self.offsets.field.stride as u64)); + + let Some(name) = self.field_name::(process, field) else { + continue; + }; + let Some(offset) = self.field_offset(process, field) else { + continue; + }; + + each(&name, field, offset); + } + } + /// Reads the address a class's static field offsets are measured from. pub fn static_table(&self, process: &Process, class: ClassRef) -> Option
{ self.runtime.static_table(process, self.pointer_size, class) diff --git a/src/game_engine/unity/mono/builds.rs b/src/game_engine/unity/mono/builds.rs index 30e0281..e36c96b 100644 --- a/src/game_engine/unity/mono/builds.rs +++ b/src/game_engine/unity/mono/builds.rs @@ -3,7 +3,7 @@ use super::offsets::{ AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, - ImageOffsets, MonoOffsets, MonoVTableOffsets, + ImageOffsets, MonoOffsets, MonoVTableOffsets, TypeOffsets, }; use super::Version; use crate::{file_format::pe::DebugId, PointerSize}; @@ -102,6 +102,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -116,7 +117,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0x94), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -144,6 +150,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -158,7 +165,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0x8c), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -188,6 +200,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -202,7 +215,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0xf0), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -230,6 +248,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -244,7 +263,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0xf0), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -272,6 +296,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -286,7 +311,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0xf0), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -314,6 +344,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x50, @@ -328,7 +359,12 @@ static BUILDS: &[Build] = &[ generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -356,6 +392,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -370,7 +407,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0xf0), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -398,6 +440,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -412,7 +455,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0xf0), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -440,6 +488,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -454,7 +503,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0xf0), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -482,6 +536,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -496,7 +551,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0xf0), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -524,6 +584,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x10), parent: 0x24, nested_in: Some(0x28), name: 0x34, @@ -538,7 +599,12 @@ static BUILDS: &[Build] = &[ generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -568,6 +634,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -582,7 +649,12 @@ static BUILDS: &[Build] = &[ generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -610,6 +682,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -624,7 +697,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0x94), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -652,6 +730,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -666,7 +745,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0xf0), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -694,6 +778,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x50, @@ -708,7 +793,12 @@ static BUILDS: &[Build] = &[ generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -736,6 +826,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -750,7 +841,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0x8c), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -778,6 +874,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x10), parent: 0x24, nested_in: Some(0x28), name: 0x34, @@ -792,7 +889,12 @@ static BUILDS: &[Build] = &[ generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -820,6 +922,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -834,7 +937,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0x8c), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -862,6 +970,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1b), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -876,7 +985,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0xf0), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -904,6 +1018,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x2a), + instance_size: Some(0x1c), parent: 0x30, nested_in: Some(0x38), name: 0x48, @@ -918,7 +1033,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0xf0), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xa), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x8, offset: 0x18, alignment: 0x20, @@ -946,6 +1066,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -960,7 +1081,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0x94), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -990,6 +1116,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x10), parent: 0x24, nested_in: Some(0x28), name: 0x30, @@ -1004,7 +1131,12 @@ static BUILDS: &[Build] = &[ generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -1032,6 +1164,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0x1e), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -1046,7 +1179,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0x94), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -1074,6 +1212,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -1088,7 +1227,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0x8c), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -1116,6 +1260,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -1130,7 +1275,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0x8c), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -1158,6 +1308,7 @@ static BUILDS: &[Build] = &[ }, class: ClassOffsets { class_kind: Some(0xf), + instance_size: Some(0x10), parent: 0x20, nested_in: Some(0x24), name: 0x2c, @@ -1172,7 +1323,12 @@ static BUILDS: &[Build] = &[ generic_class: Some(0x8c), container_class: Some(0x0), }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0x6), + }, field: FieldInfoOffsets { + type_: Some(0x0), name: 0x4, offset: 0xc, alignment: 0x10, @@ -1261,6 +1417,13 @@ mod tests { table.generic.container_class, build.offsets.generic.container_class )); + assert!(agrees( + table.class.instance_size, + build.offsets.class.instance_size + )); + assert!(agrees(table.type_words.data, build.offsets.type_words.data)); + assert!(agrees(table.type_words.kind, build.offsets.type_words.kind)); + assert!(agrees(table.field.type_, build.offsets.field.type_)); } } diff --git a/src/game_engine/unity/mono/collections_tests.rs b/src/game_engine/unity/mono/collections_tests.rs new file mode 100644 index 0000000..afed3c1 --- /dev/null +++ b/src/game_engine/unity/mono/collections_tests.rs @@ -0,0 +1,197 @@ +//! Tests pinning dictionary resolution over hand-laid class metadata at the +//! literal offsets of the Unity 2019.4 x64 runtime. Resolution starts at a +//! live object and walks classes and fields only, so the fixtures stay their +//! own small blobs. + +use super::{BinaryFormat, Module, MonoOffsets, Version}; +use crate::runtime::mock::with_process; +use crate::{Address, PointerSize, Process}; + +use std::vec; +use std::vec::Vec; + +const BASE: u64 = 0x50_0000; + +fn put(image: &mut [u8], at: u64, bytes: &[u8]) { + let at = at as usize; + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +fn ptr(image: &mut [u8], at: u64, target: u64) { + put(image, at, &target.to_le_bytes()); +} + +// One field entry: the MonoType pointer heads it, the name sits at 0x8, the +// offset at 0x18. +fn field(image: &mut [u8], at: u64, type_: u64, name: u64, offset: i32) { + if type_ != 0 { + ptr(image, at, type_); + } + ptr(image, at + 0x8, name); + put(image, at + 0x18, &offset.to_le_bytes()); +} + +// A dictionary class: field_count at +0x100, fields behind +0x98. The names +// arrive in the caller's order with their instance offsets. +fn dictionary_class(image: &mut [u8], class: u64, fields: u64, names: [u64; 4], entries_type: u64) { + put(image, class + 0x100, &4_i32.to_le_bytes()); + ptr(image, class + 0x98, BASE + fields); + field(image, fields, 0, names[0], 0x10); // the buckets array + field(image, fields + 0x20, entries_type, names[1], 0x18); + field(image, fields + 0x40, 0, names[2], 0x20); + field(image, fields + 0x60, 0, names[3], 0x24); +} + +// An entry class: instance_size at +0x1C, four members at their boxed-frame +// offsets. +fn entry_class(image: &mut [u8], class: u64, fields: u64, size: i32, strings: [u64; 4]) { + put(image, class + 0x1C, &size.to_le_bytes()); + put(image, class + 0x100, &4_i32.to_le_bytes()); + ptr(image, class + 0x98, BASE + fields); + field(image, fields, 0, strings[0], 0x10); + field(image, fields + 0x20, 0, strings[1], 0x14); + field(image, fields + 0x40, 0, strings[2], 0x18); + field(image, fields + 0x60, 0, strings[3], 0x1C); +} + +// An object heading with its vtable, whose own head is the class. +fn object(image: &mut [u8], at: u64, vtable: u64, class: u64) { + ptr(image, at, BASE + vtable); + ptr(image, vtable, BASE + class); +} + +fn image() -> Vec { + let mut i = vec![0; 0x2800]; + + let strings = [ + (0x2000, "_buckets"), + (0x2040, "_entries"), + (0x2080, "_count"), + (0x20C0, "_freeCount"), + (0x2100, "hashCode"), + (0x2140, "next"), + (0x2180, "key"), + (0x21C0, "value"), + (0x2200, "buckets"), + (0x2240, "entries"), + (0x2280, "count"), + (0x22C0, "freeCount"), + (0x2300, "table"), + (0x2340, "linkSlots"), + (0x2380, "keySlots"), + (0x23C0, "valueSlots"), + ]; + for (at, text) in strings { + put(&mut i, at, text.as_bytes()); + } + let modern = [BASE + 0x2000, BASE + 0x2040, BASE + 0x2080, BASE + 0x20C0]; + let framework = [BASE + 0x2200, BASE + 0x2240, BASE + 0x2280, BASE + 0x22C0]; + let members = [BASE + 0x2100, BASE + 0x2140, BASE + 0x2180, BASE + 0x21C0]; + + // The slots holding the references. + ptr(&mut i, 0x0, BASE + 0x100); + ptr(&mut i, 0x8, BASE + 0x180); + ptr(&mut i, 0x10, BASE + 0xB00); + ptr(&mut i, 0x18, BASE + 0xB80); + ptr(&mut i, 0x20, BASE + 0x1A00); + + // The healthy dictionary in the modern naming generation: its entries + // field's type is a SzArray whose data names the entry class directly. + object(&mut i, 0x100, 0x140, 0x200); + dictionary_class(&mut i, 0x200, 0x340, modern, BASE + 0x500); + ptr(&mut i, 0x500, BASE + 0x600); // type data: the entry class + put(&mut i, 0x50A, &[0x1D]); // SzArray + entry_class(&mut i, 0x600, 0x740, 0x20, members); + + // The framework naming generation shares the entry class. + object(&mut i, 0x180, 0x1C0, 0x900); + dictionary_class(&mut i, 0x900, 0xA40, framework, BASE + 0x500); + + // A dictionary whose entry class has no instance size yet: not ready, + // answers nothing. + object(&mut i, 0xB00, 0xB40, 0xC00); + dictionary_class(&mut i, 0xC00, 0xD40, modern, BASE + 0xF00); + ptr(&mut i, 0xF00, BASE + 0x1600); + put(&mut i, 0xF0A, &[0x1D]); + entry_class(&mut i, 0x1600, 0x1740, 0, members); + + // A dictionary whose entry layout cannot hold its members: the stride is + // eight bytes and the key sits at its end. + object(&mut i, 0xB80, 0xBC0, 0x1000); + dictionary_class(&mut i, 0x1000, 0x1140, modern, BASE + 0x1300); + ptr(&mut i, 0x1300, BASE + 0x1800); + put(&mut i, 0x130A, &[0x1D]); + entry_class(&mut i, 0x1800, 0x1940, 0x18, members); + + // The old corlib's parallel-arrays shape: its names answer, ours do not. + object(&mut i, 0x1A00, 0x1A40, 0x1B00); + let parallel = [BASE + 0x2300, BASE + 0x2340, BASE + 0x2380, BASE + 0x23C0]; + dictionary_class(&mut i, 0x1B00, 0x1C40, parallel, 0); + + i +} + +fn module() -> Module { + Module { + assemblies: Address::new(BASE), + version: Version::V2, + offsets: MonoOffsets::new(Version::V2, PointerSize::Bit64, BinaryFormat::PE).unwrap(), + pointer_size: PointerSize::Bit64, + } +} + +fn on_fixture(test: impl FnOnce(&Process, &Module)) { + with_process(&[(BASE, &image())], |process| { + test(process, &module()); + }); +} + +#[test] +fn dictionaries_resolve_in_both_naming_generations() { + on_fixture(|process, module| { + for at in [BASE, BASE + 0x8] { + let slot = Address::new(at); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + assert_eq!(offsets.entries, 0x18); + assert_eq!(offsets.count, 0x20); + assert_eq!(offsets.free_count, 0x24); + assert_eq!(offsets.layout.stride, 0x10); + assert_eq!(offsets.layout.hash, 0x0); + assert_eq!(offsets.layout.next, 0x4); + assert_eq!(offsets.layout.key, 0x8); + assert_eq!(offsets.layout.value, 0xC); + } + }); +} + +// An entry class with no instance size yet is a target still starting up: +// resolution answers nothing rather than baking a zero stride. +#[test] +fn half_initialized_entry_classes_answer_nothing() { + on_fixture(|process, module| { + assert!(module + .get_dictionary_offsets(process, Address::new(BASE + 0x10)) + .is_none()); + }); +} + +// A layout whose members cannot sit inside its stride is refused whole. +#[test] +fn scrambled_entry_layouts_answer_nothing() { + on_fixture(|process, module| { + assert!(module + .get_dictionary_offsets(process, Address::new(BASE + 0x18)) + .is_none()); + }); +} + +// The old corlib's parallel-arrays dictionary is not this shape and misses +// cleanly rather than resolving to wrong offsets. +#[test] +fn parallel_shape_names_answer_nothing() { + on_fixture(|process, module| { + assert!(module + .get_dictionary_offsets(process, Address::new(BASE + 0x20)) + .is_none()); + }); +} diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index a4c2128..c65c616 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -27,11 +27,13 @@ pub use pointer::UnityPointer; mod offsets; use offsets::MonoOffsets; #[cfg(all(test, not(target_family = "wasm")))] +mod collections_tests; +#[cfg(all(test, not(target_family = "wasm")))] mod readers_tests; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; -pub use super::managed::ListOffsets; +pub use super::managed::{DictionaryOffsets, ListOffsets}; use super::{managed, BinaryFormat}; /// Represents access to a Unity game that is using the standard Mono backend. @@ -283,6 +285,8 @@ impl Module { class_kind: self.offsets.class.class_kind, generic_class: self.offsets.generic.generic_class, container_class: self.offsets.generic.container_class, + type_data: self.offsets.type_words.data, + type_kind: self.offsets.type_words.kind, runtime_info: self.offsets.class.runtime_info, vtable_size: self.offsets.class.vtable_size.into(), vtable: self.offsets.v_table.vtable.into(), @@ -299,10 +303,12 @@ impl Module { namespace: self.offsets.class.namespace.into(), parent: self.offsets.class.parent.into(), declaring: self.offsets.class.nested_in, + instance_size: self.offsets.class.instance_size, fields: self.offsets.class.fields.into(), }, field: managed::FieldOffsets { name: self.offsets.field.name.into(), + type_: self.offsets.field.type_, offset: self.offsets.field.offset.into(), stride: self.offsets.field.alignment.into(), }, @@ -382,6 +388,26 @@ impl Module { self.walk().list_offsets(process, object) } + /// Resolves where a `Dictionary` keeps its backing entries and live + /// counts, and how one entry lays out, off the class the dictionary + /// object at the given address names as its own. The answer is a small + /// `Copy` value worth storing, like a field offset: resolution walks + /// class metadata, where the read itself is a handful of reads. An + /// object whose class is not this dictionary shape, and a target still + /// starting up, both miss. + pub fn get_dictionary_offsets( + &self, + process: &Process, + at: Address, + ) -> Option { + let object = process + .read_pointer(at, self.pointer_size) + .ok() + .filter(|address| !address.is_null())?; + + self.walk().dictionary_offsets(process, object) + } + /// Reads a managed `List` of value elements through the reference stored /// at the given address, with the offsets /// [`get_list_offsets`](Self::get_list_offsets) resolved. The list's @@ -459,4 +485,19 @@ impl Module { pub async fn wait_get_list_offsets(&self, process: &Process, at: Address) -> ListOffsets { retry(|| self.get_list_offsets(process, at)).await } + + /// Resolves where a `Dictionary` keeps its backing entries and live + /// counts, and how one entry lays out, off the class the dictionary + /// object at the given address names as its own. + /// + /// This is the `await`able version of the + /// [`get_dictionary_offsets`](Self::get_dictionary_offsets) function, + /// yielding back to the runtime between each try. + pub async fn wait_get_dictionary_offsets( + &self, + process: &Process, + at: Address, + ) -> DictionaryOffsets { + retry(|| self.get_dictionary_offsets(process, at)).await + } } diff --git a/src/game_engine/unity/mono/offsets.rs b/src/game_engine/unity/mono/offsets.rs index bfee22c..1ab29a2 100644 --- a/src/game_engine/unity/mono/offsets.rs +++ b/src/game_engine/unity/mono/offsets.rs @@ -7,6 +7,7 @@ pub(super) struct MonoOffsets { pub(super) hash_table: HashTableOffsets, pub(super) class: ClassOffsets, pub(super) generic: GenericOffsets, + pub(super) type_words: TypeOffsets, pub(super) field: FieldInfoOffsets, pub(super) v_table: MonoVTableOffsets, } @@ -32,7 +33,8 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { - class_kind: Some(0x1B), // 2021.3 through 6000.7 + class_kind: Some(0x1B), // 2021.3 through 6000.7 + instance_size: Some(0x1C), // 2021.3 through 6000.7 parent: 0x30, nested_in: Some(0x38), // 2021.3 through 6000.7 name: 0x48, @@ -47,7 +49,12 @@ impl MonoOffsets { generic_class: Some(0xF0), // 2021.3 through 6000.7 container_class: Some(0x0), // 2021.3 through 6000.7 }, + type_words: TypeOffsets { + data: Some(0x0), // 2021.3 through 6000.7 + kind: Some(0xA), // 2021.3 through 6000.7 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2021.3 through 6000.7 name: 0x8, offset: 0x18, alignment: 0x20, @@ -68,7 +75,8 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { - class_kind: Some(0xF), // 2021.3 through 6000.7 + class_kind: Some(0xF), // 2021.3 through 6000.7 + instance_size: Some(0x10), // 2021.3 through 6000.7 parent: 0x20, nested_in: Some(0x24), // 2021.3 through 6000.7 name: 0x2C, @@ -83,7 +91,12 @@ impl MonoOffsets { generic_class: Some(0x8C), // 2021.3 through 6000.7 container_class: Some(0x0), // 2021.3 through 6000.7 }, + type_words: TypeOffsets { + data: Some(0x0), // 2021.3 through 6000.7 + kind: Some(0x6), // 2021.3 through 6000.7 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2021.3 through 6000.7 name: 0x4, offset: 0xC, alignment: 0x10, @@ -104,7 +117,8 @@ impl MonoOffsets { table: 0x20, }, class: ClassOffsets { - class_kind: Some(0x2A), // 2017.4 through 2020.1 + class_kind: Some(0x2A), // 2017.4 through 2020.1 + instance_size: Some(0x1C), // 2017.4 through 2020.1 parent: 0x30, nested_in: Some(0x38), // 2017.4 through 2020.1 name: 0x48, @@ -119,7 +133,12 @@ impl MonoOffsets { generic_class: Some(0xF0), // 2017.4 through 2020.1 container_class: Some(0x0), // 2017.4 through 2020.1 }, + type_words: TypeOffsets { + data: Some(0x0), // 2017.4 through 2020.1 + kind: Some(0xA), // 2017.4 through 2020.1 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2017.4 through 2020.1 name: 0x8, offset: 0x18, alignment: 0x20, @@ -140,7 +159,8 @@ impl MonoOffsets { table: 0x14, }, class: ClassOffsets { - class_kind: Some(0x1E), // 2017.4 through 2020.1 + class_kind: Some(0x1E), // 2017.4 through 2020.1 + instance_size: Some(0x10), // 2017.4 through 2020.1 parent: 0x20, nested_in: Some(0x24), // 2017.4 through 2020.1 name: 0x2C, @@ -155,7 +175,12 @@ impl MonoOffsets { generic_class: Some(0x94), // 2017.4 through 2020.1 container_class: Some(0x0), // 2017.4 through 2020.1 }, + type_words: TypeOffsets { + data: Some(0x0), // 2017.4 through 2020.1 + kind: Some(0x6), // 2017.4 through 2020.1 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2017.4 through 2020.1 name: 0x4, offset: 0xC, alignment: 0x10, @@ -177,6 +202,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x1C), // 2017.4, 2018.4 parent: 0x30, nested_in: None, name: 0x50, @@ -191,7 +217,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: Some(0x0), // 2017.4, 2018.4 + kind: Some(0xA), // 2017.4, 2018.4 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2017.4, 2018.4 name: 0x8, offset: 0x18, alignment: 0x20, @@ -213,6 +244,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: Some(0x10), // 2017.4, 2018.4 parent: 0x24, nested_in: None, name: 0x34, @@ -227,7 +259,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: Some(0x0), // 2017.4, 2018.4 + kind: Some(0x6), // 2017.4, 2018.4 + }, field: FieldInfoOffsets { + type_: Some(0x0), // 2017.4, 2018.4 name: 0x4, offset: 0xC, alignment: 0x10, @@ -249,6 +286,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x30, nested_in: Some(0x38), // 5.6 through 2018.4 name: 0x48, @@ -263,7 +301,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -285,6 +328,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x24, nested_in: Some(0x28), // 5.6 through 2018.4 name: 0x30, @@ -299,7 +343,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x4, offset: 0xC, alignment: 0x10, @@ -322,6 +371,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x28, nested_in: None, name: 0x40, @@ -336,7 +386,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -360,6 +415,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x28, nested_in: None, name: 0x40, @@ -374,7 +430,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -398,6 +459,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x28, nested_in: None, name: 0x48, @@ -412,7 +474,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -436,6 +503,7 @@ impl MonoOffsets { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x28, nested_in: None, name: 0x40, @@ -450,7 +518,12 @@ impl MonoOffsets { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, @@ -482,6 +555,7 @@ pub(super) struct HashTableOffsets { pub(super) struct ClassOffsets { pub(super) class_kind: Option, // The byte whose low bits say what kind of class it is + pub(super) instance_size: Option, // What one instance occupies, boxed header included pub(super) parent: u8, pub(super) nested_in: Option, // Where a class keeps the one it is nested in pub(super) name: u8, @@ -493,6 +567,12 @@ pub(super) struct ClassOffsets { pub(super) next_class_cache: u16, } +// MonoType's own words: the data pointer and the element kind byte. +pub(super) struct TypeOffsets { + pub(super) data: Option, + pub(super) kind: Option, +} + // MonoClassGenericInst keeps the instantiation descriptor, whose container is // the generic definition the instance was made from. pub(super) struct GenericOffsets { @@ -501,6 +581,7 @@ pub(super) struct GenericOffsets { } pub(super) struct FieldInfoOffsets { + pub(super) type_: Option, // Where a field keeps its MonoType pub(super) name: u8, pub(super) offset: u8, pub(super) alignment: u8, diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index 6036838..f9a6c41 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -5,7 +5,7 @@ use super::offsets::{ AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, - ImageOffsets, MonoVTableOffsets, + ImageOffsets, MonoVTableOffsets, TypeOffsets, }; use super::{builds, BinaryFormat, Module, MonoOffsets, UnityPointer, Version}; use crate::file_format::pe::DebugId; @@ -359,6 +359,7 @@ fn nested_lookups_without_a_measured_offset_answer_nothing() { }, class: ClassOffsets { class_kind: None, + instance_size: None, parent: 0x30, nested_in: None, name: 0x48, @@ -373,7 +374,12 @@ fn nested_lookups_without_a_measured_offset_answer_nothing() { generic_class: None, container_class: None, }, + type_words: TypeOffsets { + data: None, + kind: None, + }, field: FieldInfoOffsets { + type_: None, name: 0x8, offset: 0x18, alignment: 0x20, From 16e394acbfd7f526d53e5cb054ebe39bf816ed1d Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 17:09:27 +0200 Subject: [PATCH 11/11] add dictionary reads --- .../unity/il2cpp/collections_tests.rs | 29 +++++ src/game_engine/unity/il2cpp/mod.rs | 18 +++ src/game_engine/unity/managed/mod.rs | 3 +- src/game_engine/unity/managed/readers.rs | 112 +++++++++++++++++- .../unity/mono/collections_tests.rs | 86 ++++++++++++++ src/game_engine/unity/mono/mod.rs | 18 +++ 6 files changed, 264 insertions(+), 2 deletions(-) diff --git a/src/game_engine/unity/il2cpp/collections_tests.rs b/src/game_engine/unity/il2cpp/collections_tests.rs index ceb9030..750cc09 100644 --- a/src/game_engine/unity/il2cpp/collections_tests.rs +++ b/src/game_engine/unity/il2cpp/collections_tests.rs @@ -81,6 +81,23 @@ fn image(version: Version) -> Vec { field(&mut i, 0x780, BASE + 0x1980, 0, 0x18); field(&mut i, 0x7A0, BASE + 0x19C0, 0, 0x1C); + // The dictionary's live state: three counted entries over a four-slot + // backing, the middle one freed, so two pairs are live. + ptr(&mut i, 0x118, BASE + 0x800); + put(&mut i, 0x120, &3_i32.to_le_bytes()); + put(&mut i, 0x124, &1_i32.to_le_bytes()); + put(&mut i, 0x818, &4_u32.to_le_bytes()); + for (index, entry) in [(1111, -1, 10, 100), (-1, -1, 99, 999), (2222, -1, 20, 200)] + .into_iter() + .enumerate() + { + let at = 0x820 + 0x10 * index as u64; + let (hash, next, key, value): (i32, i32, i32, i32) = entry; + for (word, value) in [hash, next, key, value].into_iter().enumerate() { + put(&mut i, at + 4 * word as u64, &value.to_le_bytes()); + } + } + i } @@ -120,6 +137,18 @@ fn dictionaries_resolve_through_the_cached_class() { // The 2022.2-and-later fallback table carries no cached class, because the // measured builds inside that stretch disagree. A fallback attach misses // cleanly; known builds carry their own value. +#[test] +fn dictionaries_read_their_live_pairs() { + on_fixture(Version::V2019, |process, module| { + let slot = Address::new(BASE); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + let pairs = module + .read_dictionary::(process, offsets, slot) + .unwrap(); + assert_eq!(pairs.as_slice(), [(10, 100), (20, 200)]); + }); +} + #[test] fn fallback_tables_without_a_cached_class_answer_nothing() { on_fixture(Version::V2022, |process, module| { diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 48b7816..4402830 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -396,6 +396,24 @@ impl Module { self.walk().dictionary_offsets(process, object) } + /// Reads a managed `Dictionary`'s live pairs through the reference + /// stored at the given address, with the offsets + /// [`get_dictionary_offsets`](Self::get_dictionary_offsets) resolved. + /// `N` bounds the live pairs, never the counted entries or the backing + /// capacity; freed entries are skipped by their marks, and a live tally + /// that cannot balance against the counts fails rather than answering + /// wrong pairs. The key and value types are the caller's claims, as + /// with [`read_array`](Self::read_array), refused where a claim + /// outgrows the room its member has inside one entry. + pub fn read_dictionary( + &self, + process: &Process, + offsets: DictionaryOffsets, + at: Address, + ) -> Result, Error> { + managed::read_dictionary(process, self.pointer_size, offsets, at) + } + /// Returns the pointer size the target runs at, which is what a caller /// claims reference-width values with. pub fn get_pointer_size(&self) -> PointerSize { diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 07978e0..0a513d8 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -15,7 +15,8 @@ mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; pub use readers::{ - read_array, read_list, read_string, DictionaryOffsets, EntryLayout, ListOffsets, + read_array, read_dictionary, read_list, read_string, DictionaryOffsets, EntryLayout, + ListOffsets, }; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; pub use walk::Walk; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs index d3f548d..73e9289 100644 --- a/src/game_engine/unity/managed/readers.rs +++ b/src/game_engine/unity/managed/readers.rs @@ -1,6 +1,6 @@ use arrayvec::ArrayVec; use bytemuck::CheckedBitPattern; -use core::mem::MaybeUninit; +use core::mem::{size_of, MaybeUninit}; use crate::{string::ArrayWString, Address, Error, PointerSize, Process}; @@ -130,6 +130,116 @@ pub fn read_list( Ok(out) } +/// The most entries a dictionary may claim: a torn header must not buy a +/// scan proportional to whatever it says. +const MOST_ENTRIES: u32 = 1 << 20; + +/// The freed-entry marks: some runtimes write the mark over the stored +/// hash, others link the chain word below the empty value. +fn freed(hash: u32, next: i32) -> bool { + hash == u32::MAX || next < -1 +} + +/// What a member may span before it runs into the member behind it, or the +/// entry's end. +fn room(layout: &EntryLayout, member: u32) -> u32 { + [layout.hash, layout.next, layout.key, layout.value] + .into_iter() + .filter(|&other| other > member) + .min() + .unwrap_or(layout.stride) + - member +} + +/// Reads a managed dictionary's live pairs through the reference stored at +/// the given address, with the offsets a resolution handed out earlier. +/// The buffer judges the live pairs, never the counted entries or the +/// backing capacity; freed entries are skipped by their marks, and a live +/// tally that cannot balance against the counts fails rather than +/// answering wrong pairs. The key and value types are the caller's claims, +/// refused where a claim outgrows its member's room. +pub fn read_dictionary( + process: &Process, + pointer_size: PointerSize, + offsets: DictionaryOffsets, + at: Address, +) -> Result, Error> { + let layout = &offsets.layout; + if size_of::() as u32 > room(layout, layout.key) + || size_of::() as u32 > room(layout, layout.value) + { + return Err(Error {}); + } + + let object = process + .read_pointer(at, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let count = process.read::(object + offsets.count)?; + let free = process.read::(object + offsets.free_count)?; + let (count, free) = match (u32::try_from(count), u32::try_from(free)) { + (Ok(count), Ok(free)) if free <= count && count <= MOST_ENTRIES => (count, free), + _ => return Err(Error {}), + }; + let live = (count - free) as usize; + if live > N { + return Err(Error {}); + } + + let entries = process + .read_pointer(object + offsets.entries, pointer_size) + .ok() + .filter(|address| !address.is_null()) + .ok_or(Error {})?; + + let header = object_header(pointer_size); + let backing = process + .read_pointer(entries + header + pointer_size as u64, pointer_size)? + .value(); + if u64::from(count) > backing { + return Err(Error {}); + } + + // The entries bulk-read in chunks of the scratch, each entry judged by + // its marks and its pair lifted out element-wise. + let elements = entries + header + 2 * pointer_size as u64; + let stride = layout.stride as usize; + let per_chunk = ENTRY_SCRATCH / stride; + let mut scratch = [0; ENTRY_SCRATCH]; + + let mut out = ArrayVec::new(); + let mut index = 0; + while index < count as usize { + let taken = per_chunk.min(count as usize - index); + let bytes = &mut scratch[..taken * stride]; + process.read_into_slice(elements + (index * stride) as u64, bytes)?; + + for entry in bytes.chunks_exact(stride) { + let at = |member: u32, len: usize| &entry[member as usize..member as usize + len]; + let hash = u32::from_le_bytes(at(layout.hash, 4).try_into().expect("four bytes")); + let next = i32::from_le_bytes(at(layout.next, 4).try_into().expect("four bytes")); + if freed(hash, next) { + continue; + } + + let key = bytemuck::checked::try_pod_read_unaligned(at(layout.key, size_of::())) + .map_err(|_| Error {})?; + let value = bytemuck::checked::try_pod_read_unaligned(at(layout.value, size_of::())) + .map_err(|_| Error {})?; + out.try_push((key, value)).map_err(|_| Error {})?; + } + + index += taken; + } + + if out.len() != live { + return Err(Error {}); + } + Ok(out) +} + /// Reads a managed array of value elements through the reference stored at /// the given address. The layout is runtime ABI: past the object header sit /// the bounds word, the length, and the elements inline. diff --git a/src/game_engine/unity/mono/collections_tests.rs b/src/game_engine/unity/mono/collections_tests.rs index afed3c1..4b529fd 100644 --- a/src/game_engine/unity/mono/collections_tests.rs +++ b/src/game_engine/unity/mono/collections_tests.rs @@ -128,9 +128,39 @@ fn image() -> Vec { let parallel = [BASE + 0x2300, BASE + 0x2340, BASE + 0x2380, BASE + 0x23C0]; dictionary_class(&mut i, 0x1B00, 0x1C40, parallel, 0); + // The healthy dictionary's live state: three counted entries over a + // four-slot backing, the middle one freed, so two pairs are live. + ptr(&mut i, 0x118, BASE + 0x1D00); + put(&mut i, 0x120, &3_i32.to_le_bytes()); + put(&mut i, 0x124, &1_i32.to_le_bytes()); + put(&mut i, 0x1D18, &4_u32.to_le_bytes()); + entry(&mut i, 0x1D20, 1111, -1, 10, 100); + entry(&mut i, 0x1D30, -1, -1, 99, 999); // freed: the hash carries the mark + entry(&mut i, 0x1D40, 2222, -1, 20, 200); + entry(&mut i, 0x1D50, 0, 0, 0, 0); + + // The framework dictionary claims three live entries but holds two: its + // tally cannot balance. + ptr(&mut i, 0x198, BASE + 0x1E00); + put(&mut i, 0x1A0, &3_i32.to_le_bytes()); + put(&mut i, 0x1A4, &0_i32.to_le_bytes()); + put(&mut i, 0x1E18, &4_u32.to_le_bytes()); + entry(&mut i, 0x1E20, 1111, -1, 1, 2); + entry(&mut i, 0x1E30, -1, -1, 0, 0); + entry(&mut i, 0x1E40, 2222, -1, 3, 4); + entry(&mut i, 0x1E50, 0, 0, 0, 0); + i } +// One live or freed entry at the fixture's 16-byte stride: the stored hash, +// the chain link, and an i32 key and value. +fn entry(image: &mut [u8], at: u64, hash: i32, next: i32, key: i32, value: i32) { + for (index, word) in [hash, next, key, value].into_iter().enumerate() { + put(image, at + 4 * index as u64, &word.to_le_bytes()); + } +} + fn module() -> Module { Module { assemblies: Address::new(BASE), @@ -195,3 +225,59 @@ fn parallel_shape_names_answer_nothing() { .is_none()); }); } + +// The read returns exactly the live pairs: the counted entries minus the +// freed one, in entry order. +#[test] +fn dictionaries_read_their_live_pairs() { + on_fixture(|process, module| { + let slot = Address::new(BASE); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + let pairs = module + .read_dictionary::(process, offsets, slot) + .unwrap(); + assert_eq!(pairs.as_slice(), [(10, 100), (20, 200)]); + }); +} + +// The buffer judges the live pairs, never the counted entries or the +// backing capacity. +#[test] +fn read_buffers_judge_live_pairs() { + on_fixture(|process, module| { + let slot = Address::new(BASE); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + assert!(module + .read_dictionary::(process, offsets, slot) + .is_ok()); + assert!(module + .read_dictionary::(process, offsets, slot) + .is_err()); + }); +} + +// A claimed element size past its member's room would read a sibling's +// bytes; the read refuses instead. +#[test] +fn oversized_element_claims_refuse() { + on_fixture(|process, module| { + let slot = Address::new(BASE); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + assert!(module + .read_dictionary::(process, offsets, slot) + .is_err()); + }); +} + +// A live tally that cannot balance against the counts is a torn or lying +// dictionary, and fails rather than answering wrong pairs. +#[test] +fn unbalanced_tallies_refuse() { + on_fixture(|process, module| { + let slot = Address::new(BASE + 0x8); + let offsets = module.get_dictionary_offsets(process, slot).unwrap(); + assert!(module + .read_dictionary::(process, offsets, slot) + .is_err()); + }); +} diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index c65c616..78427ae 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -408,6 +408,24 @@ impl Module { self.walk().dictionary_offsets(process, object) } + /// Reads a managed `Dictionary`'s live pairs through the reference + /// stored at the given address, with the offsets + /// [`get_dictionary_offsets`](Self::get_dictionary_offsets) resolved. + /// `N` bounds the live pairs, never the counted entries or the backing + /// capacity; freed entries are skipped by their marks, and a live tally + /// that cannot balance against the counts fails rather than answering + /// wrong pairs. The key and value types are the caller's claims, as + /// with [`read_array`](Self::read_array), refused where a claim + /// outgrows the room its member has inside one entry. + pub fn read_dictionary( + &self, + process: &Process, + offsets: DictionaryOffsets, + at: Address, + ) -> Result, Error> { + managed::read_dictionary(process, self.pointer_size, offsets, at) + } + /// Reads a managed `List` of value elements through the reference stored /// at the given address, with the offsets /// [`get_list_offsets`](Self::get_list_offsets) resolved. The list's