//! FIFA 17 store tab-bar repair — pre-warm the purchase groups before screen-show. //! //! # Confirmed root cause (live, 2026-08-19) //! //! `FUN_18007e5e0(ctx, panel)` is the native tab binder the screen framework //! invokes at store screen-show. It is an unrolled six-slot loop; each slot gates //! on one hard-coded category token and either publishes that group's id as //! `PANEL_ID` for the slot, or hides the slot: //! //! ```text //! if (FUN_180014df0(_, idx)) // token present? //! (*(panel_vtbl+0x48))(panel, slot, "PANEL_ID", FUN_180014580(_, idx)); //! else //! (*(panel_vtbl+0xa0))(panel, slot); // hide slot //! ``` //! //! slot -> token, in bind order: `mypacks, bronze, silver, gold, special, points`. //! The gate `FUN_180014df0` resolves the token through `FUN_180014380`, which scans //! the loaded purchase groups (stride `0x108`) comparing the token at `group+0x70`. //! So a tab appears iff a purchase group carrying that token is loaded AT BIND TIME. //! //! The bind detour below measured the ground truth on the retail client: //! //! ```text //! STORE_TABS: bind generation=2 mask=0x00 ... <- empty at screen-show //! STORE_TABS: rebound generation=2 mask=0x0e (...) <- groups present ~instantly after //! ``` //! //! `mask=0x00` at screen-show confirms the container is empty when the framework //! binds, so all six slots hide and no tab bar is built. The store's own //! `GET store/purchasegroup/all` only returns *after* screen-show, so re-entry works //! (groups cached) but first entry does not. (`0x0e` = bronze|silver|gold; bit 0 //! `mypacks` is clear because an empty My Packs serves no `mypacks` group.) //! //! # What did NOT work, and why this module changed //! //! A previous version re-invoked the binder at the next render, once the groups had //! arrived (`rebound ... mask=0x0e` above). The movie built NO tab bar from that //! late bind: the Scaleform movie only honours the framework's OWN bind at //! screen-show, not a later re-publish/commit. That approach is abandoned. //! //! # This module: make the container non-empty BEFORE the first bind //! //! The only publish the movie honours is the framework's bind at screen-show, and //! re-entry proves that bind builds the bar correctly when the container is already //! full. So the fix is to load the purchase groups BEFORE the store screen is shown. //! //! `FUN_180017870(storefront)` issues the store's own `GET store/purchasegroup/all`. //! Firing it from the FUT hub event pump (a real game thread, well before the store //! screen exists) gives the response time to arrive and populate the container, so //! the first screen-show bind sees a full list and binds the tabs natively — exactly //! the re-entry path, on first entry. //! //! The bind detour is retained purely as the SENSOR: the first-entry bind mask is //! the safe, definitive measurement of whether the pre-warm populated the container //! in time. `mask != 0` at first bind ⇒ pre-warm worked and the tabs bind natively; //! `mask == 0` (with `storefront_seen=1` in the pre-warm log) ⇒ a hub-time request //! cannot land in time and the remaining route is the extracted `StoreFront.apt`. //! //! # Fail-closed //! //! * Pre-warm fires at most once per process, claimed atomically, and only once the //! storefront singleton is non-null; the storefront pointer is read through a //! guarded load and the request function's signature is validated before the call. //! * The bind detour only reads (captures pointers, probes the game's own gate with //! a provably-dead `this`) and never mutates store state. //! * Image plus every function signature are verified before any write and again //! under thread suspension; one wrong byte aborts with no write and no call. //! //! # Promotion //! //! PROMOTED: armed by the build, never by an environment variable (see //! [`REPAIR_PROMOTED`]). Rollback is a `version.dll` file swap. use core::ffi::c_void; use core::sync::atomic::{AtomicBool, AtomicU32, 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}; /// Native tab binder `FUN_18007e5e0(ctx, panel)`, invoked by the screen framework /// at screen-show. Detoured as the read-only sensor: captures the gate mask it saw. const BIND_RVA: usize = 0x7e5e0; /// Category gate `FUN_180014df0(dead_this, idx) -> bool`: maps `idx` to one of the /// six hard-coded tokens and reports whether a loaded purchase group carries it. const HAS_CATEGORY_RVA: usize = 0x14df0; /// `FUN_180017870(storefront)` issues `GET store/purchasegroup/all` — the exact call /// the store screen makes at entry (from `0x18007f25e`). Fired early to pre-warm. const REQUEST_GROUPS_RVA: usize = 0x17870; /// `*(base + STOREFRONT_GLOBAL_RVA)` is the storefront the store code passes to its /// request/lookup helpers (loaded at `0x18007f25e`, right before the pack-list GET). const STOREFRONT_GLOBAL_RVA: usize = 0x2de0d0; /// Gate indices in slot order: `mypacks, bronze, silver, gold, special, points`. /// Taken from the binder's unrolled call sequence, not from the index order of /// `FUN_180014580`'s jump table (which is deliberately different). const GATE_INDICES: [u32; 6] = [0, 2, 3, 4, 5, 1]; /// Whole-instruction prologue length relocated into the trampoline; also the number /// of bytes overwritten by the entry detour. 15 bytes, a clean boundary covering the /// 14-byte absolute jump. const COPY_LEN: usize = 15; const ABS_JUMP_LEN: usize = 14; /// First 15 bytes of `FUN_18007e5e0`: `mov [rsp+8],rbx; mov [rsp+0x10],rbp; /// mov [rsp+0x18],rsi` = 5 + 5 + 5. const BIND_SIGNATURE: [u8; COPY_LEN] = [ 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18, ]; /// First 15 bytes of `FUN_180014df0`. Validated before we ever call it, so the gate /// probe only runs on the exact build it was reversed against. const HAS_CATEGORY_SIGNATURE: [u8; 15] = [ 0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x33, 0xdb, 0x44, 0x8b, 0xc3, 0x85, 0xd2, 0x74, 0x35, ]; /// First 18 bytes of `FUN_180017870`. Validated before we ever call it, so the /// pre-warm only fires the genuine request on the exact build it was reversed against. const REQUEST_GROUPS_SIGNATURE: [u8; 18] = [ 0x40, 0x57, 0x48, 0x81, 0xec, 0x90, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff, ]; type BindFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> *mut c_void; type HasCategoryFn = unsafe extern "system" fn(*mut c_void, u32) -> u8; type RequestGroupsFn = unsafe extern "system" fn(*mut c_void) -> usize; /// The tab-bar repair 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 and the thread quiesce all remain in the runtime evidence path. pub(crate) const REPAIR_PROMOTED: bool = true; /// Compile-time contract: the repair stays build-armed. Regressing it to an env gate /// would silently restore the missing first-entry tab bar on a normal launch, so it /// must be a deliberate, visible change here rather than a missing variable. const _: () = assert!(REPAIR_PROMOTED); static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false); static BIND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); static STORE_BASE: AtomicUsize = AtomicUsize::new(0); static BIND_ENTRIES: AtomicU64 = AtomicU64::new(0); /// Gate mask the framework's most recent bind observed (bit N = slot N would bind). static LAST_BIND_MASK: AtomicU32 = AtomicU32::new(0); static LAST_THREAD: AtomicUsize = AtomicUsize::new(0); /// Set once the pre-warm request has been fired (or is provably unnecessary). static PREWARM_DONE: AtomicBool = AtomicBool::new(false); static PREWARM_ATTEMPTS: AtomicU64 = AtomicU64::new(0); /// Highest storefront pointer observed at hub time (0 = never non-null yet). Logged /// so a failed pre-warm can be attributed to "storefront not up at hub" vs "fired /// but the response did not land before screen-show". static PREWARM_STOREFRONT_SEEN: AtomicUsize = AtomicUsize::new(0); /// Pure pre-warm decision, isolated for host tests. /// /// Fire exactly once, and only once the storefront singleton is non-null; before /// that, keep waiting (a null storefront early at the hub is expected). fn should_prewarm(already_done: bool, storefront: usize) -> bool { !already_done && storefront != 0 } /// Probe all six category tokens with the game's own gate and return a slot mask. /// /// `FUN_180014df0` forwards its `this` to `FUN_180014380`, which discards it and /// fetches the group container from a singleton, so a null `this` is exactly what /// the native code effectively passes. Called only from the bind detour, where the /// store subsystem is provably live. unsafe fn gate_mask() -> u8 { let base = STORE_BASE.load(Ordering::Acquire); if base == 0 { return 0; } let Some(gate) = base.checked_add(HAS_CATEGORY_RVA) else { return 0; }; let gate_fn: HasCategoryFn = core::mem::transmute(gate); let mut mask = 0u8; for (slot, index) in GATE_INDICES.iter().enumerate() { if gate_fn(core::ptr::null_mut(), *index) != 0 { mask |= 1 << slot; } } mask } /// Ask the game to load the purchase groups now, on the caller's (game) thread. /// /// Called from the FUT event dispatcher so it runs on a real game thread well before /// the store screen is ever shown — the same thread the store screen itself would use /// for this call at entry. Fail-closed: base/signature/storefront all validated, at /// most one request per process. pub(crate) unsafe fn maybe_prewarm_groups() { if PREWARM_DONE.load(Ordering::Acquire) || !REPAIR_ENABLED.load(Ordering::Acquire) { return; } let base = STORE_BASE.load(Ordering::Acquire); if base == 0 || !crate::sbc_trace::valid_cards_image(base) { return; } let Some(storefront) = base .checked_add(STOREFRONT_GLOBAL_RVA) .and_then(|slot| crate::sbc_trace::guarded_usize(slot)) else { return; }; if storefront != 0 { PREWARM_STOREFRONT_SEEN.store(storefront, Ordering::Release); } if !should_prewarm(false, storefront) { // Storefront not up yet at the hub: keep waiting, do not consume the attempt. return; } let Some(request) = base.checked_add(REQUEST_GROUPS_RVA) else { return; }; if !crate::sbc_trace::executable_range_in_image(base, request, REQUEST_GROUPS_SIGNATURE.len()) || core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len()) != REQUEST_GROUPS_SIGNATURE { return; } // Claim the single attempt before issuing it, so a re-entrant event can never // fire a second request. PREWARM_DONE.store(true, Ordering::Release); PREWARM_ATTEMPTS.fetch_add(1, Ordering::Relaxed); let request_fn: RequestGroupsFn = core::mem::transmute(request); request_fn(storefront as *mut c_void); crate::write_log(&format!( "STORE_TABS: pre-warmed purchase groups at hub (storefront={storefront:#x})\n" )); } 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 the native tab binder. Read-only sensor: records the gate mask /// the framework's bind is about to act on, then runs the original unchanged. This is /// the definitive measurement of whether the pre-warm populated the container in time. unsafe extern "system" fn bind_wrapper(ctx: *mut c_void, panel: *mut c_void) -> *mut c_void { let mask = gate_mask(); LAST_BIND_MASK.store(mask as u32, Ordering::Release); LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed); BIND_ENTRIES.fetch_add(1, Ordering::AcqRel); let original: BindFn = core::mem::transmute(BIND_TRAMPOLINE.load(Ordering::Acquire)); original(ctx, panel) } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum InstallOutcome { Installed, CleanFailure, DegradedHookActive, DegradedProcessState, DegradedHookAndProcess, } unsafe fn install_hook(base: usize) -> InstallOutcome { let Some(bind) = crate::sbc_trace::target_va(base, BIND_RVA) else { return InstallOutcome::CleanFailure; }; let Some(gate) = crate::sbc_trace::target_va(base, HAS_CATEGORY_RVA) else { return InstallOutcome::CleanFailure; }; let Some(request) = crate::sbc_trace::target_va(base, REQUEST_GROUPS_RVA) else { return InstallOutcome::CleanFailure; }; // Fingerprint the image and ALL THREE functions: the one we detour and the two we // call (gate probe, group request). 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, bind, BIND_SIGNATURE.len()) || !crate::sbc_trace::executable_range_in_image(base, gate, HAS_CATEGORY_SIGNATURE.len()) || !crate::sbc_trace::executable_range_in_image( base, request, REQUEST_GROUPS_SIGNATURE.len(), ) || core::slice::from_raw_parts(bind as *const u8, BIND_SIGNATURE.len()) != BIND_SIGNATURE || core::slice::from_raw_parts(gate as *const u8, HAS_CATEGORY_SIGNATURE.len()) != HAS_CATEGORY_SIGNATURE || core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len()) != REQUEST_GROUPS_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, bind as *const u8, &mut pinned, ) == 0 || pinned as usize != base { return InstallOutcome::CleanFailure; } let Some(trampoline) = crate::sbc_trace::allocate_trampoline(bind, COPY_LEN) else { return InstallOutcome::CleanFailure; }; BIND_TRAMPOLINE.store(trampoline, Ordering::Release); STORE_BASE.store(base, Ordering::Release); let Some(_gate_lock) = crate::sbc_trace::acquire_patch_installer_gate() else { VirtualFree(trampoline as _, 0, MEM_RELEASE); BIND_TRAMPOLINE.store(0, Ordering::Release); return InstallOutcome::CleanFailure; }; let mut peers = match crate::sbc_trace::suspend_peers(bind, bind) { Ok(peers) => peers, Err(crate::sbc_trace::QuiesceFailure::Acquire) => { VirtualFree(trampoline as _, 0, MEM_RELEASE); BIND_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(bind as *const u8, BIND_SIGNATURE.len()) == BIND_SIGNATURE; let transaction = if !final_valid { InstallOutcome::CleanFailure } else { match write_entry(bind, bind_wrapper as *const () as usize, &BIND_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); BIND_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_TABS: bind sensor + pre-warm installed (promoted)\n") } InstallOutcome::CleanFailure => { crate::write_log("STORE_TABS: clean install failure; inactive\n"); return; } InstallOutcome::DegradedHookActive => { crate::write_log("STORE_TABS: DEGRADED hook may be active; terminate game now\n"); return; } InstallOutcome::DegradedProcessState => { crate::write_log("STORE_TABS: DEGRADED thread state; terminate game now\n"); return; } InstallOutcome::DegradedHookAndProcess => { crate::write_log("STORE_TABS: DEGRADED hook and thread state; terminate game now\n"); return; } } let mut binds_seen = 0u64; let mut reports = 0u8; while reports < 64 { std::thread::sleep(std::time::Duration::from_millis(250)); let binds = BIND_ENTRIES.load(Ordering::Acquire); if binds != binds_seen { crate::write_log(&format!( "STORE_TABS: bind generation={} mask={:#04x} prewarm_fired={} storefront_seen={:#x} tid={}\n", binds, LAST_BIND_MASK.load(Ordering::Acquire), PREWARM_ATTEMPTS.load(Ordering::Acquire), PREWARM_STOREFRONT_SEEN.load(Ordering::Acquire), LAST_THREAD.load(Ordering::Relaxed), )); binds_seen = binds; reports += 1; } } crate::write_log("STORE_TABS: report cap reached; hook remains installed\n"); } pub(crate) fn install() { // Promoted: armed by the build. No environment variable participates. REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release); crate::write_log("STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n"); std::thread::spawn(|| unsafe { worker() }); } #[cfg(test)] mod tests { use super::*; #[test] fn gate_indices_match_the_native_slot_order() { // mypacks, bronze, silver, gold, special, points — the order FUN_18007e5e0 // tests them in, which is NOT the index order of FUN_180014580's jump table. assert_eq!(GATE_INDICES, [0, 2, 3, 4, 5, 1]); } #[test] fn prewarms_once_the_storefront_is_up() { assert!(should_prewarm(false, 0x1000)); } #[test] fn waits_while_the_storefront_is_still_null() { assert!(!should_prewarm(false, 0)); } #[test] fn never_prewarms_twice() { assert!(!should_prewarm(true, 0x1000)); } #[test] fn detour_signature_is_long_enough_for_the_absolute_jump() { assert!(BIND_SIGNATURE.len() >= ABS_JUMP_LEN); assert_eq!(COPY_LEN, BIND_SIGNATURE.len()); } #[test] fn request_signature_covers_the_validated_prologue() { // 18 bytes: `push rdi; sub rsp,0x90; movq [rsp+0x20],-2`. assert_eq!(REQUEST_GROUPS_SIGNATURE.len(), 18); } }