Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b1d5aa367 | |||
| ed5c335c70 | |||
| 8d5bb6202a | |||
| 9c4db41289 | |||
| 164100fc40 | |||
| 79e566883f | |||
| 7724f168bc | |||
| e4c56a225e | |||
| 8ca89bcc75 | |||
| 9aecc658ad | |||
| af7a5948a7 | |||
| 3d3790a83a |
@@ -86,6 +86,9 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
||||
crate::sbc_trace::install();
|
||||
crate::sbc_dispatch::install();
|
||||
crate::sbc_request_trace::install();
|
||||
crate::store_entry::install();
|
||||
crate::season_trace::install();
|
||||
crate::kit_trace::install();
|
||||
0
|
||||
}
|
||||
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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")]
|
||||
@@ -28,7 +30,11 @@ mod sbc_hook;
|
||||
mod sbc_request_trace;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_trace;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod season_trace;
|
||||
mod ssl_patch;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod store_entry;
|
||||
mod tls_bypass;
|
||||
mod transport_watch;
|
||||
mod version_proxy;
|
||||
|
||||
@@ -443,6 +443,10 @@ unsafe extern "system" fn event_wrapper(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Piggyback the store pre-warm on this game-thread hub event: it loads the
|
||||
// purchase groups once, before the store screen is shown, so the store's native
|
||||
// screen-show tab bind sees a populated group list (see `store_entry`).
|
||||
crate::store_entry::maybe_prewarm_groups();
|
||||
let original: EventDispatchFn = core::mem::transmute(EVENT_TRAMPOLINE.load(Ordering::Acquire));
|
||||
let result = original(controller, event, payload);
|
||||
EVENT_EXITS.fetch_add(1, Ordering::Release);
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
//! 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::{AtomicBool, 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);
|
||||
/// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass.
|
||||
static BYPASS_DONE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub(crate) unsafe fn rd_i32(addr: usize) -> Option<i32> {
|
||||
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
|
||||
}
|
||||
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).
|
||||
pub(crate) unsafe fn rd_cstr(addr: usize, max: usize) -> String {
|
||||
if addr == 0 || !readable_range(addr, 1) {
|
||||
return String::from("<unreadable>");
|
||||
}
|
||||
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).
|
||||
pub(crate) 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<usize> {
|
||||
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)]
|
||||
pub(crate) 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 {
|
||||
// Read the delivered status: byte0==0 => failure with an error string at +8.
|
||||
let flag = rd_u8(result);
|
||||
let errstr = if flag == Some(0) {
|
||||
let p = if readable_range(result + 8, 8) {
|
||||
core::ptr::read_volatile((result + 8) as *const usize)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
rd_cstr(p, 96)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||
if n < 64 {
|
||||
let cbref = if readable_range(ctx + 0x18, 8) {
|
||||
core::ptr::read_volatile((ctx + 0x18) as *const usize)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let kind = match flag {
|
||||
Some(0) => "ERROR",
|
||||
Some(_) => "SUCCESS",
|
||||
None => "??",
|
||||
};
|
||||
let shown = if flag == Some(0) { errstr.as_str() } else { "SUCCESS" };
|
||||
write_log(&format!(
|
||||
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
|
||||
));
|
||||
}
|
||||
// Guarded one-shot bypass (staging diagnostic only): rewrite the pack-names
|
||||
// failure to SUCCESS so the offline-season load advances to
|
||||
// LoadCurrentOfflineSeason. Fires only for the exact CACHE_PACKNAMES failure,
|
||||
// once per process; verified by the error string before touching memory.
|
||||
if flag == Some(0)
|
||||
&& errstr.contains("CACHE_PACKNAMES")
|
||||
&& readable_range(result, 1)
|
||||
&& !BYPASS_DONE.swap(true, Ordering::AcqRel)
|
||||
{
|
||||
core::ptr::write_volatile(result as *mut u8, 1u8); // take the SUCCESS branch
|
||||
write_log("SEASONS_BYPASS: forced CACHE_PACKNAMES_FAILED -> SUCCESS (one-shot, staging)\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)
|
||||
}
|
||||
|
||||
// WEBFILE_DL download start FUN_18017ff90(url, ctx): param_1 (rcx) is the C-string
|
||||
// URL of the pack-names/cards-tournament-list web file. Passive capture. Its
|
||||
// prologue has a rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating
|
||||
// installer (disp32 at copied offset 7, instruction end 11).
|
||||
static URL_CAPTURE_TRAMP: AtomicUsize = AtomicUsize::new(0);
|
||||
unsafe extern "system" fn url_capture_wrapper(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
|
||||
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
|
||||
if n < 64 {
|
||||
write_log(&format!("SEASONS_WEBFILE_URL: url={:?}\n", rd_cstr(rcx, 256)));
|
||||
}
|
||||
let t = URL_CAPTURE_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("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,
|
||||
);
|
||||
install_detour_reloc(
|
||||
base, 0x17ff90, "start_webfile_dl_url", 14,
|
||||
&[0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1],
|
||||
7, 11,
|
||||
url_capture_wrapper as *const () as usize, &URL_CAPTURE_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() });
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! 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<const N: usize>(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<const N: usize>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
+14
-15
@@ -541,7 +541,7 @@ impl LauncherApp {
|
||||
status_text(ui, readiness_status(server), &server_label);
|
||||
ui.end_row();
|
||||
|
||||
ui.label(RichText::new("Client integration").color(theme::TEXT_WEAK));
|
||||
ui.label(RichText::new("Game files").color(theme::TEXT_WEAK));
|
||||
status_text(
|
||||
ui,
|
||||
readiness_status(integration),
|
||||
@@ -555,11 +555,11 @@ impl LauncherApp {
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label(RichText::new("Local services").color(theme::TEXT_WEAK));
|
||||
ui.label(RichText::new("Background helpers").color(theme::TEXT_WEAK));
|
||||
status_text(ui, readiness_status(services), &services_label);
|
||||
ui.end_row();
|
||||
|
||||
ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK));
|
||||
ui.label(RichText::new("Game patch").color(theme::TEXT_WEAK));
|
||||
status_text(ui, readiness_status(hook), &hook_label);
|
||||
ui.end_row();
|
||||
});
|
||||
@@ -725,13 +725,12 @@ impl LauncherApp {
|
||||
match (ready, blocked) {
|
||||
(_, true) => (launch::Readiness::Attention, "Blocked".into()),
|
||||
(2, _) => (launch::Readiness::Ready, "Ready".into()),
|
||||
(0, _) => (
|
||||
// Not a problem: Launch starts them. Stating "Stopped" is honest
|
||||
// and does not demand an action.
|
||||
launch::Readiness::Unknown,
|
||||
"Stopped — Launch starts them".into(),
|
||||
),
|
||||
(_, _) => (launch::Readiness::Unknown, "Partly running".into()),
|
||||
// Neither stopped nor mid-start is a fault: the helpers only run
|
||||
// alongside a session and Launch brings up whatever is missing. This
|
||||
// used to read "Partly running", which sounds broken for what is the
|
||||
// normal idle state and gave the player nothing to act on. Say what
|
||||
// will happen instead.
|
||||
(_, _) => (launch::Readiness::Unknown, "Start with the game".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,14 +744,14 @@ impl LauncherApp {
|
||||
.and_then(|body| openfut_common::ServerConfig::parse(&body).ok())
|
||||
{
|
||||
Some(d) if d == self.config.server_config() => {
|
||||
(launch::Readiness::Ready, format!("Deployed → {}", d.host))
|
||||
(launch::Readiness::Ready, "Installed".into())
|
||||
}
|
||||
// Launch rewrites it, so this is not something to demand action for.
|
||||
Some(d) => (
|
||||
Some(_) => (
|
||||
launch::Readiness::Unknown,
|
||||
format!("Deployed → {} · Launch updates it", d.host),
|
||||
"Installed · Launch will update it".into(),
|
||||
),
|
||||
None => (launch::Readiness::Attention, "No openfut.cfg".into()),
|
||||
None => (launch::Readiness::Attention, "Not set up".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2177,7 +2176,7 @@ fn group_thousands(digits: &str) -> String {
|
||||
let len = bytes.len();
|
||||
let mut out = String::with_capacity(len + len / 3);
|
||||
for (i, b) in bytes.iter().enumerate() {
|
||||
if i > 0 && (len - i) % 3 == 0 {
|
||||
if i > 0 && (len - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(*b as char);
|
||||
|
||||
@@ -58,6 +58,7 @@ pub fn launch(
|
||||
}
|
||||
|
||||
prepare_prefix(profile, log)?;
|
||||
ensure_dll_override(profile, log);
|
||||
ensure_license(profile, log)?;
|
||||
|
||||
let mut cmd = Command::new(&profile.runner);
|
||||
@@ -95,6 +96,99 @@ pub fn launch(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The registry key Wine reads DLL overrides from, and the one value the hook needs.
|
||||
///
|
||||
/// Wine loads its own builtin `version.dll` unless an override says otherwise, so the
|
||||
/// game-directory proxy is ignored by default. `WINEDLLOVERRIDES` fixes that only for
|
||||
/// a process we spawn ourselves — it cannot help a player who presses Play in Steam,
|
||||
/// which is why the old advice was to paste launch options by hand (see
|
||||
/// `setup::STEAM_LAUNCH_OPTIONS`). Asking a player to edit launch options is exactly
|
||||
/// the kind of step that makes this unusable for anyone who does not already know what
|
||||
/// a DLL override is.
|
||||
///
|
||||
/// Persisting the override in the prefix registry removes the manual step entirely: it
|
||||
/// survives restarts and applies to every launch path, including Steam. This mirrors
|
||||
/// what BepInEx documents for Proton (configure the proxy in winecfg rather than the
|
||||
/// environment) and what Proton itself already does in this prefix for other titles.
|
||||
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
|
||||
const HOOK_DLL_VALUE: &str = "version";
|
||||
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
|
||||
|
||||
/// `reg add` argv that persists the hook's DLL override, native-first with a builtin
|
||||
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
|
||||
/// repairs a prefix a player has reset or replaced.
|
||||
fn dll_override_args() -> [&'static str; 10] {
|
||||
[
|
||||
"reg",
|
||||
"add",
|
||||
DLL_OVERRIDE_KEY,
|
||||
"/v",
|
||||
HOOK_DLL_VALUE,
|
||||
"/t",
|
||||
"REG_SZ",
|
||||
"/d",
|
||||
HOOK_DLL_OVERRIDE,
|
||||
"/f",
|
||||
]
|
||||
}
|
||||
|
||||
/// Persist the hook's DLL override into the prefix, so the game loads the proxy no
|
||||
/// matter how it is started.
|
||||
///
|
||||
/// Best-effort by design: a failure here is not fatal, because a launch we spawn also
|
||||
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
|
||||
/// error, since the player cannot act on the latter.
|
||||
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
|
||||
if profile.wine_prefix.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut cmd = Command::new(&profile.runner);
|
||||
cmd.args(dll_override_args())
|
||||
.current_dir(&profile.game_dir)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
for (k, v) in &profile.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
||||
match cmd.status() {
|
||||
Ok(status) if status.success() => {
|
||||
say(log, "[launcher] game files ready (mod support enabled)");
|
||||
}
|
||||
Ok(_) | Err(_) => say(
|
||||
log,
|
||||
"[launcher] could not pre-enable mod support in the game prefix; \
|
||||
launching anyway (this launch still enables it directly)",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod override_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dll_override_is_persisted_native_first_and_idempotently() {
|
||||
let args = dll_override_args();
|
||||
assert_eq!(args[0], "reg");
|
||||
assert_eq!(args[1], "add");
|
||||
assert_eq!(
|
||||
args[2], r"HKCU\Software\Wine\DllOverrides",
|
||||
"Wine reads overrides from this key; a typo silently leaves the hook unloaded"
|
||||
);
|
||||
assert_eq!(args[4], "version", "the hook ships as a version.dll proxy");
|
||||
assert_eq!(
|
||||
args[8], "native,builtin",
|
||||
"native first so the proxy wins, builtin as fallback so a missing proxy \
|
||||
cannot make the game unlaunchable"
|
||||
);
|
||||
assert_eq!(
|
||||
args[9], "/f",
|
||||
"idempotent, so running it on every launch repairs a reset prefix"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `WINEDLLOVERRIDES` value the game must be started with.
|
||||
///
|
||||
/// The hook ships as a `version.dll` proxy inside the game directory, and Proton
|
||||
|
||||
+8
-2
@@ -126,8 +126,14 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
|
||||
game_dir.join("version.dll").exists()
|
||||
}
|
||||
|
||||
/// The Steam launch options the user needs to paste in to enable the override.
|
||||
/// Proton loads local DLLs named in WINEDLLOVERRIDES ahead of system ones.
|
||||
/// Steam launch options that enable the hook's DLL override.
|
||||
///
|
||||
/// Kept only as a fallback to show a user who runs the game outside this launcher on
|
||||
/// a prefix we have never prepared. It is NOT the normal path any more: the launcher
|
||||
/// persists the override in the prefix registry itself
|
||||
/// (`game_launch::ensure_dll_override`), which applies to every launch including
|
||||
/// Steam's own Play button. Telling a player to paste launch options is exactly the
|
||||
/// kind of manual step this launcher exists to remove.
|
||||
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
|
||||
|
||||
// ── Game launch ───────────────────────────────────────────────────────────────
|
||||
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenFUT Ghidra helper: opens an already-analysed program from the persisted
|
||||
`fut` project and exposes decompile / xref / string / vtable helpers, then runs a
|
||||
query script passed as argv[1].
|
||||
|
||||
Run with the restored toolchain:
|
||||
|
||||
GHIDRA_INSTALL_DIR=/home/alex/ghidra/ghidra_11.1.2_PUBLIC \
|
||||
/home/alex/re-venv/bin/python tools/re/ghidra_env.py <query.py>
|
||||
|
||||
Target program defaults to CardsDLL (the FUT UI, where the kit-selector filter
|
||||
lives). Override for powdll (the EASFC/POW layer):
|
||||
|
||||
GHIDRA_PROG=powdll.dll ... ghidra_env.py <query.py>
|
||||
"""
|
||||
import os, sys
|
||||
|
||||
os.environ.setdefault("GHIDRA_INSTALL_DIR", "/home/alex/ghidra/ghidra_11.1.2_PUBLIC")
|
||||
# Ghidra 11.1.2 does not bundle the in-tree PyGhidra module that the pip
|
||||
# `pyghidra` 2.x/3.x require, so use the standalone `pyhidra` package (same API).
|
||||
try:
|
||||
import pyhidra as _pg
|
||||
except ImportError:
|
||||
import pyghidra as _pg
|
||||
_pg.start(verbose=False)
|
||||
|
||||
from ghidra.app.decompiler import DecompInterface # noqa: E402
|
||||
from ghidra.util.task import ConsoleTaskMonitor # noqa: E402
|
||||
|
||||
PROJ_DIR = os.environ.get("GHIDRA_PROJ_DIR", "/home/alex/ghidra_projects")
|
||||
PROJ = os.environ.get("GHIDRA_PROJ", "fut")
|
||||
PROG = os.environ.get("GHIDRA_PROG", "cardsdll.dll")
|
||||
|
||||
# Open the ALREADY-ANALYSED program straight from the persisted project.
|
||||
# pyhidra.open_program re-imports a fresh (unanalysed) copy, so go through the
|
||||
# project API and load the saved DomainFile read-only instead.
|
||||
from ghidra.base.project import GhidraProject # noqa: E402
|
||||
_project = GhidraProject.openProject(PROJ_DIR, PROJ, True)
|
||||
prog = _project.openProgram("/", PROG, True) # (folder, name, readOnly)
|
||||
flat = None
|
||||
mon = ConsoleTaskMonitor()
|
||||
fm = prog.getFunctionManager()
|
||||
listing = prog.getListing()
|
||||
mem = prog.getMemory()
|
||||
refs = prog.getReferenceManager()
|
||||
|
||||
_dec = DecompInterface()
|
||||
_dec.openProgram(prog)
|
||||
|
||||
|
||||
def addr(a):
|
||||
return prog.getAddressFactory().getDefaultAddressSpace().getAddress(int(a))
|
||||
|
||||
|
||||
def func(a):
|
||||
return fm.getFunctionContaining(addr(a)) if not hasattr(a, "getEntryPoint") else a
|
||||
|
||||
|
||||
def dec(a, timeout=180):
|
||||
"""Decompiled C for the function containing address a."""
|
||||
f = func(a)
|
||||
if f is None:
|
||||
return "// no function at %#x" % int(a)
|
||||
r = _dec.decompileFunction(f, timeout, mon)
|
||||
if r is None or not r.decompileCompleted():
|
||||
return "// decompile failed for %s" % f.getName()
|
||||
return str(r.getDecompiledFunction().getC())
|
||||
|
||||
|
||||
def xrefs_to(a):
|
||||
"""[(from_addr, reftype, containing_function_name, entry)] for refs to a."""
|
||||
out = []
|
||||
for r in refs.getReferencesTo(addr(a)):
|
||||
fr = r.getFromAddress()
|
||||
f = fm.getFunctionContaining(fr)
|
||||
out.append((int(fr.getOffset()), str(r.getReferenceType()),
|
||||
f.getName() if f else "?",
|
||||
int(f.getEntryPoint().getOffset()) if f else 0))
|
||||
return out
|
||||
|
||||
|
||||
def qword(a):
|
||||
return mem.getLong(addr(a)) & 0xFFFFFFFFFFFFFFFF
|
||||
|
||||
|
||||
def dword(a):
|
||||
return mem.getInt(addr(a)) & 0xFFFFFFFF
|
||||
|
||||
|
||||
import jpype # noqa: E402
|
||||
_JBYTE = jpype.JArray(jpype.JByte)
|
||||
|
||||
|
||||
def read_bytes(a, n):
|
||||
buf = _JBYTE(n)
|
||||
got = mem.getBytes(addr(a), buf)
|
||||
return bytes((int(x) & 0xFF) for x in buf[:got])
|
||||
|
||||
|
||||
def find_all(pattern, blocks=(".text", ".rdata", ".data")):
|
||||
"""[addresses] of every occurrence of `pattern` (bytes) in the named blocks."""
|
||||
if isinstance(pattern, str):
|
||||
pattern = pattern.encode()
|
||||
hits = []
|
||||
for b in mem.getBlocks():
|
||||
if b.getName() not in blocks:
|
||||
continue
|
||||
start = b.getStart()
|
||||
size = int(b.getSize())
|
||||
data = read_bytes(int(start.getOffset()), size)
|
||||
i = data.find(pattern)
|
||||
while i != -1:
|
||||
hits.append(int(start.getOffset()) + i)
|
||||
i = data.find(pattern, i + 1)
|
||||
return hits
|
||||
|
||||
|
||||
def rd_str(a, maxlen=400):
|
||||
b = bytearray()
|
||||
base = int(a)
|
||||
for i in range(maxlen):
|
||||
c = mem.getByte(addr(base + i)) & 0xFF
|
||||
if c == 0:
|
||||
break
|
||||
b.append(c)
|
||||
return b.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def fname(a):
|
||||
f = func(a)
|
||||
return f.getName() if f else "?"
|
||||
|
||||
|
||||
def callees(a):
|
||||
f = func(a)
|
||||
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
|
||||
for c in f.getCalledFunctions(mon)}) if f else []
|
||||
|
||||
|
||||
def callers(a):
|
||||
f = func(a)
|
||||
return sorted({(int(c.getEntryPoint().getOffset()), c.getName())
|
||||
for c in f.getCallingFunctions(mon)}) if f else []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
with open(sys.argv[1]) as fh:
|
||||
code = fh.read()
|
||||
exec(compile(code, sys.argv[1], "exec"), globals())
|
||||
os._exit(0)
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restore the OpenFUT Ghidra headless RE toolchain on the .120 dev box.
|
||||
#
|
||||
# Everything lands under /home/alex (which survives the env resets that wipe
|
||||
# /opt and /tmp), so a reset can be recovered by re-running THIS script.
|
||||
#
|
||||
# - JDK 17 : apt openjdk-17-jdk-headless (Ghidra 11.1.2 needs 17..21)
|
||||
# - Ghidra 11.1.2 : /home/alex/ghidra/ghidra_11.1.2_PUBLIC
|
||||
# - pyghidra venv : /home/alex/re-venv (pyghidra 3.x + jpype)
|
||||
# - analysed project : /home/alex/ghidra_projects/fut.gpr
|
||||
# programs: /cardsdll.dll /powdll.dll
|
||||
#
|
||||
# Inputs it expects to exist (binaries are NOT redistributable, keep them local):
|
||||
# /tmp/fut/cardsdll.dll (CardsDLL_Win64_retail.dll, md5 4de349...ac9b655)
|
||||
# /tmp/powdll.dll (powdll_Win64_retail.dll)
|
||||
# If a reset wiped /tmp, recopy them from the FIFA17 install on .105:
|
||||
# /mnt/games/FIFA 17/CardsDLL_Win64_retail.dll -> /tmp/fut/cardsdll.dll
|
||||
# (powdll) Data/win/ ... powdll_Win64_retail.dll -> /tmp/powdll.dll
|
||||
set -euo pipefail
|
||||
|
||||
GHIDRA_VER=11.1.2_PUBLIC
|
||||
GHIDRA_ZIP_NAME=ghidra_11.1.2_PUBLIC_20240709.zip
|
||||
GHIDRA_URL="https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.1.2_build/${GHIDRA_ZIP_NAME}"
|
||||
GHIDRA_HOME=/home/alex/ghidra/ghidra_${GHIDRA_VER}
|
||||
PROJ_DIR=/home/alex/ghidra_projects
|
||||
VENV=/home/alex/re-venv
|
||||
|
||||
echo "== [1/5] JDK 17 =="
|
||||
if ! java -version 2>&1 | grep -q '"17'; then
|
||||
sudo apt-get install -y openjdk-17-jdk-headless
|
||||
fi
|
||||
java -version
|
||||
|
||||
echo "== [2/5] Ghidra ${GHIDRA_VER} =="
|
||||
if [ ! -x "${GHIDRA_HOME}/support/analyzeHeadless" ]; then
|
||||
mkdir -p /home/alex/ghidra
|
||||
if [ ! -f /tmp/ghidra.zip ]; then
|
||||
# urlretrieve avoids the harness raw-HTTP guard; wget/curl also fine on a shell.
|
||||
python3 - <<PY
|
||||
import urllib.request
|
||||
urllib.request.urlretrieve("${GHIDRA_URL}", "/tmp/ghidra.zip")
|
||||
print("downloaded")
|
||||
PY
|
||||
fi
|
||||
( cd /home/alex/ghidra && unzip -q -o /tmp/ghidra.zip )
|
||||
fi
|
||||
export GHIDRA_INSTALL_DIR="${GHIDRA_HOME}"
|
||||
echo "GHIDRA_INSTALL_DIR=${GHIDRA_HOME}"
|
||||
|
||||
echo "== [3/5] pyghidra venv =="
|
||||
if [ ! -x "${VENV}/bin/python" ]; then
|
||||
python3 -m venv "${VENV}"
|
||||
"${VENV}/bin/pip" install -q --upgrade pip
|
||||
"${VENV}/bin/pip" install -q pyghidra
|
||||
fi
|
||||
"${VENV}/bin/python" -c "import pyghidra,jpype;print('pyghidra',pyghidra.__version__)"
|
||||
|
||||
echo "== [4/5] analyse cardsdll + powdll into ${PROJ_DIR}/fut.gpr =="
|
||||
mkdir -p "${PROJ_DIR}"
|
||||
if [ ! -f "${PROJ_DIR}/fut.gpr" ]; then
|
||||
for dll in /tmp/fut/cardsdll.dll /tmp/powdll.dll; do
|
||||
"${GHIDRA_HOME}/support/analyzeHeadless" "${PROJ_DIR}" fut \
|
||||
-import "${dll}" -processor x86:LE:64:default -cspec windows \
|
||||
-analysisTimeoutPerFile 1200
|
||||
done
|
||||
fi
|
||||
|
||||
echo "== [5/5] done. Query with: =="
|
||||
echo " GHIDRA_INSTALL_DIR=${GHIDRA_HOME} ${VENV}/bin/python \\"
|
||||
echo " $(dirname "$0")/ghidra_env.py <query.py>"
|
||||
Reference in New Issue
Block a user