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.
This commit is contained in:
funman300
2026-08-20 16:55:50 +00:00
parent ed5c335c70
commit 4b1d5aa367
4 changed files with 192 additions and 5 deletions
+1
View File
@@ -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
}
+184
View File
@@ -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<usize> {
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() });
}
+2
View File
@@ -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")]
+5 -5
View File
@@ -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<i32> {
pub(crate) unsafe fn rd_i32(addr: usize) -> Option<i32> {
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
}
unsafe fn rd_u8(addr: usize) -> Option<u8> {
pub(crate) unsafe fn rd_u8(addr: usize) -> Option<u8> {
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("<unreadable>");
}
@@ -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<usize> {
/// 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,