From 4b1d5aa3670e352b0a527345da627ea46fdece2c Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 20 Aug 2026 16:55:50 +0000 Subject: [PATCH] diag(fifa17): passive kit-selector data-flow trace (kit_trace) Traces the client-side FUT pre-match kit path in CardsDLL: GetMatchKits_DP gate (KITS_AVAILABLE), setAvailableKits (home/away list count), the kit-item clone driver (item type/subid/teamid), and the local teamkits DB clone. Proves in one operator match where the empty selector originates. Read-only; reuses season_trace's passive-detour installers. --- openfut-hook/src/fifa17.rs | 1 + openfut-hook/src/kit_trace.rs | 184 +++++++++++++++++++++++++++++++ openfut-hook/src/lib.rs | 2 + openfut-hook/src/season_trace.rs | 10 +- 4 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 openfut-hook/src/kit_trace.rs diff --git a/openfut-hook/src/fifa17.rs b/openfut-hook/src/fifa17.rs index bb361a8..ee6fd52 100644 --- a/openfut-hook/src/fifa17.rs +++ b/openfut-hook/src/fifa17.rs @@ -88,6 +88,7 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 { crate::sbc_request_trace::install(); crate::store_entry::install(); crate::season_trace::install(); + crate::kit_trace::install(); 0 } diff --git a/openfut-hook/src/kit_trace.rs b/openfut-hook/src/kit_trace.rs new file mode 100644 index 0000000..e403d66 --- /dev/null +++ b/openfut-hook/src/kit_trace.rs @@ -0,0 +1,184 @@ +//! Passive, behavior-preserving diagnostic traces for FIFA 17's FUT pre-match +//! KIT SELECTOR data flow. +//! +//! RE (2026-08-20, Ghidra on CardsDLL_Win64_retail.dll) established that the +//! pre-match kit selector is fed ENTIRELY client-side (NOT by POW/EASFC): +//! +//! * `FUT_GET_MATCH_KITS_DP` (id 0x7565) builder `FUN_1800be6a0` (rva 0xbe6a0) +//! reads a boolean gate `ctx+0x152` (`KITS_AVAILABLE`); when false, or when +//! the two available-kit vectors are empty, the selector renders blank/white. +//! * The available home/away kit-id lists live on `FutSquadServiceImpl` +//! (`this+0xe08` home, `this+0xe38` away) and are written by the setter +//! `FUN_180196760` (rva 0x96760, vtable slot 0x1d0): args (this, srcVec, side). +//! * A club KIT ITEM is turned into an available kit by `FUN_1801c3480` +//! (rva 0x1c3480): it reads item fields (`+0x4c==7`, `+0x60==4`, +//! `+0x5c`∈{101 home,102 away}, `+0x94` source teamid, `+0xba` +//! teamkittypetechid) and calls `FUN_1801c44b0` (rva 0x1c44b0) to clone that +//! team's kit rows from the CLIENT-LOCAL `teamkits` DB into the FUT club +//! (teamtechid 130000). +//! +//! These traces answer, in one operator-driven match, exactly WHERE the empty +//! selector originates: do kit club items reach the client (kit_item_clone), does +//! the clone into the FUT club happen (kit_db_clone), does the available list get +//! set non-empty (set_available_kits), and what does the selector finally read +//! (get_match_kits: KITS_AVAILABLE + count). Every trace is read-only: it logs, +//! then tail-calls the original through a trampoline. Copied prologues are whole, +//! position-independent instructions (the one rip-relative prologue uses the +//! relocating installer). + +use core::sync::atomic::{AtomicUsize, Ordering}; + +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; + +use crate::sbc_trace::{readable_range, validate_cards_build}; +use crate::season_trace::{install_detour, install_detour_reloc, rd_i32, rd_u8}; +use crate::write_log; + +static REPORTS: AtomicUsize = AtomicUsize::new(0); + +fn budget() -> bool { + REPORTS.fetch_add(1, Ordering::Relaxed) < 256 +} + +unsafe fn rd_usize(addr: usize) -> Option { + readable_range(addr, 8).then(|| core::ptr::read_volatile(addr as *const usize)) +} + +// FUT_GET_MATCH_KITS_DP builder FUN_1800be6a0 (0xbe6a0). rcx = DP model ctx. +// ctx+0x152 is the KITS_AVAILABLE bool that gates the whole selector list. +static GET_MATCH_KITS_TRAMP: AtomicUsize = AtomicUsize::new(0); +unsafe extern "system" fn get_match_kits_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize { + if budget() { + let avail = rd_u8(rcx + 0x152); + write_log(&format!( + "KIT_GET: FUT_GET_MATCH_KITS_DP ctx={rcx:#x} KITS_AVAILABLE={avail:?}\n" + )); + } + let t = GET_MATCH_KITS_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) +} + +// setAvailableKits FUN_180196760 (0x96760): (this, srcVec, side). srcVec is an +// int vector {begin@+0, end@+8}; count = (end-begin)/4. side 0=home, 1=away. +static SET_AVAILABLE_KITS_TRAMP: AtomicUsize = AtomicUsize::new(0); +unsafe extern "system" fn set_available_kits_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize { + if budget() { + let count = match (rd_usize(rdx), rd_usize(rdx + 8)) { + (Some(b), Some(e)) if e >= b => ((e - b) / 4) as i64, + _ => -1, + }; + write_log(&format!( + "KIT_SET: setAvailableKits this={rcx:#x} side={r8} count={count}\n" + )); + } + let t = SET_AVAILABLE_KITS_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) +} + +// Kit-item clone driver FUN_1801c3480 (0x1c3480): rdx = param_2, the club-item +// event; the item struct is at *(param_2+0x10). Logs the fields the function +// branches on so we can see whether a kit club item reaches the client and its +// home/away designator + source teamid. +static KIT_ITEM_CLONE_TRAMP: AtomicUsize = AtomicUsize::new(0); +unsafe extern "system" fn kit_item_clone_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize { + if budget() { + if let Some(item) = rd_usize(rdx + 0x10) { + write_log(&format!( + "KIT_ITEM: clone-driver item={item:#x} type[+0x4c]={:?} subid[+0x5c]={:?} \ + cat[+0x60]={:?} teamid[+0x94]={:?} kittype[+0xba]={:?}\n", + rd_i32(item + 0x4c), + rd_i32(item + 0x5c), + rd_i32(item + 0x60), + rd_i32(item + 0x94), + rd_i32(item + 0xba), + )); + } else { + write_log(&format!("KIT_ITEM: clone-driver param_2={rdx:#x} (item ptr unreadable)\n")); + } + } + let t = KIT_ITEM_CLONE_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) +} + +// Kit DB clone FUN_1801c44b0 (0x1c44b0): (clubmgr, side, teamtechid, kittype). +// Fires only when the driver decided the item is a home(101)/away(102) kit, so +// this is the proof the FUT-club (teamtechid 130000) kit rows get synthesized. +static KIT_DB_CLONE_TRAMP: AtomicUsize = AtomicUsize::new(0); +unsafe extern "system" fn kit_db_clone_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize { + if budget() { + write_log(&format!( + "KIT_DBCLONE: clone team kit side={rdx} src_teamtechid={r8} kittype={r9}\n" + )); + } + let t = KIT_DB_CLONE_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) +} + +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("KIT_TRACE: CardsDLL unavailable/invalid; kit trace inactive\n"); + return; + } + // FUN_1800be6a0: 48 8b c4 55 41 54 41 55 41 56 41 57 48 8d 68 a1 (copy_len 16). + install_detour( + base, 0xbe6a0, "GetMatchKits_DP(0xbe6a0)", 16, + &[0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, 0x68, 0xa1], + get_match_kits_wrapper as *const () as usize, &GET_MATCH_KITS_TRAMP, + ); + // FUN_180196760: 48 89 54 24 10 53 48 83 ec 30 48 c7 44 24 20 fe ff ff ff (copy_len 19). + install_detour( + base, 0x96760, "setAvailableKits(0x96760)", 19, + &[0x48, 0x89, 0x54, 0x24, 0x10, 0x53, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff], + set_available_kits_wrapper as *const () as usize, &SET_AVAILABLE_KITS_TRAMP, + ); + // FUN_1801c3480: 48 89 5c 24 08 57 48 83 ec 60 <48 8b 05 disp32> (rip-relative + // MOV RAX,[rip+..] at copied offset 10; disp32 at 13, insn end 17; copy_len 17). + install_detour_reloc( + base, 0x1c3480, "kitItemClone(0x1c3480)", 17, + &[0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0x8b, 0x05, 0x4f, 0x82, 0x11, 0x00], + 13, 17, + kit_item_clone_wrapper as *const () as usize, &KIT_ITEM_CLONE_TRAMP, + ); + // FUN_1801c44b0: 48 8b c4 55 41 54 41 55 41 56 41 57 48 8d 68 c8 (copy_len 16). + install_detour( + base, 0x1c44b0, "kitDbClone(0x1c44b0)", 16, + &[0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, 0x68, 0xc8], + kit_db_clone_wrapper as *const () as usize, &KIT_DB_CLONE_TRAMP, + ); + write_log("KIT_TRACE: all kit-selector traces armed\n"); +} + +/// Arm the passive kit-selector 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("KIT_TRACE: requested; deferred signature validation starting\n"); + std::thread::spawn(|| unsafe { worker() }); +} diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index 1d97a9c..ae208e6 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -14,6 +14,8 @@ mod dial_notification; #[cfg(feature = "fifa17")] mod fifa17; mod hooks; +#[cfg(feature = "fifa17")] +mod kit_trace; mod iat; mod origin_spy; #[cfg(feature = "probe")] diff --git a/openfut-hook/src/season_trace.rs b/openfut-hook/src/season_trace.rs index 42a6b15..2494550 100644 --- a/openfut-hook/src/season_trace.rs +++ b/openfut-hook/src/season_trace.rs @@ -31,14 +31,14 @@ static REPORTS: AtomicUsize = AtomicUsize::new(0); /// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass. static BYPASS_DONE: AtomicBool = AtomicBool::new(false); -unsafe fn rd_i32(addr: usize) -> Option { +pub(crate) 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 { +pub(crate) 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 { +pub(crate) unsafe fn rd_cstr(addr: usize, max: usize) -> String { if addr == 0 || !readable_range(addr, 1) { return String::from(""); } @@ -58,7 +58,7 @@ unsafe fn rd_cstr(addr: usize, max: usize) -> String { /// 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( +pub(crate) unsafe fn install_detour( base: usize, rva: usize, name: &str, @@ -240,7 +240,7 @@ unsafe fn alloc_near(base: usize, size: usize) -> Option { /// 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( +pub(crate) unsafe fn install_detour_reloc( base: usize, rva: usize, name: &str,