//! Optional category-request callback tracing through its class-unique vtable. //! //! Unlike the entry trampolines, these probes atomically replace two aligned //! pointer slots. The branchy callback dispatcher at 0x180154830 is never patched. use core::ffi::c_void; use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use windows_sys::Win32::System::LibraryLoader::{ GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_PIN, }; use windows_sys::Win32::System::Memory::{ VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, }; use windows_sys::Win32::System::Threading::GetCurrentThreadId; const REQUEST_VTABLE_RVA: usize = 0x22e5c0; const SLOT_88: usize = 0x88; const SLOT_90: usize = 0x90; const ORIGINAL_88_RVA: usize = 0x1631e0; const ORIGINAL_90_RVA: usize = 0x154830; const ORIGINAL_88_SIGNATURE: [u8; 16] = [ 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48, ]; const ORIGINAL_90_SIGNATURE: [u8; 16] = [ 0x4c, 0x8b, 0x81, 0x90, 0x00, 0x00, 0x00, 0x4d, 0x85, 0xc0, 0x74, 0x0a, 0x48, 0x81, 0xc1, 0x90, ]; type Callback88 = unsafe extern "system" fn(*mut c_void, *mut c_void); type Callback90 = unsafe extern "system" fn(*mut c_void, *mut c_void); static ENABLED: AtomicBool = AtomicBool::new(false); static INSTALLED: AtomicBool = AtomicBool::new(false); static ORIGINAL_88: AtomicUsize = AtomicUsize::new(0); static ORIGINAL_90: AtomicUsize = AtomicUsize::new(0); static ENTER_88: AtomicU64 = AtomicU64::new(0); static EXIT_88: AtomicU64 = AtomicU64::new(0); static ENTER_90: AtomicU64 = AtomicU64::new(0); static EXIT_90: AtomicU64 = AtomicU64::new(0); static LAST_REQUEST_88: AtomicUsize = AtomicUsize::new(0); static LAST_ARGUMENT_88: AtomicUsize = AtomicUsize::new(0); static LAST_THREAD_88: AtomicUsize = AtomicUsize::new(0); static LAST_REQUEST_90: AtomicUsize = AtomicUsize::new(0); static LAST_ARGUMENT_90: AtomicUsize = AtomicUsize::new(0); static LAST_THREAD_90: AtomicUsize = AtomicUsize::new(0); static CALLBACK_90: AtomicUsize = AtomicUsize::new(0); static CALLBACK_98: AtomicUsize = AtomicUsize::new(0); static CALLBACK_A0: AtomicUsize = AtomicUsize::new(0); static CALLBACK_A8: AtomicUsize = AtomicUsize::new(0); static SELECTED_90: AtomicUsize = AtomicUsize::new(0); static OWNER_88: AtomicUsize = AtomicUsize::new(0); static OWNER_VTABLE_88: AtomicUsize = AtomicUsize::new(0); static CONSUMER_88: AtomicUsize = AtomicUsize::new(0); static RESPONSE_VTABLE_88: AtomicUsize = AtomicUsize::new(0); static OWNER_SLOT_BEFORE_88: AtomicUsize = AtomicUsize::new(0); static OWNER_SLOT_AFTER_88: AtomicUsize = AtomicUsize::new(0); static OWNER_INNER_88: AtomicUsize = AtomicUsize::new(0); static OWNER_STATE_BEFORE_88: AtomicUsize = AtomicUsize::new(usize::MAX); static OWNER_STATE_AFTER_88: AtomicUsize = AtomicUsize::new(usize::MAX); static OWNER_FLAGS_88: AtomicUsize = AtomicUsize::new(usize::MAX); static OWNER_MANAGER_88: AtomicUsize = AtomicUsize::new(0); static OWNER_MANAGER_STATE_88: AtomicUsize = AtomicUsize::new(usize::MAX); fn enabled(value: Option<&str>) -> bool { matches!(value, Some("1")) } fn checked_va(base: usize, rva: usize) -> Option { base.checked_add(rva) } fn image_range_covered(size: usize, rva: usize, length: usize) -> bool { rva.checked_add(length) .map(|end| end <= size) .unwrap_or(false) } unsafe fn readable_range(address: usize, length: usize) -> bool { let Some(end) = address.checked_add(length) else { return false; }; let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); VirtualQuery( address as _, &mut mbi, core::mem::size_of::(), ) != 0 && mbi.State == MEM_COMMIT && mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0 && end <= mbi.BaseAddress as usize + mbi.RegionSize } unsafe fn range_in_image_allocation(base: usize, address: usize, length: usize) -> bool { let Some(end) = address.checked_add(length) else { return false; }; let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); VirtualQuery( address as _, &mut mbi, core::mem::size_of::(), ) != 0 && mbi.AllocationBase as usize == base && mbi.State == MEM_COMMIT && mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0 && end <= mbi.BaseAddress as usize + mbi.RegionSize } unsafe fn image_size(base: usize) -> Option { if !readable_range(base, 0x1000) || *(base as *const u16) != 0x5a4d { return None; } let pe_offset = *((base + 0x3c) as *const u32) as usize; if pe_offset > 0xf00 { return None; } let pe = base.checked_add(pe_offset)?; if *(pe as *const u32) != 0x0000_4550 { return None; } let size_field = pe.checked_add(24 + 0x38)?; Some(*(size_field as *const u32) as usize) } unsafe fn signature_matches(address: usize, expected: &[u8]) -> bool { readable_range(address, expected.len()) && core::slice::from_raw_parts(address as *const u8, expected.len()) == expected } unsafe fn guarded_ptr(address: usize) -> usize { if address & 7 == 0 && readable_range(address, 8) { core::ptr::read_volatile(address as *const usize) } else { 0 } } unsafe fn guarded_u32(address: usize) -> Option { if readable_range(address, 4) { Some(core::ptr::read_volatile(address as *const u32)) } else { None } } unsafe fn guarded_u8(address: usize) -> Option { if readable_range(address, 1) { Some(core::ptr::read_volatile(address as *const u8)) } else { None } } unsafe fn field_ptr(object: usize, offset: usize) -> usize { object .checked_add(offset) .map(|address| guarded_ptr(address)) .unwrap_or(0) } unsafe extern "system" fn wrapper_88(request: *mut c_void, argument: *mut c_void) { ENTER_88.fetch_add(1, Ordering::Relaxed); LAST_REQUEST_88.store(request as usize, Ordering::Relaxed); LAST_ARGUMENT_88.store(argument as usize, Ordering::Relaxed); LAST_THREAD_88.store(GetCurrentThreadId() as usize, Ordering::Relaxed); let request_address = request as usize; let argument_address = argument as usize; let owner = field_ptr(request_address, 8); let owner_vtable = guarded_ptr(owner); let consumer = field_ptr(owner_vtable, 0x18); let owner_slot_before = guarded_ptr(argument_address); let response_vtable = guarded_ptr(owner_slot_before); let owner_inner = field_ptr(owner, 8); let owner_state_before = owner_inner .checked_add(8) .and_then(|p| guarded_u32(p)) .map(|v| v as usize) .unwrap_or(usize::MAX); let owner_flags = owner_inner .checked_add(0x0c) .and_then(|p| guarded_u8(p)) .map(|v| v as usize) .unwrap_or(usize::MAX); let owner_manager = field_ptr(owner_inner, 0x14d0); let owner_manager_state = owner_manager .checked_add(0x1dc0) .and_then(|p| guarded_u32(p)) .map(|v| v as usize) .unwrap_or(usize::MAX); OWNER_88.store(owner, Ordering::Relaxed); OWNER_VTABLE_88.store(owner_vtable, Ordering::Relaxed); CONSUMER_88.store(consumer, Ordering::Relaxed); RESPONSE_VTABLE_88.store(response_vtable, Ordering::Relaxed); OWNER_SLOT_BEFORE_88.store(owner_slot_before, Ordering::Relaxed); OWNER_INNER_88.store(owner_inner, Ordering::Relaxed); OWNER_STATE_BEFORE_88.store(owner_state_before, Ordering::Relaxed); OWNER_FLAGS_88.store(owner_flags, Ordering::Relaxed); OWNER_MANAGER_88.store(owner_manager, Ordering::Relaxed); OWNER_MANAGER_STATE_88.store(owner_manager_state, Ordering::Relaxed); let original: Callback88 = core::mem::transmute(ORIGINAL_88.load(Ordering::Acquire)); original(request, argument); OWNER_SLOT_AFTER_88.store(guarded_ptr(argument_address), Ordering::Relaxed); OWNER_STATE_AFTER_88.store( owner_inner .checked_add(8) .and_then(|p| guarded_u32(p)) .map(|v| v as usize) .unwrap_or(usize::MAX), Ordering::Relaxed, ); EXIT_88.fetch_add(1, Ordering::Release); } unsafe extern "system" fn wrapper_90(request: *mut c_void, argument: *mut c_void) { ENTER_90.fetch_add(1, Ordering::Relaxed); LAST_REQUEST_90.store(request as usize, Ordering::Relaxed); LAST_ARGUMENT_90.store(argument as usize, Ordering::Relaxed); LAST_THREAD_90.store(GetCurrentThreadId() as usize, Ordering::Relaxed); let request_address = request as usize; let callback_90 = field_ptr(request_address, 0x90); let callback_98 = field_ptr(request_address, 0x98); let callback_a0 = field_ptr(request_address, 0xa0); let callback_a8 = field_ptr(request_address, 0xa8); CALLBACK_90.store(callback_90, Ordering::Relaxed); CALLBACK_98.store(callback_98, Ordering::Relaxed); CALLBACK_A0.store(callback_a0, Ordering::Relaxed); CALLBACK_A8.store(callback_a8, Ordering::Relaxed); SELECTED_90.store( if callback_90 != 0 { callback_90 } else { callback_a0 }, Ordering::Relaxed, ); let original: Callback90 = core::mem::transmute(ORIGINAL_90.load(Ordering::Acquire)); original(request, argument); EXIT_90.fetch_add(1, Ordering::Release); } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum SwapOutcome { Installed, CleanFailure, DegradedFirstSlotActive, DegradedProtection, } unsafe fn install_slots(base: usize) -> SwapOutcome { let Some(vtable) = checked_va(base, REQUEST_VTABLE_RVA) else { return SwapOutcome::CleanFailure; }; let Some(original_88) = checked_va(base, ORIGINAL_88_RVA) else { return SwapOutcome::CleanFailure; }; let Some(original_90) = checked_va(base, ORIGINAL_90_RVA) else { return SwapOutcome::CleanFailure; }; let Some(slot_88) = checked_va(vtable, SLOT_88) else { return SwapOutcome::CleanFailure; }; let Some(slot_90) = checked_va(vtable, SLOT_90) else { return SwapOutcome::CleanFailure; }; let Some(size) = image_size(base) else { return SwapOutcome::CleanFailure; }; if !image_range_covered(size, REQUEST_VTABLE_RVA, SLOT_90 + 8) || !image_range_covered(size, ORIGINAL_88_RVA, ORIGINAL_88_SIGNATURE.len()) || !image_range_covered(size, ORIGINAL_90_RVA, ORIGINAL_90_SIGNATURE.len()) || slot_88 & 7 != 0 || slot_90 & 7 != 0 || !crate::sbc_trace::validate_cards_build(base) || !range_in_image_allocation(base, vtable, SLOT_90 + 8) || !range_in_image_allocation(base, original_88, ORIGINAL_88_SIGNATURE.len()) || !range_in_image_allocation(base, original_90, ORIGINAL_90_SIGNATURE.len()) || !signature_matches(original_88, &ORIGINAL_88_SIGNATURE) || !signature_matches(original_90, &ORIGINAL_90_SIGNATURE) || (slot_88 as *const AtomicUsize) .as_ref() .unwrap() .load(Ordering::Acquire) != original_88 || (slot_90 as *const AtomicUsize) .as_ref() .unwrap() .load(Ordering::Acquire) != original_90 { return SwapOutcome::CleanFailure; } let mut pinned = core::ptr::null_mut(); if GetModuleHandleExA( GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, vtable as *const u8, &mut pinned, ) == 0 || pinned as usize != base { return SwapOutcome::CleanFailure; } ORIGINAL_88.store(original_88, Ordering::Release); ORIGINAL_90.store(original_90, Ordering::Release); // Both slots share the same vtable page. Keep it writable only across the two // compare/exchanges and possible rollback. let mut old = 0u32; if VirtualProtect(slot_88 as _, 16, PAGE_READWRITE, &mut old) == 0 { return SwapOutcome::CleanFailure; } let atom_88 = &*(slot_88 as *const AtomicUsize); let atom_90 = &*(slot_90 as *const AtomicUsize); let first = atom_88.compare_exchange( original_88, wrapper_88 as *const () as usize, Ordering::AcqRel, Ordering::Acquire, ); let outcome = if first.is_err() { SwapOutcome::CleanFailure } else if atom_90 .compare_exchange( original_90, wrapper_90 as *const () as usize, Ordering::AcqRel, Ordering::Acquire, ) .is_ok() { SwapOutcome::Installed } else if atom_88 .compare_exchange( wrapper_88 as *const () as usize, original_88, Ordering::AcqRel, Ordering::Acquire, ) .is_ok() { SwapOutcome::CleanFailure } else { SwapOutcome::DegradedFirstSlotActive }; let mut ignored = 0u32; if VirtualProtect(slot_88 as _, 16, old, &mut ignored) == 0 { return if outcome == SwapOutcome::DegradedFirstSlotActive { outcome } else { SwapOutcome::DegradedProtection }; } outcome } unsafe fn worker() { for _ in 0..700u32 { if crate::sbc_trace::code_patch_installers_ready() { break; } std::thread::sleep(std::time::Duration::from_millis(500)); } if !crate::sbc_trace::code_patch_installers_ready() { crate::write_log("SBC_REQUEST_TRACE: code-patch readiness timeout; inactive\n"); return; } let mut base = 0usize; for _ in 0..600u32 { base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize; if base != 0 { break; } std::thread::sleep(std::time::Duration::from_millis(500)); } match if base == 0 { SwapOutcome::CleanFailure } else { install_slots(base) } { SwapOutcome::Installed => { INSTALLED.store(true, Ordering::Release); crate::write_log("SBC_REQUEST_TRACE: category request vtable slots +0x88/+0x90 installed\n"); let mut seen_88 = 0u64; let mut seen_90 = 0u64; let mut reports = 0u8; while reports < 32 { std::thread::sleep(std::time::Duration::from_millis(250)); let count_88 = ENTER_88.load(Ordering::Acquire); let count_90 = ENTER_90.load(Ordering::Acquire); if count_88 != seen_88 || count_90 != seen_90 { crate::write_log(&format!( "SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x} inner={:#x} state={}->{} flags={:#x} manager={:#x} manager_state={}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n", count_88, EXIT_88.load(Ordering::Acquire), LAST_REQUEST_88.load(Ordering::Relaxed), LAST_ARGUMENT_88.load(Ordering::Relaxed), LAST_THREAD_88.load(Ordering::Relaxed), OWNER_88.load(Ordering::Relaxed), OWNER_VTABLE_88.load(Ordering::Relaxed), CONSUMER_88.load(Ordering::Relaxed), RESPONSE_VTABLE_88.load(Ordering::Relaxed), OWNER_SLOT_BEFORE_88.load(Ordering::Relaxed), OWNER_SLOT_AFTER_88.load(Ordering::Relaxed), OWNER_INNER_88.load(Ordering::Relaxed), OWNER_STATE_BEFORE_88.load(Ordering::Relaxed), OWNER_STATE_AFTER_88.load(Ordering::Relaxed), OWNER_FLAGS_88.load(Ordering::Relaxed), OWNER_MANAGER_88.load(Ordering::Relaxed), OWNER_MANAGER_STATE_88.load(Ordering::Relaxed), count_90, EXIT_90.load(Ordering::Acquire), LAST_REQUEST_90.load(Ordering::Relaxed), LAST_ARGUMENT_90.load(Ordering::Relaxed), LAST_THREAD_90.load(Ordering::Relaxed), CALLBACK_90.load(Ordering::Relaxed), CALLBACK_98.load(Ordering::Relaxed), CALLBACK_A0.load(Ordering::Relaxed), CALLBACK_A8.load(Ordering::Relaxed), SELECTED_90.load(Ordering::Relaxed), )); seen_88 = count_88; seen_90 = count_90; reports += 1; } } crate::write_log("SBC_REQUEST_TRACE: report cap reached; vtable probes remain passive\n"); } SwapOutcome::CleanFailure => crate::write_log("SBC_REQUEST_TRACE: clean install failure; inactive\n"), SwapOutcome::DegradedFirstSlotActive => crate::write_log( "SBC_REQUEST_TRACE: DEGRADED slot +0x88 may remain active; terminate game now\n", ), SwapOutcome::DegradedProtection => crate::write_log( "SBC_REQUEST_TRACE: DEGRADED vtable page protection restore failed; terminate game now\n", ), } } pub(crate) fn install() { let armed = enabled(std::env::var("OPENFUT_SBC_REQUEST_TRACE").ok().as_deref()); ENABLED.store(armed, Ordering::Release); if !armed { crate::write_log("SBC_REQUEST_TRACE: disabled\n"); return; } crate::write_log("SBC_REQUEST_TRACE: requested; deferred install starting\n"); std::thread::spawn(|| unsafe { worker() }); } #[cfg(test)] mod tests { use super::*; #[test] fn gate_is_exact() { assert!(!enabled(None)); assert!(!enabled(Some("true"))); assert!(enabled(Some("1"))); } #[test] fn slots_are_aligned_and_class_local() { assert_eq!((REQUEST_VTABLE_RVA + SLOT_88) & 7, 0); assert_eq!((REQUEST_VTABLE_RVA + SLOT_90) & 7, 0); assert_eq!(SLOT_90 - SLOT_88, 8); } #[test] fn image_coverage_is_checked_and_overflow_safe() { assert!(image_range_covered( 0x230000, REQUEST_VTABLE_RVA, SLOT_90 + 8 )); assert!(!image_range_covered( REQUEST_VTABLE_RVA + SLOT_90, REQUEST_VTABLE_RVA, SLOT_90 + 8 )); assert!(!image_range_covered(usize::MAX, usize::MAX, 8)); } }