From 7ba4ffde2190b80c753a5285a1c7221aa2724859 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sun, 30 Aug 2026 10:39:56 +0200 Subject: [PATCH 1/2] add known Linux Mono builds --- src/game_engine/unity/mono/identity_tests.rs | 119 ++++++ src/game_engine/unity/mono/linux_builds.rs | 417 +++++++++++++++++++ src/game_engine/unity/mono/mod.rs | 97 ++++- 3 files changed, 617 insertions(+), 16 deletions(-) create mode 100644 src/game_engine/unity/mono/identity_tests.rs create mode 100644 src/game_engine/unity/mono/linux_builds.rs diff --git a/src/game_engine/unity/mono/identity_tests.rs b/src/game_engine/unity/mono/identity_tests.rs new file mode 100644 index 00000000..c58f4e3d --- /dev/null +++ b/src/game_engine/unity/mono/identity_tests.rs @@ -0,0 +1,119 @@ +//! Tests pinning which binary names a Linux build. The runtime binary's own +//! build ID answers whenever it has one; `UnityPlayer.so`'s answers only when +//! the runtime carries none at all. + +use super::{BinaryFormat, Identity}; +use crate::runtime::mock::with_process; +use crate::{Address, Process}; + +use std::{vec, vec::Vec}; + +const RUNTIME: u64 = 0x7F00_0000_0000; +const PLAYER: u64 = 0x7F00_0010_0000; + +const RUNTIME_ID: [u8; 20] = [ + 0x4E, 0x69, 0x0F, 0x26, 0x4A, 0x2A, 0x90, 0x12, 0x03, 0x47, 0xF9, 0x4A, 0xE4, 0x3B, 0x0B, 0x83, + 0xD5, 0x78, 0xF6, 0x86, +]; +const PLAYER_ID: [u8; 8] = [0xC1, 0x91, 0x05, 0xBA, 0x5A, 0xAB, 0xAF, 0x80]; + +fn put(image: &mut [u8], at: usize, bytes: &[u8]) { + image[at..at + bytes.len()].copy_from_slice(bytes); +} + +// A mapped 64-bit ELF holding one note segment, carrying the given build ID +// when there is one to carry. +fn image(build_id: Option<&[u8]>) -> Vec { + let mut image = vec![0; 0x400]; + put(&mut image, 0x00, b"\x7fELF"); + image[0x04] = 2; + image[0x05] = 1; + image[0x06] = 1; + 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, &2_u16.to_le_bytes()); + put(&mut image, 0x40, &1_u32.to_le_bytes()); + + let Some(build_id) = build_id else { + return image; + }; + + put(&mut image, 0x78, &4_u32.to_le_bytes()); + put(&mut image, 0x88, &0x200_u64.to_le_bytes()); + put( + &mut image, + 0x98, + &(0x10 + build_id.len() as u64).to_le_bytes(), + ); + put(&mut image, 0x200, &4_u32.to_le_bytes()); + put(&mut image, 0x204, &(build_id.len() as u32).to_le_bytes()); + put(&mut image, 0x208, &3_u32.to_le_bytes()); + put(&mut image, 0x20C, b"GNU\0"); + put(&mut image, 0x210, build_id); + image +} + +fn on_modules(runtime: Option<&[u8]>, player: Option<&[u8]>, test: impl FnOnce(&Process)) { + let runtime = image(runtime); + let player = image(player); + with_process(&[(RUNTIME, &runtime), (PLAYER, &player)], |process| { + test(process) + }); +} + +fn read(process: &Process, player: bool) -> Option { + Identity::read( + process, + ((Address::new(RUNTIME), 0x400), "libmonobdwgc-2.0.so"), + player.then(|| (Address::new(PLAYER), 0x400)), + BinaryFormat::ELF, + ) +} + +fn named(identity: Option) -> (Vec, &'static str) { + match identity { + Some(Identity::Build(build_id, module)) => (build_id.as_bytes().to_vec(), module), + _ => panic!("the identity is a build ID"), + } +} + +#[test] +fn runtimes_are_named_by_their_own_build_id() { + on_modules(Some(&RUNTIME_ID), None, |process| { + let (build_id, module) = named(read(process, false)); + assert_eq!(build_id, RUNTIME_ID); + assert_eq!(module, "libmonobdwgc-2.0.so"); + }); +} + +// A runtime binary carrying no build ID is the later Linux shape, where the +// player is the only thing that names the build. +#[test] +fn runtimes_without_one_are_named_by_the_player() { + on_modules(None, Some(&PLAYER_ID), |process| { + let (build_id, module) = named(read(process, true)); + assert_eq!(build_id, PLAYER_ID); + assert_eq!(module, "UnityPlayer.so"); + }); +} + +// A runtime binary that names itself is the one that answers, known to the +// table or not. Reaching past it to the player would pair a runtime nobody +// measured with another binary's offsets, and attach with them. +#[test] +fn runtimes_that_name_themselves_are_never_passed_over() { + on_modules(Some(&RUNTIME_ID), Some(&PLAYER_ID), |process| { + let (build_id, module) = named(read(process, true)); + assert_eq!(build_id, RUNTIME_ID); + assert_eq!(module, "libmonobdwgc-2.0.so"); + }); +} + +#[test] +fn binaries_without_any_build_id_name_nothing() { + on_modules(None, None, |process| { + assert!(read(process, true).is_none()); + assert!(read(process, false).is_none()); + }); +} diff --git a/src/game_engine/unity/mono/linux_builds.rs b/src/game_engine/unity/mono/linux_builds.rs new file mode 100644 index 00000000..45d0f840 --- /dev/null +++ b/src/game_engine/unity/mono/linux_builds.rs @@ -0,0 +1,417 @@ +//! Known Linux Mono builds: exact runtime binaries paired with the offsets +//! measured from them. +//! +//! A build is named by its build ID, which a linker computes from the binary +//! it writes, so it names that one build and nothing else. Mono's library +//! has one through 2019.4 and none after, so `UnityPlayer.so`'s ID names the +//! later builds instead. Each entry names the file its ID was read from. + +use super::offsets::{ + AssemblyOffsets, ClassOffsets, FieldInfoOffsets, GenericOffsets, HashTableOffsets, + ImageOffsets, MonoOffsets, MonoVTableOffsets, TypeOffsets, +}; +use super::Version; +use crate::PointerSize; + +/// One exact Mono library and the offsets measured from it. +pub(super) struct Build { + pub(super) build_id: &'static [u8], + pub(super) pointer_size: PointerSize, + pub(super) version: Version, + pub(super) offsets: &'static MonoOffsets, +} + +/// Looks a build up by the ID read from the module that names it. +pub(super) fn find(build_id: &[u8]) -> Option<&'static Build> { + BUILDS.iter().find(|build| build.build_id == build_id) +} + +/// Parses a build ID from the hex it is normally written as, into the bytes +/// the binary stores. +const fn id(written: &str) -> [u8; N] { + const fn hex(byte: u8) -> u8 { + match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + _ => panic!("The build ID is not lowercase hex."), + } + } + + let written = written.as_bytes(); + assert!(written.len() == 2 * N, "The build ID is the wrong length."); + + let mut parsed = [0; N]; + let mut index = 0; + while index < N { + parsed[index] = (hex(written[2 * index]) << 4) | hex(written[2 * index + 1]); + index += 1; + } + + parsed +} + +// 5.6 +static UNITY_5_6: MonoOffsets = MonoOffsets { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x58, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x3D0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: None, + instance_size: Some(0x1C), + parent: 0x28, + nested_in: Some(0x30), + name: 0x40, + namespace: 0x48, + vtable_size: 0x18, + fields: 0xA0, + runtime_info: 0xF0, + field_count: 0x8C, + next_class_cache: 0xF8, + }, + generic: GenericOffsets { + generic_class: Some(0xD0), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xA), + }, + field: FieldInfoOffsets { + type_: Some(0x0), + name: 0x8, + offset: 0x18, + alignment: 0x20, + }, + // Nothing reads this: these builds keep their statics in the slot + // `vtable_size` points at. + v_table: MonoVTableOffsets { vtable: 0x48 }, +}; + +// 2017.4 +static UNITY_2017_4: MonoOffsets = MonoOffsets { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x58, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x3D0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: None, + instance_size: Some(0x1C), + parent: 0x28, + nested_in: Some(0x30), + name: 0x48, + namespace: 0x50, + vtable_size: 0x18, + fields: 0xA8, + runtime_info: 0xF8, + field_count: 0x94, + next_class_cache: 0x100, + }, + generic: GenericOffsets { + generic_class: Some(0xD8), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xA), + }, + field: FieldInfoOffsets { + type_: Some(0x0), + name: 0x8, + offset: 0x18, + alignment: 0x20, + }, + // Nothing reads this: these builds keep their statics in the slot + // `vtable_size` points at. + v_table: MonoVTableOffsets { vtable: 0x48 }, +}; + +// 2018.4, 2019.4 +static UNITY_2018_4: MonoOffsets = MonoOffsets { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x60, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4C0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: Some(0x24), + instance_size: Some(0x1C), + parent: 0x28, + nested_in: Some(0x30), + name: 0x40, + namespace: 0x48, + vtable_size: 0x54, + fields: 0x90, + runtime_info: 0xC8, + field_count: 0xF8, + next_class_cache: 0x100, + }, + generic: GenericOffsets { + generic_class: Some(0xE8), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xA), + }, + field: FieldInfoOffsets { + type_: Some(0x0), + name: 0x8, + offset: 0x18, + alignment: 0x20, + }, + v_table: MonoVTableOffsets { vtable: 0x40 }, +}; + +// 2021.3 - 6000.7 +static UNITY_2021_3: MonoOffsets = MonoOffsets { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x60, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4D0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: Some(0x1B), + instance_size: Some(0x1C), + parent: 0x28, + nested_in: Some(0x30), + name: 0x40, + namespace: 0x48, + vtable_size: 0x54, + fields: 0x90, + runtime_info: 0xC8, + field_count: 0xF8, + next_class_cache: 0x100, + }, + generic: GenericOffsets { + generic_class: Some(0xE8), + container_class: Some(0x0), + }, + type_words: TypeOffsets { + data: Some(0x0), + kind: Some(0xA), + }, + field: FieldInfoOffsets { + type_: Some(0x0), + name: 0x8, + offset: 0x18, + alignment: 0x20, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, +}; + +static BUILDS: &[Build] = &[ + // 5.6.7f1, libmono.so + Build { + build_id: &id::<20>("c1a53ea7109a2da58220ab30f4cab7c8ce8f3813"), + pointer_size: PointerSize::Bit64, + version: Version::V1, + offsets: &UNITY_5_6, + }, + // 2017.4.40f1, libmono.so + Build { + build_id: &id::<20>("93b5b95d7a6112b3f7d53b40e79a663cbcd62b14"), + pointer_size: PointerSize::Bit64, + version: Version::V1Cattrs, + offsets: &UNITY_2017_4, + }, + // 2018.4.36f1, libmonobdwgc-2.0.so + Build { + build_id: &id::<20>("dd788d1860d9a782468cb7304637dcdb7fbfd289"), + pointer_size: PointerSize::Bit64, + version: Version::V2, + offsets: &UNITY_2018_4, + }, + // 2019.4.41f2, libmonobdwgc-2.0.so + Build { + build_id: &id::<20>("4e690f264a2a90120347f94ae43b0b83d578f686"), + pointer_size: PointerSize::Bit64, + version: Version::V2, + offsets: &UNITY_2018_4, + }, + // 2021.3.0f1, UnityPlayer.so + Build { + build_id: &id::<8>("c19105ba5aabaf80"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: &UNITY_2021_3, + }, + // 2021.3.11f1, UnityPlayer.so + Build { + build_id: &id::<8>("1ecf45334b2b3190"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: &UNITY_2021_3, + }, + // 2022.3.0f1, UnityPlayer.so + Build { + build_id: &id::<8>("03da1f34b6af4765"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: &UNITY_2021_3, + }, + // 2023.1.0f1, UnityPlayer.so + Build { + build_id: &id::<8>("63be5bab8a2fbae2"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: &UNITY_2021_3, + }, + // 2023.1.22f1, UnityPlayer.so + Build { + build_id: &id::<8>("3bc89a83403a19ca"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: &UNITY_2021_3, + }, + // 6000.2.12f1, UnityPlayer.so + Build { + build_id: &id::<20>("bde00f619381ee2e39d0919cc82f1bb7fd314f21"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: &UNITY_2021_3, + }, + // 6000.3.21f1, UnityPlayer.so + Build { + build_id: &id::<20>("ad56ac1afbcf42b846610f49f2b6b78d9f24035f"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: &UNITY_2021_3, + }, + // 6000.5.8f1, UnityPlayer.so + Build { + build_id: &id::<20>("ac33e63fb791d385766540ef0d21b4a6677edf71"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: &UNITY_2021_3, + }, + // 6000.7.0a3, UnityPlayer.so + Build { + build_id: &id::<20>("c2e66208668984ba644c54c277f63e537a57f00e"), + pointer_size: PointerSize::Bit64, + version: Version::V3, + offsets: &UNITY_2021_3, + }, +]; + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::super::{BinaryFormat, Version}; + use super::{find, id, MonoOffsets, BUILDS}; + use crate::PointerSize; + + // The build ID of 2019.4's Mono library, as it is stored. + const STORED: [u8; 20] = [ + 0x4E, 0x69, 0x0F, 0x26, 0x4A, 0x2A, 0x90, 0x12, 0x03, 0x47, 0xF9, 0x4A, 0xE4, 0x3B, 0x0B, + 0x83, 0xD5, 0x78, 0xF6, 0x86, + ]; + + #[test] + fn parses_written_build_ids_into_stored_bytes() { + assert_eq!(id::<20>("4e690f264a2a90120347f94ae43b0b83d578f686"), STORED); + } + + #[test] + fn table_names_each_build_once() { + for (index, build) in BUILDS.iter().enumerate() { + assert!(!BUILDS[..index] + .iter() + .any(|earlier| earlier.build_id == build.build_id)); + } + } + + #[test] + fn finds_known_builds() { + let build = find(&STORED).unwrap(); + assert_eq!(build.pointer_size, PointerSize::Bit64); + assert!(matches!(build.version, Version::V2)); + + assert!(find(&[0; 20]).is_none()); + assert!(find(&[]).is_none()); + } + + // A version table's value for any member must match every measured build + // it stands in for, or say nothing. + #[test] + fn version_tables_never_contradict_a_measured_build() { + fn agrees(table: Option, measured: Option) -> bool { + table.is_none() || table == measured + } + + for build in BUILDS { + let Some(table) = + MonoOffsets::new(build.version, build.pointer_size, BinaryFormat::ELF) + else { + continue; + }; + let measured = build.offsets; + + assert_eq!(table.assembly.image, measured.assembly.image); + assert_eq!(table.image.class_cache, measured.image.class_cache); + assert_eq!(table.hash_table.size, measured.hash_table.size); + assert_eq!(table.hash_table.table, measured.hash_table.table); + assert_eq!(table.class.parent, measured.class.parent); + assert_eq!(table.class.name, measured.class.name); + assert_eq!(table.class.namespace, measured.class.namespace); + assert_eq!(table.class.vtable_size, measured.class.vtable_size); + assert_eq!(table.class.fields, measured.class.fields); + assert_eq!(table.class.runtime_info, measured.class.runtime_info); + assert_eq!(table.class.field_count, measured.class.field_count); + assert_eq!( + table.class.next_class_cache, + measured.class.next_class_cache, + ); + assert_eq!(table.field.name, measured.field.name); + assert_eq!(table.field.offset, measured.field.offset); + assert_eq!(table.field.alignment, measured.field.alignment); + + assert!(agrees(table.class.class_kind, measured.class.class_kind)); + assert!(agrees( + table.class.instance_size, + measured.class.instance_size + )); + assert!(agrees(table.class.nested_in, measured.class.nested_in)); + assert!(agrees( + table.generic.generic_class, + measured.generic.generic_class + )); + assert!(agrees( + table.generic.container_class, + measured.generic.container_class + )); + assert!(agrees(table.type_words.data, measured.type_words.data)); + assert!(agrees(table.type_words.kind, measured.type_words.kind)); + assert!(agrees(table.field.type_, measured.field.type_)); + } + } +} diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index ef0e56c0..3ecff940 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -5,6 +5,7 @@ use crate::file_format::macho; use arrayvec::ArrayVec; use bytemuck::CheckedBitPattern; +use core::fmt; use crate::{ file_format::{elf, pe}, @@ -16,6 +17,7 @@ use crate::{ mod builds; mod image; +mod linux_builds; pub use image::Image; mod class; pub use class::Class; @@ -28,6 +30,8 @@ use offsets::MonoOffsets; #[cfg(all(test, not(target_family = "wasm")))] mod collections_tests; #[cfg(all(test, not(target_family = "wasm")))] +mod identity_tests; +#[cfg(all(test, not(target_family = "wasm")))] mod readers_tests; #[cfg(all(test, not(target_family = "wasm")))] mod walk_tests; @@ -42,6 +46,64 @@ pub struct Module { pointer_size: PointerSize, } +/// The identity of one exact runtime binary, and the module it was read +/// from, so a build nobody has measured is reported as the file to look at. +enum Identity { + Debug(pe::DebugId), + Build(elf::BuildId, &'static str), +} + +impl Identity { + /// Reads what names the Mono library's build. On ELF the library's own + /// build ID answers whenever it has one, and `UnityPlayer.so`'s answers + /// only for a library that has none: a library naming itself is the one + /// that counts, known or not, where reaching past it would pair it with + /// another file's offsets. + fn read( + process: &Process, + runtime: ((Address, u64), &'static str), + player: Option<(Address, u64)>, + format: BinaryFormat, + ) -> Option { + let (runtime, runtime_name) = runtime; + + match format { + BinaryFormat::PE => pe::DebugId::read(process, runtime.0).map(Self::Debug), + BinaryFormat::ELF => match elf::build_id(process, runtime) { + Some(build_id) => Some(Self::Build(build_id, runtime_name)), + None => Some(Self::Build( + elf::build_id(process, player?)?, + "UnityPlayer.so", + )), + }, + #[allow(unreachable_patterns)] + _ => None, + } + } + + /// The version and offsets measured from the build this names, when it is + /// one that was measured at the width the target runs at. + fn find(&self, pointer_size: PointerSize) -> Option<(Version, &'static MonoOffsets)> { + match self { + Self::Debug(debug_id) => builds::find(debug_id) + .filter(|build| build.pointer_size == pointer_size) + .map(|build| (build.version, &build.offsets)), + Self::Build(build_id, _) => linux_builds::find(build_id.as_bytes()) + .filter(|build| build.pointer_size == pointer_size) + .map(|build| (build.version, build.offsets)), + } + } +} + +impl fmt::Debug for Identity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Debug(debug_id) => write!(f, "{debug_id:?}"), + Self::Build(build_id, module) => write!(f, "{build_id:?} in {module}"), + } + } +} + impl Module { /// Tries attaching to a Unity game that is using the standard Mono backend. /// If the mono runtime is a known build, this function uses the offsets @@ -49,27 +111,28 @@ impl Module { /// If you know the version in advance or it fails detecting it, use /// [`attach`](Self::attach) instead. pub fn attach_auto_detect(process: &Process) -> Option { - let (module_range, format) = Self::find_runtime_module(process)?; + let (module_range, format, name) = Self::find_runtime_module(process)?; let pointer_size = Self::pointer_size(process, module_range, format)?; - let debug_id = match format { - BinaryFormat::PE => pe::DebugId::read(process, module_range.0), + // The player names the build for a Mono library that has no ID of its + // own, which is every Linux one past a point. + let player = match format { + BinaryFormat::ELF => process.get_module_range("UnityPlayer.so").ok(), _ => None, }; + let identity = Identity::read(process, (module_range, name), player, format); - if let Some(debug_id) = &debug_id { - if let Some(build) = - builds::find(debug_id).filter(|build| build.pointer_size == pointer_size) - { + if let Some(identity) = &identity { + if let Some((version, offsets)) = identity.find(pointer_size) { if let Some(module) = Self::attach_with( process, module_range, format, pointer_size, - build.version, - &build.offsets, + version, + offsets, ) { - print_limited::<128>(&format_args!("known mono build: {debug_id:?}")); + print_limited::<128>(&format_args!("known mono build: {identity:?}")); return Some(module); } } @@ -78,9 +141,9 @@ impl Module { let version = Version::detect(process)?; let module = Self::attach(process, version)?; - if let Some(debug_id) = debug_id { - if builds::find(&debug_id).is_none() { - print_limited::<128>(&format_args!("unknown mono build: {debug_id:?}")); + if let Some(identity) = &identity { + if identity.find(pointer_size).is_none() { + print_limited::<128>(&format_args!("unknown mono build: {identity:?}")); } } @@ -92,7 +155,7 @@ impl Module { /// correct for this function to work. If you don't know the version in /// advance, use [`attach_auto_detect`](Self::attach_auto_detect) instead. pub fn attach(process: &Process, version: Version) -> Option { - let (module_range, format) = Self::find_runtime_module(process)?; + let (module_range, format, _) = Self::find_runtime_module(process)?; let pointer_size = Self::pointer_size(process, module_range, format)?; let offsets = MonoOffsets::new(version, pointer_size, format)?; @@ -106,7 +169,9 @@ impl Module { ) } - fn find_runtime_module(process: &Process) -> Option<((Address, u64), BinaryFormat)> { + fn find_runtime_module( + process: &Process, + ) -> Option<((Address, u64), BinaryFormat, &'static str)> { [ ("mono.dll", BinaryFormat::PE), ("libmono.so", BinaryFormat::ELF), @@ -118,7 +183,7 @@ impl Module { ("libmonobdwgc-2.0.dylib", BinaryFormat::MachO), ] .into_iter() - .find_map(|(name, format)| Some((process.get_module_range(name).ok()?, format))) + .find_map(|(name, format)| Some((process.get_module_range(name).ok()?, format, name))) } fn pointer_size( From f4b996722cd6ea360945c129c1fa8fed49a775e4 Mon Sep 17 00:00:00 2001 From: ero-qt Date: Sun, 30 Aug 2026 11:41:19 +0200 Subject: [PATCH 2/2] add measured members to Linux Mono offsets --- src/game_engine/unity/mono/linux_builds.rs | 53 +++++ src/game_engine/unity/mono/mod.rs | 5 +- src/game_engine/unity/mono/offsets.rs | 256 ++++++++++++++------- 3 files changed, 224 insertions(+), 90 deletions(-) diff --git a/src/game_engine/unity/mono/linux_builds.rs b/src/game_engine/unity/mono/linux_builds.rs index 45d0f840..2c60fb91 100644 --- a/src/game_engine/unity/mono/linux_builds.rs +++ b/src/game_engine/unity/mono/linux_builds.rs @@ -360,6 +360,59 @@ mod tests { assert!(find(&[]).is_none()); } + // Pairs each member a measurement can supply with its name, so both tests + // compare the same set and a failure says which member it was. + fn grown(offsets: &MonoOffsets) -> [(&'static str, Option); 8] { + [ + ("class_kind", offsets.class.class_kind), + ("instance_size", offsets.class.instance_size), + ("nested_in", offsets.class.nested_in), + ("generic_class", offsets.generic.generic_class), + ("container_class", offsets.generic.container_class), + ("type data", offsets.type_words.data), + ("type kind", offsets.type_words.kind), + ("field type", offsets.field.type_), + ] + } + + // A version table stands in for the builds nobody measured. Two builds + // agreeing on a member is what justifies that, so the table has to carry + // what they agree on. One build on its own proves nothing. + #[test] + fn version_tables_carry_what_two_builds_agree_on() { + for build in BUILDS { + let agreeing = BUILDS.iter().filter(|other| { + core::mem::discriminant(&other.version) == core::mem::discriminant(&build.version) + && other.pointer_size == build.pointer_size + }); + let mut agreed = grown(build.offsets).map(|(name, measured)| (name, Some(measured))); + let mut count = 0; + for other in agreeing { + count += 1; + for (slot, (_, measured)) in grown(other.offsets).into_iter().enumerate() { + if agreed[slot].1 != Some(measured) { + agreed[slot].1 = None; + } + } + } + if count < 2 { + continue; + } + + let Some(table) = + MonoOffsets::new(build.version, build.pointer_size, BinaryFormat::ELF) + else { + continue; + }; + for (slot, (name, carried)) in grown(table).into_iter().enumerate() { + let (_, agreed) = agreed[slot]; + if let Some(agreed) = agreed { + assert_eq!(carried, agreed, "the table says nothing about {name}"); + } + } + } + } + // A version table's value for any member must match every measured build // it stands in for, or say nothing. #[test] diff --git a/src/game_engine/unity/mono/mod.rs b/src/game_engine/unity/mono/mod.rs index 3ecff940..21b0c652 100644 --- a/src/game_engine/unity/mono/mod.rs +++ b/src/game_engine/unity/mono/mod.rs @@ -106,8 +106,9 @@ impl fmt::Debug for Identity { impl Module { /// Tries attaching to a Unity game that is using the standard Mono backend. - /// If the mono runtime is a known build, this function uses the offsets - /// from its PDB. Otherwise this function detects the [Mono version](Version). + /// If the Mono runtime is a known build, this function uses the offsets + /// measured for that exact build. Otherwise this function detects the + /// [Mono version](Version). /// If you know the version in advance or it fails detecting it, use /// [`attach`](Self::attach) instead. pub fn attach_auto_detect(process: &Process) -> Option { diff --git a/src/game_engine/unity/mono/offsets.rs b/src/game_engine/unity/mono/offsets.rs index 1ab29a2a..2eaff37a 100644 --- a/src/game_engine/unity/mono/offsets.rs +++ b/src/game_engine/unity/mono/offsets.rs @@ -355,94 +355,174 @@ impl MonoOffsets { }, v_table: MonoVTableOffsets { vtable: 0x28 }, }), - (BinaryFormat::ELF | BinaryFormat::MachO, Version::V3, PointerSize::Bit64) => { - Some(&Self { - assembly: AssemblyOffsets { - aname: Some(0x10), - image: 0x60, - }, - image: ImageOffsets { - assembly_name: None, - class_cache: 0x4D0, - }, - hash_table: HashTableOffsets { - size: 0x18, - table: 0x20, - }, - class: ClassOffsets { - class_kind: None, - instance_size: None, - parent: 0x28, - nested_in: None, - name: 0x40, - namespace: 0x48, - vtable_size: 0x54, - fields: 0x90, - runtime_info: 0xC8, - field_count: 0xF8, - next_class_cache: 0x100, - }, - generic: GenericOffsets { - generic_class: None, - container_class: None, - }, - type_words: TypeOffsets { - data: None, - kind: None, - }, - field: FieldInfoOffsets { - type_: None, - name: 0x8, - offset: 0x18, - alignment: 0x20, - }, - v_table: MonoVTableOffsets { vtable: 0x48 }, - }) - } - (BinaryFormat::ELF | BinaryFormat::MachO, Version::V2, PointerSize::Bit64) => { - Some(&Self { - assembly: AssemblyOffsets { - aname: Some(0x10), - image: 0x60, - }, - image: ImageOffsets { - assembly_name: None, - class_cache: 0x4C0, - }, - hash_table: HashTableOffsets { - size: 0x18, - table: 0x20, - }, - class: ClassOffsets { - class_kind: None, - instance_size: None, - parent: 0x28, - nested_in: None, - name: 0x40, - namespace: 0x48, - vtable_size: 0x54, - fields: 0x90, - runtime_info: 0xC8, - field_count: 0xF8, - next_class_cache: 0x100, - }, - generic: GenericOffsets { - generic_class: None, - container_class: None, - }, - type_words: TypeOffsets { - data: None, - kind: None, - }, - field: FieldInfoOffsets { - type_: None, - name: 0x8, - offset: 0x18, - alignment: 0x20, - }, - v_table: MonoVTableOffsets { vtable: 0x40 }, - }) - } + (BinaryFormat::ELF, Version::V3, PointerSize::Bit64) => Some(&Self { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x60, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4D0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: Some(0x1B), // 2021.3 through 6000.7 + instance_size: Some(0x1C), // 2021.3 through 6000.7 + parent: 0x28, + nested_in: Some(0x30), // 2021.3 through 6000.7 + name: 0x40, + namespace: 0x48, + vtable_size: 0x54, + fields: 0x90, + runtime_info: 0xC8, + field_count: 0xF8, + next_class_cache: 0x100, + }, + generic: GenericOffsets { + generic_class: Some(0xE8), // 2021.3 through 6000.7 + container_class: Some(0x0), // 2021.3 through 6000.7 + }, + type_words: TypeOffsets { + data: Some(0x0), // 2021.3 through 6000.7 + kind: Some(0xA), // 2021.3 through 6000.7 + }, + field: FieldInfoOffsets { + type_: Some(0x0), // 2021.3 through 6000.7 + name: 0x8, + offset: 0x18, + alignment: 0x20, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, + }), + (BinaryFormat::MachO, Version::V3, PointerSize::Bit64) => Some(&Self { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x60, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4D0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: None, + instance_size: None, + parent: 0x28, + nested_in: None, + name: 0x40, + namespace: 0x48, + vtable_size: 0x54, + fields: 0x90, + runtime_info: 0xC8, + field_count: 0xF8, + next_class_cache: 0x100, + }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: None, + kind: None, + }, + field: FieldInfoOffsets { + type_: None, + name: 0x8, + offset: 0x18, + alignment: 0x20, + }, + v_table: MonoVTableOffsets { vtable: 0x48 }, + }), + (BinaryFormat::ELF, Version::V2, PointerSize::Bit64) => Some(&Self { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x60, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4C0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: Some(0x24), // 2018.4, 2019.4 + instance_size: Some(0x1C), // 2018.4, 2019.4 + parent: 0x28, + nested_in: Some(0x30), // 2018.4, 2019.4 + name: 0x40, + namespace: 0x48, + vtable_size: 0x54, + fields: 0x90, + runtime_info: 0xC8, + field_count: 0xF8, + next_class_cache: 0x100, + }, + generic: GenericOffsets { + generic_class: Some(0xE8), // 2018.4, 2019.4 + container_class: Some(0x0), // 2018.4, 2019.4 + }, + type_words: TypeOffsets { + data: Some(0x0), // 2018.4, 2019.4 + kind: Some(0xA), // 2018.4, 2019.4 + }, + field: FieldInfoOffsets { + type_: Some(0x0), // 2018.4, 2019.4 + name: 0x8, + offset: 0x18, + alignment: 0x20, + }, + v_table: MonoVTableOffsets { vtable: 0x40 }, + }), + (BinaryFormat::MachO, Version::V2, PointerSize::Bit64) => Some(&Self { + assembly: AssemblyOffsets { + aname: Some(0x10), + image: 0x60, + }, + image: ImageOffsets { + assembly_name: None, + class_cache: 0x4C0, + }, + hash_table: HashTableOffsets { + size: 0x18, + table: 0x20, + }, + class: ClassOffsets { + class_kind: None, + instance_size: None, + parent: 0x28, + nested_in: None, + name: 0x40, + namespace: 0x48, + vtable_size: 0x54, + fields: 0x90, + runtime_info: 0xC8, + field_count: 0xF8, + next_class_cache: 0x100, + }, + generic: GenericOffsets { + generic_class: None, + container_class: None, + }, + type_words: TypeOffsets { + data: None, + kind: None, + }, + field: FieldInfoOffsets { + type_: None, + name: 0x8, + offset: 0x18, + alignment: 0x20, + }, + v_table: MonoVTableOffsets { vtable: 0x40 }, + }), (BinaryFormat::ELF | BinaryFormat::MachO, Version::V1Cattrs, PointerSize::Bit64) => { Some(&Self { assembly: AssemblyOffsets {