From cb47ec88e1a211525df9579b8d4b910f90700fed Mon Sep 17 00:00:00 2001 From: ero-qt Date: Fri, 28 Aug 2026 19:41:54 +0200 Subject: [PATCH 1/2] 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 c94ae418..afb3f7d3 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 6e01955d..7783ba6b 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 00000000..736c01df --- /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 2/2] 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 28f65842..00000000 --- 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 4e1ba9c4..2358971a 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 d9a5587d..00000000 --- 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 970f995e..0741b8b0 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 afa74639..933fcb5e 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 6dd1ab6d..cfd2bcc3 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 00000000..55ef411f --- /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 00000000..f15e36f2 --- /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 00000000..2393284b --- /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 00000000..f7fbf475 --- /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 00000000..eaf32de0 --- /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 baa3bfa3..1e221459 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 12b6f129..00000000 --- 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 2ffe93c6..d958b840 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 af088227..00000000 --- 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 d6ca1ae5..51faa256 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 7783ba6b..ff5b0d41 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 8b75de7b..771c4703 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)) } }