diff --git a/src/game_engine/unity/il2cpp/mod.rs b/src/game_engine/unity/il2cpp/mod.rs index 4ef61e1..7112bb9 100644 --- a/src/game_engine/unity/il2cpp/mod.rs +++ b/src/game_engine/unity/il2cpp/mod.rs @@ -343,7 +343,8 @@ impl Module { /// 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. + /// reference. A string already held by its object address reads with + /// [`read_string_object`](Self::read_string_object). pub fn read_string( &self, process: &Process, @@ -352,6 +353,23 @@ impl Module { managed::read_string(process, self.pointer_size, at) } + /// Reads a managed string at its object address rather than through a + /// reference: the form for an element + /// [`read_reference_array`](Self::read_reference_array) hands back. A + /// null address fails, so a null element refuses at the element with + /// its index in hand. The returned [`ManagedString`] holds exactly the + /// string's count of 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 does a + /// negative count. + pub fn read_string_object( + &self, + process: &Process, + object: Address, + ) -> Result, Error> { + managed::read_string_object(process, self.pointer_size, object) + } + /// 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 [`ArrayVec`] @@ -359,7 +377,8 @@ impl Module { /// 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. + /// `usize` never match. Reference elements read with + /// [`read_reference_array`](Self::read_reference_array). pub fn read_array( &self, process: &Process, @@ -368,6 +387,24 @@ impl Module { managed::read_array(process, self.pointer_size, at) } + /// Reads a managed array of reference elements through the reference + /// stored at the given address, handing back the elements' object + /// addresses read at the target's own pointer width. A null array + /// reference fails; null elements are data and come back as null + /// addresses at their positions, since positions carry meaning and + /// filtering is the caller's choice. `N` bounds how many elements the + /// returned vector holds, and an array claiming more than that fails + /// rather than truncates. A `string` element reads with + /// [`read_string_object`](Self::read_string_object); any other object's + /// fields read at its address directly. + pub fn read_reference_array( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_reference_array(process, self.pointer_size, at) + } + /// Resolves where a `List` keeps its backing array and live count, finding /// `System.Collections.Generic.List` in the class hierarchy of the object /// at the given address. The answer is a small `Copy` value worth storing, @@ -490,6 +527,24 @@ impl Module { managed::read_list(process, self.pointer_size, offsets, at) } + /// Reads a managed `List` of reference elements through the reference + /// stored at the given address, with the offsets + /// [`get_list_offsets`](Self::get_list_offsets) resolved, handing back + /// the elements' object addresses read at the target's own pointer + /// width. The list's live count is read, never its backing capacity; + /// `N` bounds the count, and a count past the buffer or past the + /// backing array's own length fails rather than truncates, as does a + /// null list reference. Null elements are data and come back as null + /// addresses at their positions. + pub fn read_reference_list( + &self, + process: &Process, + offsets: ListOffsets, + at: Address, + ) -> Result, Error> { + managed::read_reference_list(process, self.pointer_size, offsets, at) + } + /// Attaches to a Unity game that is using the IL2CPP backend. This function /// automatically detects the [IL2CPP version](Version). If you know the /// version in advance or it fails detecting it, use diff --git a/src/game_engine/unity/il2cpp/readers_tests.rs b/src/game_engine/unity/il2cpp/readers_tests.rs index ed934dd..b3ea61a 100644 --- a/src/game_engine/unity/il2cpp/readers_tests.rs +++ b/src/game_engine/unity/il2cpp/readers_tests.rs @@ -39,6 +39,11 @@ fn image() -> Vec { put(&mut i, 0x300 + 0x20, &11_u32.to_le_bytes()); put(&mut i, 0x300 + 0x24, &22_u32.to_le_bytes()); + // A string array holding the string object and a null element. + ptr(&mut i, 0x18, BASE + 0x400); + put(&mut i, 0x400 + 0x18, &2_u64.to_le_bytes()); + ptr(&mut i, 0x400 + 0x20, BASE + 0x100); + i } @@ -85,3 +90,20 @@ fn array_lengths_judge_at_full_width() { .is_err()); }); } + +#[test] +fn reference_arrays_resolve_through_their_reference() { + on_fixture(|process, module| { + let objects = module + .read_reference_array::<4>(process, Address::new(BASE + 0x18)) + .unwrap(); + assert_eq!( + objects.as_slice(), + [Address::new(BASE + 0x100), Address::NULL] + ); + + let read = module.read_string_object::<8>(process, objects[0]).unwrap(); + assert!(read.matches_str("Loop")); + assert!(module.read_string_object::<8>(process, objects[1]).is_err()); + }); +} diff --git a/src/game_engine/unity/il2cpp/walk_tests.rs b/src/game_engine/unity/il2cpp/walk_tests.rs index ff1cdf5..3f31d9a 100644 --- a/src/game_engine/unity/il2cpp/walk_tests.rs +++ b/src/game_engine/unity/il2cpp/walk_tests.rs @@ -647,6 +647,15 @@ fn image(version: Version) -> Vec { ptr(&mut i, 0x3800, BASE + lookalike); ptr(&mut i, 0x28, BASE + 0x3800); + // A reference list shares the class; its backing holds an object + // address and a null element. + ptr(&mut i, 0x1A00, BASE + list_class); + ptr(&mut i, 0x1A00 + 0x10, BASE + 0x1B00); + put(&mut i, 0x1A00 + 0x18, &2_i32.to_le_bytes()); + put(&mut i, 0x1B00 + 0x18, &2_u64.to_le_bytes()); + ptr(&mut i, 0x1B00 + 0x20, BASE + 0x1080); + ptr(&mut i, 0x30, BASE + 0x1A00); + i } @@ -816,6 +825,23 @@ fn list_shaped_objects_are_not_lists() { }); } +// A reference list hands back its elements' object addresses, a null +// element preserved at its position. +#[test] +fn reference_lists_resolve_their_element_addresses() { + on_fixture(Version::V2022, |process, module| { + let at = Address::new(BASE + 0x30); + let offsets = module.get_list_offsets(process, at).unwrap(); + let read = module + .read_reference_list::<4>(process, offsets, at) + .unwrap(); + assert_eq!( + read.as_slice(), + [Address::new(BASE + 0x1080), Address::NULL] + ); + }); +} + // The whole pointer path: the static root, the instance behind it, and a field // resolved against the object's own class read off its head. #[test] diff --git a/src/game_engine/unity/managed/mod.rs b/src/game_engine/unity/managed/mod.rs index 7aefb7d..6ebfc75 100644 --- a/src/game_engine/unity/managed/mod.rs +++ b/src/game_engine/unity/managed/mod.rs @@ -16,8 +16,9 @@ mod walk; pub use cursor::{Assemblies, Classes}; pub use pointer::PointerPath; pub use readers::{ - read_array, read_dictionary, read_hash_set, read_list, read_string, DictionaryOffsets, - EntryLayout, HashSetOffsets, ListOffsets, SlotLayout, + read_array, read_dictionary, read_hash_set, read_list, read_reference_array, + read_reference_list, read_string, read_string_object, DictionaryOffsets, EntryLayout, + HashSetOffsets, ListOffsets, SlotLayout, }; #[cfg(test)] pub(crate) use readers::{DictionaryShape, SetShape}; diff --git a/src/game_engine/unity/managed/readers.rs b/src/game_engine/unity/managed/readers.rs index 5a6e490..da187d3 100644 --- a/src/game_engine/unity/managed/readers.rs +++ b/src/game_engine/unity/managed/readers.rs @@ -3,7 +3,7 @@ use bytemuck::CheckedBitPattern; use core::mem::{size_of, MaybeUninit}; use super::ManagedString; -use crate::{Address, Error, PointerSize, Process}; +use crate::{Address, Address32, Address64, Error, PointerSize, Process}; /// How many bytes a managed object's two header words occupy. The readers /// skip them; nothing in them is read. @@ -12,10 +12,6 @@ const fn object_header(pointer_size: PointerSize) -> 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, @@ -27,6 +23,23 @@ pub fn read_string( .filter(|address| !address.is_null()) .ok_or(Error {})?; + read_string_object(process, pointer_size, object) +} + +/// Reads a managed string at its object address, the form a reference +/// array's elements hand back. 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_object( + process: &Process, + pointer_size: PointerSize, + object: Address, +) -> Result, Error> { + if object.is_null() { + return Err(Error {}); + } + let header = object_header(pointer_size); let count = process.read::(object + header)?; @@ -582,3 +595,58 @@ pub fn read_array( .map_err(|_| Error {})?; Ok(array) } + +/// Reads a managed array of reference elements through the reference +/// stored at the given address, handing back the elements' object +/// addresses. The element slots are pointers at the target's own width, +/// which the reader owns rather than taking as a claim. Null elements +/// are data and stay at their positions; a null array reference fails. +pub fn read_reference_array( + process: &Process, + pointer_size: PointerSize, + at: Address, +) -> Result, Error> { + match pointer_size { + PointerSize::Bit64 => Ok(read_array::(process, pointer_size, at)? + .iter() + .copied() + .map(Into::into) + .collect()), + PointerSize::Bit32 => Ok(read_array::(process, pointer_size, at)? + .iter() + .copied() + .map(Into::into) + .collect()), + // No managed runtime is 16-bit. + PointerSize::Bit16 => Err(Error {}), + } +} + +/// Reads a managed list of reference elements through the reference +/// stored at the given address, with the offsets a resolution handed out +/// earlier, handing back the elements' object addresses the way +/// [`read_reference_array`] does. The count and backing checks are +/// [`read_list`]'s. +pub fn read_reference_list( + process: &Process, + pointer_size: PointerSize, + offsets: ListOffsets, + at: Address, +) -> Result, Error> { + match pointer_size { + PointerSize::Bit64 => Ok( + read_list::(process, pointer_size, offsets, at)? + .into_iter() + .map(Into::into) + .collect(), + ), + PointerSize::Bit32 => Ok( + read_list::(process, pointer_size, offsets, at)? + .into_iter() + .map(Into::into) + .collect(), + ), + // No managed runtime is 16-bit. + PointerSize::Bit16 => Err(Error {}), + } +} diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index dc97954..ef0e56c 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -347,7 +347,8 @@ impl Module { /// 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. + /// reference. A string already held by its object address reads with + /// [`read_string_object`](Self::read_string_object). pub fn read_string( &self, process: &Process, @@ -356,6 +357,23 @@ impl Module { managed::read_string(process, self.pointer_size, at) } + /// Reads a managed string at its object address rather than through a + /// reference: the form for an element + /// [`read_reference_array`](Self::read_reference_array) hands back. A + /// null address fails, so a null element refuses at the element with + /// its index in hand. The returned [`ManagedString`] holds exactly the + /// string's count of 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 does a + /// negative count. + pub fn read_string_object( + &self, + process: &Process, + object: Address, + ) -> Result, Error> { + managed::read_string_object(process, self.pointer_size, object) + } + /// 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 [`ArrayVec`] @@ -363,7 +381,8 @@ impl Module { /// 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. + /// `usize` never match. Reference elements read with + /// [`read_reference_array`](Self::read_reference_array). pub fn read_array( &self, process: &Process, @@ -372,6 +391,24 @@ impl Module { managed::read_array(process, self.pointer_size, at) } + /// Reads a managed array of reference elements through the reference + /// stored at the given address, handing back the elements' object + /// addresses read at the target's own pointer width. A null array + /// reference fails; null elements are data and come back as null + /// addresses at their positions, since positions carry meaning and + /// filtering is the caller's choice. `N` bounds how many elements the + /// returned vector holds, and an array claiming more than that fails + /// rather than truncates. A `string` element reads with + /// [`read_string_object`](Self::read_string_object); any other object's + /// fields read at its address directly. + pub fn read_reference_array( + &self, + process: &Process, + at: Address, + ) -> Result, Error> { + managed::read_reference_array(process, self.pointer_size, at) + } + /// Resolves where a `List` keeps its backing array and live count, finding /// `System.Collections.Generic.List` in the class hierarchy of the object /// at the given address. The answer is a small `Copy` value worth storing, @@ -494,6 +531,24 @@ impl Module { managed::read_list(process, self.pointer_size, offsets, at) } + /// Reads a managed `List` of reference elements through the reference + /// stored at the given address, with the offsets + /// [`get_list_offsets`](Self::get_list_offsets) resolved, handing back + /// the elements' object addresses read at the target's own pointer + /// width. The list's live count is read, never its backing capacity; + /// `N` bounds the count, and a count past the buffer or past the + /// backing array's own length fails rather than truncates, as does a + /// null list reference. Null elements are data and come back as null + /// addresses at their positions. + pub fn read_reference_list( + &self, + process: &Process, + offsets: ListOffsets, + at: Address, + ) -> Result, Error> { + managed::read_reference_list(process, self.pointer_size, offsets, at) + } + /// Attaches to a Unity game that is using the standard Mono backend. This /// function automatically detects the [Mono version](Version). If you /// know the version in advance or it fails detecting it, use diff --git a/src/game_engine/unity/mono/readers_tests.rs b/src/game_engine/unity/mono/readers_tests.rs index e4d7f72..c7031f0 100644 --- a/src/game_engine/unity/mono/readers_tests.rs +++ b/src/game_engine/unity/mono/readers_tests.rs @@ -41,6 +41,7 @@ fn image() -> Vec { 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 + ptr(&mut i, 0x40, BASE + 0x800); // a string array with a null element put(&mut i, 0x100 + 0x10, &9_i32.to_le_bytes()); utf16(&mut i, 0x100 + 0x14, "Chapter 3"); @@ -71,6 +72,15 @@ fn image() -> Vec { put(&mut i, 0x700 + 0x18, &5_u32.to_le_bytes()); utf16(&mut i, 0x700 + 0x20, "melon"); + put(&mut i, 0x900 + 0x10, &5_i32.to_le_bytes()); + utf16(&mut i, 0x900 + 0x14, "Docks"); + + // The reference array's slots hold the two string objects around a null + // element. + put(&mut i, 0x800 + 0x18, &3_u32.to_le_bytes()); + ptr(&mut i, 0x800 + 0x20, BASE + 0x100); + ptr(&mut i, 0x800 + 0x30, BASE + 0x900); + i } @@ -180,7 +190,8 @@ fn array_lengths_past_the_buffer_refuse() { } // The 32-bit layout halves the header, the reference width, and the length -// slot. +// slot. The reference array's slots are 4 bytes wide with garbage right +// behind them, so a wide stride reads loudly wrong addresses. #[test] fn readers_resolve_on_32_bit_targets() { let mut i = vec![0; 0x1000]; @@ -191,6 +202,10 @@ fn readers_resolve_on_32_bit_targets() { 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()); + put(&mut i, 0x10, &((BASE + 0x300) as u32).to_le_bytes()); + put(&mut i, 0x300 + 0xC, &2_u32.to_le_bytes()); + put(&mut i, 0x300 + 0x10, &((BASE + 0x100) as u32).to_le_bytes()); + put(&mut i, 0x300 + 0x18, &u32::MAX.to_le_bytes()); with_process(&[(BASE, &i)], |process| { let module = module(PointerSize::Bit32); @@ -203,5 +218,68 @@ fn readers_resolve_on_32_bit_targets() { .read_array::(process, Address::new(BASE + 0x8)) .unwrap(); assert_eq!(read.as_slice(), [21, 22]); + + let read = module + .read_reference_array::<4>(process, Address::new(BASE + 0x10)) + .unwrap(); + assert_eq!(read.as_slice(), [Address::new(BASE + 0x100), Address::NULL]); + + let read = module.read_string_object::<8>(process, read[0]).unwrap(); + assert!(read.matches_str("Ridge")); + }); +} + +// Null elements are data: they come back as null addresses at their +// positions, since positions carry meaning and filtering is the caller's +// one-liner. Only a null array reference refuses. +#[test] +fn reference_arrays_preserve_null_elements_in_place() { + on_fixture(|process, module| { + let read = module + .read_reference_array::<8>(process, Address::new(BASE + 0x40)) + .unwrap(); + assert_eq!( + read.as_slice(), + [ + Address::new(BASE + 0x100), + Address::NULL, + Address::new(BASE + 0x900), + ] + ); + }); +} + +#[test] +fn reference_array_lengths_past_the_buffer_refuse() { + on_fixture(|process, module| { + assert!(module + .read_reference_array::<2>(process, Address::new(BASE + 0x40)) + .is_err()); + assert!(module + .read_reference_array::<8>(process, Address::new(BASE + 0x18)) + .is_err()); + }); +} + +// The object-address form composes over a reference array's elements; a +// null address refuses at the element, with the index in hand. +#[test] +fn string_objects_read_at_their_addresses() { + on_fixture(|process, module| { + let objects = module + .read_reference_array::<8>(process, Address::new(BASE + 0x40)) + .unwrap(); + + let read = module + .read_string_object::<16>(process, objects[0]) + .unwrap(); + assert!(read.matches_str("Chapter 3")); + assert!(module + .read_string_object::<16>(process, objects[1]) + .is_err()); + let read = module + .read_string_object::<16>(process, objects[2]) + .unwrap(); + assert!(read.matches_str("Docks")); }); } diff --git a/src/game_engine/unity/mono/walk_tests.rs b/src/game_engine/unity/mono/walk_tests.rs index 1a021b7..21d482f 100644 --- a/src/game_engine/unity/mono/walk_tests.rs +++ b/src/game_engine/unity/mono/walk_tests.rs @@ -240,13 +240,22 @@ fn image() -> Vec { ptr(&mut i, 0x3B00 + 0x10, BASE + 0x3A00); put(&mut i, 0x3B00 + 0x18, &99_i32.to_le_bytes()); + // A reference list shares the class too; its backing holds an object + // address and a null element. + ptr(&mut i, 0x3E50, BASE + 0x3950); + ptr(&mut i, 0x3E50 + 0x10, BASE + 0x3E80); + put(&mut i, 0x3E50 + 0x18, &2_i32.to_le_bytes()); + put(&mut i, 0x3E80 + 0x18, &4_u32.to_le_bytes()); + ptr(&mut i, 0x3E80 + 0x20, BASE + 0x1900); + ptr(&mut i, 0x3C00, BASE + 0x3C50); // an Inventory object, not a list ptr(&mut i, 0x3C50, BASE + 0x2D00); - // The slots holding the three references. + // The slots holding the four references. ptr(&mut i, 0x3F00, BASE + 0x3900); ptr(&mut i, 0x3F08, BASE + 0x3B00); ptr(&mut i, 0x3F10, BASE + 0x3C00); + ptr(&mut i, 0x3F18, BASE + 0x3E50); // A derived list inherits the two fields from List rather than declaring // them again. @@ -530,6 +539,30 @@ fn list_counts_past_their_backing_refuse() { }); } +// A reference list hands back its elements' object addresses at the +// target's width, a null element preserved at its position; the same +// torn-resize refusal guards the count. +#[test] +fn reference_lists_resolve_their_element_addresses() { + on_fixture(era(), |process, module| { + let at = Address::new(BASE + 0x3F18); + let offsets = module.get_list_offsets(process, at).unwrap(); + let read = module + .read_reference_list::<4>(process, offsets, at) + .unwrap(); + assert_eq!( + read.as_slice(), + [Address::new(BASE + 0x1900), Address::NULL] + ); + + let at = Address::new(BASE + 0x3F08); + let offsets = module.get_list_offsets(process, at).unwrap(); + assert!(module + .read_reference_list::<128>(process, offsets, at) + .is_err()); + }); +} + // An object whose class does not carry corlib's names is not a list, and // misses cleanly rather than answering with whatever offsets exist. #[test]