From 42e1985f5491d557b58cd5c691e142d8fa222e6e Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 03:05:27 +0200 Subject: [PATCH 1/3] add managed string reads --- src/game_engine/unity/il2cpp/mod.rs | 24 ++- src/game_engine/unity/il2cpp/readers_tests.rs | 56 +++++++ src/game_engine/unity/managed/mod.rs | 4 + src/game_engine/unity/managed/readers.rs | 42 ++++++ src/game_engine/unity/managed/string.rs | 78 ++++++++++ src/game_engine/unity/mod.rs | 1 + src/game_engine/unity/mono/mod.rs | 22 ++- src/game_engine/unity/mono/readers_tests.rs | 138 ++++++++++++++++++ 8 files changed, 360 insertions(+), 5 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/managed/string.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 a724f939..99d897e6 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, Address, Error, + PointerSize, Process, }; mod builds; @@ -17,9 +17,11 @@ 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; +use super::{managed, ManagedString}; /// Represents access to a Unity game that is using the IL2CPP backend. pub struct Module { @@ -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, and the returned [`ManagedString`] holds exactly that many + /// UTF-16 units, a nul character among them like any other. `N` bounds + /// how many units the string holds, and a string claiming more than that + /// fails rather than truncates, as do a negative count and a null + /// reference. + 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 00000000..99f7724d --- /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 2d12b034..f4a5655a 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -8,12 +8,16 @@ mod cursor; mod pointer; +mod readers; mod runtime; +mod string; mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; +pub use readers::read_string; pub use runtime::{Il2CppRuntime, MonoRuntime, Runtime}; +pub use string::ManagedString; pub use walk::Walk; use crate::{string::ArrayCString, Address, PointerSize, Process}; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs new file mode 100644 index 00000000..d3f3a9df --- /dev/null +++ b/src/game_engine/unity/managed/readers.rs @@ -0,0 +1,42 @@ +use super::ManagedString; +use crate::{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. The count is the string's length, so a nul character +/// inside the string is kept like any other. +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 units = [0_u16; N]; + let characters = &mut bytemuck::cast_slice_mut::(&mut units)[..2 * count]; + process.read_into_slice(object + header + 4, characters)?; + + ManagedString::from_units(&units[..count]).ok_or(Error {}) +} diff --git a/src/game_engine/unity/managed/string.rs b/src/game_engine/unity/managed/string.rs new file mode 100644 index 00000000..64472810 --- /dev/null +++ b/src/game_engine/unity/managed/string.rs @@ -0,0 +1,78 @@ +use arrayvec::ArrayVec; +use core::ops; + +/// A managed string read out of a game: the UTF-16 units of a +/// `System.String`, as many as the string counts. A nul unit is a character +/// like any other, and an unpaired surrogate stays as it was read. `N` bounds +/// how many units the string holds. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ManagedString(ArrayVec); + +impl ManagedString { + /// Builds a string from its units. More than `N` units is `None`. + pub(crate) fn from_units(units: &[u16]) -> Option { + let mut string = ArrayVec::new(); + string.try_extend_from_slice(units).ok()?; + Some(Self(string)) + } + + /// Returns every unit of the string. + pub fn as_slice(&self) -> &[u16] { + self.0.as_slice() + } + + /// Returns how many units the string holds. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Whether the string holds no units. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Checks whether the string is exactly the given units. + pub fn matches(&self, text: impl AsRef<[u16]>) -> bool { + self.as_slice() == text.as_ref() + } + + /// Checks whether the string is exactly the given text. This re-encodes + /// the text to UTF-16 as it compares, which is slower than + /// [`matches`](Self::matches). + pub fn matches_str(&self, text: &str) -> bool { + self.0.iter().copied().eq(text.encode_utf16()) + } +} + +impl ops::Deref for ManagedString { + type Target = [u16]; + + fn deref(&self) -> &Self::Target { + self.as_slice() + } +} + +#[cfg(test)] +mod tests { + use super::ManagedString; + + #[test] + fn compares_by_every_unit() { + let with_nul = ManagedString::<4>::from_units(&[b'a' as u16, 0, b'b' as u16]).unwrap(); + let a = ManagedString::<4>::from_units(&[b'a' as u16]).unwrap(); + assert_eq!(with_nul.len(), 3); + assert!(!with_nul.is_empty()); + assert_ne!(with_nul, a); + assert!(!with_nul.matches_str("a")); + assert!(with_nul.matches_str("a\0b")); + assert!(with_nul.matches([b'a' as u16, 0, b'b' as u16])); + assert!(a.matches_str("a")); + assert_eq!(with_nul[2], b'b' as u16); + } + + #[test] + fn refuses_more_units_than_it_holds() { + assert!(ManagedString::<2>::from_units(&[1, 2, 3]).is_none()); + assert!(ManagedString::<2>::from_units(&[]).unwrap().is_empty()); + } +} diff --git a/src/game_engine/unity/mod.rs b/src/game_engine/unity/mod.rs index 1e221459..c4490add 100644 --- a/src/game_engine/unity/mod.rs +++ b/src/game_engine/unity/mod.rs @@ -85,6 +85,7 @@ pub mod il2cpp; mod managed; +pub use managed::ManagedString; pub mod mono; pub mod scene_manager; diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index e0c61852..e814ed50 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -8,7 +8,7 @@ use crate::{ future::retry, print_limited, signature::Signature, - Address, Address32, PointerSize, Process, + Address, Address32, Error, PointerSize, Process, }; mod builds; @@ -23,9 +23,11 @@ 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}; +use super::{managed, BinaryFormat, ManagedString}; /// Represents access to a Unity game that is using the standard Mono backend. pub struct Module { @@ -329,6 +331,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, and the returned [`ManagedString`] holds exactly that many + /// UTF-16 units, a nul character among them like any other. `N` bounds + /// how many units the string holds, and a string claiming more than that + /// fails rather than truncates, as do a negative count and a null + /// reference. + 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 00000000..77337144 --- /dev/null +++ b/src/game_engine/unity/mono/readers_tests.rs @@ -0,0 +1,138 @@ +//! 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 + ptr(&mut i, 0x20, BASE + 0x400); // a string with a nul inside + + 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()); + + // A managed string may hold a nul character; its count says where it + // ends. + put(&mut i, 0x400 + 0x10, &3_i32.to_le_bytes()); + for (index, unit) in [b'a' as u16, 0, b'b' as u16].into_iter().enumerate() { + put(&mut i, 0x400 + 0x14 + 2 * index as u64, &unit.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. +// A nul character inside a managed string is a character like any other. +// The read keeps the runtime's count, so every unit stays reachable, and the +// string is not "a" just because a nul follows the a. +#[test] +fn strings_keep_a_nul_inside() { + on_fixture(|process, module| { + let read = module + .read_string::<16>(process, Address::new(BASE + 0x20)) + .unwrap(); + assert_eq!(read.len(), 3); + assert_eq!(read.as_slice(), [b'a' as u16, 0, b'b' as u16]); + assert!(read.matches_str("a\0b")); + assert!(read.matches([b'a' as u16, 0, b'b' as u16])); + assert!(!read.matches_str("a")); + assert!(!read.matches([b'a' as u16])); + assert_ne!( + read, + module + .read_string::<16>(process, Address::new(BASE)) + .unwrap() + ); + }); +} + +#[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 092a71848f596ee05d75adcc3ab1a0f5b5b0fc7a Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sat, 29 Aug 2026 03:09:11 +0200 Subject: [PATCH 2/3] add managed array reads --- src/game_engine/unity/il2cpp/mod.rs | 20 ++++- src/game_engine/unity/il2cpp/readers_tests.rs | 31 +++++++ src/game_engine/unity/managed/array.rs | 65 ++++++++++++++ src/game_engine/unity/managed/mod.rs | 4 +- src/game_engine/unity/managed/readers.rs | 42 ++++++++- src/game_engine/unity/mod.rs | 2 +- src/game_engine/unity/mono/mod.rs | 20 ++++- src/game_engine/unity/mono/readers_tests.rs | 90 +++++++++++++++++-- 8 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 src/game_engine/unity/managed/array.rs diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 99d897e6..84b311ed 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -1,5 +1,7 @@ //! Support for attaching to Unity games that are using the IL2CPP backend. +use bytemuck::CheckedBitPattern; + use crate::{ file_format::pe, future::retry, print_limited, signature::Signature, Address, Error, PointerSize, Process, @@ -21,7 +23,7 @@ mod readers_tests; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; -use super::{managed, ManagedString}; +use super::{managed, ManagedArray, ManagedString}; /// Represents access to a Unity game that is using the IL2CPP backend. pub struct Module { @@ -335,6 +337,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 [`ManagedArray`] + /// 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 99f7724d..ed934dd9 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/array.rs b/src/game_engine/unity/managed/array.rs new file mode 100644 index 00000000..c4b1ab50 --- /dev/null +++ b/src/game_engine/unity/managed/array.rs @@ -0,0 +1,65 @@ +use arrayvec::ArrayVec; +use core::ops; + +/// A managed array read out of a game: the elements of a `T[]`, as many as +/// the array counts. `N` bounds how many elements the array holds. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ManagedArray(ArrayVec); + +impl ManagedArray { + /// Builds an array from its elements. More than `N` elements is `None`. + pub(crate) fn from_elements(elements: &[T]) -> Option { + let mut array = ArrayVec::new(); + array.try_extend_from_slice(elements).ok()?; + Some(Self(array)) + } +} + +impl ManagedArray { + /// Returns every element of the array. + pub fn as_slice(&self) -> &[T] { + self.0.as_slice() + } + + /// Returns how many elements the array holds. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Whether the array holds no elements. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl ops::Deref for ManagedArray { + type Target = [T]; + + fn deref(&self) -> &Self::Target { + self.as_slice() + } +} + +#[cfg(test)] +mod tests { + use super::ManagedArray; + + #[test] + fn compares_by_every_element() { + let three = ManagedArray::::from_elements(&[7, 8, 9]).unwrap(); + let two = ManagedArray::::from_elements(&[7, 8]).unwrap(); + assert_eq!(three.len(), 3); + assert!(!three.is_empty()); + assert_ne!(three, two); + assert_eq!(three[2], 9); + assert_eq!(three.iter().sum::(), 24); + } + + #[test] + fn refuses_more_elements_than_it_holds() { + assert!(ManagedArray::::from_elements(&[1, 2, 3]).is_none()); + assert!(ManagedArray::::from_elements(&[]) + .unwrap() + .is_empty()); + } +} diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index f4a5655a..f2bfee48 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -6,6 +6,7 @@ //! fields, where its statics sit, and how a live object names its class. Below //! that, the walk is written once. +mod array; mod cursor; mod pointer; mod readers; @@ -13,9 +14,10 @@ mod runtime; mod string; mod walk; +pub use array::ManagedArray; 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 string::ManagedString; pub use walk::Walk; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs index d3f3a9df..908e2211 100644 --- a/src/game_engine/unity/managed/readers.rs +++ b/src/game_engine/unity/managed/readers.rs @@ -1,4 +1,7 @@ -use super::ManagedString; +use bytemuck::CheckedBitPattern; +use core::mem::MaybeUninit; + +use super::{ManagedArray, ManagedString}; use crate::{Address, Error, PointerSize, Process}; /// How many bytes a managed object's two header words occupy. The readers @@ -40,3 +43,40 @@ pub fn read_string( ManagedString::from_units(&units[..count]).ok_or(Error {}) } + +/// 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], + )?; + + ManagedArray::from_elements(elements).ok_or(Error {}) +} diff --git a/src/game_engine/unity/mod.rs b/src/game_engine/unity/mod.rs index c4490add..5ce981f6 100644 --- a/src/game_engine/unity/mod.rs +++ b/src/game_engine/unity/mod.rs @@ -85,7 +85,7 @@ pub mod il2cpp; mod managed; -pub use managed::ManagedString; +pub use managed::{ManagedArray, ManagedString}; pub mod mono; pub mod scene_manager; diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index e814ed50..79814280 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -3,6 +3,8 @@ #[cfg(feature = "alloc")] use crate::file_format::macho; +use bytemuck::CheckedBitPattern; + use crate::{ file_format::{elf, pe}, future::retry, @@ -27,7 +29,7 @@ mod readers_tests; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; -use super::{managed, BinaryFormat, ManagedString}; +use super::{managed, BinaryFormat, ManagedArray, ManagedString}; /// Represents access to a Unity game that is using the standard Mono backend. pub struct Module { @@ -347,6 +349,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 [`ManagedArray`] + /// 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 77337144..6b6f751f 100644 --- a/src/game_engine/unity/mono/readers_tests.rs +++ b/src/game_engine/unity/mono/readers_tests.rs @@ -1,7 +1,8 @@ -//! 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::super::ManagedArray; use super::{BinaryFormat, Module, MonoOffsets, Version}; use crate::runtime::mock::with_process; use crate::{Address, PointerSize, Process}; @@ -26,8 +27,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]; @@ -37,6 +39,9 @@ fn image() -> Vec { 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 string with a nul inside + ptr(&mut i, 0x28, BASE + 0x500); // a healthy i32 array + ptr(&mut i, 0x30, BASE + 0x600); // an array claiming more than a buffer holds + ptr(&mut i, 0x38, BASE + 0x700); // a u16 array put(&mut i, 0x100 + 0x10, &9_i32.to_le_bytes()); utf16(&mut i, 0x100 + 0x14, "Chapter 3"); @@ -51,6 +56,22 @@ fn image() -> Vec { put(&mut i, 0x400 + 0x14 + 2 * index as u64, &unit.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, 0x500 + 0x18, &3_u32.to_le_bytes()); + for (index, value) in [7_i32, 8, 9].into_iter().enumerate() { + put( + &mut i, + 0x500 + 0x20 + 4 * index as u64, + &value.to_le_bytes(), + ); + } + + put(&mut i, 0x600 + 0x18, &64_u32.to_le_bytes()); + + put(&mut i, 0x700 + 0x18, &5_u32.to_le_bytes()); + utf16(&mut i, 0x700 + 0x20, "melon"); + i } @@ -120,13 +141,63 @@ 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 + 0x28)) + .unwrap(); + assert_eq!(read.as_slice(), [7, 8, 9]); + assert_eq!(read.len(), 3); + assert_eq!(read[1], 8); + assert_eq!( + read, + ManagedArray::::from_elements(&[7, 8, 9]).unwrap() + ); + assert_ne!( + read, + ManagedArray::::from_elements(&[7, 8]).unwrap() + ); + }); +} + +// 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 + 0x38)) + .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 + 0x30)) + .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); @@ -134,5 +205,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 573dd1f57a8f65534319b42f5d9680a722fef012 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Thu, 17 Sep 2026 14:45:38 +0200 Subject: [PATCH 3/3] Return ArrayVec from managed array readers --- src/game_engine/unity/il2cpp/mod.rs | 7 ++- src/game_engine/unity/managed/array.rs | 65 --------------------- src/game_engine/unity/managed/mod.rs | 2 - src/game_engine/unity/managed/readers.rs | 11 +++- src/game_engine/unity/mod.rs | 2 +- src/game_engine/unity/mono/mod.rs | 7 ++- src/game_engine/unity/mono/readers_tests.rs | 11 +--- 7 files changed, 19 insertions(+), 86 deletions(-) delete mode 100644 src/game_engine/unity/managed/array.rs diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 84b311ed..76555d27 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -1,5 +1,6 @@ //! Support for attaching to Unity games that are using the IL2CPP backend. +use arrayvec::ArrayVec; use bytemuck::CheckedBitPattern; use crate::{ @@ -23,7 +24,7 @@ mod readers_tests; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; -use super::{managed, ManagedArray, ManagedString}; +use super::{managed, ManagedString}; /// Represents access to a Unity game that is using the IL2CPP backend. pub struct Module { @@ -339,7 +340,7 @@ impl Module { /// 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 [`ManagedArray`] + /// is passed; `N` bounds how many elements the returned [`ArrayVec`] /// 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 @@ -349,7 +350,7 @@ impl Module { &self, process: &Process, at: Address, - ) -> Result, Error> { + ) -> Result, Error> { managed::read_array(process, self.pointer_size, at) } diff --git a/src/game_engine/unity/managed/array.rs b/src/game_engine/unity/managed/array.rs deleted file mode 100644 index c4b1ab50..00000000 --- a/src/game_engine/unity/managed/array.rs +++ /dev/null @@ -1,65 +0,0 @@ -use arrayvec::ArrayVec; -use core::ops; - -/// A managed array read out of a game: the elements of a `T[]`, as many as -/// the array counts. `N` bounds how many elements the array holds. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ManagedArray(ArrayVec); - -impl ManagedArray { - /// Builds an array from its elements. More than `N` elements is `None`. - pub(crate) fn from_elements(elements: &[T]) -> Option { - let mut array = ArrayVec::new(); - array.try_extend_from_slice(elements).ok()?; - Some(Self(array)) - } -} - -impl ManagedArray { - /// Returns every element of the array. - pub fn as_slice(&self) -> &[T] { - self.0.as_slice() - } - - /// Returns how many elements the array holds. - pub fn len(&self) -> usize { - self.0.len() - } - - /// Whether the array holds no elements. - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } -} - -impl ops::Deref for ManagedArray { - type Target = [T]; - - fn deref(&self) -> &Self::Target { - self.as_slice() - } -} - -#[cfg(test)] -mod tests { - use super::ManagedArray; - - #[test] - fn compares_by_every_element() { - let three = ManagedArray::::from_elements(&[7, 8, 9]).unwrap(); - let two = ManagedArray::::from_elements(&[7, 8]).unwrap(); - assert_eq!(three.len(), 3); - assert!(!three.is_empty()); - assert_ne!(three, two); - assert_eq!(three[2], 9); - assert_eq!(three.iter().sum::(), 24); - } - - #[test] - fn refuses_more_elements_than_it_holds() { - assert!(ManagedArray::::from_elements(&[1, 2, 3]).is_none()); - assert!(ManagedArray::::from_elements(&[]) - .unwrap() - .is_empty()); - } -} diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index f2bfee48..18141022 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -6,7 +6,6 @@ //! fields, where its statics sit, and how a live object names its class. Below //! that, the walk is written once. -mod array; mod cursor; mod pointer; mod readers; @@ -14,7 +13,6 @@ mod runtime; mod string; mod walk; -pub use array::ManagedArray; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; pub use readers::{read_array, read_string}; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs index 908e2211..69882223 100644 --- a/src/game_engine/unity/managed/readers.rs +++ b/src/game_engine/unity/managed/readers.rs @@ -1,7 +1,8 @@ +use arrayvec::ArrayVec; use bytemuck::CheckedBitPattern; use core::mem::MaybeUninit; -use super::{ManagedArray, ManagedString}; +use super::ManagedString; use crate::{Address, Error, PointerSize, Process}; /// How many bytes a managed object's two header words occupy. The readers @@ -51,7 +52,7 @@ pub fn read_array( process: &Process, pointer_size: PointerSize, at: Address, -) -> Result, Error> { +) -> Result, Error> { let object = process .read_pointer(at, pointer_size) .ok() @@ -78,5 +79,9 @@ pub fn read_array( &mut elements[..length], )?; - ManagedArray::from_elements(elements).ok_or(Error {}) + let mut array = ArrayVec::new(); + array + .try_extend_from_slice(elements) + .map_err(|_| Error {})?; + Ok(array) } diff --git a/src/game_engine/unity/mod.rs b/src/game_engine/unity/mod.rs index 5ce981f6..c4490add 100644 --- a/src/game_engine/unity/mod.rs +++ b/src/game_engine/unity/mod.rs @@ -85,7 +85,7 @@ pub mod il2cpp; mod managed; -pub use managed::{ManagedArray, ManagedString}; +pub use managed::ManagedString; pub mod mono; pub mod scene_manager; diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index 79814280..c765633d 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -3,6 +3,7 @@ #[cfg(feature = "alloc")] use crate::file_format::macho; +use arrayvec::ArrayVec; use bytemuck::CheckedBitPattern; use crate::{ @@ -29,7 +30,7 @@ mod readers_tests; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; -use super::{managed, BinaryFormat, ManagedArray, ManagedString}; +use super::{managed, BinaryFormat, ManagedString}; /// Represents access to a Unity game that is using the standard Mono backend. pub struct Module { @@ -351,7 +352,7 @@ impl Module { /// 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 [`ManagedArray`] + /// is passed; `N` bounds how many elements the returned [`ArrayVec`] /// 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 @@ -361,7 +362,7 @@ impl Module { &self, process: &Process, at: Address, - ) -> Result, Error> { + ) -> Result, Error> { managed::read_array(process, self.pointer_size, at) } diff --git a/src/game_engine/unity/mono/readers_tests.rs b/src/game_engine/unity/mono/readers_tests.rs index 6b6f751f..e4d7f72a 100644 --- a/src/game_engine/unity/mono/readers_tests.rs +++ b/src/game_engine/unity/mono/readers_tests.rs @@ -2,7 +2,6 @@ //! 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::super::ManagedArray; use super::{BinaryFormat, Module, MonoOffsets, Version}; use crate::runtime::mock::with_process; use crate::{Address, PointerSize, Process}; @@ -150,14 +149,8 @@ fn arrays_resolve_through_their_reference() { assert_eq!(read.as_slice(), [7, 8, 9]); assert_eq!(read.len(), 3); assert_eq!(read[1], 8); - assert_eq!( - read, - ManagedArray::::from_elements(&[7, 8, 9]).unwrap() - ); - assert_ne!( - read, - ManagedArray::::from_elements(&[7, 8]).unwrap() - ); + assert_eq!(read.as_slice(), [7, 8, 9]); + assert_ne!(read.as_slice(), [7, 8]); }); }