Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b1d5aa367 | |||
| ed5c335c70 | |||
| 8d5bb6202a | |||
| 9c4db41289 | |||
| 164100fc40 | |||
| 79e566883f | |||
| 7724f168bc | |||
| e4c56a225e | |||
| 8ca89bcc75 | |||
| 9aecc658ad | |||
| af7a5948a7 | |||
| 3d3790a83a | |||
| 94feaec63f | |||
| c3addde9b1 | |||
| 9900772690 | |||
| 35ceb084ef | |||
| 1c7111ddbf | |||
| 6cdb45e482 | |||
| 1cd4f18e92 | |||
| c5424158b9 | |||
| 3174fe4c1f |
@@ -79,13 +79,16 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
|||||||
));
|
));
|
||||||
dump_modules();
|
dump_modules();
|
||||||
write_log("fifa17: worker complete (injection healthy)\n");
|
write_log("fifa17: worker complete (injection healthy)\n");
|
||||||
// SBC render intervention (inert unless OPENFUT_SBC_HOOK=1). Spawns its own deferred
|
// The promoted SBC dispatch repair (and the evidence traces it decides on) arms
|
||||||
// worker that waits for CardsDLL to load. See sbc_hook.rs / docs/sbc-hook-dll-spec.md.
|
// itself from the build; its safety is the runtime signature/evidence gate. The
|
||||||
|
// remaining legacy experiment modules stay inert unless their env gate is `1`.
|
||||||
crate::sbc_hook::install();
|
crate::sbc_hook::install();
|
||||||
// Passive transaction tracing has a separate kill switch from cache resolution.
|
|
||||||
// It currently fails closed until safe relocating trampolines are proven.
|
|
||||||
crate::sbc_trace::install();
|
crate::sbc_trace::install();
|
||||||
|
crate::sbc_dispatch::install();
|
||||||
crate::sbc_request_trace::install();
|
crate::sbc_request_trace::install();
|
||||||
|
crate::store_entry::install();
|
||||||
|
crate::season_trace::install();
|
||||||
|
crate::kit_trace::install();
|
||||||
0
|
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")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod fifa17;
|
mod fifa17;
|
||||||
mod hooks;
|
mod hooks;
|
||||||
|
#[cfg(feature = "fifa17")]
|
||||||
|
mod kit_trace;
|
||||||
mod iat;
|
mod iat;
|
||||||
mod origin_spy;
|
mod origin_spy;
|
||||||
#[cfg(feature = "probe")]
|
#[cfg(feature = "probe")]
|
||||||
@@ -21,12 +23,18 @@ mod probe;
|
|||||||
#[cfg(feature = "capture_baseline")]
|
#[cfg(feature = "capture_baseline")]
|
||||||
mod recv_hook;
|
mod recv_hook;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
|
mod sbc_dispatch;
|
||||||
|
#[cfg(feature = "fifa17")]
|
||||||
mod sbc_hook;
|
mod sbc_hook;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod sbc_request_trace;
|
mod sbc_request_trace;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod sbc_trace;
|
mod sbc_trace;
|
||||||
|
#[cfg(feature = "fifa17")]
|
||||||
|
mod season_trace;
|
||||||
mod ssl_patch;
|
mod ssl_patch;
|
||||||
|
#[cfg(feature = "fifa17")]
|
||||||
|
mod store_entry;
|
||||||
mod tls_bypass;
|
mod tls_bypass;
|
||||||
mod transport_watch;
|
mod transport_watch;
|
||||||
mod version_proxy;
|
mod version_proxy;
|
||||||
|
|||||||
@@ -0,0 +1,856 @@
|
|||||||
|
//! Guarded FIFA 17 SBC completion dispatch and passive event tracing.
|
||||||
|
//!
|
||||||
|
//! The repair is a PROMOTED feature: it is armed by the build itself, never by an
|
||||||
|
//! environment variable (see [`REPAIR_PROMOTED`]). Safety lives in the runtime
|
||||||
|
//! evidence gate, not in a flag.
|
||||||
|
|
||||||
|
use core::ffi::c_void;
|
||||||
|
use core::sync::atomic::{AtomicBool, 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::{
|
||||||
|
VirtualAlloc, VirtualFree, VirtualProtect, MEM_COMMIT, MEM_RELEASE, MEM_RESERVE,
|
||||||
|
PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_READWRITE,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
|
||||||
|
|
||||||
|
const COMPLETION_RVA: usize = 0x0b8950;
|
||||||
|
const EVENT_DISPATCH_RVA: usize = 0x1a4cd0;
|
||||||
|
const CATEGORY_RESPONSE_VTABLE_RVA: usize = 0x22e5b0;
|
||||||
|
const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
|
||||||
|
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
|
||||||
|
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
|
||||||
|
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
|
||||||
|
const COMPLETION_COPY_LEN: usize = 14;
|
||||||
|
const EVENT_COPY_LEN: usize = 16;
|
||||||
|
const ABS_JUMP_LEN: usize = 14;
|
||||||
|
const COMPLETION_TRAMPOLINE_LEN: usize = 12 + 2 + ABS_JUMP_LEN * 2;
|
||||||
|
const UNKNOWN_TRANSPORT_STATUS: u32 = 999;
|
||||||
|
const FUT_SBS_CATEGORIES_EVENT: u32 = 0x756c;
|
||||||
|
const FUT_SBS_CATEGORIES_READY_EVENT: u32 = 0x756d;
|
||||||
|
const SBC_REFRESH_EVENT: u32 = 0x138c;
|
||||||
|
|
||||||
|
const COMPLETION_SIGNATURE: [u8; 32] = [
|
||||||
|
0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2, 0x74, 0x4e, 0x83, 0x7a,
|
||||||
|
0x1c, 0x00, 0x75, 0x48, 0xc6, 0x81, 0x1d, 0x02, 0x00, 0x00, 0x01, 0x48, 0x8b, 0x89, 0x40, 0x01,
|
||||||
|
];
|
||||||
|
const EVENT_SIGNATURE: [u8; EVENT_COPY_LEN] = [
|
||||||
|
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x40, 0xb8, 0xfe, 0xff, 0xff, 0xff,
|
||||||
|
];
|
||||||
|
|
||||||
|
type CompletionFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> usize;
|
||||||
|
type EventDispatchFn = unsafe extern "system" fn(*mut c_void, u32, *mut c_void) -> usize;
|
||||||
|
|
||||||
|
/// The guarded native dispatch repair is PROMOTED: armed by the build, never by an
|
||||||
|
/// environment variable. Retail Gates A–G passed on the pinned CardsDLL build, so a
|
||||||
|
/// deployed hook must repair the SBC completion on every launch path (Steam, the
|
||||||
|
/// launcher, or a bare `umu-run`) with nothing to export.
|
||||||
|
///
|
||||||
|
/// Promotion does NOT weaken any check — every guard stays in the runtime evidence
|
||||||
|
/// gate rather than in a flag. `worker` still validates the exact CardsDLL
|
||||||
|
/// signatures before installing a detour, and [`decide`] still requires the
|
||||||
|
/// transport sentinel status, the pinned category-response vtable captured while
|
||||||
|
/// the response object was provably live, balanced parser counts on the one parser
|
||||||
|
/// thread, this generation's notifier having entered AND returned, the captured
|
||||||
|
/// controller/model identity, and one repair per deserializer generation. Anything
|
||||||
|
/// unrecognised leaves native execution untouched.
|
||||||
|
///
|
||||||
|
/// Rollback is a file swap (restore the previous `version.dll`) — the documented
|
||||||
|
/// client rollback path — deliberately not an env kill-switch.
|
||||||
|
pub(crate) const REPAIR_PROMOTED: bool = true;
|
||||||
|
|
||||||
|
/// Compile-time contract: the repair stays armed by the build. Flipping this back to
|
||||||
|
/// an env gate would silently cost a normal launch (Steam or the launcher) its SBC
|
||||||
|
/// screen, which is exactly the regression promotion removed — so it must be a
|
||||||
|
/// deliberate, visible change here rather than a missing variable at runtime.
|
||||||
|
const _: () = assert!(REPAIR_PROMOTED);
|
||||||
|
|
||||||
|
static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||||
|
static COMPLETION_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static EVENT_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static LAST_REPAIRED_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static COMPLETION_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static COMPLETION_EXITS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static COMPLETION_THREAD: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static COMPLETION_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static COMPLETION_STATUS_OBJECT: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static COMPLETION_STATUS: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||||
|
static COMPLETION_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static COMPLETION_DECISION: AtomicUsize = AtomicUsize::new(Decision::NativeSuccess as usize);
|
||||||
|
static COMPLETION_REJECTION: AtomicUsize = AtomicUsize::new(Rejection::None as usize);
|
||||||
|
static EVENT_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static EVENT_EXITS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static EVENT_THREAD: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static EVENT_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static EVENT_ID: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static EVENT_PAYLOAD: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
static EVENT_CATEGORIES: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static EVENT_REFRESH: AtomicU64 = AtomicU64::new(0);
|
||||||
|
static EVENT_READY: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[repr(usize)]
|
||||||
|
enum Decision {
|
||||||
|
NativeSuccess,
|
||||||
|
Repair,
|
||||||
|
}
|
||||||
|
|
||||||
|
const REJECTED_DECISION: usize = 2;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[repr(usize)]
|
||||||
|
enum Rejection {
|
||||||
|
None,
|
||||||
|
RepairDisabled,
|
||||||
|
NullStatus,
|
||||||
|
StatusUnreadable,
|
||||||
|
UnsupportedStatus,
|
||||||
|
CardsBuildMismatch,
|
||||||
|
ParserUnbalanced,
|
||||||
|
FactoryMismatch,
|
||||||
|
ParserThreadMismatch,
|
||||||
|
ReaderMissing,
|
||||||
|
ParseFailed,
|
||||||
|
ResponseClassMismatch,
|
||||||
|
ModelChanged,
|
||||||
|
ModelEmpty,
|
||||||
|
NotifierNotCurrent,
|
||||||
|
ControllerMismatch,
|
||||||
|
ControllerModelMismatch,
|
||||||
|
DuplicateGeneration,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct DecisionInput {
|
||||||
|
repair_enabled: bool,
|
||||||
|
status: Option<u32>,
|
||||||
|
status_present: bool,
|
||||||
|
status_copyable: bool,
|
||||||
|
cards_build_matches: bool,
|
||||||
|
factory_entries: u64,
|
||||||
|
factory_exits: u64,
|
||||||
|
factory_result: usize,
|
||||||
|
factory_thread: usize,
|
||||||
|
deserializer_entries: u64,
|
||||||
|
deserializer_exits: u64,
|
||||||
|
deserializer_this: usize,
|
||||||
|
deserializer_reader: usize,
|
||||||
|
deserializer_result: bool,
|
||||||
|
deserializer_thread: usize,
|
||||||
|
response_class_matches: bool,
|
||||||
|
model: usize,
|
||||||
|
live_category_count: usize,
|
||||||
|
category_count: usize,
|
||||||
|
notifier_entries: u64,
|
||||||
|
notifier_exits: u64,
|
||||||
|
controller_matches: bool,
|
||||||
|
controller_model_matches: bool,
|
||||||
|
last_repaired_generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decide(input: DecisionInput) -> Result<Decision, Rejection> {
|
||||||
|
let Some(status) = input.status else {
|
||||||
|
return Err(if input.status_present {
|
||||||
|
Rejection::StatusUnreadable
|
||||||
|
} else {
|
||||||
|
Rejection::NullStatus
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if status == 0 {
|
||||||
|
return Ok(Decision::NativeSuccess);
|
||||||
|
}
|
||||||
|
if !input.repair_enabled {
|
||||||
|
return Err(Rejection::RepairDisabled);
|
||||||
|
}
|
||||||
|
if status != UNKNOWN_TRANSPORT_STATUS {
|
||||||
|
return Err(Rejection::UnsupportedStatus);
|
||||||
|
}
|
||||||
|
if !input.status_copyable {
|
||||||
|
return Err(Rejection::StatusUnreadable);
|
||||||
|
}
|
||||||
|
if !input.cards_build_matches {
|
||||||
|
return Err(Rejection::CardsBuildMismatch);
|
||||||
|
}
|
||||||
|
let generation = input.deserializer_exits;
|
||||||
|
if generation == 0
|
||||||
|
|| input.factory_entries != input.factory_exits
|
||||||
|
|| input.deserializer_entries != generation
|
||||||
|
|| input.factory_exits != generation
|
||||||
|
{
|
||||||
|
return Err(Rejection::ParserUnbalanced);
|
||||||
|
}
|
||||||
|
if input.factory_result == 0 || input.factory_result != input.deserializer_this {
|
||||||
|
return Err(Rejection::FactoryMismatch);
|
||||||
|
}
|
||||||
|
if input.factory_thread == 0 || input.factory_thread != input.deserializer_thread {
|
||||||
|
return Err(Rejection::ParserThreadMismatch);
|
||||||
|
}
|
||||||
|
if input.deserializer_reader == 0 {
|
||||||
|
return Err(Rejection::ReaderMissing);
|
||||||
|
}
|
||||||
|
if !input.deserializer_result {
|
||||||
|
return Err(Rejection::ParseFailed);
|
||||||
|
}
|
||||||
|
if !input.response_class_matches {
|
||||||
|
return Err(Rejection::ResponseClassMismatch);
|
||||||
|
}
|
||||||
|
if input.model == 0 || input.category_count == 0 || input.category_count == usize::MAX {
|
||||||
|
return Err(Rejection::ModelEmpty);
|
||||||
|
}
|
||||||
|
if input.live_category_count != input.category_count {
|
||||||
|
return Err(Rejection::ModelChanged);
|
||||||
|
}
|
||||||
|
// The category-success notifier for this generation must have entered and
|
||||||
|
// fully returned before the SBC completion runs. On the pinned CardsDLL the
|
||||||
|
// completion fires immediately after the notifier unwinds (measured: notifier
|
||||||
|
// entries == exits == generation at completion), not nested inside it, so we
|
||||||
|
// bind both notifier counts to the current generation rather than requiring
|
||||||
|
// an in-flight notifier.
|
||||||
|
if input.notifier_entries != generation
|
||||||
|
|| input.notifier_entries == 0
|
||||||
|
|| input.notifier_exits != generation
|
||||||
|
{
|
||||||
|
return Err(Rejection::NotifierNotCurrent);
|
||||||
|
}
|
||||||
|
if !input.controller_matches {
|
||||||
|
return Err(Rejection::ControllerMismatch);
|
||||||
|
}
|
||||||
|
if !input.controller_model_matches {
|
||||||
|
return Err(Rejection::ControllerModelMismatch);
|
||||||
|
}
|
||||||
|
if input.last_repaired_generation >= generation {
|
||||||
|
return Err(Rejection::DuplicateGeneration);
|
||||||
|
}
|
||||||
|
Ok(Decision::Repair)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parsed response is the FIFA 17 typed SBC-category response only when the
|
||||||
|
/// vtable captured at deserializer exit (object provably live) equals the pinned
|
||||||
|
/// category-response vtable for the running CardsDLL image. A zero capture means
|
||||||
|
/// the object vtable was unreadable and never qualifies.
|
||||||
|
fn response_class_matches(base: usize, response_vtable: usize) -> bool {
|
||||||
|
response_vtable != 0 && base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA) == Some(response_vtable)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn guarded_u32(address: usize) -> Option<u32> {
|
||||||
|
crate::sbc_trace::readable_range(address, 4)
|
||||||
|
.then(|| core::ptr::read_volatile(address as *const u32))
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn status_code(status: usize) -> Option<u32> {
|
||||||
|
status
|
||||||
|
.checked_add(0x1c)
|
||||||
|
.and_then(|address| guarded_u32(address))
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn controller_identity(base: usize, controller: usize, model: usize) -> (bool, bool) {
|
||||||
|
if base == 0 || controller == 0 {
|
||||||
|
return (false, false);
|
||||||
|
}
|
||||||
|
let main_vtable = crate::sbc_trace::guarded_usize(controller);
|
||||||
|
let event_vtable = controller
|
||||||
|
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||||
|
.and_then(|address| crate::sbc_trace::guarded_usize(address));
|
||||||
|
let controller_model = controller
|
||||||
|
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
||||||
|
.and_then(|address| crate::sbc_trace::guarded_usize(address));
|
||||||
|
(
|
||||||
|
main_vtable == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
||||||
|
&& event_vtable == base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA),
|
||||||
|
controller_model == Some(model),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) unsafe fn note_sbc_controller(controller: usize, base: usize) {
|
||||||
|
let (identity_matches, _) = controller_identity(base, controller, 0);
|
||||||
|
if identity_matches {
|
||||||
|
SBC_CONTROLLER.store(controller, Ordering::Release);
|
||||||
|
crate::write_log(&format!(
|
||||||
|
"SBC_DISPATCH: captured category controller={controller:#x}\n"
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
crate::write_log(&format!(
|
||||||
|
"SBC_DISPATCH: rejected category controller={controller:#x} (class mismatch)\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(C, align(16))]
|
||||||
|
struct CompletionStatusShadow([u8; 0x20]);
|
||||||
|
|
||||||
|
unsafe extern "system" fn completion_wrapper(
|
||||||
|
controller: *mut c_void,
|
||||||
|
status: *mut c_void,
|
||||||
|
) -> usize {
|
||||||
|
COMPLETION_ENTRIES.fetch_add(1, Ordering::Relaxed);
|
||||||
|
COMPLETION_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
|
||||||
|
COMPLETION_CONTROLLER.store(controller as usize, Ordering::Relaxed);
|
||||||
|
COMPLETION_STATUS_OBJECT.store(status as usize, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let evidence = crate::sbc_trace::dispatch_evidence();
|
||||||
|
let status_address = status as usize;
|
||||||
|
let observed_status = if status_address == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
status_code(status_address)
|
||||||
|
};
|
||||||
|
COMPLETION_STATUS.store(
|
||||||
|
observed_status
|
||||||
|
.map(|value| value as usize)
|
||||||
|
.unwrap_or(usize::MAX),
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
COMPLETION_GENERATION.store(evidence.deserializer_exits, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let captured_controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
||||||
|
let live_category_count = evidence
|
||||||
|
.model
|
||||||
|
.checked_add(0x50)
|
||||||
|
.and_then(|address| crate::sbc_trace::guarded_u16(address))
|
||||||
|
.map(usize::from)
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
|
let (controller_matches, controller_model_matches) =
|
||||||
|
controller_identity(evidence.base, captured_controller, evidence.model);
|
||||||
|
let input = DecisionInput {
|
||||||
|
repair_enabled: REPAIR_ENABLED.load(Ordering::Acquire),
|
||||||
|
status: observed_status,
|
||||||
|
status_present: status_address != 0,
|
||||||
|
status_copyable: status_address != 0
|
||||||
|
&& crate::sbc_trace::readable_range(status_address, 0x20),
|
||||||
|
cards_build_matches: crate::sbc_trace::valid_cards_image(evidence.base),
|
||||||
|
factory_entries: evidence.factory_entries,
|
||||||
|
factory_exits: evidence.factory_exits,
|
||||||
|
factory_result: evidence.factory_result,
|
||||||
|
factory_thread: evidence.factory_thread,
|
||||||
|
deserializer_entries: evidence.deserializer_entries,
|
||||||
|
deserializer_exits: evidence.deserializer_exits,
|
||||||
|
deserializer_this: evidence.deserializer_this,
|
||||||
|
deserializer_reader: evidence.deserializer_reader,
|
||||||
|
deserializer_result: evidence.deserializer_result,
|
||||||
|
deserializer_thread: evidence.deserializer_thread,
|
||||||
|
response_class_matches: response_class_matches(evidence.base, evidence.response_vtable),
|
||||||
|
model: evidence.model,
|
||||||
|
live_category_count,
|
||||||
|
category_count: evidence.category_count,
|
||||||
|
notifier_entries: evidence.notifier_entries,
|
||||||
|
notifier_exits: evidence.notifier_exits,
|
||||||
|
controller_matches: controller_matches && captured_controller == controller as usize,
|
||||||
|
controller_model_matches,
|
||||||
|
last_repaired_generation: LAST_REPAIRED_GENERATION.load(Ordering::Acquire),
|
||||||
|
};
|
||||||
|
|
||||||
|
let original: CompletionFn =
|
||||||
|
core::mem::transmute(COMPLETION_TRAMPOLINE.load(Ordering::Acquire));
|
||||||
|
let result = match decide(input) {
|
||||||
|
Ok(Decision::Repair) => {
|
||||||
|
if LAST_REPAIRED_GENERATION
|
||||||
|
.compare_exchange(
|
||||||
|
input.last_repaired_generation,
|
||||||
|
evidence.deserializer_exits,
|
||||||
|
Ordering::AcqRel,
|
||||||
|
Ordering::Acquire,
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let mut shadow = CompletionStatusShadow([0; 0x20]);
|
||||||
|
core::ptr::copy_nonoverlapping(
|
||||||
|
status_address as *const u8,
|
||||||
|
shadow.0.as_mut_ptr(),
|
||||||
|
shadow.0.len(),
|
||||||
|
);
|
||||||
|
shadow.0[0x1c..0x20].copy_from_slice(&0u32.to_le_bytes());
|
||||||
|
COMPLETION_DECISION.store(Decision::Repair as usize, Ordering::Relaxed);
|
||||||
|
COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed);
|
||||||
|
original(controller, shadow.0.as_mut_ptr().cast())
|
||||||
|
} else {
|
||||||
|
COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed);
|
||||||
|
COMPLETION_REJECTION
|
||||||
|
.store(Rejection::DuplicateGeneration as usize, Ordering::Relaxed);
|
||||||
|
original(controller, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Decision::NativeSuccess) => {
|
||||||
|
COMPLETION_DECISION.store(Decision::NativeSuccess as usize, Ordering::Relaxed);
|
||||||
|
COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed);
|
||||||
|
original(controller, status)
|
||||||
|
}
|
||||||
|
Err(rejection) => {
|
||||||
|
COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed);
|
||||||
|
COMPLETION_REJECTION.store(rejection as usize, Ordering::Relaxed);
|
||||||
|
original(controller, status)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
crate::write_log(&format!(
|
||||||
|
"SBC_DISPATCH: decide gen={} status={} present={} copyable={} cards={} factory_e={} factory_x={} factory_r={:#x} factory_t={} deser_e={} deser_x={} deser_this={:#x} reader={:#x} deser_ok={} deser_t={} vt_obs={:#x} vt_exp={:#x} class={} model={:#x} live={} count={} notif_e={} notif_x={} ctrl_match={} ctrl_model={} captured_ctrl={:#x} arg_ctrl={:#x} last_gen={} decision={} rejection={}\n",
|
||||||
|
input.deserializer_exits,
|
||||||
|
input.status.map(i64::from).unwrap_or(-1),
|
||||||
|
input.status_present,
|
||||||
|
input.status_copyable,
|
||||||
|
input.cards_build_matches,
|
||||||
|
input.factory_entries,
|
||||||
|
input.factory_exits,
|
||||||
|
input.factory_result,
|
||||||
|
input.factory_thread,
|
||||||
|
input.deserializer_entries,
|
||||||
|
input.deserializer_exits,
|
||||||
|
input.deserializer_this,
|
||||||
|
input.deserializer_reader,
|
||||||
|
input.deserializer_result,
|
||||||
|
input.deserializer_thread,
|
||||||
|
evidence.response_vtable,
|
||||||
|
evidence.base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA).unwrap_or(0),
|
||||||
|
input.response_class_matches,
|
||||||
|
input.model,
|
||||||
|
input.live_category_count,
|
||||||
|
input.category_count,
|
||||||
|
input.notifier_entries,
|
||||||
|
input.notifier_exits,
|
||||||
|
input.controller_matches,
|
||||||
|
input.controller_model_matches,
|
||||||
|
captured_controller,
|
||||||
|
controller as usize,
|
||||||
|
input.last_repaired_generation,
|
||||||
|
COMPLETION_DECISION.load(Ordering::Relaxed),
|
||||||
|
COMPLETION_REJECTION.load(Ordering::Relaxed),
|
||||||
|
));
|
||||||
|
COMPLETION_EXITS.fetch_add(1, Ordering::Release);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe extern "system" fn event_wrapper(
|
||||||
|
controller: *mut c_void,
|
||||||
|
event: u32,
|
||||||
|
payload: *mut c_void,
|
||||||
|
) -> usize {
|
||||||
|
EVENT_ENTRIES.fetch_add(1, Ordering::Relaxed);
|
||||||
|
EVENT_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
|
||||||
|
EVENT_CONTROLLER.store(controller as usize, Ordering::Relaxed);
|
||||||
|
EVENT_ID.store(event as usize, Ordering::Relaxed);
|
||||||
|
EVENT_PAYLOAD.store(payload as usize, Ordering::Relaxed);
|
||||||
|
match event {
|
||||||
|
FUT_SBS_CATEGORIES_EVENT => {
|
||||||
|
EVENT_CATEGORIES.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
SBC_REFRESH_EVENT => {
|
||||||
|
EVENT_REFRESH.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
FUT_SBS_CATEGORIES_READY_EVENT => {
|
||||||
|
EVENT_READY.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn allocate_completion_trampoline(target: usize) -> Option<usize> {
|
||||||
|
let failure_target = target.checked_add(0x5c)?;
|
||||||
|
let success_target = target.checked_add(COMPLETION_COPY_LEN)?;
|
||||||
|
let memory = VirtualAlloc(
|
||||||
|
core::ptr::null(),
|
||||||
|
COMPLETION_TRAMPOLINE_LEN,
|
||||||
|
MEM_COMMIT | MEM_RESERVE,
|
||||||
|
PAGE_READWRITE,
|
||||||
|
) as usize;
|
||||||
|
if memory == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
core::ptr::copy_nonoverlapping(target as *const u8, memory as *mut u8, 12);
|
||||||
|
// The relocated branch preserves the original null-status failure edge.
|
||||||
|
core::ptr::copy_nonoverlapping([0x75, 0x0e].as_ptr(), (memory + 12) as *mut u8, 2);
|
||||||
|
let failure = crate::sbc_trace::absolute_jump(failure_target);
|
||||||
|
core::ptr::copy_nonoverlapping(failure.as_ptr(), (memory + 14) as *mut u8, ABS_JUMP_LEN);
|
||||||
|
let success = crate::sbc_trace::absolute_jump(success_target);
|
||||||
|
core::ptr::copy_nonoverlapping(success.as_ptr(), (memory + 28) as *mut u8, ABS_JUMP_LEN);
|
||||||
|
let mut old = 0u32;
|
||||||
|
if VirtualProtect(
|
||||||
|
memory as _,
|
||||||
|
COMPLETION_TRAMPOLINE_LEN,
|
||||||
|
PAGE_EXECUTE_READ,
|
||||||
|
&mut old,
|
||||||
|
) == 0
|
||||||
|
|| FlushInstructionCache(GetCurrentProcess(), memory as _, COMPLETION_TRAMPOLINE_LEN) == 0
|
||||||
|
{
|
||||||
|
VirtualFree(memory as _, 0, MEM_RELEASE);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(memory)
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum InstallOutcome {
|
||||||
|
Installed,
|
||||||
|
CleanFailure,
|
||||||
|
DegradedHookActive,
|
||||||
|
DegradedProcessState,
|
||||||
|
DegradedHookAndProcess,
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn install_pair(base: usize) -> InstallOutcome {
|
||||||
|
let Some(completion) = crate::sbc_trace::target_va(base, COMPLETION_RVA) else {
|
||||||
|
return InstallOutcome::CleanFailure;
|
||||||
|
};
|
||||||
|
let Some(event) = crate::sbc_trace::target_va(base, EVENT_DISPATCH_RVA) else {
|
||||||
|
return InstallOutcome::CleanFailure;
|
||||||
|
};
|
||||||
|
let completion_original: [u8; COMPLETION_COPY_LEN] = COMPLETION_SIGNATURE
|
||||||
|
[..COMPLETION_COPY_LEN]
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
if !crate::sbc_trace::valid_cards_image(base)
|
||||||
|
|| !crate::sbc_trace::executable_range_in_image(
|
||||||
|
base,
|
||||||
|
completion,
|
||||||
|
COMPLETION_SIGNATURE.len(),
|
||||||
|
)
|
||||||
|
|| !crate::sbc_trace::executable_range_in_image(base, event, EVENT_SIGNATURE.len())
|
||||||
|
|| core::slice::from_raw_parts(completion as *const u8, COMPLETION_SIGNATURE.len())
|
||||||
|
!= COMPLETION_SIGNATURE
|
||||||
|
|| core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len()) != EVENT_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,
|
||||||
|
completion as *const u8,
|
||||||
|
&mut pinned,
|
||||||
|
) == 0
|
||||||
|
|| pinned as usize != base
|
||||||
|
{
|
||||||
|
return InstallOutcome::CleanFailure;
|
||||||
|
}
|
||||||
|
let Some(completion_trampoline) = allocate_completion_trampoline(completion) else {
|
||||||
|
return InstallOutcome::CleanFailure;
|
||||||
|
};
|
||||||
|
let Some(event_trampoline) = crate::sbc_trace::allocate_trampoline(event, EVENT_COPY_LEN)
|
||||||
|
else {
|
||||||
|
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
|
||||||
|
return InstallOutcome::CleanFailure;
|
||||||
|
};
|
||||||
|
COMPLETION_TRAMPOLINE.store(completion_trampoline, Ordering::Release);
|
||||||
|
EVENT_TRAMPOLINE.store(event_trampoline, Ordering::Release);
|
||||||
|
|
||||||
|
let Some(_gate) = crate::sbc_trace::acquire_patch_installer_gate() else {
|
||||||
|
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
|
||||||
|
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
|
||||||
|
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
|
||||||
|
EVENT_TRAMPOLINE.store(0, Ordering::Release);
|
||||||
|
return InstallOutcome::CleanFailure;
|
||||||
|
};
|
||||||
|
let mut peers = match crate::sbc_trace::suspend_peers(completion, event) {
|
||||||
|
Ok(peers) => peers,
|
||||||
|
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
|
||||||
|
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
|
||||||
|
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
|
||||||
|
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
|
||||||
|
EVENT_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(completion as *const u8, COMPLETION_SIGNATURE.len())
|
||||||
|
== COMPLETION_SIGNATURE
|
||||||
|
&& core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len())
|
||||||
|
== EVENT_SIGNATURE;
|
||||||
|
let transaction = if !final_valid {
|
||||||
|
InstallOutcome::CleanFailure
|
||||||
|
} else {
|
||||||
|
match write_entry(
|
||||||
|
completion,
|
||||||
|
completion_wrapper as *const () as usize,
|
||||||
|
&completion_original,
|
||||||
|
) {
|
||||||
|
Ok(()) => {
|
||||||
|
match write_entry(event, event_wrapper as *const () as usize, &EVENT_SIGNATURE) {
|
||||||
|
Ok(()) => InstallOutcome::Installed,
|
||||||
|
Err(event_clean) => {
|
||||||
|
let completion_clean = restore_entry(completion, &completion_original);
|
||||||
|
if event_clean && completion_clean {
|
||||||
|
InstallOutcome::CleanFailure
|
||||||
|
} else {
|
||||||
|
InstallOutcome::DegradedHookActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(completion_trampoline as _, 0, MEM_RELEASE);
|
||||||
|
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
|
||||||
|
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
|
||||||
|
EVENT_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_pair(base)
|
||||||
|
};
|
||||||
|
drop(_pending);
|
||||||
|
match outcome {
|
||||||
|
InstallOutcome::Installed => crate::write_log(
|
||||||
|
"SBC_DISPATCH: completion+event hooks installed; repair remains gate-controlled\n",
|
||||||
|
),
|
||||||
|
InstallOutcome::CleanFailure => {
|
||||||
|
crate::write_log("SBC_DISPATCH: clean install failure; inactive\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
InstallOutcome::DegradedHookActive => {
|
||||||
|
crate::write_log("SBC_DISPATCH: DEGRADED hook may be active; terminate game now\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
InstallOutcome::DegradedProcessState => {
|
||||||
|
crate::write_log("SBC_DISPATCH: DEGRADED thread state; terminate game now\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
InstallOutcome::DegradedHookAndProcess => {
|
||||||
|
crate::write_log("SBC_DISPATCH: DEGRADED hook and thread state; terminate game now\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut completion_seen = 0u64;
|
||||||
|
let mut event_seen = 0u64;
|
||||||
|
let mut reports = 0u8;
|
||||||
|
while reports < 64 {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
|
let completion_entries = COMPLETION_ENTRIES.load(Ordering::Acquire);
|
||||||
|
let event_entries = EVENT_ENTRIES.load(Ordering::Acquire);
|
||||||
|
if completion_entries != completion_seen || event_entries != event_seen {
|
||||||
|
crate::write_log(&format!(
|
||||||
|
"SBC_DISPATCH: completion entry={} exit={} tid={} controller={:#x} status_obj={:#x} status={} generation={} decision={} rejection={}; event entry={} exit={} tid={} controller={:#x} id={:#x} payload={:#x} categories={} refresh={} ready={}\n",
|
||||||
|
completion_entries,
|
||||||
|
COMPLETION_EXITS.load(Ordering::Acquire),
|
||||||
|
COMPLETION_THREAD.load(Ordering::Relaxed),
|
||||||
|
COMPLETION_CONTROLLER.load(Ordering::Relaxed),
|
||||||
|
COMPLETION_STATUS_OBJECT.load(Ordering::Relaxed),
|
||||||
|
COMPLETION_STATUS.load(Ordering::Relaxed),
|
||||||
|
COMPLETION_GENERATION.load(Ordering::Relaxed),
|
||||||
|
COMPLETION_DECISION.load(Ordering::Relaxed),
|
||||||
|
COMPLETION_REJECTION.load(Ordering::Relaxed),
|
||||||
|
event_entries,
|
||||||
|
EVENT_EXITS.load(Ordering::Acquire),
|
||||||
|
EVENT_THREAD.load(Ordering::Relaxed),
|
||||||
|
EVENT_CONTROLLER.load(Ordering::Relaxed),
|
||||||
|
EVENT_ID.load(Ordering::Relaxed),
|
||||||
|
EVENT_PAYLOAD.load(Ordering::Relaxed),
|
||||||
|
EVENT_CATEGORIES.load(Ordering::Relaxed),
|
||||||
|
EVENT_REFRESH.load(Ordering::Relaxed),
|
||||||
|
EVENT_READY.load(Ordering::Relaxed),
|
||||||
|
));
|
||||||
|
completion_seen = completion_entries;
|
||||||
|
event_seen = event_entries;
|
||||||
|
reports += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::write_log("SBC_DISPATCH: report cap reached; hooks remain installed\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install() {
|
||||||
|
// Promoted: armed by the build. No environment variable participates in the
|
||||||
|
// decision, so every launch path behaves identically.
|
||||||
|
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
|
||||||
|
crate::write_log(
|
||||||
|
"SBC_DISPATCH: repair ARMED (promoted); strict native evidence gate enabled\n",
|
||||||
|
);
|
||||||
|
std::thread::spawn(|| unsafe { worker() });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn valid_input(generation: u64) -> DecisionInput {
|
||||||
|
DecisionInput {
|
||||||
|
repair_enabled: true,
|
||||||
|
status: Some(UNKNOWN_TRANSPORT_STATUS),
|
||||||
|
status_present: true,
|
||||||
|
status_copyable: true,
|
||||||
|
cards_build_matches: true,
|
||||||
|
factory_entries: generation,
|
||||||
|
factory_exits: generation,
|
||||||
|
factory_result: 0x2000,
|
||||||
|
factory_thread: 7,
|
||||||
|
deserializer_entries: generation,
|
||||||
|
deserializer_exits: generation,
|
||||||
|
deserializer_this: 0x2000,
|
||||||
|
deserializer_reader: 0x3000,
|
||||||
|
deserializer_result: true,
|
||||||
|
deserializer_thread: 7,
|
||||||
|
response_class_matches: true,
|
||||||
|
model: 0x4000,
|
||||||
|
live_category_count: 2,
|
||||||
|
category_count: 2,
|
||||||
|
notifier_entries: generation,
|
||||||
|
notifier_exits: generation,
|
||||||
|
controller_matches: true,
|
||||||
|
controller_model_matches: true,
|
||||||
|
last_repaired_generation: generation - 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_success_is_never_rewritten() {
|
||||||
|
let mut input = valid_input(1);
|
||||||
|
input.status = Some(0);
|
||||||
|
assert_eq!(decide(input), Ok(Decision::NativeSuccess));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_unknown_status_and_full_evidence_allow_repair() {
|
||||||
|
assert_eq!(decide(valid_input(1)), Ok(Decision::Repair));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repair_is_exactly_gated_and_fail_closed() {
|
||||||
|
let mut input = valid_input(1);
|
||||||
|
input.repair_enabled = false;
|
||||||
|
assert_eq!(decide(input), Err(Rejection::RepairDisabled));
|
||||||
|
|
||||||
|
let mut input = valid_input(1);
|
||||||
|
input.status = Some(500);
|
||||||
|
assert_eq!(decide(input), Err(Rejection::UnsupportedStatus));
|
||||||
|
|
||||||
|
let mut input = valid_input(1);
|
||||||
|
input.category_count = 0;
|
||||||
|
assert_eq!(decide(input), Err(Rejection::ModelEmpty));
|
||||||
|
|
||||||
|
let mut input = valid_input(1);
|
||||||
|
input.controller_matches = false;
|
||||||
|
assert_eq!(decide(input), Err(Rejection::ControllerMismatch));
|
||||||
|
|
||||||
|
let mut input = valid_input(1);
|
||||||
|
input.status = None;
|
||||||
|
input.status_present = false;
|
||||||
|
assert_eq!(decide(input), Err(Rejection::NullStatus));
|
||||||
|
|
||||||
|
let mut input = valid_input(1);
|
||||||
|
input.status = None;
|
||||||
|
assert_eq!(decide(input), Err(Rejection::StatusUnreadable));
|
||||||
|
|
||||||
|
// Notifier still in flight for this generation (has not returned) is rejected:
|
||||||
|
// on the pinned build the completion only runs after the notifier unwinds.
|
||||||
|
let mut input = valid_input(1);
|
||||||
|
input.notifier_exits = 0;
|
||||||
|
assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent));
|
||||||
|
|
||||||
|
// A notifier count that does not match the current generation is rejected.
|
||||||
|
let mut input = valid_input(1);
|
||||||
|
input.notifier_entries = 2;
|
||||||
|
input.notifier_exits = 2;
|
||||||
|
assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent));
|
||||||
|
|
||||||
|
let mut input = valid_input(1);
|
||||||
|
input.controller_model_matches = false;
|
||||||
|
assert_eq!(decide(input), Err(Rejection::ControllerModelMismatch));
|
||||||
|
|
||||||
|
let mut input = valid_input(1);
|
||||||
|
input.live_category_count = 0;
|
||||||
|
assert_eq!(decide(input), Err(Rejection::ModelChanged));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_generation_is_one_shot_but_next_lifecycle_is_allowed() {
|
||||||
|
let mut duplicate = valid_input(1);
|
||||||
|
duplicate.last_repaired_generation = 1;
|
||||||
|
assert_eq!(decide(duplicate), Err(Rejection::DuplicateGeneration));
|
||||||
|
|
||||||
|
let next = valid_input(2);
|
||||||
|
assert_eq!(decide(next), Ok(Decision::Repair));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_class_requires_exact_pinned_vtable() {
|
||||||
|
let base = 0x1_8000_0000usize;
|
||||||
|
let expected = base + CATEGORY_RESPONSE_VTABLE_RVA;
|
||||||
|
assert!(response_class_matches(base, expected));
|
||||||
|
// An unreadable capture (zero) never qualifies.
|
||||||
|
assert!(!response_class_matches(base, 0));
|
||||||
|
// Any other vtable (e.g. a sub-object or a freed/reused slot) is rejected.
|
||||||
|
assert!(!response_class_matches(base, expected + 8));
|
||||||
|
assert!(!response_class_matches(base, base));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relocated_completion_branch_has_proven_layout() {
|
||||||
|
assert_eq!(COMPLETION_COPY_LEN, 14);
|
||||||
|
assert_eq!(
|
||||||
|
&COMPLETION_SIGNATURE[..12],
|
||||||
|
&[0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2]
|
||||||
|
);
|
||||||
|
assert_eq!(&COMPLETION_SIGNATURE[12..14], &[0x74, 0x4e]);
|
||||||
|
assert_eq!(COMPLETION_TRAMPOLINE_LEN, 42);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,9 @@
|
|||||||
//! (all addresses, RVA math, call order, crash risks, staged test plan):
|
//! (all addresses, RVA math, call order, crash risks, staged test plan):
|
||||||
//! fifa17-recon/docs/sbc-hook-dll-spec.md
|
//! fifa17-recon/docs/sbc-hook-dll-spec.md
|
||||||
//!
|
//!
|
||||||
//! Everything here is **inert by default** and gated by env vars, so shipping the DLL
|
//! Everything here is **inert by default** and gated by env vars:
|
||||||
//! with this module compiled in changes nothing unless a var is set:
|
|
||||||
//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY)
|
//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY)
|
||||||
//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY)
|
//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY)
|
||||||
//! OPENFUT_SBC_COMMIT=1 -> after proven native parse success, arm populated M
|
|
||||||
//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns)
|
//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns)
|
||||||
//!
|
//!
|
||||||
//! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we
|
//! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we
|
||||||
@@ -20,14 +18,11 @@
|
|||||||
//! See the spec for the verified disassembly behind each one.
|
//! See the spec for the verified disassembly behind each one.
|
||||||
|
|
||||||
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
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::LibraryLoader::GetModuleHandleA;
|
||||||
use windows_sys::Win32::System::Memory::{
|
use windows_sys::Win32::System::Memory::{
|
||||||
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ,
|
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE,
|
||||||
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE,
|
PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY,
|
||||||
PAGE_WRITECOPY,
|
|
||||||
};
|
};
|
||||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
|
|
||||||
|
|
||||||
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
|
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
|
||||||
const IMAGE_BASE: usize = 0x180000000;
|
const IMAGE_BASE: usize = 0x180000000;
|
||||||
@@ -46,13 +41,6 @@ const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate)
|
|||||||
const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5)
|
const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5)
|
||||||
const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap)
|
const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap)
|
||||||
const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count
|
const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count
|
||||||
const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
|
|
||||||
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
|
|
||||||
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
|
|
||||||
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
|
|
||||||
const SBC_COMPLETION_STATUS_JNE_RVA: usize = 0x0b8962;
|
|
||||||
const SBC_COMPLETION_STATUS_JNE: [u8; 2] = [0x75, 0x48];
|
|
||||||
const SBC_COMPLETION_STATUS_FALLTHROUGH: [u8; 2] = [0x90, 0x90];
|
|
||||||
const B_DTOR_RVA: usize = 0x63040;
|
const B_DTOR_RVA: usize = 0x63040;
|
||||||
const B_ISVALID_RVA: usize = 0x65d40;
|
const B_ISVALID_RVA: usize = 0x65d40;
|
||||||
const B_CLEAR_RVA: usize = 0x65d20;
|
const B_CLEAR_RVA: usize = 0x65d20;
|
||||||
@@ -87,11 +75,9 @@ mod rva {
|
|||||||
|
|
||||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
static ARMED: AtomicBool = AtomicBool::new(false);
|
||||||
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
|
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
|
||||||
static COMMIT: AtomicBool = AtomicBool::new(false);
|
|
||||||
static POPULATE: AtomicBool = AtomicBool::new(false);
|
static POPULATE: AtomicBool = AtomicBool::new(false);
|
||||||
static DONE: AtomicBool = AtomicBool::new(false);
|
static DONE: AtomicBool = AtomicBool::new(false);
|
||||||
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||||
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
|
|
||||||
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
|
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -147,13 +133,6 @@ enum ValidationError {
|
|||||||
CollectionUnreadable,
|
CollectionUnreadable,
|
||||||
CollectionNotNull,
|
CollectionNotNull,
|
||||||
ReadyByteNotWritable,
|
ReadyByteNotWritable,
|
||||||
ModelEmpty,
|
|
||||||
ControllerMissing,
|
|
||||||
ControllerVtableMismatch,
|
|
||||||
ControllerModelMismatch,
|
|
||||||
CompletionBranchMismatch,
|
|
||||||
CompletionBranchProtectFailed,
|
|
||||||
CompletionBranchFlushFailed,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
@@ -428,12 +407,6 @@ pub fn install() {
|
|||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
Ordering::Relaxed,
|
Ordering::Relaxed,
|
||||||
);
|
);
|
||||||
COMMIT.store(
|
|
||||||
std::env::var("OPENFUT_SBC_COMMIT")
|
|
||||||
.map(|v| v == "1")
|
|
||||||
.unwrap_or(false),
|
|
||||||
Ordering::Relaxed,
|
|
||||||
);
|
|
||||||
POPULATE.store(
|
POPULATE.store(
|
||||||
std::env::var("OPENFUT_SBC_POPULATE")
|
std::env::var("OPENFUT_SBC_POPULATE")
|
||||||
.map(|v| v == "1")
|
.map(|v| v == "1")
|
||||||
@@ -444,192 +417,6 @@ pub fn install() {
|
|||||||
std::thread::spawn(|| unsafe { worker() });
|
std::thread::spawn(|| unsafe { worker() });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records the concrete SBC controller observed registering FUT_SBS_CATEGORIES.
|
|
||||||
/// The registration hook is observational; all structural checks happen again on
|
|
||||||
/// the notifier thread before this address is trusted.
|
|
||||||
pub(crate) unsafe fn note_sbc_controller(controller: usize) {
|
|
||||||
let base = CARDS_BASE.load(Ordering::Acquire);
|
|
||||||
let valid = base != 0
|
|
||||||
&& read_ptr(controller) == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
|
||||||
&& controller
|
|
||||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
|
||||||
.and_then(|p| read_ptr(p))
|
|
||||||
== base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA);
|
|
||||||
if valid {
|
|
||||||
SBC_CONTROLLER.store(controller, Ordering::Release);
|
|
||||||
crate::write_log(&format!(
|
|
||||||
"SBC_CONTROLLER_TRACE: captured controller={controller:#x}\n"
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
crate::write_log(&format!(
|
|
||||||
"SBC_CONTROLLER_TRACE: rejected controller={controller:#x} (vtable mismatch)\n"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe fn log_controller_model(native_model: usize) {
|
|
||||||
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
|
||||||
let controller_model = controller
|
|
||||||
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
|
||||||
.and_then(|p| read_ptr(p))
|
|
||||||
.unwrap_or(0);
|
|
||||||
let main_vtable = read_ptr(controller).unwrap_or(0);
|
|
||||||
let event_vtable = controller
|
|
||||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
|
||||||
.and_then(|p| read_ptr(p))
|
|
||||||
.unwrap_or(0);
|
|
||||||
crate::write_log(&format!(
|
|
||||||
"SBC_CONTROLLER_TRACE: notifier controller={controller:#x} main_vt={main_vtable:#x} event_vt={event_vtable:#x} controller_M={controller_model:#x} parsed_M={native_model:#x} match={}\n",
|
|
||||||
controller != 0 && controller_model == native_model,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe fn validated_sbc_controller(
|
|
||||||
base: usize,
|
|
||||||
native_model: usize,
|
|
||||||
) -> Result<usize, ValidationError> {
|
|
||||||
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
|
||||||
if controller == 0 {
|
|
||||||
return Err(ValidationError::ControllerMissing);
|
|
||||||
}
|
|
||||||
if read_ptr(controller) != base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
|
||||||
|| controller
|
|
||||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
|
||||||
.and_then(|p| read_ptr(p))
|
|
||||||
!= base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA)
|
|
||||||
{
|
|
||||||
return Err(ValidationError::ControllerVtableMismatch);
|
|
||||||
}
|
|
||||||
if controller
|
|
||||||
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
|
||||||
.and_then(|p| read_ptr(p))
|
|
||||||
!= Some(native_model)
|
|
||||||
{
|
|
||||||
return Err(ValidationError::ControllerModelMismatch);
|
|
||||||
}
|
|
||||||
Ok(controller)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route the already-scheduled category completion through CardsDLL's own success
|
|
||||||
/// branch. The original function first rejects a non-zero status with a two-byte
|
|
||||||
/// `jne ServerErrSets`; after a separately proven native parse, that status belongs
|
|
||||||
/// to the stale scheduler completion rather than the category HTTP transaction.
|
|
||||||
unsafe fn arm_native_completion_success(base: usize) -> Result<(), ValidationError> {
|
|
||||||
let target = base
|
|
||||||
.checked_add(SBC_COMPLETION_STATUS_JNE_RVA)
|
|
||||||
.ok_or(ValidationError::AddressOverflow)?;
|
|
||||||
if !executable_range(target, SBC_COMPLETION_STATUS_JNE.len())
|
|
||||||
|| core::slice::from_raw_parts(target as *const u8, SBC_COMPLETION_STATUS_JNE.len())
|
|
||||||
!= SBC_COMPLETION_STATUS_JNE
|
|
||||||
{
|
|
||||||
return Err(ValidationError::CompletionBranchMismatch);
|
|
||||||
}
|
|
||||||
let mut old = 0u32;
|
|
||||||
if VirtualProtect(
|
|
||||||
target as _,
|
|
||||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
|
||||||
PAGE_EXECUTE_READWRITE,
|
|
||||||
&mut old,
|
|
||||||
) == 0
|
|
||||||
{
|
|
||||||
return Err(ValidationError::CompletionBranchProtectFailed);
|
|
||||||
}
|
|
||||||
core::ptr::copy_nonoverlapping(
|
|
||||||
SBC_COMPLETION_STATUS_FALLTHROUGH.as_ptr(),
|
|
||||||
target as *mut u8,
|
|
||||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
|
||||||
);
|
|
||||||
let flushed = FlushInstructionCache(
|
|
||||||
GetCurrentProcess(),
|
|
||||||
target as _,
|
|
||||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
|
||||||
) != 0;
|
|
||||||
let mut ignored = 0u32;
|
|
||||||
let protected = VirtualProtect(
|
|
||||||
target as _,
|
|
||||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
|
||||||
old,
|
|
||||||
&mut ignored,
|
|
||||||
) != 0;
|
|
||||||
if !flushed || !protected {
|
|
||||||
return Err(ValidationError::CompletionBranchFlushFailed);
|
|
||||||
}
|
|
||||||
crate::write_log(&format!(
|
|
||||||
"SBC_HOOK: armed native completion success branch at {target:#x} tid={}\n",
|
|
||||||
GetCurrentThreadId(),
|
|
||||||
));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Commit the already-populated native SBC model after the category success notifier.
|
|
||||||
///
|
|
||||||
/// This is called synchronously by the passive notifier wrapper *after* the original
|
|
||||||
/// notifier returns. It never invokes a parser or constructs game objects. The only
|
|
||||||
/// mutation is the established cache-ready byte, and only when the normal parser has
|
|
||||||
/// produced at least one category and every pointer/vtable invariant still matches.
|
|
||||||
pub(crate) unsafe fn commit_after_native_parse() {
|
|
||||||
if !COMMIT.load(Ordering::Acquire) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let base = CARDS_BASE.load(Ordering::Acquire);
|
|
||||||
if base == 0 || !control_matches(base) {
|
|
||||||
set_failed(ValidationError::AUnreadable);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
|
|
||||||
validate_snapshot(base, &snapshot)?;
|
|
||||||
if snapshot.m == 0
|
|
||||||
|| read_u16(snapshot.m + M_COUNT_OFF)
|
|
||||||
.filter(|&count| count > 0)
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
return Err(ValidationError::ModelEmpty);
|
|
||||||
}
|
|
||||||
if !writable_u8(snapshot.b + B_READY_OFF) {
|
|
||||||
return Err(ValidationError::ReadyByteNotWritable);
|
|
||||||
}
|
|
||||||
Ok(snapshot)
|
|
||||||
}) {
|
|
||||||
Ok(snapshot) => snapshot,
|
|
||||||
Err(error) => {
|
|
||||||
set_failed(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let count = read_u16(snapshot.m + M_COUNT_OFF).unwrap_or(0);
|
|
||||||
log_controller_model(snapshot.m);
|
|
||||||
if DONE.swap(true, Ordering::AcqRel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
crate::write_log(&format!(
|
|
||||||
"SBC_HOOK: post-parse commit -> M={:#x} categories={} BYTE[{:#x}]=1\n",
|
|
||||||
snapshot.m,
|
|
||||||
count,
|
|
||||||
snapshot.b + B_READY_OFF,
|
|
||||||
));
|
|
||||||
core::ptr::write_volatile((snapshot.b + B_READY_OFF) as *mut u8, 1);
|
|
||||||
if read_u8(snapshot.b + B_READY_OFF) != Some(1)
|
|
||||||
|| !transition(RuntimeState::Validated, RuntimeState::Committed)
|
|
||||||
{
|
|
||||||
set_failed(ValidationError::ReadyByteUnexpected);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let _controller = match validated_sbc_controller(base, snapshot.m) {
|
|
||||||
Ok(controller) => controller,
|
|
||||||
Err(error) => {
|
|
||||||
set_failed(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if let Err(error) = arm_native_completion_success(base) {
|
|
||||||
set_failed(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
crate::write_log(
|
|
||||||
"SBC_HOOK: post-parse commit DONE; awaiting CardsDLL native completion events\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when
|
/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when
|
||||||
/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm)
|
/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm)
|
||||||
/// exactly once.
|
/// exactly once.
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ static TRACE_BASE: AtomicUsize = AtomicUsize::new(0);
|
|||||||
static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0);
|
static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0);
|
||||||
static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0);
|
static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||||
static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX);
|
static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||||
|
static DESERIALIZER_EXIT_RESPONSE_VTABLE: AtomicUsize = AtomicUsize::new(0);
|
||||||
static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||||
static CONTROLLER_REGISTER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
static CONTROLLER_REGISTER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||||
static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
||||||
@@ -93,7 +94,7 @@ static NOTIFIER_COUNT: AtomicUsize = AtomicUsize::new(usize::MAX);
|
|||||||
static PATCH_INSTALLER_BUSY: AtomicBool = AtomicBool::new(false);
|
static PATCH_INSTALLER_BUSY: AtomicBool = AtomicBool::new(false);
|
||||||
static CODE_PATCH_PENDING: AtomicUsize = AtomicUsize::new(0);
|
static CODE_PATCH_PENDING: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
struct PatchInstallerGate;
|
pub(crate) struct PatchInstallerGate;
|
||||||
|
|
||||||
impl Drop for PatchInstallerGate {
|
impl Drop for PatchInstallerGate {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
@@ -101,7 +102,7 @@ impl Drop for PatchInstallerGate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
|
pub(crate) fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
|
||||||
for _ in 0..200 {
|
for _ in 0..200 {
|
||||||
if PATCH_INSTALLER_BUSY
|
if PATCH_INSTALLER_BUSY
|
||||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||||
@@ -114,7 +115,7 @@ fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CodeInstallerPending;
|
pub(crate) struct CodeInstallerPending;
|
||||||
|
|
||||||
impl Drop for CodeInstallerPending {
|
impl Drop for CodeInstallerPending {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
@@ -138,15 +139,15 @@ enum TraceState {
|
|||||||
DegradedHookAndProcess,
|
DegradedHookAndProcess,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn env_enabled(value: Option<&str>) -> bool {
|
pub(crate) fn env_enabled(value: Option<&str>) -> bool {
|
||||||
matches!(value, Some("1"))
|
matches!(value, Some("1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn target_va(base: usize, rva: usize) -> Option<usize> {
|
pub(crate) fn target_va(base: usize, rva: usize) -> Option<usize> {
|
||||||
base.checked_add(rva)
|
base.checked_add(rva)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] {
|
pub(crate) fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] {
|
||||||
let mut jump = [0u8; ABS_JUMP_LEN];
|
let mut jump = [0u8; ABS_JUMP_LEN];
|
||||||
jump[..6].copy_from_slice(&[0xff, 0x25, 0, 0, 0, 0]);
|
jump[..6].copy_from_slice(&[0xff, 0x25, 0, 0, 0, 0]);
|
||||||
jump[6..].copy_from_slice(&(destination as u64).to_le_bytes());
|
jump[6..].copy_from_slice(&(destination as u64).to_le_bytes());
|
||||||
@@ -160,7 +161,7 @@ fn instruction_pointer_in_span(rip: usize, target: usize) -> bool {
|
|||||||
.unwrap_or(true)
|
.unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SuspendedPeers {
|
pub(crate) struct SuspendedPeers {
|
||||||
handles: [HANDLE; MAX_PEERS],
|
handles: [HANDLE; MAX_PEERS],
|
||||||
tids: [u32; MAX_PEERS],
|
tids: [u32; MAX_PEERS],
|
||||||
count: usize,
|
count: usize,
|
||||||
@@ -179,7 +180,7 @@ impl SuspendedPeers {
|
|||||||
self.tids[..self.count].contains(&tid)
|
self.tids[..self.count].contains(&tid)
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn resume_all(&mut self) -> bool {
|
pub(crate) unsafe fn resume_all(&mut self) -> bool {
|
||||||
let mut all_resumed = true;
|
let mut all_resumed = true;
|
||||||
for index in (0..self.count).rev() {
|
for index in (0..self.count).rev() {
|
||||||
let handle = self.handles[index];
|
let handle = self.handles[index];
|
||||||
@@ -208,14 +209,14 @@ impl Drop for SuspendedPeers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum QuiesceFailure {
|
pub(crate) enum QuiesceFailure {
|
||||||
Acquire,
|
Acquire,
|
||||||
Resume,
|
Resume,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop and inspect every peer thread before touching either entry point. Any
|
/// Stop and inspect every peer thread before touching either entry point. Any
|
||||||
/// incomplete enumeration/access/context operation fails the transaction closed.
|
/// incomplete enumeration/access/context operation fails the transaction closed.
|
||||||
unsafe fn suspend_peers(
|
pub(crate) unsafe fn suspend_peers(
|
||||||
factory: usize,
|
factory: usize,
|
||||||
deserializer: usize,
|
deserializer: usize,
|
||||||
) -> Result<SuspendedPeers, QuiesceFailure> {
|
) -> Result<SuspendedPeers, QuiesceFailure> {
|
||||||
@@ -329,7 +330,7 @@ unsafe fn executable_range(address: usize, length: usize) -> bool {
|
|||||||
) && end <= mbi.BaseAddress as usize + mbi.RegionSize
|
) && end <= mbi.BaseAddress as usize + mbi.RegionSize
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool {
|
pub(crate) unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool {
|
||||||
let Some(end) = address.checked_add(length) else {
|
let Some(end) = address.checked_add(length) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -347,7 +348,7 @@ unsafe fn executable_range_in_image(base: usize, address: usize, length: usize)
|
|||||||
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn readable_range(address: usize, length: usize) -> bool {
|
pub(crate) unsafe fn readable_range(address: usize, length: usize) -> bool {
|
||||||
let Some(end) = address.checked_add(length) else {
|
let Some(end) = address.checked_add(length) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -362,20 +363,20 @@ unsafe fn readable_range(address: usize, length: usize) -> bool {
|
|||||||
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn guarded_usize(address: usize) -> Option<usize> {
|
pub(crate) unsafe fn guarded_usize(address: usize) -> Option<usize> {
|
||||||
(address & 7 == 0 && readable_range(address, 8))
|
(address & 7 == 0 && readable_range(address, 8))
|
||||||
.then(|| core::ptr::read_volatile(address as *const usize))
|
.then(|| core::ptr::read_volatile(address as *const usize))
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn guarded_u16(address: usize) -> Option<u16> {
|
pub(crate) unsafe fn guarded_u16(address: usize) -> Option<u16> {
|
||||||
readable_range(address, 2).then(|| core::ptr::read_volatile(address as *const u16))
|
readable_range(address, 2).then(|| core::ptr::read_volatile(address as *const u16))
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn guarded_u8(address: usize) -> Option<u8> {
|
pub(crate) unsafe fn guarded_u8(address: usize) -> Option<u8> {
|
||||||
readable_range(address, 1).then(|| core::ptr::read_volatile(address as *const u8))
|
readable_range(address, 1).then(|| core::ptr::read_volatile(address as *const u8))
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn valid_cards_image(base: usize) -> bool {
|
pub(crate) unsafe fn valid_cards_image(base: usize) -> bool {
|
||||||
let Some(control) = base.checked_add(CONTROL_RVA) else {
|
let Some(control) = base.checked_add(CONTROL_RVA) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -413,7 +414,7 @@ unsafe fn signature_matches(target: usize, signature: &[u8; 32]) -> bool {
|
|||||||
core::slice::from_raw_parts(target as *const u8, signature.len()) == signature
|
core::slice::from_raw_parts(target as *const u8, signature.len()) == signature
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option<usize> {
|
pub(crate) unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option<usize> {
|
||||||
let trampoline_len = copy_len.checked_add(ABS_JUMP_LEN)?;
|
let trampoline_len = copy_len.checked_add(ABS_JUMP_LEN)?;
|
||||||
let memory = VirtualAlloc(
|
let memory = VirtualAlloc(
|
||||||
core::ptr::null(),
|
core::ptr::null(),
|
||||||
@@ -595,7 +596,10 @@ unsafe extern "system" fn controller_register_wrapper(controller: *mut c_void, e
|
|||||||
core::mem::transmute(CONTROLLER_REGISTER_TRAMPOLINE.load(Ordering::Acquire));
|
core::mem::transmute(CONTROLLER_REGISTER_TRAMPOLINE.load(Ordering::Acquire));
|
||||||
original(controller, event);
|
original(controller, event);
|
||||||
if event == FUT_SBS_CATEGORIES_EVENT {
|
if event == FUT_SBS_CATEGORIES_EVENT {
|
||||||
crate::sbc_hook::note_sbc_controller(controller as usize);
|
crate::sbc_dispatch::note_sbc_controller(
|
||||||
|
controller as usize,
|
||||||
|
TRACE_BASE.load(Ordering::Acquire),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,7 +634,6 @@ unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) {
|
|||||||
let original: unsafe extern "system" fn(*mut c_void) =
|
let original: unsafe extern "system" fn(*mut c_void) =
|
||||||
core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire));
|
core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire));
|
||||||
original(ctx);
|
original(ctx);
|
||||||
crate::sbc_hook::commit_after_native_parse();
|
|
||||||
NOTIFIER_BYTE_AFTER.store(
|
NOTIFIER_BYTE_AFTER.store(
|
||||||
address
|
address
|
||||||
.checked_add(0x88)
|
.checked_add(0x88)
|
||||||
@@ -682,12 +685,54 @@ unsafe extern "system" fn deserializer_wrapper(this: *mut c_void, reader: *mut c
|
|||||||
.and_then(|slot| guarded_u8(slot))
|
.and_then(|slot| guarded_u8(slot))
|
||||||
.map(usize::from)
|
.map(usize::from)
|
||||||
.unwrap_or(usize::MAX);
|
.unwrap_or(usize::MAX);
|
||||||
|
let response_vtable = guarded_usize(this as usize).unwrap_or(0);
|
||||||
DESERIALIZER_EXIT_M.store(m, Ordering::Relaxed);
|
DESERIALIZER_EXIT_M.store(m, Ordering::Relaxed);
|
||||||
DESERIALIZER_EXIT_COUNT.store(count, Ordering::Relaxed);
|
DESERIALIZER_EXIT_COUNT.store(count, Ordering::Relaxed);
|
||||||
DESERIALIZER_EXIT_B_READY.store(ready, Ordering::Relaxed);
|
DESERIALIZER_EXIT_B_READY.store(ready, Ordering::Relaxed);
|
||||||
|
DESERIALIZER_EXIT_RESPONSE_VTABLE.store(response_vtable, Ordering::Relaxed);
|
||||||
DESERIALIZER_EXITS.fetch_add(1, Ordering::Release);
|
DESERIALIZER_EXITS.fetch_add(1, Ordering::Release);
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(crate) struct DispatchEvidence {
|
||||||
|
pub(crate) base: usize,
|
||||||
|
pub(crate) factory_entries: u64,
|
||||||
|
pub(crate) factory_exits: u64,
|
||||||
|
pub(crate) factory_result: usize,
|
||||||
|
pub(crate) factory_thread: usize,
|
||||||
|
pub(crate) deserializer_entries: u64,
|
||||||
|
pub(crate) deserializer_exits: u64,
|
||||||
|
pub(crate) deserializer_this: usize,
|
||||||
|
pub(crate) deserializer_reader: usize,
|
||||||
|
pub(crate) deserializer_result: bool,
|
||||||
|
pub(crate) deserializer_thread: usize,
|
||||||
|
pub(crate) response_vtable: usize,
|
||||||
|
pub(crate) model: usize,
|
||||||
|
pub(crate) category_count: usize,
|
||||||
|
pub(crate) notifier_entries: u64,
|
||||||
|
pub(crate) notifier_exits: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn dispatch_evidence() -> DispatchEvidence {
|
||||||
|
DispatchEvidence {
|
||||||
|
base: TRACE_BASE.load(Ordering::Acquire),
|
||||||
|
factory_entries: FACTORY_ENTRIES.load(Ordering::Acquire),
|
||||||
|
factory_exits: FACTORY_EXITS.load(Ordering::Acquire),
|
||||||
|
factory_result: FACTORY_LAST_RESULT.load(Ordering::Acquire),
|
||||||
|
factory_thread: FACTORY_LAST_THREAD.load(Ordering::Relaxed),
|
||||||
|
deserializer_entries: DESERIALIZER_ENTRIES.load(Ordering::Acquire),
|
||||||
|
deserializer_exits: DESERIALIZER_EXITS.load(Ordering::Acquire),
|
||||||
|
deserializer_this: DESERIALIZER_LAST_THIS.load(Ordering::Relaxed),
|
||||||
|
deserializer_reader: DESERIALIZER_LAST_READER.load(Ordering::Relaxed),
|
||||||
|
deserializer_result: DESERIALIZER_LAST_RESULT.load(Ordering::Acquire),
|
||||||
|
deserializer_thread: DESERIALIZER_LAST_THREAD.load(Ordering::Relaxed),
|
||||||
|
response_vtable: DESERIALIZER_EXIT_RESPONSE_VTABLE.load(Ordering::Relaxed),
|
||||||
|
model: DESERIALIZER_EXIT_M.load(Ordering::Relaxed),
|
||||||
|
category_count: DESERIALIZER_EXIT_COUNT.load(Ordering::Relaxed),
|
||||||
|
notifier_entries: NOTIFIER_ENTRIES.load(Ordering::Acquire),
|
||||||
|
notifier_exits: NOTIFIER_EXITS.load(Ordering::Acquire),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum InstallOutcome {
|
enum InstallOutcome {
|
||||||
@@ -1103,10 +1148,17 @@ fn install_notifier(enabled: bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn install() {
|
pub(crate) fn install() {
|
||||||
let enabled = env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
|
// The repair's evidence traces (parser, notifier, controller registration) are
|
||||||
let notifier_enabled = env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
|
// its decision inputs, so they follow the promoted repair, not an env var.
|
||||||
|
let dispatch_repair = crate::sbc_dispatch::REPAIR_PROMOTED;
|
||||||
|
let dispatch_trace =
|
||||||
|
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_DISPATCH_TRACE").ok().as_deref());
|
||||||
|
let enabled =
|
||||||
|
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
|
||||||
|
let notifier_enabled =
|
||||||
|
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
|
||||||
CODE_PATCH_PENDING.store(
|
CODE_PATCH_PENDING.store(
|
||||||
enabled as usize + (notifier_enabled as usize * 2),
|
enabled as usize + (notifier_enabled as usize * 2) + dispatch_trace as usize,
|
||||||
Ordering::Release,
|
Ordering::Release,
|
||||||
);
|
);
|
||||||
install_notifier(notifier_enabled);
|
install_notifier(notifier_enabled);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+639
-451
File diff suppressed because it is too large
Load Diff
+2
-76
@@ -168,26 +168,6 @@ pub struct LauncherConfig {
|
|||||||
/// Dead EA hostnames that must resolve to `openfut_server_host`.
|
/// Dead EA hostnames that must resolve to `openfut_server_host`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ea_hostnames: Vec<String>,
|
pub ea_hostnames: Vec<String>,
|
||||||
|
|
||||||
// ── FIFA 17 local companion services (client-side, run on THIS machine) ──
|
|
||||||
// FIFA 17's FUT flow needs two pieces that are inherently local to the game
|
|
||||||
// box and cannot move to the server: the LSX Origin emulator (the game dials
|
|
||||||
// it on the hardcoded loopback 127.0.0.1:4216) and autopatch (patches
|
|
||||||
// FIFA17.exe process memory for ProtoSSL cert-verify). The launcher manages
|
|
||||||
// both as child processes. The heavy responders (Blaze/UTAS/roster/POW) run
|
|
||||||
// in the server container; these two stay here.
|
|
||||||
/// Directory holding the FIFA 17 Python responders (fifa17-recon `tools/`).
|
|
||||||
/// Empty means the local-services feature is unconfigured and its controls
|
|
||||||
/// stay disabled.
|
|
||||||
#[serde(default)]
|
|
||||||
pub fifa17_tools_dir: String,
|
|
||||||
/// Python interpreter used to run the local companion services.
|
|
||||||
#[serde(default = "default_python")]
|
|
||||||
pub fifa17_python: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_python() -> String {
|
|
||||||
"python3".to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_https_port() -> u16 {
|
fn default_https_port() -> u16 {
|
||||||
@@ -271,11 +251,6 @@ impl Default for LauncherConfig {
|
|||||||
game_profile: GameProfile::default(),
|
game_profile: GameProfile::default(),
|
||||||
ea_redirect_probe_ip: String::new(),
|
ea_redirect_probe_ip: String::new(),
|
||||||
ea_hostnames: Vec::new(),
|
ea_hostnames: Vec::new(),
|
||||||
fifa17_tools_dir: base
|
|
||||||
.join("fifa17-recon/tools")
|
|
||||||
.to_string_lossy()
|
|
||||||
.into(),
|
|
||||||
fifa17_python: default_python(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,19 +329,6 @@ impl LauncherConfig {
|
|||||||
self.server_config().validate().map_err(|e| e.to_string())
|
self.server_config().validate().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate the client-local FIFA 17 service configuration. Filesystem
|
|
||||||
/// existence is checked by the process launcher immediately before spawn;
|
|
||||||
/// this ensures required user configuration is never silently invented.
|
|
||||||
pub fn validate_local_services(&self) -> Result<(), String> {
|
|
||||||
if self.fifa17_tools_dir.trim().is_empty() {
|
|
||||||
return Err("No FIFA 17 tools dir configured. Set it in Settings.".into());
|
|
||||||
}
|
|
||||||
if self.fifa17_python.trim().is_empty() {
|
|
||||||
return Err("No Python interpreter configured. Set it in Settings.".into());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate every configuration value required by the one-button FIFA 17
|
/// Validate every configuration value required by the one-button FIFA 17
|
||||||
/// launch path. Runtime state such as hook deployment is checked by the UI.
|
/// launch path. Runtime state such as hook deployment is checked by the UI.
|
||||||
pub fn validate_launch_config(&self) -> Result<(), String> {
|
pub fn validate_launch_config(&self) -> Result<(), String> {
|
||||||
@@ -384,7 +346,7 @@ impl LauncherConfig {
|
|||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.validate_local_services()
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_account(&self) -> Result<(), String> {
|
pub fn validate_account(&self) -> Result<(), String> {
|
||||||
@@ -508,31 +470,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_services_require_tools_dir_and_python() {
|
fn launch_config_requires_server_account_and_command() {
|
||||||
let mut c = LauncherConfig::default();
|
|
||||||
c.fifa17_tools_dir.clear();
|
|
||||||
assert!(c
|
|
||||||
.validate_local_services()
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("tools dir"));
|
|
||||||
|
|
||||||
c.fifa17_tools_dir = "/tmp/fifa17-tools".into();
|
|
||||||
c.fifa17_python.clear();
|
|
||||||
assert!(c.validate_local_services().unwrap_err().contains("Python"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_services_accept_explicit_configuration() {
|
|
||||||
let c = LauncherConfig {
|
|
||||||
fifa17_tools_dir: "/tmp/fifa17-tools".into(),
|
|
||||||
fifa17_python: "/usr/bin/python3".into(),
|
|
||||||
..LauncherConfig::default()
|
|
||||||
};
|
|
||||||
assert!(c.validate_local_services().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn launch_config_requires_server_local_services_and_command() {
|
|
||||||
let mut c = LauncherConfig::default();
|
let mut c = LauncherConfig::default();
|
||||||
assert!(c.validate_launch_config().is_err());
|
assert!(c.validate_launch_config().is_err());
|
||||||
|
|
||||||
@@ -545,14 +483,6 @@ mod tests {
|
|||||||
.contains("launch command"));
|
.contains("launch command"));
|
||||||
|
|
||||||
c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into();
|
c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into();
|
||||||
c.fifa17_tools_dir.clear();
|
|
||||||
assert!(c
|
|
||||||
.validate_launch_config()
|
|
||||||
.unwrap_err()
|
|
||||||
.contains("tools dir"));
|
|
||||||
|
|
||||||
c.fifa17_tools_dir = "/home/alex/Documents/OpenFUT/fifa17-recon/tools".into();
|
|
||||||
c.fifa17_python = "/usr/bin/python3".into();
|
|
||||||
assert!(c.validate_launch_config().is_ok());
|
assert!(c.validate_launch_config().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,8 +510,6 @@ mod tests {
|
|||||||
openfut_server_host: "10.10.0.120".into(),
|
openfut_server_host: "10.10.0.120".into(),
|
||||||
fut_persona_id: 1,
|
fut_persona_id: 1,
|
||||||
fut_persona_name: "X".into(),
|
fut_persona_name: "X".into(),
|
||||||
fifa17_tools_dir: "/tmp/tools".into(),
|
|
||||||
fifa17_python: "/usr/bin/python3".into(),
|
|
||||||
..LauncherConfig::default()
|
..LauncherConfig::default()
|
||||||
};
|
};
|
||||||
c.game_launch_command.clear();
|
c.game_launch_command.clear();
|
||||||
@@ -609,8 +537,6 @@ mod tests {
|
|||||||
openfut_server_host: "10.10.0.120".into(),
|
openfut_server_host: "10.10.0.120".into(),
|
||||||
fut_persona_id: 1,
|
fut_persona_id: 1,
|
||||||
fut_persona_name: "X".into(),
|
fut_persona_name: "X".into(),
|
||||||
fifa17_tools_dir: "/tmp/tools".into(),
|
|
||||||
fifa17_python: "/usr/bin/python3".into(),
|
|
||||||
game_launch_command: "/home/u/launch.sh".into(),
|
game_launch_command: "/home/u/launch.sh".into(),
|
||||||
..LauncherConfig::default()
|
..LauncherConfig::default()
|
||||||
};
|
};
|
||||||
|
|||||||
+169
-5
@@ -24,6 +24,7 @@
|
|||||||
//! falls back to it, so an existing working setup cannot be broken by upgrading.
|
//! falls back to it, so an existing working setup cannot be broken by upgrading.
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
@@ -42,8 +43,13 @@ fn say(log: &Log, msg: impl Into<String>) {
|
|||||||
/// Prepare the prefix, satisfy the licence precondition, and start the game.
|
/// Prepare the prefix, satisfy the licence precondition, and start the game.
|
||||||
///
|
///
|
||||||
/// Returns once the game process has been spawned; its output continues to
|
/// Returns once the game process has been spawned; its output continues to
|
||||||
/// stream into `log` on background threads.
|
/// stream into `log` on background threads. `on_exit` fires when the process
|
||||||
pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
/// ends, which is how the launch state machine leaves its Running state.
|
||||||
|
pub fn launch(
|
||||||
|
profile: &GameProfile,
|
||||||
|
log: &Log,
|
||||||
|
on_exit: impl FnOnce() + Send + 'static,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
profile.validate().map_err(anyhow::Error::msg)?;
|
profile.validate().map_err(anyhow::Error::msg)?;
|
||||||
|
|
||||||
let game_dir = PathBuf::from(&profile.game_dir);
|
let game_dir = PathBuf::from(&profile.game_dir);
|
||||||
@@ -52,6 +58,7 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prepare_prefix(profile, log)?;
|
prepare_prefix(profile, log)?;
|
||||||
|
ensure_dll_override(profile, log);
|
||||||
ensure_license(profile, log)?;
|
ensure_license(profile, log)?;
|
||||||
|
|
||||||
let mut cmd = Command::new(&profile.runner);
|
let mut cmd = Command::new(&profile.runner);
|
||||||
@@ -62,6 +69,7 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
|||||||
for (k, v) in &profile.env {
|
for (k, v) in &profile.env {
|
||||||
cmd.env(k, v);
|
cmd.env(k, v);
|
||||||
}
|
}
|
||||||
|
cmd.env("WINEDLLOVERRIDES", hook_dll_overrides(&profile.env));
|
||||||
if !profile.wine_prefix.trim().is_empty() {
|
if !profile.wine_prefix.trim().is_empty() {
|
||||||
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
||||||
}
|
}
|
||||||
@@ -79,10 +87,132 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
|||||||
let child = cmd
|
let child = cmd
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
|
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
|
||||||
stream(child, log.clone(), "[launcher] game process exited.");
|
stream(
|
||||||
|
child,
|
||||||
|
log.clone(),
|
||||||
|
"[launcher] game process exited.",
|
||||||
|
on_exit,
|
||||||
|
);
|
||||||
Ok(())
|
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
|
||||||
|
/// prefers a local DLL over its own builtin ONLY when `WINEDLLOVERRIDES` names it
|
||||||
|
/// (see `setup::STEAM_LAUNCH_OPTIONS`). Steam users get that from their launch
|
||||||
|
/// options; when the launcher spawns the runner itself, nothing else supplies it.
|
||||||
|
///
|
||||||
|
/// Without it the failure is silent and badly misleading: the hook never loads, so
|
||||||
|
/// the `openfut.cfg` the launcher just wrote is inert, the game ignores the
|
||||||
|
/// configured Blaze ports, and `/etc/hosts` quietly routes it to whatever answers
|
||||||
|
/// on EA's real ports. It looks like a working launch against the configured
|
||||||
|
/// server while actually talking to a different one.
|
||||||
|
///
|
||||||
|
/// A profile that already pins `version=` wins: an operator overriding the hijack
|
||||||
|
/// deliberately must not be silently overruled.
|
||||||
|
fn hook_dll_overrides(env: &BTreeMap<String, String>) -> String {
|
||||||
|
const HOOK: &str = "version=n,b";
|
||||||
|
match env.get("WINEDLLOVERRIDES").map(|v| v.trim()) {
|
||||||
|
Some(existing) if existing.contains("version=") => existing.to_string(),
|
||||||
|
Some(existing) if !existing.is_empty() => format!("{existing};{HOOK}"),
|
||||||
|
_ => HOOK.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Create the Wine prefix's `dosdevices` entries the profile asks for.
|
/// Create the Wine prefix's `dosdevices` entries the profile asks for.
|
||||||
///
|
///
|
||||||
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
|
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
|
||||||
@@ -222,7 +352,12 @@ fn non_empty_file(path: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pump a child's stdout and stderr into the log buffer and reap it.
|
/// Pump a child's stdout and stderr into the log buffer and reap it.
|
||||||
pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
|
pub fn stream(
|
||||||
|
mut child: Child,
|
||||||
|
log: Log,
|
||||||
|
exit_msg: &'static str,
|
||||||
|
on_exit: impl FnOnce() + Send + 'static,
|
||||||
|
) {
|
||||||
if let Some(out) = child.stdout.take() {
|
if let Some(out) = child.stdout.take() {
|
||||||
let buf = Arc::clone(&log);
|
let buf = Arc::clone(&log);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -242,6 +377,7 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
log.lock().push(exit_msg.to_string());
|
log.lock().push(exit_msg.to_string());
|
||||||
|
on_exit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +580,35 @@ mod tests {
|
|||||||
game_dir: "/definitely/not/here".into(),
|
game_dir: "/definitely/not/here".into(),
|
||||||
..GameProfile::default()
|
..GameProfile::default()
|
||||||
};
|
};
|
||||||
let err = launch(&profile, &log()).unwrap_err().to_string();
|
let err = launch(&profile, &log(), || {}).unwrap_err().to_string();
|
||||||
assert!(err.contains("game_dir does not exist"), "{err}");
|
assert!(err.contains("game_dir does not exist"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_profile_without_overrides_still_gets_the_hook_hijack() {
|
||||||
|
// The regression this guards: FIFA launched from the launcher ignored the
|
||||||
|
// configured Blaze ports entirely, because Proton loaded its own builtin
|
||||||
|
// version.dll and the hook proxy never ran. The launch looked healthy.
|
||||||
|
assert_eq!(hook_dll_overrides(&BTreeMap::new()), "version=n,b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrelated_overrides_are_preserved_and_appended_to() {
|
||||||
|
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "d3d11=n".to_string())]);
|
||||||
|
assert_eq!(hook_dll_overrides(&env), "d3d11=n;version=n,b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_explicit_version_override_is_never_overruled() {
|
||||||
|
// An operator disabling the hijack on purpose must win, otherwise the
|
||||||
|
// setting is a lie.
|
||||||
|
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), "version=b".to_string())]);
|
||||||
|
assert_eq!(hook_dll_overrides(&env), "version=b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blank_override_is_treated_as_absent_rather_than_appended_to() {
|
||||||
|
let env = BTreeMap::from([("WINEDLLOVERRIDES".to_string(), " ".to_string())]);
|
||||||
|
assert_eq!(hook_dll_overrides(&env), "version=n,b");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+940
@@ -0,0 +1,940 @@
|
|||||||
|
//! The launch sequence, as an explicit state machine.
|
||||||
|
//!
|
||||||
|
//! # Why this exists
|
||||||
|
//!
|
||||||
|
//! The launcher used to make the user perform OpenFUT's internal launch order by
|
||||||
|
//! hand: start LSX, start autopatch, run pre-launch checks, "Arm client", then
|
||||||
|
//! press a button called *Start Services & Launch Game*. Every one of those is an
|
||||||
|
//! implementation detail of how FIFA 17 is persuaded to talk to OpenFUT, and
|
||||||
|
//! getting the order wrong produced failures that surfaced much later as "the
|
||||||
|
//! game crashed" — autopatch started before `ptrace_scope` was 0 silently does
|
||||||
|
//! nothing at all.
|
||||||
|
//!
|
||||||
|
//! So the sequence lives here, once, and the UI renders it. One button.
|
||||||
|
//!
|
||||||
|
//! # Ordering, and where it deviates from the obvious
|
||||||
|
//!
|
||||||
|
//! Client preparation (`arm`) runs BEFORE autopatch, not after: autopatch writes
|
||||||
|
//! `/proc/<FIFA17.exe>/mem`, which Yama forbids until arming sets
|
||||||
|
//! `kernel.yama.ptrace_scope=0`. Starting autopatch first would "succeed" and
|
||||||
|
//! then quietly fail to patch anything.
|
||||||
|
//!
|
||||||
|
//! # Idempotence
|
||||||
|
//!
|
||||||
|
//! Every step asks what is already true before acting. A healthy service is
|
||||||
|
//! reused, never restarted; client preparation is skipped when the checks it
|
||||||
|
//! would repair already pass, which also avoids an unnecessary Polkit prompt.
|
||||||
|
//!
|
||||||
|
//! # Testability
|
||||||
|
//!
|
||||||
|
//! The effects — spawning services, elevating for arming, writing the hook
|
||||||
|
//! config, starting the game — sit behind [`LaunchOps`]. [`run_sequence`] is
|
||||||
|
//! therefore a pure decision procedure over observed state, and the sequencing
|
||||||
|
//! rules that matter (don't launch after a failed step, don't restart healthy
|
||||||
|
//! services, don't kill what we didn't start) are unit-testable without a FIFA
|
||||||
|
//! install, a Polkit agent, or root.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::config::LauncherConfig;
|
||||||
|
use crate::fifa17_capability::Fifa17ClientCapabilities;
|
||||||
|
use crate::local_services::{
|
||||||
|
CapabilityWiring, Ensured, Service, ServiceRuntime, ServiceSupervisor, SpawnSpec,
|
||||||
|
};
|
||||||
|
use crate::logs::LogBuffer;
|
||||||
|
use crate::preflight::{self, Check, State};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
/// Where the launch sequence is. Rendered directly by the UI; the UI never
|
||||||
|
/// coordinates services itself.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Phase {
|
||||||
|
/// Nothing in flight. Readiness still comes from observed state, not from
|
||||||
|
/// having been here.
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
/// Looking at the world: checks + service + hook state.
|
||||||
|
Checking,
|
||||||
|
/// Elevated client preparation in flight (this is what shows a password
|
||||||
|
/// prompt).
|
||||||
|
PreparingClient,
|
||||||
|
StartingServices,
|
||||||
|
/// Re-checking after repair, before committing to a launch.
|
||||||
|
Validating,
|
||||||
|
Launching,
|
||||||
|
/// FIFA is up. Left when the process exits.
|
||||||
|
Running,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Phase {
|
||||||
|
/// Whether a launch is under way, i.e. the primary button must not start a
|
||||||
|
/// second one.
|
||||||
|
pub fn busy(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Phase::Checking
|
||||||
|
| Phase::PreparingClient
|
||||||
|
| Phase::StartingServices
|
||||||
|
| Phase::Validating
|
||||||
|
| Phase::Launching
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One step of the sequence, in execution order.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Step {
|
||||||
|
Server,
|
||||||
|
ClientFiles,
|
||||||
|
ClientPreparation,
|
||||||
|
Lsx,
|
||||||
|
Autopatch,
|
||||||
|
FinalChecks,
|
||||||
|
Game,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Step {
|
||||||
|
/// User-facing name. Deliberately not the internal vocabulary: "arm" is
|
||||||
|
/// implementation terminology and never appears in the normal flow.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Step::Server => "OpenFUT server",
|
||||||
|
Step::ClientFiles => "Client files",
|
||||||
|
Step::ClientPreparation => "Client preparation",
|
||||||
|
Step::Lsx => "LSX",
|
||||||
|
Step::Autopatch => "Autopatch",
|
||||||
|
Step::FinalChecks => "Final checks",
|
||||||
|
Step::Game => "FIFA 17",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a step ended. `Skipped` is a success that did nothing — the state it
|
||||||
|
/// would have produced was already true.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Outcome {
|
||||||
|
Done(String),
|
||||||
|
Skipped(String),
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
pub fn ok(&self) -> bool {
|
||||||
|
!matches!(self, Outcome::Failed(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detail(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Outcome::Done(d) | Outcome::Skipped(d) | Outcome::Failed(d) => d,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the UI needs to render the launch surface.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct LaunchState {
|
||||||
|
pub phase: Phase,
|
||||||
|
/// Steps attempted by the most recent run, in order.
|
||||||
|
pub steps: Vec<(Step, Outcome)>,
|
||||||
|
/// One-line reason the run failed, for the top of the failure card. The
|
||||||
|
/// per-step detail carries the specifics.
|
||||||
|
pub failure: Option<String>,
|
||||||
|
/// The most recent preflight results and when they were taken. Cached
|
||||||
|
/// because the checks open sockets with timeouts and cannot run per frame.
|
||||||
|
pub checks: Option<Vec<Check>>,
|
||||||
|
pub checks_age: Option<std::time::Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaunchState {
|
||||||
|
fn begin(&mut self, phase: Phase) {
|
||||||
|
self.phase = phase;
|
||||||
|
self.steps.clear();
|
||||||
|
self.failure = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&mut self, step: Step, outcome: Outcome) {
|
||||||
|
if let Outcome::Failed(reason) = &outcome {
|
||||||
|
self.failure = Some(format!("{}: {reason}", step.label()));
|
||||||
|
}
|
||||||
|
self.steps.push((step, outcome));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effects the sequence performs. Implemented for real by [`RealOps`] and
|
||||||
|
/// substituted in tests.
|
||||||
|
pub trait LaunchOps {
|
||||||
|
/// Confirm the configured OpenFUT server is answering AND select the account
|
||||||
|
/// for this session. The server is remote by design, so this is a network
|
||||||
|
/// fact, never "is something local up". Returns a user-facing summary.
|
||||||
|
fn connect_server(&mut self) -> Result<String, String>;
|
||||||
|
/// Version.dll + a readable openfut.cfg. `Err` is a hard stop: without them
|
||||||
|
/// FIFA talks to EA, not OpenFUT.
|
||||||
|
fn ensure_client_files(&mut self) -> Result<String, String>;
|
||||||
|
/// Which of the arming-repairable checks are currently failing.
|
||||||
|
fn run_checks(&mut self) -> Vec<Check>;
|
||||||
|
/// Elevated client preparation (`arm`). Returns what it changed.
|
||||||
|
fn prepare_client(&mut self) -> Result<Vec<String>, String>;
|
||||||
|
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String>;
|
||||||
|
fn start_game(&mut self) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks that client preparation is able to repair. A failure in any of these
|
||||||
|
/// means "prepare the client", not "give up".
|
||||||
|
fn preparation_repairs(check: &Check) -> bool {
|
||||||
|
const REPAIRABLE: [&str; 3] = [
|
||||||
|
"ptrace_scope (autopatch)",
|
||||||
|
"EA redirector IP is redirected",
|
||||||
|
"EA hostnames point at OpenFUT",
|
||||||
|
];
|
||||||
|
REPAIRABLE.contains(&check.name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the whole sequence, publishing progress into `state` as it goes.
|
||||||
|
///
|
||||||
|
/// Returns whether FIFA was started. Stops at the first failed step: launching
|
||||||
|
/// into a known-broken client produces a session that fails minutes later with
|
||||||
|
/// no message naming the cause, which is precisely the failure mode this
|
||||||
|
/// launcher exists to prevent.
|
||||||
|
pub fn run_sequence(ops: &mut dyn LaunchOps, state: &Arc<Mutex<LaunchState>>) -> bool {
|
||||||
|
macro_rules! step {
|
||||||
|
($phase:expr, $step:expr, $body:expr) => {{
|
||||||
|
state.lock().phase = $phase;
|
||||||
|
let outcome: Outcome = $body;
|
||||||
|
let ok = outcome.ok();
|
||||||
|
state.lock().record($step, outcome);
|
||||||
|
if !ok {
|
||||||
|
state.lock().phase = Phase::Failed;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lock().begin(Phase::Checking);
|
||||||
|
|
||||||
|
// ── The server, which is remote and not ours to start ────────────────────
|
||||||
|
step!(Phase::Checking, Step::Server, {
|
||||||
|
match ops.connect_server() {
|
||||||
|
Ok(detail) => Outcome::Done(detail),
|
||||||
|
Err(e) => Outcome::Failed(e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── The hook the game loads, reconciled with the current settings ────────
|
||||||
|
step!(Phase::Checking, Step::ClientFiles, {
|
||||||
|
match ops.ensure_client_files() {
|
||||||
|
Ok(detail) => Outcome::Done(detail),
|
||||||
|
Err(e) => Outcome::Failed(e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Client preparation, only if something it repairs is broken ───────────
|
||||||
|
let checks = ops.run_checks();
|
||||||
|
let broken: Vec<String> = checks
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.state == State::Fail && preparation_repairs(c))
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.collect();
|
||||||
|
{
|
||||||
|
let mut guard = state.lock();
|
||||||
|
guard.checks = Some(checks);
|
||||||
|
guard.checks_age = Some(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
step!(Phase::PreparingClient, Step::ClientPreparation, {
|
||||||
|
if broken.is_empty() {
|
||||||
|
Outcome::Skipped("already prepared".into())
|
||||||
|
} else {
|
||||||
|
match ops.prepare_client() {
|
||||||
|
Ok(changes) => Outcome::Done(format!("{} change(s) applied", changes.len())),
|
||||||
|
Err(e) => Outcome::Failed(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Companion services, in dependency order ─────────────────────────────
|
||||||
|
for (service, step) in [
|
||||||
|
(Service::Lsx, Step::Lsx),
|
||||||
|
(Service::Autopatch, Step::Autopatch),
|
||||||
|
] {
|
||||||
|
step!(Phase::StartingServices, step, {
|
||||||
|
match ops.ensure_service(service) {
|
||||||
|
Ok(Ensured::Reused) => Outcome::Skipped("already running".into()),
|
||||||
|
Ok(Ensured::Started) => Outcome::Done("started".into()),
|
||||||
|
Err(e) => Outcome::Failed(e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate what the repairs were supposed to fix ──────────────────────
|
||||||
|
step!(Phase::Validating, Step::FinalChecks, {
|
||||||
|
let checks = ops.run_checks();
|
||||||
|
let failed: Vec<String> = checks
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.state == State::Fail)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.collect();
|
||||||
|
{
|
||||||
|
let mut guard = state.lock();
|
||||||
|
guard.checks = Some(checks);
|
||||||
|
guard.checks_age = Some(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
if failed.is_empty() {
|
||||||
|
Outcome::Done("all checks pass".into())
|
||||||
|
} else {
|
||||||
|
Outcome::Failed(format!("still failing: {}", failed.join(", ")))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
step!(Phase::Launching, Step::Game, {
|
||||||
|
match ops.start_game() {
|
||||||
|
Ok(()) => Outcome::Done("started".into()),
|
||||||
|
Err(e) => Outcome::Failed(e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.lock().phase = Phase::Running;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe the world without changing it, for the status rows on open and after
|
||||||
|
/// a settings change. Shares [`run_sequence`]'s notion of what "ready" means so
|
||||||
|
/// the two cannot drift apart.
|
||||||
|
pub fn refresh_checks(ops: &mut dyn LaunchOps, state: &Arc<Mutex<LaunchState>>) {
|
||||||
|
state.lock().phase = Phase::Checking;
|
||||||
|
let checks = ops.run_checks();
|
||||||
|
let mut guard = state.lock();
|
||||||
|
guard.checks = Some(checks);
|
||||||
|
guard.checks_age = Some(std::time::Instant::now());
|
||||||
|
guard.phase = Phase::Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happens to launcher-started services when FIFA exits.
|
||||||
|
///
|
||||||
|
/// Exists so the answer is a stated policy rather than an oversight. The shipped
|
||||||
|
/// value stops nothing:
|
||||||
|
///
|
||||||
|
/// * The companion services are reusable across launches — LSX has to be holding
|
||||||
|
/// :4216 before FIFA dials it, and the next launch would only start them again.
|
||||||
|
/// * A service the launcher did NOT start is never in the stop list under any
|
||||||
|
/// value of this policy.
|
||||||
|
///
|
||||||
|
/// Client preparation is deliberately absent, and is never reverted: it is host
|
||||||
|
/// state (`ptrace_scope`, a DNAT, `/etc/hosts`) that `client_arm.sh` also leaves
|
||||||
|
/// set and that every subsequent launch needs. A flag for it would be a flag
|
||||||
|
/// nothing honours.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct CleanupPolicy {
|
||||||
|
pub stop_launcher_started_services: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which services cleanup is allowed to stop after `FIFA` exits: only ones this
|
||||||
|
/// launcher started, and only if the policy says so.
|
||||||
|
pub fn services_to_stop(
|
||||||
|
policy: CleanupPolicy,
|
||||||
|
runtimes: &[(Service, ServiceRuntime)],
|
||||||
|
) -> Vec<Service> {
|
||||||
|
if !policy.stop_launcher_started_services {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
runtimes
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, r)| r.running && r.started_by_launcher)
|
||||||
|
.map(|(s, _)| *s)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary of one dependency for the main card.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Readiness {
|
||||||
|
Ready,
|
||||||
|
Busy,
|
||||||
|
Attention,
|
||||||
|
/// Never looked, or the answer is stale. Never rendered as Ready.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-integration readiness from the cached checks. `Unknown` until a run has
|
||||||
|
/// actually happened: "we did not look" must not look like "we looked and it was
|
||||||
|
/// fine".
|
||||||
|
pub fn client_integration(state: &LaunchState) -> Readiness {
|
||||||
|
if matches!(state.phase, Phase::PreparingClient) {
|
||||||
|
return Readiness::Busy;
|
||||||
|
}
|
||||||
|
match &state.checks {
|
||||||
|
None => Readiness::Unknown,
|
||||||
|
Some(checks) => {
|
||||||
|
let relevant: Vec<&Check> = checks.iter().filter(|c| preparation_repairs(c)).collect();
|
||||||
|
if relevant.iter().any(|c| c.state == State::Fail) {
|
||||||
|
Readiness::Attention
|
||||||
|
} else if relevant.iter().all(|c| c.state == State::Skipped) {
|
||||||
|
// Nothing configured to check, so nothing was verified.
|
||||||
|
Readiness::Unknown
|
||||||
|
} else {
|
||||||
|
Readiness::Ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overall readiness for the card's headline pill. Anything short of every
|
||||||
|
/// dependency being observed-good is not Ready.
|
||||||
|
pub fn overall(
|
||||||
|
phase: Phase,
|
||||||
|
server: Readiness,
|
||||||
|
integration: Readiness,
|
||||||
|
services: Readiness,
|
||||||
|
hook: Readiness,
|
||||||
|
) -> Readiness {
|
||||||
|
if phase == Phase::Running {
|
||||||
|
return Readiness::Ready;
|
||||||
|
}
|
||||||
|
if phase.busy() {
|
||||||
|
return Readiness::Busy;
|
||||||
|
}
|
||||||
|
let parts = [server, integration, services, hook];
|
||||||
|
if parts.contains(&Readiness::Attention) {
|
||||||
|
Readiness::Attention
|
||||||
|
} else if parts.contains(&Readiness::Unknown) {
|
||||||
|
Readiness::Unknown
|
||||||
|
} else {
|
||||||
|
Readiness::Ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`LaunchOps`] against the actual machine.
|
||||||
|
///
|
||||||
|
/// Holds a snapshot of the config: a launch must not change its mind halfway
|
||||||
|
/// through because the user edited a field while it ran.
|
||||||
|
pub struct RealOps {
|
||||||
|
config: LauncherConfig,
|
||||||
|
services: Arc<Mutex<ServiceSupervisor>>,
|
||||||
|
logs: Arc<Mutex<LogBuffer>>,
|
||||||
|
caps: Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||||
|
state: Arc<Mutex<LaunchState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RealOps {
|
||||||
|
fn say(&self, message: impl Into<String>) {
|
||||||
|
self.logs.lock().push(message.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaunchOps for RealOps {
|
||||||
|
fn connect_server(&mut self) -> Result<String, String> {
|
||||||
|
self.config.validate_server()?;
|
||||||
|
if preflight::backend_reachable(&self.config).state == State::Fail {
|
||||||
|
return Err(format!(
|
||||||
|
"{} is not answering — is the OpenFUT server running?",
|
||||||
|
self.config.openfut_server_host
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Selecting the account is part of connecting: LSX and FIFA both
|
||||||
|
// authenticate as this persona, and a launch with the wrong one produces
|
||||||
|
// a session that looks fine and belongs to nobody.
|
||||||
|
let account = crate::account_sync::sync(&self.config)?;
|
||||||
|
self.say(format!(
|
||||||
|
"[launcher] account synchronized: {}/{} FUT-coins={} unopened-packs={}",
|
||||||
|
account.persona_id, account.persona_name, account.coins, account.unopened_packs
|
||||||
|
));
|
||||||
|
Ok(format!(
|
||||||
|
"{} · {}",
|
||||||
|
self.config.openfut_server_host, account.persona_name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_client_files(&mut self) -> Result<String, String> {
|
||||||
|
let game_dir = std::path::PathBuf::from(&self.config.fifa_game_dir);
|
||||||
|
if !crate::setup::hook_dll_deployed(&game_dir) {
|
||||||
|
return Err("network hook is not deployed — use Setup to deploy it".into());
|
||||||
|
}
|
||||||
|
// The file the game reads is reconciled here, and only here: this is the
|
||||||
|
// one moment it is guaranteed to agree with the settings on screen.
|
||||||
|
let contents = self.config.hook_cfg_contents()?;
|
||||||
|
crate::setup::update_hook_config(&game_dir, &contents).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"cannot write {} in {}: {e}",
|
||||||
|
crate::setup::HOOK_CFG_FILE,
|
||||||
|
self.config.fifa_game_dir
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(format!(
|
||||||
|
"hook → {}:{}",
|
||||||
|
self.config.openfut_server_host, self.config.openfut_https_port
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_checks(&mut self) -> Vec<Check> {
|
||||||
|
preflight::run(&self.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_client(&mut self) -> Result<Vec<String>, String> {
|
||||||
|
match crate::arm::arm(&self.config) {
|
||||||
|
Ok(changes) => {
|
||||||
|
for change in &changes {
|
||||||
|
self.say(format!("[launcher] prepared: {change}"));
|
||||||
|
}
|
||||||
|
Ok(changes)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
|
||||||
|
let spec = SpawnSpec {
|
||||||
|
persona_id: self.config.fut_persona_id,
|
||||||
|
persona_name: self.config.fut_persona_name.clone(),
|
||||||
|
// Only autopatch advertises the verified resolver guard, so only it
|
||||||
|
// receives the shared capability sink.
|
||||||
|
capability: match service {
|
||||||
|
Service::Autopatch => Some(CapabilityWiring {
|
||||||
|
server_host: self.config.openfut_server_host.clone(),
|
||||||
|
account_sync_port: self.config.openfut_account_sync_port,
|
||||||
|
sink: Arc::clone(&self.caps),
|
||||||
|
}),
|
||||||
|
Service::Lsx => None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.services.lock().ensure_running(service, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_game(&mut self) -> Result<(), String> {
|
||||||
|
// A new FIFA process starts with UNKNOWN capability: never inherit the
|
||||||
|
// previous launch's. The autopatch stdout reader repopulates it.
|
||||||
|
*self.caps.lock() = Default::default();
|
||||||
|
|
||||||
|
let state = Arc::clone(&self.state);
|
||||||
|
let logs = Arc::clone(&self.logs);
|
||||||
|
let services = Arc::clone(&self.services);
|
||||||
|
let on_exit = move || {
|
||||||
|
// Cleanup goes through the policy rather than through habit, so the
|
||||||
|
// list can never include a service this launcher did not start.
|
||||||
|
let runtimes: Vec<_> = {
|
||||||
|
let mut supervisor = services.lock();
|
||||||
|
[Service::Lsx, Service::Autopatch]
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| {
|
||||||
|
let runtime = supervisor.observe(s);
|
||||||
|
(s, runtime)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
for service in services_to_stop(CleanupPolicy::default(), &runtimes) {
|
||||||
|
if let Err(e) = services.lock().stop(service) {
|
||||||
|
logs.lock().push(format!("[launcher] cleanup: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.lock().phase = Phase::Idle;
|
||||||
|
logs.lock()
|
||||||
|
.push("[launcher] FIFA exited; launcher back to Ready.".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prefer the native profile; fall back to the user's shell command so an
|
||||||
|
// existing working setup keeps working after an upgrade.
|
||||||
|
if self.config.game_profile.configured() {
|
||||||
|
crate::game_launch::launch(&self.config.game_profile, &self.logs, on_exit)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
crate::setup::launch_game(
|
||||||
|
&self.config.game_launch_command,
|
||||||
|
&self.config.game_launch_workdir,
|
||||||
|
Arc::clone(&self.logs),
|
||||||
|
on_exit,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives [`run_sequence`] on a worker thread. The UI thread never blocks on a
|
||||||
|
/// socket, a Polkit prompt or a process spawn.
|
||||||
|
pub struct Controller {
|
||||||
|
pub state: Arc<Mutex<LaunchState>>,
|
||||||
|
pub services: Arc<Mutex<ServiceSupervisor>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Controller {
|
||||||
|
pub fn new(logs: Arc<Mutex<LogBuffer>>) -> Self {
|
||||||
|
Self {
|
||||||
|
state: Arc::new(Mutex::new(LaunchState::default())),
|
||||||
|
services: Arc::new(Mutex::new(ServiceSupervisor::new(logs))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> LaunchState {
|
||||||
|
self.state.lock().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ops(
|
||||||
|
&self,
|
||||||
|
config: &LauncherConfig,
|
||||||
|
logs: &Arc<Mutex<LogBuffer>>,
|
||||||
|
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||||
|
) -> RealOps {
|
||||||
|
RealOps {
|
||||||
|
config: config.clone(),
|
||||||
|
services: Arc::clone(&self.services),
|
||||||
|
logs: Arc::clone(logs),
|
||||||
|
caps: Arc::clone(caps),
|
||||||
|
state: Arc::clone(&self.state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the full sequence. Ignored while one is already in flight or the
|
||||||
|
/// game is up — the button reflects that state rather than queueing work.
|
||||||
|
pub fn launch(
|
||||||
|
&self,
|
||||||
|
config: &LauncherConfig,
|
||||||
|
logs: &Arc<Mutex<LogBuffer>>,
|
||||||
|
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||||
|
) {
|
||||||
|
{
|
||||||
|
let phase = self.state.lock().phase;
|
||||||
|
if phase.busy() || phase == Phase::Running {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ops = self.ops(config, logs, caps);
|
||||||
|
let state = Arc::clone(&self.state);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
run_sequence(&mut ops, &state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-observe without changing anything, for startup and after a settings
|
||||||
|
/// change. Skipped while a launch owns the state.
|
||||||
|
pub fn refresh(
|
||||||
|
&self,
|
||||||
|
config: &LauncherConfig,
|
||||||
|
logs: &Arc<Mutex<LogBuffer>>,
|
||||||
|
caps: &Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||||
|
) {
|
||||||
|
{
|
||||||
|
let phase = self.state.lock().phase;
|
||||||
|
if phase.busy() || phase == Phase::Running {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ops = self.ops(config, logs, caps);
|
||||||
|
let state = Arc::clone(&self.state);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
refresh_checks(&mut ops, &state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Records what the sequence asked for, and answers however the test wants.
|
||||||
|
#[derive(Default)]
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
struct FakeOps {
|
||||||
|
server_up: bool,
|
||||||
|
client_files: Option<Result<String, String>>,
|
||||||
|
checks: Vec<Check>,
|
||||||
|
checks_after_prepare: Option<Vec<Check>>,
|
||||||
|
prepare_result: Option<Result<Vec<String>, String>>,
|
||||||
|
service_result: Vec<(Service, Result<Ensured, String>)>,
|
||||||
|
game_result: Option<Result<(), String>>,
|
||||||
|
// Observed calls
|
||||||
|
prepared: usize,
|
||||||
|
started: Vec<Service>,
|
||||||
|
game_started: usize,
|
||||||
|
check_runs: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(name: &str, state: State) -> Check {
|
||||||
|
Check {
|
||||||
|
name: name.into(),
|
||||||
|
state,
|
||||||
|
detail: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ready_ops() -> FakeOps {
|
||||||
|
FakeOps {
|
||||||
|
server_up: true,
|
||||||
|
client_files: Some(Ok("deployed".into())),
|
||||||
|
checks: vec![
|
||||||
|
check("ptrace_scope (autopatch)", State::Pass),
|
||||||
|
check("EA redirector IP is redirected", State::Pass),
|
||||||
|
check("EA hostnames point at OpenFUT", State::Pass),
|
||||||
|
],
|
||||||
|
prepare_result: Some(Ok(vec!["one".into()])),
|
||||||
|
game_result: Some(Ok(())),
|
||||||
|
..FakeOps::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LaunchOps for FakeOps {
|
||||||
|
fn connect_server(&mut self) -> Result<String, String> {
|
||||||
|
if self.server_up {
|
||||||
|
Ok("connected".into())
|
||||||
|
} else {
|
||||||
|
Err("not reachable — is the OpenFUT server running?".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn ensure_client_files(&mut self) -> Result<String, String> {
|
||||||
|
self.client_files
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| Err("no client-files result configured".into()))
|
||||||
|
}
|
||||||
|
fn run_checks(&mut self) -> Vec<Check> {
|
||||||
|
self.check_runs += 1;
|
||||||
|
match (&self.checks_after_prepare, self.prepared) {
|
||||||
|
(Some(after), n) if n > 0 => after.clone(),
|
||||||
|
_ => self.checks.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn prepare_client(&mut self) -> Result<Vec<String>, String> {
|
||||||
|
self.prepared += 1;
|
||||||
|
self.prepare_result
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| Err("no prepare configured".into()))
|
||||||
|
}
|
||||||
|
fn ensure_service(&mut self, service: Service) -> Result<Ensured, String> {
|
||||||
|
self.started.push(service);
|
||||||
|
self.service_result
|
||||||
|
.iter()
|
||||||
|
.find(|(s, _)| *s == service)
|
||||||
|
.map(|(_, r)| r.clone())
|
||||||
|
.unwrap_or(Ok(Ensured::Started))
|
||||||
|
}
|
||||||
|
fn start_game(&mut self) -> Result<(), String> {
|
||||||
|
self.game_started += 1;
|
||||||
|
self.game_result
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| Err("no game result configured".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state() -> Arc<Mutex<LaunchState>> {
|
||||||
|
Arc::new(Mutex::new(LaunchState::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cold_client_is_prepared_and_started_in_dependency_order() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
|
||||||
|
checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Pass)]),
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(
|
||||||
|
ops.prepared, 1,
|
||||||
|
"a failing repairable check must be repaired"
|
||||||
|
);
|
||||||
|
// Preparation before autopatch: autopatch cannot write FIFA's memory
|
||||||
|
// until arming has set ptrace_scope, and would silently no-op.
|
||||||
|
assert_eq!(ops.started, vec![Service::Lsx, Service::Autopatch]);
|
||||||
|
assert_eq!(ops.game_started, 1);
|
||||||
|
assert_eq!(st.lock().phase, Phase::Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_already_prepared_client_is_not_prepared_again() {
|
||||||
|
let mut ops = ready_ops();
|
||||||
|
let st = state();
|
||||||
|
assert!(run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(ops.prepared, 0, "no password prompt for work already done");
|
||||||
|
let steps = &st.lock().steps;
|
||||||
|
let prep = steps
|
||||||
|
.iter()
|
||||||
|
.find(|(s, _)| *s == Step::ClientPreparation)
|
||||||
|
.expect("preparation step recorded")
|
||||||
|
.1
|
||||||
|
.clone();
|
||||||
|
assert!(matches!(prep, Outcome::Skipped(_)), "{prep:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn healthy_services_are_reused_rather_than_restarted() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
service_result: vec![
|
||||||
|
(Service::Lsx, Ok(Ensured::Reused)),
|
||||||
|
(Service::Autopatch, Ok(Ensured::Reused)),
|
||||||
|
],
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(run_sequence(&mut ops, &st));
|
||||||
|
for step in [Step::Lsx, Step::Autopatch] {
|
||||||
|
let outcome = st
|
||||||
|
.lock()
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|(s, _)| *s == step)
|
||||||
|
.expect("service step recorded")
|
||||||
|
.1
|
||||||
|
.clone();
|
||||||
|
assert!(
|
||||||
|
matches!(outcome, Outcome::Skipped(_)),
|
||||||
|
"{step:?} {outcome:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(ops.game_started, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreachable_server_stops_the_launch_before_anything_is_touched() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
server_up: false,
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(!run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(ops.prepared, 0);
|
||||||
|
assert!(ops.started.is_empty(), "nothing may be started");
|
||||||
|
assert_eq!(ops.game_started, 0);
|
||||||
|
assert_eq!(st.lock().phase, Phase::Failed);
|
||||||
|
assert!(st.lock().failure.as_deref().unwrap().contains("server"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_service_that_fails_to_start_stops_the_launch() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
service_result: vec![(Service::Autopatch, Err("autopatch: boom".into()))],
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(!run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(ops.game_started, 0, "FIFA must not start without autopatch");
|
||||||
|
let failure = st.lock().failure.clone().unwrap();
|
||||||
|
assert!(failure.contains("Autopatch"), "{failure}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_client_preparation_stops_the_launch() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
|
||||||
|
prepare_result: Some(Err("pkexec: dismissed".into())),
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(!run_sequence(&mut ops, &st));
|
||||||
|
assert!(ops.started.is_empty());
|
||||||
|
assert_eq!(ops.game_started, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_check_still_failing_after_repair_stops_the_launch() {
|
||||||
|
// Preparation ran and claimed success, but the state it was supposed to
|
||||||
|
// fix is still broken. Launching here is how a session dies later with
|
||||||
|
// no message naming the cause.
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
checks: vec![check("ptrace_scope (autopatch)", State::Fail)],
|
||||||
|
checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Fail)]),
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(!run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(ops.game_started, 0);
|
||||||
|
let failure = st.lock().failure.clone().unwrap();
|
||||||
|
assert!(failure.contains("still failing"), "{failure}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_files_failure_stops_the_launch() {
|
||||||
|
let mut ops = FakeOps {
|
||||||
|
client_files: Some(Err("cannot write openfut.cfg".into())),
|
||||||
|
..ready_ops()
|
||||||
|
};
|
||||||
|
let st = state();
|
||||||
|
assert!(!run_sequence(&mut ops, &st));
|
||||||
|
assert_eq!(ops.game_started, 0);
|
||||||
|
assert!(ops.started.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleanup_never_stops_a_service_the_launcher_did_not_start() {
|
||||||
|
let foreign = ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: false,
|
||||||
|
pid: Some(4242),
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
let ours = ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: true,
|
||||||
|
pid: Some(99),
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
let runtimes = [(Service::Lsx, foreign), (Service::Autopatch, ours)];
|
||||||
|
|
||||||
|
// Even under the most aggressive policy, a foreign service is untouched.
|
||||||
|
let aggressive = CleanupPolicy {
|
||||||
|
stop_launcher_started_services: true,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
services_to_stop(aggressive, &runtimes),
|
||||||
|
vec![Service::Autopatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the shipped policy keeps both alive for the next launch.
|
||||||
|
assert!(services_to_stop(CleanupPolicy::default(), &runtimes).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readiness_is_never_green_while_a_dependency_is_not() {
|
||||||
|
assert_eq!(
|
||||||
|
overall(
|
||||||
|
Phase::Idle,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Attention,
|
||||||
|
Readiness::Ready
|
||||||
|
),
|
||||||
|
Readiness::Attention
|
||||||
|
);
|
||||||
|
// Never checked is not the same as checked and fine.
|
||||||
|
assert_eq!(
|
||||||
|
overall(
|
||||||
|
Phase::Idle,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Unknown,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Ready
|
||||||
|
),
|
||||||
|
Readiness::Unknown
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
overall(
|
||||||
|
Phase::Idle,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Ready,
|
||||||
|
Readiness::Ready
|
||||||
|
),
|
||||||
|
Readiness::Ready
|
||||||
|
);
|
||||||
|
// A running game reports Ready even though a launch is not in flight.
|
||||||
|
assert_eq!(
|
||||||
|
overall(
|
||||||
|
Phase::Running,
|
||||||
|
Readiness::Unknown,
|
||||||
|
Readiness::Unknown,
|
||||||
|
Readiness::Unknown,
|
||||||
|
Readiness::Unknown
|
||||||
|
),
|
||||||
|
Readiness::Ready
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_integration_is_unknown_until_checks_have_run() {
|
||||||
|
let mut st = LaunchState::default();
|
||||||
|
assert_eq!(client_integration(&st), Readiness::Unknown);
|
||||||
|
|
||||||
|
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Fail)]);
|
||||||
|
assert_eq!(client_integration(&st), Readiness::Attention);
|
||||||
|
|
||||||
|
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Pass)]);
|
||||||
|
assert_eq!(client_integration(&st), Readiness::Ready);
|
||||||
|
|
||||||
|
// Only skipped checks means nothing was actually verified.
|
||||||
|
st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Skipped)]);
|
||||||
|
assert_eq!(client_integration(&st), Readiness::Unknown);
|
||||||
|
}
|
||||||
|
}
|
||||||
+418
-61
@@ -16,7 +16,7 @@
|
|||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::{
|
use std::{
|
||||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
||||||
path::Path,
|
path::{Path, PathBuf},
|
||||||
process::{Child, Command, Stdio},
|
process::{Child, Command, Stdio},
|
||||||
sync::{mpsc, Arc},
|
sync::{mpsc, Arc},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
@@ -29,6 +29,10 @@ use crate::fifa17_capability::{
|
|||||||
};
|
};
|
||||||
use crate::logs::LogBuffer;
|
use crate::logs::LogBuffer;
|
||||||
|
|
||||||
|
/// The loopback endpoint LSX must own. FIFA dials this exact address and nothing
|
||||||
|
/// else, so "is LSX ready?" is answerable without asking LSX anything.
|
||||||
|
pub const LSX_ADDR: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
struct CommandParts {
|
struct CommandParts {
|
||||||
program: String,
|
program: String,
|
||||||
@@ -36,7 +40,7 @@ struct CommandParts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Which companion service. The `str` values are used in log prefixes.
|
/// Which companion service. The `str` values are used in log prefixes.
|
||||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum Service {
|
pub enum Service {
|
||||||
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
|
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
|
||||||
Lsx,
|
Lsx,
|
||||||
@@ -52,25 +56,51 @@ impl Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The responder script filename inside the tools dir.
|
/// The companion's executable name.
|
||||||
fn script(self) -> &'static str {
|
///
|
||||||
|
/// These were Python responder scripts run through a configured interpreter. They
|
||||||
|
/// are now Rust binaries built from this workspace (`openfut-lsx`,
|
||||||
|
/// `openfut-autopatch`), which removes the interpreter and the tools directory
|
||||||
|
/// from the launch contract entirely: no `python3` to locate, no script path to
|
||||||
|
/// configure, and no chance of running a stale checkout's copy.
|
||||||
|
fn binary(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Service::Lsx => "lsx_responder_v2.py",
|
Service::Lsx => "openfut-lsx",
|
||||||
Service::Autopatch => "autopatch.py",
|
Service::Autopatch => "openfut-autopatch",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts {
|
/// Absolute path to a companion binary.
|
||||||
let mut args = vec![tools_dir
|
///
|
||||||
.join(service.script())
|
/// Prefers a sibling of the running launcher, which is what a workspace build and any
|
||||||
.to_string_lossy()
|
/// sane install layout both produce, and falls back to the bare name so a
|
||||||
.into_owned()];
|
/// PATH-installed binary still works. Returning the bare name rather than failing
|
||||||
|
/// keeps `spawn` responsible for reporting a missing binary, with one error message
|
||||||
|
/// instead of two.
|
||||||
|
fn resolve_binary(service: Service) -> PathBuf {
|
||||||
|
let name = service.binary();
|
||||||
|
if let Some(dir) = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(Path::to_path_buf))
|
||||||
|
{
|
||||||
|
let sibling = dir.join(name);
|
||||||
|
if sibling.is_file() {
|
||||||
|
return sibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_parts(service: Service) -> CommandParts {
|
||||||
|
let mut args = Vec::new();
|
||||||
if service == Service::Autopatch {
|
if service == Service::Autopatch {
|
||||||
|
// autopatch exits when the launcher does, so it cannot outlive its owner and
|
||||||
|
// keep writing to a client the launcher no longer manages.
|
||||||
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
|
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
|
||||||
}
|
}
|
||||||
CommandParts {
|
CommandParts {
|
||||||
program: python.to_string(),
|
program: resolve_binary(service).to_string_lossy().into_owned(),
|
||||||
args,
|
args,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,6 +209,11 @@ impl ManagedService {
|
|||||||
self.stopping.is_some()
|
self.stopping.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PID of the child this launcher owns, if it owns one.
|
||||||
|
pub fn pid(&self) -> Option<u32> {
|
||||||
|
self.child.as_ref().map(Child::id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Begin stopping the service without waiting on the egui UI thread.
|
/// Begin stopping the service without waiting on the egui UI thread.
|
||||||
pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) {
|
pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) {
|
||||||
if self.stopping.is_some() {
|
if self.stopping.is_some() {
|
||||||
@@ -219,17 +254,248 @@ pub struct CapabilityWiring {
|
|||||||
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
|
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a companion service. `python` is the interpreter, `tools_dir` the
|
/// What is actually true about one companion service right now.
|
||||||
/// directory holding the responder scripts. Streams stdout+stderr into `log`.
|
///
|
||||||
/// Returns an error (without spawning) if the tools dir or script is missing.
|
/// Deliberately observed, never remembered: a button press is not evidence that
|
||||||
|
/// a service is up, and a service that died on its own must not keep showing
|
||||||
|
/// green because the launcher once started it successfully.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct ServiceRuntime {
|
||||||
|
pub running: bool,
|
||||||
|
/// True only while THIS launcher owns the live process. Decides whether
|
||||||
|
/// cleanup is allowed to touch it: a service someone started by hand for a
|
||||||
|
/// debugging session must survive a launch/exit cycle.
|
||||||
|
pub started_by_launcher: bool,
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
/// Observed supporting detail for the Advanced panel. Only ever facts the
|
||||||
|
/// launcher actually established.
|
||||||
|
pub detail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceRuntime {
|
||||||
|
/// Whether this service is usable for a launch, as opposed to merely alive.
|
||||||
|
/// For LSX that means the port FIFA dials is genuinely held.
|
||||||
|
pub fn ready(&self) -> bool {
|
||||||
|
self.running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when something holds LSX's fixed loopback port.
|
||||||
|
pub fn lsx_port_busy() -> bool {
|
||||||
|
match TcpListener::bind(LSX_ADDR) {
|
||||||
|
Err(error) => error.kind() == std::io::ErrorKind::AddrInUse,
|
||||||
|
Ok(listener) => {
|
||||||
|
drop(listener);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PID of a process running `service`'s companion binary that this launcher does
|
||||||
|
/// not own, if there is one.
|
||||||
|
///
|
||||||
|
/// Scans `/proc` — no extra dependency, no privilege, and no guessing: a service
|
||||||
|
/// left running by a previous launcher instance or started by hand from a shell
|
||||||
|
/// is a real state the UI has to be able to report, and cleanup has to respect.
|
||||||
|
///
|
||||||
|
/// Matches argv entries rather than `comm`, because `comm` is truncated to 15
|
||||||
|
/// characters by the kernel and would misreport these names.
|
||||||
|
pub fn foreign_pid(service: Service, ours: Option<u32>) -> Option<u32> {
|
||||||
|
let binary = service.binary();
|
||||||
|
let self_pid = std::process::id();
|
||||||
|
let entries = std::fs::read_dir("/proc").ok()?;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if pid == self_pid || Some(pid) == ours {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(cmdline) = std::fs::read(entry.path().join("cmdline")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if cmdline.split(|b| *b == 0).any(|arg| {
|
||||||
|
// Compare the file name, so `/path/to/openfut-lsx` matches while an
|
||||||
|
// unrelated argument that merely ends with the same text does not.
|
||||||
|
Path::new(&*String::from_utf8_lossy(arg))
|
||||||
|
.file_name()
|
||||||
|
.is_some_and(|n| n == binary)
|
||||||
|
}) {
|
||||||
|
return Some(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a stop request may touch this service.
|
||||||
|
///
|
||||||
|
/// Pure, so the ownership rule is testable without a process: refusing to kill
|
||||||
|
/// something the launcher did not start is the whole reason ownership is tracked,
|
||||||
|
/// and it must not depend on what happens to be running on the test machine.
|
||||||
|
pub fn stop_permitted(runtime: &ServiceRuntime, label: &str) -> Result<(), String> {
|
||||||
|
if runtime.running && !runtime.started_by_launcher {
|
||||||
|
return Err(format!(
|
||||||
|
"{label} was started outside this launcher{} — stop it where it was started.",
|
||||||
|
match runtime.pid {
|
||||||
|
Some(pid) => format!(" (pid {pid})"),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns both companion services and answers "what is running, and who started
|
||||||
|
/// it?" for the whole launcher.
|
||||||
|
///
|
||||||
|
/// Exists so the launch sequence and the Advanced panel act on the same objects.
|
||||||
|
/// Two independent copies of that state is how a UI ends up claiming Ready while
|
||||||
|
/// the process is dead.
|
||||||
|
pub struct ServiceSupervisor {
|
||||||
|
lsx: ManagedService,
|
||||||
|
autopatch: ManagedService,
|
||||||
|
log: Arc<Mutex<LogBuffer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [`ServiceSupervisor::ensure_running`] had to do anything.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Ensured {
|
||||||
|
/// Already up — left strictly alone.
|
||||||
|
Reused,
|
||||||
|
Started,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceSupervisor {
|
||||||
|
pub fn new(log: Arc<Mutex<LogBuffer>>) -> Self {
|
||||||
|
Self {
|
||||||
|
lsx: ManagedService::default(),
|
||||||
|
autopatch: ManagedService::default(),
|
||||||
|
log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slot(&mut self, service: Service) -> &mut ManagedService {
|
||||||
|
match service {
|
||||||
|
Service::Lsx => &mut self.lsx,
|
||||||
|
Service::Autopatch => &mut self.autopatch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe one service: our own child first, then any foreign instance.
|
||||||
|
pub fn observe(&mut self, service: Service) -> ServiceRuntime {
|
||||||
|
let log = Arc::clone(&self.log);
|
||||||
|
let slot = self.slot(service);
|
||||||
|
if slot.stopping() {
|
||||||
|
return ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: true,
|
||||||
|
pid: None,
|
||||||
|
detail: Some("stopping".into()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let ours = slot.pid();
|
||||||
|
if slot.running(&log, service.label()) {
|
||||||
|
let mut runtime = ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: true,
|
||||||
|
pid: ours,
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
if service == Service::Lsx {
|
||||||
|
runtime.detail = Some(if lsx_port_busy() {
|
||||||
|
format!("holding {LSX_ADDR}")
|
||||||
|
} else {
|
||||||
|
// Alive but not listening: real, and not "ready".
|
||||||
|
runtime.running = false;
|
||||||
|
format!("process alive but {LSX_ADDR} is not held")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
match foreign_pid(service, ours) {
|
||||||
|
Some(pid) => ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: false,
|
||||||
|
pid: Some(pid),
|
||||||
|
detail: Some("started outside this launcher".into()),
|
||||||
|
},
|
||||||
|
None if service == Service::Lsx && lsx_port_busy() => ServiceRuntime {
|
||||||
|
running: false,
|
||||||
|
started_by_launcher: false,
|
||||||
|
pid: None,
|
||||||
|
detail: Some(format!("{LSX_ADDR} is held by an unrelated process")),
|
||||||
|
},
|
||||||
|
None => ServiceRuntime::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start `service` only if it is not already usable. Never restarts a healthy
|
||||||
|
/// service, and never adopts a foreign one as ours.
|
||||||
|
pub fn ensure_running(&mut self, service: Service, spec: SpawnSpec) -> Result<Ensured, String> {
|
||||||
|
let runtime = self.observe(service);
|
||||||
|
if runtime.ready() {
|
||||||
|
self.log.lock().push(format!(
|
||||||
|
"[launcher] {} already running{} — reusing it.",
|
||||||
|
service.label(),
|
||||||
|
match runtime.pid {
|
||||||
|
Some(pid) => format!(" (pid {pid})"),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
));
|
||||||
|
return Ok(Ensured::Reused);
|
||||||
|
}
|
||||||
|
if let Some(detail) = runtime.detail.filter(|_| !runtime.running) {
|
||||||
|
// No service-name prefix: every caller already renders the service it
|
||||||
|
// asked about, and the launch card would print "LSX: LSX: …".
|
||||||
|
return Err(detail);
|
||||||
|
}
|
||||||
|
let child = spawn(
|
||||||
|
service,
|
||||||
|
spec.persona_id,
|
||||||
|
&spec.persona_name,
|
||||||
|
spec.capability,
|
||||||
|
Arc::clone(&self.log),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
*self.slot(service) = ManagedService::from_child(child);
|
||||||
|
Ok(Ensured::Started)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop a service the launcher owns. A foreign process is reported, never
|
||||||
|
/// killed: the launcher did not start it and does not know who needs it.
|
||||||
|
pub fn stop(&mut self, service: Service) -> Result<(), String> {
|
||||||
|
let runtime = self.observe(service);
|
||||||
|
stop_permitted(&runtime, service.label())?;
|
||||||
|
let log = Arc::clone(&self.log);
|
||||||
|
self.slot(service).stop(&log, service);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stopping(&mut self, service: Service) -> bool {
|
||||||
|
self.slot(service).stopping()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything [`spawn`] needs, bundled so the launch sequence can hand it over
|
||||||
|
/// as one value per service.
|
||||||
|
pub struct SpawnSpec {
|
||||||
|
pub persona_id: u64,
|
||||||
|
pub persona_name: String,
|
||||||
|
pub capability: Option<CapabilityWiring>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a companion service and stream its stdout+stderr into `log`.
|
||||||
|
///
|
||||||
|
/// Returns an error without spawning if the binary is missing, which is the only
|
||||||
|
/// precondition left now that the companions are workspace binaries rather than
|
||||||
|
/// Python scripts run from a configured tools directory.
|
||||||
///
|
///
|
||||||
/// `capability` is the backend-registration wiring + shared per-FIFA-process
|
/// `capability` is the backend-registration wiring + shared per-FIFA-process
|
||||||
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the
|
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the
|
||||||
/// verified resolver guard) and `None` for LSX.
|
/// verified resolver guard) and `None` for LSX.
|
||||||
pub fn spawn(
|
pub fn spawn(
|
||||||
service: Service,
|
service: Service,
|
||||||
python: &str,
|
|
||||||
tools_dir: &str,
|
|
||||||
persona_id: u64,
|
persona_id: u64,
|
||||||
persona_name: &str,
|
persona_name: &str,
|
||||||
capability: Option<CapabilityWiring>,
|
capability: Option<CapabilityWiring>,
|
||||||
@@ -237,35 +503,28 @@ pub fn spawn(
|
|||||||
) -> anyhow::Result<Child> {
|
) -> anyhow::Result<Child> {
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
|
|
||||||
let dir = Path::new(tools_dir);
|
|
||||||
if !dir.is_dir() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"FIFA 17 tools dir not found: {} (set it in Settings)",
|
|
||||||
dir.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let script_path = dir.join(service.script());
|
|
||||||
if !script_path.exists() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"{} not found in tools dir: {}",
|
|
||||||
service.script(),
|
|
||||||
script_path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let label = service.label();
|
let label = service.label();
|
||||||
|
let parts = command_parts(service);
|
||||||
|
let program = Path::new(&parts.program);
|
||||||
|
// Only a resolved absolute path can be checked up front; a bare name is left to
|
||||||
|
// the OS to resolve through PATH, and a failure there is reported by spawn below.
|
||||||
|
if program.is_absolute() && !program.is_file() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"{label} binary not found: {} — build the workspace so it sits beside the launcher",
|
||||||
|
program.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Both services use the configured interpreter and absolute script path;
|
|
||||||
// neither invents a Python installation path. Autopatch receives launcher
|
|
||||||
// ownership and a per-user runtime log so stale root-owned /tmp files cannot
|
|
||||||
// block startup.
|
|
||||||
let parts = command_parts(service, python, dir);
|
|
||||||
let mut cmd = Command::new(&parts.program);
|
let mut cmd = Command::new(&parts.program);
|
||||||
cmd.args(&parts.args);
|
cmd.args(&parts.args);
|
||||||
if service == Service::Lsx {
|
if service == Service::Lsx {
|
||||||
|
// The persona LSX reports has to equal what Blaze returns in
|
||||||
|
// LoginResponse.SESS.PDTL and what UTAS serves as userInfo.personaId; the
|
||||||
|
// constraint is cross-layer agreement, not any particular value.
|
||||||
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
|
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
|
||||||
.env("FUT_PERSONA_NAME", persona_name);
|
.env("FUT_PERSONA_NAME", persona_name);
|
||||||
} else if service == Service::Autopatch {
|
} else if service == Service::Autopatch {
|
||||||
|
// A per-user runtime log, so a stale root-owned /tmp file cannot block startup.
|
||||||
let log_path = std::env::var_os("XDG_RUNTIME_DIR")
|
let log_path = std::env::var_os("XDG_RUNTIME_DIR")
|
||||||
.map(std::path::PathBuf::from)
|
.map(std::path::PathBuf::from)
|
||||||
.unwrap_or_else(std::env::temp_dir)
|
.unwrap_or_else(std::env::temp_dir)
|
||||||
@@ -274,19 +533,21 @@ pub fn spawn(
|
|||||||
}
|
}
|
||||||
// Put each companion in its own process group for lifecycle isolation.
|
// Put each companion in its own process group for lifecycle isolation.
|
||||||
cmd.process_group(0);
|
cmd.process_group(0);
|
||||||
cmd.current_dir(dir)
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped());
|
|
||||||
|
|
||||||
log.lock().push(format!(
|
log.lock().push(format!(
|
||||||
"[launcher] starting {label}: {} {}",
|
"[launcher] starting {label}: {}{}",
|
||||||
python,
|
parts.program,
|
||||||
script_path.display(),
|
parts.args.iter().fold(String::new(), |mut acc, a| {
|
||||||
|
acc.push(' ');
|
||||||
|
acc.push_str(a);
|
||||||
|
acc
|
||||||
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut child = cmd
|
let mut child = cmd
|
||||||
.spawn()
|
.spawn()
|
||||||
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.script()))?;
|
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.binary()))?;
|
||||||
|
|
||||||
if let Some(out) = child.stdout.take() {
|
if let Some(out) = child.stdout.take() {
|
||||||
let buf = Arc::clone(&log);
|
let buf = Arc::clone(&log);
|
||||||
@@ -350,7 +611,7 @@ pub fn spawn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if service == Service::Lsx {
|
if service == Service::Lsx {
|
||||||
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
|
let address = LSX_ADDR;
|
||||||
if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) {
|
if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
@@ -368,27 +629,38 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lsx_runs_python_directly() {
|
fn lsx_runs_its_own_binary_with_no_arguments() {
|
||||||
let parts = command_parts(Service::Lsx, "/usr/bin/python3", Path::new("/tmp/tools"));
|
let parts = command_parts(Service::Lsx);
|
||||||
assert_eq!(parts.program, "/usr/bin/python3");
|
assert_eq!(
|
||||||
assert_eq!(parts.args, vec!["/tmp/tools/lsx_responder_v2.py"]);
|
Path::new(&parts.program).file_name().unwrap(),
|
||||||
|
"openfut-lsx"
|
||||||
|
);
|
||||||
|
assert!(parts.args.is_empty(), "{:?}", parts.args);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn autopatch_runs_python_directly_with_launcher_ownership() {
|
fn autopatch_runs_its_own_binary_with_launcher_ownership() {
|
||||||
let parts = command_parts(
|
let parts = command_parts(Service::Autopatch);
|
||||||
Service::Autopatch,
|
assert_eq!(
|
||||||
"/usr/bin/python3",
|
Path::new(&parts.program).file_name().unwrap(),
|
||||||
Path::new("/tmp/tools"),
|
"openfut-autopatch"
|
||||||
);
|
);
|
||||||
assert_eq!(parts.program, "/usr/bin/python3");
|
// The launcher pid is how autopatch learns to exit with its owner.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parts.args,
|
parts.args,
|
||||||
vec![
|
vec!["--launcher-pid", &std::process::id().to_string()]
|
||||||
"/tmp/tools/autopatch.py",
|
);
|
||||||
"--launcher-pid",
|
}
|
||||||
&std::process::id().to_string(),
|
|
||||||
]
|
#[test]
|
||||||
|
fn a_companion_binary_is_looked_up_by_file_name_not_a_suffix_match() {
|
||||||
|
// Guards the foreign-process scan: an argv entry that merely ends with the
|
||||||
|
// binary name (a log path, say) must not be mistaken for the service.
|
||||||
|
assert_eq!(Service::Lsx.binary(), "openfut-lsx");
|
||||||
|
assert_eq!(Service::Autopatch.binary(), "openfut-autopatch");
|
||||||
|
assert_eq!(
|
||||||
|
Path::new("/var/log/my-openfut-lsx").file_name().unwrap(),
|
||||||
|
"my-openfut-lsx"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,4 +690,89 @@ mod tests {
|
|||||||
.expect_err("exited child must not be reported ready");
|
.expect_err("exited child must not be reported ready");
|
||||||
assert!(error.to_string().contains("exited before becoming ready"));
|
assert!(error.to_string().contains("exited before becoming ready"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn supervisor() -> ServiceSupervisor {
|
||||||
|
ServiceSupervisor::new(Arc::new(Mutex::new(LogBuffer::new())))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_service_this_launcher_never_started_is_never_reported_as_ours() {
|
||||||
|
// The old model only knew about children it spawned, so it could not tell
|
||||||
|
// "stopped" from "running, but not mine". Note this box may genuinely have
|
||||||
|
// a foreign responder running — that is a real observation, and the
|
||||||
|
// invariant is about ownership, not about it being absent.
|
||||||
|
let mut sup = supervisor();
|
||||||
|
let runtime = sup.observe(Service::Autopatch);
|
||||||
|
assert!(
|
||||||
|
!runtime.started_by_launcher,
|
||||||
|
"nothing was spawned here, so nothing may claim launcher ownership"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_launcher_owned_child_is_observed_as_ours_and_reaped_when_it_dies() {
|
||||||
|
let mut sup = supervisor();
|
||||||
|
let child = Command::new("sh")
|
||||||
|
.args(["-c", "sleep 30"])
|
||||||
|
.spawn()
|
||||||
|
.expect("spawn long-lived child");
|
||||||
|
let pid = child.id();
|
||||||
|
sup.autopatch = ManagedService::from_child(child);
|
||||||
|
|
||||||
|
let runtime = sup.observe(Service::Autopatch);
|
||||||
|
assert!(runtime.running);
|
||||||
|
assert!(runtime.started_by_launcher, "we spawned it");
|
||||||
|
assert_eq!(runtime.pid, Some(pid));
|
||||||
|
|
||||||
|
// Stopping is allowed precisely because it is ours.
|
||||||
|
sup.stop(Service::Autopatch).expect("ours to stop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stopping_a_foreign_service_is_refused_rather_than_killing_it() {
|
||||||
|
// A service someone started by hand for a debugging session must survive a
|
||||||
|
// launch/exit cycle, and the refusal has to say where to stop it. Asserted
|
||||||
|
// on the pure rule so it holds regardless of what this machine is running.
|
||||||
|
let foreign = ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: false,
|
||||||
|
pid: Some(4242),
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
let error = stop_permitted(&foreign, "autopatch").unwrap_err();
|
||||||
|
assert!(error.contains("started outside this launcher"), "{error}");
|
||||||
|
assert!(error.contains("4242"), "{error}");
|
||||||
|
|
||||||
|
let ours = ServiceRuntime {
|
||||||
|
running: true,
|
||||||
|
started_by_launcher: true,
|
||||||
|
pid: Some(99),
|
||||||
|
detail: None,
|
||||||
|
};
|
||||||
|
assert!(stop_permitted(&ours, "autopatch").is_ok());
|
||||||
|
// Stopping something that is not running is a harmless no-op.
|
||||||
|
assert!(stop_permitted(&ServiceRuntime::default(), "autopatch").is_ok());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
crate::launch::services_to_stop(
|
||||||
|
crate::launch::CleanupPolicy {
|
||||||
|
stop_launcher_started_services: true,
|
||||||
|
},
|
||||||
|
&[(Service::Autopatch, foreign)],
|
||||||
|
)
|
||||||
|
.is_empty(),
|
||||||
|
"a foreign service is never in the stop list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_pid_ignores_the_launcher_process_itself() {
|
||||||
|
// The scan matches on the responder script name; this process is not one,
|
||||||
|
// and must never be reported as a service.
|
||||||
|
assert_ne!(foreign_pid(Service::Lsx, None), Some(std::process::id()));
|
||||||
|
assert_ne!(
|
||||||
|
foreign_pid(Service::Autopatch, None),
|
||||||
|
Some(std::process::id())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ mod config;
|
|||||||
mod fifa17_capability;
|
mod fifa17_capability;
|
||||||
mod game_launch;
|
mod game_launch;
|
||||||
mod health;
|
mod health;
|
||||||
|
mod launch;
|
||||||
mod local_services;
|
mod local_services;
|
||||||
mod logs;
|
mod logs;
|
||||||
mod netcheck;
|
mod netcheck;
|
||||||
|
|||||||
+18
-32
@@ -88,7 +88,7 @@ impl Check {
|
|||||||
/// Run every applicable check. Order is the order the game exercises them.
|
/// Run every applicable check. Order is the order the game exercises them.
|
||||||
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
||||||
vec![
|
vec![
|
||||||
ptrace_scope(cfg),
|
ptrace_scope(),
|
||||||
ea_redirect(cfg),
|
ea_redirect(cfg),
|
||||||
hostname_mapping(cfg),
|
hostname_mapping(cfg),
|
||||||
backend_reachable(cfg),
|
backend_reachable(cfg),
|
||||||
@@ -109,16 +109,12 @@ pub fn warnings(checks: &[Check]) -> usize {
|
|||||||
/// autopatch writes to FIFA's process memory; Yama blocks that unless
|
/// autopatch writes to FIFA's process memory; Yama blocks that unless
|
||||||
/// `ptrace_scope` is 0. At 1 the patch silently does nothing and the game fails
|
/// `ptrace_scope` is 0. At 1 the patch silently does nothing and the game fails
|
||||||
/// its TLS handshake much later, with no message naming the cause.
|
/// its TLS handshake much later, with no message naming the cause.
|
||||||
fn ptrace_scope(cfg: &LauncherConfig) -> Check {
|
///
|
||||||
|
/// Unconditional. autopatch is a workspace binary that ships alongside the
|
||||||
|
/// launcher, so there is no configuration that could make this inapplicable —
|
||||||
|
/// every launch runs it.
|
||||||
|
fn ptrace_scope() -> Check {
|
||||||
const NAME: &str = "ptrace_scope (autopatch)";
|
const NAME: &str = "ptrace_scope (autopatch)";
|
||||||
// `fifa17_tools_dir` carries a conventional default, so a non-empty value
|
|
||||||
// does not mean the tools are installed. Key off the directory actually
|
|
||||||
// existing: that is what decides whether autopatch will run at all, and it
|
|
||||||
// keeps this from failing on a machine that never uses local services.
|
|
||||||
let tools = cfg.fifa17_tools_dir.trim();
|
|
||||||
if tools.is_empty() || !std::path::Path::new(tools).is_dir() {
|
|
||||||
return Check::skip(NAME, "no local services installed");
|
|
||||||
}
|
|
||||||
match std::fs::read_to_string(PTRACE_SCOPE) {
|
match std::fs::read_to_string(PTRACE_SCOPE) {
|
||||||
Ok(v) => ptrace_verdict(&v),
|
Ok(v) => ptrace_verdict(&v),
|
||||||
// Not every kernel has Yama. Absent means unenforced, which is what we want.
|
// Not every kernel has Yama. Absent means unenforced, which is what we want.
|
||||||
@@ -239,7 +235,7 @@ fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The server side of the same question: are the ports the game will use open?
|
/// The server side of the same question: are the ports the game will use open?
|
||||||
fn backend_reachable(cfg: &LauncherConfig) -> Check {
|
pub(crate) fn backend_reachable(cfg: &LauncherConfig) -> Check {
|
||||||
const NAME: &str = "OpenFUT server reachable";
|
const NAME: &str = "OpenFUT server reachable";
|
||||||
let host = cfg.openfut_server_host.trim();
|
let host = cfg.openfut_server_host.trim();
|
||||||
if host.is_empty() {
|
if host.is_empty() {
|
||||||
@@ -336,12 +332,19 @@ mod tests {
|
|||||||
fn an_unconfigured_launcher_skips_rather_than_passes() {
|
fn an_unconfigured_launcher_skips_rather_than_passes() {
|
||||||
// The distinction that matters: a fresh config must not display a column
|
// The distinction that matters: a fresh config must not display a column
|
||||||
// of green ticks. "Not checked" is not "checked and fine".
|
// of green ticks. "Not checked" is not "checked and fine".
|
||||||
|
//
|
||||||
|
// `ptrace_scope` is excluded because it is no longer configuration
|
||||||
|
// dependent: it reads this machine's Yama setting and reports a real
|
||||||
|
// verdict either way. `only_ptrace_scope_zero_lets_autopatch_work`
|
||||||
|
// covers it.
|
||||||
let mut c = cfg();
|
let mut c = cfg();
|
||||||
// `default()` points these at conventional paths whose existence varies
|
// `default()` points this at a conventional path whose existence varies
|
||||||
// by machine. Pin them so the assertion is about the code, not this box.
|
// by machine. Pin it so the assertion is about the code, not this box.
|
||||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
|
||||||
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
|
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
|
||||||
let checks = run(&c);
|
let checks: Vec<Check> = run(&c)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|k| k.name != "ptrace_scope (autopatch)")
|
||||||
|
.collect();
|
||||||
assert!(
|
assert!(
|
||||||
checks.iter().all(|k| k.state == State::Skipped),
|
checks.iter().all(|k| k.state == State::Skipped),
|
||||||
"{checks:#?}"
|
"{checks:#?}"
|
||||||
@@ -360,16 +363,6 @@ mod tests {
|
|||||||
assert!(ptrace_verdict("1").detail.contains("Arm client"));
|
assert!(ptrace_verdict("1").detail.contains("Arm client"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ptrace_is_skipped_when_the_tools_dir_does_not_exist() {
|
|
||||||
// Regression: the gate used to be "is the field non-empty", and the
|
|
||||||
// field has a default — so this check ran (and failed) on machines that
|
|
||||||
// never use autopatch at all.
|
|
||||||
let mut c = cfg();
|
|
||||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
|
||||||
assert_eq!(ptrace_scope(&c).state, State::Skipped);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
|
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
|
||||||
let mut c = cfg();
|
let mut c = cfg();
|
||||||
@@ -432,13 +425,6 @@ mod tests {
|
|||||||
assert_eq!(hostname_mapping(&c).state, State::Pass);
|
assert_eq!(hostname_mapping(&c).state, State::Pass);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ptrace_check_is_skipped_when_local_services_are_not_configured() {
|
|
||||||
let mut c = cfg();
|
|
||||||
c.fifa17_tools_dir.clear();
|
|
||||||
assert_eq!(ptrace_scope(&c).state, State::Skipped);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dead_backend_port_is_reported_as_a_failure() {
|
fn a_dead_backend_port_is_reported_as_a_failure() {
|
||||||
let mut c = cfg();
|
let mut c = cfg();
|
||||||
|
|||||||
+13
-3
@@ -126,8 +126,14 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
|
|||||||
game_dir.join("version.dll").exists()
|
game_dir.join("version.dll").exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Steam launch options the user needs to paste in to enable the override.
|
/// Steam launch options that enable the hook's DLL override.
|
||||||
/// Proton loads local DLLs named in WINEDLLOVERRIDES ahead of system ones.
|
///
|
||||||
|
/// 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%";
|
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
|
||||||
|
|
||||||
// ── Game launch ───────────────────────────────────────────────────────────────
|
// ── Game launch ───────────────────────────────────────────────────────────────
|
||||||
@@ -140,6 +146,7 @@ pub fn launch_game(
|
|||||||
command: &str,
|
command: &str,
|
||||||
workdir: &str,
|
workdir: &str,
|
||||||
log_buf: std::sync::Arc<parking_lot::Mutex<crate::logs::LogBuffer>>,
|
log_buf: std::sync::Arc<parking_lot::Mutex<crate::logs::LogBuffer>>,
|
||||||
|
on_exit: impl FnOnce() + Send + 'static,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
@@ -178,12 +185,15 @@ pub fn launch_game(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Reap the child in the background so a finished game doesn't linger as a
|
// Reap the child in the background so a finished game doesn't linger as a
|
||||||
// zombie; we don't block the UI on it.
|
// zombie; we don't block the UI on it. `on_exit` is how the launch state
|
||||||
|
// machine learns the game is gone — without it the UI would sit on
|
||||||
|
// "FIFA 17 Running" forever.
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
log_buf
|
log_buf
|
||||||
.lock()
|
.lock()
|
||||||
.push("[launcher] game process exited.".to_string());
|
.push("[launcher] game process exited.".to_string());
|
||||||
|
on_exit();
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
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