//! Guarded FIFA 17 SBC completion dispatch and passive event tracing. //! //! The repair is a PROMOTED feature: it is armed by the build itself, never by an //! environment variable (see [`REPAIR_PROMOTED`]). Safety lives in the runtime //! evidence gate, not in a flag. 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; /// The guarded native dispatch repair is PROMOTED: armed by the build, never by an /// environment variable. Retail Gates A–G passed on the pinned CardsDLL build, so a /// deployed hook must repair the SBC completion on every launch path (Steam, the /// launcher, or a bare `umu-run`) with nothing to export. /// /// Promotion does NOT weaken any check — every guard stays in the runtime evidence /// gate rather than in a flag. `worker` still validates the exact CardsDLL /// signatures before installing a detour, and [`decide`] still requires the /// transport sentinel status, the pinned category-response vtable captured while /// the response object was provably live, balanced parser counts on the one parser /// thread, this generation's notifier having entered AND returned, the captured /// controller/model identity, and one repair per deserializer generation. Anything /// unrecognised leaves native execution untouched. /// /// Rollback is a file swap (restore the previous `version.dll`) — the documented /// client rollback path — deliberately not an env kill-switch. pub(crate) const REPAIR_PROMOTED: bool = true; /// Compile-time contract: the repair stays armed by the build. Flipping this back to /// an env gate would silently cost a normal launch (Steam or the launcher) its SBC /// screen, which is exactly the regression promotion removed — so it must be a /// deliberate, visible change here rather than a missing variable at runtime. const _: () = assert!(REPAIR_PROMOTED); 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, ModelChanged, 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, live_category_count: 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.live_category_count != input.category_count { return Err(Rejection::ModelChanged); } // The category-success notifier for this generation must have entered and // fully returned before the SBC completion runs. On the pinned CardsDLL the // completion fires immediately after the notifier unwinds (measured: notifier // entries == exits == generation at completion), not nested inside it, so we // bind both notifier counts to the current generation rather than requiring // an in-flight notifier. if input.notifier_entries != generation || input.notifier_entries == 0 || input.notifier_exits != generation { 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) } /// The parsed response is the FIFA 17 typed SBC-category response only when the /// vtable captured at deserializer exit (object provably live) equals the pinned /// category-response vtable for the running CardsDLL image. A zero capture means /// the object vtable was unreadable and never qualifies. fn response_class_matches(base: usize, response_vtable: usize) -> bool { response_vtable != 0 && base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA) == Some(response_vtable) } 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 captured_controller = SBC_CONTROLLER.load(Ordering::Acquire); let live_category_count = evidence .model .checked_add(0x50) .and_then(|address| crate::sbc_trace::guarded_u16(address)) .map(usize::from) .unwrap_or(usize::MAX); 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_class_matches(evidence.base, evidence.response_vtable), model: evidence.model, live_category_count, 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) } }; crate::write_log(&format!( "SBC_DISPATCH: decide gen={} status={} present={} copyable={} cards={} factory_e={} factory_x={} factory_r={:#x} factory_t={} deser_e={} deser_x={} deser_this={:#x} reader={:#x} deser_ok={} deser_t={} vt_obs={:#x} vt_exp={:#x} class={} model={:#x} live={} count={} notif_e={} notif_x={} ctrl_match={} ctrl_model={} captured_ctrl={:#x} arg_ctrl={:#x} last_gen={} decision={} rejection={}\n", input.deserializer_exits, input.status.map(i64::from).unwrap_or(-1), input.status_present, input.status_copyable, input.cards_build_matches, input.factory_entries, input.factory_exits, input.factory_result, input.factory_thread, input.deserializer_entries, input.deserializer_exits, input.deserializer_this, input.deserializer_reader, input.deserializer_result, input.deserializer_thread, evidence.response_vtable, evidence.base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA).unwrap_or(0), input.response_class_matches, input.model, input.live_category_count, input.category_count, input.notifier_entries, input.notifier_exits, input.controller_matches, input.controller_model_matches, captured_controller, controller as usize, input.last_repaired_generation, COMPLETION_DECISION.load(Ordering::Relaxed), COMPLETION_REJECTION.load(Ordering::Relaxed), )); 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); } _ => {} } // Piggyback the store pre-warm on this game-thread hub event: it loads the // purchase groups once, before the store screen is shown, so the store's native // screen-show tab bind sees a populated group list (see `store_entry`). crate::store_entry::maybe_prewarm_groups(); 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() { // Promoted: armed by the build. No environment variable participates in the // decision, so every launch path behaves identically. REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release); crate::write_log( "SBC_DISPATCH: repair ARMED (promoted); strict native evidence gate enabled\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, live_category_count: 2, category_count: 2, notifier_entries: generation, notifier_exits: generation, 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)); // Notifier still in flight for this generation (has not returned) is rejected: // on the pinned build the completion only runs after the notifier unwinds. let mut input = valid_input(1); input.notifier_exits = 0; assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent)); // A notifier count that does not match the current generation is rejected. let mut input = valid_input(1); input.notifier_entries = 2; input.notifier_exits = 2; 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)); let mut input = valid_input(1); input.live_category_count = 0; assert_eq!(decide(input), Err(Rejection::ModelChanged)); } #[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 response_class_requires_exact_pinned_vtable() { let base = 0x1_8000_0000usize; let expected = base + CATEGORY_RESPONSE_VTABLE_RVA; assert!(response_class_matches(base, expected)); // An unreadable capture (zero) never qualifies. assert!(!response_class_matches(base, 0)); // Any other vtable (e.g. a sub-object or a freed/reused slot) is rejected. assert!(!response_class_matches(base, expected + 8)); assert!(!response_class_matches(base, base)); } #[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); } }