From 6cdb45e482abafed245c93d5f715afa965351c5a Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 18 Aug 2026 20:20:00 +0000 Subject: [PATCH] Instrument FIFA17 SBC completion dispatch --- openfut-hook/src/fifa17.rs | 6 +- openfut-hook/src/lib.rs | 2 + openfut-hook/src/sbc_dispatch.rs | 756 +++++++++++++++++++++++++++++++ openfut-hook/src/sbc_hook.rs | 219 +-------- openfut-hook/src/sbc_trace.rs | 89 +++- 5 files changed, 830 insertions(+), 242 deletions(-) create mode 100644 openfut-hook/src/sbc_dispatch.rs diff --git a/openfut-hook/src/fifa17.rs b/openfut-hook/src/fifa17.rs index 4119823..0e19754 100644 --- a/openfut-hook/src/fifa17.rs +++ b/openfut-hook/src/fifa17.rs @@ -79,12 +79,10 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 { )); dump_modules(); write_log("fifa17: worker complete (injection healthy)\n"); - // SBC render intervention (inert unless OPENFUT_SBC_HOOK=1). Spawns its own deferred - // worker that waits for CardsDLL to load. See sbc_hook.rs / docs/sbc-hook-dll-spec.md. + // Every SBC detour is deferred and inert unless its exact environment gate is `1`. crate::sbc_hook::install(); - // Passive transaction tracing has a separate kill switch from cache resolution. - // It currently fails closed until safe relocating trampolines are proven. crate::sbc_trace::install(); + crate::sbc_dispatch::install(); crate::sbc_request_trace::install(); 0 } diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index 4bc448d..156278f 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -21,6 +21,8 @@ mod probe; #[cfg(feature = "capture_baseline")] mod recv_hook; #[cfg(feature = "fifa17")] +mod sbc_dispatch; +#[cfg(feature = "fifa17")] mod sbc_hook; #[cfg(feature = "fifa17")] mod sbc_request_trace; diff --git a/openfut-hook/src/sbc_dispatch.rs b/openfut-hook/src/sbc_dispatch.rs new file mode 100644 index 0000000..2226df3 --- /dev/null +++ b/openfut-hook/src/sbc_dispatch.rs @@ -0,0 +1,756 @@ +//! Guarded FIFA 17 SBC completion dispatch and passive event tracing. +//! +//! `OPENFUT_SBC_DISPATCH=1` permits one narrowly-scoped repair per native +//! deserializer generation. The default and `OPENFUT_SBC_DISPATCH_TRACE=1` paths +//! are behavior-preserving. + +use core::ffi::c_void; +use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache; +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::{ + VirtualAlloc, VirtualFree, VirtualProtect, MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, + PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_READWRITE, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId}; + +const COMPLETION_RVA: usize = 0x0b8950; +const EVENT_DISPATCH_RVA: usize = 0x1a4cd0; +const CATEGORY_RESPONSE_VTABLE_RVA: usize = 0x22e5b0; +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 COMPLETION_COPY_LEN: usize = 14; +const EVENT_COPY_LEN: usize = 16; +const ABS_JUMP_LEN: usize = 14; +const COMPLETION_TRAMPOLINE_LEN: usize = 12 + 2 + ABS_JUMP_LEN * 2; +const UNKNOWN_TRANSPORT_STATUS: u32 = 999; +const FUT_SBS_CATEGORIES_EVENT: u32 = 0x756c; +const FUT_SBS_CATEGORIES_READY_EVENT: u32 = 0x756d; +const SBC_REFRESH_EVENT: u32 = 0x138c; + +const COMPLETION_SIGNATURE: [u8; 32] = [ + 0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2, 0x74, 0x4e, 0x83, 0x7a, + 0x1c, 0x00, 0x75, 0x48, 0xc6, 0x81, 0x1d, 0x02, 0x00, 0x00, 0x01, 0x48, 0x8b, 0x89, 0x40, 0x01, +]; +const EVENT_SIGNATURE: [u8; EVENT_COPY_LEN] = [ + 0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x40, 0xb8, 0xfe, 0xff, 0xff, 0xff, +]; + +type CompletionFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> usize; +type EventDispatchFn = unsafe extern "system" fn(*mut c_void, u32, *mut c_void) -> usize; + +static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false); +static COMPLETION_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static EVENT_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0); +static LAST_REPAIRED_GENERATION: AtomicU64 = AtomicU64::new(0); +static COMPLETION_ENTRIES: AtomicU64 = AtomicU64::new(0); +static COMPLETION_EXITS: AtomicU64 = AtomicU64::new(0); +static COMPLETION_THREAD: AtomicUsize = AtomicUsize::new(0); +static COMPLETION_CONTROLLER: AtomicUsize = AtomicUsize::new(0); +static COMPLETION_STATUS_OBJECT: AtomicUsize = AtomicUsize::new(0); +static COMPLETION_STATUS: AtomicUsize = AtomicUsize::new(usize::MAX); +static COMPLETION_GENERATION: AtomicU64 = AtomicU64::new(0); +static COMPLETION_DECISION: AtomicUsize = AtomicUsize::new(Decision::NativeSuccess as usize); +static COMPLETION_REJECTION: AtomicUsize = AtomicUsize::new(Rejection::None as usize); +static EVENT_ENTRIES: AtomicU64 = AtomicU64::new(0); +static EVENT_EXITS: AtomicU64 = AtomicU64::new(0); +static EVENT_THREAD: AtomicUsize = AtomicUsize::new(0); +static EVENT_CONTROLLER: AtomicUsize = AtomicUsize::new(0); +static EVENT_ID: AtomicUsize = AtomicUsize::new(0); +static EVENT_PAYLOAD: AtomicUsize = AtomicUsize::new(0); +static EVENT_CATEGORIES: AtomicU64 = AtomicU64::new(0); +static EVENT_REFRESH: AtomicU64 = AtomicU64::new(0); +static EVENT_READY: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +enum Decision { + NativeSuccess, + Repair, +} + +const REJECTED_DECISION: usize = 2; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +enum Rejection { + None, + RepairDisabled, + NullStatus, + StatusUnreadable, + UnsupportedStatus, + CardsBuildMismatch, + ParserUnbalanced, + FactoryMismatch, + ParserThreadMismatch, + ReaderMissing, + ParseFailed, + ResponseClassMismatch, + ModelEmpty, + NotifierNotCurrent, + ControllerMismatch, + ControllerModelMismatch, + DuplicateGeneration, +} + +#[derive(Clone, Copy)] +struct DecisionInput { + repair_enabled: bool, + status: Option, + status_present: bool, + status_copyable: bool, + cards_build_matches: bool, + factory_entries: u64, + factory_exits: u64, + factory_result: usize, + factory_thread: usize, + deserializer_entries: u64, + deserializer_exits: u64, + deserializer_this: usize, + deserializer_reader: usize, + deserializer_result: bool, + deserializer_thread: usize, + response_class_matches: bool, + model: usize, + category_count: usize, + notifier_entries: u64, + notifier_exits: u64, + controller_matches: bool, + controller_model_matches: bool, + last_repaired_generation: u64, +} + +fn decide(input: DecisionInput) -> Result { + let Some(status) = input.status else { + return Err(if input.status_present { + Rejection::StatusUnreadable + } else { + Rejection::NullStatus + }); + }; + if status == 0 { + return Ok(Decision::NativeSuccess); + } + if !input.repair_enabled { + return Err(Rejection::RepairDisabled); + } + if status != UNKNOWN_TRANSPORT_STATUS { + return Err(Rejection::UnsupportedStatus); + } + if !input.status_copyable { + return Err(Rejection::StatusUnreadable); + } + if !input.cards_build_matches { + return Err(Rejection::CardsBuildMismatch); + } + let generation = input.deserializer_exits; + if generation == 0 + || input.factory_entries != input.factory_exits + || input.deserializer_entries != generation + || input.factory_exits != generation + { + return Err(Rejection::ParserUnbalanced); + } + if input.factory_result == 0 || input.factory_result != input.deserializer_this { + return Err(Rejection::FactoryMismatch); + } + if input.factory_thread == 0 || input.factory_thread != input.deserializer_thread { + return Err(Rejection::ParserThreadMismatch); + } + if input.deserializer_reader == 0 { + return Err(Rejection::ReaderMissing); + } + if !input.deserializer_result { + return Err(Rejection::ParseFailed); + } + if !input.response_class_matches { + return Err(Rejection::ResponseClassMismatch); + } + if input.model == 0 || input.category_count == 0 || input.category_count == usize::MAX { + return Err(Rejection::ModelEmpty); + } + if input.notifier_entries != generation + || input.notifier_entries == 0 + || input.notifier_exits.checked_add(1) != Some(input.notifier_entries) + { + return Err(Rejection::NotifierNotCurrent); + } + if !input.controller_matches { + return Err(Rejection::ControllerMismatch); + } + if !input.controller_model_matches { + return Err(Rejection::ControllerModelMismatch); + } + if input.last_repaired_generation >= generation { + return Err(Rejection::DuplicateGeneration); + } + Ok(Decision::Repair) +} + +unsafe fn guarded_u32(address: usize) -> Option { + crate::sbc_trace::readable_range(address, 4) + .then(|| core::ptr::read_volatile(address as *const u32)) +} + +unsafe fn status_code(status: usize) -> Option { + status + .checked_add(0x1c) + .and_then(|address| guarded_u32(address)) +} + +unsafe fn controller_identity(base: usize, controller: usize, model: usize) -> (bool, bool) { + if base == 0 || controller == 0 { + return (false, false); + } + let main_vtable = crate::sbc_trace::guarded_usize(controller); + let event_vtable = controller + .checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF) + .and_then(|address| crate::sbc_trace::guarded_usize(address)); + let controller_model = controller + .checked_add(SBC_CONTROLLER_MODEL_OFF) + .and_then(|address| crate::sbc_trace::guarded_usize(address)); + ( + main_vtable == base.checked_add(SBC_CONTROLLER_VTABLE_RVA) + && event_vtable == base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA), + controller_model == Some(model), + ) +} + +pub(crate) unsafe fn note_sbc_controller(controller: usize, base: usize) { + let (identity_matches, _) = controller_identity(base, controller, 0); + if identity_matches { + SBC_CONTROLLER.store(controller, Ordering::Release); + crate::write_log(&format!( + "SBC_DISPATCH: captured category controller={controller:#x}\n" + )); + } else { + crate::write_log(&format!( + "SBC_DISPATCH: rejected category controller={controller:#x} (class mismatch)\n" + )); + } +} + +#[repr(C, align(16))] +struct CompletionStatusShadow([u8; 0x20]); + +unsafe extern "system" fn completion_wrapper( + controller: *mut c_void, + status: *mut c_void, +) -> usize { + COMPLETION_ENTRIES.fetch_add(1, Ordering::Relaxed); + COMPLETION_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed); + COMPLETION_CONTROLLER.store(controller as usize, Ordering::Relaxed); + COMPLETION_STATUS_OBJECT.store(status as usize, Ordering::Relaxed); + + let evidence = crate::sbc_trace::dispatch_evidence(); + let status_address = status as usize; + let observed_status = if status_address == 0 { + None + } else { + status_code(status_address) + }; + COMPLETION_STATUS.store( + observed_status + .map(|value| value as usize) + .unwrap_or(usize::MAX), + Ordering::Relaxed, + ); + COMPLETION_GENERATION.store(evidence.deserializer_exits, Ordering::Relaxed); + + let response_vtable = crate::sbc_trace::guarded_usize(evidence.deserializer_this); + let captured_controller = SBC_CONTROLLER.load(Ordering::Acquire); + let (controller_matches, controller_model_matches) = + controller_identity(evidence.base, captured_controller, evidence.model); + let input = DecisionInput { + repair_enabled: REPAIR_ENABLED.load(Ordering::Acquire), + status: observed_status, + status_present: status_address != 0, + status_copyable: status_address != 0 + && crate::sbc_trace::readable_range(status_address, 0x20), + cards_build_matches: crate::sbc_trace::valid_cards_image(evidence.base), + factory_entries: evidence.factory_entries, + factory_exits: evidence.factory_exits, + factory_result: evidence.factory_result, + factory_thread: evidence.factory_thread, + deserializer_entries: evidence.deserializer_entries, + deserializer_exits: evidence.deserializer_exits, + deserializer_this: evidence.deserializer_this, + deserializer_reader: evidence.deserializer_reader, + deserializer_result: evidence.deserializer_result, + deserializer_thread: evidence.deserializer_thread, + response_class_matches: response_vtable + == evidence.base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA), + model: evidence.model, + category_count: evidence.category_count, + notifier_entries: evidence.notifier_entries, + notifier_exits: evidence.notifier_exits, + controller_matches: controller_matches && captured_controller == controller as usize, + controller_model_matches, + last_repaired_generation: LAST_REPAIRED_GENERATION.load(Ordering::Acquire), + }; + + let original: CompletionFn = + core::mem::transmute(COMPLETION_TRAMPOLINE.load(Ordering::Acquire)); + let result = match decide(input) { + Ok(Decision::Repair) => { + if LAST_REPAIRED_GENERATION + .compare_exchange( + input.last_repaired_generation, + evidence.deserializer_exits, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + let mut shadow = CompletionStatusShadow([0; 0x20]); + core::ptr::copy_nonoverlapping( + status_address as *const u8, + shadow.0.as_mut_ptr(), + shadow.0.len(), + ); + shadow.0[0x1c..0x20].copy_from_slice(&0u32.to_le_bytes()); + COMPLETION_DECISION.store(Decision::Repair as usize, Ordering::Relaxed); + COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed); + original(controller, shadow.0.as_mut_ptr().cast()) + } else { + COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed); + COMPLETION_REJECTION + .store(Rejection::DuplicateGeneration as usize, Ordering::Relaxed); + original(controller, status) + } + } + Ok(Decision::NativeSuccess) => { + COMPLETION_DECISION.store(Decision::NativeSuccess as usize, Ordering::Relaxed); + COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed); + original(controller, status) + } + Err(rejection) => { + COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed); + COMPLETION_REJECTION.store(rejection as usize, Ordering::Relaxed); + original(controller, status) + } + }; + COMPLETION_EXITS.fetch_add(1, Ordering::Release); + result +} + +unsafe extern "system" fn event_wrapper( + controller: *mut c_void, + event: u32, + payload: *mut c_void, +) -> usize { + EVENT_ENTRIES.fetch_add(1, Ordering::Relaxed); + EVENT_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed); + EVENT_CONTROLLER.store(controller as usize, Ordering::Relaxed); + EVENT_ID.store(event as usize, Ordering::Relaxed); + EVENT_PAYLOAD.store(payload as usize, Ordering::Relaxed); + match event { + FUT_SBS_CATEGORIES_EVENT => { + EVENT_CATEGORIES.fetch_add(1, Ordering::Relaxed); + } + SBC_REFRESH_EVENT => { + EVENT_REFRESH.fetch_add(1, Ordering::Relaxed); + } + FUT_SBS_CATEGORIES_READY_EVENT => { + EVENT_READY.fetch_add(1, Ordering::Relaxed); + } + _ => {} + } + let original: EventDispatchFn = core::mem::transmute(EVENT_TRAMPOLINE.load(Ordering::Acquire)); + let result = original(controller, event, payload); + EVENT_EXITS.fetch_add(1, Ordering::Release); + result +} + +unsafe fn allocate_completion_trampoline(target: usize) -> Option { + let failure_target = target.checked_add(0x5c)?; + let success_target = target.checked_add(COMPLETION_COPY_LEN)?; + let memory = VirtualAlloc( + core::ptr::null(), + COMPLETION_TRAMPOLINE_LEN, + MEM_COMMIT | MEM_RESERVE, + PAGE_READWRITE, + ) as usize; + if memory == 0 { + return None; + } + core::ptr::copy_nonoverlapping(target as *const u8, memory as *mut u8, 12); + // The relocated branch preserves the original null-status failure edge. + core::ptr::copy_nonoverlapping([0x75, 0x0e].as_ptr(), (memory + 12) as *mut u8, 2); + let failure = crate::sbc_trace::absolute_jump(failure_target); + core::ptr::copy_nonoverlapping(failure.as_ptr(), (memory + 14) as *mut u8, ABS_JUMP_LEN); + let success = crate::sbc_trace::absolute_jump(success_target); + core::ptr::copy_nonoverlapping(success.as_ptr(), (memory + 28) as *mut u8, ABS_JUMP_LEN); + let mut old = 0u32; + if VirtualProtect( + memory as _, + COMPLETION_TRAMPOLINE_LEN, + PAGE_EXECUTE_READ, + &mut old, + ) == 0 + || FlushInstructionCache(GetCurrentProcess(), memory as _, COMPLETION_TRAMPOLINE_LEN) == 0 + { + VirtualFree(memory as _, 0, MEM_RELEASE); + return None; + } + Some(memory) +} + +unsafe fn restore_entry(target: usize, original: &[u8; N]) -> bool { + let mut old = 0u32; + if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 { + return false; + } + core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, N); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0; + let mut ignored = 0u32; + flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 +} + +unsafe fn write_entry( + target: usize, + destination: usize, + original: &[u8; N], +) -> Result<(), bool> { + let mut patch = [0x90u8; N]; + patch[..ABS_JUMP_LEN].copy_from_slice(&crate::sbc_trace::absolute_jump(destination)); + let mut old = 0u32; + if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 { + return Err(true); + } + core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, N); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0; + let mut ignored = 0u32; + if flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 { + Ok(()) + } else { + Err(restore_entry(target, original)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InstallOutcome { + Installed, + CleanFailure, + DegradedHookActive, + DegradedProcessState, + DegradedHookAndProcess, +} + +unsafe fn install_pair(base: usize) -> InstallOutcome { + let Some(completion) = crate::sbc_trace::target_va(base, COMPLETION_RVA) else { + return InstallOutcome::CleanFailure; + }; + let Some(event) = crate::sbc_trace::target_va(base, EVENT_DISPATCH_RVA) else { + return InstallOutcome::CleanFailure; + }; + let completion_original: [u8; COMPLETION_COPY_LEN] = COMPLETION_SIGNATURE + [..COMPLETION_COPY_LEN] + .try_into() + .unwrap(); + if !crate::sbc_trace::valid_cards_image(base) + || !crate::sbc_trace::executable_range_in_image( + base, + completion, + COMPLETION_SIGNATURE.len(), + ) + || !crate::sbc_trace::executable_range_in_image(base, event, EVENT_SIGNATURE.len()) + || core::slice::from_raw_parts(completion as *const u8, COMPLETION_SIGNATURE.len()) + != COMPLETION_SIGNATURE + || core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len()) != EVENT_SIGNATURE + { + return InstallOutcome::CleanFailure; + } + let mut pinned = core::ptr::null_mut(); + if GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + completion as *const u8, + &mut pinned, + ) == 0 + || pinned as usize != base + { + return InstallOutcome::CleanFailure; + } + let Some(completion_trampoline) = allocate_completion_trampoline(completion) else { + return InstallOutcome::CleanFailure; + }; + let Some(event_trampoline) = crate::sbc_trace::allocate_trampoline(event, EVENT_COPY_LEN) + else { + VirtualFree(completion_trampoline as _, 0, MEM_RELEASE); + return InstallOutcome::CleanFailure; + }; + COMPLETION_TRAMPOLINE.store(completion_trampoline, Ordering::Release); + EVENT_TRAMPOLINE.store(event_trampoline, Ordering::Release); + + let Some(_gate) = crate::sbc_trace::acquire_patch_installer_gate() else { + VirtualFree(completion_trampoline as _, 0, MEM_RELEASE); + VirtualFree(event_trampoline as _, 0, MEM_RELEASE); + COMPLETION_TRAMPOLINE.store(0, Ordering::Release); + EVENT_TRAMPOLINE.store(0, Ordering::Release); + return InstallOutcome::CleanFailure; + }; + let mut peers = match crate::sbc_trace::suspend_peers(completion, event) { + Ok(peers) => peers, + Err(crate::sbc_trace::QuiesceFailure::Acquire) => { + VirtualFree(completion_trampoline as _, 0, MEM_RELEASE); + VirtualFree(event_trampoline as _, 0, MEM_RELEASE); + COMPLETION_TRAMPOLINE.store(0, Ordering::Release); + EVENT_TRAMPOLINE.store(0, Ordering::Release); + return InstallOutcome::CleanFailure; + } + Err(crate::sbc_trace::QuiesceFailure::Resume) => { + return InstallOutcome::DegradedProcessState; + } + }; + let final_valid = crate::sbc_trace::valid_cards_image(base) + && core::slice::from_raw_parts(completion as *const u8, COMPLETION_SIGNATURE.len()) + == COMPLETION_SIGNATURE + && core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len()) + == EVENT_SIGNATURE; + let transaction = if !final_valid { + InstallOutcome::CleanFailure + } else { + match write_entry( + completion, + completion_wrapper as *const () as usize, + &completion_original, + ) { + Ok(()) => { + match write_entry(event, event_wrapper as *const () as usize, &EVENT_SIGNATURE) { + Ok(()) => InstallOutcome::Installed, + Err(event_clean) => { + let completion_clean = restore_entry(completion, &completion_original); + if event_clean && completion_clean { + InstallOutcome::CleanFailure + } else { + InstallOutcome::DegradedHookActive + } + } + } + } + Err(true) => InstallOutcome::CleanFailure, + Err(false) => InstallOutcome::DegradedHookActive, + } + }; + let resumed = peers.resume_all(); + let outcome = if resumed { + transaction + } else if matches!( + transaction, + InstallOutcome::Installed | InstallOutcome::DegradedHookActive + ) { + InstallOutcome::DegradedHookAndProcess + } else { + InstallOutcome::DegradedProcessState + }; + if outcome == InstallOutcome::CleanFailure { + VirtualFree(completion_trampoline as _, 0, MEM_RELEASE); + VirtualFree(event_trampoline as _, 0, MEM_RELEASE); + COMPLETION_TRAMPOLINE.store(0, Ordering::Release); + EVENT_TRAMPOLINE.store(0, Ordering::Release); + } + outcome +} + +unsafe fn worker() { + let _pending = crate::sbc_trace::CodeInstallerPending; + let mut base = 0usize; + for _ in 0..600u32 { + base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize; + if base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + let outcome = if base == 0 { + InstallOutcome::CleanFailure + } else { + install_pair(base) + }; + drop(_pending); + match outcome { + InstallOutcome::Installed => crate::write_log( + "SBC_DISPATCH: completion+event hooks installed; repair remains gate-controlled\n", + ), + InstallOutcome::CleanFailure => { + crate::write_log("SBC_DISPATCH: clean install failure; inactive\n"); + return; + } + InstallOutcome::DegradedHookActive => { + crate::write_log("SBC_DISPATCH: DEGRADED hook may be active; terminate game now\n"); + return; + } + InstallOutcome::DegradedProcessState => { + crate::write_log("SBC_DISPATCH: DEGRADED thread state; terminate game now\n"); + return; + } + InstallOutcome::DegradedHookAndProcess => { + crate::write_log("SBC_DISPATCH: DEGRADED hook and thread state; terminate game now\n"); + return; + } + } + + let mut completion_seen = 0u64; + let mut event_seen = 0u64; + let mut reports = 0u8; + while reports < 64 { + std::thread::sleep(std::time::Duration::from_millis(250)); + let completion_entries = COMPLETION_ENTRIES.load(Ordering::Acquire); + let event_entries = EVENT_ENTRIES.load(Ordering::Acquire); + if completion_entries != completion_seen || event_entries != event_seen { + crate::write_log(&format!( + "SBC_DISPATCH: completion entry={} exit={} tid={} controller={:#x} status_obj={:#x} status={} generation={} decision={} rejection={}; event entry={} exit={} tid={} controller={:#x} id={:#x} payload={:#x} categories={} refresh={} ready={}\n", + completion_entries, + COMPLETION_EXITS.load(Ordering::Acquire), + COMPLETION_THREAD.load(Ordering::Relaxed), + COMPLETION_CONTROLLER.load(Ordering::Relaxed), + COMPLETION_STATUS_OBJECT.load(Ordering::Relaxed), + COMPLETION_STATUS.load(Ordering::Relaxed), + COMPLETION_GENERATION.load(Ordering::Relaxed), + COMPLETION_DECISION.load(Ordering::Relaxed), + COMPLETION_REJECTION.load(Ordering::Relaxed), + event_entries, + EVENT_EXITS.load(Ordering::Acquire), + EVENT_THREAD.load(Ordering::Relaxed), + EVENT_CONTROLLER.load(Ordering::Relaxed), + EVENT_ID.load(Ordering::Relaxed), + EVENT_PAYLOAD.load(Ordering::Relaxed), + EVENT_CATEGORIES.load(Ordering::Relaxed), + EVENT_REFRESH.load(Ordering::Relaxed), + EVENT_READY.load(Ordering::Relaxed), + )); + completion_seen = completion_entries; + event_seen = event_entries; + reports += 1; + } + } + crate::write_log("SBC_DISPATCH: report cap reached; hooks remain installed\n"); +} + +pub(crate) fn install() { + let repair = + crate::sbc_trace::env_enabled(std::env::var("OPENFUT_SBC_DISPATCH").ok().as_deref()); + let trace = repair + || crate::sbc_trace::env_enabled( + std::env::var("OPENFUT_SBC_DISPATCH_TRACE").ok().as_deref(), + ); + REPAIR_ENABLED.store(repair, Ordering::Release); + if !trace { + crate::write_log("SBC_DISPATCH: disabled\n"); + return; + } + crate::write_log(if repair { + "SBC_DISPATCH: repair ARMED; strict native evidence gate enabled\n" + } else { + "SBC_DISPATCH: passive trace requested; repair disabled\n" + }); + std::thread::spawn(|| unsafe { worker() }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_input(generation: u64) -> DecisionInput { + DecisionInput { + repair_enabled: true, + status: Some(UNKNOWN_TRANSPORT_STATUS), + status_present: true, + status_copyable: true, + cards_build_matches: true, + factory_entries: generation, + factory_exits: generation, + factory_result: 0x2000, + factory_thread: 7, + deserializer_entries: generation, + deserializer_exits: generation, + deserializer_this: 0x2000, + deserializer_reader: 0x3000, + deserializer_result: true, + deserializer_thread: 7, + response_class_matches: true, + model: 0x4000, + category_count: 2, + notifier_entries: generation, + notifier_exits: generation - 1, + controller_matches: true, + controller_model_matches: true, + last_repaired_generation: generation - 1, + } + } + + #[test] + fn native_success_is_never_rewritten() { + let mut input = valid_input(1); + input.status = Some(0); + assert_eq!(decide(input), Ok(Decision::NativeSuccess)); + } + + #[test] + fn exact_unknown_status_and_full_evidence_allow_repair() { + assert_eq!(decide(valid_input(1)), Ok(Decision::Repair)); + } + + #[test] + fn repair_is_exactly_gated_and_fail_closed() { + let mut input = valid_input(1); + input.repair_enabled = false; + assert_eq!(decide(input), Err(Rejection::RepairDisabled)); + + let mut input = valid_input(1); + input.status = Some(500); + assert_eq!(decide(input), Err(Rejection::UnsupportedStatus)); + + let mut input = valid_input(1); + input.category_count = 0; + assert_eq!(decide(input), Err(Rejection::ModelEmpty)); + + let mut input = valid_input(1); + input.controller_matches = false; + assert_eq!(decide(input), Err(Rejection::ControllerMismatch)); + + let mut input = valid_input(1); + input.status = None; + input.status_present = false; + assert_eq!(decide(input), Err(Rejection::NullStatus)); + + let mut input = valid_input(1); + input.status = None; + assert_eq!(decide(input), Err(Rejection::StatusUnreadable)); + + let mut input = valid_input(1); + input.notifier_exits = 1; + assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent)); + + let mut input = valid_input(1); + input.controller_model_matches = false; + assert_eq!(decide(input), Err(Rejection::ControllerModelMismatch)); + } + + #[test] + fn each_generation_is_one_shot_but_next_lifecycle_is_allowed() { + let mut duplicate = valid_input(1); + duplicate.last_repaired_generation = 1; + assert_eq!(decide(duplicate), Err(Rejection::DuplicateGeneration)); + + let next = valid_input(2); + assert_eq!(decide(next), Ok(Decision::Repair)); + } + + #[test] + fn relocated_completion_branch_has_proven_layout() { + assert_eq!(COMPLETION_COPY_LEN, 14); + assert_eq!( + &COMPLETION_SIGNATURE[..12], + &[0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2] + ); + assert_eq!(&COMPLETION_SIGNATURE[12..14], &[0x74, 0x4e]); + assert_eq!(COMPLETION_TRAMPOLINE_LEN, 42); + } +} diff --git a/openfut-hook/src/sbc_hook.rs b/openfut-hook/src/sbc_hook.rs index d9600e7..d3976a3 100644 --- a/openfut-hook/src/sbc_hook.rs +++ b/openfut-hook/src/sbc_hook.rs @@ -4,11 +4,9 @@ //! (all addresses, RVA math, call order, crash risks, staged test plan): //! fifa17-recon/docs/sbc-hook-dll-spec.md //! -//! Everything here is **inert by default** and gated by env vars, so shipping the DLL -//! with this module compiled in changes nothing unless a var is set: +//! Everything here is **inert by default** and gated by env vars: //! 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 @@ -20,14 +18,11 @@ //! 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::{ - VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, - PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, - PAGE_WRITECOPY, + 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; @@ -46,13 +41,6 @@ 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; @@ -87,11 +75,9 @@ 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)] @@ -147,13 +133,6 @@ enum ValidationError { CollectionUnreadable, CollectionNotNull, ReadyByteNotWritable, - ModelEmpty, - ControllerMissing, - ControllerVtableMismatch, - ControllerModelMismatch, - CompletionBranchMismatch, - CompletionBranchProtectFailed, - CompletionBranchFlushFailed, } #[derive(Clone, Copy, Debug)] @@ -428,12 +407,6 @@ 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") @@ -444,192 +417,6 @@ 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_trace.rs b/openfut-hook/src/sbc_trace.rs index d033552..3e9f108 100644 --- a/openfut-hook/src/sbc_trace.rs +++ b/openfut-hook/src/sbc_trace.rs @@ -93,7 +93,7 @@ static NOTIFIER_COUNT: AtomicUsize = AtomicUsize::new(usize::MAX); static PATCH_INSTALLER_BUSY: AtomicBool = AtomicBool::new(false); static CODE_PATCH_PENDING: AtomicUsize = AtomicUsize::new(0); -struct PatchInstallerGate; +pub(crate) struct PatchInstallerGate; impl Drop for PatchInstallerGate { fn drop(&mut self) { @@ -101,7 +101,7 @@ impl Drop for PatchInstallerGate { } } -fn acquire_patch_installer_gate() -> Option { +pub(crate) fn acquire_patch_installer_gate() -> Option { for _ in 0..200 { if PATCH_INSTALLER_BUSY .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) @@ -114,7 +114,7 @@ fn acquire_patch_installer_gate() -> Option { None } -struct CodeInstallerPending; +pub(crate) struct CodeInstallerPending; impl Drop for CodeInstallerPending { fn drop(&mut self) { @@ -138,15 +138,15 @@ enum TraceState { DegradedHookAndProcess, } -fn env_enabled(value: Option<&str>) -> bool { +pub(crate) fn env_enabled(value: Option<&str>) -> bool { matches!(value, Some("1")) } -fn target_va(base: usize, rva: usize) -> Option { +pub(crate) fn target_va(base: usize, rva: usize) -> Option { base.checked_add(rva) } -fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] { +pub(crate) fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] { let mut jump = [0u8; ABS_JUMP_LEN]; jump[..6].copy_from_slice(&[0xff, 0x25, 0, 0, 0, 0]); jump[6..].copy_from_slice(&(destination as u64).to_le_bytes()); @@ -160,7 +160,7 @@ fn instruction_pointer_in_span(rip: usize, target: usize) -> bool { .unwrap_or(true) } -struct SuspendedPeers { +pub(crate) struct SuspendedPeers { handles: [HANDLE; MAX_PEERS], tids: [u32; MAX_PEERS], count: usize, @@ -179,7 +179,7 @@ impl SuspendedPeers { self.tids[..self.count].contains(&tid) } - unsafe fn resume_all(&mut self) -> bool { + pub(crate) unsafe fn resume_all(&mut self) -> bool { let mut all_resumed = true; for index in (0..self.count).rev() { let handle = self.handles[index]; @@ -208,14 +208,14 @@ impl Drop for SuspendedPeers { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum QuiesceFailure { +pub(crate) enum QuiesceFailure { Acquire, Resume, } /// Stop and inspect every peer thread before touching either entry point. Any /// incomplete enumeration/access/context operation fails the transaction closed. -unsafe fn suspend_peers( +pub(crate) unsafe fn suspend_peers( factory: usize, deserializer: usize, ) -> Result { @@ -329,7 +329,7 @@ unsafe fn executable_range(address: usize, length: usize) -> bool { ) && end <= mbi.BaseAddress as usize + mbi.RegionSize } -unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool { +pub(crate) unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool { let Some(end) = address.checked_add(length) else { return false; }; @@ -347,7 +347,7 @@ unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) && end <= mbi.BaseAddress as usize + mbi.RegionSize } -unsafe fn readable_range(address: usize, length: usize) -> bool { +pub(crate) unsafe fn readable_range(address: usize, length: usize) -> bool { let Some(end) = address.checked_add(length) else { return false; }; @@ -362,20 +362,20 @@ unsafe fn readable_range(address: usize, length: usize) -> bool { && end <= mbi.BaseAddress as usize + mbi.RegionSize } -unsafe fn guarded_usize(address: usize) -> Option { +pub(crate) unsafe fn guarded_usize(address: usize) -> Option { (address & 7 == 0 && readable_range(address, 8)) .then(|| core::ptr::read_volatile(address as *const usize)) } -unsafe fn guarded_u16(address: usize) -> Option { +pub(crate) unsafe fn guarded_u16(address: usize) -> Option { readable_range(address, 2).then(|| core::ptr::read_volatile(address as *const u16)) } -unsafe fn guarded_u8(address: usize) -> Option { +pub(crate) unsafe fn guarded_u8(address: usize) -> Option { readable_range(address, 1).then(|| core::ptr::read_volatile(address as *const u8)) } -unsafe fn valid_cards_image(base: usize) -> bool { +pub(crate) unsafe fn valid_cards_image(base: usize) -> bool { let Some(control) = base.checked_add(CONTROL_RVA) else { return false; }; @@ -413,7 +413,7 @@ unsafe fn signature_matches(target: usize, signature: &[u8; 32]) -> bool { core::slice::from_raw_parts(target as *const u8, signature.len()) == signature } -unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option { +pub(crate) unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option { let trampoline_len = copy_len.checked_add(ABS_JUMP_LEN)?; let memory = VirtualAlloc( core::ptr::null(), @@ -595,7 +595,10 @@ unsafe extern "system" fn controller_register_wrapper(controller: *mut c_void, e 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); + crate::sbc_dispatch::note_sbc_controller( + controller as usize, + TRACE_BASE.load(Ordering::Acquire), + ); } } @@ -630,7 +633,6 @@ 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) @@ -688,6 +690,44 @@ unsafe extern "system" fn deserializer_wrapper(this: *mut c_void, reader: *mut c DESERIALIZER_EXITS.fetch_add(1, Ordering::Release); result } +#[derive(Clone, Copy, Debug)] +pub(crate) struct DispatchEvidence { + pub(crate) base: usize, + pub(crate) factory_entries: u64, + pub(crate) factory_exits: u64, + pub(crate) factory_result: usize, + pub(crate) factory_thread: usize, + pub(crate) deserializer_entries: u64, + pub(crate) deserializer_exits: u64, + pub(crate) deserializer_this: usize, + pub(crate) deserializer_reader: usize, + pub(crate) deserializer_result: bool, + pub(crate) deserializer_thread: usize, + pub(crate) model: usize, + pub(crate) category_count: usize, + pub(crate) notifier_entries: u64, + pub(crate) notifier_exits: u64, +} + +pub(crate) fn dispatch_evidence() -> DispatchEvidence { + DispatchEvidence { + base: TRACE_BASE.load(Ordering::Acquire), + factory_entries: FACTORY_ENTRIES.load(Ordering::Acquire), + factory_exits: FACTORY_EXITS.load(Ordering::Acquire), + factory_result: FACTORY_LAST_RESULT.load(Ordering::Acquire), + factory_thread: FACTORY_LAST_THREAD.load(Ordering::Relaxed), + deserializer_entries: DESERIALIZER_ENTRIES.load(Ordering::Acquire), + deserializer_exits: DESERIALIZER_EXITS.load(Ordering::Acquire), + deserializer_this: DESERIALIZER_LAST_THIS.load(Ordering::Relaxed), + deserializer_reader: DESERIALIZER_LAST_READER.load(Ordering::Relaxed), + deserializer_result: DESERIALIZER_LAST_RESULT.load(Ordering::Acquire), + deserializer_thread: DESERIALIZER_LAST_THREAD.load(Ordering::Relaxed), + model: DESERIALIZER_EXIT_M.load(Ordering::Relaxed), + category_count: DESERIALIZER_EXIT_COUNT.load(Ordering::Relaxed), + notifier_entries: NOTIFIER_ENTRIES.load(Ordering::Acquire), + notifier_exits: NOTIFIER_EXITS.load(Ordering::Acquire), + } +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum InstallOutcome { @@ -1103,10 +1143,15 @@ fn install_notifier(enabled: bool) { } 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()); + let dispatch_repair = env_enabled(std::env::var("OPENFUT_SBC_DISPATCH").ok().as_deref()); + let dispatch_trace = + dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_DISPATCH_TRACE").ok().as_deref()); + let enabled = + dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref()); + let notifier_enabled = + dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref()); CODE_PATCH_PENDING.store( - enabled as usize + (notifier_enabled as usize * 2), + enabled as usize + (notifier_enabled as usize * 2) + dispatch_trace as usize, Ordering::Release, ); install_notifier(notifier_enabled);