//! Passive, behavior-preserving diagnostic traces for FIFA 17's offline-season //! entry flow. //! //! RE (2026-08-19, live memory) placed the "problem communicating with the FIFA //! Ultimate Team servers" modal in the `futOfflineSeasonEntry` ActionScript's //! season-load path. A first trace on the load completion `FUN_1800578e0` //! (`0x578e0`) armed but NEVER fired on an entry attempt — so the modal is raised //! before that callback runs. These traces log the actual CardsDLL season-native //! call sequence (which functions the entry screen reaches, and in what order) so //! we can see exactly where the flow stops/fails. Every trace is read-only: it //! logs, then calls the original through a trampoline; it never alters control //! flow. Targets are chosen so their copied prologues are position-independent //! (no rip-relative / rel32 in the copied bytes). use core::sync::atomic::{AtomicUsize, Ordering}; use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; use windows_sys::Win32::System::Memory::{ VirtualAlloc, VirtualProtect, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_READWRITE, }; use windows_sys::Win32::System::Threading::GetCurrentProcess; use crate::sbc_trace::{ absolute_jump, allocate_trampoline, readable_range, target_va, validate_cards_build, }; use crate::write_log; static REPORTS: AtomicUsize = AtomicUsize::new(0); unsafe fn rd_i32(addr: usize) -> Option { readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32)) } unsafe fn rd_u8(addr: usize) -> Option { readable_range(addr, 1).then(|| core::ptr::read_volatile(addr as *const u8)) } /// Read a NUL-terminated string safely (bounded, only reads mapped bytes). unsafe fn rd_cstr(addr: usize, max: usize) -> String { if addr == 0 || !readable_range(addr, 1) { return String::from(""); } let mut out = Vec::new(); let mut i = 0; while i < max && readable_range(addr + i, 1) { let b = core::ptr::read_volatile((addr + i) as *const u8); if b == 0 { break; } out.push(b); i += 1; } String::from_utf8_lossy(&out).into_owned() } /// Generic passive detour: overwrite the first `copy_len` bytes of `target` (which /// MUST be whole, position-independent instructions) with an absolute jump to /// `wrapper`; the wrapper calls the trampoline (copied prologue + jump back). unsafe fn install_detour( base: usize, rva: usize, name: &str, copy_len: usize, signature: &[u8], wrapper: usize, trampoline_slot: &AtomicUsize, ) -> bool { let Some(target) = target_va(base, rva) else { write_log(&format!("SEASON_TRACE: {name}: VA overflow\n")); return false; }; if !readable_range(target, copy_len) || core::slice::from_raw_parts(target as *const u8, copy_len) != signature { write_log(&format!( "SEASON_TRACE: {name}: prologue signature mismatch at {target:#x}; skip\n" )); return false; } let Some(trampoline) = allocate_trampoline(target, copy_len) else { write_log(&format!("SEASON_TRACE: {name}: trampoline alloc failed\n")); return false; }; trampoline_slot.store(trampoline, Ordering::Release); let mut patch = [0x90u8; 24]; let jump = absolute_jump(wrapper); patch[..jump.len()].copy_from_slice(&jump); let mut old = 0u32; if VirtualProtect(target as _, copy_len, PAGE_EXECUTE_READWRITE, &mut old) == 0 { write_log(&format!("SEASON_TRACE: {name}: VirtualProtect failed\n")); return false; } core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, copy_len); let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, copy_len) != 0; let mut ignored = 0u32; VirtualProtect(target as _, copy_len, old, &mut ignored); if flushed { write_log(&format!( "SEASON_TRACE: {name}: installed at {target:#x} (tramp {trampoline:#x})\n" )); true } else { write_log(&format!("SEASON_TRACE: {name}: flush failed\n")); false } } fn log_call(name: &str, rcx: usize, rdx: usize, r8: usize) { let n = REPORTS.fetch_add(1, Ordering::Relaxed); if n < 64 { write_log(&format!( "SEASON_CALL: {name} rcx={rcx:#x} rdx={rdx:#x} r8={r8:#x}\n" )); } } /// Declare a passive 4-register-arg call trace. The wrapper is entered via the /// abs-jump patched over the target prologue (original args in rcx/rdx/r8/r9, /// caller's return address on the stack), logs, then tail-calls the original via /// the trampoline. A 4-arg/usize-return signature safely covers these season /// natives (<=4 integer args, void/int returns). macro_rules! season_call_trace { ($wrap:ident, $tramp:ident, $name:literal) => { static $tramp: AtomicUsize = AtomicUsize::new(0); unsafe extern "system" fn $wrap(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize { log_call($name, rcx, rdx, r8); let t = $tramp.load(Ordering::Acquire); if t == 0 { return 0; } let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize = core::mem::transmute(t); original(rcx, rdx, r8, r9) } }; } season_call_trace!(load_current_native_wrapper, LOAD_CURRENT_NATIVE_TRAMP, "LoadCurrentOfflineSeason_native"); season_call_trace!(start_season_native_wrapper, START_SEASON_NATIVE_TRAMP, "StartSeason_native"); season_call_trace!(get_info_native_wrapper, GET_INFO_NATIVE_TRAMP, "GetOfflineSeasonInfo_native"); // Real LoadOfflineSeasons native (FUN_18004ee10) — what _LoadCurrentSeason // actually calls; hands the callback name to the manager's async slot 0x80. season_call_trace!(load_offline_real_wrapper, LOAD_OFFLINE_REAL_TRAMP, "LoadOfflineSeasons_native(0x4ee10)"); // Async LoadOfflineSeasons impl (mgr slot 0x80, FUN_180057560): reads the season // count and invokes the LoadSeasons_Complete AS callback. season_call_trace!(load_offline_async_wrapper, LOAD_OFFLINE_ASYNC_TRAMP, "LoadOfflineSeasons_asyncimpl(0x57560)"); // LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks // and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId // string ptr. Logs those, then calls the original. static LOAD_CURRENT_IMPL_TRAMP: AtomicUsize = AtomicUsize::new(0); unsafe extern "system" fn load_current_impl_wrapper( param_1: usize, param_2: usize, param_3: usize, param_4: usize, ) -> usize { let n = REPORTS.fetch_add(1, Ordering::Relaxed); if n < 64 { // param_3 -> C string season id (best-effort read of first bytes). let sid = if param_3 != 0 && readable_range(param_3, 8) { let p = *(param_3 as *const usize); if p != 0 && readable_range(p, 8) { *(p as *const u64) } else { 0 } } else { 0 }; write_log(&format!( "SEASON_CALL: LoadCurrentOfflineSeason_impl mgr={param_1:#x} stateByte={param_2:#x} sidPtr={param_3:#x} sidHead={sid:#x}\n" )); } let t = LOAD_CURRENT_IMPL_TRAMP.load(Ordering::Acquire); if t == 0 { return 0; } let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize = core::mem::transmute(t); original(param_1, param_2, param_3, param_4) } // Completion callback FUN_1800578e0 (0x578e0). Kept from the first pass to confirm // whether it ever fires; logs the result fields it branches on. static COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0); unsafe extern "system" fn completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize { let n = REPORTS.fetch_add(1, Ordering::Relaxed); if n < 64 { let status = rd_i32(result + 0x1c); let state = rd_u8(result + 0x68); let season_id = rd_i32(result + 0x5c); write_log(&format!( "SEASON_LOAD_COMPLETE: ctx={ctx:#x} result={result:#x} status(+0x1c)={} state(+0x68)={} seasonId(+0x5c)={}\n", status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()), state.map(|x| x.to_string()).unwrap_or_else(|| "??".into()), season_id.map(|x| x.to_string()).unwrap_or_else(|| "??".into()), )); } let t = COMPLETION_TRAMP.load(Ordering::Acquire); if t == 0 { return 0; } let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize = core::mem::transmute(t); original(ctx, result, r8, r9) } // GetUsersOfflineDivision native FUN_18004eb50 (registration FUN_18004e3f0 proved // this binding — it is NOT LoadOfflineSeasons). Called from _InitializeScreen for // the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`, // so it needs the relocating installer below. season_call_trace!(get_users_division_wrapper, GET_USERS_DIVISION_TRAMP, "GetUsersOfflineDivision_native(0x4eb50)"); /// Find a free page within ~±1.5 GiB of `base`, so a rip-relative disp32 into /// CardsDLL data still fits after we relocate a copied prologue into it. unsafe fn alloc_near(base: usize, size: usize) -> Option { const GRAN: usize = 0x10000; let mut step = GRAN; while step < 0x6000_0000 { for signed in [step as isize, -(step as isize)] { let cand = base.wrapping_add(signed as usize) & !(GRAN - 1); if cand == 0 { continue; } let p = VirtualAlloc(cand as _, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if !p.is_null() { return Some(p as usize); } } step += GRAN; } None } /// Passive detour for a target whose copied prologue contains a single /// rip-relative operand (disp32 at `disp_off`, instruction ending at `insn_end`, /// both within the copied bytes). The trampoline is allocated near `base` and the /// disp32 is relocated so it resolves to the same absolute address. Read-only. #[allow(clippy::too_many_arguments)] unsafe fn install_detour_reloc( base: usize, rva: usize, name: &str, copy_len: usize, signature: &[u8], disp_off: usize, insn_end: usize, wrapper: usize, trampoline_slot: &AtomicUsize, ) -> bool { let Some(target) = target_va(base, rva) else { write_log(&format!("SEASON_TRACE: {name}: VA overflow\n")); return false; }; if !readable_range(target, copy_len) || core::slice::from_raw_parts(target as *const u8, copy_len) != signature { write_log(&format!( "SEASON_TRACE: {name}: prologue signature mismatch at {target:#x}; skip\n" )); return false; } let jump = absolute_jump(wrapper); let tramp_len = copy_len + jump.len(); let Some(tramp) = alloc_near(base, tramp_len) else { write_log(&format!("SEASON_TRACE: {name}: near trampoline alloc failed\n")); return false; }; core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len); // Relocate the rip-relative disp32 to keep the same absolute target. let orig_disp = core::ptr::read_unaligned((target + disp_off) as *const i32) as i64; let abs_target = target as i64 + insn_end as i64 + orig_disp; let new_disp = abs_target - (tramp as i64 + insn_end as i64); if new_disp < i32::MIN as i64 || new_disp > i32::MAX as i64 { write_log(&format!("SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n")); return false; } core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32); let back = absolute_jump(target + copy_len); core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len()); let mut old = 0u32; if VirtualProtect(tramp as _, tramp_len, PAGE_EXECUTE_READ, &mut old) == 0 { write_log(&format!("SEASON_TRACE: {name}: trampoline protect failed\n")); return false; } FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len); trampoline_slot.store(tramp, Ordering::Release); let mut patch = [0x90u8; 24]; patch[..jump.len()].copy_from_slice(&jump); let mut prot = 0u32; if VirtualProtect(target as _, copy_len, PAGE_EXECUTE_READWRITE, &mut prot) == 0 { write_log(&format!("SEASON_TRACE: {name}: VirtualProtect failed\n")); return false; } core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, copy_len); let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, copy_len) != 0; let mut ignored = 0u32; VirtualProtect(target as _, copy_len, prot, &mut ignored); if flushed { write_log(&format!( "SEASON_TRACE: {name}: installed(reloc) at {target:#x} (tramp {tramp:#x})\n" )); true } else { write_log(&format!("SEASON_TRACE: {name}: flush failed\n")); false } } // FutCompetitionServiceImpl::LoadOfflineSeasons FINAL completion (FUN_1800ffe90): // delivers the result to the AS callback LoadSeasons_Complete via // FUN_18019fb30->slot0x20(vm,"_global",cbref, "SUCCESS" | errString). param_1 = the // completion ctx (cbref at +0x18), param_2 = result obj (byte0=ok flag; +8 = error // string ptr when byte0==0). Logs the EXACT status string delivered. Passive. static FINAL_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0); unsafe extern "system" fn final_completion_wrapper(ctx: usize, result: usize, r8: usize, r9: usize) -> usize { let n = REPORTS.fetch_add(1, Ordering::Relaxed); if n < 64 { let flag = rd_u8(result); let (kind, s) = match flag { Some(0) => { let p = if readable_range(result + 8, 8) { core::ptr::read_volatile((result + 8) as *const usize) } else { 0 }; ("ERROR", rd_cstr(p, 96)) } Some(_) => ("SUCCESS", String::from("SUCCESS")), None => ("??", String::from("")), }; let cbref = if readable_range(ctx + 0x18, 8) { core::ptr::read_volatile((ctx + 0x18) as *const usize) } else { 0 }; write_log(&format!( "SEASONS_LOAD_CALLBACK: final kind={kind} result={s:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n" )); } let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire); if t == 0 { return 0; } let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize = core::mem::transmute(t); original(ctx, result, r8, r9) } // LoadOfflineSeasons STAGE-1 async completion (FUN_180106240): fails with // "CACHE_PACKNAMES_FAILED" when result==0 or *(i32)(result+0x1c)!=0; else chains // the next async stage. Logs whether the first async stage succeeded. Passive. static STAGE1_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0); unsafe extern "system" fn stage1_completion_wrapper(param1: usize, result: usize, r8: usize, r9: usize) -> usize { let n = REPORTS.fetch_add(1, Ordering::Relaxed); if n < 64 { if result == 0 { write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n"); } else { let status = rd_i32(result + 0x1c); let verdict = if status == Some(0) { "ok(chain next)" } else { "CACHE_PACKNAMES_FAILED" }; write_log(&format!( "SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n", status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()), )); } } let t = STAGE1_COMPLETION_TRAMP.load(Ordering::Acquire); if t == 0 { return 0; } let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize = core::mem::transmute(t); original(param1, result, r8, r9) } unsafe fn worker() { 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)); } if base == 0 || !validate_cards_build(base) { write_log("SEASON_TRACE: CardsDLL unavailable/invalid; season trace inactive\n"); return; } // (rva, name, copy_len, signature, wrapper, trampoline slot) install_detour( base, 0x4eb70, "LoadCurrentOfflineSeason_native", 15, &[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff], load_current_native_wrapper as *const () as usize, &LOAD_CURRENT_NATIVE_TRAMP, ); install_detour( base, 0x4f340, "StartSeason_native", 15, &[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff], start_season_native_wrapper as *const () as usize, &START_SEASON_NATIVE_TRAMP, ); install_detour( base, 0x4e850, "GetOfflineSeasonInfo_native", 15, &[0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18], get_info_native_wrapper as *const () as usize, &GET_INFO_NATIVE_TRAMP, ); install_detour( base, 0x57230, "LoadCurrentOfflineSeason_impl", 19, &[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40, 0x98, 0xfe, 0xff, 0xff, 0xff], load_current_impl_wrapper as *const () as usize, &LOAD_CURRENT_IMPL_TRAMP, ); install_detour( base, 0x578e0, "LoadCurrentOfflineSeason_completion", 16, &[0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff, 0xff, 0xff], completion_wrapper as *const () as usize, &COMPLETION_TRAMP, ); install_detour_reloc( base, 0x4eb50, "GetUsersOfflineDivision_native", 14, &[0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01], 7, 11, get_users_division_wrapper as *const () as usize, &GET_USERS_DIVISION_TRAMP, ); install_detour( base, 0x4ee10, "LoadOfflineSeasons_native", 15, &[0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff], load_offline_real_wrapper as *const () as usize, &LOAD_OFFLINE_REAL_TRAMP, ); install_detour( base, 0x57560, "LoadOfflineSeasons_asyncimpl", 17, &[0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff], load_offline_async_wrapper as *const () as usize, &LOAD_OFFLINE_ASYNC_TRAMP, ); install_detour( base, 0xffe90, "LoadOfflineSeasons_final_completion", 16, &[0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48, 0x8b, 0xda], final_completion_wrapper as *const () as usize, &FINAL_COMPLETION_TRAMP, ); install_detour( base, 0x106240, "LoadOfflineSeasons_stage1_completion", 15, &[0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00, 0x00], stage1_completion_wrapper as *const () as usize, &STAGE1_COMPLETION_TRAMP, ); write_log("SEASON_TRACE: all season-native traces armed\n"); } /// Arm the passive season-flow diagnostics on a deferred thread (CardsDLL is not /// yet loaded at DllMain time). Read-only: never changes game behavior. pub(crate) fn install() { write_log("SEASON_TRACE: requested; deferred signature validation starting\n"); std::thread::spawn(|| unsafe { worker() }); }