Compare commits
37 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 | |||
| 504ceeec87 | |||
| cbf697bcd5 | |||
| 357501f549 | |||
| c2772132c1 | |||
| d1a71bd5a1 | |||
| 0d3f33cede | |||
| ca7ce267a0 | |||
| 958ff24546 | |||
| 09ed26ba16 | |||
| 3d895fb7ac | |||
| 7dcf610b71 | |||
| ff4b5a87f5 | |||
| 493b9e0573 | |||
| c1d1f03ec8 | |||
| 3c3dc32fc6 | |||
| feaff0443f |
@@ -1 +1,6 @@
|
||||
target/
|
||||
|
||||
# runtime SQLite DB (created when services run from this dir)
|
||||
openfut.db
|
||||
openfut.db-shm
|
||||
openfut.db-wal
|
||||
|
||||
@@ -13,3 +13,6 @@ serde_json = "1"
|
||||
dirs = "5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
openfut-common = { path = "openfut-common" }
|
||||
# parking_lot over std::sync: every lock here is taken and used immediately, so
|
||||
# the poisoning unwrap at each call site is pure noise (project rule).
|
||||
parking_lot = "0.12"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,9 @@
|
||||
# Standalone workspace root: this Windows-only version.dll proxy is deliberately
|
||||
# NOT a member of the OpenFUT workspace (see that root's `exclude`) so its own
|
||||
# [profile.release] below actually applies. An empty [workspace] table stops Cargo
|
||||
# from walking up and re-attaching this crate to the parent workspace.
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "openfut-hook"
|
||||
version = "0.1.0"
|
||||
@@ -6,6 +12,20 @@ edition = "2021"
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
# Build with `--features capture_baseline` to DISABLE the LSX 3216→3217 redirect,
|
||||
# so FIFA's LSX goes to anadius's in-process server (for capturing anadius's real
|
||||
# responses). Default build keeps the redirect (LSX → our bridge).
|
||||
capture_baseline = []
|
||||
# Build with `--features probe` to install passive logging detours on FIFA's
|
||||
# in-process online-flow functions (GoOnline, GetInternetConnectedState, event
|
||||
# deserializers). Writes PROBE lines to C:\openfut_hook.log for RE. See probe.rs.
|
||||
probe = []
|
||||
# Build with `--features fifa17` for the FIFA 17 injection path. DllMain runs ONLY
|
||||
# the minimal FIFA-17-safe logic in fifa17.rs (prove injection, dump module map,
|
||||
# patch DirtySDK/ProtoSSL cert-verify) and skips ALL the FIFA-23-specific hooking.
|
||||
fifa17 = []
|
||||
|
||||
[dependencies]
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
@@ -15,6 +35,9 @@ windows-sys = { version = "0.59", features = [
|
||||
"Win32_Networking_WinSock",
|
||||
"Win32_Security_Cryptography",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_Kernel",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=version.def");
|
||||
|
||||
// The proxy's PE export surface is part of its runtime contract. Feed an
|
||||
// explicit module-definition file to the MinGW linker instead of relying
|
||||
// solely on Rust symbol export attributes and linker retention heuristics.
|
||||
if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
|
||||
&& env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu")
|
||||
{
|
||||
let definition =
|
||||
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("version.def");
|
||||
println!("cargo:rustc-link-arg={}", definition.display());
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,49 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const AF_INET: u16 = 2;
|
||||
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
|
||||
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
|
||||
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
|
||||
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
|
||||
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
|
||||
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
|
||||
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
|
||||
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
|
||||
// EA App LSX. anadius handles :3216 in-process before it reaches the host TCP
|
||||
// stack (keyed on port 3216 specifically), so redirecting FIFA's LSX connect to a
|
||||
// *different* host port (:3217) slips past that interception and lands on the
|
||||
// native openfut-bridge LSX server. This is the load-bearing redirect that routes
|
||||
// LSX to our bridge; without it FIFA uses anadius's in-process emu instead.
|
||||
#[allow(dead_code)] // unused when built with the `capture_baseline` feature
|
||||
const PORT_LSX_NBO: u16 = 0x900C; // 3216 big-endian (EA App LSX)
|
||||
#[allow(dead_code)]
|
||||
const PORT_LSX_TARGET_NBO: u16 = 0x910C; // 3217 big-endian (bridge LSX target)
|
||||
const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian
|
||||
|
||||
#[repr(C)]
|
||||
struct SockaddrIn {
|
||||
sin_family: u16,
|
||||
sin_port: u16,
|
||||
sin_addr: u32,
|
||||
sin_zero: [u8; 8],
|
||||
sin_port: u16,
|
||||
sin_addr: u32,
|
||||
sin_zero: [u8; 8],
|
||||
}
|
||||
|
||||
const AF_INET6: u16 = 23; // Windows AF_INET6 value (we run under the Win ABI in Wine)
|
||||
|
||||
/// Win32 `sockaddr_in6`. `sin6_port` is network byte order; `sin6_addr` is 16 raw
|
||||
/// address bytes in network order. 28 bytes total.
|
||||
#[repr(C)]
|
||||
struct SockaddrIn6 {
|
||||
sin6_family: u16,
|
||||
sin6_port: u16,
|
||||
sin6_flowinfo: u32,
|
||||
sin6_addr: [u8; 16],
|
||||
sin6_scope_id: u32,
|
||||
}
|
||||
|
||||
/// IPv4-mapped IPv6 loopback: `::ffff:127.0.0.1`. An `AF_INET6` socket connecting to
|
||||
/// this sends real IPv4 packets to 127.0.0.1, so the connection lands on the bridge's
|
||||
/// existing IPv4 listener on :8443 — no separate IPv6 listener needed. The game's own
|
||||
/// EA dials already use v4-mapped addresses (`::ffff:x.x.x.x`), so its sockets are not
|
||||
/// `IPV6_V6ONLY` and will accept this target.
|
||||
const V4MAPPED_LOOPBACK: [u8; 16] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1];
|
||||
|
||||
// Address of ws2_32!connect (set at hook installation)
|
||||
static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
@@ -27,18 +56,26 @@ static mut ORIGINAL_BYTES: [u8; 14] = [0u8; 14];
|
||||
|
||||
// For WSAConnect IAT fallback
|
||||
type WsaConnectFn = unsafe extern "system" fn(
|
||||
s: usize, name: *const u8, namelen: i32,
|
||||
caller: *const (), callee: *const (),
|
||||
sqos: *const (), gqos: *const ()) -> i32;
|
||||
s: usize,
|
||||
name: *const u8,
|
||||
namelen: i32,
|
||||
caller: *const (),
|
||||
callee: *const (),
|
||||
sqos: *const (),
|
||||
gqos: *const (),
|
||||
) -> i32;
|
||||
static REAL_WSA: OnceLock<WsaConnectFn> = OnceLock::new();
|
||||
pub fn set_real_wsa_connect(f: WsaConnectFn) { let _ = REAL_WSA.set(f); }
|
||||
pub fn set_real_wsa_connect(f: WsaConnectFn) {
|
||||
let _ = REAL_WSA.set(f);
|
||||
}
|
||||
|
||||
unsafe fn write_hook(target: *mut u8, dest: u64) {
|
||||
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
// FF 25 00 00 00 00 JMP [rip+0]
|
||||
target.write(0xFF); target.add(1).write(0x25);
|
||||
target.write(0xFF);
|
||||
target.add(1).write(0x25);
|
||||
(target.add(2) as *mut u32).write(0u32);
|
||||
(target.add(6) as *mut u64).write(dest);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
@@ -48,42 +85,99 @@ unsafe fn restore_original(target: *mut u8) {
|
||||
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
core::ptr::copy_nonoverlapping(ORIGINAL_BYTES.as_ptr(), target, 14);
|
||||
core::ptr::copy_nonoverlapping(core::ptr::addr_of!(ORIGINAL_BYTES) as *const u8, target, 14);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
}
|
||||
|
||||
unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 16], i32)> {
|
||||
if namelen < 8 { return None; }
|
||||
let sa = &*(name as *const SockaddrIn);
|
||||
if sa.sin_family != AF_INET { return None; }
|
||||
/// If `name` is an EA-relevant connect target, return a rewritten sockaddr pointing at
|
||||
/// the local bridge (plus its byte length). Handles BOTH `AF_INET` and `AF_INET6`: the
|
||||
/// game's Blaze/DirtySDK stack dials EA over IPv6 (v4-mapped) on :443, and the old
|
||||
/// IPv4-only path let those slip straight past us to the real (dead) servers.
|
||||
///
|
||||
/// The returned buffer is 28 bytes (enough for a `sockaddr_in6`); the second value is
|
||||
/// how many of those bytes are meaningful (16 for v4, 28 for v6). `pub(crate)` so the
|
||||
/// ConnectEx path can share this one implementation.
|
||||
pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> {
|
||||
if namelen < 8 || name.is_null() {
|
||||
return None;
|
||||
}
|
||||
// The first u16 of any sockaddr is the address family.
|
||||
let family = *(name as *const u16);
|
||||
let mut buf = [0u8; 28];
|
||||
|
||||
let orig = sa.sin_addr.to_le_bytes();
|
||||
let orig_port = u16::from_be(sa.sin_port);
|
||||
|
||||
let new_port_nbo = match sa.sin_port {
|
||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
||||
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
|
||||
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
crate::write_log(&format!(
|
||||
"connect_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
|
||||
orig[3], orig[2], orig[1], orig[0], orig_port,
|
||||
u16::from_be(new_port_nbo)
|
||||
));
|
||||
|
||||
let mut buf = [0u8; 16];
|
||||
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
|
||||
out.sin_family = AF_INET;
|
||||
out.sin_port = new_port_nbo;
|
||||
out.sin_addr = ADDR_LOOPBACK_NBO;
|
||||
Some((buf, 16))
|
||||
match family {
|
||||
AF_INET => {
|
||||
// SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read.
|
||||
let sa = &*(name as *const SockaddrIn);
|
||||
let new_port_nbo = match sa.sin_port {
|
||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
||||
#[cfg(not(feature = "capture_baseline"))]
|
||||
PORT_LSX_NBO => PORT_LSX_TARGET_NBO,
|
||||
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
|
||||
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
|
||||
_ => return None,
|
||||
};
|
||||
// sin_addr is network order; to_le_bytes gives memory order = the dotted
|
||||
// quad, so b[0].b[1].b[2].b[3] is correct (the old code printed it reversed).
|
||||
let o = sa.sin_addr.to_le_bytes();
|
||||
crate::write_log(&format!(
|
||||
"connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
|
||||
o[0],
|
||||
o[1],
|
||||
o[2],
|
||||
o[3],
|
||||
u16::from_be(sa.sin_port),
|
||||
u16::from_be(new_port_nbo)
|
||||
));
|
||||
// SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write.
|
||||
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
|
||||
out.sin_family = AF_INET;
|
||||
out.sin_port = new_port_nbo;
|
||||
out.sin_addr = ADDR_LOOPBACK_NBO;
|
||||
Some((buf, 16))
|
||||
}
|
||||
AF_INET6 => {
|
||||
if namelen < 28 {
|
||||
return None;
|
||||
}
|
||||
// SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6).
|
||||
let sa6 = &*(name as *const SockaddrIn6);
|
||||
// LSX is IPv4-only (anadius keys on it), so it is intentionally omitted here.
|
||||
let new_port_nbo = match sa6.sin6_port {
|
||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
||||
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
|
||||
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
|
||||
_ => return None,
|
||||
};
|
||||
let a = sa6.sin6_addr;
|
||||
crate::write_log(&format!(
|
||||
"connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n",
|
||||
a[0],
|
||||
a[1],
|
||||
a[14],
|
||||
a[15],
|
||||
u16::from_be(sa6.sin6_port),
|
||||
u16::from_be(new_port_nbo)
|
||||
));
|
||||
// SAFE: buf is exactly 28 bytes == sizeof(sockaddr_in6).
|
||||
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6);
|
||||
out.sin6_family = AF_INET6;
|
||||
out.sin6_port = new_port_nbo;
|
||||
out.sin6_flowinfo = 0;
|
||||
out.sin6_addr = V4MAPPED_LOOPBACK;
|
||||
out.sin6_scope_id = 0;
|
||||
Some((buf, 28))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: i32) -> i32 {
|
||||
let addr = CONNECT_ADDR.load(Ordering::Relaxed) as *mut u8;
|
||||
|
||||
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
|
||||
crate::transport_watch::note_connect("connect", name, namelen, s);
|
||||
|
||||
// Log every call so we can confirm the hook fires at all
|
||||
if namelen >= 8 {
|
||||
let sa = &*(name as *const SockaddrIn);
|
||||
@@ -95,7 +189,13 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE};
|
||||
let mut ty: i32 = -1;
|
||||
let mut len: i32 = 4;
|
||||
getsockopt(s, SOL_SOCKET as i32, SO_TYPE, &mut ty as *mut i32 as *mut u8, &mut len);
|
||||
getsockopt(
|
||||
s,
|
||||
SOL_SOCKET,
|
||||
SO_TYPE,
|
||||
&mut ty as *mut i32 as *mut u8,
|
||||
&mut len,
|
||||
);
|
||||
ty
|
||||
};
|
||||
crate::write_log(&format!(
|
||||
@@ -111,11 +211,11 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
let (call_name, call_len) = if let Some((buf, len)) = redirect_if_ea(name, namelen) {
|
||||
restore_original(addr);
|
||||
let r = {
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32
|
||||
= core::mem::transmute(addr);
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 =
|
||||
core::mem::transmute(addr);
|
||||
f(s, buf.as_ptr(), len)
|
||||
};
|
||||
write_hook(addr, hooked_connect as u64);
|
||||
write_hook(addr, hooked_connect as *const () as u64);
|
||||
return r;
|
||||
} else {
|
||||
(name, namelen)
|
||||
@@ -123,18 +223,19 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
|
||||
restore_original(addr);
|
||||
let r = {
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32
|
||||
= core::mem::transmute(addr);
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr);
|
||||
f(s, call_name, call_len)
|
||||
};
|
||||
write_hook(addr, hooked_connect as u64);
|
||||
write_hook(addr, hooked_connect as *const () as u64);
|
||||
if namelen >= 8 {
|
||||
let sa = &*(call_name as *const SockaddrIn);
|
||||
if sa.sin_family == AF_INET {
|
||||
let err = if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
|
||||
WSAGetLastError()
|
||||
} else { 0 };
|
||||
} else {
|
||||
0
|
||||
};
|
||||
crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n"));
|
||||
}
|
||||
}
|
||||
@@ -142,10 +243,16 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_wsa_connect(
|
||||
s: usize, name: *const u8, namelen: i32,
|
||||
caller: *const (), callee: *const (),
|
||||
sqos: *const (), gqos: *const (),
|
||||
s: usize,
|
||||
name: *const u8,
|
||||
namelen: i32,
|
||||
caller: *const (),
|
||||
callee: *const (),
|
||||
sqos: *const (),
|
||||
gqos: *const (),
|
||||
) -> i32 {
|
||||
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
|
||||
crate::transport_watch::note_connect("WSAConnect", name, namelen, s);
|
||||
let real = REAL_WSA.get().copied().unwrap();
|
||||
if let Some((buf, len)) = redirect_if_ea(name, namelen) {
|
||||
real(s, buf.as_ptr(), len, caller, callee, sqos, gqos)
|
||||
@@ -158,18 +265,24 @@ pub unsafe extern "system" fn hooked_wsa_connect(
|
||||
pub unsafe fn install_inline_connect_hook() -> bool {
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
||||
|
||||
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr());
|
||||
if ws2.is_null() { return false; }
|
||||
let connect_fn = match GetProcAddress(ws2, b"connect\0".as_ptr()) {
|
||||
let ws2 = GetModuleHandleA(c"ws2_32.dll".as_ptr().cast());
|
||||
if ws2.is_null() {
|
||||
return false;
|
||||
}
|
||||
let connect_fn = match GetProcAddress(ws2, c"connect".as_ptr().cast()) {
|
||||
Some(f) => f as *mut u8,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
// Save original 14 bytes
|
||||
core::ptr::copy_nonoverlapping(connect_fn, ORIGINAL_BYTES.as_mut_ptr(), 14);
|
||||
core::ptr::copy_nonoverlapping(
|
||||
connect_fn,
|
||||
core::ptr::addr_of_mut!(ORIGINAL_BYTES) as *mut u8,
|
||||
14,
|
||||
);
|
||||
CONNECT_ADDR.store(connect_fn as usize, Ordering::Relaxed);
|
||||
|
||||
// Overwrite first 14 bytes with absolute indirect JMP to our hook
|
||||
write_hook(connect_fn, hooked_connect as u64);
|
||||
write_hook(connect_fn, hooked_connect as *const () as u64);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
use core::ffi::c_void;
|
||||
/// Intercepts ConnectEx (EA/DirtySDK's preferred async connect API).
|
||||
///
|
||||
/// DirtySDK calls WSAIoctl(SIO_GET_EXTENSION_FUNCTION_POINTER, WSAID_CONNECTEX) once at
|
||||
/// startup to get a ConnectEx function pointer, bypassing all IAT hooks. We hook WSAIoctl
|
||||
/// inline so that when it returns a ConnectEx pointer we swap it for our own wrapper.
|
||||
use core::sync::atomic::{AtomicUsize, Ordering};
|
||||
use core::ffi::c_void;
|
||||
|
||||
const AF_INET: u16 = 2;
|
||||
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
|
||||
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
|
||||
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
|
||||
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
|
||||
const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian
|
||||
// Address rewriting (v4 + v6) is shared from connect_hook::redirect_if_ea, so the port
|
||||
// constants and sockaddr structs no longer live here.
|
||||
|
||||
// SIO_GET_EXTENSION_FUNCTION_POINTER
|
||||
const SIO_GET_EXT_FN: u32 = 0xC8000006;
|
||||
|
||||
// WSAID_CONNECTEX = {25A207B9-DDF3-4660-8EE9-76E58C74063E}
|
||||
const CONNECTEX_GUID: [u8; 16] = [
|
||||
0xB9, 0x07, 0xA2, 0x25,
|
||||
0xF3, 0xDD, 0x60, 0x46,
|
||||
0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E,
|
||||
0xB9, 0x07, 0xA2, 0x25, 0xF3, 0xDD, 0x60, 0x46, 0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E,
|
||||
];
|
||||
|
||||
#[repr(C)]
|
||||
struct SockaddrIn {
|
||||
sin_family: u16,
|
||||
sin_port: u16,
|
||||
sin_addr: u32,
|
||||
sin_zero: [u8; 8],
|
||||
}
|
||||
|
||||
// The real ConnectEx pointer, saved after WSAIoctl returns it
|
||||
static REAL_CONNECTEX: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
@@ -65,7 +51,8 @@ unsafe fn write_hook(target: *mut u8, dest: u64) {
|
||||
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
target.write(0xFF); target.add(1).write(0x25);
|
||||
target.write(0xFF);
|
||||
target.add(1).write(0x25);
|
||||
(target.add(2) as *mut u32).write(0u32);
|
||||
(target.add(6) as *mut u64).write(dest);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
@@ -75,7 +62,7 @@ unsafe fn restore_wsaioctl(target: *mut u8) {
|
||||
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
core::ptr::copy_nonoverlapping(WSAIOCTL_ORIG.as_ptr(), target, 14);
|
||||
core::ptr::copy_nonoverlapping(core::ptr::addr_of!(WSAIOCTL_ORIG) as *const u8, target, 14);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
}
|
||||
|
||||
@@ -91,33 +78,31 @@ unsafe extern "system" fn hooked_connectex(
|
||||
) -> i32 {
|
||||
let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed));
|
||||
|
||||
if namelen >= 8 {
|
||||
let sa = &*(name as *const SockaddrIn);
|
||||
if sa.sin_family == AF_INET {
|
||||
let o = sa.sin_addr.to_le_bytes();
|
||||
let orig_port = u16::from_be(sa.sin_port);
|
||||
let new_port_nbo = match sa.sin_port {
|
||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
||||
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
|
||||
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
|
||||
_ => 0,
|
||||
};
|
||||
if new_port_nbo != 0 {
|
||||
crate::write_log(&format!(
|
||||
"connectex_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
|
||||
o[3], o[2], o[1], o[0], orig_port,
|
||||
u16::from_be(new_port_nbo)
|
||||
));
|
||||
let mut redirect = [0u8; 16];
|
||||
let out = &mut *(redirect.as_mut_ptr() as *mut SockaddrIn);
|
||||
out.sin_family = AF_INET;
|
||||
out.sin_port = new_port_nbo;
|
||||
out.sin_addr = ADDR_LOOPBACK_NBO;
|
||||
return real_fn(s, redirect.as_ptr(), 16, send_buf, send_data_len, bytes_sent, overlapped);
|
||||
}
|
||||
}
|
||||
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
|
||||
crate::transport_watch::note_connect("ConnectEx", name, namelen, s);
|
||||
|
||||
// Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx
|
||||
// dials get the same IPv6 handling as plain connect().
|
||||
if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) {
|
||||
return real_fn(
|
||||
s,
|
||||
buf.as_ptr(),
|
||||
len,
|
||||
send_buf,
|
||||
send_data_len,
|
||||
bytes_sent,
|
||||
overlapped,
|
||||
);
|
||||
}
|
||||
real_fn(s, name, namelen, send_buf, send_data_len, bytes_sent, overlapped)
|
||||
real_fn(
|
||||
s,
|
||||
name,
|
||||
namelen,
|
||||
send_buf,
|
||||
send_data_len,
|
||||
bytes_sent,
|
||||
overlapped,
|
||||
)
|
||||
}
|
||||
|
||||
/// Our WSAIoctl hook: when ConnectEx is requested, save the real pointer and return ours
|
||||
@@ -138,28 +123,28 @@ pub unsafe extern "system" fn hooked_wsaioctl(
|
||||
restore_wsaioctl(addr);
|
||||
let result = {
|
||||
let f: WsaIoctlFn = core::mem::transmute(addr);
|
||||
f(s, code, in_buf, in_len, out_buf, out_len, bytes_ret, overlapped, completion)
|
||||
f(
|
||||
s, code, in_buf, in_len, out_buf, out_len, bytes_ret, overlapped, completion,
|
||||
)
|
||||
};
|
||||
write_hook(addr, hooked_wsaioctl as u64);
|
||||
write_hook(addr, hooked_wsaioctl as *const () as u64);
|
||||
|
||||
// If this was a ConnectEx request that succeeded, swap the pointer
|
||||
if result == 0
|
||||
&& code == SIO_GET_EXT_FN
|
||||
&& in_len == 16
|
||||
&& !in_buf.is_null()
|
||||
{
|
||||
if result == 0 && code == SIO_GET_EXT_FN && in_len == 16 && !in_buf.is_null() {
|
||||
let guid = core::slice::from_raw_parts(in_buf as *const u8, 16);
|
||||
if guid == CONNECTEX_GUID
|
||||
&& out_len >= 8
|
||||
&& !out_buf.is_null()
|
||||
{
|
||||
if guid == CONNECTEX_GUID && out_len >= 8 && !out_buf.is_null() {
|
||||
let out_ptr = out_buf as *mut usize;
|
||||
let real_addr = *out_ptr;
|
||||
if REAL_CONNECTEX.compare_exchange(0, real_addr, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
|
||||
crate::write_log(&format!("connectex_hook: intercepted ConnectEx @ {real_addr:#x}\n"));
|
||||
if REAL_CONNECTEX
|
||||
.compare_exchange(0, real_addr, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
crate::write_log(&format!(
|
||||
"connectex_hook: intercepted ConnectEx @ {real_addr:#x}\n"
|
||||
));
|
||||
}
|
||||
// Return our hook instead
|
||||
*out_ptr = hooked_connectex as usize;
|
||||
*out_ptr = hooked_connectex as *const () as usize;
|
||||
}
|
||||
}
|
||||
result
|
||||
@@ -167,14 +152,20 @@ pub unsafe extern "system" fn hooked_wsaioctl(
|
||||
|
||||
pub unsafe fn install_wsaioctl_hook() -> bool {
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
||||
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr());
|
||||
if ws2.is_null() { return false; }
|
||||
let fn_ptr = match GetProcAddress(ws2, b"WSAIoctl\0".as_ptr()) {
|
||||
let ws2 = GetModuleHandleA(c"ws2_32.dll".as_ptr().cast());
|
||||
if ws2.is_null() {
|
||||
return false;
|
||||
}
|
||||
let fn_ptr = match GetProcAddress(ws2, c"WSAIoctl".as_ptr().cast()) {
|
||||
Some(f) => f as *mut u8,
|
||||
None => return false,
|
||||
};
|
||||
core::ptr::copy_nonoverlapping(fn_ptr, WSAIOCTL_ORIG.as_mut_ptr(), 14);
|
||||
core::ptr::copy_nonoverlapping(
|
||||
fn_ptr,
|
||||
core::ptr::addr_of_mut!(WSAIOCTL_ORIG) as *mut u8,
|
||||
14,
|
||||
);
|
||||
WSAIOCTL_ADDR.store(fn_ptr as usize, Ordering::Relaxed);
|
||||
write_hook(fn_ptr, hooked_wsaioctl as u64);
|
||||
write_hook(fn_ptr, hooked_wsaioctl as *const () as u64);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Synthetic "notification" struct for the direct-call dial trigger.
|
||||
//!
|
||||
//! STATIC ARTIFACT ONLY — this module builds the byte layout the dial handler
|
||||
//! (FIFA23.exe+0x4f4d360) expects in its `rdx` argument, plus a do-nothing
|
||||
//! completion callback. It does NOT call the game, does NOT install any detour,
|
||||
//! and is NOT wired into the hook yet. The invocation phase (later) consumes
|
||||
//! `build_notification()` + `completion_stub`.
|
||||
//!
|
||||
//! Layout contract (from the 2026-07-03 dial-branch RE report on 0x144f4d590):
|
||||
//! [+0x00] byte : entry gate — MUST be non-zero (else the error path fires). => 1
|
||||
//! [+0x80] qword : completion delegate fn pointer. => &completion_stub
|
||||
//! [+0x88] qword : delegate capture #1. => 0
|
||||
//! [+0x90] qword : delegate capture #2. => 0
|
||||
//! [+0xa0] dword : RpcJob key/priority (copied, never compared on dial path). => 0
|
||||
//! everything else in [0x00..0x100] : 0
|
||||
//! The RE confirmed no other offset in this range is read on the success path.
|
||||
//! Total size 0x100 (256): the tail 0xa4..0x100 is zero padding — cheap insurance
|
||||
//! against a read we might have missed. Any offset here is TODO/CONFIRM against the
|
||||
//! RE report; if the game contradicts it at runtime, stop and re-verify.
|
||||
|
||||
// This module is deliberately unused for now (the invocation phase will call into
|
||||
// it). Silence "never used" warnings until then rather than sprinkle #[allow] on
|
||||
// each item. Remove this once the trigger wires the API up.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use core::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
/// Size of the notification struct, in bytes. 0x100 = 256.
|
||||
const NOTIFICATION_SIZE: usize = 0x100;
|
||||
|
||||
// --- field offsets (named so the code reads like the RE contract) -------------
|
||||
const OFF_GATE: usize = 0x00; // byte, must be non-zero
|
||||
const OFF_DELEGATE_FN: usize = 0x80; // qword, completion fn pointer
|
||||
const OFF_DELEGATE_CAP1: usize = 0x88; // qword, capture (0)
|
||||
const OFF_DELEGATE_CAP2: usize = 0x90; // qword, capture (0)
|
||||
const OFF_KEY: usize = 0xa0; // dword, job key/priority (0)
|
||||
|
||||
/// Counts how many times `completion_stub` has been entered.
|
||||
///
|
||||
/// Why `AtomicU32` and not `static mut u32`: a `static mut` needs `unsafe` to
|
||||
/// touch and, worse, gives *undefined behaviour* if two threads write it at once
|
||||
/// (a data race). The completion callback may be invoked from an arbitrary game
|
||||
/// thread, so a plain counter would race. `AtomicU32` makes increment a single
|
||||
/// lock-free hardware instruction with well-defined concurrent semantics, and it
|
||||
/// needs no `unsafe`. `Ordering::Relaxed` is enough here: we only care about the
|
||||
/// count value, not about ordering it against other memory.
|
||||
static COMPLETION_STUB_CALLS: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// The completion callback the game may invoke when the RpcJob finishes.
|
||||
///
|
||||
/// `extern "C"`: on the `x86_64-pc-windows-gnu` target this selects the Microsoft
|
||||
/// x64 calling convention — exactly how the game invokes the pointer (`call r10`,
|
||||
/// args in rcx/rdx/r8/r9, return in rax, caller cleans the stack). Matching the
|
||||
/// convention is what makes it safe for the game to call us.
|
||||
///
|
||||
/// We declare four pointer-sized params and ignore them. The RE showed the delegate
|
||||
/// is called with e.g. an HRESULT in `rdx` and a `this`-like pointer in `rcx`; the
|
||||
/// success-path completion may pass different values. Because Win64 is caller-clean
|
||||
/// and puts the first four integer args in registers, declaring four ignored args is
|
||||
/// safe no matter what the caller actually passes — we simply never read them.
|
||||
///
|
||||
/// The body does the absolute minimum: bump the atomic counter and return 0. NO
|
||||
/// logging, NO allocation, NO calls — a completion callback can fire from any game
|
||||
/// context, and even a log write there could be unsafe. Observe from outside via
|
||||
/// `completion_stub_call_count()` instead.
|
||||
///
|
||||
/// Returns `usize` = 0, which reads as an `S_OK`-shaped HRESULT if the caller looks
|
||||
/// at the return value. (Returning void would be equally fine; 0 is a safe default.)
|
||||
pub extern "C" fn completion_stub(_a: usize, _b: usize, _c: usize, _d: usize) -> usize {
|
||||
// `fetch_add` is a single atomic read-modify-write (lock xadd) — no lock, no
|
||||
// syscall, no allocation. Safe to call from any thread/context.
|
||||
COMPLETION_STUB_CALLS.fetch_add(1, Ordering::Relaxed);
|
||||
0
|
||||
}
|
||||
|
||||
/// Read how many times `completion_stub` has fired. For an outside observer thread —
|
||||
/// keeps all I/O out of the stub itself.
|
||||
pub fn completion_stub_call_count() -> u32 {
|
||||
COMPLETION_STUB_CALLS.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Write a little-endian u64 into `buf` starting at `offset`.
|
||||
///
|
||||
/// Endianness matters because we're hand-laying a memory image the game will read
|
||||
/// back as a raw pointer/integer. x86-64 is *little-endian*: the least-significant
|
||||
/// byte sits at the lowest address. `value.to_le_bytes()` produces the 8 bytes in
|
||||
/// exactly that order, so when the game does `mov rax,[ptr]` it reconstructs the
|
||||
/// original `value`. Using the native byte order by hand (or `transmute`) would be
|
||||
/// wrong on a big-endian machine; `to_le_bytes` states the intent explicitly.
|
||||
///
|
||||
/// `buf[offset..offset + 8]` is an 8-byte sub-slice; `copy_from_slice` copies the
|
||||
/// 8-byte array into it. Both sides are length 8, so it can't panic here. (This is
|
||||
/// the standard, safe way to poke a fixed-width integer into a `[u8]`.)
|
||||
fn write_u64_le(buf: &mut [u8], offset: usize, value: u64) {
|
||||
buf[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Write a little-endian u32 into `buf` starting at `offset`. (Same idea as
|
||||
/// `write_u64_le`, 4 bytes wide.)
|
||||
fn write_u32_le(buf: &mut [u8], offset: usize, value: u32) {
|
||||
buf[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
/// Build the fully-populated notification struct, ready to be passed by pointer to
|
||||
/// the dial handler as its `rdx` argument.
|
||||
///
|
||||
/// Returns a `[u8; 0x100]` by value. Why a byte array and not a `#[repr(C)]` struct:
|
||||
/// the layout is a precise *offset* contract recovered by RE, with meaningful data
|
||||
/// only at 0x00/0x80/0x88/0x90/0xa0 and zeros elsewhere. A byte array makes every
|
||||
/// offset literally visible and immune to any field-ordering/padding surprise. A
|
||||
/// `#[repr(C)] struct` with explicit padding fields would work too, but it's easier
|
||||
/// to get a padding byte wrong than to index a flat array. (For future reference:
|
||||
/// the `bytemuck` crate can safely reinterpret a `#[repr(C)]` struct as `&[u8]`
|
||||
/// zero-copy — worth knowing, but overkill here and an extra dependency.)
|
||||
pub fn build_notification() -> [u8; NOTIFICATION_SIZE] {
|
||||
// Start fully zeroed. This already satisfies every "= 0" field (caps at +0x88/
|
||||
// +0x90, the key at +0xa0, and all padding); we only need to set the non-zero
|
||||
// fields below.
|
||||
let mut buf = [0u8; NOTIFICATION_SIZE];
|
||||
|
||||
// [+0x00] entry gate: must be non-zero to reach the dial path.
|
||||
buf[OFF_GATE] = 1;
|
||||
|
||||
// [+0x80] completion delegate function pointer = &completion_stub.
|
||||
//
|
||||
// `completion_stub as *const ()`: a *function item* in Rust is a zero-sized,
|
||||
// unique type, not a value. Casting it to a raw pointer coerces it to a function
|
||||
// pointer and then to an untyped code pointer `*const ()` — i.e. the address of
|
||||
// the function's machine code. The intermediate `*const ()` before `as u64` is
|
||||
// the idiomatic form: it says "treat this as an address" and also avoids the
|
||||
// `clippy`/rustc "direct cast of function item into an integer" lint you'd get
|
||||
// from `completion_stub as u64`.
|
||||
let stub_addr = completion_stub as *const () as u64;
|
||||
write_u64_le(&mut buf, OFF_DELEGATE_FN, stub_addr);
|
||||
|
||||
// [+0x88]/[+0x90] delegate captures = 0. Already zero from initialization; write
|
||||
// them explicitly so the layout intent is visible at a glance.
|
||||
write_u64_le(&mut buf, OFF_DELEGATE_CAP1, 0);
|
||||
write_u64_le(&mut buf, OFF_DELEGATE_CAP2, 0);
|
||||
|
||||
// [+0xa0] RpcJob key/priority dword = 0 (copied, never compared on the dial path).
|
||||
write_u32_le(&mut buf, OFF_KEY, 0);
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn notification_layout() {
|
||||
let n = build_notification();
|
||||
|
||||
// Total size is exactly 0x100.
|
||||
assert_eq!(n.len(), NOTIFICATION_SIZE);
|
||||
|
||||
// [+0x00] gate byte == 1.
|
||||
assert_eq!(n[0x00], 1);
|
||||
|
||||
// [+0xa0..0xa4] as u32 == 0.
|
||||
// `try_into().unwrap()` turns the 4-byte slice into a `[u8; 4]` (it can only
|
||||
// fail if the slice weren't length 4, which it is), and `from_le_bytes`
|
||||
// reads it back the same little-endian way we wrote it.
|
||||
let key = u32::from_le_bytes(n[0xa0..0xa4].try_into().unwrap());
|
||||
assert_eq!(key, 0);
|
||||
|
||||
// [+0x80..0x88] as u64 == address of completion_stub.
|
||||
let stub = u64::from_le_bytes(n[0x80..0x88].try_into().unwrap());
|
||||
assert_eq!(stub, completion_stub as *const () as u64);
|
||||
|
||||
// [+0x88..0x90] and [+0x90..0x98] captures == 0.
|
||||
assert_eq!(u64::from_le_bytes(n[0x88..0x90].try_into().unwrap()), 0);
|
||||
assert_eq!(u64::from_le_bytes(n[0x90..0x98].try_into().unwrap()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_counter_increments() {
|
||||
let before = completion_stub_call_count();
|
||||
let _ = completion_stub(0, 0, 0, 0);
|
||||
assert_eq!(completion_stub_call_count(), before + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! FIFA 17 injection path (feature = "fifa17").
|
||||
//!
|
||||
//! This is a *separate, minimal* entry point from the FIFA-23 `install_hooks`.
|
||||
//! FIFA 17 is a different game with different in-memory structures, so we run NONE
|
||||
//! of the FIFA-23 connect/LSX/origin_spy/dial logic here — that would at best
|
||||
//! no-op and at worst crash. For now this proves the version.dll hijack actually
|
||||
//! loads us into FIFA17.exe and dumps the module map, which we need to locate
|
||||
//! DirtySDK/ProtoSSL's cert-verify function (the next milestone: patch it so the
|
||||
//! secure Blaze redirector's TLS handshake succeeds against our bridge cert).
|
||||
//!
|
||||
//! Everything here is read-only except the (not-yet-enabled) cert-verify patch.
|
||||
|
||||
use crate::write_log;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Module32FirstW, Module32NextW, MODULEENTRY32W, TH32CS_SNAPMODULE,
|
||||
TH32CS_SNAPMODULE32,
|
||||
};
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
||||
|
||||
/// Read the SizeOfImage from a module's in-memory PE headers.
|
||||
unsafe fn size_of_image(base: usize) -> u32 {
|
||||
if base == 0 {
|
||||
return 0;
|
||||
}
|
||||
// DOS header -> e_lfanew (i32 @ 0x3c) -> PE header. SizeOfImage is in the
|
||||
// optional header at offset 0x50 from the PE signature (same for PE32/PE32+).
|
||||
let e_lfanew = *((base + 0x3c) as *const i32);
|
||||
let pe = base + e_lfanew as usize;
|
||||
// sanity: 'PE\0\0'
|
||||
if *(pe as *const u32) != 0x0000_4550 {
|
||||
return 0;
|
||||
}
|
||||
*((pe + 24 + 0x38) as *const u32) // opt header +0x38 = SizeOfImage
|
||||
}
|
||||
|
||||
fn wide_to_string(w: &[u16]) -> String {
|
||||
let end = w.iter().position(|&c| c == 0).unwrap_or(w.len());
|
||||
String::from_utf16_lossy(&w[..end])
|
||||
}
|
||||
|
||||
/// Enumerate loaded modules (name, base, size) via ToolHelp and log them.
|
||||
unsafe fn dump_modules() {
|
||||
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, 0);
|
||||
if snap == INVALID_HANDLE_VALUE {
|
||||
write_log("fifa17: module snapshot FAILED\n");
|
||||
return;
|
||||
}
|
||||
let mut me: MODULEENTRY32W = core::mem::zeroed();
|
||||
me.dwSize = core::mem::size_of::<MODULEENTRY32W>() as u32;
|
||||
if Module32FirstW(snap, &mut me) != 0 {
|
||||
loop {
|
||||
let name = wide_to_string(&me.szModule);
|
||||
let base = me.modBaseAddr as usize;
|
||||
let size = me.modBaseSize;
|
||||
write_log(&format!(
|
||||
"fifa17: module {name:<28} base={base:#018x} size={size:#x}\n"
|
||||
));
|
||||
me.dwSize = core::mem::size_of::<MODULEENTRY32W>() as u32;
|
||||
if Module32NextW(snap, &mut me) == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
write_log("fifa17: Module32FirstW FAILED\n");
|
||||
}
|
||||
CloseHandle(snap);
|
||||
}
|
||||
|
||||
/// Worker that runs AFTER DllMain returns (loader lock released). ToolHelp and
|
||||
/// other loader-touching calls are unsafe under the loader lock, so we defer them
|
||||
/// to this thread. This is what fixed the "game exits right after DllMain" issue.
|
||||
unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
||||
write_log("=== fifa17 hook: worker thread start ===\n");
|
||||
let main_base = GetModuleHandleA(core::ptr::null()) as usize;
|
||||
let img = size_of_image(main_base);
|
||||
write_log(&format!(
|
||||
"fifa17: main exe base={main_base:#018x} SizeOfImage={img:#x}\n"
|
||||
));
|
||||
dump_modules();
|
||||
write_log("fifa17: worker complete (injection healthy)\n");
|
||||
// The promoted SBC dispatch repair (and the evidence traces it decides on) arms
|
||||
// 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_trace::install();
|
||||
crate::sbc_dispatch::install();
|
||||
crate::sbc_request_trace::install();
|
||||
crate::store_entry::install();
|
||||
crate::season_trace::install();
|
||||
crate::kit_trace::install();
|
||||
0
|
||||
}
|
||||
|
||||
/// Minimal FIFA-17 install. Keep DllMain itself trivial: only spawn a worker
|
||||
/// thread and return immediately, so we never touch the loader lock from here.
|
||||
pub unsafe fn install() {
|
||||
use windows_sys::Win32::System::Threading::CreateThread;
|
||||
write_log("=== fifa17 hook: DllMain ATTACH (spawning worker) ===\n");
|
||||
let h = CreateThread(
|
||||
core::ptr::null(),
|
||||
0,
|
||||
Some(worker),
|
||||
core::ptr::null(),
|
||||
0,
|
||||
core::ptr::null_mut(),
|
||||
);
|
||||
if h == 0 as _ {
|
||||
write_log("fifa17: CreateThread FAILED\n");
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -1,19 +1,15 @@
|
||||
use std::{
|
||||
ffi::CStr,
|
||||
sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
OnceLock,
|
||||
},
|
||||
};
|
||||
|
||||
use windows_sys::Win32::Networking::WinSock::{ADDRINFOA, getaddrinfo as sys_getaddrinfo};
|
||||
use windows_sys::Win32::Networking::WinSock::{getaddrinfo as sys_getaddrinfo, ADDRINFOA};
|
||||
|
||||
type GetaddrinfoFn = unsafe extern "system" fn(
|
||||
*const u8,
|
||||
*const u8,
|
||||
*const ADDRINFOA,
|
||||
*mut *mut ADDRINFOA,
|
||||
) -> i32;
|
||||
type GetaddrinfoFn =
|
||||
unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32;
|
||||
|
||||
static REAL: OnceLock<GetaddrinfoFn> = OnceLock::new();
|
||||
static REDIRECT_IP: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
@@ -54,22 +50,28 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
|
||||
if !node_name.is_null() {
|
||||
if let Ok(host) = CStr::from_ptr(node_name as *const i8).to_str() {
|
||||
crate::write_log(&format!("openfut_hook: getaddrinfo({host})\n"));
|
||||
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
|
||||
crate::transport_watch::note_getaddrinfo(host);
|
||||
if is_ea_host(host) {
|
||||
// Apply the ProtoSSL cert-verify bypass the first time we see an EA
|
||||
// hostname — EAWebKit.dll must be loaded by now because it's calling us.
|
||||
if !CERT_PATCHED.load(Ordering::Relaxed) {
|
||||
if crate::ssl_patch::patch_eawebkit_cert_verify() {
|
||||
CERT_PATCHED.store(true, Ordering::Relaxed);
|
||||
crate::write_log("openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n");
|
||||
crate::write_log(
|
||||
"openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n",
|
||||
);
|
||||
} else {
|
||||
crate::write_log("openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n");
|
||||
crate::write_log(
|
||||
"openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let redirect = REDIRECT_IP
|
||||
.get()
|
||||
.map(|v| v.as_ptr())
|
||||
.unwrap_or(b"127.0.0.1\0".as_ptr());
|
||||
.unwrap_or(c"127.0.0.1".as_ptr().cast());
|
||||
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
|
||||
return real(redirect, service_name, hints, result);
|
||||
}
|
||||
|
||||
+12
-7
@@ -72,7 +72,11 @@ pub unsafe fn patch_iat(original_fn: *const (), hook_fn: *const ()) -> usize {
|
||||
}
|
||||
|
||||
/// Patch the IAT of a specific already-loaded DLL (e.g. b"EAWebKit.dll\0").
|
||||
pub unsafe fn patch_iat_in(module_name: &[u8], original_fn: *const (), hook_fn: *const ()) -> usize {
|
||||
pub unsafe fn patch_iat_in(
|
||||
module_name: &[u8],
|
||||
original_fn: *const (),
|
||||
hook_fn: *const (),
|
||||
) -> usize {
|
||||
let module = GetModuleHandleA(module_name.as_ptr());
|
||||
if module.is_null() {
|
||||
return 0;
|
||||
@@ -80,11 +84,7 @@ pub unsafe fn patch_iat_in(module_name: &[u8], original_fn: *const (), hook_fn:
|
||||
patch_module(module, original_fn, hook_fn)
|
||||
}
|
||||
|
||||
unsafe fn patch_module(
|
||||
module: HMODULE,
|
||||
original_fn: *const (),
|
||||
hook_fn: *const (),
|
||||
) -> usize {
|
||||
unsafe fn patch_module(module: HMODULE, original_fn: *const (), hook_fn: *const ()) -> usize {
|
||||
if module.is_null() {
|
||||
return 0;
|
||||
}
|
||||
@@ -117,7 +117,12 @@ unsafe fn patch_module(
|
||||
if val == original_fn as usize {
|
||||
let target = iat_slot.add(i) as *const std::ffi::c_void;
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target, std::mem::size_of::<usize>(), PAGE_EXECUTE_READWRITE, &mut old);
|
||||
VirtualProtect(
|
||||
target,
|
||||
std::mem::size_of::<usize>(),
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old,
|
||||
);
|
||||
*iat_slot.add(i) = hook_fn as usize;
|
||||
VirtualProtect(target, std::mem::size_of::<usize>(), old, &mut old);
|
||||
count += 1;
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
+196
-32
@@ -1,68 +1,200 @@
|
||||
// The `fifa17` feature compiles this shared crate but activates only the FIFA-17
|
||||
// injection path (fifa17.rs + sbc_*): install_hooks() routes to fifa17::install()
|
||||
// and the FIFA-23 hook modules are reached solely via install_hooks_fifa23(), which
|
||||
// is itself `#[cfg(not(feature = "fifa17"))]`. Those modules are therefore compiled
|
||||
// but unused under `fifa17` (the linker strips them from the cdylib). Scope the
|
||||
// resulting dead-code/unused-import lints to that feature so both builds stay
|
||||
// `-D warnings` clean without dropping code the default (FIFA-23) build needs.
|
||||
#![cfg_attr(feature = "fifa17", allow(dead_code, unused_imports))]
|
||||
|
||||
mod config;
|
||||
mod connect_hook;
|
||||
mod connectex_hook;
|
||||
mod dial_notification;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod fifa17;
|
||||
mod hooks;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod kit_trace;
|
||||
mod iat;
|
||||
mod origin_spy;
|
||||
#[cfg(feature = "probe")]
|
||||
mod probe;
|
||||
#[cfg(feature = "capture_baseline")]
|
||||
mod recv_hook;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_dispatch;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_hook;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_request_trace;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_trace;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod season_trace;
|
||||
mod ssl_patch;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod store_entry;
|
||||
mod tls_bypass;
|
||||
mod transport_watch;
|
||||
mod version_proxy;
|
||||
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{BOOL, HMODULE, TRUE},
|
||||
System::SystemServices::DLL_PROCESS_ATTACH,
|
||||
Networking::WinSock::ADDRINFOA,
|
||||
System::SystemServices::DLL_PROCESS_ATTACH,
|
||||
};
|
||||
|
||||
pub(crate) fn write_log(msg: &str) {
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true).append(true)
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(r"C:\openfut_hook.log")
|
||||
{ let _ = f.write_all(msg.as_bytes()); }
|
||||
{
|
||||
let _ = f.write_all(msg.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Force the log to stable storage. `write_log` already opens+closes the file per line,
|
||||
/// so nothing is buffered *inside our process* (a process crash can't lose a written
|
||||
/// line). `sync_all` additionally flushes the OS cache to disk, for durability even
|
||||
/// across a full system crash. We call this right before the dial trigger's call so the
|
||||
/// pre-call log line is guaranteed on disk if the call faults.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn flush_log() {
|
||||
if let Ok(f) = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(r"C:\openfut_hook.log")
|
||||
{
|
||||
let _ = f.sync_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// This is the DLL entry point invoked by the Windows loader; it MUST NOT be
|
||||
/// called manually. `module` must be the valid `HMODULE` the loader passes for
|
||||
/// this DLL. On `DLL_PROCESS_ATTACH` it installs process-wide inline detours
|
||||
/// (raw memory patching), so it must run exactly once, on the loader thread,
|
||||
/// before any hooked API is used.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL {
|
||||
if reason == DLL_PROCESS_ATTACH { install_hooks(module); }
|
||||
if reason == DLL_PROCESS_ATTACH {
|
||||
// VERSION forwarding must be ready before DllMain returns. Hook setup
|
||||
// may be deferred, but a caller can use any proxy export immediately.
|
||||
if version_proxy::resolve() {
|
||||
install_hooks(module);
|
||||
}
|
||||
}
|
||||
TRUE
|
||||
}
|
||||
|
||||
unsafe fn install_hooks(module: HMODULE) {
|
||||
// FIFA 17 path: run ONLY the minimal, FIFA-17-safe logic and skip every
|
||||
// FIFA-23-specific hook below (they assume FIFA 23's memory layout).
|
||||
#[cfg(feature = "fifa17")]
|
||||
{
|
||||
let _ = module;
|
||||
fifa17::install();
|
||||
}
|
||||
#[cfg(not(feature = "fifa17"))]
|
||||
install_hooks_fifa23(module)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "fifa17"))]
|
||||
unsafe fn install_hooks_fifa23(module: HMODULE) {
|
||||
write_log("openfut_hook: DllMain fired\n");
|
||||
// Milestone-0 transport watch: arm (or note disarmed) from env once, up front, so
|
||||
// the getaddrinfo/connect/ConnectEx detours below can log Blaze-flavored activity.
|
||||
transport_watch::arm_from_env();
|
||||
let ip = config::read_redirect_ip(module);
|
||||
hooks::set_redirect_ip(ip);
|
||||
|
||||
let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0");
|
||||
if !ga.is_null() {
|
||||
let f: unsafe extern "system" fn(*const u8,*const u8,*const ADDRINFOA,*mut *mut ADDRINFOA)->i32
|
||||
= std::mem::transmute(ga);
|
||||
let f: unsafe extern "system" fn(
|
||||
*const u8,
|
||||
*const u8,
|
||||
*const ADDRINFOA,
|
||||
*mut *mut ADDRINFOA,
|
||||
) -> i32 = std::mem::transmute(ga);
|
||||
hooks::set_real(f);
|
||||
let n = iat::patch_iat(ga, hooks::hooked_getaddrinfo as *const ());
|
||||
let m = iat::patch_iat_in(b"EAWebKit.dll\0", ga, hooks::hooked_getaddrinfo as *const ());
|
||||
let m = iat::patch_iat_in(
|
||||
b"EAWebKit.dll\0",
|
||||
ga,
|
||||
hooks::hooked_getaddrinfo as *const (),
|
||||
);
|
||||
write_log(&format!("openfut_hook: getaddrinfo IAT patched {n}+{m}\n"));
|
||||
}
|
||||
|
||||
if ssl_patch::patch_main_exe_cert_verify() { write_log("ssl: main exe cert-verify patched\n"); }
|
||||
else { write_log("ssl: main exe cert-verify NOT FOUND\n"); }
|
||||
if ssl_patch::patch_eawebkit_cert_verify() { write_log("ssl: EAWebKit cert-verify patched\n"); }
|
||||
else { write_log("ssl: EAWebKit cert-verify deferred\n"); }
|
||||
if ssl_patch::patch_main_exe_cert_verify() {
|
||||
write_log("ssl: main exe cert-verify patched\n");
|
||||
} else {
|
||||
write_log("ssl: main exe cert-verify NOT FOUND\n");
|
||||
}
|
||||
if ssl_patch::patch_eawebkit_cert_verify() {
|
||||
write_log("ssl: EAWebKit cert-verify patched\n");
|
||||
} else {
|
||||
write_log("ssl: EAWebKit cert-verify deferred\n");
|
||||
}
|
||||
|
||||
if connect_hook::install_inline_connect_hook() { write_log("connect: inline-hooked\n"); }
|
||||
else { write_log("connect: hook FAILED\n"); }
|
||||
if connect_hook::install_inline_connect_hook() {
|
||||
write_log("connect: inline-hooked\n");
|
||||
} else {
|
||||
write_log("connect: hook FAILED\n");
|
||||
}
|
||||
let wp = iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0");
|
||||
if !wp.is_null() {
|
||||
let f: unsafe extern "system" fn(usize,*const u8,i32,*const(),*const(),*const(),*const())->i32
|
||||
= std::mem::transmute(wp);
|
||||
let f: unsafe extern "system" fn(
|
||||
usize,
|
||||
*const u8,
|
||||
i32,
|
||||
*const (),
|
||||
*const (),
|
||||
*const (),
|
||||
*const (),
|
||||
) -> i32 = std::mem::transmute(wp);
|
||||
connect_hook::set_real_wsa_connect(f);
|
||||
iat::patch_iat(wp, connect_hook::hooked_wsa_connect as *const ());
|
||||
write_log("connect: WSAConnect IAT patched\n");
|
||||
}
|
||||
|
||||
if connectex_hook::install_wsaioctl_hook() { write_log("connectex: WSAIoctl inline-hooked\n"); }
|
||||
else { write_log("connectex: WSAIoctl hook FAILED\n"); }
|
||||
if connectex_hook::install_wsaioctl_hook() {
|
||||
write_log("connectex: WSAIoctl inline-hooked\n");
|
||||
} else {
|
||||
write_log("connectex: WSAIoctl hook FAILED\n");
|
||||
}
|
||||
|
||||
// RE instrumentation: passive logging detours on FIFA's in-process online-flow
|
||||
// functions (GoOnline, GetInternetConnectedState, event deserializers) to see
|
||||
// where FIFA stalls after our pushed LSX events. Deferred until anadius loads.
|
||||
#[cfg(feature = "probe")]
|
||||
{
|
||||
probe::install_probes_deferred();
|
||||
write_log("probe: deferred install scheduled\n");
|
||||
}
|
||||
|
||||
// recv/send hooks removed — LSX is now handled by the native openfut-bridge
|
||||
// LSX server (port 3216), so in-process interception is no longer needed.
|
||||
//
|
||||
// Except in the `capture_baseline` build: with the LSX redirect off, FIFA talks
|
||||
// to anadius directly, and these hooks log anadius's real LSX request/response
|
||||
// frames (pass-through, no emulation) so we can diff them against our bridge.
|
||||
#[cfg(feature = "capture_baseline")]
|
||||
{
|
||||
if recv_hook::install_recv_hook() {
|
||||
write_log("CAP: recv inline-hooked\n");
|
||||
} else {
|
||||
write_log("CAP: recv hook FAILED\n");
|
||||
}
|
||||
if recv_hook::install_send_hook() {
|
||||
write_log("CAP: send inline-hooked\n");
|
||||
} else {
|
||||
write_log("CAP: send hook FAILED\n");
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! hook_iat {
|
||||
($dll:expr, $sym:expr, $setter:ident, $handler:expr, $ty:ty) => {{
|
||||
@@ -72,31 +204,63 @@ unsafe fn install_hooks(module: HMODULE) {
|
||||
origin_spy::$setter(f);
|
||||
iat::patch_iat(ptr, $handler as *const ());
|
||||
"ok"
|
||||
} else { "miss" }
|
||||
} else {
|
||||
"miss"
|
||||
}
|
||||
}};
|
||||
}
|
||||
let ra = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExA\0", set_real_reg_a,
|
||||
let ra = hook_iat!(
|
||||
b"advapi32.dll\0",
|
||||
b"RegQueryValueExA\0",
|
||||
set_real_reg_a,
|
||||
origin_spy::hooked_reg_query_a,
|
||||
unsafe extern "system" fn(isize,*const u8,*mut u32,*mut u32,*mut u8,*mut u32)->i32);
|
||||
let rw = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExW\0", set_real_reg_w,
|
||||
unsafe extern "system" fn(isize, *const u8, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
|
||||
);
|
||||
let rw = hook_iat!(
|
||||
b"advapi32.dll\0",
|
||||
b"RegQueryValueExW\0",
|
||||
set_real_reg_w,
|
||||
origin_spy::hooked_reg_query_w,
|
||||
unsafe extern "system" fn(isize,*const u16,*mut u32,*mut u32,*mut u8,*mut u32)->i32);
|
||||
let ma = hook_iat!(b"kernel32.dll\0", b"OpenMutexA\0", set_real_mutex_a,
|
||||
unsafe extern "system" fn(isize, *const u16, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
|
||||
);
|
||||
let ma = hook_iat!(
|
||||
b"kernel32.dll\0",
|
||||
b"OpenMutexA\0",
|
||||
set_real_mutex_a,
|
||||
origin_spy::hooked_open_mutex_a,
|
||||
unsafe extern "system" fn(u32,i32,*const u8)->isize);
|
||||
let mw = hook_iat!(b"kernel32.dll\0", b"OpenMutexW\0", set_real_mutex_w,
|
||||
unsafe extern "system" fn(u32, i32, *const u8) -> isize
|
||||
);
|
||||
let mw = hook_iat!(
|
||||
b"kernel32.dll\0",
|
||||
b"OpenMutexW\0",
|
||||
set_real_mutex_w,
|
||||
origin_spy::hooked_open_mutex_w,
|
||||
unsafe extern "system" fn(u32,i32,*const u16)->isize);
|
||||
write_log(&format!("origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n"));
|
||||
unsafe extern "system" fn(u32, i32, *const u16) -> isize
|
||||
);
|
||||
write_log(&format!(
|
||||
"origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n"
|
||||
));
|
||||
|
||||
let cv = iat::resolve(b"crypt32.dll\0", b"CertVerifyCertificateChainPolicy\0");
|
||||
if !cv.is_null() {
|
||||
let f: unsafe extern "system" fn(*const u8,*const(),*const(),*mut u32)->BOOL
|
||||
= std::mem::transmute(cv);
|
||||
let f: unsafe extern "system" fn(*const u8, *const (), *const (), *mut u32) -> BOOL =
|
||||
std::mem::transmute(cv);
|
||||
tls_bypass::set_real(f);
|
||||
iat::patch_iat(cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
|
||||
iat::patch_iat_in(b"EAWebKit.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
|
||||
iat::patch_iat_in(b"winhttp.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
|
||||
iat::patch_iat_in(b"wininet.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
|
||||
iat::patch_iat_in(
|
||||
b"EAWebKit.dll\0",
|
||||
cv,
|
||||
tls_bypass::hooked_cert_verify_chain_policy as *const (),
|
||||
);
|
||||
iat::patch_iat_in(
|
||||
b"winhttp.dll\0",
|
||||
cv,
|
||||
tls_bypass::hooked_cert_verify_chain_policy as *const (),
|
||||
);
|
||||
iat::patch_iat_in(
|
||||
b"wininet.dll\0",
|
||||
cv,
|
||||
tls_bypass::hooked_cert_verify_chain_policy as *const (),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,28 +27,49 @@ static REAL_REG_W: OnceLock<RegQueryValueExWFn> = OnceLock::new();
|
||||
static REAL_MUTEX_A: OnceLock<OpenMutexAFn> = OnceLock::new();
|
||||
static REAL_MUTEX_W: OnceLock<OpenMutexWFn> = OnceLock::new();
|
||||
|
||||
pub fn set_real_reg_a(f: RegQueryValueExAFn) { let _ = REAL_REG_A.set(f); }
|
||||
pub fn set_real_reg_w(f: RegQueryValueExWFn) { let _ = REAL_REG_W.set(f); }
|
||||
pub fn set_real_mutex_a(f: OpenMutexAFn) { let _ = REAL_MUTEX_A.set(f); }
|
||||
pub fn set_real_mutex_w(f: OpenMutexWFn) { let _ = REAL_MUTEX_W.set(f); }
|
||||
pub fn set_real_reg_a(f: RegQueryValueExAFn) {
|
||||
let _ = REAL_REG_A.set(f);
|
||||
}
|
||||
pub fn set_real_reg_w(f: RegQueryValueExWFn) {
|
||||
let _ = REAL_REG_W.set(f);
|
||||
}
|
||||
pub fn set_real_mutex_a(f: OpenMutexAFn) {
|
||||
let _ = REAL_MUTEX_A.set(f);
|
||||
}
|
||||
pub fn set_real_mutex_w(f: OpenMutexWFn) {
|
||||
let _ = REAL_MUTEX_W.set(f);
|
||||
}
|
||||
|
||||
fn narrow_to_string(p: *const u8) -> String {
|
||||
if p.is_null() { return "(null)".into(); }
|
||||
if p.is_null() {
|
||||
return "(null)".into();
|
||||
}
|
||||
let bytes = unsafe { std::ffi::CStr::from_ptr(p as *const i8) };
|
||||
bytes.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn wide_to_string(p: *const u16) -> String {
|
||||
if p.is_null() { return "(null)".into(); }
|
||||
if p.is_null() {
|
||||
return "(null)".into();
|
||||
}
|
||||
let mut len = 0usize;
|
||||
unsafe { while *p.add(len) != 0 { len += 1; } }
|
||||
unsafe {
|
||||
while *p.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) })
|
||||
}
|
||||
|
||||
fn is_interesting(name: &str) -> bool {
|
||||
name.contains("LSX") || name.contains("Origin") || name.contains("EAL") ||
|
||||
name.contains("Client") || name.contains("lsx") || name.contains("Port") ||
|
||||
name.contains("EA") || name.contains("Connection")
|
||||
name.contains("LSX")
|
||||
|| name.contains("Origin")
|
||||
|| name.contains("EAL")
|
||||
|| name.contains("Client")
|
||||
|| name.contains("lsx")
|
||||
|| name.contains("Port")
|
||||
|| name.contains("EA")
|
||||
|| name.contains("Connection")
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_reg_query_a(
|
||||
@@ -93,8 +114,10 @@ pub unsafe extern "system" fn hooked_open_mutex_a(
|
||||
let name = narrow_to_string(lpmutexname);
|
||||
let real = REAL_MUTEX_A.get().copied().unwrap();
|
||||
let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
|
||||
crate::write_log(&format!("origin_spy: OpenMutexA({name}) → {}\n",
|
||||
if handle == 0 { "NOT_FOUND" } else { "FOUND" }));
|
||||
crate::write_log(&format!(
|
||||
"origin_spy: OpenMutexA({name}) → {}\n",
|
||||
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
|
||||
));
|
||||
handle
|
||||
}
|
||||
|
||||
@@ -106,7 +129,9 @@ pub unsafe extern "system" fn hooked_open_mutex_w(
|
||||
let name = wide_to_string(lpmutexname);
|
||||
let real = REAL_MUTEX_W.get().copied().unwrap();
|
||||
let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
|
||||
crate::write_log(&format!("origin_spy: OpenMutexW({name}) → {}\n",
|
||||
if handle == 0 { "NOT_FOUND" } else { "FOUND" }));
|
||||
crate::write_log(&format!(
|
||||
"origin_spy: OpenMutexW({name}) → {}\n",
|
||||
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
|
||||
));
|
||||
handle
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+191
-66
@@ -12,7 +12,8 @@ unsafe fn write_jmp(target: *mut u8, dest: u64) {
|
||||
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
target.write(0xFF); target.add(1).write(0x25);
|
||||
target.write(0xFF);
|
||||
target.add(1).write(0x25);
|
||||
(target.add(2) as *mut u32).write(0);
|
||||
(target.add(6) as *mut u64).write(dest);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
@@ -22,32 +23,48 @@ unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option<usize> {
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE,
|
||||
};
|
||||
// Log prologue so we can diagnose if trampolines misbehave
|
||||
let bytes: [u8; 14] = core::array::from_fn(|i| *orig.add(i));
|
||||
let hex: String = bytes.iter().map(|b| format!("{b:02x} ")).collect();
|
||||
// Read enough prologue to walk instruction boundaries.
|
||||
let probe: [u8; 24] = core::array::from_fn(|i| *orig.add(i));
|
||||
let hex: String = probe[..14].iter().map(|b| format!("{b:02x} ")).collect();
|
||||
crate::write_log(&format!("recv_hook: {name} prologue {hex}\n"));
|
||||
|
||||
// Walk instruction boundaries to find relative branches.
|
||||
// Byte-by-byte scanning mis-identifies immediate operands (e.g. `sub rsp, 0x70`)
|
||||
// as jump opcodes, so we must parse properly.
|
||||
if has_rip_relative_branch(&bytes) {
|
||||
crate::write_log(&format!("recv_hook: {name} has relative branch in prologue, skipping trampoline\n"));
|
||||
return None;
|
||||
// Copy WHOLE instructions until we've covered >= 14 bytes (the size of the JMP
|
||||
// patch), so the trampoline never splits an instruction. Copying a fixed 14
|
||||
// bytes lands mid-instruction on these prologues and crashes on execution.
|
||||
let mut copy_len = 0usize;
|
||||
while copy_len < 14 {
|
||||
let (len, branch) = decode_instr_len(&probe[copy_len..]);
|
||||
if len == 0 || branch {
|
||||
crate::write_log(&format!(
|
||||
"recv_hook: {name} unrelocatable prologue (len={len} branch={branch}), skipping\n"
|
||||
));
|
||||
return None;
|
||||
}
|
||||
copy_len += len;
|
||||
}
|
||||
|
||||
let mem = VirtualAlloc(
|
||||
core::ptr::null_mut(), 32,
|
||||
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE,
|
||||
core::ptr::null_mut(),
|
||||
64,
|
||||
MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
);
|
||||
if mem.is_null() { crate::write_log("recv_hook: VirtualAlloc failed\n"); return None; }
|
||||
if mem.is_null() {
|
||||
crate::write_log("recv_hook: VirtualAlloc failed\n");
|
||||
return None;
|
||||
}
|
||||
|
||||
let t = mem as *mut u8;
|
||||
core::ptr::copy_nonoverlapping(orig, t, 14);
|
||||
// JMP [RIP+0] → orig+14
|
||||
let cont = (orig as u64) + 14;
|
||||
t.add(14).write(0xFF); t.add(15).write(0x25);
|
||||
(t.add(16) as *mut u32).write(0);
|
||||
(t.add(20) as *mut u64).write(cont);
|
||||
core::ptr::copy_nonoverlapping(orig, t, copy_len);
|
||||
// JMP [RIP+0] → orig+copy_len (resume at the next whole instruction)
|
||||
let cont = (orig as u64) + copy_len as u64;
|
||||
t.add(copy_len).write(0xFF);
|
||||
t.add(copy_len + 1).write(0x25);
|
||||
(t.add(copy_len + 2) as *mut u32).write(0);
|
||||
(t.add(copy_len + 6) as *mut u64).write(cont);
|
||||
crate::write_log(&format!(
|
||||
"recv_hook: {name} trampoline copy_len={copy_len}\n"
|
||||
));
|
||||
Some(t as usize)
|
||||
}
|
||||
|
||||
@@ -58,8 +75,12 @@ fn has_rip_relative_branch(bytes: &[u8]) -> bool {
|
||||
let mut pos = 0;
|
||||
while pos < bytes.len() {
|
||||
let (len, branch) = decode_instr_len(&bytes[pos..]);
|
||||
if branch { return true; }
|
||||
if len == 0 { break; } // unknown/truncated — stop safely
|
||||
if branch {
|
||||
return true;
|
||||
}
|
||||
if len == 0 {
|
||||
break;
|
||||
} // unknown/truncated — stop safely
|
||||
pos += len;
|
||||
}
|
||||
false
|
||||
@@ -69,9 +90,29 @@ fn modrm_extra(modrm: u8) -> usize {
|
||||
let md = (modrm >> 6) & 3;
|
||||
let rm = modrm & 7;
|
||||
match md {
|
||||
0 => if rm == 5 { 4 } else if rm == 4 { 1 } else { 0 },
|
||||
1 => if rm == 4 { 2 } else { 1 },
|
||||
2 => if rm == 4 { 5 } else { 4 },
|
||||
0 => {
|
||||
if rm == 5 {
|
||||
4
|
||||
} else if rm == 4 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
if rm == 4 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if rm == 4 {
|
||||
5
|
||||
} else {
|
||||
4
|
||||
}
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -79,16 +120,31 @@ fn modrm_extra(modrm: u8) -> usize {
|
||||
/// Returns (instruction_length_in_bytes, is_rip_relative_branch).
|
||||
/// Returns (0, false) for unknown/truncated.
|
||||
fn decode_instr_len(b: &[u8]) -> (usize, bool) {
|
||||
if b.is_empty() { return (0, false); }
|
||||
if b.is_empty() {
|
||||
return (0, false);
|
||||
}
|
||||
let mut i = 0;
|
||||
// Legacy prefixes
|
||||
while let Some(&p) = b.get(i) {
|
||||
if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) { i += 1; } else { break; }
|
||||
if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) {
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// REX prefix (40–4F)
|
||||
if b.get(i).copied().map(|x| (0x40..=0x4F).contains(&x)).unwrap_or(false) { i += 1; }
|
||||
if b.get(i)
|
||||
.copied()
|
||||
.map(|x| (0x40..=0x4F).contains(&x))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let op = match b.get(i) { Some(&x) => x, None => return (0, false) };
|
||||
let op = match b.get(i) {
|
||||
Some(&x) => x,
|
||||
None => return (0, false),
|
||||
};
|
||||
i += 1;
|
||||
|
||||
match op {
|
||||
@@ -103,28 +159,45 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) {
|
||||
0xE9 | 0xE8 => (i + 4, true),
|
||||
// 0F prefix
|
||||
0x0F => {
|
||||
let op2 = match b.get(i) { Some(&x) => x, None => return (0, false) };
|
||||
let op2 = match b.get(i) {
|
||||
Some(&x) => x,
|
||||
None => return (0, false),
|
||||
};
|
||||
i += 1;
|
||||
if (0x80..=0x8F).contains(&op2) { return (i + 4, true); } // Jcc rel32
|
||||
// Most 0F XX: ModRM
|
||||
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
|
||||
if (0x80..=0x8F).contains(&op2) {
|
||||
return (i + 4, true);
|
||||
} // Jcc rel32
|
||||
// Most 0F XX: ModRM
|
||||
let modrm = match b.get(i) {
|
||||
Some(&x) => x,
|
||||
None => return (0, false),
|
||||
};
|
||||
(i + 1 + modrm_extra(modrm), false)
|
||||
}
|
||||
// Instructions with ModRM only (no immediate)
|
||||
0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F |
|
||||
0x01 | 0x03 | 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B |
|
||||
0x31 | 0x33 | 0x39 | 0x3B | 0xD3 | 0xFF | 0xF7 => {
|
||||
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
|
||||
0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | 0x01 | 0x03
|
||||
| 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | 0x31 | 0x33 | 0x39 | 0x3B
|
||||
| 0xD3 | 0xFF | 0xF7 => {
|
||||
let modrm = match b.get(i) {
|
||||
Some(&x) => x,
|
||||
None => return (0, false),
|
||||
};
|
||||
(i + 1 + modrm_extra(modrm), false)
|
||||
}
|
||||
// ModRM + imm8
|
||||
0x6B | 0x80 | 0x83 | 0xC0 | 0xC1 | 0xC6 => {
|
||||
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
|
||||
let modrm = match b.get(i) {
|
||||
Some(&x) => x,
|
||||
None => return (0, false),
|
||||
};
|
||||
(i + 1 + modrm_extra(modrm) + 1, false)
|
||||
}
|
||||
// ModRM + imm32
|
||||
0x69 | 0x81 | 0xC7 => {
|
||||
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
|
||||
let modrm = match b.get(i) {
|
||||
Some(&x) => x,
|
||||
None => return (0, false),
|
||||
};
|
||||
(i + 1 + modrm_extra(modrm) + 4, false)
|
||||
}
|
||||
// MOV reg, imm8/imm32
|
||||
@@ -143,7 +216,9 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) {
|
||||
unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> {
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
|
||||
let h = GetModuleHandleA(dll.as_ptr());
|
||||
if h.is_null() { return None; }
|
||||
if h.is_null() {
|
||||
return None;
|
||||
}
|
||||
GetProcAddress(h, sym.as_ptr()).map(|f| f as *mut u8)
|
||||
}
|
||||
|
||||
@@ -151,46 +226,96 @@ unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> {
|
||||
|
||||
static RECV_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
|
||||
if crate::lsx::is_lsx(s) {
|
||||
return crate::lsx::on_recv(s, buf, len);
|
||||
/// True if socket `s` is connected to the EA App LSX port (127.0.0.1:3216).
|
||||
/// Used in capture mode to tap only the LSX conversation.
|
||||
unsafe fn peer_is_lsx(s: usize) -> bool {
|
||||
use windows_sys::Win32::Networking::WinSock::getpeername;
|
||||
let mut sa = [0u8; 16];
|
||||
let mut sl: i32 = 16;
|
||||
if getpeername(s, sa.as_mut_ptr() as *mut _, &mut sl) != 0 {
|
||||
return false;
|
||||
}
|
||||
let t = RECV_TRAMPOLINE.load(Ordering::Relaxed);
|
||||
if t == 0 { return -1; }
|
||||
let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t);
|
||||
f(s, buf, len, flags)
|
||||
// sockaddr_in: sa_family (2 bytes) then sin_port (2 bytes, network order).
|
||||
u16::from_be_bytes([sa[2], sa[3]]) == 3216
|
||||
}
|
||||
|
||||
// IAT-hook approach (no inline trampoline — FIFA's `recv`/`send` prologues have
|
||||
// instructions that straddle the 14-byte patch boundary, so an inline trampoline
|
||||
// corrupts them and crashes. IAT hooking only swaps import-table pointers and
|
||||
// never touches the function body). The real fns are resolved in lib.rs and set
|
||||
// here; our hooks call them directly.
|
||||
static REAL_RECV: AtomicUsize = AtomicUsize::new(0);
|
||||
static REAL_SEND: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub fn set_real_recv(f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32) {
|
||||
REAL_RECV.store(f as usize, Ordering::Relaxed);
|
||||
}
|
||||
pub fn set_real_send(f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32) {
|
||||
REAL_SEND.store(f as usize, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Inline-hook ws2_32!recv: build a boundary-safe trampoline (the "real" fn our
|
||||
/// hook calls) and overwrite the entry with a JMP to `hooked_recv`. Inline hooks
|
||||
/// catch calls from every module and dynamically-resolved calls, unlike IAT.
|
||||
pub unsafe fn install_recv_hook() -> bool {
|
||||
let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") { Some(p) => p, None => return false };
|
||||
let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
};
|
||||
match make_trampoline(ptr, "recv") {
|
||||
Some(t) => { RECV_TRAMPOLINE.store(t, Ordering::Relaxed); }
|
||||
None => { crate::write_log("recv_hook: recv trampoline failed, hook skipped\n"); return false; }
|
||||
Some(t) => REAL_RECV.store(t, Ordering::Relaxed),
|
||||
None => return false,
|
||||
}
|
||||
write_jmp(ptr, hooked_recv as u64);
|
||||
true
|
||||
}
|
||||
|
||||
// ─── send ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
static SEND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
|
||||
if crate::lsx::is_lsx(s) {
|
||||
return crate::lsx::on_send(s, buf, len);
|
||||
}
|
||||
let t = SEND_TRAMPOLINE.load(Ordering::Relaxed);
|
||||
if t == 0 { return -1; }
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t);
|
||||
f(s, buf, len, flags)
|
||||
}
|
||||
|
||||
pub unsafe fn install_send_hook() -> bool {
|
||||
let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") { Some(p) => p, None => return false };
|
||||
let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
};
|
||||
match make_trampoline(ptr, "send") {
|
||||
Some(t) => { SEND_TRAMPOLINE.store(t, Ordering::Relaxed); }
|
||||
None => { crate::write_log("recv_hook: send trampoline failed, hook skipped\n"); return false; }
|
||||
Some(t) => REAL_SEND.store(t, Ordering::Relaxed),
|
||||
None => return false,
|
||||
}
|
||||
write_jmp(ptr, hooked_send as u64);
|
||||
true
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
|
||||
let t = REAL_RECV.load(Ordering::Relaxed);
|
||||
if t == 0 {
|
||||
return -1;
|
||||
}
|
||||
let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t);
|
||||
// Pass through to anadius's real socket, then log what it sent back
|
||||
// (anadius's LSX response — the ground truth we want to diff against).
|
||||
let n = f(s, buf, len, flags);
|
||||
if n > 0 && peer_is_lsx(s) {
|
||||
let data = core::slice::from_raw_parts(buf, n as usize);
|
||||
let text = core::str::from_utf8(data).unwrap_or("(binary)");
|
||||
crate::write_log(&format!(
|
||||
"CAP recv<-anadius s={s} n={n}: {}\n",
|
||||
&text[..text.len().min(2400)]
|
||||
));
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
|
||||
if len > 0 && peer_is_lsx(s) {
|
||||
let data = core::slice::from_raw_parts(buf, len as usize);
|
||||
let text = core::str::from_utf8(data).unwrap_or("(binary)");
|
||||
crate::write_log(&format!(
|
||||
"CAP send->anadius s={s} len={len}: {}\n",
|
||||
&text[..text.len().min(2400)]
|
||||
));
|
||||
}
|
||||
let t = REAL_SEND.load(Ordering::Relaxed);
|
||||
if t == 0 {
|
||||
return -1;
|
||||
}
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t);
|
||||
f(s, buf, len, flags)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
//! FIFA 17 SBC render intervention (feature = "fifa17").
|
||||
//!
|
||||
//! Makes the FUT **SBC menu render real data** from inside the process. Full spec
|
||||
//! (all addresses, RVA math, call order, crash risks, staged test plan):
|
||||
//! fifa17-recon/docs/sbc-hook-dll-spec.md
|
||||
//!
|
||||
//! Everything here is **inert by default** and gated by env vars:
|
||||
//! 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_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
|
||||
//! defer off the loader lock and poll for it — the same shape as
|
||||
//! `probe::install_probes_deferred` polling for anadius64.dll.
|
||||
//!
|
||||
//! ── Address model (static VAs; PE image base 0x180000000) ────────────────────────
|
||||
//! All values below are RVAs (VA_static - 0x180000000); live = cards_base + rva.
|
||||
//! See the spec for the verified disassembly behind each one.
|
||||
|
||||
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE,
|
||||
PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY,
|
||||
};
|
||||
|
||||
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
|
||||
const IMAGE_BASE: usize = 0x180000000;
|
||||
|
||||
/// FNV prologue used as the slide-proof control (must match the on-disk PE bytes).
|
||||
const CTRL_RVA: usize = 0x180d00; // VA 0x180180d00
|
||||
const CTRL_BYTES: &[u8] = &[
|
||||
0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0,
|
||||
];
|
||||
|
||||
const A_SLOT_RVA: usize = 0x2e6398; // *(0x1802e6398) = A (FUT root singleton)
|
||||
const A_VTABLE_RVA: usize = 0x21c2a0;
|
||||
const B_OFF: usize = 0x1f9d8; // B = A + 0x1f9d8 (SBC request/ready TTL cache)
|
||||
const B_VTABLE_RVA: usize = 0x1fae70;
|
||||
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 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 B_DTOR_RVA: usize = 0x63040;
|
||||
const B_ISVALID_RVA: usize = 0x65d40;
|
||||
const B_CLEAR_RVA: usize = 0x65d20;
|
||||
const B_READY_EXPECTED_BEFORE_ARM: u8 = 0;
|
||||
|
||||
#[allow(dead_code)]
|
||||
const AVT_M_GETTER: usize = 0x9b0; // A.vtable[+0x9b0] = 0x18011b7d0 (M lazy getter)
|
||||
#[allow(dead_code)]
|
||||
const AVT_B_GETTER: usize = 0x4e8; // A.vtable[+0x4e8] = 0x18011c1f0 (B getter thunk)
|
||||
|
||||
// Callable RVAs (for the Tier-1 populate sequence — see spec §6/§8). Kept for
|
||||
// reference/wiring; not invoked while Tier-1 is blocked.
|
||||
#[allow(dead_code)]
|
||||
mod rva {
|
||||
pub const M_LAZY_GETTER: usize = 0x11b7d0;
|
||||
pub const ISVALID: usize = 0x65d40;
|
||||
pub const DESER_SBS_SETS: usize = 0x17b2b0;
|
||||
pub const SAX_CTX_INIT: usize = 0x1c63e0;
|
||||
pub const REGISTRY_GETTER: usize = 0xd7170;
|
||||
pub const MANAGER_GETTER: usize = 0x9c80;
|
||||
pub const CLEAR_M: usize = 0x15f3a0;
|
||||
pub const CAT_CTOR: usize = 0x159da0;
|
||||
pub const CAT_DESER: usize = 0x17ab80;
|
||||
pub const CAT_FINALIZE: usize = 0x160e50;
|
||||
pub const APPEND: usize = 0x15a770;
|
||||
pub const CAT_DTOR: usize = 0x1105d0;
|
||||
pub const IDX_REBUILD_1: usize = 0x160e00;
|
||||
pub const IDX_REBUILD_2: usize = 0x160f30;
|
||||
pub const IDX_REBUILD_3: usize = 0x161020;
|
||||
pub const REFRESH_DISPATCH: usize = 0x1a4a70; // Scaleform events 0x756c-0x7574
|
||||
}
|
||||
|
||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
||||
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
|
||||
static POPULATE: AtomicBool = AtomicBool::new(false);
|
||||
static DONE: AtomicBool = AtomicBool::new(false);
|
||||
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[repr(usize)]
|
||||
enum RuntimeState {
|
||||
Disabled,
|
||||
Resolved,
|
||||
Intercepted,
|
||||
Parsed,
|
||||
Validated,
|
||||
Committed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
fn valid_transition(from: RuntimeState, to: RuntimeState) -> bool {
|
||||
matches!(
|
||||
(from, to),
|
||||
(RuntimeState::Disabled, RuntimeState::Resolved)
|
||||
| (RuntimeState::Resolved, RuntimeState::Intercepted)
|
||||
| (RuntimeState::Intercepted, RuntimeState::Parsed)
|
||||
| (RuntimeState::Parsed, RuntimeState::Validated)
|
||||
// Resolve-only/Tier-0 validates without installing an interceptor.
|
||||
| (RuntimeState::Resolved, RuntimeState::Validated)
|
||||
| (RuntimeState::Validated, RuntimeState::Committed)
|
||||
| (_, RuntimeState::Failed)
|
||||
)
|
||||
}
|
||||
|
||||
fn transition(from: RuntimeState, to: RuntimeState) -> bool {
|
||||
valid_transition(from, to)
|
||||
&& STATE
|
||||
.compare_exchange(
|
||||
from as usize,
|
||||
to as usize,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ValidationError {
|
||||
AddressOverflow,
|
||||
AUnreadable,
|
||||
AVtableMismatch,
|
||||
AGetterMismatch,
|
||||
BVtableMismatch,
|
||||
BDtorMismatch,
|
||||
BIsValidMismatch,
|
||||
BClearMismatch,
|
||||
MSlotUnreadable,
|
||||
ReadyByteUnexpected,
|
||||
CollectionUnreadable,
|
||||
CollectionNotNull,
|
||||
ReadyByteNotWritable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct RuntimeSnapshot {
|
||||
a: usize,
|
||||
a_vtable: usize,
|
||||
a_b_getter: usize,
|
||||
b: usize,
|
||||
b_vtable: usize,
|
||||
b_dtor: usize,
|
||||
b_isvalid: usize,
|
||||
b_clear: usize,
|
||||
b_ready: u8,
|
||||
b_coll: usize,
|
||||
m: usize,
|
||||
}
|
||||
|
||||
fn expected_va(base: usize, rva: usize) -> Result<usize, ValidationError> {
|
||||
base.checked_add(rva)
|
||||
.ok_or(ValidationError::AddressOverflow)
|
||||
}
|
||||
|
||||
fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationError> {
|
||||
if s.a == 0
|
||||
|| s.b
|
||||
!= s.a
|
||||
.checked_add(B_OFF)
|
||||
.ok_or(ValidationError::AddressOverflow)?
|
||||
{
|
||||
return Err(ValidationError::AUnreadable);
|
||||
}
|
||||
if s.a_vtable != expected_va(base, A_VTABLE_RVA)? {
|
||||
return Err(ValidationError::AVtableMismatch);
|
||||
}
|
||||
if s.a_b_getter != expected_va(base, 0x11c1f0)? {
|
||||
return Err(ValidationError::AGetterMismatch);
|
||||
}
|
||||
if s.b_vtable != expected_va(base, B_VTABLE_RVA)? {
|
||||
return Err(ValidationError::BVtableMismatch);
|
||||
}
|
||||
if s.b_dtor != expected_va(base, B_DTOR_RVA)? {
|
||||
return Err(ValidationError::BDtorMismatch);
|
||||
}
|
||||
if s.b_isvalid != expected_va(base, B_ISVALID_RVA)? {
|
||||
return Err(ValidationError::BIsValidMismatch);
|
||||
}
|
||||
if s.b_clear != expected_va(base, B_CLEAR_RVA)? {
|
||||
return Err(ValidationError::BClearMismatch);
|
||||
}
|
||||
if s.b_ready != B_READY_EXPECTED_BEFORE_ARM {
|
||||
return Err(ValidationError::ReadyByteUnexpected);
|
||||
}
|
||||
if s.b_coll != 0 {
|
||||
return Err(ValidationError::CollectionNotNull);
|
||||
}
|
||||
let _ = s.m; // The guarded snapshot read proves the M slot itself is readable.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fault-safe pointer read (mirrors `probe::read_ptr`): returns None unless `ptr` lands
|
||||
/// in a committed, readable page and the full 8 bytes fit inside the region.
|
||||
unsafe fn read_ptr(ptr: usize) -> Option<usize> {
|
||||
if ptr < 0x10000 || ptr & 7 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT {
|
||||
return None;
|
||||
}
|
||||
if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
|
||||
return None;
|
||||
}
|
||||
if ptr + 8 > mbi.BaseAddress as usize + mbi.RegionSize {
|
||||
return None;
|
||||
}
|
||||
Some(core::ptr::read_volatile(ptr as *const usize))
|
||||
}
|
||||
|
||||
/// Guarded byte read.
|
||||
unsafe fn read_u8(ptr: usize) -> Option<u8> {
|
||||
if ptr < 0x10000 {
|
||||
return None;
|
||||
}
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
|
||||
return None;
|
||||
}
|
||||
if ptr + 1 > mbi.BaseAddress as usize + mbi.RegionSize {
|
||||
return None;
|
||||
}
|
||||
Some(core::ptr::read_volatile(ptr as *const u8))
|
||||
}
|
||||
|
||||
/// A Tier-0 write is allowed only when the complete byte lies in a committed,
|
||||
/// non-guarded region whose current protection explicitly permits writes.
|
||||
unsafe fn writable_u8(ptr: usize) -> bool {
|
||||
if ptr < 0x10000 {
|
||||
return false;
|
||||
}
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
|
||||
return false;
|
||||
}
|
||||
let protection = mbi.Protect & 0xff;
|
||||
let writable = matches!(
|
||||
protection,
|
||||
PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
|
||||
);
|
||||
writable
|
||||
&& ptr
|
||||
.checked_add(1)
|
||||
.is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize))
|
||||
}
|
||||
|
||||
unsafe fn executable_range(ptr: usize, len: usize) -> bool {
|
||||
let Some(end) = ptr.checked_add(len) else {
|
||||
return false;
|
||||
};
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
|
||||
return false;
|
||||
}
|
||||
let protection = mbi.Protect & 0xff;
|
||||
matches!(
|
||||
protection,
|
||||
PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
|
||||
) && end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)
|
||||
}
|
||||
|
||||
/// Guarded 16-bit read (M category count is a WORD).
|
||||
unsafe fn read_u16(ptr: usize) -> Option<u16> {
|
||||
let lo = read_u8(ptr)? as u16;
|
||||
let hi = read_u8(ptr + 1)? as u16;
|
||||
Some(lo | (hi << 8))
|
||||
}
|
||||
|
||||
/// Resolve CardsDLL's runtime base, or 0. Tries the exact loaded name; the ToolHelp
|
||||
/// fallback (name-contains "CardsDLL") lives in the spec — add it if EA ever renames.
|
||||
unsafe fn resolve_cards_base() -> usize {
|
||||
let h = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast());
|
||||
if !h.is_null() {
|
||||
return h as usize;
|
||||
}
|
||||
// Also try the short form some tooling reports.
|
||||
let h2 = GetModuleHandleA(c"CardsDLL.dll".as_ptr().cast());
|
||||
if !h2.is_null() {
|
||||
return h2 as usize;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn va(base: usize, rva: usize) -> usize {
|
||||
base + rva
|
||||
}
|
||||
|
||||
/// Prove the module didn't move: the FNV control prologue must match the on-disk PE.
|
||||
unsafe fn control_matches(base: usize) -> bool {
|
||||
let p = va(base, CTRL_RVA);
|
||||
for (i, &want) in CTRL_BYTES.iter().enumerate() {
|
||||
match read_u8(p + i) {
|
||||
Some(got) if got == want => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Take one guarded identity snapshot. A failure to read any identity-bearing field is
|
||||
/// distinct from a value mismatch and aborts before mutation.
|
||||
unsafe fn runtime_snapshot(base: usize) -> Result<RuntimeSnapshot, ValidationError> {
|
||||
let a_slot = expected_va(base, A_SLOT_RVA)?;
|
||||
let a = read_ptr(a_slot)
|
||||
.filter(|&value| value != 0)
|
||||
.ok_or(ValidationError::AUnreadable)?;
|
||||
let a_vtable = read_ptr(a).ok_or(ValidationError::AVtableMismatch)?;
|
||||
let a_b_getter = read_ptr(
|
||||
a_vtable
|
||||
.checked_add(AVT_B_GETTER)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::AGetterMismatch)?;
|
||||
let b = a
|
||||
.checked_add(B_OFF)
|
||||
.ok_or(ValidationError::AddressOverflow)?;
|
||||
let b_vtable = read_ptr(b).ok_or(ValidationError::BVtableMismatch)?;
|
||||
let b_dtor = read_ptr(b_vtable).ok_or(ValidationError::BDtorMismatch)?;
|
||||
let b_isvalid = read_ptr(
|
||||
b_vtable
|
||||
.checked_add(8)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::BIsValidMismatch)?;
|
||||
let b_clear = read_ptr(
|
||||
b_vtable
|
||||
.checked_add(16)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::BClearMismatch)?;
|
||||
let b_ready = read_u8(
|
||||
b.checked_add(B_READY_OFF)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::ReadyByteUnexpected)?;
|
||||
let b_coll = read_ptr(
|
||||
b.checked_add(B_COLL_OFF)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::CollectionUnreadable)?;
|
||||
let m = read_ptr(
|
||||
a.checked_add(M_CACHE_OFF)
|
||||
.ok_or(ValidationError::AddressOverflow)?,
|
||||
)
|
||||
.ok_or(ValidationError::MSlotUnreadable)?;
|
||||
Ok(RuntimeSnapshot {
|
||||
a,
|
||||
a_vtable,
|
||||
a_b_getter,
|
||||
b,
|
||||
b_vtable,
|
||||
b_dtor,
|
||||
b_isvalid,
|
||||
b_clear,
|
||||
b_ready,
|
||||
b_coll,
|
||||
m,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_failed(error: ValidationError) {
|
||||
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: runtime validation FAILED: {error:?} -- no write\n"
|
||||
));
|
||||
}
|
||||
|
||||
/// Public entry: called from `fifa17::install`. Spawns the deferred worker if
|
||||
/// OPENFUT_SBC_HOOK=1; otherwise logs "disabled" and returns (fully inert).
|
||||
pub fn install() {
|
||||
let armed = std::env::var("OPENFUT_SBC_HOOK")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false);
|
||||
ARMED.store(armed, Ordering::Relaxed);
|
||||
if !armed {
|
||||
STATE.store(RuntimeState::Disabled as usize, Ordering::Relaxed);
|
||||
crate::write_log("SBC_HOOK: disabled (set OPENFUT_SBC_HOOK=1 to enable)\n");
|
||||
return;
|
||||
}
|
||||
ARM_ONLY.store(
|
||||
std::env::var("OPENFUT_SBC_ARM_ONLY")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
POPULATE.store(
|
||||
std::env::var("OPENFUT_SBC_POPULATE")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
crate::write_log("SBC_HOOK: ARMED (deferred worker spawning)\n");
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// exactly once.
|
||||
unsafe fn worker() {
|
||||
let mut base = 0usize;
|
||||
for _ in 0..600u32 {
|
||||
base = resolve_cards_base();
|
||||
if base != 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
if base == 0 {
|
||||
crate::write_log("SBC_HOOK: CardsDLL_Win64_retail.dll never loaded — giving up\n");
|
||||
return;
|
||||
}
|
||||
CARDS_BASE.store(base, Ordering::Relaxed);
|
||||
let slide = base.wrapping_sub(IMAGE_BASE);
|
||||
let ctrl_ok = control_matches(base);
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: CardsDLL base={base:#x} slide={slide:#x} CONTROL={}\n",
|
||||
if ctrl_ok { "OK" } else { "MISMATCH-ABORT" }
|
||||
));
|
||||
if !ctrl_ok {
|
||||
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
|
||||
return; // module map moved -> offsets untrustworthy (spec §1)
|
||||
}
|
||||
|
||||
if !transition(RuntimeState::Disabled, RuntimeState::Resolved) {
|
||||
crate::write_log("SBC_HOOK: invalid state transition to Resolved -- no write\n");
|
||||
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve and validate A -> B, M. The vtable method checks make it substantially
|
||||
// harder for a coincidental heap pointer to pass after a binary/layout mismatch.
|
||||
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
|
||||
validate_snapshot(base, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !transition(RuntimeState::Resolved, RuntimeState::Validated) {
|
||||
crate::write_log("SBC_HOOK: invalid state transition to Validated -- no write\n");
|
||||
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
let a = snapshot.a;
|
||||
let b = snapshot.b;
|
||||
let m = snapshot.m;
|
||||
let m_count = (m != 0).then(|| read_u16(m + M_COUNT_OFF)).flatten();
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: A={a:#x} B={b:#x} B+0x28(ready)={:?} B+0x08(coll)={:?} M=*(A+0x20a68)={:?} WORD[M+0x50]={:?}\n",
|
||||
Some(snapshot.b_ready), opt_hex(Some(snapshot.b_coll)), opt_hex(Some(m)), m_count,
|
||||
));
|
||||
|
||||
// Tier-0 — arm-only negative control. Write ONLY BYTE[B+0x28]=1; leave B+0x08=0 so
|
||||
// isValid takes the short-circuit (spec §4). Renders the menu EMPTY (M null/empty) —
|
||||
// this is the baseline, NOT the fix. One-shot.
|
||||
if ARM_ONLY.load(Ordering::Relaxed) {
|
||||
if DONE.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Re-snapshot immediately before mutation to reduce the time-of-check/time-of-use
|
||||
// window. In particular, the exact patch byte must still be 0 and B+0x08 null.
|
||||
let write_snapshot = match runtime_snapshot(base).and_then(|snapshot| {
|
||||
validate_snapshot(base, &snapshot)?;
|
||||
if !writable_u8(snapshot.b + B_READY_OFF) {
|
||||
return Err(ValidationError::ReadyByteNotWritable);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: Tier-0 arm-only -> writing BYTE[{:#x}]=1 (expect EMPTY render, no modal)\n",
|
||||
write_snapshot.b + B_READY_OFF
|
||||
));
|
||||
core::ptr::write_volatile((write_snapshot.b + B_READY_OFF) as *mut u8, 1u8);
|
||||
match read_u8(write_snapshot.b + B_READY_OFF) {
|
||||
Some(1) if transition(RuntimeState::Validated, RuntimeState::Committed) => {}
|
||||
_ => {
|
||||
set_failed(ValidationError::ReadyByteUnexpected);
|
||||
return;
|
||||
}
|
||||
}
|
||||
crate::write_log(
|
||||
"SBC_HOOK: Tier-0 arm-only DONE (open the SBC menu; ~2 placeholder tiles expected)\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy Tier-1 gate — deliberately blocked. The fresh live exchange proves FIFA
|
||||
// already owns a real response and SAX reader for /sbs/sets. The next milestone is
|
||||
// passive tracing of the native response-to-deserializer dispatch, not construction
|
||||
// of a reader. Cold-calling with a fabricated reader would CLEAR M and/or segfault.
|
||||
if POPULATE.load(Ordering::Relaxed) {
|
||||
crate::write_log(
|
||||
"SBC_HOOK: legacy Tier-1 populate is BLOCKED — capture the genuine response \
|
||||
and reader at the native dispatch boundary first (see client-hook plan M3/M4). \
|
||||
No deser call made; fabricated readers can clear M or crash.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_hex(o: Option<usize>) -> String {
|
||||
match o {
|
||||
Some(v) => format!("{v:#x}"),
|
||||
None => "<unreadable>".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy Tier-1 scaffold. **Never call this with a fabricated reader.** The intended
|
||||
/// implementation is now a guarded synchronous dispatch repair that borrows the genuine
|
||||
/// response and reader from the real HTTP transaction on its native thread.
|
||||
///
|
||||
/// Sequence once `reader` (a primed SAX reader over canned sbs/sets JSON) exists:
|
||||
/// let base = CARDS_BASE.load(Relaxed);
|
||||
/// let deser: unsafe extern "system" fn(*mut u8, *mut u8) -> bool =
|
||||
/// transmute(va(base, rva::DESER_SBS_SETS));
|
||||
/// deser(core::ptr::null_mut(), reader); // self-locates mgr, clears+appends+finalizes+commits M
|
||||
/// // then Tier-0 arm: BYTE[B+0x28]=1, leave B+0x08=0
|
||||
/// // then refresh so 0x1800b5eda re-reads WORD[M+0x50]
|
||||
#[allow(dead_code)]
|
||||
unsafe fn populate_m(_reader: *mut u8) {
|
||||
// Intentionally unimplemented: the passive trace must prove the response/reader
|
||||
// ownership and exact virtual-dispatch boundary before any parser call is enabled.
|
||||
unreachable!(
|
||||
"populate_m requires a proven native dispatch contract; see client-hook plan M3/M4"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn valid_snapshot(base: usize) -> RuntimeSnapshot {
|
||||
let a = 0x1000_0000usize;
|
||||
RuntimeSnapshot {
|
||||
a,
|
||||
a_vtable: base + A_VTABLE_RVA,
|
||||
a_b_getter: base + 0x11c1f0,
|
||||
b: a + B_OFF,
|
||||
b_vtable: base + B_VTABLE_RVA,
|
||||
b_dtor: base + B_DTOR_RVA,
|
||||
b_isvalid: base + B_ISVALID_RVA,
|
||||
b_clear: base + B_CLEAR_RVA,
|
||||
b_ready: B_READY_EXPECTED_BEFORE_ARM,
|
||||
b_coll: 0,
|
||||
m: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_exact_runtime_identity_with_null_uninitialized_m() {
|
||||
let base = 0x7fff_0000_0000usize;
|
||||
assert_eq!(validate_snapshot(base, &valid_snapshot(base)), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_a_or_b_class_identity() {
|
||||
let base = 0x7fff_0000_0000usize;
|
||||
let mut snapshot = valid_snapshot(base);
|
||||
snapshot.a_vtable += 8;
|
||||
assert_eq!(
|
||||
validate_snapshot(base, &snapshot),
|
||||
Err(ValidationError::AVtableMismatch)
|
||||
);
|
||||
|
||||
let mut snapshot = valid_snapshot(base);
|
||||
snapshot.b_vtable += 8;
|
||||
assert_eq!(
|
||||
validate_snapshot(base, &snapshot),
|
||||
Err(ValidationError::BVtableMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_changed_patch_byte_or_live_collection() {
|
||||
let base = 0x7fff_0000_0000usize;
|
||||
let mut snapshot = valid_snapshot(base);
|
||||
snapshot.b_ready = 1;
|
||||
assert_eq!(
|
||||
validate_snapshot(base, &snapshot),
|
||||
Err(ValidationError::ReadyByteUnexpected)
|
||||
);
|
||||
|
||||
let mut snapshot = valid_snapshot(base);
|
||||
snapshot.b_coll = 0x1234_0000;
|
||||
assert_eq!(
|
||||
validate_snapshot(base, &snapshot),
|
||||
Err(ValidationError::CollectionNotNull)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine_is_forward_only_and_fail_closed() {
|
||||
assert!(valid_transition(
|
||||
RuntimeState::Disabled,
|
||||
RuntimeState::Resolved
|
||||
));
|
||||
assert!(valid_transition(
|
||||
RuntimeState::Resolved,
|
||||
RuntimeState::Validated
|
||||
));
|
||||
assert!(valid_transition(
|
||||
RuntimeState::Validated,
|
||||
RuntimeState::Committed
|
||||
));
|
||||
assert!(valid_transition(RuntimeState::Parsed, RuntimeState::Failed));
|
||||
assert!(!valid_transition(
|
||||
RuntimeState::Validated,
|
||||
RuntimeState::Resolved
|
||||
));
|
||||
assert!(!valid_transition(
|
||||
RuntimeState::Failed,
|
||||
RuntimeState::Resolved
|
||||
));
|
||||
assert!(!valid_transition(
|
||||
RuntimeState::Resolved,
|
||||
RuntimeState::Committed
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
//! Optional category-request callback tracing through its class-unique vtable.
|
||||
//!
|
||||
//! Unlike the entry trampolines, these probes atomically replace two aligned
|
||||
//! pointer slots. The branchy callback dispatcher at 0x180154830 is never patched.
|
||||
|
||||
use core::ffi::c_void;
|
||||
use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
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::{
|
||||
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_GUARD, PAGE_NOACCESS,
|
||||
PAGE_READWRITE,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
|
||||
|
||||
const REQUEST_VTABLE_RVA: usize = 0x22e5c0;
|
||||
const SLOT_88: usize = 0x88;
|
||||
const SLOT_90: usize = 0x90;
|
||||
const ORIGINAL_88_RVA: usize = 0x1631e0;
|
||||
const ORIGINAL_90_RVA: usize = 0x154830;
|
||||
const ORIGINAL_88_SIGNATURE: [u8; 16] = [
|
||||
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48,
|
||||
];
|
||||
const ORIGINAL_90_SIGNATURE: [u8; 16] = [
|
||||
0x4c, 0x8b, 0x81, 0x90, 0x00, 0x00, 0x00, 0x4d, 0x85, 0xc0, 0x74, 0x0a, 0x48, 0x81, 0xc1, 0x90,
|
||||
];
|
||||
|
||||
type Callback88 = unsafe extern "system" fn(*mut c_void, *mut c_void);
|
||||
type Callback90 = unsafe extern "system" fn(*mut c_void, *mut c_void);
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static INSTALLED: AtomicBool = AtomicBool::new(false);
|
||||
static ORIGINAL_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static ORIGINAL_90: AtomicUsize = AtomicUsize::new(0);
|
||||
static ENTER_88: AtomicU64 = AtomicU64::new(0);
|
||||
static EXIT_88: AtomicU64 = AtomicU64::new(0);
|
||||
static ENTER_90: AtomicU64 = AtomicU64::new(0);
|
||||
static EXIT_90: AtomicU64 = AtomicU64::new(0);
|
||||
static LAST_REQUEST_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static LAST_ARGUMENT_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static LAST_THREAD_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static LAST_REQUEST_90: AtomicUsize = AtomicUsize::new(0);
|
||||
static LAST_ARGUMENT_90: AtomicUsize = AtomicUsize::new(0);
|
||||
static LAST_THREAD_90: AtomicUsize = AtomicUsize::new(0);
|
||||
static CALLBACK_90: AtomicUsize = AtomicUsize::new(0);
|
||||
static CALLBACK_98: AtomicUsize = AtomicUsize::new(0);
|
||||
static CALLBACK_A0: AtomicUsize = AtomicUsize::new(0);
|
||||
static CALLBACK_A8: AtomicUsize = AtomicUsize::new(0);
|
||||
static SELECTED_90: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_VTABLE_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static CONSUMER_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static RESPONSE_VTABLE_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_SLOT_BEFORE_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_SLOT_AFTER_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_INNER_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_STATE_BEFORE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static OWNER_STATE_AFTER_88: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static OWNER_FLAGS_88: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static OWNER_MANAGER_88: AtomicUsize = AtomicUsize::new(0);
|
||||
static OWNER_MANAGER_STATE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
|
||||
fn enabled(value: Option<&str>) -> bool {
|
||||
matches!(value, Some("1"))
|
||||
}
|
||||
|
||||
fn checked_va(base: usize, rva: usize) -> Option<usize> {
|
||||
base.checked_add(rva)
|
||||
}
|
||||
|
||||
fn image_range_covered(size: usize, rva: usize, length: usize) -> bool {
|
||||
rva.checked_add(length)
|
||||
.map(|end| end <= size)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
unsafe fn readable_range(address: usize, length: usize) -> bool {
|
||||
let Some(end) = address.checked_add(length) else {
|
||||
return false;
|
||||
};
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
VirtualQuery(
|
||||
address as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
) != 0
|
||||
&& mbi.State == MEM_COMMIT
|
||||
&& mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0
|
||||
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
||||
}
|
||||
|
||||
unsafe fn range_in_image_allocation(base: usize, address: usize, length: usize) -> bool {
|
||||
let Some(end) = address.checked_add(length) else {
|
||||
return false;
|
||||
};
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
VirtualQuery(
|
||||
address as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
) != 0
|
||||
&& mbi.AllocationBase as usize == base
|
||||
&& mbi.State == MEM_COMMIT
|
||||
&& mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0
|
||||
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
||||
}
|
||||
|
||||
unsafe fn image_size(base: usize) -> Option<usize> {
|
||||
if !readable_range(base, 0x1000) || *(base as *const u16) != 0x5a4d {
|
||||
return None;
|
||||
}
|
||||
let pe_offset = *((base + 0x3c) as *const u32) as usize;
|
||||
if pe_offset > 0xf00 {
|
||||
return None;
|
||||
}
|
||||
let pe = base.checked_add(pe_offset)?;
|
||||
if *(pe as *const u32) != 0x0000_4550 {
|
||||
return None;
|
||||
}
|
||||
let size_field = pe.checked_add(24 + 0x38)?;
|
||||
Some(*(size_field as *const u32) as usize)
|
||||
}
|
||||
|
||||
unsafe fn signature_matches(address: usize, expected: &[u8]) -> bool {
|
||||
readable_range(address, expected.len())
|
||||
&& core::slice::from_raw_parts(address as *const u8, expected.len()) == expected
|
||||
}
|
||||
|
||||
unsafe fn guarded_ptr(address: usize) -> usize {
|
||||
if address & 7 == 0 && readable_range(address, 8) {
|
||||
core::ptr::read_volatile(address as *const usize)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn guarded_u32(address: usize) -> Option<u32> {
|
||||
if readable_range(address, 4) {
|
||||
Some(core::ptr::read_volatile(address as *const u32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn guarded_u8(address: usize) -> Option<u8> {
|
||||
if readable_range(address, 1) {
|
||||
Some(core::ptr::read_volatile(address as *const u8))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn field_ptr(object: usize, offset: usize) -> usize {
|
||||
object
|
||||
.checked_add(offset)
|
||||
.map(|address| guarded_ptr(address))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
unsafe extern "system" fn wrapper_88(request: *mut c_void, argument: *mut c_void) {
|
||||
ENTER_88.fetch_add(1, Ordering::Relaxed);
|
||||
LAST_REQUEST_88.store(request as usize, Ordering::Relaxed);
|
||||
LAST_ARGUMENT_88.store(argument as usize, Ordering::Relaxed);
|
||||
LAST_THREAD_88.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
|
||||
let request_address = request as usize;
|
||||
let argument_address = argument as usize;
|
||||
let owner = field_ptr(request_address, 8);
|
||||
let owner_vtable = guarded_ptr(owner);
|
||||
let consumer = field_ptr(owner_vtable, 0x18);
|
||||
let owner_slot_before = guarded_ptr(argument_address);
|
||||
let response_vtable = guarded_ptr(owner_slot_before);
|
||||
let owner_inner = field_ptr(owner, 8);
|
||||
let owner_state_before = owner_inner
|
||||
.checked_add(8)
|
||||
.and_then(|p| guarded_u32(p))
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(usize::MAX);
|
||||
let owner_flags = owner_inner
|
||||
.checked_add(0x0c)
|
||||
.and_then(|p| guarded_u8(p))
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(usize::MAX);
|
||||
let owner_manager = field_ptr(owner_inner, 0x14d0);
|
||||
let owner_manager_state = owner_manager
|
||||
.checked_add(0x1dc0)
|
||||
.and_then(|p| guarded_u32(p))
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(usize::MAX);
|
||||
OWNER_88.store(owner, Ordering::Relaxed);
|
||||
OWNER_VTABLE_88.store(owner_vtable, Ordering::Relaxed);
|
||||
CONSUMER_88.store(consumer, Ordering::Relaxed);
|
||||
RESPONSE_VTABLE_88.store(response_vtable, Ordering::Relaxed);
|
||||
OWNER_SLOT_BEFORE_88.store(owner_slot_before, Ordering::Relaxed);
|
||||
OWNER_INNER_88.store(owner_inner, Ordering::Relaxed);
|
||||
OWNER_STATE_BEFORE_88.store(owner_state_before, Ordering::Relaxed);
|
||||
OWNER_FLAGS_88.store(owner_flags, Ordering::Relaxed);
|
||||
OWNER_MANAGER_88.store(owner_manager, Ordering::Relaxed);
|
||||
OWNER_MANAGER_STATE_88.store(owner_manager_state, Ordering::Relaxed);
|
||||
let original: Callback88 = core::mem::transmute(ORIGINAL_88.load(Ordering::Acquire));
|
||||
original(request, argument);
|
||||
OWNER_SLOT_AFTER_88.store(guarded_ptr(argument_address), Ordering::Relaxed);
|
||||
OWNER_STATE_AFTER_88.store(
|
||||
owner_inner
|
||||
.checked_add(8)
|
||||
.and_then(|p| guarded_u32(p))
|
||||
.map(|v| v as usize)
|
||||
.unwrap_or(usize::MAX),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
EXIT_88.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
|
||||
unsafe extern "system" fn wrapper_90(request: *mut c_void, argument: *mut c_void) {
|
||||
ENTER_90.fetch_add(1, Ordering::Relaxed);
|
||||
LAST_REQUEST_90.store(request as usize, Ordering::Relaxed);
|
||||
LAST_ARGUMENT_90.store(argument as usize, Ordering::Relaxed);
|
||||
LAST_THREAD_90.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
|
||||
let request_address = request as usize;
|
||||
let callback_90 = field_ptr(request_address, 0x90);
|
||||
let callback_98 = field_ptr(request_address, 0x98);
|
||||
let callback_a0 = field_ptr(request_address, 0xa0);
|
||||
let callback_a8 = field_ptr(request_address, 0xa8);
|
||||
CALLBACK_90.store(callback_90, Ordering::Relaxed);
|
||||
CALLBACK_98.store(callback_98, Ordering::Relaxed);
|
||||
CALLBACK_A0.store(callback_a0, Ordering::Relaxed);
|
||||
CALLBACK_A8.store(callback_a8, Ordering::Relaxed);
|
||||
SELECTED_90.store(
|
||||
if callback_90 != 0 {
|
||||
callback_90
|
||||
} else {
|
||||
callback_a0
|
||||
},
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
let original: Callback90 = core::mem::transmute(ORIGINAL_90.load(Ordering::Acquire));
|
||||
original(request, argument);
|
||||
EXIT_90.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum SwapOutcome {
|
||||
Installed,
|
||||
CleanFailure,
|
||||
DegradedFirstSlotActive,
|
||||
DegradedProtection,
|
||||
}
|
||||
|
||||
unsafe fn install_slots(base: usize) -> SwapOutcome {
|
||||
let Some(vtable) = checked_va(base, REQUEST_VTABLE_RVA) else {
|
||||
return SwapOutcome::CleanFailure;
|
||||
};
|
||||
let Some(original_88) = checked_va(base, ORIGINAL_88_RVA) else {
|
||||
return SwapOutcome::CleanFailure;
|
||||
};
|
||||
let Some(original_90) = checked_va(base, ORIGINAL_90_RVA) else {
|
||||
return SwapOutcome::CleanFailure;
|
||||
};
|
||||
let Some(slot_88) = checked_va(vtable, SLOT_88) else {
|
||||
return SwapOutcome::CleanFailure;
|
||||
};
|
||||
let Some(slot_90) = checked_va(vtable, SLOT_90) else {
|
||||
return SwapOutcome::CleanFailure;
|
||||
};
|
||||
let Some(size) = image_size(base) else {
|
||||
return SwapOutcome::CleanFailure;
|
||||
};
|
||||
if !image_range_covered(size, REQUEST_VTABLE_RVA, SLOT_90 + 8)
|
||||
|| !image_range_covered(size, ORIGINAL_88_RVA, ORIGINAL_88_SIGNATURE.len())
|
||||
|| !image_range_covered(size, ORIGINAL_90_RVA, ORIGINAL_90_SIGNATURE.len())
|
||||
|| slot_88 & 7 != 0
|
||||
|| slot_90 & 7 != 0
|
||||
|| !crate::sbc_trace::validate_cards_build(base)
|
||||
|| !range_in_image_allocation(base, vtable, SLOT_90 + 8)
|
||||
|| !range_in_image_allocation(base, original_88, ORIGINAL_88_SIGNATURE.len())
|
||||
|| !range_in_image_allocation(base, original_90, ORIGINAL_90_SIGNATURE.len())
|
||||
|| !signature_matches(original_88, &ORIGINAL_88_SIGNATURE)
|
||||
|| !signature_matches(original_90, &ORIGINAL_90_SIGNATURE)
|
||||
|| (slot_88 as *const AtomicUsize)
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.load(Ordering::Acquire)
|
||||
!= original_88
|
||||
|| (slot_90 as *const AtomicUsize)
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.load(Ordering::Acquire)
|
||||
!= original_90
|
||||
{
|
||||
return SwapOutcome::CleanFailure;
|
||||
}
|
||||
|
||||
let mut pinned = core::ptr::null_mut();
|
||||
if GetModuleHandleExA(
|
||||
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
|
||||
vtable as *const u8,
|
||||
&mut pinned,
|
||||
) == 0
|
||||
|| pinned as usize != base
|
||||
{
|
||||
return SwapOutcome::CleanFailure;
|
||||
}
|
||||
ORIGINAL_88.store(original_88, Ordering::Release);
|
||||
ORIGINAL_90.store(original_90, Ordering::Release);
|
||||
|
||||
// Both slots share the same vtable page. Keep it writable only across the two
|
||||
// compare/exchanges and possible rollback.
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(slot_88 as _, 16, PAGE_READWRITE, &mut old) == 0 {
|
||||
return SwapOutcome::CleanFailure;
|
||||
}
|
||||
let atom_88 = &*(slot_88 as *const AtomicUsize);
|
||||
let atom_90 = &*(slot_90 as *const AtomicUsize);
|
||||
let first = atom_88.compare_exchange(
|
||||
original_88,
|
||||
wrapper_88 as *const () as usize,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
);
|
||||
let outcome = if first.is_err() {
|
||||
SwapOutcome::CleanFailure
|
||||
} else if atom_90
|
||||
.compare_exchange(
|
||||
original_90,
|
||||
wrapper_90 as *const () as usize,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
SwapOutcome::Installed
|
||||
} else if atom_88
|
||||
.compare_exchange(
|
||||
wrapper_88 as *const () as usize,
|
||||
original_88,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
SwapOutcome::CleanFailure
|
||||
} else {
|
||||
SwapOutcome::DegradedFirstSlotActive
|
||||
};
|
||||
let mut ignored = 0u32;
|
||||
if VirtualProtect(slot_88 as _, 16, old, &mut ignored) == 0 {
|
||||
return if outcome == SwapOutcome::DegradedFirstSlotActive {
|
||||
outcome
|
||||
} else {
|
||||
SwapOutcome::DegradedProtection
|
||||
};
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
unsafe fn worker() {
|
||||
for _ in 0..700u32 {
|
||||
if crate::sbc_trace::code_patch_installers_ready() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
if !crate::sbc_trace::code_patch_installers_ready() {
|
||||
crate::write_log("SBC_REQUEST_TRACE: code-patch readiness timeout; inactive\n");
|
||||
return;
|
||||
}
|
||||
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));
|
||||
}
|
||||
match if base == 0 { SwapOutcome::CleanFailure } else { install_slots(base) } {
|
||||
SwapOutcome::Installed => {
|
||||
INSTALLED.store(true, Ordering::Release);
|
||||
crate::write_log("SBC_REQUEST_TRACE: category request vtable slots +0x88/+0x90 installed\n");
|
||||
let mut seen_88 = 0u64;
|
||||
let mut seen_90 = 0u64;
|
||||
let mut reports = 0u8;
|
||||
while reports < 32 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
let count_88 = ENTER_88.load(Ordering::Acquire);
|
||||
let count_90 = ENTER_90.load(Ordering::Acquire);
|
||||
if count_88 != seen_88 || count_90 != seen_90 {
|
||||
crate::write_log(&format!(
|
||||
"SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x} inner={:#x} state={}->{} flags={:#x} manager={:#x} manager_state={}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n",
|
||||
count_88,
|
||||
EXIT_88.load(Ordering::Acquire),
|
||||
LAST_REQUEST_88.load(Ordering::Relaxed),
|
||||
LAST_ARGUMENT_88.load(Ordering::Relaxed),
|
||||
LAST_THREAD_88.load(Ordering::Relaxed),
|
||||
OWNER_88.load(Ordering::Relaxed),
|
||||
OWNER_VTABLE_88.load(Ordering::Relaxed),
|
||||
CONSUMER_88.load(Ordering::Relaxed),
|
||||
RESPONSE_VTABLE_88.load(Ordering::Relaxed),
|
||||
OWNER_SLOT_BEFORE_88.load(Ordering::Relaxed),
|
||||
OWNER_SLOT_AFTER_88.load(Ordering::Relaxed),
|
||||
OWNER_INNER_88.load(Ordering::Relaxed),
|
||||
OWNER_STATE_BEFORE_88.load(Ordering::Relaxed),
|
||||
OWNER_STATE_AFTER_88.load(Ordering::Relaxed),
|
||||
OWNER_FLAGS_88.load(Ordering::Relaxed),
|
||||
OWNER_MANAGER_88.load(Ordering::Relaxed),
|
||||
OWNER_MANAGER_STATE_88.load(Ordering::Relaxed),
|
||||
count_90,
|
||||
EXIT_90.load(Ordering::Acquire),
|
||||
LAST_REQUEST_90.load(Ordering::Relaxed),
|
||||
LAST_ARGUMENT_90.load(Ordering::Relaxed),
|
||||
LAST_THREAD_90.load(Ordering::Relaxed),
|
||||
CALLBACK_90.load(Ordering::Relaxed),
|
||||
CALLBACK_98.load(Ordering::Relaxed),
|
||||
CALLBACK_A0.load(Ordering::Relaxed),
|
||||
CALLBACK_A8.load(Ordering::Relaxed),
|
||||
SELECTED_90.load(Ordering::Relaxed),
|
||||
));
|
||||
seen_88 = count_88;
|
||||
seen_90 = count_90;
|
||||
reports += 1;
|
||||
}
|
||||
}
|
||||
crate::write_log("SBC_REQUEST_TRACE: report cap reached; vtable probes remain passive\n");
|
||||
}
|
||||
SwapOutcome::CleanFailure => crate::write_log("SBC_REQUEST_TRACE: clean install failure; inactive\n"),
|
||||
SwapOutcome::DegradedFirstSlotActive => crate::write_log(
|
||||
"SBC_REQUEST_TRACE: DEGRADED slot +0x88 may remain active; terminate game now\n",
|
||||
),
|
||||
SwapOutcome::DegradedProtection => crate::write_log(
|
||||
"SBC_REQUEST_TRACE: DEGRADED vtable page protection restore failed; terminate game now\n",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn install() {
|
||||
let armed = enabled(std::env::var("OPENFUT_SBC_REQUEST_TRACE").ok().as_deref());
|
||||
ENABLED.store(armed, Ordering::Release);
|
||||
if !armed {
|
||||
crate::write_log("SBC_REQUEST_TRACE: disabled\n");
|
||||
return;
|
||||
}
|
||||
crate::write_log("SBC_REQUEST_TRACE: requested; deferred install starting\n");
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gate_is_exact() {
|
||||
assert!(!enabled(None));
|
||||
assert!(!enabled(Some("true")));
|
||||
assert!(enabled(Some("1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_are_aligned_and_class_local() {
|
||||
assert_eq!((REQUEST_VTABLE_RVA + SLOT_88) & 7, 0);
|
||||
assert_eq!((REQUEST_VTABLE_RVA + SLOT_90) & 7, 0);
|
||||
assert_eq!(SLOT_90 - SLOT_88, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_coverage_is_checked_and_overflow_safe() {
|
||||
assert!(image_range_covered(
|
||||
0x230000,
|
||||
REQUEST_VTABLE_RVA,
|
||||
SLOT_90 + 8
|
||||
));
|
||||
assert!(!image_range_covered(
|
||||
REQUEST_VTABLE_RVA + SLOT_90,
|
||||
REQUEST_VTABLE_RVA,
|
||||
SLOT_90 + 8
|
||||
));
|
||||
assert!(!image_range_covered(usize::MAX, usize::MAX, 8));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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() });
|
||||
}
|
||||
@@ -9,11 +9,9 @@
|
||||
// server's certificate chain. Always returning 1 is equivalent to trusting all certs,
|
||||
// which is the behaviour we want for the local self-signed bridge certificate.
|
||||
|
||||
use windows_sys::Win32::{
|
||||
System::{
|
||||
LibraryLoader::GetModuleHandleA,
|
||||
Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE},
|
||||
},
|
||||
use windows_sys::Win32::System::{
|
||||
LibraryLoader::GetModuleHandleA,
|
||||
Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE},
|
||||
};
|
||||
|
||||
// Unique 22-byte prologue of ProtoSSL's cert-verify function.
|
||||
@@ -21,24 +19,26 @@ use windows_sys::Win32::{
|
||||
const PROLOGUE: &[u8] = &[
|
||||
0x44, 0x89, 0x44, 0x24, 0x18, // mov [rsp+0x18], r8d
|
||||
0x48, 0x89, 0x54, 0x24, 0x10, // mov [rsp+0x10], rdx
|
||||
0x56, // push rsi
|
||||
0x57, // push rdi
|
||||
0x41, 0x55, // push r13
|
||||
0x41, 0x56, // push r14
|
||||
0x41, 0x57, // push r15
|
||||
0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30
|
||||
0x56, // push rsi
|
||||
0x57, // push rdi
|
||||
0x41, 0x55, // push r13
|
||||
0x41, 0x56, // push r14
|
||||
0x41, 0x57, // push r15
|
||||
0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30
|
||||
];
|
||||
|
||||
// Return 0 (PROTOSSL_ERROR_NONE = success). ProtoSSL convention: 0 = ok, negative = error.
|
||||
// The function sets r15d = 0xFFFFFFFF (-1) for its own error returns, confirming 0 = success.
|
||||
const PATCH: &[u8] = &[
|
||||
0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE)
|
||||
0xc3, // ret
|
||||
0x90, 0x90, 0x90, // nop padding
|
||||
0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE)
|
||||
0xc3, // ret
|
||||
0x90, 0x90, 0x90, // nop padding
|
||||
];
|
||||
|
||||
fn patch_module(module: isize, scan_bytes: usize) -> bool {
|
||||
if module == 0 { return false; }
|
||||
if module == 0 {
|
||||
return false;
|
||||
}
|
||||
let base = module as usize;
|
||||
let image: &[u8] = unsafe { core::slice::from_raw_parts(base as *const u8, scan_bytes) };
|
||||
let offset = match image.windows(PROLOGUE.len()).position(|w| w == PROLOGUE) {
|
||||
@@ -48,16 +48,26 @@ fn patch_module(module: isize, scan_bytes: usize) -> bool {
|
||||
let target = (base + offset) as *mut u8;
|
||||
let mut old_prot: u32 = 0;
|
||||
unsafe {
|
||||
VirtualProtect(target as *const core::ffi::c_void, PATCH.len(), PAGE_EXECUTE_READWRITE, &mut old_prot);
|
||||
VirtualProtect(
|
||||
target as *const core::ffi::c_void,
|
||||
PATCH.len(),
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old_prot,
|
||||
);
|
||||
core::ptr::copy_nonoverlapping(PATCH.as_ptr(), target, PATCH.len());
|
||||
VirtualProtect(target as *const core::ffi::c_void, PATCH.len(), old_prot, &mut old_prot);
|
||||
VirtualProtect(
|
||||
target as *const core::ffi::c_void,
|
||||
PATCH.len(),
|
||||
old_prot,
|
||||
&mut old_prot,
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Patch ProtoSSL cert-verify in EAWebKit.dll (call when EAWebKit is loaded).
|
||||
pub unsafe fn patch_eawebkit_cert_verify() -> bool {
|
||||
let module = GetModuleHandleA(b"EAWebKit.dll\0".as_ptr()) as isize;
|
||||
let module = GetModuleHandleA(c"EAWebKit.dll".as_ptr().cast()) as isize;
|
||||
// EAWebKit.dll is ~22 MB
|
||||
patch_module(module, 24 * 1024 * 1024)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ use windows_sys::Win32::Foundation::BOOL;
|
||||
// CERT_CHAIN_POLICY_STATUS.dwError offset 0 = u32 error code; 0 = success.
|
||||
// We use raw pointers to avoid pulling in the full Cryptography struct tree.
|
||||
type CertVerifyChainPolicyFn = unsafe extern "system" fn(
|
||||
*const u8, // pszPolicyOID
|
||||
*const (), // pChainContext
|
||||
*const (), // pPolicyPara
|
||||
*mut u32, // &mut pPolicyStatus.dwError (first field)
|
||||
*const u8, // pszPolicyOID
|
||||
*const (), // pChainContext
|
||||
*const (), // pPolicyPara
|
||||
*mut u32, // &mut pPolicyStatus.dwError (first field)
|
||||
) -> BOOL;
|
||||
|
||||
static REAL: OnceLock<CertVerifyChainPolicyFn> = OnceLock::new();
|
||||
@@ -25,7 +25,12 @@ pub unsafe extern "system" fn hooked_cert_verify_chain_policy(
|
||||
p_policy_status: *mut u32,
|
||||
) -> BOOL {
|
||||
if let Some(real) = REAL.get().copied() {
|
||||
real(psz_policy_oid, p_chain_context, p_policy_para, p_policy_status);
|
||||
real(
|
||||
psz_policy_oid,
|
||||
p_chain_context,
|
||||
p_policy_para,
|
||||
p_policy_status,
|
||||
);
|
||||
}
|
||||
// Clear the error field of CERT_CHAIN_POLICY_STATUS regardless
|
||||
if !p_policy_status.is_null() {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Milestone 0 — Blaze transport reachability observation.
|
||||
//!
|
||||
//! PURE LOGGING, NO NEW DETOURS. This module does not hook anything itself. It is
|
||||
//! called from the three Winsock detours the hook ALREADY installs — getaddrinfo
|
||||
//! (`hooks.rs`), connect/WSAConnect (`connect_hook.rs`) and ConnectEx
|
||||
//! (`connectex_hook.rs`) — and, when armed, emits a single grep-friendly
|
||||
//! `TRANSPORT_WATCH:` line per resolution/connect so we can answer one question:
|
||||
//!
|
||||
//! Does the FIFA 23 client attempt ANY Blaze-flavored transport activity across a
|
||||
//! full menu+FUT session, or none at all?
|
||||
//!
|
||||
//! Everything here is READ-ONLY: we parse the hostname / sockaddr the game passed
|
||||
//! only to describe it in the log. We never change a resolution result or a
|
||||
//! connection target — that redirect logic lives in the detours themselves and is
|
||||
//! untouched. The env kill switch `OPENFUT_TRANSPORT_WATCH=1` gates all output;
|
||||
//! disarmed (default) this module is inert (each entry point returns immediately).
|
||||
//!
|
||||
//! Future-reference note (beyond-beginner, deliberately NOT done here): a
|
||||
//! types-first design would model a `ConnectTarget` enum (Inet{ip,port} / NonInet /
|
||||
//! Short) and a `TransportEvent` and route them through the `tracing` crate with
|
||||
//! structured fields, instead of hand-formatting strings into a flat log file. That
|
||||
//! buys machine-parseable logs and log levels. For a one-shot observation gate,
|
||||
//! flat `write_log` lines that `grep` cleanly are the lower-ceremony choice.
|
||||
|
||||
use core::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Armed once at DLL load from `OPENFUT_TRANSPORT_WATCH`. `AtomicBool` (not a plain
|
||||
/// `static mut bool`) because the detours that read it run on arbitrary game threads;
|
||||
/// an atomic gives race-free reads with no `unsafe`. `Relaxed` is enough — this is a
|
||||
/// standalone flag with no ordering relationship to other memory.
|
||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Read the env var once, at DLL load, and log the arm state. Called from `DllMain`
|
||||
/// (`install_hooks`). Reading the env in-process (rather than as a command prefix) is
|
||||
/// what makes the switch actually propagate through the umu/Proton launch — the same
|
||||
/// gotcha the probe switches hit; it works because the launch script `export`s it.
|
||||
pub fn arm_from_env() {
|
||||
let on = std::env::var("OPENFUT_TRANSPORT_WATCH")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false);
|
||||
ARMED.store(on, Ordering::Relaxed);
|
||||
crate::write_log(&format!(
|
||||
"TRANSPORT_WATCH: {} (env OPENFUT_TRANSPORT_WATCH)\n",
|
||||
if on { "ARMED" } else { "disarmed" }
|
||||
));
|
||||
}
|
||||
|
||||
fn armed() -> bool {
|
||||
ARMED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// True if `host` looks like EA/Blaze infrastructure. Broad on purpose: this is a log
|
||||
/// classifier that makes a hit visually pop (`<-- BLAZE/EA-FLAVORED`), NOT a routing
|
||||
/// decision. The actual redirect decision stays in `hooks::is_ea_host`, which is
|
||||
/// deliberately narrower and unchanged.
|
||||
fn is_blaze_flavored(host: &str) -> bool {
|
||||
let h = host.to_ascii_lowercase();
|
||||
[
|
||||
"redirector",
|
||||
"gosredirector",
|
||||
"blaze",
|
||||
"gosca",
|
||||
"easfc",
|
||||
"utas",
|
||||
"fut",
|
||||
"ea.com",
|
||||
"easports",
|
||||
]
|
||||
.iter()
|
||||
.any(|k| h.contains(k))
|
||||
}
|
||||
|
||||
/// Log one getaddrinfo hostname. Self-gates on the arm flag, so the call site can be
|
||||
/// unconditional. The existing `openfut_hook: getaddrinfo(...)` line stays; this adds
|
||||
/// the tagged, classified line so `grep TRANSPORT_WATCH` sees the full resolution set
|
||||
/// and a Blaze host stands out.
|
||||
pub fn note_getaddrinfo(host: &str) {
|
||||
if !armed() {
|
||||
return;
|
||||
}
|
||||
let tag = if is_blaze_flavored(host) {
|
||||
" <-- BLAZE/EA-FLAVORED"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
crate::write_log(&format!(
|
||||
"TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n"
|
||||
));
|
||||
}
|
||||
|
||||
const AF_INET: u16 = 2; // IPv4
|
||||
const AF_INET6: u16 = 23; // IPv6 (Windows value; Linux uses 10 — we're in Wine/Win ABI)
|
||||
|
||||
/// Minimal view of a `sockaddr_in`; the first `u16` is the address family for ANY
|
||||
/// sockaddr, so reading this layout is safe enough to classify the family even when
|
||||
/// the real struct is a `sockaddr_un` or larger — we only trust the rest once we've
|
||||
/// confirmed `sin_family == AF_INET`.
|
||||
#[repr(C)]
|
||||
struct SockaddrIn {
|
||||
sin_family: u16,
|
||||
sin_port: u16,
|
||||
sin_addr: u32,
|
||||
sin_zero: [u8; 8],
|
||||
}
|
||||
|
||||
/// Minimal view of a `sockaddr_in6` (Win32 layout). `sin6_port` is network byte order;
|
||||
/// `sin6_addr` is the 16 raw address bytes in network order. We ignore flowinfo/scope.
|
||||
#[repr(C)]
|
||||
struct SockaddrIn6 {
|
||||
sin6_family: u16,
|
||||
sin6_port: u16,
|
||||
sin6_flowinfo: u32,
|
||||
sin6_addr: [u8; 16],
|
||||
sin6_scope_id: u32,
|
||||
}
|
||||
|
||||
/// Is `port` a known/suspected Blaze port? SHAPE — public general knowledge; the exact
|
||||
/// port for FIFA23's Blaze version is UNKNOWN. 42127 main, 10041/10744 redirector
|
||||
/// variants, 3659 classic redirector.
|
||||
fn is_blaze_port(port: u16) -> bool {
|
||||
matches!(port, 42127 | 10744 | 3659 | 10041)
|
||||
}
|
||||
|
||||
/// Log one outbound connect attempt. `api` names the call path (`connect` /
|
||||
/// `WSAConnect` / `ConnectEx`) so we can tell which Winsock entry the client used.
|
||||
///
|
||||
/// SAFETY: `name` must point to at least `namelen` readable bytes — it's the sockaddr
|
||||
/// the game just handed to a Winsock connect API, so that always holds at the call
|
||||
/// sites. We read it read-only and never write through it. `s` is the socket handle,
|
||||
/// used only to query `SO_TYPE` (TCP=1 / UDP=2) so a real Blaze TCP dial is
|
||||
/// distinguishable from UDP game/voice traffic.
|
||||
pub unsafe fn note_connect(api: &str, name: *const u8, namelen: i32, s: usize) {
|
||||
if !armed() {
|
||||
return;
|
||||
}
|
||||
if name.is_null() || namelen < 8 {
|
||||
crate::write_log(&format!(
|
||||
"TRANSPORT_WATCH: {api} (no/short sockaddr, namelen={namelen})\n"
|
||||
));
|
||||
return;
|
||||
}
|
||||
// SAFE: name is non-null and >= 8 bytes (checked above); the first u16 is the
|
||||
// address family for ANY sockaddr, so reading it is valid regardless of the real
|
||||
// struct type. We only trust family-specific fields after matching the family.
|
||||
let family = *(name as *const u16);
|
||||
|
||||
// SAFE: getsockopt is a read-only Winsock query on a valid socket handle; a bad
|
||||
// handle just leaves ty=-1, which we log verbatim. TCP=1 / UDP=2.
|
||||
let sock_type = {
|
||||
use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE};
|
||||
let mut ty: i32 = -1;
|
||||
let mut len: i32 = 4;
|
||||
getsockopt(
|
||||
s,
|
||||
SOL_SOCKET,
|
||||
SO_TYPE,
|
||||
&mut ty as *mut i32 as *mut u8,
|
||||
&mut len,
|
||||
);
|
||||
ty
|
||||
};
|
||||
|
||||
match family {
|
||||
AF_INET => {
|
||||
// SAFE: family is AF_INET and namelen >= 8 == sizeof(sockaddr_in) fields we read.
|
||||
let sa = &*(name as *const SockaddrIn);
|
||||
// sin_addr holds the address in NETWORK byte order; on little-endian x86,
|
||||
// to_le_bytes reproduces those 4 bytes in memory order, which IS the dotted
|
||||
// quad. So b[0].b[1].b[2].b[3] is correct. (The legacy connect_hook log line
|
||||
// prints these reversed — a cosmetic bug there; this M0 line is the correct
|
||||
// one to trust.)
|
||||
let b = sa.sin_addr.to_le_bytes();
|
||||
let port = u16::from_be(sa.sin_port);
|
||||
let is_loopback = b[0] == 127;
|
||||
let is_lsx = matches!(port, 3216 | 3217); // known-good LSX channel; not Blaze
|
||||
let mut tag = String::new();
|
||||
if is_blaze_port(port) {
|
||||
tag.push_str(" <-- BLAZE-PORT");
|
||||
}
|
||||
// A loopback connect on anything other than LSX is the situation-(a) signal.
|
||||
if is_loopback && !is_lsx {
|
||||
tag.push_str(" <-- LOOPBACK non-LSX");
|
||||
}
|
||||
crate::write_log(&format!(
|
||||
"TRANSPORT_WATCH: {api} target={}.{}.{}.{}:{port} sock_type={sock_type}{tag}\n",
|
||||
b[0], b[1], b[2], b[3]
|
||||
));
|
||||
}
|
||||
AF_INET6 => {
|
||||
if namelen < 28 {
|
||||
crate::write_log(&format!(
|
||||
"TRANSPORT_WATCH: {api} family=INET6 (short sockaddr, namelen={namelen})\n"
|
||||
));
|
||||
return;
|
||||
}
|
||||
// SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6).
|
||||
let sa = &*(name as *const SockaddrIn6);
|
||||
let a = sa.sin6_addr; // 16 bytes, network order
|
||||
let port = u16::from_be(sa.sin6_port);
|
||||
// Format as 8 colon-separated hex groups (not compressed — clarity over
|
||||
// brevity for a log meant to be grepped).
|
||||
let hex = (0..8)
|
||||
.map(|i| format!("{:02x}{:02x}", a[i * 2], a[i * 2 + 1]))
|
||||
.collect::<Vec<_>>()
|
||||
.join(":");
|
||||
// ::1 = loopback: first 15 bytes zero, last byte 1.
|
||||
let is_loopback = a[..15].iter().all(|&x| x == 0) && a[15] == 1;
|
||||
let mut tag = String::new();
|
||||
if is_blaze_port(port) {
|
||||
tag.push_str(" <-- BLAZE-PORT");
|
||||
}
|
||||
if is_loopback {
|
||||
tag.push_str(" <-- IPv6 LOOPBACK (::1)");
|
||||
}
|
||||
crate::write_log(&format!(
|
||||
"TRANSPORT_WATCH: {api} target=[{hex}]:{port} sock_type={sock_type} (IPv6){tag}\n"
|
||||
));
|
||||
}
|
||||
other => {
|
||||
// AF_UNIX=1 or anything else — where a named-pipe/unix-socket-style local
|
||||
// Blaze transport would surface.
|
||||
crate::write_log(&format!(
|
||||
"TRANSPORT_WATCH: {api} family={other} (non-INET — possible AF_UNIX/pipe-like)\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Transparent forwarding for the system `version.dll` API.
|
||||
//!
|
||||
//! The hook is deployed under the `version.dll` filename, so every VERSION API
|
||||
//! import must continue to behave exactly as it would without OpenFUT. Resolve
|
||||
//! the genuine system DLL once during process attach, then tail-jump from each
|
||||
//! exported stub. A tail jump preserves the caller's complete Windows x64 ABI
|
||||
//! state, including stack arguments whose signatures differ between exports.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
|
||||
|
||||
const EXPORT_COUNT: usize = 16;
|
||||
|
||||
/// Keep this list in the same order as the generated stubs below.
|
||||
const EXPORTS: [&[u8]; EXPORT_COUNT] = [
|
||||
b"GetFileVersionInfoA\0",
|
||||
b"GetFileVersionInfoExA\0",
|
||||
b"GetFileVersionInfoExW\0",
|
||||
b"GetFileVersionInfoSizeA\0",
|
||||
b"GetFileVersionInfoSizeExA\0",
|
||||
b"GetFileVersionInfoSizeExW\0",
|
||||
b"GetFileVersionInfoSizeW\0",
|
||||
b"GetFileVersionInfoW\0",
|
||||
b"VerFindFileA\0",
|
||||
b"VerFindFileW\0",
|
||||
b"VerInstallFileA\0",
|
||||
b"VerInstallFileW\0",
|
||||
b"VerLanguageNameA\0",
|
||||
b"VerLanguageNameW\0",
|
||||
b"VerQueryValueA\0",
|
||||
b"VerQueryValueW\0",
|
||||
];
|
||||
|
||||
/// Addresses in the genuine system DLL. Atomic storage gives the assembly
|
||||
/// stubs stable, directly addressable pointer-sized slots without `static mut`.
|
||||
static REAL: [AtomicUsize; EXPORT_COUNT] = [const { AtomicUsize::new(0) }; EXPORT_COUNT];
|
||||
|
||||
macro_rules! proxy_stub {
|
||||
($index:literal, $name:ident) => {
|
||||
#[unsafe(no_mangle)]
|
||||
#[unsafe(naked)]
|
||||
pub unsafe extern "system" fn $name() {
|
||||
core::arch::naked_asm!(
|
||||
"jmp qword ptr [rip + {base} + {offset}]",
|
||||
base = sym REAL,
|
||||
offset = const $index * size_of::<usize>(),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
proxy_stub!(0, GetFileVersionInfoA);
|
||||
proxy_stub!(1, GetFileVersionInfoExA);
|
||||
proxy_stub!(2, GetFileVersionInfoExW);
|
||||
proxy_stub!(3, GetFileVersionInfoSizeA);
|
||||
proxy_stub!(4, GetFileVersionInfoSizeExA);
|
||||
proxy_stub!(5, GetFileVersionInfoSizeExW);
|
||||
proxy_stub!(6, GetFileVersionInfoSizeW);
|
||||
proxy_stub!(7, GetFileVersionInfoW);
|
||||
proxy_stub!(8, VerFindFileA);
|
||||
proxy_stub!(9, VerFindFileW);
|
||||
proxy_stub!(10, VerInstallFileA);
|
||||
proxy_stub!(11, VerInstallFileW);
|
||||
proxy_stub!(12, VerLanguageNameA);
|
||||
proxy_stub!(13, VerLanguageNameW);
|
||||
proxy_stub!(14, VerQueryValueA);
|
||||
proxy_stub!(15, VerQueryValueW);
|
||||
|
||||
/// Resolve forwarding targets before returning from `DLL_PROCESS_ATTACH`.
|
||||
/// Calls into our exports may happen as soon as the loader releases its lock,
|
||||
/// so deferring this operation to the hook worker would create a race.
|
||||
pub(crate) unsafe fn resolve() -> bool {
|
||||
// Loading by absolute path prevents this proxy from recursively loading
|
||||
// itself. Proton/Wine exposes the Windows system directory at this path.
|
||||
let path: Vec<u16> = "C:\\Windows\\System32\\version.dll\0"
|
||||
.encode_utf16()
|
||||
.collect();
|
||||
let module = LoadLibraryW(path.as_ptr());
|
||||
if module.is_null() {
|
||||
crate::write_log("version_proxy: FATAL: system version.dll load failed\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut missing = 0;
|
||||
for (slot, name) in REAL.iter().zip(EXPORTS) {
|
||||
let address = GetProcAddress(module, name.as_ptr()).map_or(0, |proc| proc as usize);
|
||||
slot.store(address, Ordering::Release);
|
||||
if address == 0 {
|
||||
missing += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if missing == 0 {
|
||||
crate::write_log("version_proxy: forwarded all 16 exports\n");
|
||||
true
|
||||
} else {
|
||||
crate::write_log(&format!(
|
||||
"version_proxy: FATAL: {missing}/16 system exports missing\n"
|
||||
));
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn export_table_is_complete_and_nul_terminated() {
|
||||
assert_eq!(EXPORTS.len(), EXPORT_COUNT);
|
||||
assert!(EXPORTS.iter().all(|name| name.last() == Some(&0)));
|
||||
assert!(EXPORTS
|
||||
.iter()
|
||||
.all(|name| !name[..name.len() - 1].contains(&0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
LIBRARY version
|
||||
EXPORTS
|
||||
GetFileVersionInfoA
|
||||
GetFileVersionInfoExA
|
||||
GetFileVersionInfoExW
|
||||
GetFileVersionInfoSizeA
|
||||
GetFileVersionInfoSizeExA
|
||||
GetFileVersionInfoSizeExW
|
||||
GetFileVersionInfoSizeW
|
||||
GetFileVersionInfoW
|
||||
VerFindFileA
|
||||
VerFindFileW
|
||||
VerInstallFileA
|
||||
VerInstallFileW
|
||||
VerLanguageNameA
|
||||
VerLanguageNameW
|
||||
VerQueryValueA
|
||||
VerQueryValueW
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Read-only background polling of the OpenFUT account summary.
|
||||
//!
|
||||
//! The launcher already POSTs `/openfut/account/sync` once at launch time
|
||||
//! (see [`crate::account_sync::sync`]) to select the active profile. This
|
||||
//! module reuses that request in a background thread so the Dashboard can show
|
||||
//! a live "Your Club" card — coins, level, packs — without ever blocking the UI
|
||||
//! thread on the network. It mirrors [`crate::health::HealthMonitor`]: a shared
|
||||
//! target the UI re-points when the server config changes, and a shared state
|
||||
//! snapshot the UI renders each frame.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::account_sync::{self, AccountSummary};
|
||||
use crate::config::LauncherConfig;
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// A snapshot of the last account fetch, rendered by the dashboard.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AccountState {
|
||||
/// The most recently fetched summary, or None while none has succeeded.
|
||||
pub summary: Option<AccountSummary>,
|
||||
/// The error from the latest failed attempt (cleared on success).
|
||||
pub error: Option<String>,
|
||||
/// Whether a server target is currently configured. `false` = idle: the
|
||||
/// launcher has nothing to poll, so the UI shows the "connect" prompt.
|
||||
pub configured: bool,
|
||||
pub last_checked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl AccountState {
|
||||
/// True when the latest error looks like a connectivity failure (server
|
||||
/// down / unresolvable) rather than a protocol/validation error. Lets the
|
||||
/// UI show the calm "offline" prompt for the common "server not up" case
|
||||
/// and reserve the loud error state for genuinely broken responses.
|
||||
pub fn unreachable(&self) -> bool {
|
||||
self.error.as_deref().is_some_and(|e| {
|
||||
e.contains("cannot connect")
|
||||
|| e.contains("cannot resolve")
|
||||
|| e.contains("resolved to no addresses")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Background poller. Holds a shared target config the UI can update when the
|
||||
/// user changes the server address/account, and a shared state the UI reads.
|
||||
pub struct AccountMonitor {
|
||||
pub state: Arc<Mutex<AccountState>>,
|
||||
target: Arc<Mutex<Option<LauncherConfig>>>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AccountMonitor {
|
||||
pub fn new() -> Self {
|
||||
let state = Arc::new(Mutex::new(AccountState::default()));
|
||||
let target: Arc<Mutex<Option<LauncherConfig>>> = Arc::new(Mutex::new(None));
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let t_state = Arc::clone(&state);
|
||||
let t_target = Arc::clone(&target);
|
||||
let t_running = Arc::clone(&running);
|
||||
thread::spawn(move || {
|
||||
while t_running.load(Ordering::Relaxed) {
|
||||
let target = t_target.lock().clone();
|
||||
match target {
|
||||
None => {
|
||||
// No server configured — reset to the idle prompt state.
|
||||
*t_state.lock() = AccountState::default();
|
||||
}
|
||||
Some(config) => {
|
||||
let result = account_sync::sync(&config);
|
||||
let mut state = t_state.lock();
|
||||
state.configured = true;
|
||||
state.last_checked = Some(Instant::now());
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
state.summary = Some(summary);
|
||||
state.error = None;
|
||||
}
|
||||
Err(error) => {
|
||||
// Drop the stale summary so the card never shows
|
||||
// populated data alongside an error/offline pill.
|
||||
state.summary = None;
|
||||
state.error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
state,
|
||||
target,
|
||||
running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the monitor at a new server/account. `None` (no server configured)
|
||||
/// puts it back into the idle prompt state.
|
||||
pub fn set_target(&self, target: Option<LauncherConfig>) {
|
||||
*self.target.lock() = target;
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> AccountState {
|
||||
self.state.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AccountMonitor {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
+187
-22
@@ -7,11 +7,18 @@ use std::time::Duration;
|
||||
const ACCOUNT_SYNC_PATH: &str = "/openfut/account/sync";
|
||||
const TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// The launcher's view of the account, sent on every sync.
|
||||
///
|
||||
/// `persona_id`/`persona_name` are `Option` because omitting them is meaningful:
|
||||
/// the server then answers with the persona *it* is configured for, which is how
|
||||
/// first-run account creation learns an identity instead of inventing one.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountSyncRequest<'a> {
|
||||
persona_id: u64,
|
||||
persona_name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
persona_id: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
persona_name: Option<&'a str>,
|
||||
level: u32,
|
||||
experience: u32,
|
||||
experience_max: u32,
|
||||
@@ -24,14 +31,25 @@ pub struct AccountSyncResult {
|
||||
pub account: AccountSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountSummary {
|
||||
pub persona_id: u64,
|
||||
pub persona_name: String,
|
||||
/// Club identity for the account bar. Optional in older envelopes.
|
||||
#[serde(default)]
|
||||
pub club_name: String,
|
||||
#[serde(default)]
|
||||
pub club_abbr: String,
|
||||
pub level: u32,
|
||||
pub experience: u32,
|
||||
/// XP required for the next level. Optional; 0 means "unknown".
|
||||
#[serde(default)]
|
||||
pub experience_max: u32,
|
||||
pub account_funds: u32,
|
||||
/// EASFC funds ceiling. Optional; 0 means "unknown".
|
||||
#[serde(default)]
|
||||
pub account_funds_cap: u32,
|
||||
pub coins: i64,
|
||||
pub unopened_packs: usize,
|
||||
}
|
||||
@@ -43,7 +61,64 @@ pub struct AccountSummary {
|
||||
pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
||||
config.validate_server()?;
|
||||
config.validate_account()?;
|
||||
let account = post(
|
||||
config,
|
||||
&AccountSyncRequest {
|
||||
persona_id: Some(config.fut_persona_id),
|
||||
persona_name: Some(config.fut_persona_name.trim()),
|
||||
level: config.fut_account_level,
|
||||
experience: config.fut_account_experience,
|
||||
experience_max: config.fut_account_experience_max,
|
||||
account_funds: config.fut_account_funds,
|
||||
account_funds_cap: config.fut_account_funds_cap,
|
||||
},
|
||||
)?;
|
||||
// The server echoes the persona it selected. A different one means the two
|
||||
// sides disagree about who is playing, which must never pass silently.
|
||||
if account.persona_id != config.fut_persona_id {
|
||||
return Err(format!(
|
||||
"account server selected persona {} instead of {}",
|
||||
account.persona_id, config.fut_persona_id
|
||||
));
|
||||
}
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
/// Ask the server which account it serves, for first-run account creation.
|
||||
///
|
||||
/// Sending no persona makes the server fall back to the one it was started with
|
||||
/// and answer with its real club and Core coin balance. That is the whole reason
|
||||
/// the launcher never has to invent a persona id: the identity that matters is
|
||||
/// the server's, and this is how it is claimed.
|
||||
pub fn discover(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
||||
config.validate_server()?;
|
||||
let account = post(
|
||||
config,
|
||||
&AccountSyncRequest {
|
||||
persona_id: None,
|
||||
persona_name: None,
|
||||
level: config.fut_account_level.max(1),
|
||||
experience: config.fut_account_experience,
|
||||
experience_max: config.fut_account_experience_max.max(1),
|
||||
account_funds: config.fut_account_funds,
|
||||
account_funds_cap: config.fut_account_funds_cap,
|
||||
},
|
||||
)?;
|
||||
if account.persona_id == 0 {
|
||||
return Err(
|
||||
"account server returned no persona — is it configured with \
|
||||
a persona id?"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if account.persona_name.trim().is_empty() {
|
||||
return Err("account server returned an empty persona name".to_string());
|
||||
}
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
/// One bounded POST to `/openfut/account/sync`, returning the account summary.
|
||||
fn post(config: &LauncherConfig, body: &AccountSyncRequest<'_>) -> Result<AccountSummary, String> {
|
||||
let host = config.openfut_server_host.trim();
|
||||
let port = config.openfut_account_sync_port;
|
||||
let address = (host, port)
|
||||
@@ -60,16 +135,8 @@ pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
||||
.set_write_timeout(Some(TIMEOUT))
|
||||
.map_err(|error| format!("cannot set account sync timeout: {error}"))?;
|
||||
|
||||
let payload = serde_json::to_vec(&AccountSyncRequest {
|
||||
persona_id: config.fut_persona_id,
|
||||
persona_name: config.fut_persona_name.trim(),
|
||||
level: config.fut_account_level,
|
||||
experience: config.fut_account_experience,
|
||||
experience_max: config.fut_account_experience_max,
|
||||
account_funds: config.fut_account_funds,
|
||||
account_funds_cap: config.fut_account_funds_cap,
|
||||
})
|
||||
.map_err(|error| format!("cannot encode account sync request: {error}"))?;
|
||||
let payload = serde_json::to_vec(body)
|
||||
.map_err(|error| format!("cannot encode account sync request: {error}"))?;
|
||||
|
||||
let request = format!(
|
||||
"POST {ACCOUNT_SYNC_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
@@ -96,21 +163,15 @@ pub fn sync(config: &LauncherConfig) -> Result<AccountSummary, String> {
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.ok_or_else(|| "account server returned a malformed status line".to_string())?;
|
||||
let body = &response[separator + 4..];
|
||||
let response_body = &response[separator + 4..];
|
||||
if !(200..300).contains(&status) {
|
||||
let detail = String::from_utf8_lossy(body);
|
||||
let detail = String::from_utf8_lossy(response_body);
|
||||
return Err(format!(
|
||||
"account server rejected sync (HTTP {status}): {detail}"
|
||||
));
|
||||
}
|
||||
let envelope: AccountSyncResult = serde_json::from_slice(body)
|
||||
let envelope: AccountSyncResult = serde_json::from_slice(response_body)
|
||||
.map_err(|error| format!("account server returned invalid JSON: {error}"))?;
|
||||
if envelope.account.persona_id != config.fut_persona_id {
|
||||
return Err(format!(
|
||||
"account server selected persona {} instead of {}",
|
||||
envelope.account.persona_id, config.fut_persona_id
|
||||
));
|
||||
}
|
||||
Ok(envelope.account)
|
||||
}
|
||||
|
||||
@@ -174,4 +235,108 @@ mod tests {
|
||||
assert_eq!(selected.unopened_packs, 1);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
/// Serve exactly one `/openfut/account/sync` POST, handing the decoded
|
||||
/// request text to `inspect` and replying with `body`.
|
||||
fn serve_once(
|
||||
inspect: impl FnOnce(&str) + Send + 'static,
|
||||
body: &'static str,
|
||||
) -> (u16, thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
let mut request = Vec::new();
|
||||
loop {
|
||||
let mut chunk = [0; 1024];
|
||||
let count = socket.read(&mut chunk).unwrap();
|
||||
assert!(count > 0);
|
||||
request.extend_from_slice(&chunk[..count]);
|
||||
if let Some(separator) = request.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let headers = String::from_utf8_lossy(&request[..separator]);
|
||||
let length = headers
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("Content-Length: "))
|
||||
.unwrap()
|
||||
.parse::<usize>()
|
||||
.unwrap();
|
||||
if request.len() >= separator + 4 + length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
inspect(&String::from_utf8_lossy(&request));
|
||||
write!(
|
||||
socket,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_omits_the_persona_so_the_server_names_its_own() {
|
||||
// The point of first-run discovery: the launcher must not send a guessed
|
||||
// persona, because the server would echo the guess straight back.
|
||||
let (port, server) = serve_once(
|
||||
|request| {
|
||||
assert!(!request.contains("personaId"), "{request}");
|
||||
assert!(!request.contains("personaName"), "{request}");
|
||||
},
|
||||
r#"{"status":"OK","account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC","level":1,"experience":0,"accountFunds":0,"coins":29876776,"unopenedPacks":0}}"#,
|
||||
);
|
||||
let config = LauncherConfig {
|
||||
openfut_server_host: "127.0.0.1".into(),
|
||||
openfut_account_sync_port: port,
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
// Deliberately an unconfigured account: discovery must work before one
|
||||
// exists, which is the whole reason it does not call `validate_account`.
|
||||
assert_eq!(config.fut_persona_id, 0);
|
||||
let found = discover(&config).unwrap();
|
||||
assert_eq!(found.persona_id, 33_068_179);
|
||||
assert_eq!(found.persona_name, "CAGE");
|
||||
assert_eq!(found.club_name, "OpenFUT");
|
||||
assert_eq!(found.coins, 29_876_776);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_rejects_a_server_that_names_no_persona() {
|
||||
// A zero persona would otherwise be written into the config as a real
|
||||
// account and fail much later, at launch, as a mismatch.
|
||||
let (port, server) = serve_once(
|
||||
|_| {},
|
||||
r#"{"status":"OK","account":{"personaId":0,"personaName":"","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#,
|
||||
);
|
||||
let config = LauncherConfig {
|
||||
openfut_server_host: "127.0.0.1".into(),
|
||||
openfut_account_sync_port: port,
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
let error = discover(&config).unwrap_err();
|
||||
assert!(error.contains("no persona"), "{error}");
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_refuses_a_server_that_selects_a_different_persona() {
|
||||
let (port, server) = serve_once(
|
||||
|_| {},
|
||||
r#"{"status":"OK","account":{"personaId":999,"personaName":"OTHER","level":1,"experience":0,"accountFunds":0,"coins":0,"unopenedPacks":0}}"#,
|
||||
);
|
||||
let config = LauncherConfig {
|
||||
openfut_server_host: "127.0.0.1".into(),
|
||||
openfut_account_sync_port: port,
|
||||
fut_persona_id: 12345678,
|
||||
fut_persona_name: "TEST_USER".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
let error = sync(&config).unwrap_err();
|
||||
assert!(error.contains("999"), "{error}");
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+1833
-758
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -106,14 +106,16 @@ pub(crate) fn arming_summary(
|
||||
pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
|
||||
let server = cfg.openfut_server_host.trim();
|
||||
if server.is_empty() {
|
||||
anyhow::bail!("Set the OpenFUT server host in the Config tab before arming.");
|
||||
anyhow::bail!("Set the OpenFUT server host in Settings before arming.");
|
||||
}
|
||||
let ea_ip = cfg.ea_redirect_probe_ip.trim();
|
||||
if ea_ip.is_empty() {
|
||||
anyhow::bail!("Set the EA redirector IP (Config tab) before arming.");
|
||||
anyhow::bail!("Set the EA redirector IP (Settings) before arming.");
|
||||
}
|
||||
if cfg.ea_hostnames.is_empty() {
|
||||
anyhow::bail!("Add at least one EA hostname (e.g. easw.easports.com) in the Config tab before arming.");
|
||||
anyhow::bail!(
|
||||
"Add at least one EA hostname (e.g. easw.easports.com) in Settings before arming."
|
||||
);
|
||||
}
|
||||
let redirector_port = cfg.openfut_blaze_redirector_port;
|
||||
let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?;
|
||||
|
||||
+58
-80
@@ -168,26 +168,6 @@ pub struct LauncherConfig {
|
||||
/// Dead EA hostnames that must resolve to `openfut_server_host`.
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -271,11 +251,6 @@ impl Default for LauncherConfig {
|
||||
game_profile: GameProfile::default(),
|
||||
ea_redirect_probe_ip: String::new(),
|
||||
ea_hostnames: Vec::new(),
|
||||
fifa17_tools_dir: base
|
||||
.join("fifa17-recon/tools")
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
fifa17_python: default_python(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,6 +293,17 @@ impl LauncherConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// The config the account monitor should poll with, or None when no server
|
||||
/// is configured. Returns a clone so the background thread owns its own
|
||||
/// snapshot and never races the UI's live config.
|
||||
pub fn account_target(&self) -> Option<LauncherConfig> {
|
||||
if self.openfut_server_host.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the shared [`ServerConfig`] from the launcher's configured server
|
||||
/// host + destination ports. This is the single place the launcher turns UI
|
||||
/// fields into the canonical config consumed by the hook.
|
||||
@@ -343,19 +329,6 @@ impl LauncherConfig {
|
||||
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 the Config tab.".into());
|
||||
}
|
||||
if self.fifa17_python.trim().is_empty() {
|
||||
return Err("No Python interpreter configured. Set it in the Config tab.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate every configuration value required by the one-button FIFA 17
|
||||
/// launch path. Runtime state such as hook deployment is checked by the UI.
|
||||
pub fn validate_launch_config(&self) -> Result<(), String> {
|
||||
@@ -369,19 +342,19 @@ impl LauncherConfig {
|
||||
} else if self.game_launch_command.trim().is_empty() {
|
||||
return Err(
|
||||
"No game configured. Fill in the game profile, or set a launch command, \
|
||||
in the Config tab."
|
||||
in Settings."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
self.validate_local_services()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_account(&self) -> Result<(), String> {
|
||||
if self.fut_persona_id == 0 {
|
||||
return Err("No EA persona ID configured. Set the account in the Config tab.".into());
|
||||
return Err("No account yet. Create one from the Get started tab.".into());
|
||||
}
|
||||
if self.fut_persona_name.trim().is_empty() {
|
||||
return Err("No EA persona name configured. Set the account in the Config tab.".into());
|
||||
return Err("Account has no persona name. Recreate it from Get started.".into());
|
||||
}
|
||||
if self.fut_account_level == 0 {
|
||||
return Err("EA account level must be at least 1.".into());
|
||||
@@ -397,6 +370,21 @@ impl LauncherConfig {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether an account has been claimed from the server (see
|
||||
/// [`crate::account_sync::discover`]). Distinct from
|
||||
/// [`Self::validate_account`], which also polices the derived EASFC values:
|
||||
/// this answers only "does this install know who is playing?".
|
||||
pub fn account_configured(&self) -> bool {
|
||||
self.fut_persona_id != 0 && !self.fut_persona_name.trim().is_empty()
|
||||
}
|
||||
|
||||
/// Whether the launcher should open on the guided first-run flow instead of
|
||||
/// the dashboard. Keyed on the two things a new user cannot be expected to
|
||||
/// guess: where the server is, and who they are.
|
||||
pub fn needs_onboarding(&self) -> bool {
|
||||
self.validate_server().is_err() || !self.account_configured()
|
||||
}
|
||||
|
||||
/// The exact `openfut.cfg` bytes to write for the hook, or an error if the
|
||||
/// server isn't validly configured (never emits a loopback fallback).
|
||||
///
|
||||
@@ -482,31 +470,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_services_require_tools_dir_and_python() {
|
||||
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() {
|
||||
fn launch_config_requires_server_account_and_command() {
|
||||
let mut c = LauncherConfig::default();
|
||||
assert!(c.validate_launch_config().is_err());
|
||||
|
||||
@@ -519,14 +483,6 @@ mod tests {
|
||||
.contains("launch command"));
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -554,8 +510,6 @@ mod tests {
|
||||
openfut_server_host: "10.10.0.120".into(),
|
||||
fut_persona_id: 1,
|
||||
fut_persona_name: "X".into(),
|
||||
fifa17_tools_dir: "/tmp/tools".into(),
|
||||
fifa17_python: "/usr/bin/python3".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
c.game_launch_command.clear();
|
||||
@@ -583,8 +537,6 @@ mod tests {
|
||||
openfut_server_host: "10.10.0.120".into(),
|
||||
fut_persona_id: 1,
|
||||
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(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
@@ -685,12 +637,38 @@ mod tests {
|
||||
#[test]
|
||||
fn launch_requires_a_valid_ea_account() {
|
||||
let mut c = LauncherConfig::default();
|
||||
assert!(c.validate_account().unwrap_err().contains("persona ID"));
|
||||
// A fresh install has no account, and must say so rather than launching
|
||||
// FIFA as persona 0.
|
||||
assert!(!c.account_configured());
|
||||
assert!(c.validate_account().is_err());
|
||||
c.fut_persona_id = 12345678;
|
||||
assert!(
|
||||
!c.account_configured(),
|
||||
"an id without a name is not an account"
|
||||
);
|
||||
assert!(c.validate_account().unwrap_err().contains("persona name"));
|
||||
c.fut_persona_name = "TEST_USER".into();
|
||||
assert!(c.account_configured());
|
||||
assert!(c.validate_account().is_ok());
|
||||
c.fut_account_experience = 1001;
|
||||
assert!(c.validate_account().unwrap_err().contains("XP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onboarding_is_needed_until_both_server_and_account_are_known() {
|
||||
// Drives which tab the launcher opens on, so the two halves must both
|
||||
// count: a server with no account is still a dead end for a new user.
|
||||
let mut c = LauncherConfig::default();
|
||||
assert!(c.needs_onboarding());
|
||||
c.openfut_server_host = "10.10.0.120".into();
|
||||
assert!(
|
||||
c.needs_onboarding(),
|
||||
"a server alone cannot launch anything"
|
||||
);
|
||||
c.fut_persona_id = 33_068_179;
|
||||
c.fut_persona_name = "CAGE".into();
|
||||
assert!(!c.needs_onboarding());
|
||||
c.openfut_server_host.clear();
|
||||
assert!(c.needs_onboarding(), "losing the server reopens the flow");
|
||||
}
|
||||
}
|
||||
|
||||
+175
-10
@@ -23,10 +23,12 @@
|
||||
//! `game_launch_command` remains as an escape hatch: an unconfigured profile
|
||||
//! falls back to it, so an existing working setup cannot be broken by upgrading.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::config::GameProfile;
|
||||
@@ -35,14 +37,19 @@ use crate::logs::LogBuffer;
|
||||
type Log = Arc<Mutex<LogBuffer>>;
|
||||
|
||||
fn say(log: &Log, msg: impl Into<String>) {
|
||||
log.lock().unwrap().push(msg.into());
|
||||
log.lock().push(msg.into());
|
||||
}
|
||||
|
||||
/// Prepare the prefix, satisfy the licence precondition, and start the game.
|
||||
///
|
||||
/// Returns once the game process has been spawned; its output continues to
|
||||
/// stream into `log` on background threads.
|
||||
pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
/// stream into `log` on background threads. `on_exit` fires when the process
|
||||
/// 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)?;
|
||||
|
||||
let game_dir = PathBuf::from(&profile.game_dir);
|
||||
@@ -51,6 +58,7 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
prepare_prefix(profile, log)?;
|
||||
ensure_dll_override(profile, log);
|
||||
ensure_license(profile, log)?;
|
||||
|
||||
let mut cmd = Command::new(&profile.runner);
|
||||
@@ -61,6 +69,7 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
for (k, v) in &profile.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.env("WINEDLLOVERRIDES", hook_dll_overrides(&profile.env));
|
||||
if !profile.wine_prefix.trim().is_empty() {
|
||||
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
||||
}
|
||||
@@ -78,10 +87,132 @@ pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.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(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
|
||||
@@ -221,12 +352,17 @@ fn non_empty_file(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
let buf = Arc::clone(&log);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
buf.lock().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -234,13 +370,14 @@ pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
|
||||
let buf = Arc::clone(&log);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
buf.lock().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
log.lock().unwrap().push(exit_msg.to_string());
|
||||
log.lock().push(exit_msg.to_string());
|
||||
on_exit();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -443,7 +580,35 @@ mod tests {
|
||||
game_dir: "/definitely/not/here".into(),
|
||||
..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}");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -6,11 +6,12 @@
|
||||
//! stops, or assumes anything about how the server is hosted; it only asks
|
||||
//! "can the FIFA client reach it right now?".
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
net::{TcpStream, ToSocketAddrs},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Mutex,
|
||||
Arc,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
@@ -57,14 +58,14 @@ impl HealthMonitor {
|
||||
let t_running = Arc::clone(&running);
|
||||
thread::spawn(move || {
|
||||
while t_running.load(Ordering::Relaxed) {
|
||||
let target = t_target.lock().unwrap().clone();
|
||||
let target = t_target.lock().clone();
|
||||
match target {
|
||||
None => {
|
||||
*t_state.lock().unwrap() = HealthState::default();
|
||||
*t_state.lock() = HealthState::default();
|
||||
}
|
||||
Some((host, port)) => {
|
||||
let snapshot = probe(&host, port);
|
||||
*t_state.lock().unwrap() = snapshot;
|
||||
*t_state.lock() = snapshot;
|
||||
}
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
@@ -81,11 +82,11 @@ impl HealthMonitor {
|
||||
/// Point the monitor at a new server address (host + bridge port). Passing
|
||||
/// None (e.g. no server configured) puts it back into the idle state.
|
||||
pub fn set_target(&self, target: Option<(String, u16)>) {
|
||||
*self.target.lock().unwrap() = target;
|
||||
*self.target.lock() = target;
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> HealthState {
|
||||
self.state.lock().unwrap().clone()
|
||||
self.state.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+428
-79
@@ -13,11 +13,12 @@
|
||||
//! user after host arming sets `ptrace_scope=0`; this avoids an asynchronous
|
||||
//! Polkit prompt delaying cert patching until after FIFA's first TLS attempt.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
|
||||
path::Path,
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
sync::{mpsc, Arc},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
@@ -28,6 +29,10 @@ use crate::fifa17_capability::{
|
||||
};
|
||||
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)]
|
||||
struct CommandParts {
|
||||
program: String,
|
||||
@@ -35,7 +40,7 @@ struct CommandParts {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
|
||||
Lsx,
|
||||
@@ -51,25 +56,51 @@ impl Service {
|
||||
}
|
||||
}
|
||||
|
||||
/// The responder script filename inside the tools dir.
|
||||
fn script(self) -> &'static str {
|
||||
/// The companion's executable name.
|
||||
///
|
||||
/// 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 {
|
||||
Service::Lsx => "lsx_responder_v2.py",
|
||||
Service::Autopatch => "autopatch.py",
|
||||
Service::Lsx => "openfut-lsx",
|
||||
Service::Autopatch => "openfut-autopatch",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts {
|
||||
let mut args = vec![tools_dir
|
||||
.join(service.script())
|
||||
.to_string_lossy()
|
||||
.into_owned()];
|
||||
/// Absolute path to a companion binary.
|
||||
///
|
||||
/// Prefers a sibling of the running launcher, which is what a workspace build and any
|
||||
/// sane install layout both produce, and falls back to the bare name so a
|
||||
/// 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 {
|
||||
// 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()]);
|
||||
}
|
||||
CommandParts {
|
||||
program: python.to_string(),
|
||||
program: resolve_binary(service).to_string_lossy().into_owned(),
|
||||
args,
|
||||
}
|
||||
}
|
||||
@@ -138,22 +169,19 @@ impl ManagedService {
|
||||
if let Some(result) = self.stopping.as_ref() {
|
||||
match result.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] {label} stopped."));
|
||||
log.lock().push(format!("[launcher] {label} stopped."));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] failed to stop {label}: {error}"));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => return true,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
log.lock().unwrap().push(format!(
|
||||
log.lock().push(format!(
|
||||
"[launcher] {label} stop worker exited unexpectedly."
|
||||
));
|
||||
self.stopping = None;
|
||||
@@ -168,7 +196,6 @@ impl ManagedService {
|
||||
Ok(None) => true,
|
||||
Ok(Some(status)) => {
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] {label} exited ({status})."));
|
||||
self.child = None;
|
||||
false
|
||||
@@ -182,6 +209,11 @@ impl ManagedService {
|
||||
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.
|
||||
pub fn stop(&mut self, log: &Arc<Mutex<LogBuffer>>, service: Service) {
|
||||
if self.stopping.is_some() {
|
||||
@@ -189,9 +221,7 @@ impl ManagedService {
|
||||
}
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let label = service.label();
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] stopping {label}…"));
|
||||
log.lock().push(format!("[launcher] stopping {label}…"));
|
||||
|
||||
self.stopping = Some(dispatch_stop_work(move || {
|
||||
child
|
||||
@@ -224,17 +254,248 @@ pub struct CapabilityWiring {
|
||||
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||
}
|
||||
|
||||
/// Spawn a companion service. `python` is the interpreter, `tools_dir` the
|
||||
/// directory holding the responder scripts. Streams stdout+stderr into `log`.
|
||||
/// Returns an error (without spawning) if the tools dir or script is missing.
|
||||
/// What is actually true about one companion service right now.
|
||||
///
|
||||
/// 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 sink — `Some(..)` for autopatch (whose stdout advertises the
|
||||
/// verified resolver guard) and `None` for LSX.
|
||||
pub fn spawn(
|
||||
service: Service,
|
||||
python: &str,
|
||||
tools_dir: &str,
|
||||
persona_id: u64,
|
||||
persona_name: &str,
|
||||
capability: Option<CapabilityWiring>,
|
||||
@@ -242,35 +503,28 @@ pub fn spawn(
|
||||
) -> anyhow::Result<Child> {
|
||||
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 the Config tab)",
|
||||
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 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);
|
||||
cmd.args(&parts.args);
|
||||
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())
|
||||
.env("FUT_PERSONA_NAME", persona_name);
|
||||
} 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")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
@@ -279,19 +533,21 @@ pub fn spawn(
|
||||
}
|
||||
// Put each companion in its own process group for lifecycle isolation.
|
||||
cmd.process_group(0);
|
||||
cmd.current_dir(dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
log.lock().unwrap().push(format!(
|
||||
"[launcher] starting {label}: {} {}",
|
||||
python,
|
||||
script_path.display(),
|
||||
log.lock().push(format!(
|
||||
"[launcher] starting {label}: {}{}",
|
||||
parts.program,
|
||||
parts.args.iter().fold(String::new(), |mut acc, a| {
|
||||
acc.push(' ');
|
||||
acc.push_str(a);
|
||||
acc
|
||||
}),
|
||||
));
|
||||
|
||||
let mut child = cmd
|
||||
.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() {
|
||||
let buf = Arc::clone(&log);
|
||||
@@ -304,7 +560,7 @@ pub fn spawn(
|
||||
let mut registered = false;
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
// Every raw line is still mirrored into the log, as before.
|
||||
buf.lock().unwrap().push(format!("[{lbl}] {line}"));
|
||||
buf.lock().push(format!("[{lbl}] {line}"));
|
||||
|
||||
let Some(wiring) = cap_wiring.as_ref() else {
|
||||
continue;
|
||||
@@ -317,9 +573,9 @@ pub fn spawn(
|
||||
};
|
||||
registered = true;
|
||||
let fifa_pid = parse_fifa_pid(&line).unwrap_or(0);
|
||||
wiring.sink.lock().unwrap().empty_mypacks_resolver = Some(version);
|
||||
wiring.sink.lock().empty_mypacks_resolver = Some(version);
|
||||
{
|
||||
let mut log = buf.lock().unwrap();
|
||||
let mut log = buf.lock();
|
||||
log.push(format!(
|
||||
"[fifa17] resolver capability verified for FIFA pid {fifa_pid}"
|
||||
));
|
||||
@@ -336,11 +592,9 @@ pub fn spawn(
|
||||
) {
|
||||
Ok(()) => buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("[fifa17] capability registered with backend".to_string()),
|
||||
Err(error) => buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("[fifa17] capability registration failed: {error}")),
|
||||
}
|
||||
}
|
||||
@@ -351,20 +605,19 @@ pub fn spawn(
|
||||
let lbl = label.to_string();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(format!("[{lbl}] {line}"));
|
||||
buf.lock().push(format!("[{lbl}] {line}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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)) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(error);
|
||||
}
|
||||
log.lock()
|
||||
.unwrap()
|
||||
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
|
||||
}
|
||||
|
||||
@@ -376,27 +629,38 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lsx_runs_python_directly() {
|
||||
let parts = command_parts(Service::Lsx, "/usr/bin/python3", Path::new("/tmp/tools"));
|
||||
assert_eq!(parts.program, "/usr/bin/python3");
|
||||
assert_eq!(parts.args, vec!["/tmp/tools/lsx_responder_v2.py"]);
|
||||
fn lsx_runs_its_own_binary_with_no_arguments() {
|
||||
let parts = command_parts(Service::Lsx);
|
||||
assert_eq!(
|
||||
Path::new(&parts.program).file_name().unwrap(),
|
||||
"openfut-lsx"
|
||||
);
|
||||
assert!(parts.args.is_empty(), "{:?}", parts.args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autopatch_runs_python_directly_with_launcher_ownership() {
|
||||
let parts = command_parts(
|
||||
Service::Autopatch,
|
||||
"/usr/bin/python3",
|
||||
Path::new("/tmp/tools"),
|
||||
fn autopatch_runs_its_own_binary_with_launcher_ownership() {
|
||||
let parts = command_parts(Service::Autopatch);
|
||||
assert_eq!(
|
||||
Path::new(&parts.program).file_name().unwrap(),
|
||||
"openfut-autopatch"
|
||||
);
|
||||
assert_eq!(parts.program, "/usr/bin/python3");
|
||||
// The launcher pid is how autopatch learns to exit with its owner.
|
||||
assert_eq!(
|
||||
parts.args,
|
||||
vec![
|
||||
"/tmp/tools/autopatch.py",
|
||||
"--launcher-pid",
|
||||
&std::process::id().to_string(),
|
||||
]
|
||||
vec!["--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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -426,4 +690,89 @@ mod tests {
|
||||
.expect_err("exited child must not be reported 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+92
-2
@@ -1,3 +1,4 @@
|
||||
mod account_monitor;
|
||||
mod account_sync;
|
||||
mod app;
|
||||
mod arm;
|
||||
@@ -5,18 +6,22 @@ mod config;
|
||||
mod fifa17_capability;
|
||||
mod game_launch;
|
||||
mod health;
|
||||
mod launch;
|
||||
mod local_services;
|
||||
mod logs;
|
||||
mod netcheck;
|
||||
mod preflight;
|
||||
mod setup;
|
||||
mod theme;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("OpenFUT Launcher")
|
||||
.with_inner_size([780.0, 560.0])
|
||||
.with_min_inner_size([600.0, 400.0]),
|
||||
.with_app_id("openfut-launcher")
|
||||
.with_icon(app_icon())
|
||||
.with_inner_size([1040.0, 720.0])
|
||||
.with_min_inner_size([880.0, 600.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -26,3 +31,88 @@ fn main() -> eframe::Result<()> {
|
||||
Box::new(|cc| Ok(Box::new(app::LauncherApp::new(cc)))),
|
||||
)
|
||||
}
|
||||
|
||||
/// The application / taskbar icon: the same "OF" monogram the header wordmark
|
||||
/// shows, drawn white on the signature accent tile. Generated in code (no PNG
|
||||
/// dependency) at 4x supersampling and box-downsampled to a crisp 64x64 RGBA —
|
||||
/// scales cleanly to the 32x32 the WM typically renders. Colours come from the
|
||||
/// theme palette so the icon never drifts from the in-app brand.
|
||||
fn app_icon() -> egui::IconData {
|
||||
const SIZE: usize = 64; // output edge
|
||||
const SS: usize = 4; // supersampling factor
|
||||
|
||||
let accent = theme::ACCENT;
|
||||
let fg = theme::ON_ACCENT;
|
||||
|
||||
// Rounded-square background: point inside the [0,SIZE]² square with corners
|
||||
// rounded to `round_r` (transparent outside, so the icon reads as a tile).
|
||||
let round_r = 13.0_f32;
|
||||
let inside_bg = |x: f32, y: f32| -> bool {
|
||||
let s = SIZE as f32;
|
||||
let cx = x.clamp(round_r, s - round_r);
|
||||
let cy = y.clamp(round_r, s - round_r);
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
dx * dx + dy * dy <= round_r * round_r
|
||||
};
|
||||
|
||||
// "O" — an elliptical ring on the left.
|
||||
let inside_o = |x: f32, y: f32| -> bool {
|
||||
let (cx, cy) = (21.0_f32, 32.0_f32);
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
let outer = (dx / 9.0).powi(2) + (dy / 14.0).powi(2) <= 1.0;
|
||||
let inner = (dx / 4.8).powi(2) + (dy / 9.5).powi(2) < 1.0;
|
||||
outer && !inner
|
||||
};
|
||||
|
||||
// "F" — a stem plus a top and middle bar on the right.
|
||||
let inside_f = |x: f32, y: f32| -> bool {
|
||||
let stem = (34.0..=39.0).contains(&x) && (18.0..=46.0).contains(&y);
|
||||
let top = (34.0..=52.0).contains(&x) && (18.0..=23.0).contains(&y);
|
||||
let mid = (34.0..=48.0).contains(&x) && (29.5..=34.0).contains(&y);
|
||||
stem || top || mid
|
||||
};
|
||||
|
||||
// Premultiplied-alpha accumulation per output pixel so antialiased edges
|
||||
// (both the rounded tile and the letters) never fringe dark.
|
||||
let mut rgba = vec![0u8; SIZE * SIZE * 4];
|
||||
for oy in 0..SIZE {
|
||||
for ox in 0..SIZE {
|
||||
let (mut ar, mut ag, mut ab, mut aa) = (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32);
|
||||
for sy in 0..SS {
|
||||
for sx in 0..SS {
|
||||
let x = ox as f32 + (sx as f32 + 0.5) / SS as f32;
|
||||
let y = oy as f32 + (sy as f32 + 0.5) / SS as f32;
|
||||
let (r, g, b, a) = if inside_o(x, y) || inside_f(x, y) {
|
||||
(fg.r(), fg.g(), fg.b(), 255u16)
|
||||
} else if inside_bg(x, y) {
|
||||
(accent.r(), accent.g(), accent.b(), 255u16)
|
||||
} else {
|
||||
(0, 0, 0, 0)
|
||||
};
|
||||
let af = a as f32 / 255.0;
|
||||
ar += r as f32 * af;
|
||||
ag += g as f32 * af;
|
||||
ab += b as f32 * af;
|
||||
aa += af;
|
||||
}
|
||||
}
|
||||
let samples = (SS * SS) as f32;
|
||||
let idx = (oy * SIZE + ox) * 4;
|
||||
let (r, g, b) = if aa > 0.0 {
|
||||
(ar / aa, ag / aa, ab / aa)
|
||||
} else {
|
||||
(0.0, 0.0, 0.0)
|
||||
};
|
||||
rgba[idx] = r.round() as u8;
|
||||
rgba[idx + 1] = g.round() as u8;
|
||||
rgba[idx + 2] = b.round() as u8;
|
||||
rgba[idx + 3] = (aa / samples * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
|
||||
egui::IconData {
|
||||
rgba,
|
||||
width: SIZE as u32,
|
||||
height: SIZE as u32,
|
||||
}
|
||||
}
|
||||
|
||||
+122
-36
@@ -88,10 +88,11 @@ impl Check {
|
||||
/// Run every applicable check. Order is the order the game exercises them.
|
||||
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
||||
vec![
|
||||
ptrace_scope(cfg),
|
||||
ptrace_scope(),
|
||||
ea_redirect(cfg),
|
||||
hostname_mapping(cfg),
|
||||
backend_reachable(cfg),
|
||||
hook_config(cfg),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -108,16 +109,12 @@ pub fn warnings(checks: &[Check]) -> usize {
|
||||
/// 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
|
||||
/// 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)";
|
||||
// `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) {
|
||||
Ok(v) => ptrace_verdict(&v),
|
||||
// Not every kernel has Yama. Absent means unenforced, which is what we want.
|
||||
@@ -238,7 +235,7 @@ fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
||||
}
|
||||
|
||||
/// 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";
|
||||
let host = cfg.openfut_server_host.trim();
|
||||
if host.is_empty() {
|
||||
@@ -261,6 +258,50 @@ fn backend_reachable(cfg: &LauncherConfig) -> Check {
|
||||
}
|
||||
}
|
||||
|
||||
/// The deployed `openfut.cfg` is the only server address the *game* can see.
|
||||
///
|
||||
/// Every panel in this launcher reads the in-memory config, so a settings change
|
||||
/// that never reached the file produces the worst possible failure: the UI shows
|
||||
/// the new server online while FIFA connects to the old one. Compare the two.
|
||||
fn hook_config(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "Hook server address";
|
||||
let game_dir = cfg.fifa_game_dir.trim();
|
||||
if game_dir.is_empty() {
|
||||
return Check::skip(NAME, "no FIFA game dir configured");
|
||||
}
|
||||
let Some(body) = crate::setup::read_hook_config(std::path::Path::new(game_dir)) else {
|
||||
return Check::skip(
|
||||
NAME,
|
||||
format!("no {} deployed yet", crate::setup::HOOK_CFG_FILE),
|
||||
);
|
||||
};
|
||||
let deployed = match openfut_common::ServerConfig::parse(&body) {
|
||||
Ok(parsed) => parsed,
|
||||
// Unparseable means the hook cannot read it either, and nothing else in
|
||||
// the stack recovers from that — so this one is a genuine failure.
|
||||
Err(e) => {
|
||||
return Check::fail(
|
||||
NAME,
|
||||
format!("{} is unreadable: {e}", crate::setup::HOOK_CFG_FILE),
|
||||
)
|
||||
}
|
||||
};
|
||||
let wanted = cfg.server_config();
|
||||
if deployed == wanted {
|
||||
return Check::pass(NAME, format!("hook redirects to {}", wanted.host));
|
||||
}
|
||||
// Warn, not fail: the launch path rewrites this file before starting the
|
||||
// game, so the drift is real but already covered. Naming both addresses is
|
||||
// what makes it actionable.
|
||||
Check::warn(
|
||||
NAME,
|
||||
format!(
|
||||
"deployed hook still points at {} (settings say {}) — launching rewrites it",
|
||||
deployed.host, wanted.host
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn connects(host: &str, port: u16) -> bool {
|
||||
match (host, port).to_socket_addrs() {
|
||||
Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()),
|
||||
@@ -289,13 +330,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_unconfigured_launcher_skips_rather_than_passes() {
|
||||
// The distinction that matters: a fresh config must not display four
|
||||
// green ticks. "Not checked" is not "checked and fine".
|
||||
// The distinction that matters: a fresh config must not display a column
|
||||
// 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();
|
||||
// `default()` points this at a conventional path whose existence varies
|
||||
// by machine. Pin it so the assertion is about the code, not this box.
|
||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
||||
let checks = run(&c);
|
||||
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
|
||||
let checks: Vec<Check> = run(&c)
|
||||
.into_iter()
|
||||
.filter(|k| k.name != "ptrace_scope (autopatch)")
|
||||
.collect();
|
||||
assert!(
|
||||
checks.iter().all(|k| k.state == State::Skipped),
|
||||
"{checks:#?}"
|
||||
@@ -314,16 +363,6 @@ mod tests {
|
||||
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]
|
||||
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
|
||||
let mut c = cfg();
|
||||
@@ -356,15 +395,24 @@ mod tests {
|
||||
|
||||
/// A shadowed hostname must not be counted as a reason to expect failure.
|
||||
/// This is the exact case the first version got wrong.
|
||||
///
|
||||
/// Asserts the hostname check itself rather than counting states across the
|
||||
/// whole run: `backend_reachable` opens real sockets, so an aggregate count
|
||||
/// silently asserts that THIS machine has the OpenFUT ports open. That made
|
||||
/// the test pass only on the server host and fail on the game machine, which
|
||||
/// is precisely where someone building the launcher runs the suite.
|
||||
#[test]
|
||||
fn a_shadowed_hostname_is_a_warning_not_a_failure() {
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.2".into();
|
||||
c.ea_hostnames = vec!["localhost".into()];
|
||||
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
|
||||
let checks = run(&c);
|
||||
assert_eq!(failures(&checks), 0, "must not be reported as fatal");
|
||||
assert_eq!(warnings(&checks), 1);
|
||||
let check = hostname_mapping(&c);
|
||||
assert_eq!(check.state, State::Warn, "{}", check.detail);
|
||||
assert!(
|
||||
check.detail.contains("localhost"),
|
||||
"the warning must name the shadowed host: {}",
|
||||
check.detail
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -377,13 +425,6 @@ mod tests {
|
||||
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]
|
||||
fn a_dead_backend_port_is_reported_as_a_failure() {
|
||||
let mut c = cfg();
|
||||
@@ -395,4 +436,49 @@ mod tests {
|
||||
assert_eq!(check.state, State::Fail, "{}", check.detail);
|
||||
assert!(check.detail.contains("no answer on"), "{}", check.detail);
|
||||
}
|
||||
|
||||
/// A temp game dir holding one `openfut.cfg` body.
|
||||
fn game_dir_with_cfg(tag: &str, body: &str) -> std::path::PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("openfut-preflight-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(crate::setup::HOOK_CFG_FILE), body).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_hook_config_is_reported_and_names_both_addresses() {
|
||||
// The silent failure this check exists for: settings changed, the file
|
||||
// the game reads did not.
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "10.0.0.2".into();
|
||||
let old = openfut_common::ServerConfig {
|
||||
host: "10.0.0.1".into(),
|
||||
ports: c.server_config().ports,
|
||||
};
|
||||
let dir = game_dir_with_cfg("stale", &old.to_cfg_string());
|
||||
c.fifa_game_dir = dir.to_string_lossy().into_owned();
|
||||
let check = hook_config(&c);
|
||||
assert_eq!(check.state, State::Warn, "{}", check.detail);
|
||||
assert!(check.detail.contains("10.0.0.1"), "{}", check.detail);
|
||||
assert!(check.detail.contains("10.0.0.2"), "{}", check.detail);
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hook_config_matching_settings_passes() {
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "10.0.0.2".into();
|
||||
let dir = game_dir_with_cfg("fresh", &c.server_config().to_cfg_string());
|
||||
c.fifa_game_dir = dir.to_string_lossy().into_owned();
|
||||
assert_eq!(hook_config(&c).state, State::Pass);
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_hook_config_is_skipped_not_passed() {
|
||||
let mut c = cfg();
|
||||
c.fifa_game_dir = "/nonexistent/fifa-game-dir".into();
|
||||
assert_eq!(hook_config(&c).state, State::Skipped);
|
||||
}
|
||||
}
|
||||
|
||||
+31
-11
@@ -67,6 +67,9 @@ pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> {
|
||||
|
||||
// ── DLL hook deployment ───────────────────────────────────────────────────────
|
||||
|
||||
/// The file the injected hook reads its server address from, in the game dir.
|
||||
pub const HOOK_CFG_FILE: &str = "openfut.cfg";
|
||||
|
||||
/// Deploy openfut_hook.dll into the FIFA 23 game directory and write
|
||||
/// openfut.cfg with the structured server configuration the hook reads.
|
||||
/// `cfg_contents` must be the full `openfut.cfg` body (see
|
||||
@@ -85,14 +88,14 @@ pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> a
|
||||
}
|
||||
std::fs::create_dir_all(game_dir)?;
|
||||
std::fs::copy(dll_src, game_dir.join("version.dll"))?;
|
||||
std::fs::write(game_dir.join("openfut.cfg"), cfg_contents)?;
|
||||
std::fs::write(game_dir.join(HOOK_CFG_FILE), cfg_contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update only openfut.cfg without redeploying the DLL. `cfg_contents` is the
|
||||
/// full structured `openfut.cfg` body.
|
||||
pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
|
||||
let cfg = game_dir.join("openfut.cfg");
|
||||
let cfg = game_dir.join(HOOK_CFG_FILE);
|
||||
if !cfg.exists() {
|
||||
anyhow::bail!("Hook DLL not deployed yet — deploy first.");
|
||||
}
|
||||
@@ -100,6 +103,15 @@ pub fn update_hook_config(game_dir: &Path, cfg_contents: &str) -> anyhow::Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the `openfut.cfg` the hook will actually load, if one is deployed.
|
||||
///
|
||||
/// The launcher's own health and account requests are built from the in-memory
|
||||
/// config, but the *game* only ever sees this file. Reading it back is the only
|
||||
/// way to tell whether the two agree.
|
||||
pub fn read_hook_config(game_dir: &Path) -> Option<String> {
|
||||
std::fs::read_to_string(game_dir.join(HOOK_CFG_FILE)).ok()
|
||||
}
|
||||
|
||||
/// Remove the deployed hook DLL from the FIFA game directory.
|
||||
pub fn remove_hook_dll(game_dir: &Path) -> anyhow::Result<()> {
|
||||
let dest = game_dir.join("version.dll");
|
||||
@@ -114,8 +126,14 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
|
||||
game_dir.join("version.dll").exists()
|
||||
}
|
||||
|
||||
/// The Steam launch options the user needs to paste in to enable the override.
|
||||
/// Proton loads local DLLs named in WINEDLLOVERRIDES ahead of system ones.
|
||||
/// Steam launch options that enable the hook's DLL override.
|
||||
///
|
||||
/// Kept only as a fallback to show a user who runs the game outside this launcher on
|
||||
/// a prefix we have never prepared. It is NOT the normal path any more: the launcher
|
||||
/// persists the override in the prefix registry itself
|
||||
/// (`game_launch::ensure_dll_override`), which applies to every launch including
|
||||
/// Steam's own Play button. Telling a player to paste launch options is exactly the
|
||||
/// kind of manual step this launcher exists to remove.
|
||||
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
|
||||
|
||||
// ── Game launch ───────────────────────────────────────────────────────────────
|
||||
@@ -127,13 +145,14 @@ pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %comman
|
||||
pub fn launch_game(
|
||||
command: &str,
|
||||
workdir: &str,
|
||||
log_buf: std::sync::Arc<std::sync::Mutex<crate::logs::LogBuffer>>,
|
||||
log_buf: std::sync::Arc<parking_lot::Mutex<crate::logs::LogBuffer>>,
|
||||
on_exit: impl FnOnce() + Send + 'static,
|
||||
) -> anyhow::Result<()> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
if command.trim().is_empty() {
|
||||
anyhow::bail!("No game launch command configured (set it in the Config tab).");
|
||||
anyhow::bail!("No game launch command configured (set it in Settings).");
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("sh");
|
||||
@@ -145,7 +164,6 @@ pub fn launch_game(
|
||||
|
||||
log_buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("[launcher] launching game: {command}"));
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
@@ -154,7 +172,7 @@ pub fn launch_game(
|
||||
let buf = std::sync::Arc::clone(&log_buf);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(out).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
buf.lock().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -162,18 +180,20 @@ pub fn launch_game(
|
||||
let buf = std::sync::Arc::clone(&log_buf);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
buf.lock().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 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 || {
|
||||
let _ = child.wait();
|
||||
log_buf
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push("[launcher] game process exited.".to_string());
|
||||
on_exit();
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
//! OpenFUT launcher visual system.
|
||||
//!
|
||||
//! A single place that owns the app's look: the semantic colour palette, the
|
||||
//! type scale, embedded fonts, and the tuned egui [`Style`]/[`Visuals`]. UI code
|
||||
//! composes *with* this system — it never hard-codes `Color32::from_rgb(...)` or
|
||||
//! stray pixel radii. The palette is deliberately small: one signature accent
|
||||
//! plus four status hues (success / warn / error / idle) and a tinted neutral
|
||||
//! ramp. Nothing here changes launcher behaviour; it is presentation only.
|
||||
|
||||
use egui::{
|
||||
Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Frame, Margin, Rounding,
|
||||
Stroke, TextStyle,
|
||||
};
|
||||
|
||||
// ── Semantic palette ────────────────────────────────────────────────────────
|
||||
// Neutrals are always *tinted* (a hint of cool blue), never pure #000/#fff.
|
||||
|
||||
/// Window backdrop — the deepest surface.
|
||||
pub const BG_DEEP: Color32 = Color32::from_rgb(0x10, 0x12, 0x18);
|
||||
/// Standard panel fill (nav rail, central body).
|
||||
pub const BG: Color32 = Color32::from_rgb(0x15, 0x18, 0x22);
|
||||
/// Raised card / group surface.
|
||||
pub const SURFACE: Color32 = Color32::from_rgb(0x1c, 0x20, 0x2e);
|
||||
/// Hovered / interactive raised surface.
|
||||
pub const SURFACE_HOVER: Color32 = Color32::from_rgb(0x24, 0x29, 0x3a);
|
||||
/// Inset surface (text fields, console, code).
|
||||
pub const INSET: Color32 = Color32::from_rgb(0x0e, 0x10, 0x17);
|
||||
|
||||
/// Hairline divider / card border.
|
||||
pub const BORDER: Color32 = Color32::from_rgb(0x2a, 0x31, 0x45);
|
||||
/// Stronger border for emphasis / hover.
|
||||
pub const BORDER_STRONG: Color32 = Color32::from_rgb(0x3a, 0x43, 0x5e);
|
||||
|
||||
/// Primary text.
|
||||
pub const TEXT: Color32 = Color32::from_rgb(0xe6, 0xe9, 0xf2);
|
||||
/// Secondary / supporting text.
|
||||
pub const TEXT_WEAK: Color32 = Color32::from_rgb(0x9a, 0xa3, 0xb8);
|
||||
/// Tertiary / disabled-ish text.
|
||||
pub const TEXT_FAINT: Color32 = Color32::from_rgb(0x6a, 0x73, 0x8a);
|
||||
|
||||
/// Signature OpenFUT accent — a confident royal blue used for the wordmark,
|
||||
/// active navigation, and primary calls-to-action.
|
||||
pub const ACCENT: Color32 = Color32::from_rgb(0x4c, 0x6f, 0xff);
|
||||
pub const ACCENT_HOVER: Color32 = Color32::from_rgb(0x6a, 0x87, 0xff);
|
||||
pub const ACCENT_PRESSED: Color32 = Color32::from_rgb(0x3b, 0x5b, 0xe0);
|
||||
/// Faint accent wash for active-nav backgrounds / selection.
|
||||
pub const ACCENT_WASH: Color32 = Color32::from_rgb(0x22, 0x2c, 0x50);
|
||||
/// Text drawn on top of the solid accent.
|
||||
pub const ON_ACCENT: Color32 = Color32::from_rgb(0xf5, 0xf7, 0xff);
|
||||
|
||||
/// Status hues — distinct from the accent so "primary action" never reads as
|
||||
/// "healthy" and vice-versa.
|
||||
pub const SUCCESS: Color32 = Color32::from_rgb(0x3f, 0xcf, 0x8e);
|
||||
pub const WARN: Color32 = Color32::from_rgb(0xf2, 0xb4, 0x4c);
|
||||
pub const ERROR: Color32 = Color32::from_rgb(0xf2, 0x6d, 0x6d);
|
||||
pub const IDLE: Color32 = Color32::from_rgb(0x7a, 0x83, 0x99);
|
||||
/// Informational blue for log lines (lighter than the accent).
|
||||
pub const INFO: Color32 = Color32::from_rgb(0x8f, 0xb6, 0xff);
|
||||
|
||||
// ── Type scale (custom named text styles) ───────────────────────────────────
|
||||
|
||||
/// Large branded wordmark.
|
||||
pub const HERO: &str = "Hero";
|
||||
/// Card / section titles.
|
||||
pub const SUBHEADING: &str = "Subheading";
|
||||
/// Small monospace (console meta, launch command).
|
||||
pub const MONO_SM: &str = "MonoSm";
|
||||
|
||||
fn bold_family() -> FontFamily {
|
||||
FontFamily::Name("openfut-bold".into())
|
||||
}
|
||||
|
||||
/// A [`TextStyle`] handle for one of our custom scale steps.
|
||||
pub fn text_style(name: &str) -> TextStyle {
|
||||
TextStyle::Name(name.into())
|
||||
}
|
||||
|
||||
// ── Status semantics ────────────────────────────────────────────────────────
|
||||
|
||||
/// A coarse health/activity state, mapped to one palette hue + glyph. Using an
|
||||
/// enum keeps status rendering consistent everywhere (dashboard, preflight,
|
||||
/// services) instead of ad-hoc colour+string pairs.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
/// Healthy / online / running / passed.
|
||||
Ok,
|
||||
/// Advisory — worth attention, usually not fatal.
|
||||
Warn,
|
||||
/// Broken / unreachable / failed.
|
||||
Error,
|
||||
/// Not running / not configured / not checked.
|
||||
Idle,
|
||||
/// Transient (stopping / working).
|
||||
Busy,
|
||||
/// Unknown / not yet probed.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn color(self) -> Color32 {
|
||||
match self {
|
||||
Status::Ok => SUCCESS,
|
||||
Status::Warn => WARN,
|
||||
Status::Error => ERROR,
|
||||
Status::Idle => IDLE,
|
||||
Status::Busy => WARN,
|
||||
Status::Unknown => TEXT_FAINT,
|
||||
}
|
||||
}
|
||||
|
||||
/// A consistent status glyph: filled ● for active/terminal states, hollow ○
|
||||
/// for idle/unknown. (Kept to glyphs the bundled fonts render.)
|
||||
pub fn glyph(self) -> &'static str {
|
||||
match self {
|
||||
Status::Ok | Status::Error | Status::Warn | Status::Busy => "●",
|
||||
Status::Idle | Status::Unknown => "○",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a compact status pill: a tinted, rounded chip with a status dot and
|
||||
/// label. Used for the at-a-glance state on each dashboard card.
|
||||
pub fn status_pill(ui: &mut egui::Ui, label: &str, status: Status) {
|
||||
let color = status.color();
|
||||
let bg = tint(color, 0.14);
|
||||
Frame::none()
|
||||
.fill(bg)
|
||||
.rounding(Rounding::same(999.0))
|
||||
.inner_margin(Margin::symmetric(10.0, 3.0))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
ui.label(egui::RichText::new(status.glyph()).color(color).size(11.0));
|
||||
ui.label(egui::RichText::new(label).color(color).size(12.0).strong());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A raised card surface: rounded, hairline-bordered, generously padded. The
|
||||
/// building block for the dashboard and setup sections.
|
||||
pub fn card() -> Frame {
|
||||
Frame::none()
|
||||
.fill(SURFACE)
|
||||
.stroke(Stroke::new(1.0_f32, BORDER))
|
||||
.rounding(Rounding::same(12.0))
|
||||
.inner_margin(Margin::same(18.0))
|
||||
}
|
||||
|
||||
/// Blend `color` toward the app background by `bg_weight` (0 = full colour,
|
||||
/// 1 = pure background). Used for tinted chips and washes.
|
||||
pub fn tint(color: Color32, weight: f32) -> Color32 {
|
||||
let w = weight.clamp(0.0, 1.0);
|
||||
let lerp = |c: u8, b: u8| ((c as f32) * w + (b as f32) * (1.0 - w)).round() as u8;
|
||||
// Chips sit on card surfaces; lerp toward the surface, not the deep bg.
|
||||
Color32::from_rgb(
|
||||
lerp(color.r(), SURFACE.r()),
|
||||
lerp(color.g(), SURFACE.g()),
|
||||
lerp(color.b(), SURFACE.b()),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Install ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Embed the bundled fonts and apply the OpenFUT style. Called once at startup.
|
||||
pub fn install(ctx: &Context) {
|
||||
install_fonts(ctx);
|
||||
install_style(ctx);
|
||||
}
|
||||
|
||||
fn install_fonts(ctx: &Context) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
|
||||
fonts.font_data.insert(
|
||||
"openfut-sans".to_owned(),
|
||||
FontData::from_static(include_bytes!("../assets/fonts/LiberationSans-Regular.ttf")),
|
||||
);
|
||||
fonts.font_data.insert(
|
||||
"openfut-bold".to_owned(),
|
||||
FontData::from_static(include_bytes!("../assets/fonts/LiberationSans-Bold.ttf")),
|
||||
);
|
||||
fonts.font_data.insert(
|
||||
"openfut-mono".to_owned(),
|
||||
FontData::from_static(include_bytes!("../assets/fonts/DejaVuSansMono.ttf")),
|
||||
);
|
||||
|
||||
// Proportional & monospace default to the bundled faces so the UI looks
|
||||
// identical regardless of the host's installed fonts.
|
||||
fonts
|
||||
.families
|
||||
.entry(FontFamily::Proportional)
|
||||
.or_default()
|
||||
.insert(0, "openfut-sans".to_owned());
|
||||
fonts
|
||||
.families
|
||||
.entry(FontFamily::Monospace)
|
||||
.or_default()
|
||||
.insert(0, "openfut-mono".to_owned());
|
||||
|
||||
// A dedicated bold family — egui does not synthesize weight, so headings
|
||||
// reference this explicitly for a real type hierarchy.
|
||||
fonts.families.insert(
|
||||
FontFamily::Name("openfut-bold".into()),
|
||||
vec!["openfut-bold".to_owned(), "openfut-sans".to_owned()],
|
||||
);
|
||||
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
fn install_style(ctx: &Context) {
|
||||
let mut style = (*ctx.style()).clone();
|
||||
|
||||
// ── Type scale ──────────────────────────────────────────────────────────
|
||||
let bold = bold_family();
|
||||
let prop = FontFamily::Proportional;
|
||||
let mono = FontFamily::Monospace;
|
||||
let ts = &mut style.text_styles;
|
||||
ts.insert(text_style(HERO), FontId::new(28.0, bold.clone()));
|
||||
ts.insert(TextStyle::Heading, FontId::new(19.0, bold.clone()));
|
||||
ts.insert(text_style(SUBHEADING), FontId::new(15.0, bold));
|
||||
ts.insert(TextStyle::Body, FontId::new(14.0, prop.clone()));
|
||||
ts.insert(TextStyle::Button, FontId::new(14.0, prop.clone()));
|
||||
ts.insert(TextStyle::Small, FontId::new(12.0, prop));
|
||||
ts.insert(TextStyle::Monospace, FontId::new(13.0, mono.clone()));
|
||||
ts.insert(text_style(MONO_SM), FontId::new(11.5, mono));
|
||||
|
||||
// ── Spacing scale (multiples of 4) ────────────────────────────────────────
|
||||
let sp = &mut style.spacing;
|
||||
sp.item_spacing = egui::vec2(8.0, 8.0);
|
||||
sp.button_padding = egui::vec2(12.0, 7.0);
|
||||
sp.menu_margin = Margin::same(8.0);
|
||||
sp.indent = 18.0;
|
||||
sp.interact_size.y = 30.0;
|
||||
sp.scroll.bar_width = 9.0;
|
||||
|
||||
// ── Visuals ───────────────────────────────────────────────────────────────
|
||||
let mut v = egui::Visuals::dark();
|
||||
v.dark_mode = true;
|
||||
v.override_text_color = Some(TEXT);
|
||||
v.panel_fill = BG;
|
||||
v.window_fill = BG;
|
||||
v.extreme_bg_color = INSET;
|
||||
v.faint_bg_color = SURFACE;
|
||||
v.code_bg_color = INSET;
|
||||
v.hyperlink_color = ACCENT_HOVER;
|
||||
|
||||
v.window_rounding = Rounding::same(12.0);
|
||||
v.window_stroke = Stroke::new(1.0_f32, BORDER);
|
||||
v.menu_rounding = Rounding::same(8.0);
|
||||
v.window_shadow = egui::epaint::Shadow::NONE;
|
||||
v.popup_shadow = egui::epaint::Shadow {
|
||||
offset: egui::vec2(0.0, 6.0),
|
||||
blur: 18.0,
|
||||
spread: 0.0,
|
||||
color: Color32::from_black_alpha(120),
|
||||
};
|
||||
|
||||
// Selection uses the accent wash so highlighted text/nav reads as branded.
|
||||
v.selection.bg_fill = ACCENT_WASH;
|
||||
v.selection.stroke = Stroke::new(1.0_f32, ACCENT_HOVER);
|
||||
|
||||
// Separators / hairlines.
|
||||
let radius = Rounding::same(8.0);
|
||||
|
||||
// Non-interactive widgets (labels, separators).
|
||||
v.widgets.noninteractive.bg_fill = SURFACE;
|
||||
v.widgets.noninteractive.weak_bg_fill = SURFACE;
|
||||
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, BORDER);
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.widgets.noninteractive.rounding = radius;
|
||||
|
||||
// Inactive interactive widgets (idle buttons).
|
||||
v.widgets.inactive.bg_fill = SURFACE_HOVER;
|
||||
v.widgets.inactive.weak_bg_fill = SURFACE_HOVER;
|
||||
v.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, BORDER);
|
||||
v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.widgets.inactive.rounding = radius;
|
||||
|
||||
// Hovered.
|
||||
v.widgets.hovered.bg_fill = tint(ACCENT, 0.30);
|
||||
v.widgets.hovered.weak_bg_fill = tint(ACCENT, 0.30);
|
||||
v.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, BORDER_STRONG);
|
||||
v.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.widgets.hovered.rounding = radius;
|
||||
v.widgets.hovered.expansion = 1.0;
|
||||
|
||||
// Active / pressed.
|
||||
v.widgets.active.bg_fill = ACCENT_PRESSED;
|
||||
v.widgets.active.weak_bg_fill = ACCENT_PRESSED;
|
||||
v.widgets.active.bg_stroke = Stroke::new(1.0_f32, ACCENT);
|
||||
v.widgets.active.fg_stroke = Stroke::new(1.0_f32, ON_ACCENT);
|
||||
v.widgets.active.rounding = radius;
|
||||
v.widgets.active.expansion = 1.0;
|
||||
|
||||
// Open (combo boxes / menus).
|
||||
v.widgets.open.bg_fill = SURFACE_HOVER;
|
||||
v.widgets.open.weak_bg_fill = SURFACE_HOVER;
|
||||
v.widgets.open.bg_stroke = Stroke::new(1.0_f32, BORDER_STRONG);
|
||||
v.widgets.open.fg_stroke = Stroke::new(1.0_f32, TEXT);
|
||||
v.widgets.open.rounding = radius;
|
||||
|
||||
style.visuals = v;
|
||||
ctx.set_style(style);
|
||||
}
|
||||
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