use crate::{
emulation::{
memory::HeapObject,
runtime::hook::{Hook, HookContext, HookManager, PreHookResult},
thread::EmulationThread,
EmValue,
},
metadata::{
tables::FieldRvaRaw,
typesystem::{CilFlavor, PointerSize},
},
Result,
};
pub fn register(manager: &HookManager) -> Result<()> {
manager.register(
Hook::new("System.Runtime.CompilerServices.RuntimeHelpers.InitializeArray")
.match_name(
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"InitializeArray",
)
.pre(runtime_helpers_initialize_array_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode")
.match_name(
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"GetHashCode",
)
.pre(runtime_helpers_get_hash_code_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.RuntimeHelpers.Equals")
.match_name(
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"Equals",
)
.pre(runtime_helpers_equals_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.RuntimeHelpers.GetObjectValue")
.match_name(
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"GetObjectValue",
)
.pre(runtime_helpers_get_object_value_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor")
.match_name(
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"RunClassConstructor",
)
.pre(runtime_helpers_run_class_constructor_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.RuntimeHelpers.RunModuleConstructor")
.match_name(
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"RunModuleConstructor",
)
.pre(runtime_helpers_run_module_constructor_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.RuntimeHelpers.PrepareDelegate")
.match_name(
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"PrepareDelegate",
)
.pre(runtime_helpers_prepare_noop_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.RuntimeHelpers.PrepareMethod")
.match_name(
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"PrepareMethod",
)
.pre(runtime_helpers_prepare_noop_pre),
)?;
manager.register(
Hook::new("System.Object.Equals")
.match_name("System", "Object", "Equals")
.pre(object_equals_pre),
)?;
manager.register(
Hook::new("System.Object..ctor")
.match_name("System", "Object", ".ctor")
.pre(object_ctor_pre),
)?;
manager.register(
Hook::new("System.Object.ToString")
.match_name("System", "Object", "ToString")
.pre(object_to_string_pre),
)?;
manager.register(
Hook::new("System.Object.GetHashCode")
.match_name("System", "Object", "GetHashCode")
.pre(object_get_hash_code_pre),
)?;
manager.register(
Hook::new("System.Object.MemberwiseClone")
.match_name("System", "Object", "MemberwiseClone")
.pre(object_memberwise_clone_pre),
)?;
manager.register(
Hook::new("System.ValueType.Equals")
.match_name("System", "ValueType", "Equals")
.pre(valuetype_equals_pre),
)?;
manager.register(
Hook::new("System.GC.Collect")
.match_name("System", "GC", "Collect")
.pre(gc_noop_pre),
)?;
manager.register(
Hook::new("System.GC.SuppressFinalize")
.match_name("System", "GC", "SuppressFinalize")
.pre(gc_noop_pre),
)?;
manager.register(
Hook::new("System.GC.KeepAlive")
.match_name("System", "GC", "KeepAlive")
.pre(gc_noop_pre),
)?;
manager.register(
Hook::new("System.GC.WaitForPendingFinalizers")
.match_name("System", "GC", "WaitForPendingFinalizers")
.pre(gc_noop_pre),
)?;
manager.register(
Hook::new("System.GC.get_MaxGeneration")
.match_name("System", "GC", "get_MaxGeneration")
.pre(gc_get_max_generation_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.Unsafe.As")
.match_name("System.Runtime.CompilerServices", "Unsafe", "As")
.pre(unsafe_as_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.Unsafe.SizeOf")
.match_name("System.Runtime.CompilerServices", "Unsafe", "SizeOf")
.pre(unsafe_sizeof_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.Unsafe.Add")
.match_name("System.Runtime.CompilerServices", "Unsafe", "Add")
.pre(unsafe_passthrough_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.Unsafe.AddByteOffset")
.match_name("System.Runtime.CompilerServices", "Unsafe", "AddByteOffset")
.pre(unsafe_passthrough_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.Unsafe.ReadUnaligned")
.match_name("System.Runtime.CompilerServices", "Unsafe", "ReadUnaligned")
.pre(unsafe_read_unaligned_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.Unsafe.WriteUnaligned")
.match_name(
"System.Runtime.CompilerServices",
"Unsafe",
"WriteUnaligned",
)
.pre(unsafe_write_noop_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.Unsafe.AsRef")
.match_name("System.Runtime.CompilerServices", "Unsafe", "AsRef")
.pre(unsafe_as_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.Unsafe.AsPointer")
.match_name("System.Runtime.CompilerServices", "Unsafe", "AsPointer")
.pre(unsafe_passthrough_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.Unsafe.ByteOffset")
.match_name("System.Runtime.CompilerServices", "Unsafe", "ByteOffset")
.pre(unsafe_byte_offset_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.Unsafe.AreSame")
.match_name("System.Runtime.CompilerServices", "Unsafe", "AreSame")
.pre(unsafe_are_same_pre),
)?;
manager.register(
Hook::new("System.Runtime.CompilerServices.Unsafe.IsNullRef")
.match_name("System.Runtime.CompilerServices", "Unsafe", "IsNullRef")
.pre(unsafe_is_null_ref_pre),
)?;
Ok(())
}
fn runtime_helpers_initialize_array_pre(
ctx: &HookContext<'_>,
thread: &mut EmulationThread,
) -> PreHookResult {
if ctx.args.len() < 2 {
return PreHookResult::Bypass(None);
}
let array_ref = match &ctx.args[0] {
EmValue::ObjectRef(r) => *r,
_ => return PreHookResult::Bypass(None),
};
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let field_token = match &ctx.args[1] {
EmValue::I32(v) => (*v).cast_unsigned(),
EmValue::NativeInt(v) | EmValue::I64(v) => *v as u32,
_ => return PreHookResult::Bypass(None),
};
let Some(assembly) = thread.assembly() else {
return PreHookResult::Bypass(None);
};
let Some(tables) = assembly.tables() else {
return PreHookResult::Bypass(None);
};
let Some(fieldrva_table) = tables.table::<FieldRvaRaw>() else {
return PreHookResult::Bypass(None);
};
let mut rva: Option<u32> = None;
for row in fieldrva_table {
let row_token = row.field | 0x0400_0000;
if row_token == field_token && row.rva > 0 {
rva = Some(row.rva);
break;
}
}
let Some(rva) = rva else {
return PreHookResult::Bypass(None);
};
let file = assembly.file();
let Ok(file_offset) = file.rva_to_offset(rva as usize) else {
return PreHookResult::Bypass(None);
};
let pe_data = file.data();
if file_offset >= pe_data.len() {
return PreHookResult::Bypass(None);
}
let array_len = thread.heap().get_array_length(array_ref).unwrap_or(0);
if array_len == 0 {
return PreHookResult::Bypass(None);
}
let element_type = thread
.heap()
.get_array_element_type(array_ref)
.unwrap_or(CilFlavor::U1);
let Some(element_size) = element_type.element_size(ctx.pointer_size) else {
return PreHookResult::Bypass(None);
};
let total_bytes = array_len * element_size;
let bytes_to_read = total_bytes.min(pe_data.len() - file_offset);
let data = &pe_data[file_offset..file_offset + bytes_to_read];
for i in 0..array_len {
let byte_offset = i * element_size;
if byte_offset + element_size > data.len() {
break;
}
let value = match element_type {
CilFlavor::Boolean | CilFlavor::I1 | CilFlavor::U1 => {
EmValue::I32(i32::from(data[byte_offset]))
}
CilFlavor::Char | CilFlavor::I2 | CilFlavor::U2 => {
let bytes = [data[byte_offset], data[byte_offset + 1]];
EmValue::I32(i32::from(i16::from_le_bytes(bytes)))
}
CilFlavor::I4 | CilFlavor::U4 => {
let bytes = [
data[byte_offset],
data[byte_offset + 1],
data[byte_offset + 2],
data[byte_offset + 3],
];
EmValue::I32(i32::from_le_bytes(bytes))
}
CilFlavor::R4 => {
let bytes = [
data[byte_offset],
data[byte_offset + 1],
data[byte_offset + 2],
data[byte_offset + 3],
];
EmValue::F32(f32::from_le_bytes(bytes))
}
CilFlavor::I8 | CilFlavor::U8 => {
let bytes = [
data[byte_offset],
data[byte_offset + 1],
data[byte_offset + 2],
data[byte_offset + 3],
data[byte_offset + 4],
data[byte_offset + 5],
data[byte_offset + 6],
data[byte_offset + 7],
];
EmValue::I64(i64::from_le_bytes(bytes))
}
CilFlavor::R8 => {
let bytes = [
data[byte_offset],
data[byte_offset + 1],
data[byte_offset + 2],
data[byte_offset + 3],
data[byte_offset + 4],
data[byte_offset + 5],
data[byte_offset + 6],
data[byte_offset + 7],
];
EmValue::F64(f64::from_le_bytes(bytes))
}
_ => EmValue::I32(i32::from(data[byte_offset])),
};
try_hook!(thread.heap_mut().set_array_element(array_ref, i, value));
}
PreHookResult::Bypass(None)
}
fn runtime_helpers_get_hash_code_pre(
ctx: &HookContext<'_>,
_thread: &mut EmulationThread,
) -> PreHookResult {
let result = if let Some(EmValue::ObjectRef(r)) = ctx.args.first() {
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let hash = r.id() as i32;
EmValue::I32(hash)
} else {
EmValue::I32(0)
};
PreHookResult::Bypass(Some(result))
}
fn runtime_helpers_equals_pre(
ctx: &HookContext<'_>,
_thread: &mut EmulationThread,
) -> PreHookResult {
if ctx.args.len() < 2 {
return PreHookResult::Bypass(Some(EmValue::I32(0))); }
let equal = match (&ctx.args[0], &ctx.args[1]) {
(EmValue::ObjectRef(a), EmValue::ObjectRef(b)) => a.id() == b.id(),
(EmValue::Null, EmValue::Null) => true,
_ => false,
};
PreHookResult::Bypass(Some(EmValue::I32(i32::from(equal))))
}
fn runtime_helpers_get_object_value_pre(
ctx: &HookContext<'_>,
_thread: &mut EmulationThread,
) -> PreHookResult {
let result = if let Some(arg) = ctx.args.first() {
arg.clone()
} else {
EmValue::Null
};
PreHookResult::Bypass(Some(result))
}
fn runtime_helpers_run_class_constructor_pre(
_ctx: &HookContext<'_>,
_thread: &mut EmulationThread,
) -> PreHookResult {
PreHookResult::Bypass(None)
}
fn runtime_helpers_run_module_constructor_pre(
_ctx: &HookContext<'_>,
_thread: &mut EmulationThread,
) -> PreHookResult {
PreHookResult::Bypass(None)
}
fn runtime_helpers_prepare_noop_pre(
_ctx: &HookContext<'_>,
_thread: &mut EmulationThread,
) -> PreHookResult {
PreHookResult::Bypass(None)
}
fn object_equals_pre(ctx: &HookContext<'_>, _thread: &mut EmulationThread) -> PreHookResult {
if let Some(this) = ctx.this {
let other = if ctx.args.is_empty() {
&EmValue::Null
} else {
&ctx.args[0]
};
let equal = this.clr_equals(other);
return PreHookResult::Bypass(Some(EmValue::I32(i32::from(equal))));
}
if ctx.args.len() >= 2 {
let equal = ctx.args[0].clr_equals(&ctx.args[1]);
return PreHookResult::Bypass(Some(EmValue::I32(i32::from(equal))));
}
PreHookResult::Bypass(Some(EmValue::I32(0)))
}
fn object_ctor_pre(_ctx: &HookContext<'_>, _thread: &mut EmulationThread) -> PreHookResult {
PreHookResult::Bypass(None)
}
#[allow(clippy::cast_sign_loss)]
fn object_to_string_pre(ctx: &HookContext<'_>, thread: &mut EmulationThread) -> PreHookResult {
let text = match ctx.this {
Some(EmValue::ObjectRef(href)) => {
match thread.heap().get(*href) {
Ok(HeapObject::String(_)) => {
return PreHookResult::Bypass(Some(EmValue::ObjectRef(*href)));
}
Ok(HeapObject::BoxedValue { value, .. }) => match &*value {
EmValue::I32(v) => v.to_string(),
EmValue::I64(v) => v.to_string(),
EmValue::F32(v) => v.to_string(),
EmValue::F64(v) => v.to_string(),
EmValue::Bool(b) => if *b { "True" } else { "False" }.to_string(),
EmValue::Char(c) => c.to_string(),
_ => "System.Object".to_string(),
},
Ok(HeapObject::ReflectionType { type_token, .. }) => {
if let Some(asm) = thread.assembly().cloned() {
if let Some(cil_type) = asm.types().resolve(&type_token) {
if cil_type.namespace.is_empty() {
cil_type.name.clone()
} else {
format!("{}.{}", cil_type.namespace, cil_type.name)
}
} else {
format!("Type(0x{:08X})", type_token.value())
}
} else {
"System.Object".to_string()
}
}
Ok(HeapObject::Object { type_token, .. }) => {
if let Some(asm) = thread.assembly().cloned() {
if let Some(cil_type) = asm.types().resolve(&type_token) {
if cil_type.namespace.is_empty() {
cil_type.name.clone()
} else {
format!("{}.{}", cil_type.namespace, cil_type.name)
}
} else {
"System.Object".to_string()
}
} else {
"System.Object".to_string()
}
}
_ => "System.Object".to_string(),
}
}
Some(EmValue::I32(v)) => v.to_string(),
Some(EmValue::I64(v)) => v.to_string(),
Some(EmValue::Bool(b)) => if *b { "True" } else { "False" }.to_string(),
Some(EmValue::Char(c)) => c.to_string(),
_ => "System.Object".to_string(),
};
match thread.heap_mut().alloc_string(&text) {
Ok(str_ref) => PreHookResult::Bypass(Some(EmValue::ObjectRef(str_ref))),
Err(e) => PreHookResult::Error(format!("heap allocation failed: {e}")),
}
}
fn object_get_hash_code_pre(ctx: &HookContext<'_>, _thread: &mut EmulationThread) -> PreHookResult {
#[allow(clippy::cast_possible_truncation)]
let hash = match ctx.this {
Some(EmValue::ObjectRef(href)) => href.id() as i32,
Some(EmValue::I32(v)) => *v,
Some(EmValue::I64(v)) => *v as i32,
Some(EmValue::Bool(b)) => i32::from(*b),
Some(EmValue::Char(c)) => *c as i32,
_ => 0,
};
PreHookResult::Bypass(Some(EmValue::I32(hash)))
}
fn object_memberwise_clone_pre(
ctx: &HookContext<'_>,
thread: &mut EmulationThread,
) -> PreHookResult {
let Some(EmValue::ObjectRef(src_ref)) = ctx.this else {
return PreHookResult::Bypass(Some(EmValue::Null));
};
let src_obj = match thread.heap().get(*src_ref) {
Ok(obj) => obj,
Err(_) => return PreHookResult::Bypass(Some(EmValue::Null)),
};
match src_obj {
HeapObject::Object {
type_token, fields, ..
} => {
let new_ref = match thread.heap_mut().alloc_object(type_token) {
Ok(r) => r,
Err(_) => return PreHookResult::Bypass(Some(EmValue::Null)),
};
for (field_token, value) in &fields {
try_hook!(thread
.heap_mut()
.set_field(new_ref, *field_token, value.clone()));
}
PreHookResult::Bypass(Some(EmValue::ObjectRef(new_ref)))
}
HeapObject::BoxedValue {
type_token, value, ..
} => {
match thread.heap_mut().alloc_boxed(type_token, (*value).clone()) {
Ok(new_ref) => PreHookResult::Bypass(Some(EmValue::ObjectRef(new_ref))),
Err(_) => PreHookResult::Bypass(Some(EmValue::Null)),
}
}
_ => {
PreHookResult::Bypass(Some(EmValue::ObjectRef(*src_ref)))
}
}
}
fn valuetype_equals_pre(ctx: &HookContext<'_>, thread: &mut EmulationThread) -> PreHookResult {
let Some(this_val) = ctx.this else {
return PreHookResult::Bypass(Some(EmValue::I32(0)));
};
let other = if ctx.args.is_empty() {
&EmValue::Null
} else {
&ctx.args[0]
};
if this_val.clr_equals(other) {
return PreHookResult::Bypass(Some(EmValue::I32(1)));
}
let (this_ref, other_ref) = match (this_val, other) {
(EmValue::ObjectRef(a), EmValue::ObjectRef(b)) => (*a, *b),
_ => return PreHookResult::Bypass(Some(EmValue::I32(0))),
};
let this_obj = match thread.heap().get(this_ref) {
Ok(obj) => obj,
Err(_) => return PreHookResult::Bypass(Some(EmValue::I32(0))),
};
let other_obj = match thread.heap().get(other_ref) {
Ok(obj) => obj,
Err(_) => return PreHookResult::Bypass(Some(EmValue::I32(0))),
};
let equal = match (&this_obj, &other_obj) {
(
HeapObject::BoxedValue {
type_token: t1,
value: v1,
..
},
HeapObject::BoxedValue {
type_token: t2,
value: v2,
..
},
) => {
t1 == t2 && v1.clr_equals(v2)
}
(
HeapObject::Object {
type_token: t1,
fields: f1,
..
},
HeapObject::Object {
type_token: t2,
fields: f2,
..
},
) => {
t1 == t2
&& f1.len() == f2.len()
&& f1
.iter()
.all(|(k, v)| f2.get(k).is_some_and(|v2| v.clr_equals(v2)))
}
_ => false,
};
PreHookResult::Bypass(Some(EmValue::I32(i32::from(equal))))
}
fn gc_noop_pre(_ctx: &HookContext<'_>, _thread: &mut EmulationThread) -> PreHookResult {
PreHookResult::Bypass(None)
}
fn gc_get_max_generation_pre(
_ctx: &HookContext<'_>,
_thread: &mut EmulationThread,
) -> PreHookResult {
PreHookResult::Bypass(Some(EmValue::I32(2)))
}
fn unsafe_as_pre(ctx: &HookContext<'_>, _thread: &mut EmulationThread) -> PreHookResult {
let result = ctx.args.first().cloned().unwrap_or(EmValue::Null);
PreHookResult::Bypass(Some(result))
}
fn unsafe_sizeof_pre(ctx: &HookContext<'_>, _thread: &mut EmulationThread) -> PreHookResult {
let size = match ctx.pointer_size {
PointerSize::Bit64 => 8,
PointerSize::Bit32 => 4,
};
PreHookResult::Bypass(Some(EmValue::I32(size)))
}
fn unsafe_passthrough_pre(ctx: &HookContext<'_>, _thread: &mut EmulationThread) -> PreHookResult {
let result = ctx.args.first().cloned().unwrap_or(EmValue::Null);
PreHookResult::Bypass(Some(result))
}
fn unsafe_read_unaligned_pre(
_ctx: &HookContext<'_>,
_thread: &mut EmulationThread,
) -> PreHookResult {
PreHookResult::Bypass(Some(EmValue::I32(0)))
}
fn unsafe_write_noop_pre(_ctx: &HookContext<'_>, _thread: &mut EmulationThread) -> PreHookResult {
PreHookResult::Bypass(None)
}
fn unsafe_byte_offset_pre(_ctx: &HookContext<'_>, _thread: &mut EmulationThread) -> PreHookResult {
PreHookResult::Bypass(Some(EmValue::NativeInt(0)))
}
fn unsafe_are_same_pre(ctx: &HookContext<'_>, _thread: &mut EmulationThread) -> PreHookResult {
if ctx.args.len() >= 2 {
let same = ctx.args[0].clr_equals(&ctx.args[1]);
PreHookResult::Bypass(Some(EmValue::I32(i32::from(same))))
} else {
PreHookResult::Bypass(Some(EmValue::I32(0)))
}
}
fn unsafe_is_null_ref_pre(ctx: &HookContext<'_>, _thread: &mut EmulationThread) -> PreHookResult {
let is_null = matches!(ctx.args.first(), Some(EmValue::Null) | None);
PreHookResult::Bypass(Some(EmValue::I32(i32::from(is_null))))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
emulation::{runtime::hook::HookManager, HeapRef},
metadata::{token::Token, typesystem::PointerSize},
};
#[test]
fn test_register_hooks() {
let manager = HookManager::new();
register(&manager).unwrap();
assert_eq!(manager.len(), 30);
}
#[test]
fn test_get_hash_code_hook() {
let obj_ref = HeapRef::new(42);
let args = [EmValue::ObjectRef(obj_ref)];
let ctx = HookContext::new(
Token::new(0x0A000001),
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"GetHashCode",
PointerSize::Bit64,
)
.with_args(&args);
let mut thread = crate::test::emulation::create_test_thread();
let result = runtime_helpers_get_hash_code_pre(&ctx, &mut thread);
match result {
PreHookResult::Bypass(Some(EmValue::I32(v))) => assert_eq!(v, 42),
_ => panic!("Expected Bypass with I32(42)"),
}
}
#[test]
fn test_equals_hook_same_reference() {
let obj1 = HeapRef::new(1);
let obj2 = HeapRef::new(1);
let args = [EmValue::ObjectRef(obj1), EmValue::ObjectRef(obj2)];
let ctx = HookContext::new(
Token::new(0x0A000001),
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"Equals",
PointerSize::Bit64,
)
.with_args(&args);
let mut thread = crate::test::emulation::create_test_thread();
let result = runtime_helpers_equals_pre(&ctx, &mut thread);
match result {
PreHookResult::Bypass(Some(EmValue::I32(v))) => assert_eq!(v, 1), _ => panic!("Expected Bypass with I32(1)"),
}
}
#[test]
fn test_equals_hook_different_references() {
let obj1 = HeapRef::new(1);
let obj2 = HeapRef::new(2);
let args = [EmValue::ObjectRef(obj1), EmValue::ObjectRef(obj2)];
let ctx = HookContext::new(
Token::new(0x0A000001),
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"Equals",
PointerSize::Bit64,
)
.with_args(&args);
let mut thread = crate::test::emulation::create_test_thread();
let result = runtime_helpers_equals_pre(&ctx, &mut thread);
match result {
PreHookResult::Bypass(Some(EmValue::I32(v))) => assert_eq!(v, 0), _ => panic!("Expected Bypass with I32(0)"),
}
}
#[test]
fn test_get_object_value_hook() {
let args = [EmValue::I32(42)];
let ctx = HookContext::new(
Token::new(0x0A000001),
"System.Runtime.CompilerServices",
"RuntimeHelpers",
"GetObjectValue",
PointerSize::Bit64,
)
.with_args(&args);
let mut thread = crate::test::emulation::create_test_thread();
let result = runtime_helpers_get_object_value_pre(&ctx, &mut thread);
match result {
PreHookResult::Bypass(Some(EmValue::I32(v))) => assert_eq!(v, 42),
_ => panic!("Expected Bypass with I32(42)"),
}
}
}