Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f16828a584 |
@@ -4,3 +4,6 @@ target/
|
||||
openfut.db
|
||||
openfut.db-shm
|
||||
openfut.db-wal
|
||||
|
||||
# hook cross-build test output
|
||||
target-test/
|
||||
|
||||
Generated
-5
@@ -2279,10 +2279,6 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-common"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-launcher"
|
||||
version = "0.1.0"
|
||||
@@ -2292,7 +2288,6 @@ dependencies = [
|
||||
"dirs",
|
||||
"eframe",
|
||||
"egui",
|
||||
"openfut-common",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
||||
@@ -12,7 +12,3 @@ serde = { version = "1", features = ["derive"] }
|
||||
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,9 +1,3 @@
|
||||
# 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"
|
||||
@@ -27,6 +21,7 @@ probe = []
|
||||
fifa17 = []
|
||||
|
||||
[dependencies]
|
||||
openfut-common = { path = "../openfut-common" }
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_LibraryLoader",
|
||||
|
||||
+37
-15
@@ -1,20 +1,16 @@
|
||||
/// Reads openfut.cfg from the same directory as this DLL.
|
||||
///
|
||||
/// The file contains a single line: the IP the hook should redirect EA
|
||||
/// hostnames to, e.g. "192.168.1.10" or "127.0.0.1".
|
||||
/// Falls back to 127.0.0.1 if the file is missing or unreadable.
|
||||
//! Load the configured OpenFUT host and destination ports from `openfut.cfg`.
|
||||
//! Missing or invalid configuration is a hard error; there is no loopback
|
||||
//! fallback. Both structured config and the legacy bare-host line are accepted
|
||||
//! by `openfut-common`.
|
||||
use openfut_common::{ConfigError, ServerConfig};
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleFileNameA;
|
||||
|
||||
pub fn read_redirect_ip(module: windows_sys::Win32::Foundation::HMODULE) -> String {
|
||||
if let Some(cfg_path) = config_path(module) {
|
||||
if let Ok(content) = std::fs::read_to_string(&cfg_path) {
|
||||
let ip = content.trim().to_string();
|
||||
if !ip.is_empty() {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
"127.0.0.1".to_string()
|
||||
pub fn load_config(
|
||||
module: windows_sys::Win32::Foundation::HMODULE,
|
||||
) -> Result<ServerConfig, ConfigError> {
|
||||
let cfg_path = config_path(module).ok_or(ConfigError::ConfigMissing)?;
|
||||
let content = std::fs::read_to_string(&cfg_path).map_err(|_| ConfigError::ConfigMissing)?;
|
||||
ServerConfig::parse(&content)
|
||||
}
|
||||
|
||||
fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option<std::path::PathBuf> {
|
||||
@@ -30,3 +26,29 @@ fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option<std::p
|
||||
let dll_path = std::path::Path::new(path);
|
||||
Some(dll_path.parent()?.join("openfut.cfg"))
|
||||
}
|
||||
|
||||
/// Read a raw feature-flag value (`key=value`) from `openfut.cfg` beside the DLL.
|
||||
///
|
||||
/// Returns the trimmed value, or `None` if the file or key is absent. This reads the
|
||||
/// SAME config file as [`load_config`] but does NOT go through the strict
|
||||
/// [`ServerConfig`] parser (which owns host/port validation and hard-errors on bad
|
||||
/// input) — optional client feature flags must never be able to break server config.
|
||||
pub fn feature_value(
|
||||
module: windows_sys::Win32::Foundation::HMODULE,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
let cfg_path = config_path(module)?;
|
||||
let content = std::fs::read_to_string(&cfg_path).ok()?;
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
if k.trim() == key {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/// Hooks ws2_32!connect via inline detour (no iptables needed).
|
||||
/// Uses unhook/rehook pattern: restores original bytes, calls real function, re-installs hook.
|
||||
/// This avoids trampoline RIP-relocation issues entirely.
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicU16, AtomicU32, 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_FIFA17_BLAZE_REDIRECTOR_NBO: u16 = 0xF6A4; // 42230 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
|
||||
@@ -18,7 +18,47 @@ const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
|
||||
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
|
||||
/// Redirect target for rewritten EA connects, stored in **network byte order**
|
||||
/// (same layout as `sockaddr_in.sin_addr`). Zero means unconfigured and causes
|
||||
/// redirect_if_ea to leave traffic untouched; there is no loopback fallback.
|
||||
static TARGET_ADDR_NBO: AtomicU32 = AtomicU32::new(0);
|
||||
static TARGET_HTTPS_PORT_NBO: AtomicU16 = AtomicU16::new(0);
|
||||
static TARGET_BLAZE_REDIRECTOR_PORT_NBO: AtomicU16 = AtomicU16::new(0);
|
||||
static TARGET_BLAZE_MAIN_PORT_NBO: AtomicU16 = AtomicU16::new(0);
|
||||
|
||||
/// Install the single resolved destination shared by every socket path.
|
||||
pub fn set_server(server: openfut_common::ResolvedServer) {
|
||||
TARGET_ADDR_NBO.store(
|
||||
openfut_common::sin_addr_from_ipv4(server.redirect_ip),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
TARGET_HTTPS_PORT_NBO.store(
|
||||
openfut_common::sin_port_nbo(server.ports.https),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
TARGET_BLAZE_REDIRECTOR_PORT_NBO.store(
|
||||
openfut_common::sin_port_nbo(server.ports.blaze_redirector),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
TARGET_BLAZE_MAIN_PORT_NBO.store(
|
||||
openfut_common::sin_port_nbo(server.ports.blaze_main),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Current redirect target in network byte order.
|
||||
fn target_addr_nbo() -> u32 {
|
||||
TARGET_ADDR_NBO.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Build the 16-byte IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) for the current
|
||||
/// target, so an AF_INET6 socket reaches the same host as the AF_INET path.
|
||||
fn target_v4mapped() -> [u8; 16] {
|
||||
let o = target_addr_nbo().to_ne_bytes(); // a.b.c.d in memory order
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, o[0], o[1], o[2], o[3],
|
||||
]
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct SockaddrIn {
|
||||
@@ -41,12 +81,8 @@ struct SockaddrIn6 {
|
||||
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];
|
||||
/// IPv4-mapped IPv6 loopback is no longer hardcoded — the v4-mapped target is
|
||||
/// derived from the configurable `TARGET_ADDR_NBO` via `target_v4mapped()`.
|
||||
|
||||
// Address of ws2_32!connect (set at hook installation)
|
||||
static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
@@ -110,30 +146,40 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u
|
||||
// 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,
|
||||
PORT_HTTPS_NBO => TARGET_HTTPS_PORT_NBO.load(Ordering::Relaxed),
|
||||
#[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,
|
||||
PORT_BLAZE_REDIRECTOR_NBO | PORT_FIFA17_BLAZE_REDIRECTOR_NBO => {
|
||||
TARGET_BLAZE_REDIRECTOR_PORT_NBO.load(Ordering::Relaxed)
|
||||
}
|
||||
PORT_BLAZE_MAIN_NBO => TARGET_BLAZE_MAIN_PORT_NBO.load(Ordering::Relaxed),
|
||||
_ => return None,
|
||||
};
|
||||
if new_port_nbo == 0 || target_addr_nbo() == 0 {
|
||||
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();
|
||||
let t = target_addr_nbo().to_ne_bytes();
|
||||
crate::write_log(&format!(
|
||||
"connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
|
||||
"connect_hook: v4 {}.{}.{}.{}:{} → {}.{}.{}.{}:{}\n",
|
||||
o[0],
|
||||
o[1],
|
||||
o[2],
|
||||
o[3],
|
||||
u16::from_be(sa.sin_port),
|
||||
t[0],
|
||||
t[1],
|
||||
t[2],
|
||||
t[3],
|
||||
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;
|
||||
out.sin_addr = target_addr_nbo();
|
||||
Some((buf, 16))
|
||||
}
|
||||
AF_INET6 => {
|
||||
@@ -144,11 +190,16 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u
|
||||
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,
|
||||
PORT_HTTPS_NBO => TARGET_HTTPS_PORT_NBO.load(Ordering::Relaxed),
|
||||
PORT_BLAZE_REDIRECTOR_NBO | PORT_FIFA17_BLAZE_REDIRECTOR_NBO => {
|
||||
TARGET_BLAZE_REDIRECTOR_PORT_NBO.load(Ordering::Relaxed)
|
||||
}
|
||||
PORT_BLAZE_MAIN_NBO => TARGET_BLAZE_MAIN_PORT_NBO.load(Ordering::Relaxed),
|
||||
_ => return None,
|
||||
};
|
||||
if new_port_nbo == 0 || target_addr_nbo() == 0 {
|
||||
return None;
|
||||
}
|
||||
let a = sa6.sin6_addr;
|
||||
crate::write_log(&format!(
|
||||
"connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n",
|
||||
@@ -164,7 +215,7 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u
|
||||
out.sin6_family = AF_INET6;
|
||||
out.sin6_port = new_port_nbo;
|
||||
out.sin6_flowinfo = 0;
|
||||
out.sin6_addr = V4MAPPED_LOOPBACK;
|
||||
out.sin6_addr = target_v4mapped();
|
||||
out.sin6_scope_id = 0;
|
||||
Some((buf, 28))
|
||||
}
|
||||
@@ -191,7 +242,7 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
let mut len: i32 = 4;
|
||||
getsockopt(
|
||||
s,
|
||||
SOL_SOCKET,
|
||||
SOL_SOCKET as i32,
|
||||
SO_TYPE,
|
||||
&mut ty as *mut i32 as *mut u8,
|
||||
&mut len,
|
||||
@@ -215,7 +266,17 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
core::mem::transmute(addr);
|
||||
f(s, buf.as_ptr(), len)
|
||||
};
|
||||
let wsa_error = if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
|
||||
WSAGetLastError()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
write_hook(addr, hooked_connect as *const () as u64);
|
||||
if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
|
||||
WSASetLastError(wsa_error);
|
||||
}
|
||||
return r;
|
||||
} else {
|
||||
(name, namelen)
|
||||
@@ -226,19 +287,23 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
|
||||
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr);
|
||||
f(s, call_name, call_len)
|
||||
};
|
||||
let wsa_error = if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
|
||||
WSAGetLastError()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
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
|
||||
};
|
||||
crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n"));
|
||||
crate::write_log(&format!("connect_hook: result={r} wsa_err={wsa_error}\n"));
|
||||
}
|
||||
}
|
||||
if r != 0 {
|
||||
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
|
||||
WSASetLastError(wsa_error);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
@@ -265,11 +330,11 @@ 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(c"ws2_32.dll".as_ptr().cast());
|
||||
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr());
|
||||
if ws2.is_null() {
|
||||
return false;
|
||||
}
|
||||
let connect_fn = match GetProcAddress(ws2, c"connect".as_ptr().cast()) {
|
||||
let connect_fn = match GetProcAddress(ws2, b"connect\0".as_ptr()) {
|
||||
Some(f) => f as *mut u8,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
@@ -152,11 +152,11 @@ 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(c"ws2_32.dll".as_ptr().cast());
|
||||
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr());
|
||||
if ws2.is_null() {
|
||||
return false;
|
||||
}
|
||||
let fn_ptr = match GetProcAddress(ws2, c"WSAIoctl".as_ptr().cast()) {
|
||||
let fn_ptr = match GetProcAddress(ws2, b"WSAIoctl\0".as_ptr()) {
|
||||
Some(f) => f as *mut u8,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
+104
-11
@@ -2,13 +2,19 @@
|
||||
//!
|
||||
//! 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).
|
||||
//! of the FIFA-23 memory-layout-specific logic here (origin_spy, LSX dial, event
|
||||
//! deserializer probes) — that would at best no-op and at worst crash.
|
||||
//!
|
||||
//! Everything here is read-only except the (not-yet-enabled) cert-verify patch.
|
||||
//! What it DOES do:
|
||||
//! 1. Prove the version.dll hijack loads us into FIFA17.exe (module dump).
|
||||
//! 2. Install the *generic*, memory-layout-independent network redirect:
|
||||
//! ws2_32 `getaddrinfo` (EA host → configured server) and an inline
|
||||
//! `connect` / `WSAConnect` detour (EA ports → bridge, dest → configured
|
||||
//! server IP). These key on hostnames/ports only, not on FIFA-23 offsets,
|
||||
//! so they are safe to reuse on FIFA 17.
|
||||
//!
|
||||
//! Not yet done (next milestone): DirtySDK/ProtoSSL cert-verify patch for the
|
||||
//! secure Blaze handshake. The module dump locates the DLL that needs it.
|
||||
|
||||
use crate::write_log;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
@@ -70,7 +76,7 @@ unsafe fn dump_modules() {
|
||||
/// 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 {
|
||||
unsafe extern "system" fn worker(param: *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);
|
||||
@@ -78,25 +84,112 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
||||
"fifa17: main exe base={main_base:#018x} SizeOfImage={img:#x}\n"
|
||||
));
|
||||
dump_modules();
|
||||
|
||||
// Install the generic network redirect. `param` carries our own DLL's
|
||||
// HMODULE so config::load_config can find openfut.cfg beside the DLL.
|
||||
let dll_module = param as windows_sys::Win32::Foundation::HMODULE;
|
||||
let server = match crate::config::load_config(dll_module).and_then(|c| c.resolve()) {
|
||||
Ok(server) => server,
|
||||
Err(e) => {
|
||||
write_log(&format!(
|
||||
"fifa17: invalid/missing openfut.cfg ({e}); network redirect DISABLED\n"
|
||||
));
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
write_log(&format!(
|
||||
"fifa17: OpenFUT server={} https={} blaze_redir={} blaze_main={}\n",
|
||||
server.redirect_ip,
|
||||
server.ports.https,
|
||||
server.ports.blaze_redirector,
|
||||
server.ports.blaze_main
|
||||
));
|
||||
install_network_redirect(server);
|
||||
|
||||
write_log("fifa17: worker complete (injection healthy)\n");
|
||||
// Every SBC detour is deferred and inert unless its exact environment gate is `1`.
|
||||
// SBC render intervention (inert unless OPENFUT_SBC_HOOK=1). Spawns its own deferred
|
||||
// worker that waits for CardsDLL to load. See sbc_hook.rs / docs/sbc-hook-dll-spec.md.
|
||||
crate::sbc_hook::install();
|
||||
// Passive transaction tracing has a separate kill switch from cache resolution.
|
||||
// It currently fails closed until safe relocating trampolines are proven.
|
||||
crate::sbc_trace::install();
|
||||
crate::sbc_dispatch::install();
|
||||
crate::sbc_request_trace::install();
|
||||
// Empty-My-Packs Store fix (inert unless store_mypacks_fix=1 in openfut.cfg).
|
||||
crate::store_hook::install(dll_module);
|
||||
0
|
||||
}
|
||||
|
||||
/// Install the generic network redirect (getaddrinfo + connect + WSAConnect).
|
||||
///
|
||||
/// `server` is the resolved host + configured destination ports from openfut.cfg.
|
||||
/// two independent mechanisms, both keyed only on EA hostnames/ports (no
|
||||
/// FIFA-version-specific memory layout):
|
||||
/// - getaddrinfo: EA hostnames resolve to `redirect_ip`.
|
||||
/// - connect/WSAConnect: EA source ports are remapped to the bridge ports and
|
||||
/// the destination address is rewritten to `redirect_ip`.
|
||||
///
|
||||
/// If `redirect_ip` parses as an IPv4 literal, the connect detour rewrites the
|
||||
/// destination directly (no DNS). When it is a hostname, getaddrinfo already
|
||||
/// resolves it, and the connect detour falls back to leaving the resolved
|
||||
/// address in place (only remapping the port).
|
||||
unsafe fn install_network_redirect(server: openfut_common::ResolvedServer) {
|
||||
let redirect_ip = server.redirect_ip.to_string();
|
||||
// Resolver redirect: EA hostnames → configured server. Uses INLINE detours at
|
||||
// the ws2_32 export addresses (getaddrinfo / GetAddrInfoW / gethostbyname),
|
||||
// not IAT patching — the IAT approach patched 0 slots on FIFA 17 because the
|
||||
// game doesn't import the resolver through its import table.
|
||||
crate::hooks::set_redirect_ip(redirect_ip.clone());
|
||||
let (ok, total) = crate::resolver_hook::install_resolver_hooks();
|
||||
write_log(&format!(
|
||||
"fifa17: resolver detours {ok}/{total} installed\n"
|
||||
));
|
||||
|
||||
crate::connect_hook::set_server(server);
|
||||
write_log(&format!(
|
||||
"fifa17: connect target set to {} (https={} blaze_redir={} blaze_main={})\n",
|
||||
server.redirect_ip,
|
||||
server.ports.https,
|
||||
server.ports.blaze_redirector,
|
||||
server.ports.blaze_main
|
||||
));
|
||||
|
||||
// Inline connect detour (port remap + destination rewrite).
|
||||
if crate::connect_hook::install_inline_connect_hook() {
|
||||
write_log("fifa17: connect inline-hooked\n");
|
||||
} else {
|
||||
write_log("fifa17: connect hook FAILED\n");
|
||||
}
|
||||
|
||||
// WSAConnect IAT fallback (some EA paths use WSAConnect instead of connect).
|
||||
let wp = crate::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 = core::mem::transmute(wp);
|
||||
crate::connect_hook::set_real_wsa_connect(f);
|
||||
crate::iat::patch_iat(wp, crate::connect_hook::hooked_wsa_connect as *const ());
|
||||
write_log("fifa17: WSAConnect IAT patched\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
/// `module` is our own DLL's HMODULE, passed to the worker so it can locate
|
||||
/// openfut.cfg beside the DLL.
|
||||
pub unsafe fn install(module: windows_sys::Win32::Foundation::HMODULE) {
|
||||
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(),
|
||||
module as *const core::ffi::c_void,
|
||||
0,
|
||||
core::ptr::null_mut(),
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ type GetaddrinfoFn =
|
||||
|
||||
static REAL: OnceLock<GetaddrinfoFn> = OnceLock::new();
|
||||
static REDIRECT_IP: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
static REDIRECT_IP_STR: OnceLock<String> = OnceLock::new();
|
||||
|
||||
// Flipped to true the first time we successfully apply the runtime cert patch.
|
||||
// The patch is deferred to here (rather than DllMain) because EAWebKit.dll may
|
||||
@@ -24,9 +25,21 @@ pub fn set_real(f: GetaddrinfoFn) {
|
||||
}
|
||||
|
||||
pub fn set_redirect_ip(ip: String) {
|
||||
let mut bytes = ip.into_bytes();
|
||||
let mut bytes = ip.clone().into_bytes();
|
||||
bytes.push(0);
|
||||
let _ = REDIRECT_IP.set(bytes);
|
||||
let _ = REDIRECT_IP_STR.set(ip);
|
||||
}
|
||||
|
||||
/// The redirect IP as a NUL-terminated C string pointer, or None if unset.
|
||||
/// Used by the resolver detours to rewrite an EA query's node name.
|
||||
pub fn redirect_ip_cstr() -> Option<*const u8> {
|
||||
REDIRECT_IP.get().map(|v| v.as_ptr())
|
||||
}
|
||||
|
||||
/// The redirect IP as a Rust &str, or None if unset (for the wide/UTF-16 path).
|
||||
pub fn redirect_ip_str() -> Option<&'static str> {
|
||||
REDIRECT_IP_STR.get().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Returns true if `host` is an EA / EA-Sports domain that should be redirected
|
||||
@@ -68,12 +81,13 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
|
||||
}
|
||||
}
|
||||
|
||||
let redirect = REDIRECT_IP
|
||||
.get()
|
||||
.map(|v| v.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);
|
||||
if let Some(redirect) = REDIRECT_IP.get() {
|
||||
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
|
||||
return real(redirect.as_ptr(), service_name, hints, result);
|
||||
}
|
||||
crate::write_log(
|
||||
"openfut_hook: EA hostname seen without configured server; not redirecting\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-22
@@ -1,12 +1,3 @@
|
||||
// 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;
|
||||
@@ -20,14 +11,15 @@ mod origin_spy;
|
||||
mod probe;
|
||||
#[cfg(feature = "capture_baseline")]
|
||||
mod recv_hook;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_dispatch;
|
||||
mod resolver_hook;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_hook;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_request_trace;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod sbc_trace;
|
||||
#[cfg(feature = "fifa17")]
|
||||
mod store_hook;
|
||||
mod ssl_patch;
|
||||
mod tls_bypass;
|
||||
mod transport_watch;
|
||||
@@ -65,13 +57,6 @@ pub(crate) fn flush_log() {
|
||||
}
|
||||
}
|
||||
|
||||
/// # 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 {
|
||||
@@ -89,8 +74,8 @@ unsafe fn install_hooks(module: HMODULE) {
|
||||
// FIFA-23-specific hook below (they assume FIFA 23's memory layout).
|
||||
#[cfg(feature = "fifa17")]
|
||||
{
|
||||
let _ = module;
|
||||
fifa17::install();
|
||||
fifa17::install(module);
|
||||
return;
|
||||
}
|
||||
#[cfg(not(feature = "fifa17"))]
|
||||
install_hooks_fifa23(module)
|
||||
@@ -102,8 +87,17 @@ unsafe fn install_hooks_fifa23(module: HMODULE) {
|
||||
// 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);
|
||||
match config::load_config(module).and_then(|c| c.resolve()) {
|
||||
Ok(server) => {
|
||||
hooks::set_redirect_ip(server.redirect_ip.to_string());
|
||||
connect_hook::set_server(server);
|
||||
}
|
||||
Err(e) => {
|
||||
write_log(&format!(
|
||||
"openfut_hook: invalid/missing openfut.cfg ({e}); redirection DISABLED\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0");
|
||||
if !ga.is_null() {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! DNS-resolver inline detours (getaddrinfo / GetAddrInfoW / gethostbyname).
|
||||
//!
|
||||
//! WHY THIS EXISTS (FIFA 17): the IAT approach in `hooks.rs` patched **0** slots on
|
||||
//! FIFA 17 (`getaddrinfo IAT patched 0+0`) because the game does not import the
|
||||
//! resolver through its import table — it resolves EA hostnames via a path the IAT
|
||||
//! scan never covers (dynamic `GetProcAddress`, a statically-linked DirtySDK
|
||||
//! resolver, or the legacy `gethostbyname`). An IAT patch can only rewrite callers
|
||||
//! that go through the table, so it missed every real resolution.
|
||||
//!
|
||||
//! FIX: detour the resolver **at its export address** in ws2_32.dll, exactly like
|
||||
//! `connect_hook` does for `connect`. An inline JMP at the function entry catches
|
||||
//! *every* caller regardless of how it found the function. We use the same
|
||||
//! unhook → call real → rehook pattern (no trampoline, no RIP relocation).
|
||||
//!
|
||||
//! We cover three resolvers:
|
||||
//! - `getaddrinfo` (ANSI, modern)
|
||||
//! - `GetAddrInfoW` (wide, modern) — EAWebKit/WinHTTP often use the W variant
|
||||
//! - `gethostbyname` (legacy, DirtySDK-era) — returns a `hostent`
|
||||
//!
|
||||
//! On an EA hostname we rewrite the query node to the configured redirect IP so the
|
||||
//! real resolver returns the bridge's address. The redirect IP string is owned by
|
||||
//! `hooks` (set once via `hooks::set_redirect_ip`); we read it back through
|
||||
//! `hooks::redirect_ip_cstr()`.
|
||||
|
||||
use std::ffi::CStr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use windows_sys::Win32::Networking::WinSock::ADDRINFOA;
|
||||
|
||||
// ── EA host classifier (shared logic mirrors hooks::is_ea_host) ────────────────
|
||||
|
||||
fn is_ea_host(host: &str) -> bool {
|
||||
let h = host.to_ascii_lowercase();
|
||||
h.ends_with(".ea.com")
|
||||
|| h == "ea.com"
|
||||
|| h.ends_with(".easports.com")
|
||||
|| h == "easports.com"
|
||||
|| h.ends_with(".ugc.footapi.com")
|
||||
|| h.ends_with(".footapi.com")
|
||||
|| h.ends_with(".dice.se")
|
||||
}
|
||||
|
||||
// ── getaddrinfo (ANSI) ────────────────────────────────────────────────────────
|
||||
|
||||
type GetaddrinfoFn =
|
||||
unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32;
|
||||
|
||||
static GAI_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
static mut GAI_ORIG: [u8; 14] = [0u8; 14];
|
||||
|
||||
// ── GetAddrInfoW (wide) ───────────────────────────────────────────────────────
|
||||
|
||||
type GetAddrInfoWFn = unsafe extern "system" fn(
|
||||
*const u16,
|
||||
*const u16,
|
||||
*const core::ffi::c_void,
|
||||
*mut *mut core::ffi::c_void,
|
||||
) -> i32;
|
||||
|
||||
static GAIW_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
static mut GAIW_ORIG: [u8; 14] = [0u8; 14];
|
||||
|
||||
// ── gethostbyname (legacy) ────────────────────────────────────────────────────
|
||||
|
||||
type GethostbynameFn = unsafe extern "system" fn(*const u8) -> *mut core::ffi::c_void;
|
||||
|
||||
static GHBN_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
static mut GHBN_ORIG: [u8; 14] = [0u8; 14];
|
||||
|
||||
// ── inline-hook primitives (identical pattern to connect_hook) ────────────────
|
||||
|
||||
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] ; then 8-byte absolute target
|
||||
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);
|
||||
}
|
||||
|
||||
unsafe fn restore(target: *mut u8, orig: *const 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(orig, target, 14);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
}
|
||||
|
||||
/// Save the first 14 bytes at `addr` into `orig`, store `addr`, and write the JMP.
|
||||
unsafe fn install_one(addr: *mut u8, orig: *mut u8, slot: &AtomicUsize, hook: *const ()) -> bool {
|
||||
if addr.is_null() {
|
||||
return false;
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(addr, orig, 14);
|
||||
slot.store(addr as usize, Ordering::Relaxed);
|
||||
write_hook(addr, hook as u64);
|
||||
true
|
||||
}
|
||||
|
||||
// ── hooked entry points ───────────────────────────────────────────────────────
|
||||
|
||||
pub unsafe extern "system" fn hooked_getaddrinfo(
|
||||
node: *const u8,
|
||||
service: *const u8,
|
||||
hints: *const ADDRINFOA,
|
||||
result: *mut *mut ADDRINFOA,
|
||||
) -> i32 {
|
||||
let addr = GAI_ADDR.load(Ordering::Relaxed) as *mut u8;
|
||||
let mut redirected = node;
|
||||
let redirect_cstr = crate::hooks::redirect_ip_cstr();
|
||||
|
||||
if !node.is_null() {
|
||||
if let Ok(host) = CStr::from_ptr(node as *const i8).to_str() {
|
||||
crate::write_log(&format!("resolver: getaddrinfo({host})\n"));
|
||||
if is_ea_host(host) {
|
||||
if let Some(ip) = redirect_cstr {
|
||||
redirected = ip;
|
||||
crate::write_log(&format!("resolver: getaddrinfo {host} → redirect\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restore(addr, core::ptr::addr_of!(GAI_ORIG) as *const u8);
|
||||
let r = {
|
||||
let f: GetaddrinfoFn = core::mem::transmute(addr);
|
||||
f(redirected, service, hints, result)
|
||||
};
|
||||
write_hook(addr, hooked_getaddrinfo as *const () as u64);
|
||||
r
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_getaddrinfo_w(
|
||||
node: *const u16,
|
||||
service: *const u16,
|
||||
hints: *const core::ffi::c_void,
|
||||
result: *mut *mut core::ffi::c_void,
|
||||
) -> i32 {
|
||||
let addr = GAIW_ADDR.load(Ordering::Relaxed) as *mut u8;
|
||||
|
||||
// Decode the wide hostname for classification/logging.
|
||||
let mut redirected_buf: Vec<u16> = Vec::new();
|
||||
let mut redirected = node;
|
||||
if !node.is_null() {
|
||||
let mut len = 0usize;
|
||||
while *node.add(len) != 0 {
|
||||
len += 1;
|
||||
}
|
||||
let host = String::from_utf16_lossy(core::slice::from_raw_parts(node, len));
|
||||
crate::write_log(&format!("resolver: GetAddrInfoW({host})\n"));
|
||||
if is_ea_host(&host) {
|
||||
if let Some(ip) = crate::hooks::redirect_ip_str() {
|
||||
redirected_buf = ip.encode_utf16().chain(core::iter::once(0)).collect();
|
||||
redirected = redirected_buf.as_ptr();
|
||||
crate::write_log(&format!("resolver: GetAddrInfoW {host} → redirect\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restore(addr, core::ptr::addr_of!(GAIW_ORIG) as *const u8);
|
||||
let r = {
|
||||
let f: GetAddrInfoWFn = core::mem::transmute(addr);
|
||||
f(redirected, service, hints, result)
|
||||
};
|
||||
write_hook(addr, hooked_getaddrinfo_w as *const () as u64);
|
||||
// keep redirected_buf alive until after the call
|
||||
drop(redirected_buf);
|
||||
r
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_gethostbyname(name: *const u8) -> *mut core::ffi::c_void {
|
||||
let addr = GHBN_ADDR.load(Ordering::Relaxed) as *mut u8;
|
||||
let mut redirected = name;
|
||||
let redirect_cstr = crate::hooks::redirect_ip_cstr();
|
||||
|
||||
if !name.is_null() {
|
||||
if let Ok(host) = CStr::from_ptr(name as *const i8).to_str() {
|
||||
crate::write_log(&format!("resolver: gethostbyname({host})\n"));
|
||||
if is_ea_host(host) {
|
||||
if let Some(ip) = redirect_cstr {
|
||||
redirected = ip;
|
||||
crate::write_log(&format!("resolver: gethostbyname {host} → redirect\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
restore(addr, core::ptr::addr_of!(GHBN_ORIG) as *const u8);
|
||||
let r = {
|
||||
let f: GethostbynameFn = core::mem::transmute(addr);
|
||||
f(redirected)
|
||||
};
|
||||
write_hook(addr, hooked_gethostbyname as *const () as u64);
|
||||
r
|
||||
}
|
||||
|
||||
// ── installer ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Install inline detours on all three resolvers. Returns a (ok, total) count for
|
||||
/// logging. Safe to call once from the fifa17 worker after ws2_32 is loaded.
|
||||
pub unsafe fn install_resolver_hooks() -> (u32, u32) {
|
||||
let mut ok = 0u32;
|
||||
let total = 3u32;
|
||||
|
||||
let gai = crate::iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0") as *mut u8;
|
||||
if install_one(
|
||||
gai,
|
||||
core::ptr::addr_of_mut!(GAI_ORIG) as *mut u8,
|
||||
&GAI_ADDR,
|
||||
hooked_getaddrinfo as *const (),
|
||||
) {
|
||||
ok += 1;
|
||||
crate::write_log("resolver: getaddrinfo inline-hooked\n");
|
||||
} else {
|
||||
crate::write_log("resolver: getaddrinfo resolve FAILED\n");
|
||||
}
|
||||
|
||||
let gaiw = crate::iat::resolve(b"ws2_32.dll\0", b"GetAddrInfoW\0") as *mut u8;
|
||||
if install_one(
|
||||
gaiw,
|
||||
core::ptr::addr_of_mut!(GAIW_ORIG) as *mut u8,
|
||||
&GAIW_ADDR,
|
||||
hooked_getaddrinfo_w as *const (),
|
||||
) {
|
||||
ok += 1;
|
||||
crate::write_log("resolver: GetAddrInfoW inline-hooked\n");
|
||||
} else {
|
||||
crate::write_log("resolver: GetAddrInfoW resolve FAILED\n");
|
||||
}
|
||||
|
||||
let ghbn = crate::iat::resolve(b"ws2_32.dll\0", b"gethostbyname\0") as *mut u8;
|
||||
if install_one(
|
||||
ghbn,
|
||||
core::ptr::addr_of_mut!(GHBN_ORIG) as *mut u8,
|
||||
&GHBN_ADDR,
|
||||
hooked_gethostbyname as *const (),
|
||||
) {
|
||||
ok += 1;
|
||||
crate::write_log("resolver: gethostbyname inline-hooked\n");
|
||||
} else {
|
||||
crate::write_log("resolver: gethostbyname resolve FAILED\n");
|
||||
}
|
||||
|
||||
(ok, total)
|
||||
}
|
||||
@@ -1,824 +0,0 @@
|
||||
//! Guarded FIFA 17 SBC completion dispatch and passive event tracing.
|
||||
//!
|
||||
//! `OPENFUT_SBC_DISPATCH=1` permits one narrowly-scoped repair per native
|
||||
//! deserializer generation. The default and `OPENFUT_SBC_DISPATCH_TRACE=1` paths
|
||||
//! are behavior-preserving.
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
if input.notifier_entries != generation
|
||||
|| input.notifier_entries == 0
|
||||
|| input.notifier_exits.checked_add(1) != Some(input.notifier_entries)
|
||||
{
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
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() {
|
||||
let repair =
|
||||
crate::sbc_trace::env_enabled(std::env::var("OPENFUT_SBC_DISPATCH").ok().as_deref());
|
||||
let trace = repair
|
||||
|| crate::sbc_trace::env_enabled(
|
||||
std::env::var("OPENFUT_SBC_DISPATCH_TRACE").ok().as_deref(),
|
||||
);
|
||||
REPAIR_ENABLED.store(repair, Ordering::Release);
|
||||
if !trace {
|
||||
crate::write_log("SBC_DISPATCH: disabled\n");
|
||||
return;
|
||||
}
|
||||
crate::write_log(if repair {
|
||||
"SBC_DISPATCH: repair ARMED; strict native evidence gate enabled\n"
|
||||
} else {
|
||||
"SBC_DISPATCH: passive trace requested; repair disabled\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 - 1,
|
||||
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));
|
||||
|
||||
let mut input = valid_input(1);
|
||||
input.notifier_exits = 1;
|
||||
assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent));
|
||||
|
||||
let mut input = valid_input(1);
|
||||
input.controller_model_matches = false;
|
||||
assert_eq!(decide(input), Err(Rejection::ControllerModelMismatch));
|
||||
|
||||
let mut input = valid_input(1);
|
||||
input.live_category_count = 0;
|
||||
assert_eq!(decide(input), Err(Rejection::ModelChanged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_generation_is_one_shot_but_next_lifecycle_is_allowed() {
|
||||
let mut duplicate = valid_input(1);
|
||||
duplicate.last_repaired_generation = 1;
|
||||
assert_eq!(decide(duplicate), Err(Rejection::DuplicateGeneration));
|
||||
|
||||
let next = valid_input(2);
|
||||
assert_eq!(decide(next), Ok(Decision::Repair));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_class_requires_exact_pinned_vtable() {
|
||||
let base = 0x1_8000_0000usize;
|
||||
let expected = base + CATEGORY_RESPONSE_VTABLE_RVA;
|
||||
assert!(response_class_matches(base, expected));
|
||||
// An unreadable capture (zero) never qualifies.
|
||||
assert!(!response_class_matches(base, 0));
|
||||
// Any other vtable (e.g. a sub-object or a freed/reused slot) is rejected.
|
||||
assert!(!response_class_matches(base, expected + 8));
|
||||
assert!(!response_class_matches(base, base));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relocated_completion_branch_has_proven_layout() {
|
||||
assert_eq!(COMPLETION_COPY_LEN, 14);
|
||||
assert_eq!(
|
||||
&COMPLETION_SIGNATURE[..12],
|
||||
&[0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2]
|
||||
);
|
||||
assert_eq!(&COMPLETION_SIGNATURE[12..14], &[0x74, 0x4e]);
|
||||
assert_eq!(COMPLETION_TRAMPOLINE_LEN, 42);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@
|
||||
//! (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:
|
||||
//! Everything here is **inert by default** and gated by env vars, so shipping the DLL
|
||||
//! with this module compiled in changes nothing unless a var is set:
|
||||
//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY)
|
||||
//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY)
|
||||
//! OPENFUT_SBC_COMMIT=1 -> after proven native parse success, arm populated M
|
||||
//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns)
|
||||
//!
|
||||
//! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we
|
||||
@@ -18,11 +20,14 @@
|
||||
//! See the spec for the verified disassembly behind each one.
|
||||
|
||||
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::{
|
||||
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE,
|
||||
PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY,
|
||||
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ,
|
||||
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE,
|
||||
PAGE_WRITECOPY,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
|
||||
|
||||
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
|
||||
const IMAGE_BASE: usize = 0x180000000;
|
||||
@@ -41,6 +46,13 @@ 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 SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
|
||||
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
|
||||
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
|
||||
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
|
||||
const SBC_COMPLETION_STATUS_JNE_RVA: usize = 0x0b8962;
|
||||
const SBC_COMPLETION_STATUS_JNE: [u8; 2] = [0x75, 0x48];
|
||||
const SBC_COMPLETION_STATUS_FALLTHROUGH: [u8; 2] = [0x90, 0x90];
|
||||
const B_DTOR_RVA: usize = 0x63040;
|
||||
const B_ISVALID_RVA: usize = 0x65d40;
|
||||
const B_CLEAR_RVA: usize = 0x65d20;
|
||||
@@ -75,9 +87,11 @@ mod rva {
|
||||
|
||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
||||
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
|
||||
static COMMIT: AtomicBool = AtomicBool::new(false);
|
||||
static POPULATE: AtomicBool = AtomicBool::new(false);
|
||||
static DONE: AtomicBool = AtomicBool::new(false);
|
||||
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
|
||||
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -133,6 +147,13 @@ enum ValidationError {
|
||||
CollectionUnreadable,
|
||||
CollectionNotNull,
|
||||
ReadyByteNotWritable,
|
||||
ModelEmpty,
|
||||
ControllerMissing,
|
||||
ControllerVtableMismatch,
|
||||
ControllerModelMismatch,
|
||||
CompletionBranchMismatch,
|
||||
CompletionBranchProtectFailed,
|
||||
CompletionBranchFlushFailed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -292,12 +313,12 @@ unsafe fn read_u16(ptr: usize) -> Option<u16> {
|
||||
/// 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());
|
||||
let h = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr());
|
||||
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());
|
||||
let h2 = GetModuleHandleA(b"CardsDLL.dll\0".as_ptr());
|
||||
if !h2.is_null() {
|
||||
return h2 as usize;
|
||||
}
|
||||
@@ -407,6 +428,12 @@ pub fn install() {
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
COMMIT.store(
|
||||
std::env::var("OPENFUT_SBC_COMMIT")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
POPULATE.store(
|
||||
std::env::var("OPENFUT_SBC_POPULATE")
|
||||
.map(|v| v == "1")
|
||||
@@ -417,6 +444,192 @@ pub fn install() {
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
/// Records the concrete SBC controller observed registering FUT_SBS_CATEGORIES.
|
||||
/// The registration hook is observational; all structural checks happen again on
|
||||
/// the notifier thread before this address is trusted.
|
||||
pub(crate) unsafe fn note_sbc_controller(controller: usize) {
|
||||
let base = CARDS_BASE.load(Ordering::Acquire);
|
||||
let valid = base != 0
|
||||
&& read_ptr(controller) == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
||||
&& controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
== base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA);
|
||||
if valid {
|
||||
SBC_CONTROLLER.store(controller, Ordering::Release);
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: captured controller={controller:#x}\n"
|
||||
));
|
||||
} else {
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: rejected controller={controller:#x} (vtable mismatch)\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn log_controller_model(native_model: usize) {
|
||||
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
||||
let controller_model = controller
|
||||
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
.unwrap_or(0);
|
||||
let main_vtable = read_ptr(controller).unwrap_or(0);
|
||||
let event_vtable = controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
.unwrap_or(0);
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: notifier controller={controller:#x} main_vt={main_vtable:#x} event_vt={event_vtable:#x} controller_M={controller_model:#x} parsed_M={native_model:#x} match={}\n",
|
||||
controller != 0 && controller_model == native_model,
|
||||
));
|
||||
}
|
||||
|
||||
unsafe fn validated_sbc_controller(
|
||||
base: usize,
|
||||
native_model: usize,
|
||||
) -> Result<usize, ValidationError> {
|
||||
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
||||
if controller == 0 {
|
||||
return Err(ValidationError::ControllerMissing);
|
||||
}
|
||||
if read_ptr(controller) != base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
||||
|| controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
!= base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA)
|
||||
{
|
||||
return Err(ValidationError::ControllerVtableMismatch);
|
||||
}
|
||||
if controller
|
||||
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
!= Some(native_model)
|
||||
{
|
||||
return Err(ValidationError::ControllerModelMismatch);
|
||||
}
|
||||
Ok(controller)
|
||||
}
|
||||
|
||||
/// Route the already-scheduled category completion through CardsDLL's own success
|
||||
/// branch. The original function first rejects a non-zero status with a two-byte
|
||||
/// `jne ServerErrSets`; after a separately proven native parse, that status belongs
|
||||
/// to the stale scheduler completion rather than the category HTTP transaction.
|
||||
unsafe fn arm_native_completion_success(base: usize) -> Result<(), ValidationError> {
|
||||
let target = base
|
||||
.checked_add(SBC_COMPLETION_STATUS_JNE_RVA)
|
||||
.ok_or(ValidationError::AddressOverflow)?;
|
||||
if !executable_range(target, SBC_COMPLETION_STATUS_JNE.len())
|
||||
|| core::slice::from_raw_parts(target as *const u8, SBC_COMPLETION_STATUS_JNE.len())
|
||||
!= SBC_COMPLETION_STATUS_JNE
|
||||
{
|
||||
return Err(ValidationError::CompletionBranchMismatch);
|
||||
}
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old,
|
||||
) == 0
|
||||
{
|
||||
return Err(ValidationError::CompletionBranchProtectFailed);
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.as_ptr(),
|
||||
target as *mut u8,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
);
|
||||
let flushed = FlushInstructionCache(
|
||||
GetCurrentProcess(),
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
) != 0;
|
||||
let mut ignored = 0u32;
|
||||
let protected = VirtualProtect(
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
old,
|
||||
&mut ignored,
|
||||
) != 0;
|
||||
if !flushed || !protected {
|
||||
return Err(ValidationError::CompletionBranchFlushFailed);
|
||||
}
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: armed native completion success branch at {target:#x} tid={}\n",
|
||||
GetCurrentThreadId(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit the already-populated native SBC model after the category success notifier.
|
||||
///
|
||||
/// This is called synchronously by the passive notifier wrapper *after* the original
|
||||
/// notifier returns. It never invokes a parser or constructs game objects. The only
|
||||
/// mutation is the established cache-ready byte, and only when the normal parser has
|
||||
/// produced at least one category and every pointer/vtable invariant still matches.
|
||||
pub(crate) unsafe fn commit_after_native_parse() {
|
||||
if !COMMIT.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let base = CARDS_BASE.load(Ordering::Acquire);
|
||||
if base == 0 || !control_matches(base) {
|
||||
set_failed(ValidationError::AUnreadable);
|
||||
return;
|
||||
}
|
||||
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
|
||||
validate_snapshot(base, &snapshot)?;
|
||||
if snapshot.m == 0
|
||||
|| read_u16(snapshot.m + M_COUNT_OFF)
|
||||
.filter(|&count| count > 0)
|
||||
.is_none()
|
||||
{
|
||||
return Err(ValidationError::ModelEmpty);
|
||||
}
|
||||
if !writable_u8(snapshot.b + B_READY_OFF) {
|
||||
return Err(ValidationError::ReadyByteNotWritable);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let count = read_u16(snapshot.m + M_COUNT_OFF).unwrap_or(0);
|
||||
log_controller_model(snapshot.m);
|
||||
if DONE.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: post-parse commit -> M={:#x} categories={} BYTE[{:#x}]=1\n",
|
||||
snapshot.m,
|
||||
count,
|
||||
snapshot.b + B_READY_OFF,
|
||||
));
|
||||
core::ptr::write_volatile((snapshot.b + B_READY_OFF) as *mut u8, 1);
|
||||
if read_u8(snapshot.b + B_READY_OFF) != Some(1)
|
||||
|| !transition(RuntimeState::Validated, RuntimeState::Committed)
|
||||
{
|
||||
set_failed(ValidationError::ReadyByteUnexpected);
|
||||
return;
|
||||
}
|
||||
let _controller = match validated_sbc_controller(base, snapshot.m) {
|
||||
Ok(controller) => controller,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) = arm_native_completion_success(base) {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
crate::write_log(
|
||||
"SBC_HOOK: post-parse commit DONE; awaiting CardsDLL native completion events\n",
|
||||
);
|
||||
}
|
||||
|
||||
/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when
|
||||
/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm)
|
||||
/// exactly once.
|
||||
|
||||
@@ -367,7 +367,7 @@ unsafe fn worker() {
|
||||
}
|
||||
let mut base = 0usize;
|
||||
for _ in 0..600u32 {
|
||||
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
||||
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
|
||||
if base != 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,6 @@ static TRACE_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0);
|
||||
static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static DESERIALIZER_EXIT_RESPONSE_VTABLE: AtomicUsize = AtomicUsize::new(0);
|
||||
static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
static CONTROLLER_REGISTER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
||||
static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
||||
@@ -94,7 +93,7 @@ static NOTIFIER_COUNT: AtomicUsize = AtomicUsize::new(usize::MAX);
|
||||
static PATCH_INSTALLER_BUSY: AtomicBool = AtomicBool::new(false);
|
||||
static CODE_PATCH_PENDING: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub(crate) struct PatchInstallerGate;
|
||||
struct PatchInstallerGate;
|
||||
|
||||
impl Drop for PatchInstallerGate {
|
||||
fn drop(&mut self) {
|
||||
@@ -102,7 +101,7 @@ impl Drop for PatchInstallerGate {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
|
||||
fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
|
||||
for _ in 0..200 {
|
||||
if PATCH_INSTALLER_BUSY
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
@@ -115,7 +114,7 @@ pub(crate) fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) struct CodeInstallerPending;
|
||||
struct CodeInstallerPending;
|
||||
|
||||
impl Drop for CodeInstallerPending {
|
||||
fn drop(&mut self) {
|
||||
@@ -139,15 +138,15 @@ enum TraceState {
|
||||
DegradedHookAndProcess,
|
||||
}
|
||||
|
||||
pub(crate) fn env_enabled(value: Option<&str>) -> bool {
|
||||
fn env_enabled(value: Option<&str>) -> bool {
|
||||
matches!(value, Some("1"))
|
||||
}
|
||||
|
||||
pub(crate) fn target_va(base: usize, rva: usize) -> Option<usize> {
|
||||
fn target_va(base: usize, rva: usize) -> Option<usize> {
|
||||
base.checked_add(rva)
|
||||
}
|
||||
|
||||
pub(crate) fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] {
|
||||
fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] {
|
||||
let mut jump = [0u8; ABS_JUMP_LEN];
|
||||
jump[..6].copy_from_slice(&[0xff, 0x25, 0, 0, 0, 0]);
|
||||
jump[6..].copy_from_slice(&(destination as u64).to_le_bytes());
|
||||
@@ -161,7 +160,7 @@ fn instruction_pointer_in_span(rip: usize, target: usize) -> bool {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub(crate) struct SuspendedPeers {
|
||||
struct SuspendedPeers {
|
||||
handles: [HANDLE; MAX_PEERS],
|
||||
tids: [u32; MAX_PEERS],
|
||||
count: usize,
|
||||
@@ -180,7 +179,7 @@ impl SuspendedPeers {
|
||||
self.tids[..self.count].contains(&tid)
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn resume_all(&mut self) -> bool {
|
||||
unsafe fn resume_all(&mut self) -> bool {
|
||||
let mut all_resumed = true;
|
||||
for index in (0..self.count).rev() {
|
||||
let handle = self.handles[index];
|
||||
@@ -209,14 +208,14 @@ impl Drop for SuspendedPeers {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum QuiesceFailure {
|
||||
enum QuiesceFailure {
|
||||
Acquire,
|
||||
Resume,
|
||||
}
|
||||
|
||||
/// Stop and inspect every peer thread before touching either entry point. Any
|
||||
/// incomplete enumeration/access/context operation fails the transaction closed.
|
||||
pub(crate) unsafe fn suspend_peers(
|
||||
unsafe fn suspend_peers(
|
||||
factory: usize,
|
||||
deserializer: usize,
|
||||
) -> Result<SuspendedPeers, QuiesceFailure> {
|
||||
@@ -330,7 +329,7 @@ unsafe fn executable_range(address: usize, length: usize) -> bool {
|
||||
) && end <= mbi.BaseAddress as usize + mbi.RegionSize
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool {
|
||||
unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool {
|
||||
let Some(end) = address.checked_add(length) else {
|
||||
return false;
|
||||
};
|
||||
@@ -348,7 +347,7 @@ pub(crate) unsafe fn executable_range_in_image(base: usize, address: usize, leng
|
||||
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn readable_range(address: usize, length: usize) -> bool {
|
||||
unsafe fn readable_range(address: usize, length: usize) -> bool {
|
||||
let Some(end) = address.checked_add(length) else {
|
||||
return false;
|
||||
};
|
||||
@@ -363,20 +362,20 @@ pub(crate) unsafe fn readable_range(address: usize, length: usize) -> bool {
|
||||
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn guarded_usize(address: usize) -> Option<usize> {
|
||||
unsafe fn guarded_usize(address: usize) -> Option<usize> {
|
||||
(address & 7 == 0 && readable_range(address, 8))
|
||||
.then(|| core::ptr::read_volatile(address as *const usize))
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn guarded_u16(address: usize) -> Option<u16> {
|
||||
unsafe fn guarded_u16(address: usize) -> Option<u16> {
|
||||
readable_range(address, 2).then(|| core::ptr::read_volatile(address as *const u16))
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn guarded_u8(address: usize) -> Option<u8> {
|
||||
unsafe fn guarded_u8(address: usize) -> Option<u8> {
|
||||
readable_range(address, 1).then(|| core::ptr::read_volatile(address as *const u8))
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn valid_cards_image(base: usize) -> bool {
|
||||
unsafe fn valid_cards_image(base: usize) -> bool {
|
||||
let Some(control) = base.checked_add(CONTROL_RVA) else {
|
||||
return false;
|
||||
};
|
||||
@@ -414,7 +413,7 @@ unsafe fn signature_matches(target: usize, signature: &[u8; 32]) -> bool {
|
||||
core::slice::from_raw_parts(target as *const u8, signature.len()) == signature
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option<usize> {
|
||||
unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option<usize> {
|
||||
let trampoline_len = copy_len.checked_add(ABS_JUMP_LEN)?;
|
||||
let memory = VirtualAlloc(
|
||||
core::ptr::null(),
|
||||
@@ -596,10 +595,7 @@ unsafe extern "system" fn controller_register_wrapper(controller: *mut c_void, e
|
||||
core::mem::transmute(CONTROLLER_REGISTER_TRAMPOLINE.load(Ordering::Acquire));
|
||||
original(controller, event);
|
||||
if event == FUT_SBS_CATEGORIES_EVENT {
|
||||
crate::sbc_dispatch::note_sbc_controller(
|
||||
controller as usize,
|
||||
TRACE_BASE.load(Ordering::Acquire),
|
||||
);
|
||||
crate::sbc_hook::note_sbc_controller(controller as usize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,6 +630,7 @@ unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) {
|
||||
let original: unsafe extern "system" fn(*mut c_void) =
|
||||
core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire));
|
||||
original(ctx);
|
||||
crate::sbc_hook::commit_after_native_parse();
|
||||
NOTIFIER_BYTE_AFTER.store(
|
||||
address
|
||||
.checked_add(0x88)
|
||||
@@ -685,54 +682,12 @@ unsafe extern "system" fn deserializer_wrapper(this: *mut c_void, reader: *mut c
|
||||
.and_then(|slot| guarded_u8(slot))
|
||||
.map(usize::from)
|
||||
.unwrap_or(usize::MAX);
|
||||
let response_vtable = guarded_usize(this as usize).unwrap_or(0);
|
||||
DESERIALIZER_EXIT_M.store(m, Ordering::Relaxed);
|
||||
DESERIALIZER_EXIT_COUNT.store(count, Ordering::Relaxed);
|
||||
DESERIALIZER_EXIT_B_READY.store(ready, Ordering::Relaxed);
|
||||
DESERIALIZER_EXIT_RESPONSE_VTABLE.store(response_vtable, Ordering::Relaxed);
|
||||
DESERIALIZER_EXITS.fetch_add(1, Ordering::Release);
|
||||
result
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct DispatchEvidence {
|
||||
pub(crate) base: usize,
|
||||
pub(crate) factory_entries: u64,
|
||||
pub(crate) factory_exits: u64,
|
||||
pub(crate) factory_result: usize,
|
||||
pub(crate) factory_thread: usize,
|
||||
pub(crate) deserializer_entries: u64,
|
||||
pub(crate) deserializer_exits: u64,
|
||||
pub(crate) deserializer_this: usize,
|
||||
pub(crate) deserializer_reader: usize,
|
||||
pub(crate) deserializer_result: bool,
|
||||
pub(crate) deserializer_thread: usize,
|
||||
pub(crate) response_vtable: usize,
|
||||
pub(crate) model: usize,
|
||||
pub(crate) category_count: usize,
|
||||
pub(crate) notifier_entries: u64,
|
||||
pub(crate) notifier_exits: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_evidence() -> DispatchEvidence {
|
||||
DispatchEvidence {
|
||||
base: TRACE_BASE.load(Ordering::Acquire),
|
||||
factory_entries: FACTORY_ENTRIES.load(Ordering::Acquire),
|
||||
factory_exits: FACTORY_EXITS.load(Ordering::Acquire),
|
||||
factory_result: FACTORY_LAST_RESULT.load(Ordering::Acquire),
|
||||
factory_thread: FACTORY_LAST_THREAD.load(Ordering::Relaxed),
|
||||
deserializer_entries: DESERIALIZER_ENTRIES.load(Ordering::Acquire),
|
||||
deserializer_exits: DESERIALIZER_EXITS.load(Ordering::Acquire),
|
||||
deserializer_this: DESERIALIZER_LAST_THIS.load(Ordering::Relaxed),
|
||||
deserializer_reader: DESERIALIZER_LAST_READER.load(Ordering::Relaxed),
|
||||
deserializer_result: DESERIALIZER_LAST_RESULT.load(Ordering::Acquire),
|
||||
deserializer_thread: DESERIALIZER_LAST_THREAD.load(Ordering::Relaxed),
|
||||
response_vtable: DESERIALIZER_EXIT_RESPONSE_VTABLE.load(Ordering::Relaxed),
|
||||
model: DESERIALIZER_EXIT_M.load(Ordering::Relaxed),
|
||||
category_count: DESERIALIZER_EXIT_COUNT.load(Ordering::Relaxed),
|
||||
notifier_entries: NOTIFIER_ENTRIES.load(Ordering::Acquire),
|
||||
notifier_exits: NOTIFIER_EXITS.load(Ordering::Acquire),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum InstallOutcome {
|
||||
@@ -884,7 +839,7 @@ unsafe fn worker() {
|
||||
let _pending = CodeInstallerPending;
|
||||
let mut base = 0usize;
|
||||
for _ in 0..600u32 {
|
||||
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
||||
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
|
||||
if base != 0 {
|
||||
break;
|
||||
}
|
||||
@@ -971,7 +926,7 @@ unsafe fn notifier_worker() {
|
||||
let _pending = CodeInstallerPending;
|
||||
let mut base = 0usize;
|
||||
for _ in 0..600u32 {
|
||||
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
||||
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
|
||||
if base != 0 {
|
||||
break;
|
||||
}
|
||||
@@ -1074,7 +1029,7 @@ unsafe fn controller_register_worker() {
|
||||
let _pending = CodeInstallerPending;
|
||||
let mut base = 0usize;
|
||||
for _ in 0..600u32 {
|
||||
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
||||
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
|
||||
if base != 0 {
|
||||
break;
|
||||
}
|
||||
@@ -1148,15 +1103,10 @@ fn install_notifier(enabled: bool) {
|
||||
}
|
||||
|
||||
pub(crate) fn install() {
|
||||
let dispatch_repair = env_enabled(std::env::var("OPENFUT_SBC_DISPATCH").ok().as_deref());
|
||||
let dispatch_trace =
|
||||
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_DISPATCH_TRACE").ok().as_deref());
|
||||
let enabled =
|
||||
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
|
||||
let notifier_enabled =
|
||||
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
|
||||
let enabled = env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
|
||||
let notifier_enabled = env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
|
||||
CODE_PATCH_PENDING.store(
|
||||
enabled as usize + (notifier_enabled as usize * 2) + dispatch_trace as usize,
|
||||
enabled as usize + (notifier_enabled as usize * 2),
|
||||
Ordering::Release,
|
||||
);
|
||||
install_notifier(notifier_enabled);
|
||||
|
||||
@@ -67,7 +67,7 @@ fn patch_module(module: isize, scan_bytes: usize) -> bool {
|
||||
|
||||
/// Patch ProtoSSL cert-verify in EAWebKit.dll (call when EAWebKit is loaded).
|
||||
pub unsafe fn patch_eawebkit_cert_verify() -> bool {
|
||||
let module = GetModuleHandleA(c"EAWebKit.dll".as_ptr().cast()) as isize;
|
||||
let module = GetModuleHandleA(b"EAWebKit.dll\0".as_ptr()) as isize;
|
||||
// EAWebKit.dll is ~22 MB
|
||||
patch_module(module, 24 * 1024 * 1024)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
//! FIFA 17 empty-"My Packs" client fix (config flag `store_mypacks_fix=1`).
|
||||
//!
|
||||
//! ## What this does
|
||||
//! When the account owns **zero unopened packs**, FIFA 17's Store still selects the
|
||||
//! "My Packs" category on open. CardsDLL's category resolver (`FUN_1800147f0` →
|
||||
//! `FUN_180014420`) then looks up the My-Packs group ordinal and, if no such group
|
||||
//! exists, dereferences a NULL group pointer → crash (`0x180014882`, read of `0x48`).
|
||||
//! The backend currently avoids this with an active placeholder pack (sentinel 65534)
|
||||
//! that leaves a fake empty tile.
|
||||
//!
|
||||
//! This hook removes the need for that sentinel *for a validated build*: it detours the
|
||||
//! Store render entry `FUN_18007dab0` and, **only when the requested category is My Packs
|
||||
//! AND the client's unopened-pack count is 0**, rewrites the requested category id at
|
||||
//! `screen+0x290` to `0` (list-all = "Browse Packs"). The Store then opens on Browse
|
||||
//! Packs, never resolves the absent My-Packs group, and neither crashes nor shows a fake
|
||||
//! tile. With a real unopened pack (count > 0) nothing is changed and My Packs works
|
||||
//! normally.
|
||||
//!
|
||||
//! ## Safety model
|
||||
//! - **Inert unless enabled**: reads `store_mypacks_fix` from `openfut.cfg`; default OFF.
|
||||
//! - **Validated build only**: refuses to install unless CardsDLL matches the known FIFA
|
||||
//! 17 build (PE timestamp + SizeOfImage + a slide-proof control prologue + the target
|
||||
//! function's own prologue signature). An unknown build → no patch, log, and the
|
||||
//! backend sentinel remains the fallback.
|
||||
//! - **Deferred**: CardsDLL loads lazily on entering Ultimate Team, so we poll off the
|
||||
//! loader lock, exactly like `sbc_hook`.
|
||||
//! - **Fail-safe count**: if the unopened-pack count cannot be read, we DO NOT redirect
|
||||
//! (leave the category unchanged and call the original) — never a forced Browse.
|
||||
//! - **Inline detour**: same proven `unhook → call real → rehook` primitive as
|
||||
//! `resolver_hook`/`connect_hook` (no trampoline, no RIP relocation).
|
||||
//!
|
||||
//! Addresses are RVAs (static VA − image base `0x180000000`); see
|
||||
//! `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART II for the disassembly evidence.
|
||||
|
||||
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use windows_sys::Win32::Foundation::HMODULE;
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ,
|
||||
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READONLY,
|
||||
PAGE_READWRITE, PAGE_WRITECOPY,
|
||||
};
|
||||
|
||||
// ── Build identity (verified against CardsDLL_Win64_retail.dll 4706a881…) ────────
|
||||
const IMAGE_BASE: usize = 0x1_8000_0000;
|
||||
const PE_TIMESTAMP: u32 = 1_497_050_156; // 2017-06-09T23:15:56Z
|
||||
const SIZE_OF_IMAGE: u32 = 0x31d000;
|
||||
/// Slide-proof FNV-hasher control prologue at VA 0x180180d00 (same control sbc_hook uses).
|
||||
const CTRL_RVA: usize = 0x180d00;
|
||||
const CTRL_BYTES: [u8; 12] = [
|
||||
0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0,
|
||||
];
|
||||
|
||||
// ── Target + helper RVAs ─────────────────────────────────────────────────────────
|
||||
/// FUN_18007dab0 — Store render entry (Flash message 0x753f). arg0 = store screen (RCX).
|
||||
const RENDER_RVA: usize = 0x7dab0;
|
||||
/// First 14 bytes of FUN_18007dab0 (PUSH RDI; SUB RSP,0x40; MOV [RSP+0x30],-2 …).
|
||||
/// Doubles as the target-site signature and the bytes we save/restore for the detour.
|
||||
const RENDER_PROLOGUE: [u8; 14] = [
|
||||
0x40, 0x57, 0x48, 0x83, 0xec, 0x40, 0x48, 0xc7, 0x44, 0x24, 0x30, 0xfe, 0xff, 0xff,
|
||||
];
|
||||
/// FUN_180014580(store, tab) → category id (1-based group ordinal, or -1 if absent).
|
||||
const TABMAP_RVA: usize = 0x14580;
|
||||
/// FUN_1800d7170() → registry (no args).
|
||||
const REGISTRY_GETTER_RVA: usize = 0xd7170;
|
||||
/// FUN_180009c80(out, registry, 0, 0) → writes the data-manager singleton into *out.
|
||||
const MANAGER_GETTER_RVA: usize = 0x9c80;
|
||||
/// manager->vtbl[+0x4d8]() → unopened-pack count (i32).
|
||||
const UNOPENED_COUNT_VSLOT: usize = 0x4d8;
|
||||
/// manager->vtbl[+0x08]() → release.
|
||||
const RELEASE_VSLOT: usize = 0x08;
|
||||
/// screen+0x290 = requested CATEGORY_ID (movie-written; the resolver's input).
|
||||
const SCREEN_CATEGORY_OFF: usize = 0x290;
|
||||
/// FUN_180014580 tab index for "mypacks".
|
||||
const MYPACKS_TAB: u32 = 0;
|
||||
/// Category 0 = list-all group tiles = "Browse Packs".
|
||||
const CAT_BROWSE: i32 = 0;
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────────────
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static INSTALLED: AtomicBool = AtomicBool::new(false);
|
||||
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static RENDER_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
static mut RENDER_ORIG: [u8; 14] = [0u8; 14];
|
||||
|
||||
// ── Internal CardsDLL function types (MS x64 ABI) ─────────────────────────────────
|
||||
type RegistryGetterFn = unsafe extern "system" fn() -> usize;
|
||||
type ManagerGetterFn = unsafe extern "system" fn(*mut usize, usize, usize, usize) -> *mut usize;
|
||||
type TabMapFn = unsafe extern "system" fn(usize, u32) -> u32;
|
||||
type CountGetterFn = unsafe extern "system" fn(usize) -> i32;
|
||||
type ReleaseFn = unsafe extern "system" fn(usize);
|
||||
type RenderFn = unsafe extern "system" fn(usize) -> usize;
|
||||
|
||||
// ── Pure decision (host-testable; the correctness core) ───────────────────────────
|
||||
/// Redirect the Store to Browse Packs iff the feature is enabled, the requested
|
||||
/// category is exactly the My-Packs category, and the client owns zero unopened packs.
|
||||
/// A `None` count (read failed) is treated as "do not redirect".
|
||||
fn should_redirect(enabled: bool, count: Option<i32>, requested: i32, mypacks: i32) -> bool {
|
||||
enabled && requested == mypacks && count == Some(0)
|
||||
}
|
||||
|
||||
// ── Guarded memory access (no blind dereferences) ─────────────────────────────────
|
||||
unsafe fn readable(ptr: usize, len: usize) -> bool {
|
||||
if ptr < 0x1_0000 || len == 0 {
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
let prot = mbi.Protect;
|
||||
if prot & PAGE_GUARD != 0 || prot == PAGE_NOACCESS {
|
||||
return false;
|
||||
}
|
||||
const READABLE: u32 = PAGE_READONLY
|
||||
| PAGE_READWRITE
|
||||
| PAGE_WRITECOPY
|
||||
| PAGE_EXECUTE_READ
|
||||
| PAGE_EXECUTE_READWRITE
|
||||
| PAGE_EXECUTE_WRITECOPY;
|
||||
if prot & READABLE == 0 {
|
||||
return false;
|
||||
}
|
||||
let region_end = mbi.BaseAddress as usize + mbi.RegionSize;
|
||||
ptr.checked_add(len).is_some_and(|end| end <= region_end)
|
||||
}
|
||||
|
||||
unsafe fn writable(ptr: usize, len: usize) -> bool {
|
||||
if ptr < 0x1_0000 || len == 0 {
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
let prot = mbi.Protect;
|
||||
if prot & PAGE_GUARD != 0 {
|
||||
return false;
|
||||
}
|
||||
const WRITABLE: u32 =
|
||||
PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
|
||||
if prot & WRITABLE == 0 {
|
||||
return false;
|
||||
}
|
||||
let region_end = mbi.BaseAddress as usize + mbi.RegionSize;
|
||||
ptr.checked_add(len).is_some_and(|end| end <= region_end)
|
||||
}
|
||||
|
||||
unsafe fn executable(ptr: usize) -> bool {
|
||||
if ptr < 0x1_0000 {
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
const EXEC: u32 = PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
|
||||
mbi.Protect & PAGE_GUARD == 0 && mbi.Protect & EXEC != 0
|
||||
}
|
||||
|
||||
unsafe fn read_u8(ptr: usize) -> Option<u8> {
|
||||
readable(ptr, 1).then(|| *(ptr as *const u8))
|
||||
}
|
||||
|
||||
unsafe fn read_u32(ptr: usize) -> Option<u32> {
|
||||
(ptr & 3 == 0 && readable(ptr, 4)).then(|| *(ptr as *const u32))
|
||||
}
|
||||
|
||||
unsafe fn read_ptr(ptr: usize) -> Option<usize> {
|
||||
(ptr & 7 == 0 && readable(ptr, 8)).then(|| *(ptr as *const usize))
|
||||
}
|
||||
|
||||
unsafe fn bytes_match(addr: usize, want: &[u8]) -> bool {
|
||||
want.iter()
|
||||
.enumerate()
|
||||
.all(|(i, &b)| read_u8(addr + i) == Some(b))
|
||||
}
|
||||
|
||||
// ── Inline-hook primitive (identical to resolver_hook/connect_hook) ───────────────
|
||||
unsafe fn write_hook(target: *mut u8, dest: u64) {
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
// FF 25 00 00 00 00 JMP [rip+0] ; then 8-byte absolute target
|
||||
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);
|
||||
}
|
||||
|
||||
unsafe fn restore(target: *mut u8, orig: *const u8) {
|
||||
let mut old: u32 = 0;
|
||||
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
|
||||
core::ptr::copy_nonoverlapping(orig, target, 14);
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
}
|
||||
|
||||
// ── Runtime helpers ────────────────────────────────────────────────────────────
|
||||
unsafe fn resolve_cards_base() -> usize {
|
||||
let h = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr() as *const u8);
|
||||
if !h.is_null() {
|
||||
return h as usize;
|
||||
}
|
||||
let h2 = GetModuleHandleA(c"CardsDLL.dll".as_ptr() as *const u8);
|
||||
if !h2.is_null() {
|
||||
return h2 as usize;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// Read the client's unopened-pack count via the data-manager singleton
|
||||
/// (`registry → manager → vtbl[0x4d8]`), releasing the manager afterwards. Returns
|
||||
/// `None` on any unreadable pointer/vtable so the caller never redirects on a bad read.
|
||||
unsafe fn read_unopened_count(base: usize) -> Option<i32> {
|
||||
if !executable(base + REGISTRY_GETTER_RVA) || !executable(base + MANAGER_GETTER_RVA) {
|
||||
return None;
|
||||
}
|
||||
let registry_getter: RegistryGetterFn = core::mem::transmute(base + REGISTRY_GETTER_RVA);
|
||||
let registry = registry_getter();
|
||||
if registry == 0 {
|
||||
return None;
|
||||
}
|
||||
let manager_getter: ManagerGetterFn = core::mem::transmute(base + MANAGER_GETTER_RVA);
|
||||
let mut out: usize = 0;
|
||||
manager_getter(&mut out, registry, 0, 0);
|
||||
let manager = out;
|
||||
if manager == 0 {
|
||||
return None;
|
||||
}
|
||||
let vtbl = read_ptr(manager)?;
|
||||
let count_fn = read_ptr(vtbl + UNOPENED_COUNT_VSLOT)?;
|
||||
let release_fn = read_ptr(vtbl + RELEASE_VSLOT)?;
|
||||
if !executable(count_fn) || !executable(release_fn) {
|
||||
return None;
|
||||
}
|
||||
let getter: CountGetterFn = core::mem::transmute(count_fn);
|
||||
let count = getter(manager);
|
||||
let release: ReleaseFn = core::mem::transmute(release_fn);
|
||||
release(manager);
|
||||
Some(count)
|
||||
}
|
||||
|
||||
/// The redirect decision + write, executed before the original render runs.
|
||||
unsafe fn maybe_redirect(store: usize) {
|
||||
if store == 0 {
|
||||
return;
|
||||
}
|
||||
let base = CARDS_BASE.load(Ordering::Relaxed);
|
||||
if base == 0 {
|
||||
return;
|
||||
}
|
||||
let cat_ptr = store + SCREEN_CATEGORY_OFF;
|
||||
if !readable(cat_ptr, 4) {
|
||||
return;
|
||||
}
|
||||
let requested = *(cat_ptr as *const i32);
|
||||
if !executable(base + TABMAP_RVA) {
|
||||
return;
|
||||
}
|
||||
let tabmap: TabMapFn = core::mem::transmute(base + TABMAP_RVA);
|
||||
let mypacks_id = tabmap(store, MYPACKS_TAB) as i32;
|
||||
// Only pay for the count read when the requested category is actually My Packs.
|
||||
if requested != mypacks_id {
|
||||
return;
|
||||
}
|
||||
let count = read_unopened_count(base);
|
||||
if should_redirect(
|
||||
ENABLED.load(Ordering::Relaxed),
|
||||
count,
|
||||
requested,
|
||||
mypacks_id,
|
||||
) {
|
||||
if writable(cat_ptr, 4) {
|
||||
*(cat_ptr as *mut i32) = CAT_BROWSE;
|
||||
crate::write_log("[store-hook] zero unopened packs: My Packs -> Browse Packs\n");
|
||||
} else {
|
||||
crate::write_log("[store-hook] category slot not writable; left unchanged\n");
|
||||
}
|
||||
}
|
||||
// requested == mypacks with count > 0 or unknown: leave My Packs unchanged.
|
||||
}
|
||||
|
||||
pub unsafe extern "system" fn hooked_render(store: usize) -> usize {
|
||||
let addr = RENDER_ADDR.load(Ordering::Relaxed) as *mut u8;
|
||||
if addr.is_null() {
|
||||
return 0;
|
||||
}
|
||||
maybe_redirect(store);
|
||||
restore(addr, core::ptr::addr_of!(RENDER_ORIG) as *const u8);
|
||||
let r = {
|
||||
let f: RenderFn = core::mem::transmute(addr as *const ());
|
||||
f(store)
|
||||
};
|
||||
write_hook(addr, hooked_render as *const () as u64);
|
||||
r
|
||||
}
|
||||
|
||||
// ── Build guard + install ─────────────────────────────────────────────────────
|
||||
unsafe fn build_supported(base: usize) -> bool {
|
||||
let fail = |why: &str| {
|
||||
crate::write_log(&format!(
|
||||
"[store-hook] CardsDLL build UNSUPPORTED ({why}); not installing (backend sentinel remains)\n"
|
||||
));
|
||||
false
|
||||
};
|
||||
let Some(e_lfanew) = read_u32(base + 0x3c) else {
|
||||
return fail("PE header unreadable");
|
||||
};
|
||||
let pe = base + e_lfanew as usize;
|
||||
if read_u32(pe) != Some(0x0000_4550) {
|
||||
return fail("PE signature");
|
||||
}
|
||||
if read_u32(pe + 8) != Some(PE_TIMESTAMP) {
|
||||
return fail("PE timestamp");
|
||||
}
|
||||
if read_u32(pe + 24 + 0x38) != Some(SIZE_OF_IMAGE) {
|
||||
return fail("SizeOfImage");
|
||||
}
|
||||
if !bytes_match(base + CTRL_RVA, &CTRL_BYTES) {
|
||||
return fail("control prologue");
|
||||
}
|
||||
if !bytes_match(base + RENDER_RVA, &RENDER_PROLOGUE) {
|
||||
return fail("FUN_18007dab0 prologue");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Deferred worker: CardsDLL loads only on entering Ultimate Team, so poll for it
|
||||
/// (≤5 min) off the loader lock, then validate the build and install the detour 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("[store-hook] CardsDLL never loaded; hook not installed\n");
|
||||
return;
|
||||
}
|
||||
let slide = base.wrapping_sub(IMAGE_BASE);
|
||||
crate::write_log(&format!(
|
||||
"[store-hook] CardsDLL base={base:#x} slide={slide:#x}; validating build\n"
|
||||
));
|
||||
if !build_supported(base) {
|
||||
return;
|
||||
}
|
||||
CARDS_BASE.store(base, Ordering::Relaxed);
|
||||
let render = base + RENDER_RVA;
|
||||
core::ptr::copy_nonoverlapping(
|
||||
render as *const u8,
|
||||
core::ptr::addr_of_mut!(RENDER_ORIG) as *mut u8,
|
||||
14,
|
||||
);
|
||||
RENDER_ADDR.store(render, Ordering::Relaxed);
|
||||
write_hook(render as *mut u8, hooked_render as *const () as u64);
|
||||
INSTALLED.store(true, Ordering::Relaxed);
|
||||
crate::write_log(&format!(
|
||||
"[store-hook] build supported; installed at CardsDLL+{RENDER_RVA:#x} (VA {render:#x})\n"
|
||||
));
|
||||
}
|
||||
|
||||
/// Public entry, called from `fifa17::worker`. Reads `store_mypacks_fix` from
|
||||
/// `openfut.cfg`; if enabled, spawns the deferred CardsDLL-load worker. Fully inert
|
||||
/// otherwise (no thread, no patch).
|
||||
pub fn install(module: HMODULE) {
|
||||
let enabled = match crate::config::feature_value(module, "store_mypacks_fix").as_deref() {
|
||||
Some("1") => true,
|
||||
Some("0") | None => false,
|
||||
Some(other) => {
|
||||
crate::write_log(&format!(
|
||||
"[store-hook] invalid store_mypacks_fix={other:?}; feature disabled\n"
|
||||
));
|
||||
false
|
||||
}
|
||||
};
|
||||
ENABLED.store(enabled, Ordering::Relaxed);
|
||||
if !enabled {
|
||||
crate::write_log("[store-hook] disabled (set store_mypacks_fix=1 in openfut.cfg)\n");
|
||||
return;
|
||||
}
|
||||
crate::write_log("[store-hook] enabled; deferring until CardsDLL loads\n");
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_redirect;
|
||||
|
||||
#[test]
|
||||
fn disabled_never_redirects() {
|
||||
assert!(!should_redirect(false, Some(0), 3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_zero_mypacks_redirects() {
|
||||
assert!(should_redirect(true, Some(0), 3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_one_pack_keeps_mypacks() {
|
||||
assert!(!should_redirect(true, Some(1), 3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_zero_browse_untouched() {
|
||||
// requested Browse (0) != mypacks ordinal (3)
|
||||
assert!(!should_redirect(true, Some(0), 0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_zero_other_tab_untouched() {
|
||||
// e.g. bronze ordinal 4 != mypacks 3
|
||||
assert!(!should_redirect(true, Some(0), 4, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_unknown_count_does_not_redirect() {
|
||||
assert!(!should_redirect(true, None, 3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_zero_absent_mypacks_group_redirects() {
|
||||
// With no sentinel, both the requested id and mypacks id are -1 (group absent).
|
||||
assert!(should_redirect(true, Some(0), -1, -1));
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ pub unsafe fn note_connect(api: &str, name: *const u8, namelen: i32, s: usize) {
|
||||
let mut len: i32 = 4;
|
||||
getsockopt(
|
||||
s,
|
||||
SOL_SOCKET,
|
||||
SOL_SOCKET as i32,
|
||||
SO_TYPE,
|
||||
&mut ty as *mut i32 as *mut u8,
|
||||
&mut len,
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
use crate::config::LauncherConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
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> {
|
||||
#[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,
|
||||
account_funds: u32,
|
||||
account_funds_cap: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AccountSyncResult {
|
||||
pub account: AccountSummary,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// Select the persistent EA/FUT account before LSX and FIFA start.
|
||||
///
|
||||
/// This deliberately uses a tiny stdlib HTTP client so the launcher does not
|
||||
/// acquire an async runtime solely for one bounded control-plane request.
|
||||
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)
|
||||
.to_socket_addrs()
|
||||
.map_err(|error| format!("cannot resolve account server {host}:{port}: {error}"))?
|
||||
.next()
|
||||
.ok_or_else(|| format!("account server {host}:{port} resolved to no addresses"))?;
|
||||
let mut stream = TcpStream::connect_timeout(&address, TIMEOUT)
|
||||
.map_err(|error| format!("cannot connect to account server {host}:{port}: {error}"))?;
|
||||
stream
|
||||
.set_read_timeout(Some(TIMEOUT))
|
||||
.map_err(|error| format!("cannot set account sync timeout: {error}"))?;
|
||||
stream
|
||||
.set_write_timeout(Some(TIMEOUT))
|
||||
.map_err(|error| format!("cannot set account sync timeout: {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",
|
||||
payload.len()
|
||||
);
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.and_then(|()| stream.write_all(&payload))
|
||||
.map_err(|error| format!("cannot send account sync request: {error}"))?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut response)
|
||||
.map_err(|error| format!("cannot read account sync response: {error}"))?;
|
||||
let separator = response
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.ok_or_else(|| "account server returned a malformed HTTP response".to_string())?;
|
||||
let headers = std::str::from_utf8(&response[..separator])
|
||||
.map_err(|_| "account server returned non-UTF-8 headers".to_string())?;
|
||||
let status = headers
|
||||
.lines()
|
||||
.next()
|
||||
.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 response_body = &response[separator + 4..];
|
||||
if !(200..300).contains(&status) {
|
||||
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(response_body)
|
||||
.map_err(|error| format!("account server returned invalid JSON: {error}"))?;
|
||||
Ok(envelope.account)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn sync_posts_account_and_reads_selected_profile() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&request);
|
||||
assert!(request.starts_with("POST /openfut/account/sync HTTP/1.1"));
|
||||
assert!(request.contains("\"personaId\":12345678"));
|
||||
assert!(request.contains("\"personaName\":\"TEST_USER\""));
|
||||
let body = r#"{"status":"OK","account":{"personaId":12345678,"personaName":"TEST_USER","level":7,"experience":200,"accountFunds":50,"coins":15000,"unopenedPacks":1}}"#;
|
||||
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();
|
||||
});
|
||||
|
||||
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(),
|
||||
fut_account_level: 7,
|
||||
fut_account_experience: 200,
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
let selected = sync(&config).unwrap();
|
||||
assert_eq!(selected.persona_name, "TEST_USER");
|
||||
assert_eq!(selected.coins, 15000);
|
||||
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();
|
||||
}
|
||||
}
|
||||
+408
-1970
File diff suppressed because it is too large
Load Diff
-241
@@ -1,241 +0,0 @@
|
||||
//! One-click client arming — the GUI equivalent of `client_arm.sh`, driven by
|
||||
//! [`LauncherConfig`] so it repairs exactly what [`crate::preflight`] checks.
|
||||
//!
|
||||
//! Everything the game reaches by a routable address is redirected to the
|
||||
//! OpenFUT server; two things are inherently local and are NOT touched here (they
|
||||
//! are managed as child processes, see [`crate::local_services`]): the LSX/Origin
|
||||
//! emulator on loopback `:4216` and `autopatch`.
|
||||
//!
|
||||
//! The three privileged steps run in ONE elevated batch (a single `pkexec`
|
||||
//! prompt), mirroring the volatile state `client_arm.sh` set by hand:
|
||||
//!
|
||||
//! 1. `kernel.yama.ptrace_scope=0` — so `autopatch` can write FIFA's `/proc/PID/mem`.
|
||||
//! 2. DNAT EA's hardcoded redirector IP → `server:redirector_port` (+ MASQUERADE
|
||||
//! on the reply path, required for a DNAT to a remote host).
|
||||
//! 3. Point each dead EA hostname at the server in `/etc/hosts`.
|
||||
//!
|
||||
//! All of it is idempotent: the DNAT deletes any prior copy before adding, and
|
||||
//! every `/etc/hosts` line for a managed hostname is removed first — including a
|
||||
//! foreign single-machine-era `127.0.0.1 easw.easports.com` shadow that
|
||||
//! `client_arm.sh` could not remove, because it only deleted its own `# openfut`
|
||||
//! lines and glibc returns the FIRST match.
|
||||
|
||||
use crate::config::LauncherConfig;
|
||||
|
||||
/// Accept only hostname/IP characters. These values come from config fields that
|
||||
/// are ever only IPs or hostnames, so a surprising character is a bug — reject it
|
||||
/// rather than try to escape it into an elevated shell command.
|
||||
fn safe_host(s: &str) -> anyhow::Result<&str> {
|
||||
let t = s.trim();
|
||||
if t.is_empty() {
|
||||
anyhow::bail!("empty host/address");
|
||||
}
|
||||
if t.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b':' | b'-' | b'_'))
|
||||
{
|
||||
Ok(t)
|
||||
} else {
|
||||
anyhow::bail!("refusing to arm with an unexpected character in {t:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the privileged arming script. Pure and unit-tested; the effectful part
|
||||
/// ([`arm`]) only validates config and hands this to the elevated runner.
|
||||
pub(crate) fn arming_script(
|
||||
server: &str,
|
||||
redirector_port: u16,
|
||||
ea_ip: &str,
|
||||
hostnames: &[String],
|
||||
) -> anyhow::Result<String> {
|
||||
let server = safe_host(server)?;
|
||||
let ea_ip = safe_host(ea_ip)?;
|
||||
|
||||
let mut s = String::from("set -eu\n");
|
||||
|
||||
// 1) ptrace_scope for autopatch's /proc/PID/mem write.
|
||||
s.push_str("sysctl -q kernel.yama.ptrace_scope=0\n");
|
||||
|
||||
// 2) DNAT EA's hardcoded redirector IP to the server; SNAT the redirected
|
||||
// flow (a DNAT from OUTPUT to a remote host needs a matching MASQUERADE or
|
||||
// the server's replies won't match the game's conntrack entry). Both are
|
||||
// delete-then-add so re-running and IP changes stay clean.
|
||||
s.push_str(&format!(
|
||||
"while iptables -t nat -D OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port} 2>/dev/null; do :; done\n\
|
||||
iptables -t nat -A OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port}\n\
|
||||
while iptables -t nat -D POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE 2>/dev/null; do :; done\n\
|
||||
iptables -t nat -A POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE\n"
|
||||
));
|
||||
|
||||
// 3) Every dead EA hostname resolves to the server. Delete ALL existing lines
|
||||
// listing the name (foreign shadow included) BEFORE writing ours, so the
|
||||
// first-match-wins resolution can never land on a stale loopback line.
|
||||
for host in hostnames {
|
||||
let host = safe_host(host)?;
|
||||
let re = host.replace('.', "\\.");
|
||||
s.push_str(&format!(
|
||||
"sed -ri '/[[:space:]]{re}([[:space:]]|$)/d' /etc/hosts\n\
|
||||
printf '%s\\t%s\\t# openfut\\n' '{server}' '{host}' >> /etc/hosts\n"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Human-readable list of what [`arm`] changed, in the order the script applies
|
||||
/// it. Logged by the UI so the user sees exactly what was set — not just that
|
||||
/// "something" ran under `pkexec`.
|
||||
pub(crate) fn arming_summary(
|
||||
server: &str,
|
||||
redirector_port: u16,
|
||||
ea_ip: &str,
|
||||
hostnames: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut out = vec![
|
||||
"kernel.yama.ptrace_scope = 0 (autopatch can attach)".to_string(),
|
||||
format!("DNAT {ea_ip} -> {server}:{redirector_port} (+ MASQUERADE reply path)"),
|
||||
];
|
||||
for host in hostnames {
|
||||
out.push(format!("hosts: {host} -> {server}"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Arm the client from config, under one elevated prompt. Requires the same
|
||||
/// fields preflight reads; a missing one is a clear error, never a silent
|
||||
/// loopback fallback. Returns the applied changes for the UI to surface.
|
||||
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 Settings before arming.");
|
||||
}
|
||||
let ea_ip = cfg.ea_redirect_probe_ip.trim();
|
||||
if ea_ip.is_empty() {
|
||||
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 Settings before arming."
|
||||
);
|
||||
}
|
||||
let redirector_port = cfg.openfut_blaze_redirector_port;
|
||||
let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?;
|
||||
crate::setup::run_elevated(&script)?;
|
||||
Ok(arming_summary(
|
||||
server,
|
||||
redirector_port,
|
||||
ea_ip,
|
||||
&cfg.ea_hostnames,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn script() -> String {
|
||||
arming_script(
|
||||
"10.10.0.120",
|
||||
42127,
|
||||
"159.153.51.20",
|
||||
&["easw.easports.com".to_string()],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sets_ptrace_scope_zero() {
|
||||
assert!(script().contains("sysctl -q kernel.yama.ptrace_scope=0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dnats_ea_ip_to_server_and_masquerades() {
|
||||
let s = script();
|
||||
assert!(s.contains(
|
||||
"iptables -t nat -A OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 10.10.0.120:42127"
|
||||
));
|
||||
assert!(s.contains(
|
||||
"iptables -t nat -A POSTROUTING -p tcp -d 10.10.0.120 --dport 42127 -j MASQUERADE"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dnat_is_delete_then_add_for_idempotence() {
|
||||
let s = script();
|
||||
// The delete loop precedes the add, so re-arming never stacks duplicates.
|
||||
let del = s.find("-D OUTPUT").unwrap();
|
||||
let add = s.find("-A OUTPUT").unwrap();
|
||||
assert!(del < add, "delete must run before add");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_shadowing_hosts_line_before_writing_ours() {
|
||||
let s = script();
|
||||
// Deletes any existing easw.easports.com line (foreign shadow included)…
|
||||
assert!(
|
||||
s.contains("sed -ri '/[[:space:]]easw\\.easports\\.com([[:space:]]|$)/d' /etc/hosts")
|
||||
);
|
||||
// …then appends the OpenFUT-tagged mapping to the server.
|
||||
assert!(s.contains(
|
||||
"printf '%s\\t%s\\t# openfut\\n' '10.10.0.120' 'easw.easports.com' >> /etc/hosts"
|
||||
));
|
||||
let del = s.find("sed -ri").unwrap();
|
||||
let add = s.find("printf").unwrap();
|
||||
assert!(del < add, "shadow removal must precede our line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_hostnames_each_get_a_mapping() {
|
||||
let s = arming_script(
|
||||
"10.10.0.120",
|
||||
42127,
|
||||
"159.153.51.20",
|
||||
&["easw.easports.com".into(), "utas.fut.ea.com".into()],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.contains("'easw.easports.com' >> /etc/hosts"));
|
||||
assert!(s.contains("'utas.fut.ea.com' >> /etc/hosts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_shell_metacharacters_in_config() {
|
||||
assert!(arming_script("10.0.0.1; rm -rf /", 42127, "159.153.51.20", &[]).is_err());
|
||||
assert!(arming_script("10.0.0.1", 42127, "$(evil)", &[]).is_err());
|
||||
assert!(
|
||||
arming_script("10.0.0.1", 42127, "159.153.51.20", &["a b`c`".into()]).is_err(),
|
||||
"a hostname with a backtick is rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_requires_server_ea_ip_and_hostname() {
|
||||
let mut c = LauncherConfig::default();
|
||||
assert!(arm(&c).unwrap_err().to_string().contains("server host"));
|
||||
c.openfut_server_host = "10.10.0.120".into();
|
||||
assert!(arm(&c)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("EA redirector IP"));
|
||||
c.ea_redirect_probe_ip = "159.153.51.20".into();
|
||||
assert!(arm(&c).unwrap_err().to_string().contains("EA hostname"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_lists_ptrace_dnat_and_each_host() {
|
||||
let s = arming_summary(
|
||||
"10.10.0.120",
|
||||
42127,
|
||||
"159.153.51.20",
|
||||
&["easw.easports.com".into(), "utas.fut.ea.com".into()],
|
||||
);
|
||||
assert!(s.iter().any(|l| l.contains("ptrace_scope = 0")));
|
||||
assert!(s
|
||||
.iter()
|
||||
.any(|l| l.contains("DNAT 159.153.51.20 -> 10.10.0.120:42127")));
|
||||
assert!(s
|
||||
.iter()
|
||||
.any(|l| l == "hosts: easw.easports.com -> 10.10.0.120"));
|
||||
assert!(s
|
||||
.iter()
|
||||
.any(|l| l == "hosts: utas.fut.ea.com -> 10.10.0.120"));
|
||||
}
|
||||
}
|
||||
+18
-583
@@ -1,101 +1,4 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One `dosdevices` entry to create inside the Wine prefix before launching.
|
||||
/// `link` is relative to the prefix (e.g. `dosdevices/w:`).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PrefixLink {
|
||||
pub link: String,
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
/// A DRM licence file the game refuses to start without, and the executable
|
||||
/// that recreates it. A crashed launch deletes the licence, so this is a
|
||||
/// per-launch precondition rather than a one-time setup step.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LicenseCheck {
|
||||
/// Absolute, or relative to the Wine prefix.
|
||||
pub path: String,
|
||||
/// Executable run through the profile's runner to regenerate it.
|
||||
pub generator: String,
|
||||
#[serde(default = "default_license_timeout")]
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
/// Everything needed to start one game, as data.
|
||||
///
|
||||
/// This is what keeps the launcher game-independent: FIFA 17's runner, prefix,
|
||||
/// executable, `w:` drive and licence id live here in the user's config, never
|
||||
/// in launcher code. An unconfigured profile means "fall back to
|
||||
/// `game_launch_command`", so upgrading cannot break a working setup.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GameProfile {
|
||||
/// Program that starts the game (e.g. `umu-run`). Empty = profile unused.
|
||||
#[serde(default)]
|
||||
pub runner: String,
|
||||
/// Argument passed to the runner (e.g. `FIFA17.exe`).
|
||||
#[serde(default)]
|
||||
pub executable: String,
|
||||
/// Working directory the runner is started from.
|
||||
#[serde(default)]
|
||||
pub game_dir: String,
|
||||
/// `WINEPREFIX` for the game. Exported automatically when set.
|
||||
#[serde(default)]
|
||||
pub wine_prefix: String,
|
||||
/// Extra environment for the runner (`GAMEID`, `PROTONPATH`, …).
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub prefix_links: Vec<PrefixLink>,
|
||||
#[serde(default)]
|
||||
pub license: Option<LicenseCheck>,
|
||||
}
|
||||
|
||||
impl GameProfile {
|
||||
/// Whether this profile is filled in enough to launch from.
|
||||
pub fn configured(&self) -> bool {
|
||||
!self.runner.trim().is_empty()
|
||||
&& !self.executable.trim().is_empty()
|
||||
&& !self.game_dir.trim().is_empty()
|
||||
}
|
||||
|
||||
/// Reject a half-filled profile rather than launching something surprising.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.runner.trim().is_empty() {
|
||||
return Err("Game profile has no runner (e.g. umu-run).".into());
|
||||
}
|
||||
if self.executable.trim().is_empty() {
|
||||
return Err("Game profile has no executable.".into());
|
||||
}
|
||||
if self.game_dir.trim().is_empty() {
|
||||
return Err("Game profile has no game directory.".into());
|
||||
}
|
||||
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
|
||||
return Err("Game profile defines prefix links but no wine_prefix.".into());
|
||||
}
|
||||
for l in &self.prefix_links {
|
||||
if l.link.trim().is_empty() || l.target.trim().is_empty() {
|
||||
return Err("Game profile has a prefix link with an empty link or target.".into());
|
||||
}
|
||||
if std::path::Path::new(&l.link).is_absolute() {
|
||||
return Err(format!(
|
||||
"Prefix link {:?} must be relative to the Wine prefix.",
|
||||
l.link
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(lic) = &self.license {
|
||||
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
|
||||
return Err("Game profile licence needs both a path and a generator.".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn default_license_timeout() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LauncherConfig {
|
||||
@@ -112,84 +15,8 @@ pub struct LauncherConfig {
|
||||
pub hook_dll_path: String,
|
||||
/// FIFA 23 game folder inside the Proton prefix (where the DLL is deployed).
|
||||
pub fifa_game_dir: String,
|
||||
/// The OpenFUT server FIFA's EA traffic is redirected to. IPv4 literal or
|
||||
/// hostname. Empty means "not configured" — launching is blocked until set.
|
||||
/// There is intentionally NO loopback default.
|
||||
#[serde(default, alias = "hook_redirect_ip")]
|
||||
pub openfut_server_host: String,
|
||||
/// OpenFUT destination port for intercepted EA :443 (bridge HTTPS).
|
||||
#[serde(default = "default_https_port")]
|
||||
pub openfut_https_port: u16,
|
||||
/// OpenFUT destination port for intercepted EA :10041 (Blaze redirector).
|
||||
#[serde(default = "default_blaze_redirector_port")]
|
||||
pub openfut_blaze_redirector_port: u16,
|
||||
/// OpenFUT destination port for intercepted EA :42127 (Blaze main).
|
||||
#[serde(default = "default_blaze_main_port")]
|
||||
pub openfut_blaze_main_port: u16,
|
||||
/// Plain HTTP UTAS/control-plane port used to select the active account.
|
||||
#[serde(default = "default_account_sync_port")]
|
||||
pub openfut_account_sync_port: u16,
|
||||
/// EA/Origin persona selected for this local single-player profile.
|
||||
#[serde(default)]
|
||||
pub fut_persona_id: u64,
|
||||
#[serde(default)]
|
||||
pub fut_persona_name: String,
|
||||
/// EASFC/POW account-bar state (separate from FUT club coins).
|
||||
#[serde(default = "default_account_level")]
|
||||
pub fut_account_level: u32,
|
||||
#[serde(default)]
|
||||
pub fut_account_experience: u32,
|
||||
#[serde(default = "default_account_experience_max")]
|
||||
pub fut_account_experience_max: u32,
|
||||
#[serde(default)]
|
||||
pub fut_account_funds: u32,
|
||||
#[serde(default = "default_account_funds_cap")]
|
||||
pub fut_account_funds_cap: u32,
|
||||
/// Shell command the launcher runs to start the game. Run via `sh -c`, from
|
||||
/// `game_launch_workdir` if set. Empty means "not configured" — the Launch
|
||||
/// Game button is disabled until the user provides one. This keeps the
|
||||
/// launcher agnostic to Steam vs umu-run vs a custom script.
|
||||
#[serde(default)]
|
||||
pub game_launch_command: String,
|
||||
/// Optional working directory for `game_launch_command`. Empty = inherit.
|
||||
#[serde(default)]
|
||||
pub game_launch_workdir: String,
|
||||
/// Native launch definition. When [`GameProfile::configured`], the launcher
|
||||
/// starts the game itself and `game_launch_command` is not used; the command
|
||||
/// remains as a fallback so an existing setup keeps working after upgrade.
|
||||
#[serde(default)]
|
||||
pub game_profile: GameProfile,
|
||||
|
||||
// ── Pre-launch checks (see `preflight`) ─────────────────────────────────
|
||||
/// EA's hardcoded redirector IP, probed to confirm the client-side DNAT is
|
||||
/// armed. Empty = the check is skipped. A game fact, so it is configuration.
|
||||
#[serde(default)]
|
||||
pub ea_redirect_probe_ip: String,
|
||||
/// Dead EA hostnames that must resolve to `openfut_server_host`.
|
||||
#[serde(default)]
|
||||
pub ea_hostnames: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_https_port() -> u16 {
|
||||
openfut_common::default_ports::HTTPS
|
||||
}
|
||||
fn default_blaze_redirector_port() -> u16 {
|
||||
openfut_common::default_ports::BLAZE_REDIRECTOR
|
||||
}
|
||||
fn default_blaze_main_port() -> u16 {
|
||||
openfut_common::default_ports::BLAZE_MAIN
|
||||
}
|
||||
fn default_account_sync_port() -> u16 {
|
||||
8099
|
||||
}
|
||||
fn default_account_level() -> u32 {
|
||||
1
|
||||
}
|
||||
fn default_account_experience_max() -> u32 {
|
||||
1000
|
||||
}
|
||||
fn default_account_funds_cap() -> u32 {
|
||||
100_000
|
||||
/// IP the hook DLL redirects EA hostnames to (written to openfut.cfg).
|
||||
pub hook_redirect_ip: String,
|
||||
}
|
||||
|
||||
impl Default for LauncherConfig {
|
||||
@@ -230,27 +57,7 @@ impl Default for LauncherConfig {
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
// No server configured by default — the user MUST enter one. There
|
||||
// is deliberately no loopback/localhost default.
|
||||
openfut_server_host: String::new(),
|
||||
openfut_https_port: default_https_port(),
|
||||
openfut_blaze_redirector_port: default_blaze_redirector_port(),
|
||||
openfut_blaze_main_port: default_blaze_main_port(),
|
||||
openfut_account_sync_port: default_account_sync_port(),
|
||||
fut_persona_id: 0,
|
||||
fut_persona_name: String::new(),
|
||||
fut_account_level: default_account_level(),
|
||||
fut_account_experience: 0,
|
||||
fut_account_experience_max: default_account_experience_max(),
|
||||
fut_account_funds: 0,
|
||||
fut_account_funds_cap: default_account_funds_cap(),
|
||||
game_launch_command: String::new(),
|
||||
game_launch_workdir: String::new(),
|
||||
// Empty by default, exactly like the server host: the launcher must
|
||||
// never invent a path to somebody's game install.
|
||||
game_profile: GameProfile::default(),
|
||||
ea_redirect_probe_ip: String::new(),
|
||||
ea_hostnames: Vec::new(),
|
||||
hook_redirect_ip: "127.0.0.1".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,394 +88,22 @@ impl LauncherConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// The (host, port) the health monitor should poll, or None when no server
|
||||
/// is configured. Uses the bridge HTTPS port — the port the FIFA client
|
||||
/// actually connects to — so "reachable" means what the game will see.
|
||||
pub fn health_target(&self) -> Option<(String, u16)> {
|
||||
let host = self.openfut_server_host.trim();
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((host.to_string(), self.openfut_https_port))
|
||||
}
|
||||
pub fn core_env(&self) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("DATABASE_URL".into(), self.core_database_url.clone()),
|
||||
("DATA_DIR".into(), self.core_data_dir.clone()),
|
||||
("LISTEN_ADDR".into(), self.core_listen_addr.clone()),
|
||||
("RUST_LOG".into(), "openfut_core=info,tower_http=info".into()),
|
||||
]
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn server_config(&self) -> openfut_common::ServerConfig {
|
||||
openfut_common::ServerConfig {
|
||||
host: self.openfut_server_host.trim().to_string(),
|
||||
ports: openfut_common::OpenFutPorts {
|
||||
https: self.openfut_https_port,
|
||||
blaze_redirector: self.openfut_blaze_redirector_port,
|
||||
blaze_main: self.openfut_blaze_main_port,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the configured server (syntax only, no DNS). Returns the same
|
||||
/// user-facing message the task specifies when nothing is configured.
|
||||
pub fn validate_server(&self) -> Result<(), String> {
|
||||
if self.openfut_server_host.trim().is_empty() {
|
||||
return Err("No OpenFUT server configured. Please enter the hostname \
|
||||
or IP address of your OpenFUT server."
|
||||
.to_string());
|
||||
}
|
||||
self.server_config().validate().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
self.validate_server()?;
|
||||
self.validate_account()?;
|
||||
// Either launch route is acceptable, but a half-filled profile is not:
|
||||
// silently falling back to the shell command would hide the mistake, so
|
||||
// ANY profile that has been touched must be complete.
|
||||
if self.game_profile != GameProfile::default() {
|
||||
self.game_profile.validate()?;
|
||||
} 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 Settings."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_account(&self) -> Result<(), String> {
|
||||
if self.fut_persona_id == 0 {
|
||||
return Err("No account yet. Create one from the Get started tab.".into());
|
||||
}
|
||||
if self.fut_persona_name.trim().is_empty() {
|
||||
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());
|
||||
}
|
||||
if self.fut_account_experience_max == 0
|
||||
|| self.fut_account_experience > self.fut_account_experience_max
|
||||
{
|
||||
return Err("EA account XP must not exceed a nonzero XP maximum.".into());
|
||||
}
|
||||
if self.fut_account_funds > self.fut_account_funds_cap {
|
||||
return Err("EA account funds must not exceed the funds cap.".into());
|
||||
}
|
||||
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).
|
||||
///
|
||||
/// The deployed FIFA 17 hook reads this structured format through the same
|
||||
/// shared parser, so changing a destination port never requires recompiling
|
||||
/// the DLL. Fixed EA source ports remain protocol signatures in the hook.
|
||||
pub fn hook_cfg_contents(&self) -> Result<String, String> {
|
||||
self.validate_server()?;
|
||||
Ok(self.server_config().to_cfg_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_has_no_server_and_blocks_launch() {
|
||||
let c = LauncherConfig::default();
|
||||
assert!(c.openfut_server_host.is_empty());
|
||||
let err = c.validate_server().unwrap_err();
|
||||
assert!(err.contains("No OpenFUT server configured"));
|
||||
assert!(c.hook_cfg_contents().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_server_roundtrips_into_hook_cfg() {
|
||||
let c = LauncherConfig {
|
||||
openfut_server_host: "192.168.1.50".into(),
|
||||
openfut_https_port: 9443,
|
||||
openfut_blaze_redirector_port: 43127,
|
||||
openfut_blaze_main_port: 43130,
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
let cfg = c
|
||||
.hook_cfg_contents()
|
||||
.expect("valid server should produce cfg");
|
||||
let parsed = openfut_common::ServerConfig::parse(&cfg).unwrap();
|
||||
assert_eq!(parsed.host, "192.168.1.50");
|
||||
assert_eq!(parsed.ports, c.server_config().ports);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_server_changes_hook_cfg_no_rebuild() {
|
||||
// Models the Server A -> Server B acceptance test at the config layer:
|
||||
// only the value changes; the same code path produces the new cfg.
|
||||
let mut c = LauncherConfig {
|
||||
openfut_server_host: "10.0.0.1".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
let a = c.hook_cfg_contents().unwrap();
|
||||
c.openfut_server_host = "10.0.0.2".into();
|
||||
let b = c.hook_cfg_contents().unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(
|
||||
openfut_common::ServerConfig::parse(&b).unwrap().host,
|
||||
"10.0.0.2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_target_none_until_configured() {
|
||||
let mut c = LauncherConfig::default();
|
||||
assert!(c.health_target().is_none());
|
||||
c.openfut_server_host = "10.10.0.120".into();
|
||||
let (host, port) = c.health_target().expect("configured host yields a target");
|
||||
assert_eq!(host, "10.10.0.120");
|
||||
assert_eq!(port, c.openfut_https_port);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_hook_redirect_ip_field_is_read() {
|
||||
// Old configs stored the address under `hook_redirect_ip`; serde alias
|
||||
// must map it onto the new field so upgrades keep working.
|
||||
let json = r#"{
|
||||
"core_binary":"","bridge_binary":"","core_database_url":"",
|
||||
"core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"",
|
||||
"bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true,
|
||||
"hook_dll_path":"","fifa_game_dir":"","hook_redirect_ip":"192.168.5.5"
|
||||
}"#;
|
||||
let c: LauncherConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(c.openfut_server_host, "192.168.5.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_config_requires_server_account_and_command() {
|
||||
let mut c = LauncherConfig::default();
|
||||
assert!(c.validate_launch_config().is_err());
|
||||
|
||||
c.openfut_server_host = "10.10.0.120".into();
|
||||
c.fut_persona_id = 12345678;
|
||||
c.fut_persona_name = "TEST_USER".into();
|
||||
assert!(c
|
||||
.validate_launch_config()
|
||||
.unwrap_err()
|
||||
.contains("launch command"));
|
||||
|
||||
c.game_launch_command = "/home/alex/Desktop/launch-fifa17.sh".into();
|
||||
assert!(c.validate_launch_config().is_ok());
|
||||
}
|
||||
|
||||
/// An old config.json has no `game_profile` key at all. It must keep
|
||||
/// launching exactly as before rather than failing to parse or silently
|
||||
/// switching route.
|
||||
#[test]
|
||||
fn a_config_without_a_game_profile_still_uses_the_shell_command() {
|
||||
let json = r#"{
|
||||
"core_binary":"","bridge_binary":"","core_database_url":"",
|
||||
"core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"",
|
||||
"bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true,
|
||||
"hook_dll_path":"","fifa_game_dir":"","openfut_server_host":"10.0.0.1",
|
||||
"game_launch_command":"/home/u/launch.sh"
|
||||
}"#;
|
||||
let c: LauncherConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!c.game_profile.configured());
|
||||
assert_eq!(c.game_profile, GameProfile::default());
|
||||
assert!(c.ea_hostnames.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_profile_satisfies_launch_without_a_shell_command() {
|
||||
let mut c = LauncherConfig {
|
||||
openfut_server_host: "10.10.0.120".into(),
|
||||
fut_persona_id: 1,
|
||||
fut_persona_name: "X".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
c.game_launch_command.clear();
|
||||
assert!(
|
||||
c.validate_launch_config().is_err(),
|
||||
"neither route configured"
|
||||
);
|
||||
|
||||
c.game_profile = GameProfile {
|
||||
runner: "umu-run".into(),
|
||||
executable: "FIFA17.exe".into(),
|
||||
game_dir: "/mnt/games/FIFA 17".into(),
|
||||
..GameProfile::default()
|
||||
};
|
||||
assert!(c.game_profile.configured());
|
||||
assert!(c.validate_launch_config().is_ok());
|
||||
}
|
||||
|
||||
/// The trap this guards: a profile filled in halfway would fail
|
||||
/// `configured()` and quietly fall through to the shell command, so the user
|
||||
/// edits the profile and nothing they change has any effect.
|
||||
#[test]
|
||||
fn a_half_filled_profile_is_an_error_not_a_silent_fallback() {
|
||||
let mut c = LauncherConfig {
|
||||
openfut_server_host: "10.10.0.120".into(),
|
||||
fut_persona_id: 1,
|
||||
fut_persona_name: "X".into(),
|
||||
game_launch_command: "/home/u/launch.sh".into(),
|
||||
..LauncherConfig::default()
|
||||
};
|
||||
c.game_profile.runner = "umu-run".into(); // and nothing else
|
||||
let err = c.validate_launch_config().unwrap_err();
|
||||
assert!(err.contains("executable"), "{err}");
|
||||
}
|
||||
|
||||
/// The exact profile block deployed to the FIFA 17 machine.
|
||||
///
|
||||
/// `load()` swallows a parse error and returns `Default` — so a config this
|
||||
/// binary cannot read would not produce an error, it would silently discard
|
||||
/// the user's persona, server and ports. That makes "the shipped config
|
||||
/// actually deserializes" a property worth asserting, not assuming.
|
||||
#[test]
|
||||
fn the_deployed_fifa17_profile_parses_exactly() {
|
||||
let json = r#"{
|
||||
"core_binary":"","bridge_binary":"","core_database_url":"",
|
||||
"core_data_dir":"","core_listen_addr":"","bridge_listen_addr":"",
|
||||
"bridge_captures_dir":"","bridge_core_url":"","bridge_tls_enabled":true,
|
||||
"hook_dll_path":"","fifa_game_dir":"",
|
||||
"openfut_server_host":"10.10.0.120",
|
||||
"ea_hostnames":["easw.easports.com"],
|
||||
"ea_redirect_probe_ip":"159.153.51.20",
|
||||
"game_profile":{
|
||||
"env":{"GAMEID":"fifa17","PROTONPATH":"UMU-Proton-10.0-4","STEAM_COMPAT_CONFIG":"sdlinput"},
|
||||
"executable":"FIFA17.exe",
|
||||
"game_dir":"/mnt/games/FIFA 17",
|
||||
"license":{
|
||||
"generator":"_fifa17.exe",
|
||||
"path":"drive_c/ProgramData/Electronic Arts/EA Services/License/1027460.dlf",
|
||||
"timeout_secs":60
|
||||
},
|
||||
"prefix_links":[{"link":"dosdevices/w:","target":"/mnt"}],
|
||||
"runner":"umu-run",
|
||||
"wine_prefix":"/home/alex/Games/umu/fifa17"
|
||||
}
|
||||
}"#;
|
||||
let c: LauncherConfig = serde_json::from_str(json).expect("deployed config must parse");
|
||||
let p = &c.game_profile;
|
||||
assert!(p.configured());
|
||||
assert!(p.validate().is_ok());
|
||||
assert_eq!(p.runner, "umu-run");
|
||||
assert_eq!(
|
||||
p.env.get("STEAM_COMPAT_CONFIG").map(String::as_str),
|
||||
Some("sdlinput")
|
||||
);
|
||||
assert_eq!(p.prefix_links.len(), 1);
|
||||
let lic = p.license.as_ref().expect("licence block");
|
||||
assert_eq!(lic.timeout_secs, 60);
|
||||
assert!(lic.path.ends_with("1027460.dlf"));
|
||||
assert_eq!(c.ea_redirect_probe_ip, "159.153.51.20");
|
||||
}
|
||||
|
||||
/// `configured()` alone decides which launch route runs, so it is pinned
|
||||
/// directly rather than only through `validate_launch_config`. All three
|
||||
/// fields are required: a profile missing any of them cannot start a game.
|
||||
#[test]
|
||||
fn configured_requires_runner_executable_and_dir() {
|
||||
let mut p = GameProfile::default();
|
||||
assert!(!p.configured());
|
||||
p.runner = "umu-run".into();
|
||||
assert!(!p.configured(), "runner alone is not launchable");
|
||||
p.executable = "G.exe".into();
|
||||
assert!(!p.configured(), "no game_dir is not launchable");
|
||||
p.game_dir = "/games/G".into();
|
||||
assert!(p.configured());
|
||||
// Whitespace is not configuration.
|
||||
p.executable = " ".into();
|
||||
assert!(!p.configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_links_must_be_relative_and_have_a_prefix() {
|
||||
let mut p = GameProfile {
|
||||
runner: "umu-run".into(),
|
||||
executable: "G.exe".into(),
|
||||
game_dir: "/games/G".into(),
|
||||
prefix_links: vec![PrefixLink {
|
||||
link: "dosdevices/w:".into(),
|
||||
target: "/mnt".into(),
|
||||
}],
|
||||
..GameProfile::default()
|
||||
};
|
||||
assert!(
|
||||
p.validate().unwrap_err().contains("wine_prefix"),
|
||||
"links without a prefix have nowhere to go"
|
||||
);
|
||||
|
||||
p.wine_prefix = "/prefix".into();
|
||||
assert!(p.validate().is_ok());
|
||||
|
||||
// An absolute link would be created outside the prefix entirely.
|
||||
p.prefix_links[0].link = "/etc/w:".into();
|
||||
assert!(p.validate().unwrap_err().contains("relative"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_requires_a_valid_ea_account() {
|
||||
let mut c = LauncherConfig::default();
|
||||
// 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");
|
||||
pub fn bridge_env(&self) -> Vec<(String, String)> {
|
||||
vec![
|
||||
("CORE_URL".into(), self.bridge_core_url.clone()),
|
||||
("LISTEN_ADDR".into(), self.bridge_listen_addr.clone()),
|
||||
("CAPTURES_DIR".into(), self.bridge_captures_dir.clone()),
|
||||
("TLS_ENABLED".into(), self.bridge_tls_enabled.to_string()),
|
||||
("RUST_LOG".into(), "openfut_bridge=info".into()),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
//! FIFA 17 verified patched-client capability negotiation (launcher side).
|
||||
//!
|
||||
//! The FIFA 17 backend suppresses its synthetic empty-My-Packs sentinel (pack id
|
||||
//! 65534) only when the *current* FIFA process has positively verified the
|
||||
//! CardsDLL resolver guard. autopatch proves that at runtime and advertises it on
|
||||
//! its stdout; the launcher parses that line, records the capability for the live
|
||||
//! FIFA process, and registers it with the backend over the same tiny stdlib-HTTP
|
||||
//! transport used by [`crate::account_sync`]. See
|
||||
//! `docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md`.
|
||||
//!
|
||||
//! Everything here is fail-closed: a line we cannot parse, or a registration POST
|
||||
//! that fails, simply leaves the backend on its default active-sentinel path.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
const CAPABILITY_NAME: &str = "empty_mypacks_resolver";
|
||||
const CAPABILITY_PATH: &str = "/openfut/fifa17/capability";
|
||||
const TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Capabilities verified for the *current* FIFA process. Starts UNKNOWN at each
|
||||
/// launch and is discarded when that FIFA process ends — it is never persisted,
|
||||
/// so a previous launch's capability can never leak into a later one.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Fifa17ClientCapabilities {
|
||||
/// `Some(version)` once autopatch has verified the resolver guard for the
|
||||
/// live FIFA process; `None` while unknown / unverified.
|
||||
pub empty_mypacks_resolver: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CapabilityRegistration<'a> {
|
||||
capability: &'a str,
|
||||
version: u32,
|
||||
persona_id: u64,
|
||||
fifa_pid: u64,
|
||||
}
|
||||
|
||||
/// Pure parser for an autopatch stdout line. Returns `Some(version)` iff the raw
|
||||
/// line advertises the capability — it must contain both `verified capability`
|
||||
/// and `fifa17.empty_mypacks_resolver=<N>` (with `<N>` a `u32`). Non-advertising
|
||||
/// lines (e.g. `guard status=UNSUPPORTED_BUILD …`) and unrelated log output
|
||||
/// return `None`. Robust to a trailing ` fifa_pid=<pid>`.
|
||||
pub fn parse_capability_line(line: &str) -> Option<u32> {
|
||||
if !line.contains("verified capability") {
|
||||
return None;
|
||||
}
|
||||
parse_u32_after(line, "fifa17.empty_mypacks_resolver=")
|
||||
}
|
||||
|
||||
/// Extract the FIFA pid from a `fifa_pid=<n>` token if present.
|
||||
pub fn parse_fifa_pid(line: &str) -> Option<u64> {
|
||||
let digits = digits_after(line, "fifa_pid=")?;
|
||||
digits.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn parse_u32_after(line: &str, marker: &str) -> Option<u32> {
|
||||
digits_after(line, marker)?.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
fn digits_after<'a>(line: &'a str, marker: &str) -> Option<&'a str> {
|
||||
let start = line.find(marker)? + marker.len();
|
||||
let rest = &line[start..];
|
||||
let end = rest
|
||||
.find(|c: char| !c.is_ascii_digit())
|
||||
.unwrap_or(rest.len());
|
||||
if end == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(&rest[..end])
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the verified capability with the backend via `POST
|
||||
/// /openfut/fifa17/capability`. Modeled exactly on [`crate::account_sync::sync`]:
|
||||
/// a tiny stdlib `TcpStream` client, `Connection: close`, 3s timeouts, status
|
||||
/// line parsed, and any non-2xx (or connect/IO error) returned as `Err`. The
|
||||
/// caller logs the outcome; a failure is fail-closed — the backend records
|
||||
/// nothing and keeps the sentinel.
|
||||
pub fn register(
|
||||
host: &str,
|
||||
port: u16,
|
||||
persona_id: u64,
|
||||
fifa_pid: u64,
|
||||
version: u32,
|
||||
) -> Result<(), String> {
|
||||
let host = host.trim();
|
||||
let address = (host, port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|error| format!("cannot resolve capability server {host}:{port}: {error}"))?
|
||||
.next()
|
||||
.ok_or_else(|| format!("capability server {host}:{port} resolved to no addresses"))?;
|
||||
let mut stream = TcpStream::connect_timeout(&address, TIMEOUT)
|
||||
.map_err(|error| format!("cannot connect to capability server {host}:{port}: {error}"))?;
|
||||
stream
|
||||
.set_read_timeout(Some(TIMEOUT))
|
||||
.map_err(|error| format!("cannot set capability timeout: {error}"))?;
|
||||
stream
|
||||
.set_write_timeout(Some(TIMEOUT))
|
||||
.map_err(|error| format!("cannot set capability timeout: {error}"))?;
|
||||
|
||||
let payload = serde_json::to_vec(&CapabilityRegistration {
|
||||
capability: CAPABILITY_NAME,
|
||||
version,
|
||||
persona_id,
|
||||
fifa_pid,
|
||||
})
|
||||
.map_err(|error| format!("cannot encode capability request: {error}"))?;
|
||||
|
||||
let request = format!(
|
||||
"POST {CAPABILITY_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
payload.len()
|
||||
);
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.and_then(|()| stream.write_all(&payload))
|
||||
.map_err(|error| format!("cannot send capability request: {error}"))?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream
|
||||
.read_to_end(&mut response)
|
||||
.map_err(|error| format!("cannot read capability response: {error}"))?;
|
||||
let separator = response
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.ok_or_else(|| "capability server returned a malformed HTTP response".to_string())?;
|
||||
let headers = std::str::from_utf8(&response[..separator])
|
||||
.map_err(|_| "capability server returned non-UTF-8 headers".to_string())?;
|
||||
let status = headers
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.ok_or_else(|| "capability server returned a malformed status line".to_string())?;
|
||||
if !(200..300).contains(&status) {
|
||||
let detail = String::from_utf8_lossy(&response[separator + 4..]);
|
||||
return Err(format!(
|
||||
"capability server rejected registration (HTTP {status}): {detail}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn parses_the_verified_capability_line() {
|
||||
let line =
|
||||
"[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242";
|
||||
assert_eq!(parse_capability_line(line), Some(1));
|
||||
assert_eq!(parse_fifa_pid(line), Some(4242));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_advertising_status_line_yields_none() {
|
||||
let line =
|
||||
"[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=4242 (no capability advertised)";
|
||||
assert_eq!(parse_capability_line(line), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_log_line_yields_none() {
|
||||
let line = "[autopatch] patched /proc/4242/mem at rva 0x14858";
|
||||
assert_eq!(parse_capability_line(line), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_gating_is_left_to_the_backend() {
|
||||
let line = "[store-guard] verified capability fifa17.empty_mypacks_resolver=2 fifa_pid=7";
|
||||
assert_eq!(parse_capability_line(line), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_posts_capability_to_the_backend() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&request);
|
||||
assert!(request.starts_with("POST /openfut/fifa17/capability HTTP/1.1"));
|
||||
assert!(request.contains("\"capability\":\"empty_mypacks_resolver\""));
|
||||
assert!(request.contains("\"version\":1"));
|
||||
assert!(request.contains("\"personaId\":12345678"));
|
||||
assert!(request.contains("\"fifaPid\":4242"));
|
||||
let body = r#"{"status":"OK"}"#;
|
||||
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();
|
||||
});
|
||||
|
||||
register("127.0.0.1", port, 12345678, 4242, 1).unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
//! Launch the game directly, without an external shell script.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! The launcher used to shell out to a user-written script (`game_launch_command`)
|
||||
//! that set the Proton environment, prepared the Wine prefix, regenerated the
|
||||
//! DRM licence and finally ran the game. That script lived on the user's Desktop
|
||||
//! — and on 2026-08-11 it was moved to the Trash, after which every launch failed
|
||||
//! with `sh: No such file or directory`. Three unrelated client-side faults that
|
||||
//! morning each looked like "the game crashed"; none of them were.
|
||||
//!
|
||||
//! Everything the script did is mechanical and belongs inside the launcher, where
|
||||
//! it cannot be deleted, is covered by tests, and reports failures into the same
|
||||
//! log buffer as the rest of the launch.
|
||||
//!
|
||||
//! # What stays out of this file
|
||||
//!
|
||||
//! Every FIFA-17 fact — the runner, the executable, the prefix path, the `w:`
|
||||
//! drive symlink, the licence file id — is [`GameProfile`] *data*, not code.
|
||||
//! OpenFUT is not a FIFA 17 project; FIFA 17 is its first reference target. A
|
||||
//! second game must be a different profile, never a second branch in here.
|
||||
//!
|
||||
//! `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;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::config::GameProfile;
|
||||
use crate::logs::LogBuffer;
|
||||
|
||||
type Log = Arc<Mutex<LogBuffer>>;
|
||||
|
||||
fn say(log: &Log, msg: impl Into<String>) {
|
||||
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. `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);
|
||||
if !game_dir.is_dir() {
|
||||
anyhow::bail!("game_dir does not exist: {}", game_dir.display());
|
||||
}
|
||||
|
||||
prepare_prefix(profile, log)?;
|
||||
ensure_license(profile, log)?;
|
||||
|
||||
let mut cmd = Command::new(&profile.runner);
|
||||
cmd.arg(&profile.executable)
|
||||
.current_dir(&game_dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
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);
|
||||
}
|
||||
|
||||
say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] launching {} {} (cwd {})",
|
||||
profile.runner,
|
||||
profile.executable,
|
||||
game_dir.display()
|
||||
),
|
||||
);
|
||||
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", profile.runner))?;
|
||||
stream(
|
||||
child,
|
||||
log.clone(),
|
||||
"[launcher] game process exited.",
|
||||
on_exit,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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>`:
|
||||
/// an existing link is replaced, so re-running is harmless.
|
||||
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let prefix = PathBuf::from(&profile.wine_prefix);
|
||||
for link in &profile.prefix_links {
|
||||
let path = prefix.join(&link.link);
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("prefix link has no parent: {}", link.link))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
// Replace rather than fail: `ln -sfn` semantics. Only ever remove a
|
||||
// symlink — refusing on a real file avoids destroying prefix contents
|
||||
// if a profile is misconfigured.
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_file(&path)?,
|
||||
Ok(_) => anyhow::bail!(
|
||||
"refusing to replace {}: it exists and is not a symlink",
|
||||
path.display()
|
||||
),
|
||||
Err(_) => {}
|
||||
}
|
||||
std::os::unix::fs::symlink(&link.target, &path)?;
|
||||
say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] prefix link {} -> {}",
|
||||
path.display(),
|
||||
link.target
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make sure the DRM licence file exists, running the generator if it does not.
|
||||
///
|
||||
/// A crashed or failed launch deletes the licence, so this runs before every
|
||||
/// launch rather than only on first setup — that is the behaviour the shell
|
||||
/// script proved, and it is why a crash is normally self-healing on the next try.
|
||||
fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
|
||||
let Some(lic) = &profile.license else {
|
||||
return Ok(());
|
||||
};
|
||||
let path = resolve_under_prefix(&profile.wine_prefix, &lic.path);
|
||||
if non_empty_file(&path) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] licence missing ({}) — running {} to regenerate it",
|
||||
path.display(),
|
||||
lic.generator
|
||||
),
|
||||
);
|
||||
|
||||
let mut cmd = Command::new(&profile.runner);
|
||||
cmd.arg(&lic.generator)
|
||||
.current_dir(&profile.game_dir)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
for (k, v) in &profile.env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
if !profile.wine_prefix.trim().is_empty() {
|
||||
cmd.env("WINEPREFIX", &profile.wine_prefix);
|
||||
}
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("could not start licence generator: {e}"))?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(lic.timeout_secs.max(1));
|
||||
while Instant::now() < deadline {
|
||||
if non_empty_file(&path) {
|
||||
stop_generator(&mut child, lic, log);
|
||||
say(log, "[launcher] licence regenerated.");
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
stop_generator(&mut child, lic, log);
|
||||
anyhow::bail!(
|
||||
"{} did not create {} within {}s. Run it manually, choose GENERATE, then launch again.",
|
||||
lic.generator,
|
||||
path.display(),
|
||||
lic.timeout_secs
|
||||
)
|
||||
}
|
||||
|
||||
/// Stop the licence generator and the Windows process it started.
|
||||
///
|
||||
/// Killing the runner is not enough: it launches the executable through Proton,
|
||||
/// so the `.exe` outlives its parent. The shell script used `pkill -f` for this
|
||||
/// and it is reproduced deliberately — the pattern is a Windows executable name,
|
||||
/// which cannot match the launcher or a shell running it. (A `pkill -f` pattern
|
||||
/// that *can* match its own caller is a real hazard; this one cannot.)
|
||||
fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
match Command::new("pkill").arg("-f").arg(&lic.generator).status() {
|
||||
Ok(_) => {}
|
||||
Err(e) => say(
|
||||
log,
|
||||
format!(
|
||||
"[launcher] note: could not run pkill for {}: {e}",
|
||||
lic.generator
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A relative licence path is taken as relative to the Wine prefix; an absolute
|
||||
/// one is used as given.
|
||||
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() || prefix.trim().is_empty() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
Path::new(prefix).join(p)
|
||||
}
|
||||
}
|
||||
|
||||
/// The script's `[[ -s FILE ]]`: present *and* non-empty. A zero-byte licence is
|
||||
/// as useless as a missing one, and treating it as valid would skip the
|
||||
/// regeneration that fixes it.
|
||||
fn non_empty_file(path: &Path) -> bool {
|
||||
std::fs::metadata(path)
|
||||
.map(|m| m.len() > 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
let buf = Arc::clone(&log);
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
log.lock().push(exit_msg.to_string());
|
||||
on_exit();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{LicenseCheck, PrefixLink};
|
||||
|
||||
fn log() -> Log {
|
||||
Arc::new(Mutex::new(LogBuffer::new()))
|
||||
}
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let d =
|
||||
std::env::temp_dir().join(format!("openfut-launch-test-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_licence_path_is_resolved_under_the_prefix() {
|
||||
assert_eq!(
|
||||
resolve_under_prefix("/p", "drive_c/lic.dlf"),
|
||||
PathBuf::from("/p/drive_c/lic.dlf")
|
||||
);
|
||||
// Absolute wins, so a profile can point outside the prefix.
|
||||
assert_eq!(
|
||||
resolve_under_prefix("/p", "/elsewhere/lic.dlf"),
|
||||
PathBuf::from("/elsewhere/lic.dlf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_byte_licence_does_not_count_as_present() {
|
||||
let d = tmpdir("empty-lic");
|
||||
let f = d.join("lic.dlf");
|
||||
std::fs::write(&f, b"").unwrap();
|
||||
assert!(
|
||||
!non_empty_file(&f),
|
||||
"an empty licence must trigger regeneration"
|
||||
);
|
||||
std::fs::write(&f, b"x").unwrap();
|
||||
assert!(non_empty_file(&f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_links_are_created_and_are_idempotent() {
|
||||
let d = tmpdir("links");
|
||||
let prefix = d.join("prefix");
|
||||
let target = d.join("target");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "true".into(),
|
||||
executable: "x.exe".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: prefix.to_string_lossy().into(),
|
||||
prefix_links: vec![PrefixLink {
|
||||
link: "dosdevices/w:".into(),
|
||||
target: target.to_string_lossy().into(),
|
||||
}],
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
prepare_prefix(&profile, &log()).expect("first run creates the link");
|
||||
let link = prefix.join("dosdevices/w:");
|
||||
assert!(std::fs::symlink_metadata(&link)
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink());
|
||||
|
||||
// Re-running must not fail — the launcher prepares the prefix on EVERY
|
||||
// launch, so a second launch would break if this were not idempotent.
|
||||
prepare_prefix(&profile, &log()).expect("second run replaces the link");
|
||||
assert_eq!(std::fs::read_link(&link).unwrap(), target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_file_where_a_link_belongs_is_refused_not_deleted() {
|
||||
let d = tmpdir("clobber");
|
||||
let prefix = d.join("prefix");
|
||||
std::fs::create_dir_all(prefix.join("dosdevices")).unwrap();
|
||||
let occupied = prefix.join("dosdevices/w:");
|
||||
std::fs::write(&occupied, b"important").unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "true".into(),
|
||||
executable: "x.exe".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: prefix.to_string_lossy().into(),
|
||||
prefix_links: vec![PrefixLink {
|
||||
link: "dosdevices/w:".into(),
|
||||
target: "/tmp".into(),
|
||||
}],
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
assert!(prepare_prefix(&profile, &log()).is_err());
|
||||
assert_eq!(
|
||||
std::fs::read(&occupied).unwrap(),
|
||||
b"important",
|
||||
"a misconfigured profile must not destroy prefix contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_present_licence_skips_the_generator_entirely() {
|
||||
let d = tmpdir("lic-present");
|
||||
let lic = d.join("lic.dlf");
|
||||
std::fs::write(&lic, b"valid").unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "/nonexistent/runner".into(), // would fail if it were run
|
||||
executable: "x.exe".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: d.to_string_lossy().into(),
|
||||
license: Some(LicenseCheck {
|
||||
path: "lic.dlf".into(),
|
||||
generator: "_gen.exe".into(),
|
||||
timeout_secs: 1,
|
||||
}),
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
// Proves the skip: the runner path is invalid, so reaching the generator
|
||||
// would error. Ok() means it never tried.
|
||||
ensure_license(&profile, &log()).expect("present licence must short-circuit");
|
||||
}
|
||||
|
||||
/// The whole point of the licence step: a missing licence must actually run
|
||||
/// the generator and wait for it. Without this, deleting `ensure_license`
|
||||
/// entirely would still pass every other test in this file.
|
||||
#[test]
|
||||
fn a_missing_licence_runs_the_generator_and_waits_for_it() {
|
||||
let d = tmpdir("lic-regen");
|
||||
let lic = d.join("lic.dlf");
|
||||
let gen = d.join("gen.sh");
|
||||
// Sleeps first, so passing requires actually waiting rather than
|
||||
// happening to observe a file that was already there. The target path
|
||||
// is baked in: `generator` is passed as ONE argument, exactly as
|
||||
// `umu-run "_fifa17.exe"` is.
|
||||
std::fs::write(
|
||||
&gen,
|
||||
format!(
|
||||
"#!/bin/sh\nsleep 1\nprintf licensed > '{}'\n",
|
||||
lic.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let profile = GameProfile {
|
||||
runner: "/bin/sh".into(),
|
||||
executable: "unused".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: d.to_string_lossy().into(),
|
||||
license: Some(LicenseCheck {
|
||||
generator: gen.to_string_lossy().into(),
|
||||
path: "lic.dlf".into(),
|
||||
timeout_secs: 10,
|
||||
}),
|
||||
..GameProfile::default()
|
||||
};
|
||||
|
||||
assert!(!non_empty_file(&lic));
|
||||
ensure_license(&profile, &log()).expect("generator should produce the licence");
|
||||
assert!(non_empty_file(&lic), "licence was not created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_generator_that_never_delivers_times_out_with_an_actionable_error() {
|
||||
let d = tmpdir("lic-timeout");
|
||||
let gen = d.join("gen.sh");
|
||||
std::fs::write(&gen, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
let profile = GameProfile {
|
||||
runner: "/bin/sh".into(),
|
||||
executable: "unused".into(),
|
||||
game_dir: d.to_string_lossy().into(),
|
||||
wine_prefix: d.to_string_lossy().into(),
|
||||
license: Some(LicenseCheck {
|
||||
generator: gen.to_string_lossy().into(), // runs, writes nothing
|
||||
path: "lic.dlf".into(),
|
||||
timeout_secs: 1,
|
||||
}),
|
||||
..GameProfile::default()
|
||||
};
|
||||
let err = ensure_license(&profile, &log()).unwrap_err().to_string();
|
||||
assert!(err.contains("did not create"), "{err}");
|
||||
assert!(
|
||||
err.contains("GENERATE"),
|
||||
"the error must say what to do: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_refuses_a_missing_game_dir_before_touching_anything() {
|
||||
let profile = GameProfile {
|
||||
runner: "true".into(),
|
||||
executable: "x.exe".into(),
|
||||
game_dir: "/definitely/not/here".into(),
|
||||
..GameProfile::default()
|
||||
};
|
||||
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");
|
||||
}
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
//! Read-only health monitoring of the (remote) OpenFUT server.
|
||||
//!
|
||||
//! The launcher no longer *controls* the servers — they run elsewhere (e.g. in
|
||||
//! Docker on the server host). This module polls the configured server in a
|
||||
//! background thread and exposes a snapshot the UI can render. It never starts,
|
||||
//! 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,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// A snapshot of the last health probe, rendered by the dashboard.
|
||||
#[derive(Clone)]
|
||||
pub struct HealthState {
|
||||
/// None = not yet checked / no target; Some(true/false) = reachable or not.
|
||||
pub reachable: Option<bool>,
|
||||
pub detail: String,
|
||||
pub last_checked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for HealthState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
reachable: None,
|
||||
detail: "No server configured.".into(),
|
||||
last_checked: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background poller. Holds a shared target (host, port) the UI can update when
|
||||
/// the user changes the server address, and a shared state the UI reads.
|
||||
pub struct HealthMonitor {
|
||||
pub state: Arc<Mutex<HealthState>>,
|
||||
target: Arc<Mutex<Option<(String, u16)>>>,
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl HealthMonitor {
|
||||
pub fn new() -> Self {
|
||||
let state = Arc::new(Mutex::new(HealthState::default()));
|
||||
let target: Arc<Mutex<Option<(String, u16)>>> = 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 => {
|
||||
*t_state.lock() = HealthState::default();
|
||||
}
|
||||
Some((host, port)) => {
|
||||
let snapshot = probe(&host, port);
|
||||
*t_state.lock() = snapshot;
|
||||
}
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
state,
|
||||
target,
|
||||
running,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() = target;
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> HealthState {
|
||||
self.state.lock().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HealthMonitor {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single reachability probe: DNS-resolve host:port and attempt a bounded TCP
|
||||
/// connect. A successful connect proves the FIFA client can reach the bridge.
|
||||
fn probe(host: &str, port: u16) -> HealthState {
|
||||
let now = Some(Instant::now());
|
||||
let addrs = match (host, port).to_socket_addrs() {
|
||||
Ok(a) => a.collect::<Vec<_>>(),
|
||||
Err(e) => {
|
||||
return HealthState {
|
||||
reachable: Some(false),
|
||||
detail: format!("Cannot resolve {host}: {e}"),
|
||||
last_checked: now,
|
||||
};
|
||||
}
|
||||
};
|
||||
if addrs.is_empty() {
|
||||
return HealthState {
|
||||
reachable: Some(false),
|
||||
detail: format!("{host} resolved to no addresses"),
|
||||
last_checked: now,
|
||||
};
|
||||
}
|
||||
for addr in &addrs {
|
||||
if TcpStream::connect_timeout(addr, CONNECT_TIMEOUT).is_ok() {
|
||||
return HealthState {
|
||||
reachable: Some(true),
|
||||
detail: format!("Reachable at {addr}"),
|
||||
last_checked: now,
|
||||
};
|
||||
}
|
||||
}
|
||||
HealthState {
|
||||
reachable: Some(false),
|
||||
detail: format!("{host}:{port} not reachable"),
|
||||
last_checked: now,
|
||||
}
|
||||
}
|
||||
-940
@@ -1,940 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,778 +0,0 @@
|
||||
//! FIFA 17 local companion services — LSX (Origin emulator) + autopatch
|
||||
//! (ProtoSSL cert-verify memory patcher). Both are inherently local to the game
|
||||
//! machine and are managed by the launcher as child processes, mirroring the way
|
||||
//! `setup::launch_game` spawns and log-streams the game.
|
||||
//!
|
||||
//! WHY THESE TWO ARE LOCAL (and the rest is not): the heavy FUT responders
|
||||
//! (Blaze / UTAS / roster / POW) run in the server container. LSX must stay here
|
||||
//! because the game dials it on the hardcoded loopback `127.0.0.1:4216`;
|
||||
//! autopatch must stay here because it writes `/proc/<FIFA17.exe>/mem`.
|
||||
//!
|
||||
//! Lifecycle: each service is a long-running daemon. We keep the `Child` handle
|
||||
//! so the UI can show running/stopped and stop them. Both run as the launcher
|
||||
//! 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, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{mpsc, Arc},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
use crate::fifa17_capability::{
|
||||
parse_capability_line, parse_fifa_pid, register, Fifa17ClientCapabilities,
|
||||
};
|
||||
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,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which companion service. The `str` values are used in log prefixes.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Service {
|
||||
/// LSX Origin/EADesktop emulator — binds loopback 4216, unprivileged.
|
||||
Lsx,
|
||||
/// autopatch — patches FIFA17.exe process memory after host ptrace arming.
|
||||
Autopatch,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Service::Lsx => "LSX",
|
||||
Service::Autopatch => "autopatch",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 => "openfut-lsx",
|
||||
Service::Autopatch => "openfut-autopatch",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: resolve_binary(service).to_string_lossy().into_owned(),
|
||||
args,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stop_work<F>(work: F) -> mpsc::Receiver<anyhow::Result<()>>
|
||||
where
|
||||
F: FnOnce() -> anyhow::Result<()> + Send + 'static,
|
||||
{
|
||||
let (send, receive) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = send.send(work());
|
||||
});
|
||||
receive
|
||||
}
|
||||
|
||||
fn wait_for_listener_ready(
|
||||
child: &mut Child,
|
||||
address: SocketAddr,
|
||||
timeout: Duration,
|
||||
) -> anyhow::Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
// Let immediate startup/bind errors surface before accepting an occupied
|
||||
// port as evidence that this child became ready.
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
loop {
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.map_err(|error| anyhow::anyhow!("could not inspect LSX startup: {error}"))?
|
||||
{
|
||||
anyhow::bail!("LSX exited before becoming ready ({status}); port 4216 may be in use");
|
||||
}
|
||||
match TcpListener::bind(address) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => return Ok(()),
|
||||
Err(error) => anyhow::bail!("could not probe LSX listener {address}: {error}"),
|
||||
Ok(listener) => drop(listener),
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"LSX did not bind {address} within {} ms",
|
||||
timeout.as_millis()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// A managed companion service process.
|
||||
#[derive(Default)]
|
||||
pub struct ManagedService {
|
||||
child: Option<Child>,
|
||||
stopping: Option<mpsc::Receiver<anyhow::Result<()>>>,
|
||||
}
|
||||
|
||||
impl ManagedService {
|
||||
/// Wrap an already-spawned child.
|
||||
pub fn from_child(child: Child) -> Self {
|
||||
Self {
|
||||
child: Some(child),
|
||||
stopping: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True while the child is spawned and has not yet exited. Reaps the exit
|
||||
/// status if it has, so the UI reflects a service that died on its own.
|
||||
pub fn running(&mut self, log: &Arc<Mutex<LogBuffer>>, label: &str) -> bool {
|
||||
if let Some(result) = self.stopping.as_ref() {
|
||||
match result.try_recv() {
|
||||
Ok(Ok(())) => {
|
||||
log.lock().push(format!("[launcher] {label} stopped."));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
log.lock()
|
||||
.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().push(format!(
|
||||
"[launcher] {label} stop worker exited unexpectedly."
|
||||
));
|
||||
self.stopping = None;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match self.child.as_mut() {
|
||||
None => false,
|
||||
Some(c) => match c.try_wait() {
|
||||
Ok(None) => true,
|
||||
Ok(Some(status)) => {
|
||||
log.lock()
|
||||
.push(format!("[launcher] {label} exited ({status})."));
|
||||
self.child = None;
|
||||
false
|
||||
}
|
||||
Err(_) => true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stopping(&self) -> bool {
|
||||
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() {
|
||||
return;
|
||||
}
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let label = service.label();
|
||||
log.lock().push(format!("[launcher] stopping {label}…"));
|
||||
|
||||
self.stopping = Some(dispatch_stop_work(move || {
|
||||
child
|
||||
.kill()
|
||||
.map_err(|error| anyhow::anyhow!("kill failed: {error}"))?;
|
||||
|
||||
child
|
||||
.wait()
|
||||
.map_err(|error| anyhow::anyhow!("reap failed: {error}"))?;
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedService {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut c) = self.child.take() {
|
||||
let _ = c.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend-registration wiring handed to the autopatch stdout reader so a
|
||||
/// verified resolver-guard line can advertise the per-FIFA-process capability to
|
||||
/// the backend. `Some(..)` for autopatch; `None` for LSX.
|
||||
pub struct CapabilityWiring {
|
||||
pub server_host: String,
|
||||
pub account_sync_port: u16,
|
||||
pub sink: Arc<Mutex<Fifa17ClientCapabilities>>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
persona_id: u64,
|
||||
persona_name: &str,
|
||||
capability: Option<CapabilityWiring>,
|
||||
log: Arc<Mutex<LogBuffer>>,
|
||||
) -> anyhow::Result<Child> {
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
.join("openfut-autopatch.log");
|
||||
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
|
||||
}
|
||||
// Put each companion in its own process group for lifecycle isolation.
|
||||
cmd.process_group(0);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
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.binary()))?;
|
||||
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let buf = Arc::clone(&log);
|
||||
let lbl = label.to_string();
|
||||
// Only autopatch carries capability wiring; LSX passes `None`.
|
||||
let cap_wiring = capability;
|
||||
let cap_persona = persona_id;
|
||||
std::thread::spawn(move || {
|
||||
// Fires the backend registration at most once per FIFA process.
|
||||
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().push(format!("[{lbl}] {line}"));
|
||||
|
||||
let Some(wiring) = cap_wiring.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if registered {
|
||||
continue;
|
||||
}
|
||||
let Some(version) = parse_capability_line(&line) else {
|
||||
continue;
|
||||
};
|
||||
registered = true;
|
||||
let fifa_pid = parse_fifa_pid(&line).unwrap_or(0);
|
||||
wiring.sink.lock().empty_mypacks_resolver = Some(version);
|
||||
{
|
||||
let mut log = buf.lock();
|
||||
log.push(format!(
|
||||
"[fifa17] resolver capability verified for FIFA pid {fifa_pid}"
|
||||
));
|
||||
log.push(format!(
|
||||
"[fifa17] registering capability for session (persona {cap_persona})"
|
||||
));
|
||||
}
|
||||
match register(
|
||||
&wiring.server_host,
|
||||
wiring.account_sync_port,
|
||||
cap_persona,
|
||||
fifa_pid,
|
||||
version,
|
||||
) {
|
||||
Ok(()) => buf
|
||||
.lock()
|
||||
.push("[fifa17] capability registered with backend".to_string()),
|
||||
Err(error) => buf
|
||||
.lock()
|
||||
.push(format!("[fifa17] capability registration failed: {error}")),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
let buf = Arc::clone(&log);
|
||||
let lbl = label.to_string();
|
||||
std::thread::spawn(move || {
|
||||
for line in BufReader::new(err).lines().map_while(Result::ok) {
|
||||
buf.lock().push(format!("[{lbl}] {line}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if service == Service::Lsx {
|
||||
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()
|
||||
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
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_its_own_binary_with_launcher_ownership() {
|
||||
let parts = command_parts(Service::Autopatch);
|
||||
assert_eq!(
|
||||
Path::new(&parts.program).file_name().unwrap(),
|
||||
"openfut-autopatch"
|
||||
);
|
||||
// The launcher pid is how autopatch learns to exit with its owner.
|
||||
assert_eq!(
|
||||
parts.args,
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_work_is_dispatched_without_blocking_the_caller() {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let started = Instant::now();
|
||||
let done = dispatch_stop_work(|| {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
Ok(())
|
||||
});
|
||||
|
||||
assert!(started.elapsed() < Duration::from_millis(100));
|
||||
assert!(done.try_recv().is_err());
|
||||
assert!(done.recv_timeout(Duration::from_secs(1)).unwrap().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_rejects_an_lsx_child_that_exits_before_binding() {
|
||||
let mut child = Command::new("sh")
|
||||
.args(["-c", "exit 7"])
|
||||
.spawn()
|
||||
.expect("spawn short-lived child");
|
||||
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0));
|
||||
let error = wait_for_listener_ready(&mut child, address, Duration::from_secs(1))
|
||||
.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())
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-100
@@ -1,27 +1,15 @@
|
||||
mod account_monitor;
|
||||
mod account_sync;
|
||||
mod app;
|
||||
mod arm;
|
||||
mod config;
|
||||
mod fifa17_capability;
|
||||
mod game_launch;
|
||||
mod health;
|
||||
mod launch;
|
||||
mod local_services;
|
||||
mod logs;
|
||||
mod netcheck;
|
||||
mod preflight;
|
||||
mod process;
|
||||
mod setup;
|
||||
mod theme;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("OpenFUT Launcher")
|
||||
.with_app_id("openfut-launcher")
|
||||
.with_icon(app_icon())
|
||||
.with_inner_size([1040.0, 720.0])
|
||||
.with_min_inner_size([880.0, 600.0]),
|
||||
.with_inner_size([780.0, 560.0])
|
||||
.with_min_inner_size([600.0, 400.0]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -31,88 +19,3 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
//! "Test Connection" support: verify the configured OpenFUT server is actually
|
||||
//! reachable before the user launches FIFA.
|
||||
//!
|
||||
//! This resolves the configured host through the SAME shared path the hook uses
|
||||
//! ([`openfut_common::ServerConfig::resolve`]) and then does a bounded TCP
|
||||
//! connect to the OpenFUT destination port(s). It never falls back to loopback:
|
||||
//! if the server isn't configured/resolvable, it reports that plainly.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::time::Duration;
|
||||
|
||||
use openfut_common::ServerConfig;
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Outcome of a connection test, suitable for showing in the UI.
|
||||
pub struct TestOutcome {
|
||||
pub ok: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Resolve `cfg` and attempt to reach the OpenFUT server. Checks the HTTPS
|
||||
/// destination port (the one EA :443 traffic is redirected to) since that is the
|
||||
/// service the client relies on first. On success, also reports whether the core
|
||||
/// `/health` endpoint answered (best-effort; a plain-text probe, TLS not spoken).
|
||||
pub fn test_connection(cfg: &ServerConfig) -> TestOutcome {
|
||||
let resolved = match cfg.resolve() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return TestOutcome {
|
||||
ok: false,
|
||||
message: format!("Cannot resolve OpenFUT server: {e}"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let addr = SocketAddr::from((resolved.redirect_ip, resolved.ports.https));
|
||||
match TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) {
|
||||
Ok(mut stream) => {
|
||||
// Best-effort HTTP probe of /health. The bridge front door speaks
|
||||
// TLS, so a plaintext request may not get a clean 200 — a successful
|
||||
// TCP connect already proves reachability, so we don't fail on this.
|
||||
let health = probe_health(&mut stream);
|
||||
let detail = match health {
|
||||
Some(true) => " (core /health responded OK)".to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
TestOutcome {
|
||||
ok: true,
|
||||
message: format!(
|
||||
"Reachable: {}:{} is accepting connections{detail}.",
|
||||
resolved.redirect_ip, resolved.ports.https
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(e) => TestOutcome {
|
||||
ok: false,
|
||||
message: format!(
|
||||
"Could not reach {}:{} — {e}. Check the server is running and the \
|
||||
address/port are correct.",
|
||||
resolved.redirect_ip, resolved.ports.https
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_health(stream: &mut TcpStream) -> Option<bool> {
|
||||
let _ = stream.set_read_timeout(Some(CONNECT_TIMEOUT));
|
||||
let _ = stream.set_write_timeout(Some(CONNECT_TIMEOUT));
|
||||
let req = "GET /health HTTP/1.0\r\nConnection: close\r\n\r\n";
|
||||
stream.write_all(req.as_bytes()).ok()?;
|
||||
let mut buf = [0u8; 512];
|
||||
let n = stream.read(&mut buf).ok()?;
|
||||
let text = String::from_utf8_lossy(&buf[..n]);
|
||||
Some(text.contains("200") || text.contains("\"status\""))
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
//! Pre-launch checks for the client-side state FIFA depends on.
|
||||
//!
|
||||
//! # Why
|
||||
//!
|
||||
//! On 2026-08-11 the game machine rebooted. Everything `client_arm.sh` sets —
|
||||
//! `ptrace_scope=0`, the DNAT of EA's hardcoded redirector IP, the
|
||||
//! `easw.easports.com` mapping — is volatile and was silently gone. The launcher
|
||||
//! started, the local services started, the game started, and forty minutes later
|
||||
//! the only symptom was FIFA's own dialog: *"the servers for this title have been
|
||||
//! shut down"*. Nothing in the stack said anything, because nothing was looking.
|
||||
//!
|
||||
//! Every one of those conditions is observable **without privilege**. This module
|
||||
//! looks, and reports before the user clicks Launch.
|
||||
//!
|
||||
//! # Deliberately not checked here
|
||||
//!
|
||||
//! Certificate parity across the FIFA-facing TLS services — the fault that cost
|
||||
//! three redirector gates — is the single most valuable check available, but the
|
||||
//! launcher has no TLS dependency (`account_sync` speaks plaintext HTTP by hand)
|
||||
//! and adding one is a decision, not a detail. `scripts/check-tls-parity.sh` on
|
||||
//! the server covers it in the meantime.
|
||||
//!
|
||||
//! # Advisory, not a gate
|
||||
//!
|
||||
//! Results colour the UI; they never disable Launch. A preflight that is itself
|
||||
//! wrong must not be able to lock the user out of their own game.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::LauncherConfig;
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
Pass,
|
||||
/// Genuinely wrong, but something else in the stack covers it, so the game
|
||||
/// can still work. Kept distinct from [`State::Fail`] because a checker that
|
||||
/// cries "this will fail" and is then contradicted by a working game teaches
|
||||
/// the user to ignore it — which is worse than not checking at all.
|
||||
Warn,
|
||||
Fail,
|
||||
/// Not configured, so there is nothing to assert. Never reported as a pass:
|
||||
/// "we did not look" and "we looked and it was fine" must not look alike.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Check {
|
||||
pub name: String,
|
||||
pub state: State,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Check {
|
||||
fn pass(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Pass,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn fail(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Fail,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn warn(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Warn,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
fn skip(name: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state: State::Skipped,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run every applicable check. Order is the order the game exercises them.
|
||||
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
|
||||
vec![
|
||||
ptrace_scope(),
|
||||
ea_redirect(cfg),
|
||||
hostname_mapping(cfg),
|
||||
backend_reachable(cfg),
|
||||
hook_config(cfg),
|
||||
]
|
||||
}
|
||||
|
||||
/// Checks that will stop the game working.
|
||||
pub fn failures(checks: &[Check]) -> usize {
|
||||
checks.iter().filter(|c| c.state == State::Fail).count()
|
||||
}
|
||||
|
||||
/// Checks that are wrong but survivable.
|
||||
pub fn warnings(checks: &[Check]) -> usize {
|
||||
checks.iter().filter(|c| c.state == State::Warn).count()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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)";
|
||||
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.
|
||||
Err(_) => Check::skip(NAME, "Yama not present on this kernel"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision, split from the file read so it can be tested.
|
||||
///
|
||||
/// Reading `/proc` in a test would assert facts about the machine running the
|
||||
/// suite rather than about this code — and left inline, "any value is fine"
|
||||
/// was a mutation no test could catch.
|
||||
fn ptrace_verdict(raw: &str) -> Check {
|
||||
const NAME: &str = "ptrace_scope (autopatch)";
|
||||
let v = raw.trim();
|
||||
if v == "0" {
|
||||
Check::pass(NAME, "0 — autopatch can attach")
|
||||
} else {
|
||||
Check::fail(
|
||||
NAME,
|
||||
format!("{v} — autopatch cannot patch FIFA. Click 'Arm client'."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// FIFA dials EA's redirector by hardcoded IP. Armed, that address is DNAT'd to
|
||||
/// the OpenFUT server and connects instantly; unarmed it leaves the LAN and
|
||||
/// times out — which is exactly the "servers have been shut down" dialog.
|
||||
///
|
||||
/// This tests the *effect* rather than reading firewall rules, so it needs no
|
||||
/// privilege and stays honest about what the game will actually experience.
|
||||
fn ea_redirect(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "EA redirector IP is redirected";
|
||||
let ip = cfg.ea_redirect_probe_ip.trim();
|
||||
if ip.is_empty() {
|
||||
return Check::skip(NAME, "no probe IP configured");
|
||||
}
|
||||
let Ok(addr) = ip.parse::<IpAddr>() else {
|
||||
return Check::fail(NAME, format!("ea_redirect_probe_ip is not an IP: {ip:?}"));
|
||||
};
|
||||
let port = cfg.openfut_blaze_redirector_port;
|
||||
match TcpStream::connect_timeout(&SocketAddr::new(addr, port), PROBE_TIMEOUT) {
|
||||
Ok(_) => Check::pass(NAME, format!("{ip}:{port} answered — redirect is in place")),
|
||||
Err(e) => Check::fail(
|
||||
NAME,
|
||||
format!("{ip}:{port} did not answer ({e}). Click 'Arm client'."),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The dead EA hostnames should resolve to the OpenFUT server.
|
||||
///
|
||||
/// Resolution is done with `getaddrinfo`, the same call the game makes, so a
|
||||
/// duplicate `/etc/hosts` line that shadows the OpenFUT one is caught by its
|
||||
/// effect. Parsing `/etc/hosts` would miss it: the file can contain the right
|
||||
/// line and still resolve to the wrong address, because the first match wins.
|
||||
///
|
||||
/// # Why a warning and not a failure
|
||||
///
|
||||
/// Measured, not assumed. On 2026-08-11 this reported `easw.easports.com ->
|
||||
/// ::1,127.0.0.1` and the game reached the FUT hub regardless. The reason is in
|
||||
/// `client_arm.sh`'s own header: the responders run with `OPENFUT_ADVERTISE`
|
||||
/// set, so after the first redirected contact the game is handed the server's
|
||||
/// *address* for every later hop and stops using the hostname. The name is only
|
||||
/// CardsDLL's built-in fallback.
|
||||
///
|
||||
/// So this is a real misconfiguration worth fixing and not a reason to expect
|
||||
/// failure. Reporting it as fatal, and then being contradicted by a working
|
||||
/// game, is how a checklist trains its user to ignore it.
|
||||
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
|
||||
const NAME: &str = "EA hostnames point at OpenFUT";
|
||||
if cfg.ea_hostnames.is_empty() {
|
||||
return Check::skip(NAME, "no EA hostnames configured");
|
||||
}
|
||||
let server = cfg.openfut_server_host.trim();
|
||||
if server.is_empty() {
|
||||
return Check::skip(NAME, "no OpenFUT server configured");
|
||||
}
|
||||
let want = match resolve(server) {
|
||||
Ok(ips) if !ips.is_empty() => ips,
|
||||
_ => {
|
||||
return Check::fail(
|
||||
NAME,
|
||||
format!("cannot resolve the OpenFUT server {server:?}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let mut wrong = Vec::new();
|
||||
for host in &cfg.ea_hostnames {
|
||||
match resolve(host) {
|
||||
Ok(got) if got.iter().any(|ip| want.contains(ip)) => {}
|
||||
Ok(got) => wrong.push(format!(
|
||||
"{host} -> {} (expected {})",
|
||||
join(&got),
|
||||
join(&want)
|
||||
)),
|
||||
Err(e) => wrong.push(format!("{host} -> unresolvable ({e})")),
|
||||
}
|
||||
}
|
||||
|
||||
if wrong.is_empty() {
|
||||
Check::pass(
|
||||
NAME,
|
||||
format!("{} host(s) resolve to {server}", cfg.ea_hostnames.len()),
|
||||
)
|
||||
} else {
|
||||
Check::warn(
|
||||
NAME,
|
||||
format!(
|
||||
"{}. Look for an earlier /etc/hosts line shadowing it. \
|
||||
Usually survivable: the server advertises its address, so the \
|
||||
game stops using this name after the first hop.",
|
||||
wrong.join("; ")
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The server side of the same question: are the ports the game will use open?
|
||||
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() {
|
||||
return Check::skip(NAME, "no OpenFUT server configured");
|
||||
}
|
||||
let ports = [
|
||||
("blaze redirector", cfg.openfut_blaze_redirector_port),
|
||||
("account sync", cfg.openfut_account_sync_port),
|
||||
];
|
||||
let mut dead = Vec::new();
|
||||
for (label, port) in ports {
|
||||
if !connects(host, port) {
|
||||
dead.push(format!("{label} :{port}"));
|
||||
}
|
||||
}
|
||||
if dead.is_empty() {
|
||||
Check::pass(NAME, format!("{host}: all {} ports answering", ports.len()))
|
||||
} else {
|
||||
Check::fail(NAME, format!("{host}: no answer on {}", dead.join(", ")))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(host: &str) -> std::io::Result<Vec<IpAddr>> {
|
||||
Ok((host, 0u16).to_socket_addrs()?.map(|a| a.ip()).collect())
|
||||
}
|
||||
|
||||
fn join(ips: &[IpAddr]) -> String {
|
||||
ips.iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> LauncherConfig {
|
||||
LauncherConfig::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unconfigured_launcher_skips_rather_than_passes() {
|
||||
// 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.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:#?}"
|
||||
);
|
||||
assert_eq!(failures(&checks), 0, "nothing configured is not a failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ptrace_scope_zero_lets_autopatch_work() {
|
||||
assert_eq!(ptrace_verdict("0\n").state, State::Pass);
|
||||
// 1 is the default on most distributions and is exactly the state that
|
||||
// let autopatch fail silently for forty minutes on 2026-08-11.
|
||||
assert_eq!(ptrace_verdict("1\n").state, State::Fail);
|
||||
assert_eq!(ptrace_verdict("2").state, State::Fail);
|
||||
assert_eq!(ptrace_verdict("3").state, State::Fail);
|
||||
assert!(ptrace_verdict("1").detail.contains("Arm client"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
|
||||
let mut c = cfg();
|
||||
c.ea_redirect_probe_ip = "not-an-ip".into();
|
||||
let check = ea_redirect(&c);
|
||||
assert_eq!(check.state, State::Fail);
|
||||
assert!(check.detail.contains("not an IP"), "{}", check.detail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_check_is_skipped_without_a_server_but_not_passed() {
|
||||
let mut c = cfg();
|
||||
c.ea_hostnames = vec!["easw.easports.com".into()];
|
||||
assert_eq!(hostname_mapping(&c).state, State::Skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_check_detects_a_host_pointing_somewhere_else() {
|
||||
// localhost and 127.0.0.1 resolve without a network; this is the
|
||||
// shadowed-/etc/hosts shape without depending on the real one.
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.2".into();
|
||||
c.ea_hostnames = vec!["localhost".into()];
|
||||
let check = hostname_mapping(&c);
|
||||
// Warn, not Fail: observed on 2026-08-11 to be survivable, because the
|
||||
// server advertises its address after the first hop.
|
||||
assert_eq!(check.state, State::Warn, "{}", check.detail);
|
||||
assert!(check.detail.contains("localhost -> "), "{}", check.detail);
|
||||
}
|
||||
|
||||
/// 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()];
|
||||
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]
|
||||
fn hostname_check_passes_when_it_points_at_the_server() {
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.1".into();
|
||||
c.ea_hostnames = vec!["localhost".into()];
|
||||
// `localhost` may resolve to ::1 as well; the check requires only that
|
||||
// one resolved address matches, which mirrors what connecting does.
|
||||
assert_eq!(hostname_mapping(&c).state, State::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_backend_port_is_reported_as_a_failure() {
|
||||
let mut c = cfg();
|
||||
c.openfut_server_host = "127.0.0.1".into();
|
||||
// Port 1 requires root to bind, so nothing is listening on it.
|
||||
c.openfut_blaze_redirector_port = 1;
|
||||
c.openfut_account_sync_port = 1;
|
||||
let check = backend_reachable(&c);
|
||||
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);
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
use std::{
|
||||
io::{BufRead, BufReader},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
};
|
||||
|
||||
use crate::logs::LogBuffer;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ServiceStatus {
|
||||
Stopped,
|
||||
Starting,
|
||||
Running,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl ServiceStatus {
|
||||
pub fn label(&self) -> &str {
|
||||
match self {
|
||||
ServiceStatus::Stopped => "Stopped",
|
||||
ServiceStatus::Starting => "Starting…",
|
||||
ServiceStatus::Running => "Running",
|
||||
ServiceStatus::Failed(_) => "Failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(&self) -> egui::Color32 {
|
||||
match self {
|
||||
ServiceStatus::Running => egui::Color32::from_rgb(80, 200, 120),
|
||||
ServiceStatus::Starting => egui::Color32::from_rgb(255, 200, 0),
|
||||
ServiceStatus::Failed(_) => egui::Color32::from_rgb(220, 60, 60),
|
||||
ServiceStatus::Stopped => egui::Color32::from_rgb(150, 150, 150),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ServiceHandle {
|
||||
child: Option<Child>,
|
||||
pub status: Arc<Mutex<ServiceStatus>>,
|
||||
}
|
||||
|
||||
impl ServiceHandle {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
child: None,
|
||||
status: Arc::new(Mutex::new(ServiceStatus::Stopped)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
&mut self,
|
||||
binary: &str,
|
||||
env_pairs: &[(String, String)],
|
||||
log_buf: Arc<Mutex<LogBuffer>>,
|
||||
) -> anyhow::Result<()> {
|
||||
if self.is_running() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
*self.status.lock().unwrap() = ServiceStatus::Starting;
|
||||
|
||||
let mut cmd = Command::new(binary);
|
||||
for (k, v) in env_pairs {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().inspect_err(|e| {
|
||||
*self.status.lock().unwrap() = ServiceStatus::Failed(e.to_string());
|
||||
})?;
|
||||
|
||||
// Drain stdout
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let buf = Arc::clone(&log_buf);
|
||||
let status = Arc::clone(&self.status);
|
||||
thread::spawn(move || {
|
||||
*status.lock().unwrap() = ServiceStatus::Running;
|
||||
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drain stderr
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let buf = Arc::clone(&log_buf);
|
||||
thread::spawn(move || {
|
||||
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
||||
buf.lock().unwrap().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.child = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
*self.status.lock().unwrap() = ServiceStatus::Stopped;
|
||||
}
|
||||
|
||||
pub fn is_running(&mut self) -> bool {
|
||||
if let Some(child) = &mut self.child {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
// process exited
|
||||
self.child = None;
|
||||
*self.status.lock().unwrap() = ServiceStatus::Stopped;
|
||||
false
|
||||
}
|
||||
Ok(None) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> ServiceStatus {
|
||||
self.status.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServiceHandle {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
+38
-94
@@ -1,7 +1,25 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
use std::{path::{Path, PathBuf}, process::Command};
|
||||
|
||||
// ── Port 443 capability ───────────────────────────────────────────────────────
|
||||
|
||||
/// Check whether the bridge binary already has cap_net_bind_service set.
|
||||
pub fn bridge_has_cap443(binary: &Path) -> bool {
|
||||
std::process::Command::new("getcap")
|
||||
.arg(binary)
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("cap_net_bind_service"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Grant cap_net_bind_service to the bridge binary so it can bind port 443
|
||||
/// without running as root. Uses pkexec (or sudo as fallback).
|
||||
pub fn setcap_bridge_443(binary: &Path) -> anyhow::Result<()> {
|
||||
let script = format!(
|
||||
"setcap cap_net_bind_service=+ep '{}'",
|
||||
binary.to_string_lossy()
|
||||
);
|
||||
run_elevated(&script)
|
||||
}
|
||||
|
||||
// ── Cert installation ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -49,13 +67,17 @@ fn try_wine_certutil(cert_src: &Path) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> {
|
||||
let status = Command::new("pkexec").args(["sh", "-c", script]).status();
|
||||
fn run_elevated(script: &str) -> anyhow::Result<()> {
|
||||
let status = Command::new("pkexec")
|
||||
.args(["sh", "-c", script])
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok(()),
|
||||
_ => {
|
||||
let s = Command::new("sudo").args(["sh", "-c", script]).status()?;
|
||||
let s = Command::new("sudo")
|
||||
.args(["sh", "-c", script])
|
||||
.status()?;
|
||||
if s.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -67,17 +89,11 @@ 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
|
||||
/// `LauncherConfig::hook_cfg_contents`) — this function does not invent any
|
||||
/// address itself, so a missing server can never silently become loopback.
|
||||
/// openfut.cfg with the redirect IP the hook will use.
|
||||
/// Uses `version.dll` as the hijack name — FIFA 23 loads it but defers to
|
||||
/// the system copy, so Proton picks up our local one first.
|
||||
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
|
||||
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, redirect_ip: &str) -> anyhow::Result<()> {
|
||||
if !dll_src.exists() {
|
||||
anyhow::bail!(
|
||||
"Hook DLL not found at {}. Build it first with:\n\
|
||||
@@ -88,30 +104,20 @@ 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(HOOK_CFG_FILE), cfg_contents)?;
|
||||
std::fs::write(game_dir.join("openfut.cfg"), redirect_ip)?;
|
||||
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(HOOK_CFG_FILE);
|
||||
/// Update only openfut.cfg without redeploying the DLL.
|
||||
pub fn update_hook_config(game_dir: &Path, redirect_ip: &str) -> anyhow::Result<()> {
|
||||
let cfg = game_dir.join("openfut.cfg");
|
||||
if !cfg.exists() {
|
||||
anyhow::bail!("Hook DLL not deployed yet — deploy first.");
|
||||
}
|
||||
std::fs::write(cfg, cfg_contents)?;
|
||||
std::fs::write(cfg, redirect_ip)?;
|
||||
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");
|
||||
@@ -128,67 +134,5 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
|
||||
|
||||
/// 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.
|
||||
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
|
||||
|
||||
// ── Game launch ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Launch the game via the user-provided shell command. Runs `sh -c <command>`
|
||||
/// (optionally from `workdir`), streaming stdout+stderr into `log_buf` on a
|
||||
/// background thread. The launcher does not assume Steam vs umu-run vs a custom
|
||||
/// script — whatever the user configured is what runs.
|
||||
pub fn launch_game(
|
||||
command: &str,
|
||||
workdir: &str,
|
||||
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 Settings).");
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(command);
|
||||
if !workdir.trim().is_empty() {
|
||||
cmd.current_dir(workdir);
|
||||
}
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
log_buf
|
||||
.lock()
|
||||
.push(format!("[launcher] launching game: {command}"));
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
if let Some(out) = child.stdout.take() {
|
||||
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().push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
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().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. `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()
|
||||
.push("[launcher] game process exited.".to_string());
|
||||
on_exit();
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
pub const STEAM_LAUNCH_OPTIONS: &str =
|
||||
"WINEDLLOVERRIDES=\"version=n,b\" %command%";
|
||||
|
||||
-303
@@ -1,303 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
Reference in New Issue
Block a user