//! Guarded FIFA 17 store-entry category clamp. //! //! # What this repairs //! //! Opening the FUT store shows a one-frame "Browse Packs" overview (all group //! tiles) before it settles on a tabbed category. The overview is the store //! screen's constructor default: `screen+0x290` (the CATEGORY_ID the renderer //! resolves) starts at `0`, and the category resolver `FUN_1800147f0` treats a //! `0` category as "list every group" (`FUN_180014610`, the Browse path). Only //! after the Scaleform movie posts a real tab ordinal does the screen re-render //! on a tab — hence the visible flash on first open. //! //! This hook removes that flash by making the FIRST render already land on a //! real tab: it detours the store render `FUN_18007dab0` and, when the incoming //! category is `0`, substitutes the first present group ordinal (`1`). //! //! # Why it cannot crash the client //! //! The resolver crashes (`0x180014882`, `[NULL+0x48]`) only when it resolves a //! POSITIVE ordinal that `FUN_180014420` (ordinal->group lookup) cannot find and //! returns NULL for. The existing autopatch guard (`JG` at `0x180014858`) already //! routes `category <= 0` to the safe Browse path, but does NOT cover a positive //! ordinal that misses. So this hook substitutes `1` ONLY after calling the exact //! same lookup the resolver uses — `FUN_180014420(_, 1)` — and confirming it //! returns non-NULL. That is the resolver's own non-crash precondition, so by //! construction the substituted category can never reach the NULL deref. When no //! group exists yet, the category is left untouched (`0` -> Browse, still safe). //! `FUN_180014420` ignores its first argument (it fetches the group list from a //! process singleton), which the render itself dereferences on entry, so calling //! it here is exactly as safe as the render's own first action. //! //! # Promotion //! //! Like the SBC dispatch repair, this is a PROMOTED feature: armed by the build, //! never by an environment variable (see [`CLAMP_PROMOTED`]). Safety is the //! runtime signature/evidence gate, not a flag. Rollback is a `version.dll` file //! swap, not an env kill-switch. 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::{ VirtualFree, VirtualProtect, MEM_RELEASE, PAGE_EXECUTE_READWRITE, }; use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId}; /// Store render `FUN_18007dab0`: reads `screen+0x290` and drives the resolver. const RENDER_RVA: usize = 0x7dab0; /// Ordinal->group lookup `FUN_180014420`: returns the group for a 1-based ordinal /// or NULL when absent. Its first argument is dead (list comes from a singleton). const LOOKUP_RVA: usize = 0x14420; /// `screen + CATEGORY_OFFSET` holds the CATEGORY_ID the renderer resolves. const CATEGORY_OFFSET: usize = 0x290; /// The first present group's ordinal. `FUN_180014420` numbers present groups from /// 1, so `1` is always the first present group — the natural default landing tab. const FIRST_ORDINAL: u32 = 1; /// Tab publish `FUN_18007df60(screen)`: resolves the six hardcoded tab tokens /// (`mypacks/points/bronze/silver/gold/special`) against the loaded purchase groups /// and publishes `MYPACK_/POINTS_/BRONZE_/SILVER_/GOLD_/SPECIAL_CATEGORY_ID` to the /// movie, which is what makes the tab bar appear. The store screen's message /// dispatcher (`0x18007d880`) calls it for event `0x278a`, entirely separately from /// the render it calls for event `0x753f`. const TAB_PUBLISH_RVA: usize = 0x7df60; /// `screen + SCREEN_STATE_OFFSET` is the state the tab publish self-gates on /// (`cmpl $0x418, 0x2cc(%rbp); jne ` at `0x18007dfbf`). Logged for evidence: /// a mismatch makes the publish a native no-op rather than a fault. const SCREEN_STATE_OFFSET: usize = 0x2cc; /// Whole-instruction prologue length relocated into the render trampoline; also the /// number of bytes overwritten by the entry detour. `push rdi; sub rsp,0x40; /// movq [rsp+0x30],-2` = 2 + 4 + 9 = 15, a clean boundary that covers the 14-byte /// absolute jump. const COPY_LEN: usize = 15; const ABS_JUMP_LEN: usize = 14; /// First 15 bytes of `FUN_18007dab0` on the pinned CardsDLL. Verified before the /// detour is written and again under thread suspension. const RENDER_SIGNATURE: [u8; COPY_LEN] = [ 0x40, 0x57, 0x48, 0x83, 0xec, 0x40, 0x48, 0xc7, 0x44, 0x24, 0x30, 0xfe, 0xff, 0xff, 0xff, ]; /// First 15 bytes of `FUN_180014420`. Validated before we ever call it, so the clamp /// only invokes the genuine lookup on the exact build it was reversed against. const LOOKUP_SIGNATURE: [u8; 15] = [ 0x40, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff, ]; /// First 15 bytes of `FUN_18007df60`. Validated before we ever call it. const TAB_PUBLISH_SIGNATURE: [u8; 15] = [ 0x48, 0x8b, 0xc4, 0x56, 0x57, 0x41, 0x54, 0x41, 0x56, 0x41, 0x57, 0x48, 0x83, 0xec, 0x60, ]; type RenderFn = unsafe extern "system" fn(*mut c_void) -> *mut c_void; type LookupFn = unsafe extern "system" fn(*mut c_void, u32) -> *mut c_void; type TabPublishFn = unsafe extern "system" fn(*mut c_void) -> *mut c_void; /// The store-entry clamp is PROMOTED: armed by the build, never by an environment /// variable, so every launch path (Steam, the launcher, a bare `umu-run`) behaves /// identically. Promotion does not weaken any check — the signature gate, the image /// validation, the thread quiesce, and the resolver-backed non-NULL precondition all /// remain in the runtime evidence path. pub(crate) const CLAMP_PROMOTED: bool = true; /// Compile-time contract: the clamp stays build-armed. Regressing this to an env gate /// would silently restore the overview flash on a normal launch, so it must be a /// deliberate, visible change here rather than a missing variable at runtime. const _: () = assert!(CLAMP_PROMOTED); static CLAMP_ENABLED: AtomicBool = AtomicBool::new(false); static RENDER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); static STORE_BASE: AtomicUsize = AtomicUsize::new(0); static RENDER_ENTRIES: AtomicU64 = AtomicU64::new(0); static CLAMPS_APPLIED: AtomicU64 = AtomicU64::new(0); static CLAMP_LAST_THREAD: AtomicUsize = AtomicUsize::new(0); static TAB_PUBLISH_SCREEN: AtomicUsize = AtomicUsize::new(0); static TAB_PUBLISHES: AtomicU64 = AtomicU64::new(0); static LAST_SCREEN_STATE: AtomicUsize = AtomicUsize::new(usize::MAX); /// Pure clamp decision, isolated for host tests. Returns the category the renderer /// should resolve: substitute the first present ordinal only for the overview /// default (`0`) and only when that ordinal actually resolves to a group; otherwise /// leave the incoming category untouched. fn clamp_category(current: i32, first_group_present: bool) -> i32 { if current == 0 && first_group_present { FIRST_ORDINAL as i32 } else { current } } /// Pure decision for the tab-bar repair, isolated for host tests. /// /// The tab bar is published by a DIFFERENT dispatcher event (`0x278a`) than the /// render (`0x753f`). On first store entry that publish runs before the /// `/store/purchasegroup` groups exist, so all six tab tokens resolve to −1, every /// panel hides, and the store draws its category with no tab bar; re-entry works /// only because the groups are cached by then. Re-running the publish once per /// screen at render time — when the groups are provably present — reproduces the /// re-entry ordering (publish, then render) on the very first entry. fn should_publish_tabs(screen: usize, last_published: usize, groups_present: bool) -> bool { groups_present && screen != 0 && screen != last_published } unsafe fn guarded_i32(address: usize) -> Option { crate::sbc_trace::readable_range(address, 4) .then(|| core::ptr::read_volatile(address as *const i32)) } 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)) } } /// Detour target for `FUN_18007dab0`. Runs on the native render thread. Before the /// original renders, clamp an overview-default category to the first present group so /// the first frame already shows a tab. Any guard miss leaves the category untouched /// and simply tail-calls the original. unsafe extern "system" fn store_render_wrapper(screen: *mut c_void) -> *mut c_void { RENDER_ENTRIES.fetch_add(1, Ordering::Relaxed); if CLAMP_ENABLED.load(Ordering::Acquire) { let base = STORE_BASE.load(Ordering::Acquire); if base != 0 && !screen.is_null() && crate::sbc_trace::validate_cards_build(base) { // Record the state the native tab publish self-gates on (expects 0x418), // so a no-op publish is diagnosable from the log rather than a mystery. if let Some(state) = guarded_i32((screen as usize).wrapping_add(SCREEN_STATE_OFFSET)) { LAST_SCREEN_STATE.store(state as u32 as usize, Ordering::Relaxed); } // The lookup's first argument is dead; pass null. Non-NULL for ordinal 1 is // both the resolver's own non-crash precondition AND proof that the // purchase groups have finished loading. let groups_present = match base.checked_add(LOOKUP_RVA) { Some(lookup) => { let lookup_fn: LookupFn = core::mem::transmute(lookup); !lookup_fn(core::ptr::null_mut(), FIRST_ORDINAL).is_null() } None => false, }; // Publish first, then set the category — the native order for a working // re-entry is event 0x278a (tab publish) followed by 0x753f (render). if should_publish_tabs( screen as usize, TAB_PUBLISH_SCREEN.load(Ordering::Relaxed), groups_present, ) { if let Some(publish) = base.checked_add(TAB_PUBLISH_RVA) { let publish_fn: TabPublishFn = core::mem::transmute(publish); publish_fn(screen); TAB_PUBLISH_SCREEN.store(screen as usize, Ordering::Relaxed); TAB_PUBLISHES.fetch_add(1, Ordering::Relaxed); } } let category_addr = (screen as usize).wrapping_add(CATEGORY_OFFSET); if let Some(current) = guarded_i32(category_addr) { let clamped = clamp_category(current, groups_present); if clamped != current { core::ptr::write_volatile(category_addr as *mut i32, clamped); CLAMPS_APPLIED.fetch_add(1, Ordering::Relaxed); CLAMP_LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed); } } } } let original: RenderFn = core::mem::transmute(RENDER_TRAMPOLINE.load(Ordering::Acquire)); original(screen) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum InstallOutcome { Installed, CleanFailure, DegradedHookActive, DegradedProcessState, DegradedHookAndProcess, } unsafe fn install_hook(base: usize) -> InstallOutcome { let Some(render) = crate::sbc_trace::target_va(base, RENDER_RVA) else { return InstallOutcome::CleanFailure; }; let Some(lookup) = crate::sbc_trace::target_va(base, LOOKUP_RVA) else { return InstallOutcome::CleanFailure; }; let Some(publish) = crate::sbc_trace::target_va(base, TAB_PUBLISH_RVA) else { return InstallOutcome::CleanFailure; }; // Fingerprint the image and ALL THREE functions: the one we detour, and the two // we call (ordinal lookup, tab publish). A single mismatched byte aborts cleanly // with no write and no call. if !crate::sbc_trace::valid_cards_image(base) || !crate::sbc_trace::executable_range_in_image(base, render, RENDER_SIGNATURE.len()) || !crate::sbc_trace::executable_range_in_image(base, lookup, LOOKUP_SIGNATURE.len()) || !crate::sbc_trace::executable_range_in_image(base, publish, TAB_PUBLISH_SIGNATURE.len()) || core::slice::from_raw_parts(render as *const u8, RENDER_SIGNATURE.len()) != RENDER_SIGNATURE || core::slice::from_raw_parts(lookup as *const u8, LOOKUP_SIGNATURE.len()) != LOOKUP_SIGNATURE || core::slice::from_raw_parts(publish as *const u8, TAB_PUBLISH_SIGNATURE.len()) != TAB_PUBLISH_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, render as *const u8, &mut pinned, ) == 0 || pinned as usize != base { return InstallOutcome::CleanFailure; } let Some(trampoline) = crate::sbc_trace::allocate_trampoline(render, COPY_LEN) else { return InstallOutcome::CleanFailure; }; RENDER_TRAMPOLINE.store(trampoline, Ordering::Release); STORE_BASE.store(base, Ordering::Release); let Some(_gate) = crate::sbc_trace::acquire_patch_installer_gate() else { VirtualFree(trampoline as _, 0, MEM_RELEASE); RENDER_TRAMPOLINE.store(0, Ordering::Release); return InstallOutcome::CleanFailure; }; let mut peers = match crate::sbc_trace::suspend_peers(render, lookup) { Ok(peers) => peers, Err(crate::sbc_trace::QuiesceFailure::Acquire) => { VirtualFree(trampoline as _, 0, MEM_RELEASE); RENDER_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(render as *const u8, RENDER_SIGNATURE.len()) == RENDER_SIGNATURE; let transaction = if !final_valid { InstallOutcome::CleanFailure } else { match write_entry( render, store_render_wrapper as *const () as usize, &RENDER_SIGNATURE, ) { Ok(()) => InstallOutcome::Installed, 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(trampoline as _, 0, MEM_RELEASE); RENDER_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_hook(base) }; drop(_pending); match outcome { InstallOutcome::Installed => { crate::write_log("STORE_ENTRY: render clamp installed (promoted)\n") } InstallOutcome::CleanFailure => { crate::write_log("STORE_ENTRY: clean install failure; inactive\n"); return; } InstallOutcome::DegradedHookActive => { crate::write_log("STORE_ENTRY: DEGRADED hook may be active; terminate game now\n"); return; } InstallOutcome::DegradedProcessState => { crate::write_log("STORE_ENTRY: DEGRADED thread state; terminate game now\n"); return; } InstallOutcome::DegradedHookAndProcess => { crate::write_log("STORE_ENTRY: DEGRADED hook and thread state; terminate game now\n"); return; } } let mut entries_seen = 0u64; let mut reports = 0u8; while reports < 64 { std::thread::sleep(std::time::Duration::from_millis(250)); let entries = RENDER_ENTRIES.load(Ordering::Acquire); if entries != entries_seen { crate::write_log(&format!( "STORE_ENTRY: render entries={} clamps={} tabpublish={} state={:#x} tid={}\n", entries, CLAMPS_APPLIED.load(Ordering::Acquire), TAB_PUBLISHES.load(Ordering::Acquire), LAST_SCREEN_STATE.load(Ordering::Relaxed), CLAMP_LAST_THREAD.load(Ordering::Relaxed), )); entries_seen = entries; reports += 1; } } crate::write_log("STORE_ENTRY: report cap reached; hook remains installed\n"); } pub(crate) fn install() { // Promoted: armed by the build. No environment variable participates. CLAMP_ENABLED.store(CLAMP_PROMOTED, Ordering::Release); crate::write_log("STORE_ENTRY: clamp ARMED (promoted); strict native signature gate\n"); std::thread::spawn(|| unsafe { worker() }); } #[cfg(test)] mod tests { use super::*; #[test] fn overview_default_clamps_to_first_ordinal_when_group_present() { assert_eq!( clamp_category(0, true), 1, "the ctor overview default (0) must land on the first present group" ); } #[test] fn overview_default_left_alone_when_no_group_exists() { // No group -> leaving 0 routes to the safe Browse path; substituting a // positive ordinal here would be the exact positive-invalid crash. assert_eq!(clamp_category(0, false), 0); } #[test] fn explicit_positive_category_is_never_touched() { // A real tab selection must pass through unchanged, group present or not. assert_eq!(clamp_category(3, true), 3); assert_eq!(clamp_category(3, false), 3); } #[test] fn negative_category_is_left_to_the_existing_browse_guard() { // The absent-mypacks -1 is handled by the JG guard (routes <=0 to Browse); // this clamp deliberately only touches the 0 overview default. assert_eq!(clamp_category(-1, true), -1); } #[test] fn tabs_publish_once_per_screen_when_groups_are_loaded() { // First render for this screen with groups present -> publish (this is the // first-entry case where the native 0x278a publish already ran too early). assert!(should_publish_tabs(0x1000, 0, true)); // Already published for this screen -> never again, so later renders don't // re-publish on every frame. assert!(!should_publish_tabs(0x1000, 0x1000, true)); // A new store screen instance publishes again. assert!(should_publish_tabs(0x2000, 0x1000, true)); } #[test] fn tabs_never_publish_before_groups_load() { // Publishing with no groups is what leaves all six tab tokens at -1 and hides // every panel — the defect itself. Never repeat it. assert!(!should_publish_tabs(0x1000, 0, false)); } #[test] fn null_screen_never_publishes() { assert!(!should_publish_tabs(0, 0, true)); } }