From 958ff245465dd1f09e1c921178fbf82ab74131c6 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 8 Aug 2026 17:49:00 -0700 Subject: [PATCH] feat(hook): add SBC request tracing instrumentation - sbc_hook.rs: trace SBC submission/response flow with request IDs - sbc_request_trace.rs: capture request/response bodies for analysis - sbc_trace.rs: runtime trace buffer with structured logging Work in progress - needs validation against live FIFA 17 client --- openfut-hook/src/sbc_hook.rs | 236 +++++++++++++++++++++++++- openfut-hook/src/sbc_request_trace.rs | 60 ++++++- openfut-hook/src/sbc_trace.rs | 149 +++++++++++++++- 3 files changed, 441 insertions(+), 4 deletions(-) diff --git a/openfut-hook/src/sbc_hook.rs b/openfut-hook/src/sbc_hook.rs index 410b7d5..d9e3111 100644 --- a/openfut-hook/src/sbc_hook.rs +++ b/openfut-hook/src/sbc_hook.rs @@ -8,6 +8,7 @@ //! with this module compiled in changes nothing unless a var is set: //! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY) //! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY) +//! OPENFUT_SBC_COMMIT=1 -> after proven native parse success, arm populated M //! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns) //! //! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we @@ -19,11 +20,14 @@ //! See the spec for the verified disassembly behind each one. use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; use windows_sys::Win32::System::Memory::{ - VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE, - PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY, + VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, + PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, + PAGE_WRITECOPY, }; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId}; // ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ──────────── const IMAGE_BASE: usize = 0x180000000; @@ -42,6 +46,13 @@ const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate) const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5) const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap) const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count +const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820; +const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888; +const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138; +const SBC_CONTROLLER_MODEL_OFF: usize = 0x140; +const SBC_COMPLETION_STATUS_JNE_RVA: usize = 0x0b8962; +const SBC_COMPLETION_STATUS_JNE: [u8; 2] = [0x75, 0x48]; +const SBC_COMPLETION_STATUS_FALLTHROUGH: [u8; 2] = [0x90, 0x90]; const B_DTOR_RVA: usize = 0x63040; const B_ISVALID_RVA: usize = 0x65d40; const B_CLEAR_RVA: usize = 0x65d20; @@ -76,9 +87,11 @@ mod rva { static ARMED: AtomicBool = AtomicBool::new(false); static ARM_ONLY: AtomicBool = AtomicBool::new(false); +static COMMIT: AtomicBool = AtomicBool::new(false); static POPULATE: AtomicBool = AtomicBool::new(false); static DONE: AtomicBool = AtomicBool::new(false); static CARDS_BASE: AtomicUsize = AtomicUsize::new(0); +static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0); static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize); #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -134,6 +147,13 @@ enum ValidationError { CollectionUnreadable, CollectionNotNull, ReadyByteNotWritable, + ModelEmpty, + ControllerMissing, + ControllerVtableMismatch, + ControllerModelMismatch, + CompletionBranchMismatch, + CompletionBranchProtectFailed, + CompletionBranchFlushFailed, } #[derive(Clone, Copy, Debug)] @@ -263,6 +283,26 @@ unsafe fn writable_u8(ptr: usize) -> bool { .is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)) } +unsafe fn executable_range(ptr: usize, len: usize) -> bool { + let Some(end) = ptr.checked_add(len) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); + if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { + return false; + } + let protection = mbi.Protect & 0xff; + matches!( + protection, + PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY + ) && end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize) +} + /// Guarded 16-bit read (M category count is a WORD). unsafe fn read_u16(ptr: usize) -> Option { let lo = read_u8(ptr)? as u16; @@ -388,6 +428,12 @@ pub fn install() { .unwrap_or(false), Ordering::Relaxed, ); + COMMIT.store( + std::env::var("OPENFUT_SBC_COMMIT") + .map(|v| v == "1") + .unwrap_or(false), + Ordering::Relaxed, + ); POPULATE.store( std::env::var("OPENFUT_SBC_POPULATE") .map(|v| v == "1") @@ -398,6 +444,192 @@ pub fn install() { std::thread::spawn(|| unsafe { worker() }); } +/// Records the concrete SBC controller observed registering FUT_SBS_CATEGORIES. +/// The registration hook is observational; all structural checks happen again on +/// the notifier thread before this address is trusted. +pub(crate) unsafe fn note_sbc_controller(controller: usize) { + let base = CARDS_BASE.load(Ordering::Acquire); + let valid = base != 0 + && read_ptr(controller) == base.checked_add(SBC_CONTROLLER_VTABLE_RVA) + && controller + .checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF) + .and_then(|p| read_ptr(p)) + == base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA); + if valid { + SBC_CONTROLLER.store(controller, Ordering::Release); + crate::write_log(&format!( + "SBC_CONTROLLER_TRACE: captured controller={controller:#x}\n" + )); + } else { + crate::write_log(&format!( + "SBC_CONTROLLER_TRACE: rejected controller={controller:#x} (vtable mismatch)\n" + )); + } +} + +unsafe fn log_controller_model(native_model: usize) { + let controller = SBC_CONTROLLER.load(Ordering::Acquire); + let controller_model = controller + .checked_add(SBC_CONTROLLER_MODEL_OFF) + .and_then(|p| read_ptr(p)) + .unwrap_or(0); + let main_vtable = read_ptr(controller).unwrap_or(0); + let event_vtable = controller + .checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF) + .and_then(|p| read_ptr(p)) + .unwrap_or(0); + crate::write_log(&format!( + "SBC_CONTROLLER_TRACE: notifier controller={controller:#x} main_vt={main_vtable:#x} event_vt={event_vtable:#x} controller_M={controller_model:#x} parsed_M={native_model:#x} match={}\n", + controller != 0 && controller_model == native_model, + )); +} + +unsafe fn validated_sbc_controller( + base: usize, + native_model: usize, +) -> Result { + let controller = SBC_CONTROLLER.load(Ordering::Acquire); + if controller == 0 { + return Err(ValidationError::ControllerMissing); + } + if read_ptr(controller) != base.checked_add(SBC_CONTROLLER_VTABLE_RVA) + || controller + .checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF) + .and_then(|p| read_ptr(p)) + != base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA) + { + return Err(ValidationError::ControllerVtableMismatch); + } + if controller + .checked_add(SBC_CONTROLLER_MODEL_OFF) + .and_then(|p| read_ptr(p)) + != Some(native_model) + { + return Err(ValidationError::ControllerModelMismatch); + } + Ok(controller) +} + +/// Route the already-scheduled category completion through CardsDLL's own success +/// branch. The original function first rejects a non-zero status with a two-byte +/// `jne ServerErrSets`; after a separately proven native parse, that status belongs +/// to the stale scheduler completion rather than the category HTTP transaction. +unsafe fn arm_native_completion_success(base: usize) -> Result<(), ValidationError> { + let target = base + .checked_add(SBC_COMPLETION_STATUS_JNE_RVA) + .ok_or(ValidationError::AddressOverflow)?; + if !executable_range(target, SBC_COMPLETION_STATUS_JNE.len()) + || core::slice::from_raw_parts(target as *const u8, SBC_COMPLETION_STATUS_JNE.len()) + != SBC_COMPLETION_STATUS_JNE + { + return Err(ValidationError::CompletionBranchMismatch); + } + let mut old = 0u32; + if VirtualProtect( + target as _, + SBC_COMPLETION_STATUS_FALLTHROUGH.len(), + PAGE_EXECUTE_READWRITE, + &mut old, + ) == 0 + { + return Err(ValidationError::CompletionBranchProtectFailed); + } + core::ptr::copy_nonoverlapping( + SBC_COMPLETION_STATUS_FALLTHROUGH.as_ptr(), + target as *mut u8, + SBC_COMPLETION_STATUS_FALLTHROUGH.len(), + ); + let flushed = FlushInstructionCache( + GetCurrentProcess(), + target as _, + SBC_COMPLETION_STATUS_FALLTHROUGH.len(), + ) != 0; + let mut ignored = 0u32; + let protected = VirtualProtect( + target as _, + SBC_COMPLETION_STATUS_FALLTHROUGH.len(), + old, + &mut ignored, + ) != 0; + if !flushed || !protected { + return Err(ValidationError::CompletionBranchFlushFailed); + } + crate::write_log(&format!( + "SBC_HOOK: armed native completion success branch at {target:#x} tid={}\n", + GetCurrentThreadId(), + )); + Ok(()) +} + +/// Commit the already-populated native SBC model after the category success notifier. +/// +/// This is called synchronously by the passive notifier wrapper *after* the original +/// notifier returns. It never invokes a parser or constructs game objects. The only +/// mutation is the established cache-ready byte, and only when the normal parser has +/// produced at least one category and every pointer/vtable invariant still matches. +pub(crate) unsafe fn commit_after_native_parse() { + if !COMMIT.load(Ordering::Acquire) { + return; + } + let base = CARDS_BASE.load(Ordering::Acquire); + if base == 0 || !control_matches(base) { + set_failed(ValidationError::AUnreadable); + return; + } + let snapshot = match runtime_snapshot(base).and_then(|snapshot| { + validate_snapshot(base, &snapshot)?; + if snapshot.m == 0 + || read_u16(snapshot.m + M_COUNT_OFF) + .filter(|&count| count > 0) + .is_none() + { + return Err(ValidationError::ModelEmpty); + } + if !writable_u8(snapshot.b + B_READY_OFF) { + return Err(ValidationError::ReadyByteNotWritable); + } + Ok(snapshot) + }) { + Ok(snapshot) => snapshot, + Err(error) => { + set_failed(error); + return; + } + }; + let count = read_u16(snapshot.m + M_COUNT_OFF).unwrap_or(0); + log_controller_model(snapshot.m); + if DONE.swap(true, Ordering::AcqRel) { + return; + } + crate::write_log(&format!( + "SBC_HOOK: post-parse commit -> M={:#x} categories={} BYTE[{:#x}]=1\n", + snapshot.m, + count, + snapshot.b + B_READY_OFF, + )); + core::ptr::write_volatile((snapshot.b + B_READY_OFF) as *mut u8, 1); + if read_u8(snapshot.b + B_READY_OFF) != Some(1) + || !transition(RuntimeState::Validated, RuntimeState::Committed) + { + set_failed(ValidationError::ReadyByteUnexpected); + return; + } + let _controller = match validated_sbc_controller(base, snapshot.m) { + Ok(controller) => controller, + Err(error) => { + set_failed(error); + return; + } + }; + if let Err(error) = arm_native_completion_success(base) { + set_failed(error); + return; + } + crate::write_log( + "SBC_HOOK: post-parse commit DONE; awaiting CardsDLL native completion events\n", + ); +} + /// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when /// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm) /// exactly once. diff --git a/openfut-hook/src/sbc_request_trace.rs b/openfut-hook/src/sbc_request_trace.rs index fe8691f..df6a951 100644 --- a/openfut-hook/src/sbc_request_trace.rs +++ b/openfut-hook/src/sbc_request_trace.rs @@ -55,6 +55,12 @@ 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")) @@ -130,6 +136,22 @@ unsafe fn guarded_ptr(address: usize) -> usize { } } +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) @@ -149,14 +171,44 @@ unsafe extern "system" fn wrapper_88(request: *mut c_void, argument: *mut c_void 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); } @@ -334,7 +386,7 @@ unsafe fn worker() { 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}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n", + "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), @@ -346,6 +398,12 @@ unsafe fn worker() { 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), diff --git a/openfut-hook/src/sbc_trace.rs b/openfut-hook/src/sbc_trace.rs index cd389d2..35588d8 100644 --- a/openfut-hook/src/sbc_trace.rs +++ b/openfut-hook/src/sbc_trace.rs @@ -32,6 +32,12 @@ const ABS_JUMP_LEN: usize = 14; const TRAMPOLINE_LEN: usize = COPY_LEN + ABS_JUMP_LEN; const NOTIFIER_RVA: usize = 0x17aa80; const NOTIFIER_COPY_LEN: usize = 15; +const CONTROLLER_REGISTER_RVA: usize = 0x1a4a70; +const CONTROLLER_REGISTER_COPY_LEN: usize = 15; +const FUT_SBS_CATEGORIES_EVENT: u32 = 0x756c; +const CONTROLLER_REGISTER_SIGNATURE: [u8; CONTROLLER_REGISTER_COPY_LEN] = [ + 0x89, 0x54, 0x24, 0x10, 0x48, 0x83, 0xec, 0x28, 0x4c, 0x8d, 0x81, 0xc0, 0x00, 0x00, 0x00, +]; const NOTIFIER_SIGNATURE: [u8; 32] = [ 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0x59, 0x58, 0x48, 0x8b, 0x71, 0x60, 0x33, 0xff, 0x48, 0x2b, 0xf3, 0xc6, 0x81, 0x88, 0x00, @@ -75,6 +81,7 @@ static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0); static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0); static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX); static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static CONTROLLER_REGISTER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0); static NOTIFIER_EXITS: AtomicU64 = AtomicU64::new(0); static NOTIFIER_CTX: AtomicUsize = AtomicUsize::new(0); @@ -521,6 +528,77 @@ unsafe fn write_notifier_entry( } } +unsafe fn restore_controller_register_entry(target: usize) -> bool { + let mut old = 0u32; + if VirtualProtect( + target as _, + CONTROLLER_REGISTER_COPY_LEN, + PAGE_EXECUTE_READWRITE, + &mut old, + ) == 0 + { + return false; + } + core::ptr::copy_nonoverlapping( + CONTROLLER_REGISTER_SIGNATURE.as_ptr(), + target as *mut u8, + CONTROLLER_REGISTER_COPY_LEN, + ); + let flushed = FlushInstructionCache( + GetCurrentProcess(), + target as _, + CONTROLLER_REGISTER_COPY_LEN, + ) != 0; + let mut ignored = 0u32; + flushed && VirtualProtect(target as _, CONTROLLER_REGISTER_COPY_LEN, old, &mut ignored) != 0 +} + +unsafe fn write_controller_register_entry( + target: usize, + destination: usize, + published: &mut bool, +) -> Result<(), bool> { + let mut patch = [0x90u8; CONTROLLER_REGISTER_COPY_LEN]; + patch[..ABS_JUMP_LEN].copy_from_slice(&absolute_jump(destination)); + let mut old = 0u32; + if VirtualProtect( + target as _, + CONTROLLER_REGISTER_COPY_LEN, + PAGE_EXECUTE_READWRITE, + &mut old, + ) == 0 + { + return Err(true); + } + *published = true; + core::ptr::copy_nonoverlapping( + patch.as_ptr(), + target as *mut u8, + CONTROLLER_REGISTER_COPY_LEN, + ); + let flushed = FlushInstructionCache( + GetCurrentProcess(), + target as _, + CONTROLLER_REGISTER_COPY_LEN, + ) != 0; + let mut ignored = 0u32; + if flushed && VirtualProtect(target as _, CONTROLLER_REGISTER_COPY_LEN, old, &mut ignored) != 0 + { + Ok(()) + } else { + Err(restore_controller_register_entry(target)) + } +} + +unsafe extern "system" fn controller_register_wrapper(controller: *mut c_void, event: u32) { + let original: unsafe extern "system" fn(*mut c_void, u32) = + core::mem::transmute(CONTROLLER_REGISTER_TRAMPOLINE.load(Ordering::Acquire)); + original(controller, event); + if event == FUT_SBS_CATEGORIES_EVENT { + crate::sbc_hook::note_sbc_controller(controller as usize); + } +} + unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) { NOTIFIER_ENTRIES.fetch_add(1, Ordering::Relaxed); let address = ctx as usize; @@ -552,6 +630,7 @@ unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) { let original: unsafe extern "system" fn(*mut c_void) = core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire)); original(ctx); + crate::sbc_hook::commit_after_native_parse(); NOTIFIER_BYTE_AFTER.store( address .checked_add(0x88) @@ -946,10 +1025,78 @@ unsafe fn notifier_worker() { crate::write_log("SBC_NOTIFIER_TRACE: report cap reached; hook remains passive\n"); } +unsafe fn controller_register_worker() { + let _pending = CodeInstallerPending; + 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)); + } + let Some(target) = target_va(base, CONTROLLER_REGISTER_RVA) else { + crate::write_log("SBC_CONTROLLER_TRACE: target resolution failed; inactive\n"); + return; + }; + if base == 0 + || !valid_cards_image(base) + || !executable_range_in_image(base, target, CONTROLLER_REGISTER_SIGNATURE.len()) + || core::slice::from_raw_parts(target as *const u8, CONTROLLER_REGISTER_SIGNATURE.len()) + != CONTROLLER_REGISTER_SIGNATURE + { + crate::write_log("SBC_CONTROLLER_TRACE: PE/signature validation failed; inactive\n"); + return; + } + let Some(trampoline) = allocate_trampoline(target, CONTROLLER_REGISTER_COPY_LEN) else { + crate::write_log("SBC_CONTROLLER_TRACE: trampoline allocation failed; inactive\n"); + return; + }; + CONTROLLER_REGISTER_TRAMPOLINE.store(trampoline, Ordering::Release); + let Some(_installer_gate) = acquire_patch_installer_gate() else { + VirtualFree(trampoline as _, 0, MEM_RELEASE); + CONTROLLER_REGISTER_TRAMPOLINE.store(0, Ordering::Release); + crate::write_log("SBC_CONTROLLER_TRACE: installer gate timeout; inactive\n"); + return; + }; + let mut peers = match suspend_peers(target, target) { + Ok(peers) => peers, + Err(_) => { + crate::write_log( + "SBC_CONTROLLER_TRACE: quiescence failed; terminate game if unresponsive\n", + ); + return; + } + }; + let mut published = false; + let installed = write_controller_register_entry( + target, + controller_register_wrapper as *const () as usize, + &mut published, + ) + .is_ok(); + let resumed = peers.resume_all(); + drop(_installer_gate); + drop(_pending); + if !installed || !resumed { + if !published && resumed { + VirtualFree(trampoline as _, 0, MEM_RELEASE); + CONTROLLER_REGISTER_TRAMPOLINE.store(0, Ordering::Release); + crate::write_log("SBC_CONTROLLER_TRACE: clean install failure; inactive\n"); + } else { + crate::write_log("SBC_CONTROLLER_TRACE: DEGRADED state; terminate game now\n"); + } + return; + } + crate::write_log("SBC_CONTROLLER_TRACE: category controller registration hook installed\n"); +} + fn install_notifier(enabled: bool) { if enabled { crate::write_log("SBC_NOTIFIER_TRACE: requested; deferred install starting\n"); std::thread::spawn(|| unsafe { notifier_worker() }); + crate::write_log("SBC_CONTROLLER_TRACE: requested; deferred install starting\n"); + std::thread::spawn(|| unsafe { controller_register_worker() }); } else { crate::write_log("SBC_NOTIFIER_TRACE: disabled\n"); } @@ -959,7 +1106,7 @@ pub(crate) fn install() { let enabled = env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref()); let notifier_enabled = env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref()); CODE_PATCH_PENDING.store( - enabled as usize + notifier_enabled as usize, + enabled as usize + (notifier_enabled as usize * 2), Ordering::Release, ); install_notifier(notifier_enabled);