//! FIFA 17 SBC render intervention (feature = "fifa17"). //! //! Makes the FUT **SBC menu render real data** from inside the process. Full spec //! (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: //! 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_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 //! defer off the loader lock and poll for it in a background thread. //! //! ── Address model (static VAs; PE image base 0x180000000) ──────────────────────── //! All values below are RVAs (VA_static - 0x180000000); live = cards_base + rva. //! See the spec for the verified disassembly behind each one. use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; use windows_sys::Win32::System::Memory::{ VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY, }; // ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ──────────── const IMAGE_BASE: usize = 0x180000000; /// FNV prologue used as the slide-proof control (must match the on-disk PE bytes). const CTRL_RVA: usize = 0x180d00; // VA 0x180180d00 const CTRL_BYTES: &[u8] = &[ 0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0, ]; const A_SLOT_RVA: usize = 0x2e6398; // *(0x1802e6398) = A (FUT root singleton) const A_VTABLE_RVA: usize = 0x21c2a0; const B_OFF: usize = 0x1f9d8; // B = A + 0x1f9d8 (SBC request/ready TTL cache) const B_VTABLE_RVA: usize = 0x1fae70; 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 B_DTOR_RVA: usize = 0x63040; const B_ISVALID_RVA: usize = 0x65d40; const B_CLEAR_RVA: usize = 0x65d20; const B_READY_EXPECTED_BEFORE_ARM: u8 = 0; #[allow(dead_code)] const AVT_M_GETTER: usize = 0x9b0; // A.vtable[+0x9b0] = 0x18011b7d0 (M lazy getter) #[allow(dead_code)] const AVT_B_GETTER: usize = 0x4e8; // A.vtable[+0x4e8] = 0x18011c1f0 (B getter thunk) // Callable RVAs (for the Tier-1 populate sequence — see spec §6/§8). Kept for // reference/wiring; not invoked while Tier-1 is blocked. #[allow(dead_code)] mod rva { pub const M_LAZY_GETTER: usize = 0x11b7d0; pub const ISVALID: usize = 0x65d40; pub const DESER_SBS_SETS: usize = 0x17b2b0; pub const SAX_CTX_INIT: usize = 0x1c63e0; pub const REGISTRY_GETTER: usize = 0xd7170; pub const MANAGER_GETTER: usize = 0x9c80; pub const CLEAR_M: usize = 0x15f3a0; pub const CAT_CTOR: usize = 0x159da0; pub const CAT_DESER: usize = 0x17ab80; pub const CAT_FINALIZE: usize = 0x160e50; pub const APPEND: usize = 0x15a770; pub const CAT_DTOR: usize = 0x1105d0; pub const IDX_REBUILD_1: usize = 0x160e00; pub const IDX_REBUILD_2: usize = 0x160f30; pub const IDX_REBUILD_3: usize = 0x161020; pub const REFRESH_DISPATCH: usize = 0x1a4a70; // Scaleform events 0x756c-0x7574 } static ARMED: AtomicBool = AtomicBool::new(false); static ARM_ONLY: AtomicBool = AtomicBool::new(false); static POPULATE: AtomicBool = AtomicBool::new(false); static DONE: AtomicBool = AtomicBool::new(false); static CARDS_BASE: AtomicUsize = AtomicUsize::new(0); static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize); // Full SBC state model. The live repair jumps Resolved -> Validated -> Committed; // Intercepted/Parsed document the intermediate states but are never entered. #[allow(dead_code)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(usize)] enum RuntimeState { Disabled, Resolved, Intercepted, Parsed, Validated, Committed, Failed, } fn valid_transition(from: RuntimeState, to: RuntimeState) -> bool { matches!( (from, to), (RuntimeState::Disabled, RuntimeState::Resolved) | (RuntimeState::Resolved, RuntimeState::Intercepted) | (RuntimeState::Intercepted, RuntimeState::Parsed) | (RuntimeState::Parsed, RuntimeState::Validated) // Resolve-only/Tier-0 validates without installing an interceptor. | (RuntimeState::Resolved, RuntimeState::Validated) | (RuntimeState::Validated, RuntimeState::Committed) | (_, RuntimeState::Failed) ) } fn transition(from: RuntimeState, to: RuntimeState) -> bool { valid_transition(from, to) && STATE .compare_exchange( from as usize, to as usize, Ordering::AcqRel, Ordering::Acquire, ) .is_ok() } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ValidationError { AddressOverflow, AUnreadable, AVtableMismatch, AGetterMismatch, BVtableMismatch, BDtorMismatch, BIsValidMismatch, BClearMismatch, MSlotUnreadable, ReadyByteUnexpected, CollectionUnreadable, CollectionNotNull, ReadyByteNotWritable, } #[derive(Clone, Copy, Debug)] struct RuntimeSnapshot { a: usize, a_vtable: usize, a_b_getter: usize, b: usize, b_vtable: usize, b_dtor: usize, b_isvalid: usize, b_clear: usize, b_ready: u8, b_coll: usize, m: usize, } fn expected_va(base: usize, rva: usize) -> Result { base.checked_add(rva) .ok_or(ValidationError::AddressOverflow) } fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationError> { if s.a == 0 || s.b != s.a .checked_add(B_OFF) .ok_or(ValidationError::AddressOverflow)? { return Err(ValidationError::AUnreadable); } if s.a_vtable != expected_va(base, A_VTABLE_RVA)? { return Err(ValidationError::AVtableMismatch); } if s.a_b_getter != expected_va(base, 0x11c1f0)? { return Err(ValidationError::AGetterMismatch); } if s.b_vtable != expected_va(base, B_VTABLE_RVA)? { return Err(ValidationError::BVtableMismatch); } if s.b_dtor != expected_va(base, B_DTOR_RVA)? { return Err(ValidationError::BDtorMismatch); } if s.b_isvalid != expected_va(base, B_ISVALID_RVA)? { return Err(ValidationError::BIsValidMismatch); } if s.b_clear != expected_va(base, B_CLEAR_RVA)? { return Err(ValidationError::BClearMismatch); } if s.b_ready != B_READY_EXPECTED_BEFORE_ARM { return Err(ValidationError::ReadyByteUnexpected); } if s.b_coll != 0 { return Err(ValidationError::CollectionNotNull); } let _ = s.m; // The guarded snapshot read proves the M slot itself is readable. Ok(()) } /// Fault-safe pointer read: returns None unless `ptr` lands /// in a committed, readable page and the full 8 bytes fit inside the region. unsafe fn read_ptr(ptr: usize) -> Option { if ptr < 0x10000 || ptr & 7 != 0 { return None; } let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); let n = VirtualQuery( ptr as _, &mut mbi, core::mem::size_of::(), ); if n == 0 || mbi.State != MEM_COMMIT { return None; } if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { return None; } if ptr + 8 > mbi.BaseAddress as usize + mbi.RegionSize { return None; } Some(core::ptr::read_volatile(ptr as *const usize)) } /// Guarded byte read. unsafe fn read_u8(ptr: usize) -> Option { if ptr < 0x10000 { return None; } let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); let n = VirtualQuery( ptr as _, &mut mbi, core::mem::size_of::(), ); if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { return None; } if ptr + 1 > mbi.BaseAddress as usize + mbi.RegionSize { return None; } Some(core::ptr::read_volatile(ptr as *const u8)) } /// A Tier-0 write is allowed only when the complete byte lies in a committed, /// non-guarded region whose current protection explicitly permits writes. unsafe fn writable_u8(ptr: usize) -> bool { if ptr < 0x10000 { return false; } let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); let n = VirtualQuery( ptr as _, &mut mbi, core::mem::size_of::(), ); if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { return false; } let protection = mbi.Protect & 0xff; let writable = matches!( protection, PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY ); writable && ptr .checked_add(1) .is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)) } // Fault-safe executable-range check retained with the address model; not currently wired. #[allow(dead_code)] unsafe fn executable_range(ptr: usize, len: usize) -> bool { let Some(end) = ptr.checked_add(len) else { return false; }; let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); let n = VirtualQuery( ptr as _, &mut mbi, core::mem::size_of::(), ); if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { return false; } let protection = mbi.Protect & 0xff; matches!( protection, PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY ) && end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize) } /// Guarded 16-bit read (M category count is a WORD). unsafe fn read_u16(ptr: usize) -> Option { let lo = read_u8(ptr)? as u16; let hi = read_u8(ptr + 1)? as u16; Some(lo | (hi << 8)) } /// Resolve CardsDLL's runtime base, or 0. Tries the exact loaded name; the ToolHelp /// fallback (name-contains "CardsDLL") lives in the spec — add it if EA ever renames. unsafe fn resolve_cards_base() -> usize { let h = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()); if !h.is_null() { return h as usize; } // Also try the short form some tooling reports. let h2 = GetModuleHandleA(c"CardsDLL.dll".as_ptr().cast()); if !h2.is_null() { return h2 as usize; } 0 } #[inline] fn va(base: usize, rva: usize) -> usize { base + rva } /// Prove the module didn't move: the FNV control prologue must match the on-disk PE. unsafe fn control_matches(base: usize) -> bool { let p = va(base, CTRL_RVA); for (i, &want) in CTRL_BYTES.iter().enumerate() { match read_u8(p + i) { Some(got) if got == want => {} _ => return false, } } true } /// Take one guarded identity snapshot. A failure to read any identity-bearing field is /// distinct from a value mismatch and aborts before mutation. unsafe fn runtime_snapshot(base: usize) -> Result { let a_slot = expected_va(base, A_SLOT_RVA)?; let a = read_ptr(a_slot) .filter(|&value| value != 0) .ok_or(ValidationError::AUnreadable)?; let a_vtable = read_ptr(a).ok_or(ValidationError::AVtableMismatch)?; let a_b_getter = read_ptr( a_vtable .checked_add(AVT_B_GETTER) .ok_or(ValidationError::AddressOverflow)?, ) .ok_or(ValidationError::AGetterMismatch)?; let b = a .checked_add(B_OFF) .ok_or(ValidationError::AddressOverflow)?; let b_vtable = read_ptr(b).ok_or(ValidationError::BVtableMismatch)?; let b_dtor = read_ptr(b_vtable).ok_or(ValidationError::BDtorMismatch)?; let b_isvalid = read_ptr( b_vtable .checked_add(8) .ok_or(ValidationError::AddressOverflow)?, ) .ok_or(ValidationError::BIsValidMismatch)?; let b_clear = read_ptr( b_vtable .checked_add(16) .ok_or(ValidationError::AddressOverflow)?, ) .ok_or(ValidationError::BClearMismatch)?; let b_ready = read_u8( b.checked_add(B_READY_OFF) .ok_or(ValidationError::AddressOverflow)?, ) .ok_or(ValidationError::ReadyByteUnexpected)?; let b_coll = read_ptr( b.checked_add(B_COLL_OFF) .ok_or(ValidationError::AddressOverflow)?, ) .ok_or(ValidationError::CollectionUnreadable)?; let m = read_ptr( a.checked_add(M_CACHE_OFF) .ok_or(ValidationError::AddressOverflow)?, ) .ok_or(ValidationError::MSlotUnreadable)?; Ok(RuntimeSnapshot { a, a_vtable, a_b_getter, b, b_vtable, b_dtor, b_isvalid, b_clear, b_ready, b_coll, m, }) } fn set_failed(error: ValidationError) { STATE.store(RuntimeState::Failed as usize, Ordering::Release); crate::write_log(&format!( "SBC_HOOK: runtime validation FAILED: {error:?} -- no write\n" )); } /// Public entry: called from `fifa17::install`. Spawns the deferred worker if /// OPENFUT_SBC_HOOK=1; otherwise logs "disabled" and returns (fully inert). pub fn install() { let armed = std::env::var("OPENFUT_SBC_HOOK") .map(|v| v == "1") .unwrap_or(false); ARMED.store(armed, Ordering::Relaxed); if !armed { STATE.store(RuntimeState::Disabled as usize, Ordering::Relaxed); crate::write_log("SBC_HOOK: disabled (set OPENFUT_SBC_HOOK=1 to enable)\n"); return; } ARM_ONLY.store( std::env::var("OPENFUT_SBC_ARM_ONLY") .map(|v| v == "1") .unwrap_or(false), Ordering::Relaxed, ); POPULATE.store( std::env::var("OPENFUT_SBC_POPULATE") .map(|v| v == "1") .unwrap_or(false), Ordering::Relaxed, ); crate::write_log("SBC_HOOK: ARMED (deferred worker spawning)\n"); std::thread::spawn(|| unsafe { worker() }); } /// 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. unsafe fn worker() { let mut base = 0usize; for _ in 0..600u32 { base = resolve_cards_base(); if base != 0 { break; } std::thread::sleep(std::time::Duration::from_millis(500)); } if base == 0 { crate::write_log("SBC_HOOK: CardsDLL_Win64_retail.dll never loaded — giving up\n"); return; } CARDS_BASE.store(base, Ordering::Relaxed); let slide = base.wrapping_sub(IMAGE_BASE); let ctrl_ok = control_matches(base); crate::write_log(&format!( "SBC_HOOK: CardsDLL base={base:#x} slide={slide:#x} CONTROL={}\n", if ctrl_ok { "OK" } else { "MISMATCH-ABORT" } )); if !ctrl_ok { STATE.store(RuntimeState::Failed as usize, Ordering::Release); return; // module map moved -> offsets untrustworthy (spec §1) } if !transition(RuntimeState::Disabled, RuntimeState::Resolved) { crate::write_log("SBC_HOOK: invalid state transition to Resolved -- no write\n"); STATE.store(RuntimeState::Failed as usize, Ordering::Release); return; } // Resolve and validate A -> B, M. The vtable method checks make it substantially // harder for a coincidental heap pointer to pass after a binary/layout mismatch. let snapshot = match runtime_snapshot(base).and_then(|snapshot| { validate_snapshot(base, &snapshot)?; Ok(snapshot) }) { Ok(snapshot) => snapshot, Err(error) => { set_failed(error); return; } }; if !transition(RuntimeState::Resolved, RuntimeState::Validated) { crate::write_log("SBC_HOOK: invalid state transition to Validated -- no write\n"); STATE.store(RuntimeState::Failed as usize, Ordering::Release); return; } let a = snapshot.a; let b = snapshot.b; let m = snapshot.m; let m_count = (m != 0).then(|| read_u16(m + M_COUNT_OFF)).flatten(); crate::write_log(&format!( "SBC_HOOK: A={a:#x} B={b:#x} B+0x28(ready)={:?} B+0x08(coll)={:?} M=*(A+0x20a68)={:?} WORD[M+0x50]={:?}\n", Some(snapshot.b_ready), opt_hex(Some(snapshot.b_coll)), opt_hex(Some(m)), m_count, )); // Tier-0 — arm-only negative control. Write ONLY BYTE[B+0x28]=1; leave B+0x08=0 so // isValid takes the short-circuit (spec §4). Renders the menu EMPTY (M null/empty) — // this is the baseline, NOT the fix. One-shot. if ARM_ONLY.load(Ordering::Relaxed) { if DONE.swap(true, Ordering::Relaxed) { return; } // Re-snapshot immediately before mutation to reduce the time-of-check/time-of-use // window. In particular, the exact patch byte must still be 0 and B+0x08 null. let write_snapshot = match runtime_snapshot(base).and_then(|snapshot| { validate_snapshot(base, &snapshot)?; if !writable_u8(snapshot.b + B_READY_OFF) { return Err(ValidationError::ReadyByteNotWritable); } Ok(snapshot) }) { Ok(snapshot) => snapshot, Err(error) => { set_failed(error); return; } }; crate::write_log(&format!( "SBC_HOOK: Tier-0 arm-only -> writing BYTE[{:#x}]=1 (expect EMPTY render, no modal)\n", write_snapshot.b + B_READY_OFF )); core::ptr::write_volatile((write_snapshot.b + B_READY_OFF) as *mut u8, 1u8); match read_u8(write_snapshot.b + B_READY_OFF) { Some(1) if transition(RuntimeState::Validated, RuntimeState::Committed) => {} _ => { set_failed(ValidationError::ReadyByteUnexpected); return; } } crate::write_log( "SBC_HOOK: Tier-0 arm-only DONE (open the SBC menu; ~2 placeholder tiles expected)\n", ); return; } // Legacy Tier-1 gate — deliberately blocked. The fresh live exchange proves FIFA // already owns a real response and SAX reader for /sbs/sets. The next milestone is // passive tracing of the native response-to-deserializer dispatch, not construction // of a reader. Cold-calling with a fabricated reader would CLEAR M and/or segfault. if POPULATE.load(Ordering::Relaxed) { crate::write_log( "SBC_HOOK: legacy Tier-1 populate is BLOCKED — capture the genuine response \ and reader at the native dispatch boundary first (see client-hook plan M3/M4). \ No deser call made; fabricated readers can clear M or crash.\n", ); } } fn opt_hex(o: Option) -> String { match o { Some(v) => format!("{v:#x}"), None => "".to_string(), } } /// Legacy Tier-1 scaffold. **Never call this with a fabricated reader.** The intended /// implementation is now a guarded synchronous dispatch repair that borrows the genuine /// response and reader from the real HTTP transaction on its native thread. /// /// Sequence once `reader` (a primed SAX reader over canned sbs/sets JSON) exists: /// let base = CARDS_BASE.load(Relaxed); /// let deser: unsafe extern "system" fn(*mut u8, *mut u8) -> bool = /// transmute(va(base, rva::DESER_SBS_SETS)); /// deser(core::ptr::null_mut(), reader); // self-locates mgr, clears+appends+finalizes+commits M /// // then Tier-0 arm: BYTE[B+0x28]=1, leave B+0x08=0 /// // then refresh so 0x1800b5eda re-reads WORD[M+0x50] #[allow(dead_code)] unsafe fn populate_m(_reader: *mut u8) { // Intentionally unimplemented: the passive trace must prove the response/reader // ownership and exact virtual-dispatch boundary before any parser call is enabled. unreachable!( "populate_m requires a proven native dispatch contract; see client-hook plan M3/M4" ); } #[cfg(test)] mod tests { use super::*; fn valid_snapshot(base: usize) -> RuntimeSnapshot { let a = 0x1000_0000usize; RuntimeSnapshot { a, a_vtable: base + A_VTABLE_RVA, a_b_getter: base + 0x11c1f0, b: a + B_OFF, b_vtable: base + B_VTABLE_RVA, b_dtor: base + B_DTOR_RVA, b_isvalid: base + B_ISVALID_RVA, b_clear: base + B_CLEAR_RVA, b_ready: B_READY_EXPECTED_BEFORE_ARM, b_coll: 0, m: 0, } } #[test] fn accepts_exact_runtime_identity_with_null_uninitialized_m() { let base = 0x7fff_0000_0000usize; assert_eq!(validate_snapshot(base, &valid_snapshot(base)), Ok(())); } #[test] fn rejects_wrong_a_or_b_class_identity() { let base = 0x7fff_0000_0000usize; let mut snapshot = valid_snapshot(base); snapshot.a_vtable += 8; assert_eq!( validate_snapshot(base, &snapshot), Err(ValidationError::AVtableMismatch) ); let mut snapshot = valid_snapshot(base); snapshot.b_vtable += 8; assert_eq!( validate_snapshot(base, &snapshot), Err(ValidationError::BVtableMismatch) ); } #[test] fn rejects_changed_patch_byte_or_live_collection() { let base = 0x7fff_0000_0000usize; let mut snapshot = valid_snapshot(base); snapshot.b_ready = 1; assert_eq!( validate_snapshot(base, &snapshot), Err(ValidationError::ReadyByteUnexpected) ); let mut snapshot = valid_snapshot(base); snapshot.b_coll = 0x1234_0000; assert_eq!( validate_snapshot(base, &snapshot), Err(ValidationError::CollectionNotNull) ); } #[test] fn state_machine_is_forward_only_and_fail_closed() { assert!(valid_transition( RuntimeState::Disabled, RuntimeState::Resolved )); assert!(valid_transition( RuntimeState::Resolved, RuntimeState::Validated )); assert!(valid_transition( RuntimeState::Validated, RuntimeState::Committed )); assert!(valid_transition(RuntimeState::Parsed, RuntimeState::Failed)); assert!(!valid_transition( RuntimeState::Validated, RuntimeState::Resolved )); assert!(!valid_transition( RuntimeState::Failed, RuntimeState::Resolved )); assert!(!valid_transition( RuntimeState::Resolved, RuntimeState::Committed )); } }