Skip to content
51 changes: 51 additions & 0 deletions src/game_engine/unity/il2cpp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ impl Module {
Self::attach_with(process, il2cpp_module, format, profile)
}

/// Tries attaching to a Unity game that is using the IL2CPP backend, in
/// the module at `il2cpp_module`, with the provided measured [`Profile`].
/// Use this when the runtime sits in a module [`attach`](Self::attach)
/// doesn't look for, such as a renamed `GameAssembly.dll`.
/// [`Process::get_module_address`] finds a module by its name without
/// allocating. The module must be a PE or an ELF image whose pointer width
/// matches the profile.
pub fn attach_to_module(
process: &Process,
il2cpp_module: Address,
profile: Profile,
) -> Option<Self> {
let (il2cpp_module, format) = super::module_at(process, il2cpp_module)?;
let pointer_size = Self::pointer_size(process, il2cpp_module, format)?;
(pointer_size == profile.pointer_size).then_some(())?;

Self::attach_with(process, il2cpp_module, format, profile)
}

/// Finds the module the runtime is compiled into, which is
/// `GameAssembly.dll` on Windows and `GameAssembly.so` on Linux.
fn find_runtime_module(process: &Process) -> Option<((Address, u64), BinaryFormat)> {
Expand Down Expand Up @@ -480,6 +499,38 @@ impl Module {
retry(|| Self::attach(process, profile)).await
}

/// Attaches to a Unity game that is using the IL2CPP backend, in the
/// module at `il2cpp_module`, with the provided measured [`Profile`].
///
/// This is the `await`able version of
/// [`attach_to_module`](Self::attach_to_module), yielding back to the
/// runtime between each try.
pub async fn wait_attach_to_module(
process: &Process,
il2cpp_module: Address,
profile: Profile,
) -> Module {
retry(|| Self::attach_to_module(process, il2cpp_module, profile)).await
}

/// Creates a module from the addresses of the `s_Assemblies` vector and
/// the `s_TypeInfoDefinitionTable` pointer, with the provided measured
/// [`Profile`]. Use this when the auto splitter finds the two globals
/// itself because [`attach`](Self::attach) doesn't find them in a game.
/// Nothing checks the addresses or the profile against the game.
pub const fn new(
assemblies: Address,
type_info_definition_table: Address,
profile: Profile,
) -> Self {
Self {
assemblies,
type_info_definition_table,
profile,
pointer_size: profile.pointer_size,
}
}

/// Looks for the specified binary [image](Image) inside the target process.
/// An [image](Image) is a .NET DLL that is loaded
/// by the game. The `Assembly-CSharp` [image](Image) is the main game
Expand Down
67 changes: 67 additions & 0 deletions src/game_engine/unity/il2cpp/walk_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,30 @@ fn attach_auto_detect_uses_a_measured_build() {
assert_eq!(module.profile.class.static_fields, 0xA0);
}

// A runtime under a file name `attach` doesn't look for still attaches
// through the module the auto splitter hands in. An address that holds no
// module doesn't attach.
#[test]
fn a_given_module_attaches_under_any_name() {
let image = Player::game_assembly();
with_modules(
&[(GAME_ASSEMBLY, &image)],
&[("GameAssembly_x64.dll", GAME_ASSEMBLY, 0x1000)],
|process| {
let profile = super::profiles::UNITY_6000_5_0F1_X86_64;
assert!(Module::attach(process, profile).is_none());
let module = process.get_module_address("GameAssembly_x64.dll").unwrap();
let attached = Module::attach_to_module(process, module, profile).unwrap();
assert_eq!(attached.profile, profile);
assert!(Module::attach_to_module(process, module + 0x800, profile).is_none());
assert!(matches!(
poll_once(Module::wait_attach_to_module(process, module, profile)),
Poll::Ready(_)
));
},
);
}

#[test]
fn attach_uses_the_explicit_profile_and_checks_its_width() {
let player = Player {
Expand Down Expand Up @@ -668,6 +692,49 @@ fn type_counts_no_image_has_read_as_no_classes() {
});
}

// An auto splitter that found the two globals itself hands them in and gets
// the module `attach` would build from them. The pointer width comes from the
// profile.
#[test]
fn given_globals_read_like_found_ones() {
let found = Player {
unity: MEASURED_6000_5,
}
.attach()
.unwrap();
let given = Module::new(
found.assemblies,
found.type_info_definition_table,
found.profile,
);
assert_eq!(given.assemblies, found.assemblies);
assert_eq!(
given.type_info_definition_table,
found.type_info_definition_table
);
assert_eq!(given.profile, found.profile);
assert_eq!(given.pointer_size, found.pointer_size);

for unity in [MEASURED_2019, MEASURED_2022] {
with_process(&[(BASE, &image(unity))], |process| {
let module = Module::new(
Address::new(BASE),
Address::new(BASE + 0x10),
measured(unity, PointerSize::Bit64),
);
let image = module.get_default_image(process).unwrap();
assert!(image.get_class(process, &module, "GameManager").is_some());
assert_eq!(image.classes(process, &module).count(), 5);
});
}
let x86 = Module::new(
Address::new(BASE),
Address::new(BASE + 0x10),
measured(MEASURED_2019, PointerSize::Bit32),
);
assert_eq!(x86.get_pointer_size(), PointerSize::Bit32);
}

#[test]
fn classes_resolve_by_name_and_namespace() {
for unity in [MEASURED_2019, MEASURED_2022] {
Expand Down
22 changes: 21 additions & 1 deletion src/game_engine/unity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,14 @@ pub use managed::{DictionaryOffsets, HashSetOffsets, ListOffsets, ManagedString}
pub mod mono;
pub mod scene_manager;

use crate::{signature::Signature, Address, Process};
#[cfg(all(test, not(target_family = "wasm")))]
mod module_tests;

use crate::{
file_format::{elf, pe},
signature::Signature,
Address, Process,
};

const CSTR: usize = 128;

Expand All @@ -102,6 +109,19 @@ enum BinaryFormat {
MachO,
}

/// Reads the format and the size of the module at `address` from its
/// header. A PE image spans the size of image its optional header gives and
/// an ELF image runs to the end of its last load segment. asr has no reader
/// for a Mach-O image's size, so a Mach-O image gives `None` like any header
/// it doesn't know.
fn module_at(process: &Process, address: Address) -> Option<((Address, u64), BinaryFormat)> {
if let Some(size) = pe::read_size_of_image(process, address) {
return Some(((address, size as u64), BinaryFormat::PE));
}
let size = elf::read_size_of_image(process, address)?;
Some(((address, size), BinaryFormat::ELF))
}

/// Finds the Unity version string in a player, `2021.3.11f1` for
/// example, and returns its three numbers. The fourth part of a file
/// version has no equal in the string, so it is 0. A NUL heads the
Expand Down
61 changes: 61 additions & 0 deletions src/game_engine/unity/module_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! Tests reading a module's format and size from its header, for auto
//! splitters that hand in the module themselves.

use super::{module_at, BinaryFormat};
use crate::{runtime::mock::with_process, Address};

use std::vec;

const BASE: u64 = 0x7FF6_1000_0000;

fn put(image: &mut [u8], at: usize, bytes: &[u8]) {
image[at..at + bytes.len()].copy_from_slice(bytes);
}

#[test]
fn pe_module_spans_its_size_of_image() {
// The DOS header points at the COFF header, which is followed by a PE32+
// optional header that says the image is 0x5000 bytes.
let mut image = vec![0; 0x400];
put(&mut image, 0, b"MZ");
put(&mut image, 0x3C, &0x80_u32.to_le_bytes());
put(&mut image, 0x80, b"PE\0\0");
put(&mut image, 0x84, &0x8664_u16.to_le_bytes());
put(&mut image, 0x94, &0xF0_u16.to_le_bytes());
put(&mut image, 0x98, &0x20B_u16.to_le_bytes());
put(&mut image, 0x98 + 0x38, &0x5000_u32.to_le_bytes());
with_process(&[(BASE, &image)], |process| {
assert_eq!(
module_at(process, Address::new(BASE)),
Some(((Address::new(BASE), 0x5000), BinaryFormat::PE))
);
});
}

#[test]
fn elf_module_spans_to_its_last_load_segment() {
// An ELF64 header with one program header, a load segment that runs to
// 0x3000 in memory.
let mut image = vec![0; 0x400];
put(&mut image, 0, b"\x7fELF\x02\x01\x01");
put(&mut image, 0x10, &3_u16.to_le_bytes());
put(&mut image, 0x20, &0x40_u64.to_le_bytes());
put(&mut image, 0x36, &56_u16.to_le_bytes());
put(&mut image, 0x38, &1_u16.to_le_bytes());
put(&mut image, 0x40, &1_u32.to_le_bytes());
put(&mut image, 0x68, &0x3000_u64.to_le_bytes());
with_process(&[(BASE, &image)], |process| {
assert_eq!(
module_at(process, Address::new(BASE)),
Some(((Address::new(BASE), 0x3000), BinaryFormat::ELF))
);
});
}

#[test]
fn unknown_header_gives_no_module() {
let image = vec![0; 0x400];
with_process(&[(BASE, &image)], |process| {
assert_eq!(module_at(process, Address::new(BASE)), None);
});
}
45 changes: 45 additions & 0 deletions src/game_engine/unity/mono/attachment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,25 @@ fn runtime() -> Vec<u8> {
image
}

// An auto splitter that found the global itself hands it in and gets the
// module `attach` would build from it.
#[test]
fn a_given_global_builds_the_attached_module() {
let runtime = runtime();
with_modules(
&[(RUNTIME, &runtime)],
&[("libmono.so", RUNTIME, runtime.len() as u64)],
|process| {
let profile = profiles::UNITY_5_6_0F1_LINUX_MONO_X86_64;
let found = Module::attach(process, profile).unwrap();
let given = Module::new(found.assemblies, profile);
assert_eq!(given.assemblies, found.assemblies);
assert_eq!(given.profile, found.profile);
assert_eq!(given.pointer_size, found.pointer_size);
},
);
}

#[test]
fn an_explicit_profile_needs_neither_a_player_nor_alloc() {
let runtime = runtime();
Expand Down Expand Up @@ -111,3 +130,29 @@ fn an_unknown_legacy_runtime_uses_the_executables_version() {
},
);
}

// A runtime under a file name `attach` doesn't look for still attaches
// through the module the auto splitter hands in, which doesn't need `alloc`.
// An address that holds no module doesn't attach.
#[test]
fn a_given_module_attaches_under_any_name() {
let runtime = runtime();
with_modules(
&[(RUNTIME, &runtime)],
&[("libmono-game.so", RUNTIME, runtime.len() as u64)],
|process| {
let profile = profiles::UNITY_5_6_0F1_LINUX_MONO_X86_64;
assert!(Module::attach(process, profile).is_none());
let module = process.get_module_address("libmono-game.so").unwrap();
let attached = Module::attach_to_module(process, module, profile).unwrap();
assert_eq!(attached.profile, profile);
assert!(Module::attach_to_module(process, module + 0x1000, profile).is_none());
let Poll::Ready(attached) =
poll_once(Module::wait_attach_to_module(process, module, profile))
else {
panic!("the given module should attach immediately");
};
assert_eq!(attached.profile, profile);
},
);
}
53 changes: 52 additions & 1 deletion src/game_engine/unity/mono/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
//! a separate player module can be identified through its executable. A
//! `no_std` auto splitter using allocation must provide a global allocator. Use
//! [`Module::attach`] or [`Module::wait_attach`] with a known [`Profile`] to
//! attach without allocation on Windows and Linux. macOS support requires
//! attach without allocation on Windows and Linux.
//! [`Module::attach_to_module`] does the same for a runtime the auto splitter
//! finds itself. macOS support requires
//! `alloc` for Mach-O symbol lookup even with an explicit profile.

#[cfg(feature = "alloc")]
Expand Down Expand Up @@ -228,6 +230,28 @@ impl Module {
Self::attach_with(process, module_range, format, profile)
}

/// Tries attaching to a Unity game that is using the standard Mono
/// backend, in the module at `mono_module`, with the provided measured
/// [`Profile`]. Use this when the runtime sits in a module
/// [`attach`](Self::attach) doesn't look for, such as a renamed runtime
/// library. [`Process::get_module_address`] finds a module by its name
/// without allocating. The module must be a PE or an ELF image whose
/// pointer width matches the profile and the profile's library must be
/// the runtime the module holds.
pub fn attach_to_module(
process: &Process,
mono_module: Address,
profile: Profile,
) -> Option<Self> {
let (module_range, format) = super::module_at(process, mono_module)?;
let pointer_size = Self::pointer_size(process, module_range, format)?;
if pointer_size != profile.pointer_size {
return None;
}

Self::attach_with(process, module_range, format, profile)
}

fn find_runtime_module(
process: &Process,
) -> Option<((Address, u64), BinaryFormat, &'static str, Library)> {
Expand Down Expand Up @@ -739,6 +763,33 @@ impl Module {
retry(|| Self::attach(process, profile)).await
}

/// Attaches to a Unity game that is using the standard Mono backend, in
/// the module at `mono_module`, with the provided measured [`Profile`].
///
/// This is the `await`able version of
/// [`attach_to_module`](Self::attach_to_module), yielding back to the
/// runtime between each try.
pub async fn wait_attach_to_module(
process: &Process,
mono_module: Address,
profile: Profile,
) -> Self {
retry(|| Self::attach_to_module(process, mono_module, profile)).await
}

/// Creates a module from the address of the `loaded_assemblies` global,
/// the one that holds the pointer to the list, with the provided measured
/// [`Profile`]. Use this when the auto splitter finds the global itself
/// because [`attach`](Self::attach) doesn't find it in a game. Nothing
/// checks the address or the profile against the game.
pub const fn new(assemblies: Address, profile: Profile) -> Self {
Self {
assemblies,
profile,
pointer_size: profile.pointer_size,
}
}

/// Looks for the specified binary [image](Image) inside the target process.
/// An [image](Image) is a .NET DLL that is loaded
/// by the game. The `Assembly-CSharp` [image](Image) is the main game
Expand Down
17 changes: 17 additions & 0 deletions src/game_engine/unity/mono/walk_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,23 @@ fn images_resolve_by_name_through_both_routes() {
});
}

// A module built from the `loaded_assemblies` global reads the same as one
// `attach` built from it. The pointer width comes from the profile.
#[test]
fn given_assemblies_read_like_found_ones() {
with_process(&[(BASE, &image())], |process| {
let module = Module::new(Address::new(BASE), measured());
let image = module.get_default_image(process).unwrap();
assert!(image.get_class(process, &module, "GameManager").is_some());
assert_eq!(image.classes(process, &module).count(), 6);
});
let x86 = Module::new(
Address::new(BASE),
profiles::UNITY_2017_2_0F1_WINDOWS_MONO_BDWGC_X86,
);
assert_eq!(x86.get_pointer_size(), PointerSize::Bit32);
}

#[test]
fn classes_resolve_by_name_and_namespace() {
on_fixture(measured(), |process, module| {
Expand Down
Loading
Loading