1 Commits

Author SHA1 Message Date
OpenFUT Dev f16828a584 wip(hook): retained client-hook WIP \u2014 resolver_hook, store_hook, openfut-common crate
RETAINED PRE-EXISTING client-hook WIP (brought forward after verification).
Adds resolver_hook.rs + store_hook.rs and a new openfut-common crate, plus
config/connect/fifa17/hooks/lib refinements. No secrets/staging addresses.
Preserved on feat/sbc-hook-tracing so it is recoverable and pushed.
2026-08-20 09:12:59 -07:00
53 changed files with 5667 additions and 10272 deletions
+3
View File
@@ -4,3 +4,6 @@ target/
openfut.db
openfut.db-shm
openfut.db-wal
# hook cross-build test output
target-test/
Generated
-5
View File
@@ -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",
-4
View File
@@ -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.
-89
View File
@@ -115,41 +115,6 @@ pub struct ResolvedServer {
pub ports: OpenFutPorts,
}
/// Where one matched EA connection is rewritten to, in the exact WinSock
/// on-the-wire representation the socket hooks need. Produced by
/// [`ResolvedServer::redirect_for_ea_port`] so the hook and the launcher's
/// `openfut.cfg` share one decision by construction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Redirect {
/// Rewritten IPv4 for `sockaddr_in.sin_addr` (network byte order in memory).
pub addr_nbo: u32,
/// Rewritten port for `sin_port` / `sin6_port` (network byte order).
pub port_nbo: u16,
/// The resolved server IPv4, for callers building an IPv6 v4-mapped address.
pub redirect_ip: Ipv4Addr,
}
impl ResolvedServer {
/// Decide the redirect for an outbound EA connection whose destination port
/// is `ea_port_nbo` (network byte order, as read straight from the sockaddr).
///
/// Returns `None` when the port is not a recognised OpenFUT route — the hook
/// then leaves the connection untouched. The original destination IP is
/// intentionally ignored: matching is by the fixed EA source-port signature
/// ([`ea_ports`]), so a hardcoded EA IP (e.g. FIFA17's `159.153.51.20`
/// redirector) and a DNS-resolved one are treated identically and both land
/// on the configured server — no `/etc/hosts`, DNAT, or portproxy required.
pub fn redirect_for_ea_port(&self, ea_port_nbo: u16) -> Option<Redirect> {
let ea_port = u16::from_be(ea_port_nbo);
let dest_port = self.ports.map_source_port(ea_port)?;
Some(Redirect {
addr_nbo: sin_addr_from_ipv4(self.redirect_ip),
port_nbo: sin_port_nbo(dest_port),
redirect_ip: self.redirect_ip,
})
}
}
/// Errors loading/validating OpenFUT server configuration. Every one of these
/// must BLOCK operation — none of them may fall back to loopback.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -511,58 +476,4 @@ mod tests {
};
assert_eq!(c.resolve().unwrap_err(), ConfigError::ServerMissing);
}
#[test]
fn redirect_maps_every_fifa17_route_to_configured_server() {
// The canonical staging cfg. Ports come from the file, not constants.
let resolved = ServerConfig::parse(
"host=10.10.0.120\nhttps_port=8443\nblaze_redirector_port=42127\nblaze_main_port=42130\n",
)
.unwrap()
.resolve()
.unwrap();
let server = Ipv4Addr::new(10, 10, 0, 120);
// (EA source port [host order], expected OpenFUT dest port)
for (ea, dest) in [
(443u16, 8443u16),
(10041, 42127),
(42230, 42127),
(42127, 42130),
] {
let r = resolved
.redirect_for_ea_port(ea.to_be())
.unwrap_or_else(|| panic!("EA port {ea} should be a route"));
assert_eq!(u16::from_be(r.port_nbo), dest, "EA {ea} -> dest");
assert_eq!(r.redirect_ip, server, "EA {ea} -> server ip");
assert_eq!(
r.addr_nbo,
sin_addr_from_ipv4(server),
"EA {ea} -> sin_addr"
);
}
}
#[test]
fn redirect_leaves_unknown_ports_untouched() {
let resolved = ServerConfig::parse("host=10.10.0.120\n")
.unwrap()
.resolve()
.unwrap();
assert!(resolved.redirect_for_ea_port(8080u16.to_be()).is_none());
assert!(resolved.redirect_for_ea_port(22u16.to_be()).is_none());
assert!(resolved.redirect_for_ea_port(443u16.to_be()).is_some());
}
#[test]
fn redirect_targets_configured_remote_host_not_loopback() {
let resolved = ServerConfig::parse("host=10.10.0.120\n")
.unwrap()
.resolve()
.unwrap();
// FIFA17 redirector (hardcoded EA IP 159.153.51.20:42230) must be rewritten
// to the configured REMOTE server, never 127.0.0.1.
let r = resolved.redirect_for_ea_port(42230u16.to_be()).unwrap();
assert_eq!(r.redirect_ip, Ipv4Addr::new(10, 10, 0, 120));
assert_ne!(r.redirect_ip, Ipv4Addr::LOCALHOST);
}
}
-5
View File
@@ -2,15 +2,10 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "openfut-common"
version = "0.1.0"
[[package]]
name = "openfut-hook"
version = "0.1.0"
dependencies = [
"openfut-common",
"windows-sys",
]
+12 -14
View File
@@ -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"
@@ -13,13 +7,21 @@ edition = "2021"
crate-type = ["cdylib"]
[features]
# Per-game selection: each supported game is a feature enabling its module. Exactly
# one MUST be set (the crate emits a compile_error otherwise). Build the deployed
# artifact with `--features fifa17`. Add a future game as a new feature here plus a
# `mod <game>;` + dispatch arm in lib.rs — never by copying a retired game's code.
# Build with `--features capture_baseline` to DISABLE the LSX 3216→3217 redirect,
# so FIFA's LSX goes to anadius's in-process server (for capturing anadius's real
# responses). Default build keeps the redirect (LSX → our bridge).
capture_baseline = []
# Build with `--features probe` to install passive logging detours on FIFA's
# in-process online-flow functions (GoOnline, GetInternetConnectedState, event
# deserializers). Writes PROBE lines to C:\openfut_hook.log for RE. See probe.rs.
probe = []
# Build with `--features fifa17` for the FIFA 17 injection path. DllMain runs ONLY
# the minimal FIFA-17-safe logic in fifa17.rs (prove injection, dump module map,
# patch DirtySDK/ProtoSSL cert-verify) and skips ALL the FIFA-23-specific hooking.
fifa17 = []
[dependencies]
openfut-common = { path = "../openfut-common" }
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_LibraryLoader",
@@ -32,10 +34,6 @@ windows-sys = { version = "0.59", features = [
"Win32_System_Diagnostics_Debug",
"Win32_System_Kernel",
] }
# Single source of truth for the OpenFUT redirect config (openfut.cfg schema,
# EA-port -> OpenFUT-port map, WinSock byte-order helpers). Shared with the
# launcher so the hook and openfut.cfg agree by construction.
openfut-common = { path = "../openfut-common" }
[profile.release]
opt-level = "s"
+1 -1
View File
@@ -12,6 +12,6 @@ fn main() {
{
let definition =
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("version.def");
println!("cargo:rustc-cdylib-link-arg={}", definition.display());
println!("cargo:rustc-link-arg={}", definition.display());
}
}
+54
View File
@@ -0,0 +1,54 @@
//! 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 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> {
let mut buf = vec![0u8; 512];
let len = unsafe { GetModuleFileNameA(module, buf.as_mut_ptr(), buf.len() as u32) };
if len == 0 {
return None;
}
let path = std::ffi::CStr::from_bytes_until_nul(&buf[..len as usize + 1])
.ok()?
.to_str()
.ok()?;
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
}
+151 -106
View File
@@ -1,10 +1,64 @@
/// 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_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
// *different* host port (:3217) slips past that interception and lands on the
// native openfut-bridge LSX server. This is the load-bearing redirect that routes
// LSX to our bridge; without it FIFA uses anadius's in-process emu instead.
#[allow(dead_code)] // unused when built with the `capture_baseline` feature
const PORT_LSX_NBO: u16 = 0x900C; // 3216 big-endian (EA App LSX)
#[allow(dead_code)]
const PORT_LSX_TARGET_NBO: u16 = 0x910C; // 3217 big-endian (bridge LSX target)
/// 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 {
@@ -27,37 +81,15 @@ struct SockaddrIn6 {
sin6_scope_id: u32,
}
/// 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);
// Original 14 bytes saved before we overwrite them
static mut ORIGINAL_BYTES: [u8; 14] = [0u8; 14];
/// Restores the real WinSock call's thread-local last error after detour repair,
/// logging, and other instrumentation have run. Callers inspect this value after
/// `SOCKET_ERROR`; leaking a logger/VirtualProtect error changes connect semantics.
struct WsaLastErrorGuard(i32);
impl WsaLastErrorGuard {
unsafe fn capture() -> Self {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
Self(WSAGetLastError())
}
fn value(&self) -> i32 {
self.0
}
}
impl Drop for WsaLastErrorGuard {
fn drop(&mut self) {
unsafe {
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
WSASetLastError(self.0);
}
}
}
// For WSAConnect IAT fallback
type WsaConnectFn = unsafe extern "system" fn(
s: usize,
@@ -93,82 +125,97 @@ unsafe fn restore_original(target: *mut u8) {
VirtualProtect(target as _, 14, old, &mut old);
}
/// The armed redirect target, resolved once from `openfut.cfg` via `openfut-common`.
/// When set, `redirect_if_ea` rewrites matched EA connections to this configured
/// server; when unset, matched connections are left untouched (no redirect).
static REDIRECT: OnceLock<openfut_common::ResolvedServer> = OnceLock::new();
/// Arm the config-driven redirect (FIFA17). Idempotent: the first call wins.
pub fn set_redirect(server: openfut_common::ResolvedServer) {
let _ = REDIRECT.set(server);
}
/// If `name` is a matched EA connect target, return a rewritten sockaddr pointing
/// at the configured OpenFUT server (plus its meaningful byte length: 16 for v4,
/// 28 for v6). The target is armed once from `openfut.cfg` via `set_redirect`;
/// when unset — or when the port is not a known EA route — the connection is left
/// untouched. Shared by the connect / WSAConnect / ConnectEx detours.
/// If `name` is an EA-relevant connect target, return a rewritten sockaddr pointing at
/// the local bridge (plus its byte length). Handles BOTH `AF_INET` and `AF_INET6`: the
/// game's Blaze/DirtySDK stack dials EA over IPv6 (v4-mapped) on :443, and the old
/// IPv4-only path let those slip straight past us to the real (dead) servers.
///
/// The returned buffer is 28 bytes (enough for a `sockaddr_in6`); the second value is
/// how many of those bytes are meaningful (16 for v4, 28 for v6). `pub(crate)` so the
/// ConnectEx path can share this one implementation.
pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> {
if namelen < 8 || name.is_null() {
return None;
}
redirect_configured(REDIRECT.get()?, name, namelen)
}
/// FIFA17 config-driven rewrite. Destination host+port come from `openfut.cfg`
/// through `openfut-common`, so the hook and the launcher agree by construction.
/// Matching is by EA source-port signature only (see `openfut_common::ea_ports`),
/// so a hardcoded EA IP (e.g. the `159.153.51.20:42230` redirector) and a
/// DNS-resolved one both land on the configured — possibly remote — server. An
/// unrecognised port returns `None` (connection left untouched). Never corrupts
/// the sockaddr: it only writes into a fresh 28-byte buffer.
unsafe fn redirect_configured(
server: &openfut_common::ResolvedServer,
name: *const u8,
namelen: i32,
) -> Option<([u8; 28], i32)> {
// The first u16 of any sockaddr is the address family.
let family = *(name as *const u16);
let mut buf = [0u8; 28];
match family {
AF_INET => {
// SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read.
let sa = &*(name as *const SockaddrIn);
let redir = server.redirect_for_ea_port(sa.sin_port)?;
let new_port_nbo = match sa.sin_port {
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_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 :{}{}:{}\n",
"connect_hook: v4 {}.{}.{}.{}:{}{}.{}.{}.{}:{}\n",
o[0],
o[1],
o[2],
o[3],
u16::from_be(sa.sin_port),
redir.redirect_ip,
u16::from_be(redir.port_nbo)
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 = redir.port_nbo;
out.sin_addr = redir.addr_nbo;
out.sin_port = new_port_nbo;
out.sin_addr = target_addr_nbo();
Some((buf, 16))
}
AF_INET6 => {
if namelen < 28 {
return None;
}
// SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6).
let sa6 = &*(name as *const SockaddrIn6);
let redir = server.redirect_for_ea_port(sa6.sin6_port)?;
// ::ffff:<redirect_ip> — a v4-mapped v6 target so a v6 socket sends
// real IPv4 packets to the configured server.
let o = redir.redirect_ip.octets();
let mut v4mapped = [0u8; 16];
v4mapped[10] = 0xff;
v4mapped[11] = 0xff;
v4mapped[12..16].copy_from_slice(&o);
// 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 => 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 :{} → ::ffff:{}:{}\n",
"connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n",
a[0],
a[1],
a[14],
a[15],
u16::from_be(sa6.sin6_port),
redir.redirect_ip,
u16::from_be(redir.port_nbo)
u16::from_be(new_port_nbo)
));
// SAFE: buf is exactly 28 bytes == sizeof(sockaddr_in6).
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6);
out.sin6_family = AF_INET6;
out.sin6_port = redir.port_nbo;
out.sin6_port = new_port_nbo;
out.sin6_flowinfo = 0;
out.sin6_addr = v4mapped;
out.sin6_addr = target_v4mapped();
out.sin6_scope_id = 0;
Some((buf, 28))
}
@@ -179,6 +226,9 @@ unsafe fn redirect_configured(
pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: i32) -> i32 {
let addr = CONNECT_ADDR.load(Ordering::Relaxed) as *mut u8;
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("connect", name, namelen, s);
// Log every call so we can confirm the hook fires at all
if namelen >= 8 {
let sa = &*(name as *const SockaddrIn);
@@ -192,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,
@@ -216,9 +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)
};
// Named binding held until `return r`: its Drop restores the WSA error after `write_hook`.
let _last_error = WsaLastErrorGuard::capture();
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)
@@ -229,17 +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 last_error = WsaLastErrorGuard::capture();
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 logged_error = if r != 0 { last_error.value() } else { 0 };
crate::write_log(&format!(
"connect_hook: result={r} wsa_err={logged_error}\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
}
@@ -252,6 +316,8 @@ pub unsafe extern "system" fn hooked_wsa_connect(
sqos: *const (),
gqos: *const (),
) -> i32 {
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("WSAConnect", name, namelen, s);
let real = REAL_WSA.get().copied().unwrap();
if let Some((buf, len)) = redirect_if_ea(name, namelen) {
real(s, buf.as_ptr(), len, caller, callee, sqos, gqos)
@@ -264,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,
};
@@ -285,24 +351,3 @@ pub unsafe fn install_inline_connect_hook() -> bool {
write_hook(connect_fn, hooked_connect as *const () as u64);
true
}
#[cfg(test)]
mod tests {
use super::WsaLastErrorGuard;
use windows_sys::Win32::Networking::WinSock::{
WSAGetLastError, WSASetLastError, WSAEWOULDBLOCK,
};
#[test]
fn restores_winsock_last_error_after_instrumentation() {
unsafe {
WSASetLastError(WSAEWOULDBLOCK);
{
let guard = WsaLastErrorGuard::capture();
assert_eq!(guard.value(), WSAEWOULDBLOCK);
WSASetLastError(0);
}
assert_eq!(WSAGetLastError(), WSAEWOULDBLOCK);
}
}
}
+6 -2
View File
@@ -77,6 +77,10 @@ unsafe extern "system" fn hooked_connectex(
overlapped: *mut c_void,
) -> i32 {
let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed));
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("ConnectEx", name, namelen, s);
// Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx
// dials get the same IPv6 handling as plain connect().
if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) {
@@ -148,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,
};
+183
View File
@@ -0,0 +1,183 @@
//! Synthetic "notification" struct for the direct-call dial trigger.
//!
//! STATIC ARTIFACT ONLY — this module builds the byte layout the dial handler
//! (FIFA23.exe+0x4f4d360) expects in its `rdx` argument, plus a do-nothing
//! completion callback. It does NOT call the game, does NOT install any detour,
//! and is NOT wired into the hook yet. The invocation phase (later) consumes
//! `build_notification()` + `completion_stub`.
//!
//! Layout contract (from the 2026-07-03 dial-branch RE report on 0x144f4d590):
//! [+0x00] byte : entry gate — MUST be non-zero (else the error path fires). => 1
//! [+0x80] qword : completion delegate fn pointer. => &completion_stub
//! [+0x88] qword : delegate capture #1. => 0
//! [+0x90] qword : delegate capture #2. => 0
//! [+0xa0] dword : RpcJob key/priority (copied, never compared on dial path). => 0
//! everything else in [0x00..0x100] : 0
//! The RE confirmed no other offset in this range is read on the success path.
//! Total size 0x100 (256): the tail 0xa4..0x100 is zero padding — cheap insurance
//! against a read we might have missed. Any offset here is TODO/CONFIRM against the
//! RE report; if the game contradicts it at runtime, stop and re-verify.
// This module is deliberately unused for now (the invocation phase will call into
// it). Silence "never used" warnings until then rather than sprinkle #[allow] on
// each item. Remove this once the trigger wires the API up.
#![allow(dead_code)]
use core::sync::atomic::{AtomicU32, Ordering};
/// Size of the notification struct, in bytes. 0x100 = 256.
const NOTIFICATION_SIZE: usize = 0x100;
// --- field offsets (named so the code reads like the RE contract) -------------
const OFF_GATE: usize = 0x00; // byte, must be non-zero
const OFF_DELEGATE_FN: usize = 0x80; // qword, completion fn pointer
const OFF_DELEGATE_CAP1: usize = 0x88; // qword, capture (0)
const OFF_DELEGATE_CAP2: usize = 0x90; // qword, capture (0)
const OFF_KEY: usize = 0xa0; // dword, job key/priority (0)
/// Counts how many times `completion_stub` has been entered.
///
/// Why `AtomicU32` and not `static mut u32`: a `static mut` needs `unsafe` to
/// touch and, worse, gives *undefined behaviour* if two threads write it at once
/// (a data race). The completion callback may be invoked from an arbitrary game
/// thread, so a plain counter would race. `AtomicU32` makes increment a single
/// lock-free hardware instruction with well-defined concurrent semantics, and it
/// needs no `unsafe`. `Ordering::Relaxed` is enough here: we only care about the
/// count value, not about ordering it against other memory.
static COMPLETION_STUB_CALLS: AtomicU32 = AtomicU32::new(0);
/// The completion callback the game may invoke when the RpcJob finishes.
///
/// `extern "C"`: on the `x86_64-pc-windows-gnu` target this selects the Microsoft
/// x64 calling convention — exactly how the game invokes the pointer (`call r10`,
/// args in rcx/rdx/r8/r9, return in rax, caller cleans the stack). Matching the
/// convention is what makes it safe for the game to call us.
///
/// We declare four pointer-sized params and ignore them. The RE showed the delegate
/// is called with e.g. an HRESULT in `rdx` and a `this`-like pointer in `rcx`; the
/// success-path completion may pass different values. Because Win64 is caller-clean
/// and puts the first four integer args in registers, declaring four ignored args is
/// safe no matter what the caller actually passes — we simply never read them.
///
/// The body does the absolute minimum: bump the atomic counter and return 0. NO
/// logging, NO allocation, NO calls — a completion callback can fire from any game
/// context, and even a log write there could be unsafe. Observe from outside via
/// `completion_stub_call_count()` instead.
///
/// Returns `usize` = 0, which reads as an `S_OK`-shaped HRESULT if the caller looks
/// at the return value. (Returning void would be equally fine; 0 is a safe default.)
pub extern "C" fn completion_stub(_a: usize, _b: usize, _c: usize, _d: usize) -> usize {
// `fetch_add` is a single atomic read-modify-write (lock xadd) — no lock, no
// syscall, no allocation. Safe to call from any thread/context.
COMPLETION_STUB_CALLS.fetch_add(1, Ordering::Relaxed);
0
}
/// Read how many times `completion_stub` has fired. For an outside observer thread —
/// keeps all I/O out of the stub itself.
pub fn completion_stub_call_count() -> u32 {
COMPLETION_STUB_CALLS.load(Ordering::Relaxed)
}
/// Write a little-endian u64 into `buf` starting at `offset`.
///
/// Endianness matters because we're hand-laying a memory image the game will read
/// back as a raw pointer/integer. x86-64 is *little-endian*: the least-significant
/// byte sits at the lowest address. `value.to_le_bytes()` produces the 8 bytes in
/// exactly that order, so when the game does `mov rax,[ptr]` it reconstructs the
/// original `value`. Using the native byte order by hand (or `transmute`) would be
/// wrong on a big-endian machine; `to_le_bytes` states the intent explicitly.
///
/// `buf[offset..offset + 8]` is an 8-byte sub-slice; `copy_from_slice` copies the
/// 8-byte array into it. Both sides are length 8, so it can't panic here. (This is
/// the standard, safe way to poke a fixed-width integer into a `[u8]`.)
fn write_u64_le(buf: &mut [u8], offset: usize, value: u64) {
buf[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
}
/// Write a little-endian u32 into `buf` starting at `offset`. (Same idea as
/// `write_u64_le`, 4 bytes wide.)
fn write_u32_le(buf: &mut [u8], offset: usize, value: u32) {
buf[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
/// Build the fully-populated notification struct, ready to be passed by pointer to
/// the dial handler as its `rdx` argument.
///
/// Returns a `[u8; 0x100]` by value. Why a byte array and not a `#[repr(C)]` struct:
/// the layout is a precise *offset* contract recovered by RE, with meaningful data
/// only at 0x00/0x80/0x88/0x90/0xa0 and zeros elsewhere. A byte array makes every
/// offset literally visible and immune to any field-ordering/padding surprise. A
/// `#[repr(C)] struct` with explicit padding fields would work too, but it's easier
/// to get a padding byte wrong than to index a flat array. (For future reference:
/// the `bytemuck` crate can safely reinterpret a `#[repr(C)]` struct as `&[u8]`
/// zero-copy — worth knowing, but overkill here and an extra dependency.)
pub fn build_notification() -> [u8; NOTIFICATION_SIZE] {
// Start fully zeroed. This already satisfies every "= 0" field (caps at +0x88/
// +0x90, the key at +0xa0, and all padding); we only need to set the non-zero
// fields below.
let mut buf = [0u8; NOTIFICATION_SIZE];
// [+0x00] entry gate: must be non-zero to reach the dial path.
buf[OFF_GATE] = 1;
// [+0x80] completion delegate function pointer = &completion_stub.
//
// `completion_stub as *const ()`: a *function item* in Rust is a zero-sized,
// unique type, not a value. Casting it to a raw pointer coerces it to a function
// pointer and then to an untyped code pointer `*const ()` — i.e. the address of
// the function's machine code. The intermediate `*const ()` before `as u64` is
// the idiomatic form: it says "treat this as an address" and also avoids the
// `clippy`/rustc "direct cast of function item into an integer" lint you'd get
// from `completion_stub as u64`.
let stub_addr = completion_stub as *const () as u64;
write_u64_le(&mut buf, OFF_DELEGATE_FN, stub_addr);
// [+0x88]/[+0x90] delegate captures = 0. Already zero from initialization; write
// them explicitly so the layout intent is visible at a glance.
write_u64_le(&mut buf, OFF_DELEGATE_CAP1, 0);
write_u64_le(&mut buf, OFF_DELEGATE_CAP2, 0);
// [+0xa0] RpcJob key/priority dword = 0 (copied, never compared on the dial path).
write_u32_le(&mut buf, OFF_KEY, 0);
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notification_layout() {
let n = build_notification();
// Total size is exactly 0x100.
assert_eq!(n.len(), NOTIFICATION_SIZE);
// [+0x00] gate byte == 1.
assert_eq!(n[0x00], 1);
// [+0xa0..0xa4] as u32 == 0.
// `try_into().unwrap()` turns the 4-byte slice into a `[u8; 4]` (it can only
// fail if the slice weren't length 4, which it is), and `from_le_bytes`
// reads it back the same little-endian way we wrote it.
let key = u32::from_le_bytes(n[0xa0..0xa4].try_into().unwrap());
assert_eq!(key, 0);
// [+0x80..0x88] as u64 == address of completion_stub.
let stub = u64::from_le_bytes(n[0x80..0x88].try_into().unwrap());
assert_eq!(stub, completion_stub as *const () as u64);
// [+0x88..0x90] and [+0x90..0x98] captures == 0.
assert_eq!(u64::from_le_bytes(n[0x88..0x90].try_into().unwrap()), 0);
assert_eq!(u64::from_le_bytes(n[0x90..0x98].try_into().unwrap()), 0);
}
#[test]
fn stub_counter_increments() {
let before = completion_stub_call_count();
let _ = completion_stub(0, 0, 0, 0);
assert_eq!(completion_stub_call_count(), before + 1);
}
}
+179
View File
@@ -0,0 +1,179 @@
/// In-process LSX server (port 3216 / EA App Local Services Exchange).
///
/// Runs in a background thread inside FIFA's process so Wine's wineserver
/// routes FIFA's connect() directly here without needing any external process.
///
/// Protocol: server speaks first (sends XML greeting with challenge key),
/// then both sides do an AES-128-ECB challenge/response handshake, then
/// all subsequent messages are AES-128-ECB encrypted.
use windows_sys::Win32::Networking::WinSock::{
WSAStartup, WSACleanup, socket, bind, listen, accept, recv, send,
closesocket, setsockopt,
WSADATA, SOCKADDR, SOCKET, SOCKET_ERROR, INVALID_SOCKET,
AF_INET, SOCK_STREAM, IPPROTO_TCP, SOMAXCONN,
SO_REUSEADDR, SOL_SOCKET,
};
const PORT: u16 = 3216;
const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb";
fn server_loop() {
unsafe {
let mut wsa = core::mem::zeroed::<WSADATA>();
if WSAStartup(0x0202, &mut wsa) != 0 {
crate::write_log("ea_stub: WSAStartup failed\n");
return;
}
let srv = socket(AF_INET as i32, SOCK_STREAM, IPPROTO_TCP as i32);
if srv == INVALID_SOCKET {
crate::write_log("ea_stub: socket() failed\n");
WSACleanup();
return;
}
let yes: i32 = 1;
setsockopt(srv, SOL_SOCKET as i32, SO_REUSEADDR, &yes as *const i32 as *const u8, 4);
// sockaddr_in: sin_family(u16-LE) + sin_port(u16-BE) + sin_addr(u32) + padding
let mut addr = [0u8; 16];
let family = AF_INET as u16;
addr[0] = (family & 0xFF) as u8;
addr[1] = (family >> 8) as u8;
addr[2] = (PORT >> 8) as u8;
addr[3] = (PORT & 0xFF) as u8;
if bind(srv, addr.as_ptr() as *const SOCKADDR, addr.len() as i32) == SOCKET_ERROR {
crate::write_log("ea_stub: bind() failed — port 3216 in use\n");
closesocket(srv);
WSACleanup();
return;
}
listen(srv, SOMAXCONN as i32);
crate::write_log("ea_stub: listening on port 3216\n");
loop {
crate::write_log("ea_stub: calling accept...\n");
let client = accept(srv, core::ptr::null_mut(), core::ptr::null_mut());
if client == INVALID_SOCKET {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
let e = WSAGetLastError();
crate::write_log(&format!("ea_stub: accept FAILED wsa_err={e}\n"));
break;
}
crate::write_log("ea_stub: connection accepted\n");
handle_lsx(client);
}
closesocket(srv);
WSACleanup();
}
}
unsafe fn lsx_send(sock: SOCKET, msg: &str) -> bool {
// LSX messages are null-terminated
let mut buf = msg.as_bytes().to_vec();
buf.push(0);
let n = send(sock, buf.as_ptr(), buf.len() as i32, 0);
if n == SOCKET_ERROR {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
let e = WSAGetLastError();
crate::write_log(&format!("ea_stub: send FAILED wsa_err={e}\n"));
false
} else {
crate::write_log(&format!("ea_stub: sent {n} bytes\n"));
true
}
}
unsafe fn lsx_recv(sock: SOCKET) -> Option<String> {
let mut buf = vec![0u8; 8192];
let n = recv(sock, buf.as_mut_ptr(), buf.len() as i32, 0);
if n <= 0 {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
let e = WSAGetLastError();
crate::write_log(&format!("ea_stub: recv returned {n} wsa_err={e}\n"));
return None;
}
let text = String::from_utf8_lossy(&buf[..n as usize])
.trim_matches('\0')
.to_string();
crate::write_log(&format!("ea_stub: recv {n} bytes: {}\n", &text[..text.len().min(300)]));
Some(text)
}
unsafe fn handle_lsx(sock: SOCKET) {
// ── 1. Send greeting (server speaks first) ────────────────────────────
let greeting = format!(
"<LSX>\r\n <Event sender=\"EALS\">\r\n <Challenge build=\"release\" key=\"{GREETING_KEY}\" version=\"10,5,30,15625\" />\r\n </Event>\r\n</LSX>"
);
crate::write_log("ea_stub: sending LSX greeting\n");
if !lsx_send(sock, &greeting) {
closesocket(sock);
return;
}
// ── 2. Receive FIFA's ChallengeResponse ───────────────────────────────
let challenge_xml = match lsx_recv(sock) {
Some(s) => s,
None => { closesocket(sock); return; }
};
// Parse: split on '"' — EAappEmulater style
// <Request id="N" ...><ChallengeResponse ... response="HEX" key="HEX">
let parts: Vec<&str> = challenge_xml.split('"').collect();
let id = parts.get(3).copied().unwrap_or("1");
let key = parts.get(7).copied().unwrap_or("");
crate::write_log(&format!("ea_stub: challenge id={id} key={key}\n"));
let our_response = crate::lsx::make_challenge_response(key);
let seed = compute_seed(&our_response);
crate::write_log(&format!("ea_stub: our_response={our_response} seed={seed}\n"));
// ── 3. Send ChallengeAccepted ─────────────────────────────────────────
let accepted = format!(
"<LSX>\r\n <Response id=\"{id}\" sender=\"EALS\">\r\n <ChallengeAccepted response=\"{our_response}\" />\r\n </Response>\r\n</LSX>"
);
crate::write_log("ea_stub: sending ChallengeAccepted\n");
if !lsx_send(sock, &accepted) {
closesocket(sock);
return;
}
// ── 4. Session loop ───────────────────────────────────────────────────
loop {
let encrypted = match lsx_recv(sock) {
Some(s) => s,
None => break,
};
if encrypted.trim().is_empty() { continue; }
let request = crate::lsx::lsx_decrypt(&encrypted, seed);
crate::write_log(&format!("ea_stub: request: {}\n", &request[..request.len().min(300)]));
if request.trim().is_empty() {
crate::write_log("ea_stub: empty decrypted request — skipping\n");
continue;
}
let response_xml = crate::lsx::dispatch(request.trim());
crate::write_log(&format!("ea_stub: response: {}\n", &response_xml[..response_xml.len().min(300)]));
let encrypted_resp = crate::lsx::lsx_encrypt(&response_xml, seed);
if !lsx_send(sock, &encrypted_resp) { break; }
}
closesocket(sock);
crate::write_log("ea_stub: client disconnected\n");
}
fn compute_seed(hex: &str) -> u16 {
let b0 = u8::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0);
let b1 = u8::from_str_radix(&hex[2..4.min(hex.len())], 16).unwrap_or(0);
((b0 as u16) << 8) | (b1 as u16)
}
pub fn start() {
std::thread::spawn(server_loop);
}
+106 -88
View File
@@ -1,12 +1,20 @@
//! FIFA 17 injection path (feature = "fifa17").
//!
//! This is the game module selected by the `fifa17` feature: `install()` spawns a
//! worker (off the loader lock) that dumps the module map, arms the config-driven
//! network redirect (connect / WSAConnect / ConnectEx, target from `openfut.cfg`
//! via `openfut-common`), and installs the FIFA-17 SBC dispatch repair plus the
//! store/season hooks. Structures and RVAs here are specific to FIFA17.exe /
//! CardsDLL_Win64_retail.dll; a future game gets its own module, never a copy of
//! this one.
//! 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 memory-layout-specific logic here (origin_spy, LSX dial, event
//! deserializer probes) — that would at best no-op and at worst crash.
//!
//! 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};
@@ -65,33 +73,10 @@ unsafe fn dump_modules() {
CloseHandle(snap);
}
/// Read `openfut.cfg` from the game directory (next to `FIFA17.exe`) and resolve
/// the OpenFUT server via the shared `openfut-common` parser. Returns `None`
/// with a diagnostic when the file is absent or unusable, so the hook fails
/// safe — no redirect installed rather than a corrupt one.
fn load_server() -> Option<openfut_common::ResolvedServer> {
let dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
let path = dir.join("openfut.cfg");
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
write_log(&format!("fifa17: cannot read {}: {e}\n", path.display()));
return None;
}
};
match openfut_common::ServerConfig::parse(&contents).and_then(|c| c.resolve()) {
Ok(server) => Some(server),
Err(e) => {
write_log(&format!("fifa17: openfut.cfg unusable: {e}\n"));
None
}
}
}
/// 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);
@@ -99,79 +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();
write_log("fifa17: worker complete (injection healthy)\n");
// ── FIFA17 in-process network redirect (Milestone A) ──────────────────────
// Route EA endpoints to the configured OpenFUT server from openfut.cfg
// (openfut-common is the single source of truth). No hosts/iptables/portproxy.
match load_server() {
Some(server) => {
// 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: redirect armed → {} https={} redirector={} main={}\n",
server.redirect_ip,
server.ports.https,
server.ports.blaze_redirector,
server.ports.blaze_main
"fifa17: invalid/missing openfut.cfg ({e}); network redirect DISABLED\n"
));
crate::connect_hook::set_redirect(server);
if crate::connect_hook::install_inline_connect_hook() {
write_log("fifa17: connect inline-hooked\n");
} else {
write_log("fifa17: connect hook FAILED\n");
}
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");
}
if crate::connectex_hook::install_wsaioctl_hook() {
write_log("fifa17: ConnectEx (WSAIoctl) hooked\n");
} else {
write_log("fifa17: ConnectEx hook FAILED\n");
}
return 0;
}
None => write_log(
"fifa17: NO redirect installed (openfut.cfg missing/invalid) — EA traffic left untouched\n",
),
};
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");
// 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_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");
}
// FIFA17 TLS/certificate + store crash-guard compatibility (Milestone B).
// Spawns its own bounded polling worker: patches the FIFA17.exe ProtoSSL cert
// gates once the packer unpacks them, then the CardsDLL store guard once UT
// loads it. Fail-closed and one-shot; replaces the external openfut-autopatch.
crate::fifa17_tls::install();
// The promoted SBC dispatch repair (and the evidence traces it decides on) arms
// itself from the build; its safety is the runtime signature/evidence gate. The
// remaining legacy experiment modules stay inert unless their env gate is `1`.
crate::sbc_hook::install();
crate::sbc_trace::install();
crate::sbc_dispatch::install();
crate::sbc_request_trace::install();
crate::store_entry::install();
crate::season_trace::install();
0
// 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(),
);
-334
View File
@@ -1,334 +0,0 @@
//! FIFA 17 in-process TLS/certificate + store crash-guard compatibility.
//!
//! Ports the *proven* subset of the external `openfut-autopatch` patch set into
//! `version.dll`, so the client-local contract no longer needs an external
//! `/proc`-writing patcher. Two concerns, both fail-closed and one-shot:
//!
//! 1. ProtoSSL certificate gates in FIFA17.exe (REQUIRED_FOR_TLS) — let the
//! TLS handshake against the OpenFUT bridge cert succeed. Present only after
//! the STEAMPUNKS packer maps/decrypts the real code, so they are polled for.
//! 2. The empty-"My Packs" store resolver crash-guard in CardsDLL
//! (REQUIRED_FOR_STORE_TLS, bug 6c) — CardsDLL loads lazily on entering UT,
//! so it is applied once the module appears.
//!
//! Deliberately NOT ported: the eight unconditional `STORE_PATCHES` from the
//! external patcher. They carry no recovered original bytes (cannot be
//! fail-closed) and are re-applied every tick (would require the very
//! constant-rewrite loop this milestone forbids); the external patcher's own
//! source records no rationale for them. See the Vault ADR.
//!
//! Every address is ASLR-relocated from its preferred image base at runtime
//! (`live = module_base + (static_va - preferred_base)`); nothing patches an
//! absolute address. Every write goes through [`crate::patch_mem`]'s fail-closed
//! primitive: original → write+verify, already-patched → no-op, anything else →
//! logged and skipped.
use crate::patch_mem::{self, ApplyOutcome, Mem, PatchState, WinMem};
use crate::write_log;
use std::time::{Duration, Instant};
/// FIFA17.exe preferred image base (confirmed: futmem reports the client mapped
/// flat at this base; Wine honours it, native Windows ASLR may not — hence the
/// runtime-base + RVA model below).
const FIFA17_PREFERRED_BASE: u64 = 0x1_4000_0000;
/// CardsDLL_Win64_retail.dll preferred image base.
const CARDS_PREFERRED_BASE: u64 = 0x1_8000_0000;
/// Which module a site lives in.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Module {
Fifa17Exe,
CardsDll,
}
impl Module {
const fn preferred_base(self) -> u64 {
match self {
Module::Fifa17Exe => FIFA17_PREFERRED_BASE,
Module::CardsDll => CARDS_PREFERRED_BASE,
}
}
/// Runtime base of the loaded module, or `None` if not mapped yet. FIFA17.exe
/// is the main image (null name); CardsDLL is resolved by its retail name.
unsafe fn runtime_base(self) -> Option<usize> {
match self {
Module::Fifa17Exe => patch_mem::module_base(core::ptr::null()),
Module::CardsDll => {
patch_mem::module_base(c"CardsDLL_Win64_retail.dll".as_ptr().cast())
.or_else(|| patch_mem::module_base(c"CardsDLL.dll".as_ptr().cast()))
}
}
}
}
/// One fail-closed byte patch, expressed as a static VA in its module's preferred
/// image so the derivation `RVA = VA - preferred_base` is auditable.
struct Site {
module: Module,
static_va: u64,
orig: &'static [u8],
patch: &'static [u8],
label: &'static str,
}
impl Site {
const fn rva(&self) -> u64 {
patch_mem::rva(self.static_va, self.module.preferred_base())
}
fn live_addr(&self, base: usize) -> usize {
patch_mem::live_addr(base, self.rva())
}
}
// ── ProtoSSL certificate gates (FIFA17.exe) — REQUIRED_FOR_TLS ──────────────────
// GATE1: JNZ rel32 -> 6×NOP (fall through the cert-verify failure branch).
// GATE2: function prologue -> `xor eax,eax; ret` (cert-verify returns 0/false).
// Applied as a pair, exactly like the external patcher: written only when BOTH
// read their known original, treated as done when BOTH already hold the patch.
const GATE1: Site = Site {
module: Module::Fifa17Exe,
static_va: 0x1_4613_2548,
orig: &[0x0f, 0x85, 0x76, 0x01, 0x00, 0x00],
patch: &[0x90, 0x90, 0x90, 0x90, 0x90, 0x90],
label: "GATE1",
};
const GATE2: Site = Site {
module: Module::Fifa17Exe,
static_va: 0x1_4613_61b0,
orig: &[0x48, 0x89, 0x5c],
patch: &[0x31, 0xc0, 0xc3],
label: "GATE2",
};
// ── Empty "My Packs" store resolver crash-guard (CardsDLL) — REQUIRED_FOR_STORE_TLS
// JNZ 0x14869 (75 0f) -> JG 0x14869 (7f 0f): routes zero/negative store category
// ids through the Browse path instead of a NULL deref. Fail-closed one-shot.
const STORE_GUARD: Site = Site {
module: Module::CardsDll,
static_va: 0x1_8001_4858,
orig: &[0x75, 0x0f],
patch: &[0x7f, 0x0f],
label: "empty-mypacks-store-guard",
};
/// Poll cadence while waiting for the packer to unpack / CardsDLL to load. Low
/// frequency: the thread sleeps between ticks, so idle CPU is negligible.
const POLL: Duration = Duration::from_millis(250);
/// Upper bound on the whole worker's lifetime so it can never spin forever if the
/// user never enters Ultimate Team (CardsDLL never loads).
const MAX_WAIT: Duration = Duration::from_secs(15 * 60);
/// Decision for the FIFA17.exe cert-gate pair.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum CertAction {
/// Not both readable yet, or a mixed/unrecognised state — keep polling.
Wait,
/// Both gates hold their known original — safe to apply the pair.
Apply,
/// Both gates already hold the patch — nothing to do.
Done,
}
/// Pure pairing rule (unit-tested): only act when both gates agree.
fn cert_action(g1: Option<PatchState>, g2: Option<PatchState>) -> CertAction {
match (g1, g2) {
(Some(PatchState::AlreadyPatched), Some(PatchState::AlreadyPatched)) => CertAction::Done,
(Some(PatchState::Original), Some(PatchState::Original)) => CertAction::Apply,
_ => CertAction::Wait,
}
}
/// Arm the FIFA17 TLS/store compatibility patcher: spawns a bounded background
/// worker so it never touches the loader lock and never blocks `install()`.
pub fn install() {
std::thread::spawn(|| unsafe { worker() });
}
unsafe fn worker() {
write_log("fifa17_tls: patch worker start\n");
let mut mem = WinMem;
let start = Instant::now();
let mut cert_done = false;
let mut guard_done = false;
// Throttle the "still waiting" diagnostics to one line each.
let mut logged_cert_wait = false;
let mut logged_guard_wait = false;
loop {
if !cert_done {
cert_done = try_cert_gates(&mut mem, &mut logged_cert_wait);
}
if !guard_done {
match Module::CardsDll.runtime_base() {
Some(cbase) => guard_done = try_store_guard(&mut mem, cbase),
None => {
if !logged_guard_wait {
write_log("fifa17_tls: waiting for CardsDLL (enter Ultimate Team)\n");
logged_guard_wait = true;
}
}
}
}
if cert_done && guard_done {
write_log("fifa17_tls: TLS patch set complete\n");
return;
}
if start.elapsed() >= MAX_WAIT {
write_log(&format!(
"fifa17_tls: worker stop (timeout {MAX_WAIT:?}); cert_gates_done={cert_done} store_guard_done={guard_done}\n"
));
return;
}
std::thread::sleep(POLL);
}
}
/// Apply the FIFA17.exe cert-gate pair. Returns `true` once the pair is settled
/// (applied or already patched); `false` while still unpacking / not both ready.
unsafe fn try_cert_gates(mem: &mut WinMem, logged_wait: &mut bool) -> bool {
let base = match Module::Fifa17Exe.runtime_base() {
Some(b) => b,
None => return false,
};
let g1_addr = GATE1.live_addr(base);
let g2_addr = GATE2.live_addr(base);
let g1 = patch_mem::read_state(mem, g1_addr, GATE1.orig, GATE1.patch);
let g2 = patch_mem::read_state(mem, g2_addr, GATE2.orig, GATE2.patch);
match cert_action(g1, g2) {
CertAction::Done => {
write_log("fifa17_tls: cert gates already patched\n");
true
}
CertAction::Apply => {
let o1 = patch_mem::apply_checked(mem, g1_addr, GATE1.orig, GATE1.patch);
let o2 = patch_mem::apply_checked(mem, g2_addr, GATE2.orig, GATE2.patch);
if o1.is_patched() && o2.is_patched() {
write_log(&format!(
"fifa17_tls: PATCHED cert gates ({} @ {g1_addr:#x} {o1:?}; {} @ {g2_addr:#x} {o2:?})\n",
GATE1.label, GATE2.label
));
true
} else {
write_log(&format!(
"fifa17_tls: cert gate write FAILED ({} {o1:?}; {} {o2:?}) — TLS NOT installed\n",
GATE1.label, GATE2.label
));
// Terminal: a write/verify failure will not fix itself by retrying.
true
}
}
CertAction::Wait => {
if !*logged_wait {
write_log(&format!(
"fifa17_tls: cert gates not ready (still unpacking?) {}={g1:?} {}={g2:?}\n",
GATE1.label, GATE2.label
));
*logged_wait = true;
}
false
}
}
}
/// Apply the CardsDLL store crash-guard once CardsDLL is mapped. Returns `true`
/// once the site is settled (its bytes are final the moment CardsDLL is loaded,
/// so any read outcome is a terminal decision — no further polling).
unsafe fn try_store_guard(mem: &mut WinMem, cbase: usize) -> bool {
let addr = STORE_GUARD.live_addr(cbase);
let outcome = patch_mem::apply_checked(mem, addr, STORE_GUARD.orig, STORE_GUARD.patch);
match outcome {
ApplyOutcome::NotReadable => false, // CardsDLL mapped but this page not yet — retry
ApplyOutcome::Applied | ApplyOutcome::AlreadyPatched => {
write_log(&format!(
"fifa17_tls: store guard {} @ {addr:#x} {outcome:?} (VERIFIED empty-My-Packs)\n",
STORE_GUARD.label
));
true
}
ApplyOutcome::Mismatch => {
let mut cur = [0u8; patch_mem::MAX_PATCH_LEN];
let n = STORE_GUARD.patch.len();
let seen = if mem.read(addr, &mut cur[..n]) {
patch_mem::hex(&cur[..n])
} else {
"unreadable".into()
};
write_log(&format!(
"fifa17_tls: SKIP store guard @ {addr:#x}: unexpected {seen} (build mismatch)\n"
));
true
}
ApplyOutcome::WriteFailed | ApplyOutcome::VerifyFailed => {
write_log(&format!(
"fifa17_tls: store guard @ {addr:#x} {outcome:?}\n"
));
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_site_is_well_formed() {
for s in [&GATE1, &GATE2, &STORE_GUARD] {
assert_eq!(
s.orig.len(),
s.patch.len(),
"{}: orig/patch length",
s.label
);
assert!(!s.orig.is_empty(), "{}: empty", s.label);
assert!(
s.patch.len() <= patch_mem::MAX_PATCH_LEN,
"{}: exceeds MAX_PATCH_LEN",
s.label
);
assert_ne!(s.orig, s.patch, "{}: orig == patch", s.label);
}
}
#[test]
fn rvas_match_the_recovered_derivation() {
assert_eq!(GATE1.rva(), 0x613_2548);
assert_eq!(GATE2.rva(), 0x613_61b0);
assert_eq!(STORE_GUARD.rva(), 0x1_4858);
}
#[test]
fn live_addresses_track_the_runtime_base() {
// At the preferred base the live address is the recorded static VA.
assert_eq!(GATE1.live_addr(0x1_4000_0000), 0x1_4613_2548);
assert_eq!(STORE_GUARD.live_addr(0x1_8000_0000), 0x1_8001_4858);
// Relocated bases shift every site by the same delta.
assert_eq!(GATE1.live_addr(0x3_0000_0000), 0x3_0613_2548);
}
#[test]
fn cert_pair_only_acts_when_both_gates_agree() {
use PatchState::*;
assert_eq!(
cert_action(Some(Original), Some(Original)),
CertAction::Apply
);
assert_eq!(
cert_action(Some(AlreadyPatched), Some(AlreadyPatched)),
CertAction::Done
);
// Not yet unpacked / partial / mismatched => never a blind half-write.
assert_eq!(cert_action(None, None), CertAction::Wait);
assert_eq!(cert_action(Some(Original), None), CertAction::Wait);
assert_eq!(
cert_action(Some(Original), Some(AlreadyPatched)),
CertAction::Wait
);
assert_eq!(
cert_action(Some(Mismatch), Some(Mismatch)),
CertAction::Wait
);
}
}
+96
View File
@@ -0,0 +1,96 @@
use std::{
ffi::CStr,
sync::{
atomic::{AtomicBool, Ordering},
OnceLock,
},
};
use windows_sys::Win32::Networking::WinSock::{getaddrinfo as sys_getaddrinfo, ADDRINFOA};
type GetaddrinfoFn =
unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32;
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
// not be loaded yet when the hook DLL is injected.
static CERT_PATCHED: AtomicBool = AtomicBool::new(false);
pub fn set_real(f: GetaddrinfoFn) {
let _ = REAL.set(f);
}
pub fn set_redirect_ip(ip: String) {
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
/// to the local OpenFUT bridge.
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")
}
pub unsafe extern "system" fn hooked_getaddrinfo(
node_name: *const u8,
service_name: *const u8,
hints: *const ADDRINFOA,
result: *mut *mut ADDRINFOA,
) -> i32 {
if !node_name.is_null() {
if let Ok(host) = CStr::from_ptr(node_name as *const i8).to_str() {
crate::write_log(&format!("openfut_hook: getaddrinfo({host})\n"));
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_getaddrinfo(host);
if is_ea_host(host) {
// Apply the ProtoSSL cert-verify bypass the first time we see an EA
// hostname — EAWebKit.dll must be loaded by now because it's calling us.
if !CERT_PATCHED.load(Ordering::Relaxed) {
if crate::ssl_patch::patch_eawebkit_cert_verify() {
CERT_PATCHED.store(true, Ordering::Relaxed);
crate::write_log(
"openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n",
);
} else {
crate::write_log(
"openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n",
);
}
}
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",
);
}
}
}
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
real(node_name, service_name, hints, result)
}
+13
View File
@@ -71,6 +71,19 @@ pub unsafe fn patch_iat(original_fn: *const (), hook_fn: *const ()) -> usize {
patch_module(module, original_fn, hook_fn)
}
/// Patch the IAT of a specific already-loaded DLL (e.g. b"EAWebKit.dll\0").
pub unsafe fn patch_iat_in(
module_name: &[u8],
original_fn: *const (),
hook_fn: *const (),
) -> usize {
let module = GetModuleHandleA(module_name.as_ptr());
if module.is_null() {
return 0;
}
patch_module(module, original_fn, hook_fn)
}
unsafe fn patch_module(module: HMODULE, original_fn: *const (), hook_fn: *const ()) -> usize {
if module.is_null() {
return 0;
+210 -32
View File
@@ -1,26 +1,17 @@
// openfut-hook: the version.dll proxy that injects OpenFUT's client-side
// compatibility hooks into an EA FUT client.
//
// GAME-GENERIC BY FEATURE: each supported game is its own module, selected by a
// per-game Cargo feature (currently only `fifa17`). `install_hooks` dispatches to
// the selected game's `install()`. Generic infrastructure — the version proxy,
// the connect/WSAConnect/ConnectEx redirect, IAT primitives, and the shared
// `openfut-common` config — stays game-neutral. Add a future game with its own
// `mod <game>;` behind a feature plus a dispatch arm; never by copying a retired
// game's reverse-engineering.
#[cfg(not(any(feature = "fifa17")))]
compile_error!("select a game, e.g. --features fifa17");
mod config;
mod connect_hook;
mod connectex_hook;
mod dial_notification;
#[cfg(feature = "fifa17")]
mod fifa17;
#[cfg(feature = "fifa17")]
mod fifa17_tls;
mod hooks;
mod iat;
mod patch_mem;
#[cfg(feature = "fifa17")]
mod sbc_dispatch;
mod origin_spy;
#[cfg(feature = "probe")]
mod probe;
#[cfg(feature = "capture_baseline")]
mod recv_hook;
mod resolver_hook;
#[cfg(feature = "fifa17")]
mod sbc_hook;
#[cfg(feature = "fifa17")]
@@ -28,13 +19,15 @@ mod sbc_request_trace;
#[cfg(feature = "fifa17")]
mod sbc_trace;
#[cfg(feature = "fifa17")]
mod season_trace;
#[cfg(feature = "fifa17")]
mod store_entry;
mod store_hook;
mod ssl_patch;
mod tls_bypass;
mod transport_watch;
mod version_proxy;
use windows_sys::Win32::{
Foundation::{BOOL, HMODULE, TRUE},
Networking::WinSock::ADDRINFOA,
System::SystemServices::DLL_PROCESS_ATTACH,
};
@@ -49,13 +42,21 @@ pub(crate) fn write_log(msg: &str) {
}
}
/// # 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.
/// Force the log to stable storage. `write_log` already opens+closes the file per line,
/// so nothing is buffered *inside our process* (a process crash can't lose a written
/// line). `sync_all` additionally flushes the OS cache to disk, for durability even
/// across a full system crash. We call this right before the dial trigger's call so the
/// pre-call log line is guaranteed on disk if the call faults.
#[allow(dead_code)]
pub(crate) fn flush_log() {
if let Ok(f) = std::fs::OpenOptions::new()
.append(true)
.open(r"C:\openfut_hook.log")
{
let _ = f.sync_all();
}
}
#[no_mangle]
pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL {
if reason == DLL_PROCESS_ATTACH {
@@ -68,9 +69,186 @@ pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ())
TRUE
}
/// Dispatch to the selected game's install path. Exactly one game feature must be
/// enabled (enforced by the crate-level `compile_error!` above).
unsafe fn install_hooks(_module: HMODULE) {
unsafe fn install_hooks(module: HMODULE) {
// FIFA 17 path: run ONLY the minimal, FIFA-17-safe logic and skip every
// FIFA-23-specific hook below (they assume FIFA 23's memory layout).
#[cfg(feature = "fifa17")]
fifa17::install();
{
fifa17::install(module);
return;
}
#[cfg(not(feature = "fifa17"))]
install_hooks_fifa23(module)
}
#[cfg(not(feature = "fifa17"))]
unsafe fn install_hooks_fifa23(module: HMODULE) {
write_log("openfut_hook: DllMain fired\n");
// Milestone-0 transport watch: arm (or note disarmed) from env once, up front, so
// the getaddrinfo/connect/ConnectEx detours below can log Blaze-flavored activity.
transport_watch::arm_from_env();
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() {
let f: unsafe extern "system" fn(
*const u8,
*const u8,
*const ADDRINFOA,
*mut *mut ADDRINFOA,
) -> i32 = std::mem::transmute(ga);
hooks::set_real(f);
let n = iat::patch_iat(ga, hooks::hooked_getaddrinfo as *const ());
let m = iat::patch_iat_in(
b"EAWebKit.dll\0",
ga,
hooks::hooked_getaddrinfo as *const (),
);
write_log(&format!("openfut_hook: getaddrinfo IAT patched {n}+{m}\n"));
}
if ssl_patch::patch_main_exe_cert_verify() {
write_log("ssl: main exe cert-verify patched\n");
} else {
write_log("ssl: main exe cert-verify NOT FOUND\n");
}
if ssl_patch::patch_eawebkit_cert_verify() {
write_log("ssl: EAWebKit cert-verify patched\n");
} else {
write_log("ssl: EAWebKit cert-verify deferred\n");
}
if connect_hook::install_inline_connect_hook() {
write_log("connect: inline-hooked\n");
} else {
write_log("connect: hook FAILED\n");
}
let wp = iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0");
if !wp.is_null() {
let f: unsafe extern "system" fn(
usize,
*const u8,
i32,
*const (),
*const (),
*const (),
*const (),
) -> i32 = std::mem::transmute(wp);
connect_hook::set_real_wsa_connect(f);
iat::patch_iat(wp, connect_hook::hooked_wsa_connect as *const ());
write_log("connect: WSAConnect IAT patched\n");
}
if connectex_hook::install_wsaioctl_hook() {
write_log("connectex: WSAIoctl inline-hooked\n");
} else {
write_log("connectex: WSAIoctl hook FAILED\n");
}
// RE instrumentation: passive logging detours on FIFA's in-process online-flow
// functions (GoOnline, GetInternetConnectedState, event deserializers) to see
// where FIFA stalls after our pushed LSX events. Deferred until anadius loads.
#[cfg(feature = "probe")]
{
probe::install_probes_deferred();
write_log("probe: deferred install scheduled\n");
}
// recv/send hooks removed — LSX is now handled by the native openfut-bridge
// LSX server (port 3216), so in-process interception is no longer needed.
//
// Except in the `capture_baseline` build: with the LSX redirect off, FIFA talks
// to anadius directly, and these hooks log anadius's real LSX request/response
// frames (pass-through, no emulation) so we can diff them against our bridge.
#[cfg(feature = "capture_baseline")]
{
if recv_hook::install_recv_hook() {
write_log("CAP: recv inline-hooked\n");
} else {
write_log("CAP: recv hook FAILED\n");
}
if recv_hook::install_send_hook() {
write_log("CAP: send inline-hooked\n");
} else {
write_log("CAP: send hook FAILED\n");
}
}
macro_rules! hook_iat {
($dll:expr, $sym:expr, $setter:ident, $handler:expr, $ty:ty) => {{
let ptr = iat::resolve($dll, $sym);
if !ptr.is_null() {
let f: $ty = std::mem::transmute(ptr);
origin_spy::$setter(f);
iat::patch_iat(ptr, $handler as *const ());
"ok"
} else {
"miss"
}
}};
}
let ra = hook_iat!(
b"advapi32.dll\0",
b"RegQueryValueExA\0",
set_real_reg_a,
origin_spy::hooked_reg_query_a,
unsafe extern "system" fn(isize, *const u8, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
);
let rw = hook_iat!(
b"advapi32.dll\0",
b"RegQueryValueExW\0",
set_real_reg_w,
origin_spy::hooked_reg_query_w,
unsafe extern "system" fn(isize, *const u16, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
);
let ma = hook_iat!(
b"kernel32.dll\0",
b"OpenMutexA\0",
set_real_mutex_a,
origin_spy::hooked_open_mutex_a,
unsafe extern "system" fn(u32, i32, *const u8) -> isize
);
let mw = hook_iat!(
b"kernel32.dll\0",
b"OpenMutexW\0",
set_real_mutex_w,
origin_spy::hooked_open_mutex_w,
unsafe extern "system" fn(u32, i32, *const u16) -> isize
);
write_log(&format!(
"origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n"
));
let cv = iat::resolve(b"crypt32.dll\0", b"CertVerifyCertificateChainPolicy\0");
if !cv.is_null() {
let f: unsafe extern "system" fn(*const u8, *const (), *const (), *mut u32) -> BOOL =
std::mem::transmute(cv);
tls_bypass::set_real(f);
iat::patch_iat(cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
iat::patch_iat_in(
b"EAWebKit.dll\0",
cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
iat::patch_iat_in(
b"winhttp.dll\0",
cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
iat::patch_iat_in(
b"wininet.dll\0",
cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
}
}
+548
View File
@@ -0,0 +1,548 @@
/// EA App LSX protocol emulator (port 3216).
///
/// FIFA 23 opens two concurrent connections to port 3216 (one for EbisuSDK,
/// one for the login service). We track up to 4 sockets in LSX_POOL with
/// independent state per connection.
use core::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
// ─── per-connection slot ─────────────────────────────────────────────────────
struct LsxSlot {
socket: AtomicUsize, // usize::MAX = empty
state: AtomicUsize,
seed: AtomicUsize,
pending: Mutex<Option<Vec<u8>>>,
}
const MAX_LSX: usize = 4;
macro_rules! empty_slot {
() => { LsxSlot {
socket: AtomicUsize::new(usize::MAX),
state: AtomicUsize::new(0),
seed: AtomicUsize::new(0),
pending: Mutex::new(None),
}};
}
static POOL: [LsxSlot; MAX_LSX] = [
empty_slot!(), empty_slot!(), empty_slot!(), empty_slot!(),
];
fn find_slot(s: usize) -> Option<&'static LsxSlot> {
POOL.iter().find(|sl| sl.socket.load(Ordering::Relaxed) == s)
}
// ─── public API ──────────────────────────────────────────────────────────────
pub fn set_lsx_socket(s: usize) {
// Try to reuse an existing slot for this socket first
if find_slot(s).is_some() { return; }
// Find a free slot
for sl in &POOL {
if sl.socket.load(Ordering::Relaxed) == usize::MAX {
sl.state.store(0, Ordering::Relaxed);
sl.seed.store(0, Ordering::Relaxed);
if let Ok(mut g) = sl.pending.lock() { *g = None; }
sl.socket.store(s, Ordering::Relaxed);
crate::write_log(&format!("lsx: socket registered s={s}\n"));
return;
}
}
// All slots full — evict the first one
let sl = &POOL[0];
sl.state.store(0, Ordering::Relaxed);
sl.seed.store(0, Ordering::Relaxed);
if let Ok(mut g) = sl.pending.lock() { *g = None; }
sl.socket.store(s, Ordering::Relaxed);
crate::write_log(&format!("lsx: socket registered s={s} (evicted old slot)\n"));
}
pub fn is_lsx(s: usize) -> bool {
find_slot(s).is_some()
}
pub fn current_socket() -> usize {
// Return any active LSX socket (used by select hook if needed)
POOL.iter()
.map(|sl| sl.socket.load(Ordering::Relaxed))
.find(|&s| s != usize::MAX)
.unwrap_or(usize::MAX)
}
const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb";
const AES_KEY: [u8; 16] = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15];
pub unsafe fn on_recv(s: usize, buf: *mut u8, len: i32) -> i32 {
let sl = match find_slot(s) { Some(x) => x, None => return -1 };
let state = sl.state.load(Ordering::Relaxed);
crate::write_log(&format!("lsx: recv s={s} state={state}\n"));
let payload: Vec<u8> = match state {
0 => {
let xml = format!(
"<LSX>\r\n <Event sender=\"EALS\">\r\n <Challenge build=\"release\" key=\"{GREETING_KEY}\" version=\"10,5,30,15625\" />\r\n </Event>\r\n</LSX>\0"
);
sl.state.store(1, Ordering::Relaxed);
xml.into_bytes()
}
_ => {
let mut guard = sl.pending.lock().unwrap_or_else(|e| e.into_inner());
match guard.take() {
Some(pb) => pb,
None => {
// No pending data — return 0.
// For the state-1 probe recv (FIFA checking if there is more
// greeting data), 0 is the correct "no more data" signal and
// FIFA proceeds to send the ChallengeResponse.
return 0;
}
}
}
};
let n = payload.len().min(len as usize);
core::ptr::copy_nonoverlapping(payload.as_ptr(), buf, n);
crate::write_log(&format!("lsx: recv -> {n} bytes\n"));
n as i32
}
pub unsafe fn on_send(s: usize, buf: *const u8, len: i32) -> i32 {
let sl = match find_slot(s) { Some(x) => x, None => return len };
let state = sl.state.load(Ordering::Relaxed);
let data = core::slice::from_raw_parts(buf, len as usize);
let text = core::str::from_utf8(data).unwrap_or("(binary)");
crate::write_log(&format!("lsx: send s={s} state={state} len={len} data={}\n",
&text[..text.len().min(300)]));
let response = match state {
1 => handle_challenge(sl, data),
st => handle_request(sl, data, st),
};
if let Some(payload) = response {
let mut guard = sl.pending.lock().unwrap_or_else(|e| e.into_inner());
*guard = Some(payload);
}
sl.state.fetch_add(1, Ordering::Relaxed);
len
}
// ─── handshake ───────────────────────────────────────────────────────────────
fn handle_challenge(sl: &LsxSlot, raw: &[u8]) -> Option<Vec<u8>> {
let text = core::str::from_utf8(raw).unwrap_or("").trim_end_matches('\0');
let parts: Vec<&str> = text.split('"').collect();
let id = parts.get(3).copied().unwrap_or("1");
let key = parts.get(7).copied().unwrap_or("");
crate::write_log(&format!("lsx: challenge id={id} key={key}\n"));
let our_response = make_challenge_response(key);
let seed = compute_seed(&our_response);
sl.seed.store(seed as usize, Ordering::Relaxed);
crate::write_log(&format!("lsx: response={our_response} seed={seed}\n"));
let xml = format!(
"<LSX>\r\n <Response id=\"{id}\" sender=\"EALS\">\r\n <ChallengeAccepted response=\"{our_response}\" />\r\n </Response>\r\n</LSX>\0"
);
Some(xml.into_bytes())
}
fn compute_seed(hex: &str) -> u16 {
let b0 = u8::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0);
let b1 = u8::from_str_radix(&hex[2..4.min(hex.len())], 16).unwrap_or(0);
((b0 as u16) << 8) | (b1 as u16)
}
fn handle_request(sl: &LsxSlot, raw: &[u8], _state: usize) -> Option<Vec<u8>> {
let seed = sl.seed.load(Ordering::Relaxed) as u16;
let text = core::str::from_utf8(raw).unwrap_or("").trim_end_matches('\0');
let decrypted = lsx_decrypt(text, seed);
crate::write_log(&format!("lsx: request decrypted={}\n", &decrypted[..decrypted.len().min(300)]));
let response_xml = dispatch_request(decrypted.trim());
crate::write_log(&format!("lsx: response={}\n", &response_xml[..response_xml.len().min(300)]));
let encrypted = lsx_encrypt(&response_xml, seed);
let payload = format!("{encrypted}\0");
Some(payload.into_bytes())
}
// ─── session dispatcher ───────────────────────────────────────────────────────
pub fn dispatch(xml: &str) -> String { dispatch_request(xml) }
fn dispatch_request(xml: &str) -> String {
let parts: Vec<&str> = xml.split('"').collect();
let id = parts.get(3).copied().unwrap_or("1");
let req_type = parts.get(4).copied().unwrap_or("");
crate::write_log(&format!("lsx: dispatch id={id} type={req_type}\n"));
match req_type {
"><GetConfig version=" => get_config(id),
"><GetAuthCode ClientId=" | "><GetAuthCode UserId=" => get_auth_code(id),
"><GetInternetConnectedState version=" => get_internet_state(id),
"><GetProfile index=" => get_profile(id),
"><GetSetting SettingId=" => {
let setting = parts.get(5).copied().unwrap_or("");
get_setting(id, setting)
}
"><QueryEntitlements UserId=" => query_entitlements(id),
"><RequestLicense UserId=" => request_license(id),
"><QueryContent UserId=" => query_content(id),
"><GetBlockList version=" => get_block_list(id),
"><QueryFriends UserId=" => query_friends(id),
"><QueryPresence UserId=" => query_presence(id),
"><SetPresence UserId=" => set_presence(id),
"><GetPresenceVisibility UserId=" => get_presence_visibility(id),
"><GetWalletBalance UserId=" => get_wallet_balance(id),
"><GetAllGameInfo version=" => get_all_game_info(id),
_ => {
crate::write_log(&format!("lsx: UNKNOWN type: {req_type}\n"));
format!("<LSX><Response id=\"{id}\" sender=\"EbisuSDK\"><Ok /></Response></LSX>\0")
}
}
}
// ─── LSX response templates ───────────────────────────────────────────────────
fn get_config(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="EbisuSDK">
<GetConfigResponse>
<Service Facility="SDK" Name="EbisuSDK" />
<Service Facility="PROFILE" Name="EbisuSDK" />
<Service Facility="PRESENCE" Name="XMPP" />
<Service Facility="FRIENDS" Name="XMPP" />
<Service Facility="COMMERCE" Name="Commerce" />
<Service Facility="RECENTPLAYER" Name="EbisuSDK" />
<Service Facility="IGO" Name="EbisuSDK" />
<Service Facility="MISC" Name="EbisuSDK" />
<Service Facility="LOGIN" Name="EALS" />
<Service Facility="UTILITY" Name="Utility" />
<Service Facility="XMPP" Name="XMPP" />
<Service Facility="CHAT" Name="XMPP" />
<Service Facility="IGO_EVENT" Name="EbisuSDK" />
<Service Facility="EALS_EVENTS" Name="EALS" />
<Service Facility="LOGIN_EVENT" Name="EbisuSDK" />
<Service Facility="INVITE_EVENT" Name="XMPP" />
<Service Facility="PROFILE_EVENT" Name="EbisuSDK" />
<Service Facility="PRESENCE_EVENT" Name="XMPP" />
<Service Facility="FRIENDS_EVENT" Name="XMPP" />
<Service Facility="COMMERCE_EVENT" Name="Commerce" />
<Service Facility="CHAT_EVENT" Name="XMPP" />
<Service Facility="DOWNLOAD_EVENT" Name="EbisuSDK" />
<Service Facility="PERMISSION" Name="EbisuSDK" />
<Service Facility="RESOURCES" Name="EbisuSDK" />
<Service Facility="BLOCKED_USERS" Name="EbisuSDK" />
<Service Facility="BLOCKED_USER_EVENT" Name="EbisuSDK" />
<Service Facility="GET_USERID" Name="EbisuSDK" />
<Service Facility="ONLINE_STATUS_EVENT" Name="EbisuSDK" />
<Service Facility="ACHIEVEMENT" Name="EbisuSDK" />
<Service Facility="ACHIEVEMENT_EVENT" Name="EbisuSDK" />
<Service Facility="BROADCAST_EVENT" Name="EbisuSDK" />
<Service Facility="PROGRESSIVE_INSTALLATION" Name="PI" />
<Service Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI" />
<Service Facility="CONTENT" Name="EbisuSDK" />
</GetConfigResponse>
</Response>
</LSX>"#)
}
fn get_auth_code(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="Utility">
<AuthCode value="OpenFUT_fake_auth_code_v1" />
</Response>
</LSX>"#)
}
fn get_internet_state(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="Utility">
<InternetConnectedState connected="1" />
</Response>
</LSX>"#)
}
fn get_profile(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="EbisuSDK">
<GetProfileResponse PersonaId="1000000000001" Persona="OpenFUT_Player" Country="US" GeoCountry="US"
UserIndex="0" IsTrialSubscriber="false" AvatarId="1"
IsUnderAge="false" IsSubscriber="false" IsSteamSubscriber="false" SubscriberLevel="2"
CommerceCurrency="USD" UserId="2000000000001" CommerceCountry="US" />
</Response>
</LSX>"#)
}
fn get_setting(id: &str, setting: &str) -> String {
let value = match setting { "ENVIRONMENT" => "production", _ => "false" };
format!(r#"<LSX>
<Response id="{id}" sender="EbisuSDK">
<GetSettingResponse Setting="{value}" />
</Response>
</LSX>"#)
}
fn query_entitlements(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="Commerce">
<QueryEntitlementsResponse>
<Entitlements ItemId="Origin.OFR.50.0004658" Type="ONLINE_ACCESS"
EntitlementId="1021747550001" EntitlementTag="ONLINE_ACCESS"
Group="FIFA23PC" ResourceId="" UseCount="0"
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
<Entitlements ItemId="Origin.OFR.50.0004658" Type="DEFAULT"
EntitlementId="1021747550002" EntitlementTag="ONLINE_ACCESS"
Group="FIFA23PC" ResourceId="" UseCount="0"
Expiration="0000-00-00T00:00:00" GrantDate="2022-09-30T00:00:00"
LastModifiedDate="2022-09-30T00:00:00" Version="0" />
</QueryEntitlementsResponse>
</Response>
</LSX>"#)
}
fn request_license(id: &str) -> String {
format!(r#"<LSX>
<Response sender="EbisuSDK" id="{id}">
<RequestLicenseResponse License="OpenFUT_fake_license_v1" />
</Response>
</LSX>"#)
}
fn query_content(id: &str) -> String {
format!(r#"<LSX>
<Response id="{id}" sender="EbisuSDK">
<QueryContentResponse>
<Content Gamestate="READY_TO_PLAY" progressValue="0"
contentID="Origin.OFR.50.0004658"
installedVersion="1.0.0.0" availableVersion="1.0.0.0"
displayName="FIFA 23" />
</QueryContentResponse>
</Response>
</LSX>"#)
}
fn get_block_list(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetBlockListResponse /></Response></LSX>"#)
}
fn query_friends(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryFriendsResponse /></Response></LSX>"#)
}
fn query_presence(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="XMPP"><QueryPresenceResponse UserId="2000000000001" PersonaId="1000000000001" /></Response></LSX>"#)
}
fn set_presence(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="XMPP"><SetPresenceResponse /></Response></LSX>"#)
}
fn get_presence_visibility(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetPresenceVisibilityResponse Visibility="FRIENDS" /></Response></LSX>"#)
}
fn get_wallet_balance(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="Commerce"><GetWalletBalanceResponse Balance="0" Currency="USD" /></Response></LSX>"#)
}
fn get_all_game_info(id: &str) -> String {
format!(r#"<LSX><Response id="{id}" sender="EbisuSDK"><GetAllGameInfoResponse /></Response></LSX>"#)
}
// ─── AES-128-ECB (pure Rust) ──────────────────────────────────────────────────
#[rustfmt::skip]
const SBOX: [u8; 256] = [
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16,
];
fn xtime(a: u8) -> u8 { if a & 0x80 != 0 { (a << 1) ^ 0x1b } else { a << 1 } }
fn mul(mut a: u8, mut b: u8) -> u8 {
let mut r = 0u8;
while b > 0 { if b & 1 != 0 { r ^= a; } a = xtime(a); b >>= 1; }
r
}
fn sub_bytes(s: &mut [u8; 16]) { for b in s.iter_mut() { *b = SBOX[*b as usize]; } }
fn shift_rows(s: &mut [u8; 16]) {
let t = s[1]; s[1]=s[5]; s[5]=s[9]; s[9]=s[13]; s[13]=t;
s.swap(2,10); s.swap(6,14);
let t = s[15]; s[15]=s[11]; s[11]=s[7]; s[7]=s[3]; s[3]=t;
}
fn mix_col(s: &mut [u8; 16], c: usize) {
let (a,b,c2,d) = (s[c],s[c+4],s[c+8],s[c+12]);
s[c] = mul(2,a)^mul(3,b)^c2^d;
s[c+4] = a^mul(2,b)^mul(3,c2)^d;
s[c+8] = a^b^mul(2,c2)^mul(3,d);
s[c+12] = mul(3,a)^b^c2^mul(2,d);
}
fn mix_columns(s: &mut [u8; 16]) { for c in 0..4 { mix_col(s,c); } }
fn add_round_key(s: &mut [u8; 16], rk: &[u8; 16]) { for i in 0..16 { s[i] ^= rk[i]; } }
fn expand_key(key: &[u8; 16]) -> [[u8; 16]; 11] {
let rcon: [u8; 10] = [0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36];
let mut w = [[0u8; 4]; 44];
for i in 0..4 { w[i] = [key[4*i],key[4*i+1],key[4*i+2],key[4*i+3]]; }
for i in 4..44 {
let mut t = w[i-1];
if i % 4 == 0 {
t.rotate_left(1);
for b in &mut t { *b = SBOX[*b as usize]; }
t[0] ^= rcon[i/4-1];
}
w[i] = [w[i-4][0]^t[0], w[i-4][1]^t[1], w[i-4][2]^t[2], w[i-4][3]^t[3]];
}
let mut rk = [[0u8; 16]; 11];
for r in 0..11 { for c in 0..4 { rk[r][4*c..4*c+4].copy_from_slice(&w[r*4+c]); } }
rk
}
fn aes_block_encrypt(block: &[u8; 16], rk: &[[u8; 16]; 11]) -> [u8; 16] {
let mut s = *block;
add_round_key(&mut s, &rk[0]);
for r in 1..10 { sub_bytes(&mut s); shift_rows(&mut s); mix_columns(&mut s); add_round_key(&mut s, &rk[r]); }
sub_bytes(&mut s); shift_rows(&mut s); add_round_key(&mut s, &rk[10]);
s
}
fn aes_ecb_pkcs7_encrypt(key: &[u8; 16], plaintext: &[u8]) -> Vec<u8> {
let rk = expand_key(key);
let pad = 16 - (plaintext.len() % 16);
let mut padded = plaintext.to_vec();
padded.resize(plaintext.len() + pad, pad as u8);
let mut out = Vec::with_capacity(padded.len());
for chunk in padded.chunks(16) {
let mut b = [0u8; 16]; b.copy_from_slice(chunk);
out.extend_from_slice(&aes_block_encrypt(&b, &rk));
}
out
}
#[rustfmt::skip]
const INV_SBOX: [u8; 256] = [
0x52,0x09,0x6a,0xd5,0x30,0x36,0xa5,0x38,0xbf,0x40,0xa3,0x9e,0x81,0xf3,0xd7,0xfb,
0x7c,0xe3,0x39,0x82,0x9b,0x2f,0xff,0x87,0x34,0x8e,0x43,0x44,0xc4,0xde,0xe9,0xcb,
0x54,0x7b,0x94,0x32,0xa6,0xc2,0x23,0x3d,0xee,0x4c,0x95,0x0b,0x42,0xfa,0xc3,0x4e,
0x08,0x2e,0xa1,0x66,0x28,0xd9,0x24,0xb2,0x76,0x5b,0xa2,0x49,0x6d,0x8b,0xd1,0x25,
0x72,0xf8,0xf6,0x64,0x86,0x68,0x98,0x16,0xd4,0xa4,0x5c,0xcc,0x5d,0x65,0xb6,0x92,
0x6c,0x70,0x48,0x50,0xfd,0xed,0xb9,0xda,0x5e,0x15,0x46,0x57,0xa7,0x8d,0x9d,0x84,
0x90,0xd8,0xab,0x00,0x8c,0xbc,0xd3,0x0a,0xf7,0xe4,0x58,0x05,0xb8,0xb3,0x45,0x06,
0xd0,0x2c,0x1e,0x8f,0xca,0x3f,0x0f,0x02,0xc1,0xaf,0xbd,0x03,0x01,0x13,0x8a,0x6b,
0x3a,0x91,0x11,0x41,0x4f,0x67,0xdc,0xea,0x97,0xf2,0xcf,0xce,0xf0,0xb4,0xe6,0x73,
0x96,0xac,0x74,0x22,0xe7,0xad,0x35,0x85,0xe2,0xf9,0x37,0xe8,0x1c,0x75,0xdf,0x6e,
0x47,0xf1,0x1a,0x71,0x1d,0x29,0xc5,0x89,0x6f,0xb7,0x62,0x0e,0xaa,0x18,0xbe,0x1b,
0xfc,0x56,0x3e,0x4b,0xc6,0xd2,0x79,0x20,0x9a,0xdb,0xc0,0xfe,0x78,0xcd,0x5a,0xf4,
0x1f,0xdd,0xa8,0x33,0x88,0x07,0xc7,0x31,0xb1,0x12,0x10,0x59,0x27,0x80,0xec,0x5f,
0x60,0x51,0x7f,0xa9,0x19,0xb5,0x4a,0x0d,0x2d,0xe5,0x7a,0x9f,0x93,0xc9,0x9c,0xef,
0xa0,0xe0,0x3b,0x4d,0xae,0x2a,0xf5,0xb0,0xc8,0xeb,0xbb,0x3c,0x83,0x53,0x99,0x61,
0x17,0x2b,0x04,0x7e,0xba,0x77,0xd6,0x26,0xe1,0x69,0x14,0x63,0x55,0x21,0x0c,0x7d,
];
fn inv_sub_bytes(s: &mut [u8; 16]) { for b in s.iter_mut() { *b = INV_SBOX[*b as usize]; } }
fn inv_shift_rows(s: &mut [u8; 16]) {
let t = s[13]; s[13]=s[9]; s[9]=s[5]; s[5]=s[1]; s[1]=t;
s.swap(2,10); s.swap(6,14);
let t = s[3]; s[3]=s[7]; s[7]=s[11]; s[11]=s[15]; s[15]=t;
}
fn inv_mix_col(s: &mut [u8; 16], c: usize) {
let (a,b,c2,d) = (s[c],s[c+4],s[c+8],s[c+12]);
s[c] = mul(0x0e,a)^mul(0x0b,b)^mul(0x0d,c2)^mul(0x09,d);
s[c+4] = mul(0x09,a)^mul(0x0e,b)^mul(0x0b,c2)^mul(0x0d,d);
s[c+8] = mul(0x0d,a)^mul(0x09,b)^mul(0x0e,c2)^mul(0x0b,d);
s[c+12] = mul(0x0b,a)^mul(0x0d,b)^mul(0x09,c2)^mul(0x0e,d);
}
fn inv_mix_columns(s: &mut [u8; 16]) { for c in 0..4 { inv_mix_col(s,c); } }
fn aes_ecb_decrypt_nopad(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
let rk = expand_key(key);
let mut out = Vec::with_capacity(data.len());
for chunk in data.chunks(16) {
if chunk.len() < 16 { break; }
let mut b = [0u8; 16]; b.copy_from_slice(chunk);
add_round_key(&mut b, &rk[10]);
inv_shift_rows(&mut b); inv_sub_bytes(&mut b);
for r in (1..10).rev() {
add_round_key(&mut b, &rk[r]);
inv_mix_columns(&mut b); inv_shift_rows(&mut b); inv_sub_bytes(&mut b);
}
add_round_key(&mut b, &rk[0]);
out.extend_from_slice(&b);
}
if let Some(&pad) = out.last() {
let pad = pad as usize;
if pad <= 16 && out.len() >= pad { out.truncate(out.len() - pad); }
}
out
}
// ─── CRandom ─────────────────────────────────────────────────────────────────
struct CRandom { seed: u32 }
impl CRandom {
fn new() -> Self { Self { seed: 0 } }
fn seed_with(&mut self, s: u32) { self.seed = s; }
fn rand(&mut self) -> u32 {
self.seed = self.seed.wrapping_mul(214013).wrapping_add(2531011);
(self.seed >> 16) & 0xFFFF
}
}
fn get_lsx_key(seed: u16) -> [u8; 16] {
let mut rng = CRandom::new();
rng.seed_with(7);
let next = rng.rand();
rng.seed_with(next.wrapping_add(seed as u32));
let mut k = [0u8; 16];
for b in &mut k { *b = rng.rand() as u8; }
k
}
// ─── session encrypt/decrypt ─────────────────────────────────────────────────
fn hex_to_bytes(s: &str) -> Vec<u8> {
let s: String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect();
if s.len() % 2 != 0 { return Vec::new(); }
(0..s.len()/2).filter_map(|i| u8::from_str_radix(&s[2*i..2*i+2], 16).ok()).collect()
}
fn bytes_to_hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
pub fn lsx_decrypt(hex_data: &str, seed: u16) -> String {
let key = get_lsx_key(seed);
let ct = hex_to_bytes(hex_data);
if ct.is_empty() { return String::new(); }
let plain = aes_ecb_decrypt_nopad(&key, &ct);
String::from_utf8_lossy(&plain).trim_matches('\0').to_string()
}
pub fn lsx_encrypt(text: &str, seed: u16) -> String {
let key = get_lsx_key(seed);
bytes_to_hex(&aes_ecb_pkcs7_encrypt(&key, text.as_bytes()))
}
pub fn make_challenge_response(key: &str) -> String {
bytes_to_hex(&aes_ecb_pkcs7_encrypt(&AES_KEY, key.as_bytes()))
}
+137
View File
@@ -0,0 +1,137 @@
/// Hooks RegQueryValueExA/W and OpenMutexA/W to log what the Origin SDK is checking.
use std::sync::OnceLock;
type RegQueryValueExAFn = unsafe extern "system" fn(
hkey: isize,
lpvaluename: *const u8,
lpreserved: *mut u32,
lptype: *mut u32,
lpdata: *mut u8,
lpcbdata: *mut u32,
) -> i32;
type RegQueryValueExWFn = unsafe extern "system" fn(
hkey: isize,
lpvaluename: *const u16,
lpreserved: *mut u32,
lptype: *mut u32,
lpdata: *mut u8,
lpcbdata: *mut u32,
) -> i32;
type OpenMutexAFn = unsafe extern "system" fn(u32, i32, *const u8) -> isize;
type OpenMutexWFn = unsafe extern "system" fn(u32, i32, *const u16) -> isize;
static REAL_REG_A: OnceLock<RegQueryValueExAFn> = OnceLock::new();
static REAL_REG_W: OnceLock<RegQueryValueExWFn> = OnceLock::new();
static REAL_MUTEX_A: OnceLock<OpenMutexAFn> = OnceLock::new();
static REAL_MUTEX_W: OnceLock<OpenMutexWFn> = OnceLock::new();
pub fn set_real_reg_a(f: RegQueryValueExAFn) {
let _ = REAL_REG_A.set(f);
}
pub fn set_real_reg_w(f: RegQueryValueExWFn) {
let _ = REAL_REG_W.set(f);
}
pub fn set_real_mutex_a(f: OpenMutexAFn) {
let _ = REAL_MUTEX_A.set(f);
}
pub fn set_real_mutex_w(f: OpenMutexWFn) {
let _ = REAL_MUTEX_W.set(f);
}
fn narrow_to_string(p: *const u8) -> String {
if p.is_null() {
return "(null)".into();
}
let bytes = unsafe { std::ffi::CStr::from_ptr(p as *const i8) };
bytes.to_string_lossy().into_owned()
}
fn wide_to_string(p: *const u16) -> String {
if p.is_null() {
return "(null)".into();
}
let mut len = 0usize;
unsafe {
while *p.add(len) != 0 {
len += 1;
}
}
String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) })
}
fn is_interesting(name: &str) -> bool {
name.contains("LSX")
|| name.contains("Origin")
|| name.contains("EAL")
|| name.contains("Client")
|| name.contains("lsx")
|| name.contains("Port")
|| name.contains("EA")
|| name.contains("Connection")
}
pub unsafe extern "system" fn hooked_reg_query_a(
hkey: isize,
lpvaluename: *const u8,
lpreserved: *mut u32,
lptype: *mut u32,
lpdata: *mut u8,
lpcbdata: *mut u32,
) -> i32 {
let name = narrow_to_string(lpvaluename);
let real = REAL_REG_A.get().copied().unwrap();
let ret = real(hkey, lpvaluename, lpreserved, lptype, lpdata, lpcbdata);
if is_interesting(&name) {
crate::write_log(&format!("origin_spy: RegQueryValueExA({name}) → {ret}\n"));
}
ret
}
pub unsafe extern "system" fn hooked_reg_query_w(
hkey: isize,
lpvaluename: *const u16,
lpreserved: *mut u32,
lptype: *mut u32,
lpdata: *mut u8,
lpcbdata: *mut u32,
) -> i32 {
let name = wide_to_string(lpvaluename);
let real = REAL_REG_W.get().copied().unwrap();
let ret = real(hkey, lpvaluename, lpreserved, lptype, lpdata, lpcbdata);
if is_interesting(&name) {
crate::write_log(&format!("origin_spy: RegQueryValueExW({name}) → {ret}\n"));
}
ret
}
pub unsafe extern "system" fn hooked_open_mutex_a(
dwdesiredaccess: u32,
binherithandle: i32,
lpmutexname: *const u8,
) -> isize {
let name = narrow_to_string(lpmutexname);
let real = REAL_MUTEX_A.get().copied().unwrap();
let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
crate::write_log(&format!(
"origin_spy: OpenMutexA({name}) → {}\n",
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
));
handle
}
pub unsafe extern "system" fn hooked_open_mutex_w(
dwdesiredaccess: u32,
binherithandle: i32,
lpmutexname: *const u16,
) -> isize {
let name = wide_to_string(lpmutexname);
let real = REAL_MUTEX_W.get().copied().unwrap();
let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
crate::write_log(&format!(
"origin_spy: OpenMutexW({name}) → {}\n",
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
));
handle
}
-358
View File
@@ -1,358 +0,0 @@
//! Generic, fail-closed byte-patch primitive shared by per-game compatibility
//! patch tables (currently FIFA 17's TLS/store gates in [`crate::fifa17_tls`]).
//!
//! The decision logic is expressed against the [`Mem`] trait rather than raw
//! process memory, so every outcome — ORIGINAL / ALREADY_PATCHED / MISMATCH and
//! the write/verify path — is unit-testable on the host without a live client.
//! [`WinMem`] is the in-process Windows implementation used at runtime.
//!
//! FAIL-CLOSED INVARIANT: a site is written only when its live bytes are *exactly*
//! the known original. Already-patched is an idempotent no-op; anything else is
//! reported and left untouched — an unrecognised or not-yet-unpacked build is
//! never blindly overwritten.
/// Longest patch payload across all tables (FIFA17 GATE1 is 6 bytes). Sizes the
/// fixed stack buffers so no slicing panic is reachable from the patch logic.
pub const MAX_PATCH_LEN: usize = 6;
/// Byte-level access to the target's address space.
pub trait Mem {
/// Fill `buf` from `addr`. `false` = not readable yet (page uncommitted /
/// module not mapped / not unpacked) — the caller waits, it is not an error.
fn read(&self, addr: usize, buf: &mut [u8]) -> bool;
/// Write `data` at `addr`. `false` = the write could not be performed.
fn write(&mut self, addr: usize, data: &[u8]) -> bool;
}
/// Fail-closed classification of live bytes against a site's original/replacement.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PatchState {
/// Live bytes are the known original — safe to patch.
Original,
/// Live bytes already equal the replacement — idempotent.
AlreadyPatched,
/// Neither — unrecognised/not-yet-ready build; must be left untouched.
Mismatch,
}
/// Pure classification (no memory access).
pub fn classify(cur: &[u8], orig: &[u8], patch: &[u8]) -> PatchState {
if cur == patch {
PatchState::AlreadyPatched
} else if cur == orig {
PatchState::Original
} else {
PatchState::Mismatch
}
}
/// Outcome of a checked patch attempt at one site.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApplyOutcome {
/// Bytes were the original and were written and re-read as the replacement.
Applied,
/// Bytes already equalled the replacement; nothing written.
AlreadyPatched,
/// Bytes were neither original nor replacement; nothing written.
Mismatch,
/// Bytes could not be read yet (module/page not available) — retry later.
NotReadable,
/// The write itself failed (protection change or copy).
WriteFailed,
/// Wrote, but the re-read did not equal the replacement.
VerifyFailed,
}
impl ApplyOutcome {
/// Whether the site now holds the replacement (freshly or already).
pub fn is_patched(self) -> bool {
matches!(self, ApplyOutcome::Applied | ApplyOutcome::AlreadyPatched)
}
}
/// Read → classify → (only on ORIGINAL) write → re-read verify. Never writes on
/// MISMATCH; treats ALREADY_PATCHED as success. `orig`/`patch` must be equal,
/// non-empty and within [`MAX_PATCH_LEN`].
pub fn apply_checked<M: Mem>(mem: &mut M, addr: usize, orig: &[u8], patch: &[u8]) -> ApplyOutcome {
debug_assert_eq!(orig.len(), patch.len());
debug_assert!(!patch.is_empty() && patch.len() <= MAX_PATCH_LEN);
let n = patch.len();
let mut cur = [0u8; MAX_PATCH_LEN];
if !mem.read(addr, &mut cur[..n]) {
return ApplyOutcome::NotReadable;
}
match classify(&cur[..n], orig, patch) {
PatchState::AlreadyPatched => ApplyOutcome::AlreadyPatched,
PatchState::Mismatch => ApplyOutcome::Mismatch,
PatchState::Original => {
if !mem.write(addr, patch) {
return ApplyOutcome::WriteFailed;
}
let mut after = [0u8; MAX_PATCH_LEN];
if !mem.read(addr, &mut after[..n]) || &after[..n] != patch {
return ApplyOutcome::VerifyFailed;
}
ApplyOutcome::Applied
}
}
}
/// RVA of a static VA relative to an image's preferred base (pure).
pub const fn rva(static_va: u64, preferred_base: u64) -> u64 {
static_va - preferred_base
}
/// Read and classify a site without writing (`None` = not readable yet). Used to
/// decide multi-site patches (e.g. apply a gate pair only when both are original).
pub fn read_state<M: Mem>(mem: &M, addr: usize, orig: &[u8], patch: &[u8]) -> Option<PatchState> {
let n = patch.len();
let mut cur = [0u8; MAX_PATCH_LEN];
if !mem.read(addr, &mut cur[..n]) {
return None;
}
Some(classify(&cur[..n], orig, patch))
}
/// Live in-process address of an image-relative site given the module's runtime base.
pub const fn live_addr(module_base: usize, rva: u64) -> usize {
module_base + rva as usize
}
/// Lowercase, unseparated hex for diagnostics (matches the autopatch SKIP line).
pub fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(char::from_digit((b >> 4) as u32, 16).unwrap());
s.push(char::from_digit((b & 0xf) as u32, 16).unwrap());
}
s
}
// ─── In-process Windows memory (runtime only; not exercised by host tests) ──────
/// In-process implementation of [`Mem`] over this (FIFA17.exe) address space.
pub struct WinMem;
impl Mem for WinMem {
fn read(&self, addr: usize, buf: &mut [u8]) -> bool {
unsafe { guarded_read(addr, buf) }
}
fn write(&mut self, addr: usize, data: &[u8]) -> bool {
unsafe { protected_write(addr, data) }
}
}
/// Resolve a loaded module's runtime base by name, or `None` if not loaded.
pub unsafe fn module_base(name: *const u8) -> Option<usize> {
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
let h = GetModuleHandleA(name);
if h.is_null() {
None
} else {
Some(h as usize)
}
}
/// Read `buf.len()` bytes from `addr` only if the whole range is committed and
/// readable (VirtualQuery-guarded), so a wrong base/RVA can never fault.
unsafe fn guarded_read(addr: usize, buf: &mut [u8]) -> bool {
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_READONLY,
PAGE_READWRITE, PAGE_WRITECOPY,
};
if addr == 0 || buf.is_empty() {
return false;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let want = core::mem::size_of::<MEMORY_BASIC_INFORMATION>();
if VirtualQuery(addr as _, &mut mbi, want) != want {
return false;
}
if mbi.State != MEM_COMMIT {
return false;
}
let readable = PAGE_READONLY
| PAGE_READWRITE
| PAGE_WRITECOPY
| PAGE_EXECUTE_READ
| PAGE_EXECUTE_READWRITE
| PAGE_EXECUTE_WRITECOPY;
if mbi.Protect & readable == 0 || mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) != 0 {
return false;
}
// The full range must fit inside this single committed region.
let region_end = (mbi.BaseAddress as usize).wrapping_add(mbi.RegionSize);
if addr.checked_add(buf.len()).is_none_or(|e| e > region_end) {
return false;
}
core::ptr::copy_nonoverlapping(addr as *const u8, buf.as_mut_ptr(), buf.len());
true
}
/// Make `[addr, addr+data.len())` writable, copy `data`, flush the instruction
/// cache, then restore the original protection. `false` if protection could not
/// be changed. Verification is the caller's re-read (see [`apply_checked`]).
unsafe fn protected_write(addr: usize, data: &[u8]) -> bool {
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
use windows_sys::Win32::System::Threading::GetCurrentProcess;
if addr == 0 || data.is_empty() {
return false;
}
let mut old: u32 = 0;
if VirtualProtect(addr as _, data.len(), PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return false;
}
core::ptr::copy_nonoverlapping(data.as_ptr(), addr as *mut u8, data.len());
FlushInstructionCache(GetCurrentProcess(), addr as _, data.len());
// Best-effort restore of the original page protection.
let mut restored: u32 = 0;
VirtualProtect(addr as _, data.len(), old, &mut restored);
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// Deterministic fake address space for the pure patch logic.
struct FakeMem {
cells: HashMap<usize, u8>,
readable: bool,
writable: bool,
}
impl FakeMem {
fn with(addr: usize, bytes: &[u8]) -> Self {
let mut cells = HashMap::new();
for (i, b) in bytes.iter().enumerate() {
cells.insert(addr + i, *b);
}
Self {
cells,
readable: true,
writable: true,
}
}
}
impl Mem for FakeMem {
fn read(&self, addr: usize, buf: &mut [u8]) -> bool {
if !self.readable {
return false;
}
for (i, slot) in buf.iter_mut().enumerate() {
match self.cells.get(&(addr + i)) {
Some(b) => *slot = *b,
None => return false,
}
}
true
}
fn write(&mut self, addr: usize, data: &[u8]) -> bool {
if !self.writable {
return false;
}
for (i, b) in data.iter().enumerate() {
self.cells.insert(addr + i, *b);
}
true
}
}
const ORIG: [u8; 2] = [0x75, 0x0f];
const PATCH: [u8; 2] = [0x7f, 0x0f];
#[test]
fn classify_recognises_all_three_states() {
assert_eq!(classify(&ORIG, &ORIG, &PATCH), PatchState::Original);
assert_eq!(classify(&PATCH, &ORIG, &PATCH), PatchState::AlreadyPatched);
assert_eq!(classify(&[0x12, 0x34], &ORIG, &PATCH), PatchState::Mismatch);
}
#[test]
fn original_bytes_are_applied_and_verified() {
let mut m = FakeMem::with(0x1000, &ORIG);
assert_eq!(
apply_checked(&mut m, 0x1000, &ORIG, &PATCH),
ApplyOutcome::Applied
);
// Memory now holds the replacement.
let mut got = [0u8; 2];
assert!(m.read(0x1000, &mut got));
assert_eq!(got, PATCH);
}
#[test]
fn already_patched_is_idempotent_noop() {
let mut m = FakeMem::with(0x2000, &PATCH);
assert_eq!(
apply_checked(&mut m, 0x2000, &ORIG, &PATCH),
ApplyOutcome::AlreadyPatched
);
}
#[test]
fn mismatch_never_writes() {
let junk = [0xde, 0xad];
let mut m = FakeMem::with(0x3000, &junk);
assert_eq!(
apply_checked(&mut m, 0x3000, &ORIG, &PATCH),
ApplyOutcome::Mismatch
);
// Untouched.
let mut got = [0u8; 2];
assert!(m.read(0x3000, &mut got));
assert_eq!(got, junk);
}
#[test]
fn unreadable_module_waits_without_crashing() {
let mut m = FakeMem::with(0x4000, &ORIG);
m.readable = false;
let out = apply_checked(&mut m, 0x4000, &ORIG, &PATCH);
assert_eq!(out, ApplyOutcome::NotReadable);
assert!(!out.is_patched());
}
#[test]
fn write_failure_is_reported_not_pretended() {
let mut m = FakeMem::with(0x5000, &ORIG);
m.writable = false;
assert_eq!(
apply_checked(&mut m, 0x5000, &ORIG, &PATCH),
ApplyOutcome::WriteFailed
);
}
#[test]
fn running_twice_does_not_corrupt() {
let mut m = FakeMem::with(0x6000, &ORIG);
assert_eq!(
apply_checked(&mut m, 0x6000, &ORIG, &PATCH),
ApplyOutcome::Applied
);
// Second pass sees the replacement and is a no-op.
assert_eq!(
apply_checked(&mut m, 0x6000, &ORIG, &PATCH),
ApplyOutcome::AlreadyPatched
);
let mut got = [0u8; 2];
assert!(m.read(0x6000, &mut got));
assert_eq!(got, PATCH);
}
#[test]
fn rva_and_live_addr_relocate_across_bases() {
// GATE1 example: preferred 0x140000000, VA 0x146132548.
assert_eq!(rva(0x1_4613_2548, 0x1_4000_0000), 0x613_2548);
// Applied at the preferred base gives the static VA back.
assert_eq!(live_addr(0x1_4000_0000, 0x613_2548), 0x1_4613_2548);
// Applied at a relocated (ASLR) base tracks the base exactly.
assert_eq!(live_addr(0x2_0000_0000, 0x613_2548), 0x2_0613_2548);
}
#[test]
fn hex_is_lowercase_unseparated() {
assert_eq!(hex(&[0x0f, 0x85, 0xde]), "0f85de");
}
}
File diff suppressed because it is too large Load Diff
+321
View File
@@ -0,0 +1,321 @@
/// Inline hooks on ws2_32!recv and ws2_32!send only.
///
/// WSARecv/WSASend are NOT hooked — their prologues contain RIP-relative
/// (short conditional jump) instructions that would break trampolines.
/// FIFA's LSX client uses plain recv/send, which is confirmed by prior logs.
///
/// Trampolines allow multiple threads to call the original function
/// concurrently without locks or unhook/rehook races.
use core::sync::atomic::{AtomicUsize, Ordering};
unsafe fn write_jmp(target: *mut u8, dest: u64) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
target.write(0xFF);
target.add(1).write(0x25);
(target.add(2) as *mut u32).write(0);
(target.add(6) as *mut u64).write(dest);
VirtualProtect(target as _, 14, old, &mut old);
}
unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option<usize> {
use windows_sys::Win32::System::Memory::{
VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE,
};
// Read enough prologue to walk instruction boundaries.
let probe: [u8; 24] = core::array::from_fn(|i| *orig.add(i));
let hex: String = probe[..14].iter().map(|b| format!("{b:02x} ")).collect();
crate::write_log(&format!("recv_hook: {name} prologue {hex}\n"));
// Copy WHOLE instructions until we've covered >= 14 bytes (the size of the JMP
// patch), so the trampoline never splits an instruction. Copying a fixed 14
// bytes lands mid-instruction on these prologues and crashes on execution.
let mut copy_len = 0usize;
while copy_len < 14 {
let (len, branch) = decode_instr_len(&probe[copy_len..]);
if len == 0 || branch {
crate::write_log(&format!(
"recv_hook: {name} unrelocatable prologue (len={len} branch={branch}), skipping\n"
));
return None;
}
copy_len += len;
}
let mem = VirtualAlloc(
core::ptr::null_mut(),
64,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE,
);
if mem.is_null() {
crate::write_log("recv_hook: VirtualAlloc failed\n");
return None;
}
let t = mem as *mut u8;
core::ptr::copy_nonoverlapping(orig, t, copy_len);
// JMP [RIP+0] → orig+copy_len (resume at the next whole instruction)
let cont = (orig as u64) + copy_len as u64;
t.add(copy_len).write(0xFF);
t.add(copy_len + 1).write(0x25);
(t.add(copy_len + 2) as *mut u32).write(0);
(t.add(copy_len + 6) as *mut u64).write(cont);
crate::write_log(&format!(
"recv_hook: {name} trampoline copy_len={copy_len}\n"
));
Some(t as usize)
}
/// Walk x86-64 instruction boundaries and return true if any relative branch
/// (JE/JNE/JCC rel8, JMP rel8, JMP/CALL rel32, Jcc rel32) is encountered.
/// Correctly skips over immediate operands so `sub rsp, 0x70` doesn't trigger.
fn has_rip_relative_branch(bytes: &[u8]) -> bool {
let mut pos = 0;
while pos < bytes.len() {
let (len, branch) = decode_instr_len(&bytes[pos..]);
if branch {
return true;
}
if len == 0 {
break;
} // unknown/truncated — stop safely
pos += len;
}
false
}
fn modrm_extra(modrm: u8) -> usize {
let md = (modrm >> 6) & 3;
let rm = modrm & 7;
match md {
0 => {
if rm == 5 {
4
} else if rm == 4 {
1
} else {
0
}
}
1 => {
if rm == 4 {
2
} else {
1
}
}
2 => {
if rm == 4 {
5
} else {
4
}
}
_ => 0,
}
}
/// Returns (instruction_length_in_bytes, is_rip_relative_branch).
/// Returns (0, false) for unknown/truncated.
fn decode_instr_len(b: &[u8]) -> (usize, bool) {
if b.is_empty() {
return (0, false);
}
let mut i = 0;
// Legacy prefixes
while let Some(&p) = b.get(i) {
if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) {
i += 1;
} else {
break;
}
}
// REX prefix (404F)
if b.get(i)
.copied()
.map(|x| (0x40..=0x4F).contains(&x))
.unwrap_or(false)
{
i += 1;
}
let op = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
i += 1;
match op {
// push/pop reg (50-5F): no extra bytes
0x50..=0x5F => (i, false),
// nop
0x90 => (i, false),
// Short Jcc (70-7F): 1 byte operand, IS a relative branch
x if (0x70..=0x7F).contains(&x) => (i + 1, true),
// JMP rel8, JMP rel32, CALL rel32
0xEB => (i + 1, true),
0xE9 | 0xE8 => (i + 4, true),
// 0F prefix
0x0F => {
let op2 = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
i += 1;
if (0x80..=0x8F).contains(&op2) {
return (i + 4, true);
} // Jcc rel32
// Most 0F XX: ModRM
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm), false)
}
// Instructions with ModRM only (no immediate)
0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | 0x01 | 0x03
| 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | 0x31 | 0x33 | 0x39 | 0x3B
| 0xD3 | 0xFF | 0xF7 => {
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm), false)
}
// ModRM + imm8
0x6B | 0x80 | 0x83 | 0xC0 | 0xC1 | 0xC6 => {
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm) + 1, false)
}
// ModRM + imm32
0x69 | 0x81 | 0xC7 => {
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm) + 4, false)
}
// MOV reg, imm8/imm32
0xB0..=0xB7 => (i + 1, false),
0xB8..=0xBF => (i + 4, false),
// PUSH imm
0x6A => (i + 1, false),
0x68 => (i + 4, false),
// RET
0xC2 => (i + 2, false),
0xC3 => (i, false),
_ => (0, false), // unknown — stop
}
}
unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> {
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
let h = GetModuleHandleA(dll.as_ptr());
if h.is_null() {
return None;
}
GetProcAddress(h, sym.as_ptr()).map(|f| f as *mut u8)
}
// ─── recv ──────────────────────────────────────────────────────────────────────
static RECV_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
/// True if socket `s` is connected to the EA App LSX port (127.0.0.1:3216).
/// Used in capture mode to tap only the LSX conversation.
unsafe fn peer_is_lsx(s: usize) -> bool {
use windows_sys::Win32::Networking::WinSock::getpeername;
let mut sa = [0u8; 16];
let mut sl: i32 = 16;
if getpeername(s, sa.as_mut_ptr() as *mut _, &mut sl) != 0 {
return false;
}
// sockaddr_in: sa_family (2 bytes) then sin_port (2 bytes, network order).
u16::from_be_bytes([sa[2], sa[3]]) == 3216
}
// IAT-hook approach (no inline trampoline — FIFA's `recv`/`send` prologues have
// instructions that straddle the 14-byte patch boundary, so an inline trampoline
// corrupts them and crashes. IAT hooking only swaps import-table pointers and
// never touches the function body). The real fns are resolved in lib.rs and set
// here; our hooks call them directly.
static REAL_RECV: AtomicUsize = AtomicUsize::new(0);
static REAL_SEND: AtomicUsize = AtomicUsize::new(0);
pub fn set_real_recv(f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32) {
REAL_RECV.store(f as usize, Ordering::Relaxed);
}
pub fn set_real_send(f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32) {
REAL_SEND.store(f as usize, Ordering::Relaxed);
}
/// Inline-hook ws2_32!recv: build a boundary-safe trampoline (the "real" fn our
/// hook calls) and overwrite the entry with a JMP to `hooked_recv`. Inline hooks
/// catch calls from every module and dynamically-resolved calls, unlike IAT.
pub unsafe fn install_recv_hook() -> bool {
let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") {
Some(p) => p,
None => return false,
};
match make_trampoline(ptr, "recv") {
Some(t) => REAL_RECV.store(t, Ordering::Relaxed),
None => return false,
}
write_jmp(ptr, hooked_recv as u64);
true
}
pub unsafe fn install_send_hook() -> bool {
let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") {
Some(p) => p,
None => return false,
};
match make_trampoline(ptr, "send") {
Some(t) => REAL_SEND.store(t, Ordering::Relaxed),
None => return false,
}
write_jmp(ptr, hooked_send as u64);
true
}
pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
let t = REAL_RECV.load(Ordering::Relaxed);
if t == 0 {
return -1;
}
let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t);
// Pass through to anadius's real socket, then log what it sent back
// (anadius's LSX response — the ground truth we want to diff against).
let n = f(s, buf, len, flags);
if n > 0 && peer_is_lsx(s) {
let data = core::slice::from_raw_parts(buf, n as usize);
let text = core::str::from_utf8(data).unwrap_or("(binary)");
crate::write_log(&format!(
"CAP recv<-anadius s={s} n={n}: {}\n",
&text[..text.len().min(2400)]
));
}
n
}
pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
if len > 0 && peer_is_lsx(s) {
let data = core::slice::from_raw_parts(buf, len as usize);
let text = core::str::from_utf8(data).unwrap_or("(binary)");
crate::write_log(&format!(
"CAP send->anadius s={s} len={len}: {}\n",
&text[..text.len().min(2400)]
));
}
let t = REAL_SEND.load(Ordering::Relaxed);
if t == 0 {
return -1;
}
let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t);
f(s, buf, len, flags)
}
+248
View File
@@ -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)
}
-856
View File
@@ -1,856 +0,0 @@
//! Guarded FIFA 17 SBC completion dispatch and passive event tracing.
//!
//! The repair is a PROMOTED feature: it is armed by the build itself, never by an
//! environment variable (see [`REPAIR_PROMOTED`]). Safety lives in the runtime
//! evidence gate, not in a flag.
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::LibraryLoader::{
GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
GET_MODULE_HANDLE_EX_FLAG_PIN,
};
use windows_sys::Win32::System::Memory::{
VirtualAlloc, VirtualFree, VirtualProtect, MEM_COMMIT, MEM_RELEASE, MEM_RESERVE,
PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_READWRITE,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
const COMPLETION_RVA: usize = 0x0b8950;
const EVENT_DISPATCH_RVA: usize = 0x1a4cd0;
const CATEGORY_RESPONSE_VTABLE_RVA: usize = 0x22e5b0;
const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
const COMPLETION_COPY_LEN: usize = 14;
const EVENT_COPY_LEN: usize = 16;
const ABS_JUMP_LEN: usize = 14;
const COMPLETION_TRAMPOLINE_LEN: usize = 12 + 2 + ABS_JUMP_LEN * 2;
const UNKNOWN_TRANSPORT_STATUS: u32 = 999;
const FUT_SBS_CATEGORIES_EVENT: u32 = 0x756c;
const FUT_SBS_CATEGORIES_READY_EVENT: u32 = 0x756d;
const SBC_REFRESH_EVENT: u32 = 0x138c;
const COMPLETION_SIGNATURE: [u8; 32] = [
0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2, 0x74, 0x4e, 0x83, 0x7a,
0x1c, 0x00, 0x75, 0x48, 0xc6, 0x81, 0x1d, 0x02, 0x00, 0x00, 0x01, 0x48, 0x8b, 0x89, 0x40, 0x01,
];
const EVENT_SIGNATURE: [u8; EVENT_COPY_LEN] = [
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x40, 0xb8, 0xfe, 0xff, 0xff, 0xff,
];
type CompletionFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> usize;
type EventDispatchFn = unsafe extern "system" fn(*mut c_void, u32, *mut c_void) -> usize;
/// The guarded native dispatch repair is PROMOTED: armed by the build, never by an
/// environment variable. Retail Gates AG passed on the pinned CardsDLL build, so a
/// deployed hook must repair the SBC completion on every launch path (Steam, the
/// launcher, or a bare `umu-run`) with nothing to export.
///
/// Promotion does NOT weaken any check — every guard stays in the runtime evidence
/// gate rather than in a flag. `worker` still validates the exact CardsDLL
/// signatures before installing a detour, and [`decide`] still requires the
/// transport sentinel status, the pinned category-response vtable captured while
/// the response object was provably live, balanced parser counts on the one parser
/// thread, this generation's notifier having entered AND returned, the captured
/// controller/model identity, and one repair per deserializer generation. Anything
/// unrecognised leaves native execution untouched.
///
/// Rollback is a file swap (restore the previous `version.dll`) — the documented
/// client rollback path — deliberately not an env kill-switch.
pub(crate) const REPAIR_PROMOTED: bool = true;
/// Compile-time contract: the repair stays armed by the build. Flipping this back to
/// an env gate would silently cost a normal launch (Steam or the launcher) its SBC
/// screen, which is exactly the regression promotion removed — so it must be a
/// deliberate, visible change here rather than a missing variable at runtime.
const _: () = assert!(REPAIR_PROMOTED);
static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false);
static COMPLETION_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static EVENT_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static LAST_REPAIRED_GENERATION: AtomicU64 = AtomicU64::new(0);
static COMPLETION_ENTRIES: AtomicU64 = AtomicU64::new(0);
static COMPLETION_EXITS: AtomicU64 = AtomicU64::new(0);
static COMPLETION_THREAD: AtomicUsize = AtomicUsize::new(0);
static COMPLETION_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static COMPLETION_STATUS_OBJECT: AtomicUsize = AtomicUsize::new(0);
static COMPLETION_STATUS: AtomicUsize = AtomicUsize::new(usize::MAX);
static COMPLETION_GENERATION: AtomicU64 = AtomicU64::new(0);
static COMPLETION_DECISION: AtomicUsize = AtomicUsize::new(Decision::NativeSuccess as usize);
static COMPLETION_REJECTION: AtomicUsize = AtomicUsize::new(Rejection::None as usize);
static EVENT_ENTRIES: AtomicU64 = AtomicU64::new(0);
static EVENT_EXITS: AtomicU64 = AtomicU64::new(0);
static EVENT_THREAD: AtomicUsize = AtomicUsize::new(0);
static EVENT_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static EVENT_ID: AtomicUsize = AtomicUsize::new(0);
static EVENT_PAYLOAD: AtomicUsize = AtomicUsize::new(0);
static EVENT_CATEGORIES: AtomicU64 = AtomicU64::new(0);
static EVENT_REFRESH: AtomicU64 = AtomicU64::new(0);
static EVENT_READY: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum Decision {
NativeSuccess,
Repair,
}
const REJECTED_DECISION: usize = 2;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum Rejection {
None,
RepairDisabled,
NullStatus,
StatusUnreadable,
UnsupportedStatus,
CardsBuildMismatch,
ParserUnbalanced,
FactoryMismatch,
ParserThreadMismatch,
ReaderMissing,
ParseFailed,
ResponseClassMismatch,
ModelChanged,
ModelEmpty,
NotifierNotCurrent,
ControllerMismatch,
ControllerModelMismatch,
DuplicateGeneration,
}
#[derive(Clone, Copy)]
struct DecisionInput {
repair_enabled: bool,
status: Option<u32>,
status_present: bool,
status_copyable: bool,
cards_build_matches: bool,
factory_entries: u64,
factory_exits: u64,
factory_result: usize,
factory_thread: usize,
deserializer_entries: u64,
deserializer_exits: u64,
deserializer_this: usize,
deserializer_reader: usize,
deserializer_result: bool,
deserializer_thread: usize,
response_class_matches: bool,
model: usize,
live_category_count: usize,
category_count: usize,
notifier_entries: u64,
notifier_exits: u64,
controller_matches: bool,
controller_model_matches: bool,
last_repaired_generation: u64,
}
fn decide(input: DecisionInput) -> Result<Decision, Rejection> {
let Some(status) = input.status else {
return Err(if input.status_present {
Rejection::StatusUnreadable
} else {
Rejection::NullStatus
});
};
if status == 0 {
return Ok(Decision::NativeSuccess);
}
if !input.repair_enabled {
return Err(Rejection::RepairDisabled);
}
if status != UNKNOWN_TRANSPORT_STATUS {
return Err(Rejection::UnsupportedStatus);
}
if !input.status_copyable {
return Err(Rejection::StatusUnreadable);
}
if !input.cards_build_matches {
return Err(Rejection::CardsBuildMismatch);
}
let generation = input.deserializer_exits;
if generation == 0
|| input.factory_entries != input.factory_exits
|| input.deserializer_entries != generation
|| input.factory_exits != generation
{
return Err(Rejection::ParserUnbalanced);
}
if input.factory_result == 0 || input.factory_result != input.deserializer_this {
return Err(Rejection::FactoryMismatch);
}
if input.factory_thread == 0 || input.factory_thread != input.deserializer_thread {
return Err(Rejection::ParserThreadMismatch);
}
if input.deserializer_reader == 0 {
return Err(Rejection::ReaderMissing);
}
if !input.deserializer_result {
return Err(Rejection::ParseFailed);
}
if !input.response_class_matches {
return Err(Rejection::ResponseClassMismatch);
}
if input.model == 0 || input.category_count == 0 || input.category_count == usize::MAX {
return Err(Rejection::ModelEmpty);
}
if input.live_category_count != input.category_count {
return Err(Rejection::ModelChanged);
}
// The category-success notifier for this generation must have entered and
// fully returned before the SBC completion runs. On the pinned CardsDLL the
// completion fires immediately after the notifier unwinds (measured: notifier
// entries == exits == generation at completion), not nested inside it, so we
// bind both notifier counts to the current generation rather than requiring
// an in-flight notifier.
if input.notifier_entries != generation
|| input.notifier_entries == 0
|| input.notifier_exits != generation
{
return Err(Rejection::NotifierNotCurrent);
}
if !input.controller_matches {
return Err(Rejection::ControllerMismatch);
}
if !input.controller_model_matches {
return Err(Rejection::ControllerModelMismatch);
}
if input.last_repaired_generation >= generation {
return Err(Rejection::DuplicateGeneration);
}
Ok(Decision::Repair)
}
/// The parsed response is the FIFA 17 typed SBC-category response only when the
/// vtable captured at deserializer exit (object provably live) equals the pinned
/// category-response vtable for the running CardsDLL image. A zero capture means
/// the object vtable was unreadable and never qualifies.
fn response_class_matches(base: usize, response_vtable: usize) -> bool {
response_vtable != 0 && base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA) == Some(response_vtable)
}
unsafe fn guarded_u32(address: usize) -> Option<u32> {
crate::sbc_trace::readable_range(address, 4)
.then(|| core::ptr::read_volatile(address as *const u32))
}
unsafe fn status_code(status: usize) -> Option<u32> {
status
.checked_add(0x1c)
.and_then(|address| guarded_u32(address))
}
unsafe fn controller_identity(base: usize, controller: usize, model: usize) -> (bool, bool) {
if base == 0 || controller == 0 {
return (false, false);
}
let main_vtable = crate::sbc_trace::guarded_usize(controller);
let event_vtable = controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|address| crate::sbc_trace::guarded_usize(address));
let controller_model = controller
.checked_add(SBC_CONTROLLER_MODEL_OFF)
.and_then(|address| crate::sbc_trace::guarded_usize(address));
(
main_vtable == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
&& event_vtable == base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA),
controller_model == Some(model),
)
}
pub(crate) unsafe fn note_sbc_controller(controller: usize, base: usize) {
let (identity_matches, _) = controller_identity(base, controller, 0);
if identity_matches {
SBC_CONTROLLER.store(controller, Ordering::Release);
crate::write_log(&format!(
"SBC_DISPATCH: captured category controller={controller:#x}\n"
));
} else {
crate::write_log(&format!(
"SBC_DISPATCH: rejected category controller={controller:#x} (class mismatch)\n"
));
}
}
#[repr(C, align(16))]
struct CompletionStatusShadow([u8; 0x20]);
unsafe extern "system" fn completion_wrapper(
controller: *mut c_void,
status: *mut c_void,
) -> usize {
COMPLETION_ENTRIES.fetch_add(1, Ordering::Relaxed);
COMPLETION_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
COMPLETION_CONTROLLER.store(controller as usize, Ordering::Relaxed);
COMPLETION_STATUS_OBJECT.store(status as usize, Ordering::Relaxed);
let evidence = crate::sbc_trace::dispatch_evidence();
let status_address = status as usize;
let observed_status = if status_address == 0 {
None
} else {
status_code(status_address)
};
COMPLETION_STATUS.store(
observed_status
.map(|value| value as usize)
.unwrap_or(usize::MAX),
Ordering::Relaxed,
);
COMPLETION_GENERATION.store(evidence.deserializer_exits, Ordering::Relaxed);
let captured_controller = SBC_CONTROLLER.load(Ordering::Acquire);
let live_category_count = evidence
.model
.checked_add(0x50)
.and_then(|address| crate::sbc_trace::guarded_u16(address))
.map(usize::from)
.unwrap_or(usize::MAX);
let (controller_matches, controller_model_matches) =
controller_identity(evidence.base, captured_controller, evidence.model);
let input = DecisionInput {
repair_enabled: REPAIR_ENABLED.load(Ordering::Acquire),
status: observed_status,
status_present: status_address != 0,
status_copyable: status_address != 0
&& crate::sbc_trace::readable_range(status_address, 0x20),
cards_build_matches: crate::sbc_trace::valid_cards_image(evidence.base),
factory_entries: evidence.factory_entries,
factory_exits: evidence.factory_exits,
factory_result: evidence.factory_result,
factory_thread: evidence.factory_thread,
deserializer_entries: evidence.deserializer_entries,
deserializer_exits: evidence.deserializer_exits,
deserializer_this: evidence.deserializer_this,
deserializer_reader: evidence.deserializer_reader,
deserializer_result: evidence.deserializer_result,
deserializer_thread: evidence.deserializer_thread,
response_class_matches: response_class_matches(evidence.base, evidence.response_vtable),
model: evidence.model,
live_category_count,
category_count: evidence.category_count,
notifier_entries: evidence.notifier_entries,
notifier_exits: evidence.notifier_exits,
controller_matches: controller_matches && captured_controller == controller as usize,
controller_model_matches,
last_repaired_generation: LAST_REPAIRED_GENERATION.load(Ordering::Acquire),
};
let original: CompletionFn =
core::mem::transmute(COMPLETION_TRAMPOLINE.load(Ordering::Acquire));
let result = match decide(input) {
Ok(Decision::Repair) => {
if LAST_REPAIRED_GENERATION
.compare_exchange(
input.last_repaired_generation,
evidence.deserializer_exits,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
let mut shadow = CompletionStatusShadow([0; 0x20]);
core::ptr::copy_nonoverlapping(
status_address as *const u8,
shadow.0.as_mut_ptr(),
shadow.0.len(),
);
shadow.0[0x1c..0x20].copy_from_slice(&0u32.to_le_bytes());
COMPLETION_DECISION.store(Decision::Repair as usize, Ordering::Relaxed);
COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed);
original(controller, shadow.0.as_mut_ptr().cast())
} else {
COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed);
COMPLETION_REJECTION
.store(Rejection::DuplicateGeneration as usize, Ordering::Relaxed);
original(controller, status)
}
}
Ok(Decision::NativeSuccess) => {
COMPLETION_DECISION.store(Decision::NativeSuccess as usize, Ordering::Relaxed);
COMPLETION_REJECTION.store(Rejection::None as usize, Ordering::Relaxed);
original(controller, status)
}
Err(rejection) => {
COMPLETION_DECISION.store(REJECTED_DECISION, Ordering::Relaxed);
COMPLETION_REJECTION.store(rejection as usize, Ordering::Relaxed);
original(controller, status)
}
};
crate::write_log(&format!(
"SBC_DISPATCH: decide gen={} status={} present={} copyable={} cards={} factory_e={} factory_x={} factory_r={:#x} factory_t={} deser_e={} deser_x={} deser_this={:#x} reader={:#x} deser_ok={} deser_t={} vt_obs={:#x} vt_exp={:#x} class={} model={:#x} live={} count={} notif_e={} notif_x={} ctrl_match={} ctrl_model={} captured_ctrl={:#x} arg_ctrl={:#x} last_gen={} decision={} rejection={}\n",
input.deserializer_exits,
input.status.map(i64::from).unwrap_or(-1),
input.status_present,
input.status_copyable,
input.cards_build_matches,
input.factory_entries,
input.factory_exits,
input.factory_result,
input.factory_thread,
input.deserializer_entries,
input.deserializer_exits,
input.deserializer_this,
input.deserializer_reader,
input.deserializer_result,
input.deserializer_thread,
evidence.response_vtable,
evidence.base.checked_add(CATEGORY_RESPONSE_VTABLE_RVA).unwrap_or(0),
input.response_class_matches,
input.model,
input.live_category_count,
input.category_count,
input.notifier_entries,
input.notifier_exits,
input.controller_matches,
input.controller_model_matches,
captured_controller,
controller as usize,
input.last_repaired_generation,
COMPLETION_DECISION.load(Ordering::Relaxed),
COMPLETION_REJECTION.load(Ordering::Relaxed),
));
COMPLETION_EXITS.fetch_add(1, Ordering::Release);
result
}
unsafe extern "system" fn event_wrapper(
controller: *mut c_void,
event: u32,
payload: *mut c_void,
) -> usize {
EVENT_ENTRIES.fetch_add(1, Ordering::Relaxed);
EVENT_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
EVENT_CONTROLLER.store(controller as usize, Ordering::Relaxed);
EVENT_ID.store(event as usize, Ordering::Relaxed);
EVENT_PAYLOAD.store(payload as usize, Ordering::Relaxed);
match event {
FUT_SBS_CATEGORIES_EVENT => {
EVENT_CATEGORIES.fetch_add(1, Ordering::Relaxed);
}
SBC_REFRESH_EVENT => {
EVENT_REFRESH.fetch_add(1, Ordering::Relaxed);
}
FUT_SBS_CATEGORIES_READY_EVENT => {
EVENT_READY.fetch_add(1, Ordering::Relaxed);
}
_ => {}
}
// Piggyback the store pre-warm on this game-thread hub event: it loads the
// purchase groups once, before the store screen is shown, so the store's native
// screen-show tab bind sees a populated group list (see `store_entry`).
crate::store_entry::maybe_prewarm_groups();
let original: EventDispatchFn = core::mem::transmute(EVENT_TRAMPOLINE.load(Ordering::Acquire));
let result = original(controller, event, payload);
EVENT_EXITS.fetch_add(1, Ordering::Release);
result
}
unsafe fn allocate_completion_trampoline(target: usize) -> Option<usize> {
let failure_target = target.checked_add(0x5c)?;
let success_target = target.checked_add(COMPLETION_COPY_LEN)?;
let memory = VirtualAlloc(
core::ptr::null(),
COMPLETION_TRAMPOLINE_LEN,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE,
) as usize;
if memory == 0 {
return None;
}
core::ptr::copy_nonoverlapping(target as *const u8, memory as *mut u8, 12);
// The relocated branch preserves the original null-status failure edge.
core::ptr::copy_nonoverlapping([0x75, 0x0e].as_ptr(), (memory + 12) as *mut u8, 2);
let failure = crate::sbc_trace::absolute_jump(failure_target);
core::ptr::copy_nonoverlapping(failure.as_ptr(), (memory + 14) as *mut u8, ABS_JUMP_LEN);
let success = crate::sbc_trace::absolute_jump(success_target);
core::ptr::copy_nonoverlapping(success.as_ptr(), (memory + 28) as *mut u8, ABS_JUMP_LEN);
let mut old = 0u32;
if VirtualProtect(
memory as _,
COMPLETION_TRAMPOLINE_LEN,
PAGE_EXECUTE_READ,
&mut old,
) == 0
|| FlushInstructionCache(GetCurrentProcess(), memory as _, COMPLETION_TRAMPOLINE_LEN) == 0
{
VirtualFree(memory as _, 0, MEM_RELEASE);
return None;
}
Some(memory)
}
unsafe fn restore_entry<const N: usize>(target: usize, original: &[u8; N]) -> bool {
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return false;
}
core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0
}
unsafe fn write_entry<const N: usize>(
target: usize,
destination: usize,
original: &[u8; N],
) -> Result<(), bool> {
let mut patch = [0x90u8; N];
patch[..ABS_JUMP_LEN].copy_from_slice(&crate::sbc_trace::absolute_jump(destination));
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return Err(true);
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
if flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 {
Ok(())
} else {
Err(restore_entry(target, original))
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InstallOutcome {
Installed,
CleanFailure,
DegradedHookActive,
DegradedProcessState,
DegradedHookAndProcess,
}
unsafe fn install_pair(base: usize) -> InstallOutcome {
let Some(completion) = crate::sbc_trace::target_va(base, COMPLETION_RVA) else {
return InstallOutcome::CleanFailure;
};
let Some(event) = crate::sbc_trace::target_va(base, EVENT_DISPATCH_RVA) else {
return InstallOutcome::CleanFailure;
};
let completion_original: [u8; COMPLETION_COPY_LEN] = COMPLETION_SIGNATURE
[..COMPLETION_COPY_LEN]
.try_into()
.unwrap();
if !crate::sbc_trace::valid_cards_image(base)
|| !crate::sbc_trace::executable_range_in_image(
base,
completion,
COMPLETION_SIGNATURE.len(),
)
|| !crate::sbc_trace::executable_range_in_image(base, event, EVENT_SIGNATURE.len())
|| core::slice::from_raw_parts(completion as *const u8, COMPLETION_SIGNATURE.len())
!= COMPLETION_SIGNATURE
|| core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len()) != EVENT_SIGNATURE
{
return InstallOutcome::CleanFailure;
}
let mut pinned = core::ptr::null_mut();
if GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
completion as *const u8,
&mut pinned,
) == 0
|| pinned as usize != base
{
return InstallOutcome::CleanFailure;
}
let Some(completion_trampoline) = allocate_completion_trampoline(completion) else {
return InstallOutcome::CleanFailure;
};
let Some(event_trampoline) = crate::sbc_trace::allocate_trampoline(event, EVENT_COPY_LEN)
else {
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
return InstallOutcome::CleanFailure;
};
COMPLETION_TRAMPOLINE.store(completion_trampoline, Ordering::Release);
EVENT_TRAMPOLINE.store(event_trampoline, Ordering::Release);
let Some(_gate) = crate::sbc_trace::acquire_patch_installer_gate() else {
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
EVENT_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
};
let mut peers = match crate::sbc_trace::suspend_peers(completion, event) {
Ok(peers) => peers,
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
EVENT_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
}
Err(crate::sbc_trace::QuiesceFailure::Resume) => {
return InstallOutcome::DegradedProcessState;
}
};
let final_valid = crate::sbc_trace::valid_cards_image(base)
&& core::slice::from_raw_parts(completion as *const u8, COMPLETION_SIGNATURE.len())
== COMPLETION_SIGNATURE
&& core::slice::from_raw_parts(event as *const u8, EVENT_SIGNATURE.len())
== EVENT_SIGNATURE;
let transaction = if !final_valid {
InstallOutcome::CleanFailure
} else {
match write_entry(
completion,
completion_wrapper as *const () as usize,
&completion_original,
) {
Ok(()) => {
match write_entry(event, event_wrapper as *const () as usize, &EVENT_SIGNATURE) {
Ok(()) => InstallOutcome::Installed,
Err(event_clean) => {
let completion_clean = restore_entry(completion, &completion_original);
if event_clean && completion_clean {
InstallOutcome::CleanFailure
} else {
InstallOutcome::DegradedHookActive
}
}
}
}
Err(true) => InstallOutcome::CleanFailure,
Err(false) => InstallOutcome::DegradedHookActive,
}
};
let resumed = peers.resume_all();
let outcome = if resumed {
transaction
} else if matches!(
transaction,
InstallOutcome::Installed | InstallOutcome::DegradedHookActive
) {
InstallOutcome::DegradedHookAndProcess
} else {
InstallOutcome::DegradedProcessState
};
if outcome == InstallOutcome::CleanFailure {
VirtualFree(completion_trampoline as _, 0, MEM_RELEASE);
VirtualFree(event_trampoline as _, 0, MEM_RELEASE);
COMPLETION_TRAMPOLINE.store(0, Ordering::Release);
EVENT_TRAMPOLINE.store(0, Ordering::Release);
}
outcome
}
unsafe fn worker() {
let _pending = crate::sbc_trace::CodeInstallerPending;
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
let outcome = if base == 0 {
InstallOutcome::CleanFailure
} else {
install_pair(base)
};
drop(_pending);
match outcome {
InstallOutcome::Installed => crate::write_log(
"SBC_DISPATCH: completion+event hooks installed; repair remains gate-controlled\n",
),
InstallOutcome::CleanFailure => {
crate::write_log("SBC_DISPATCH: clean install failure; inactive\n");
return;
}
InstallOutcome::DegradedHookActive => {
crate::write_log("SBC_DISPATCH: DEGRADED hook may be active; terminate game now\n");
return;
}
InstallOutcome::DegradedProcessState => {
crate::write_log("SBC_DISPATCH: DEGRADED thread state; terminate game now\n");
return;
}
InstallOutcome::DegradedHookAndProcess => {
crate::write_log("SBC_DISPATCH: DEGRADED hook and thread state; terminate game now\n");
return;
}
}
let mut completion_seen = 0u64;
let mut event_seen = 0u64;
let mut reports = 0u8;
while reports < 64 {
std::thread::sleep(std::time::Duration::from_millis(250));
let completion_entries = COMPLETION_ENTRIES.load(Ordering::Acquire);
let event_entries = EVENT_ENTRIES.load(Ordering::Acquire);
if completion_entries != completion_seen || event_entries != event_seen {
crate::write_log(&format!(
"SBC_DISPATCH: completion entry={} exit={} tid={} controller={:#x} status_obj={:#x} status={} generation={} decision={} rejection={}; event entry={} exit={} tid={} controller={:#x} id={:#x} payload={:#x} categories={} refresh={} ready={}\n",
completion_entries,
COMPLETION_EXITS.load(Ordering::Acquire),
COMPLETION_THREAD.load(Ordering::Relaxed),
COMPLETION_CONTROLLER.load(Ordering::Relaxed),
COMPLETION_STATUS_OBJECT.load(Ordering::Relaxed),
COMPLETION_STATUS.load(Ordering::Relaxed),
COMPLETION_GENERATION.load(Ordering::Relaxed),
COMPLETION_DECISION.load(Ordering::Relaxed),
COMPLETION_REJECTION.load(Ordering::Relaxed),
event_entries,
EVENT_EXITS.load(Ordering::Acquire),
EVENT_THREAD.load(Ordering::Relaxed),
EVENT_CONTROLLER.load(Ordering::Relaxed),
EVENT_ID.load(Ordering::Relaxed),
EVENT_PAYLOAD.load(Ordering::Relaxed),
EVENT_CATEGORIES.load(Ordering::Relaxed),
EVENT_REFRESH.load(Ordering::Relaxed),
EVENT_READY.load(Ordering::Relaxed),
));
completion_seen = completion_entries;
event_seen = event_entries;
reports += 1;
}
}
crate::write_log("SBC_DISPATCH: report cap reached; hooks remain installed\n");
}
pub(crate) fn install() {
// Promoted: armed by the build. No environment variable participates in the
// decision, so every launch path behaves identically.
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
crate::write_log(
"SBC_DISPATCH: repair ARMED (promoted); strict native evidence gate enabled\n",
);
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_input(generation: u64) -> DecisionInput {
DecisionInput {
repair_enabled: true,
status: Some(UNKNOWN_TRANSPORT_STATUS),
status_present: true,
status_copyable: true,
cards_build_matches: true,
factory_entries: generation,
factory_exits: generation,
factory_result: 0x2000,
factory_thread: 7,
deserializer_entries: generation,
deserializer_exits: generation,
deserializer_this: 0x2000,
deserializer_reader: 0x3000,
deserializer_result: true,
deserializer_thread: 7,
response_class_matches: true,
model: 0x4000,
live_category_count: 2,
category_count: 2,
notifier_entries: generation,
notifier_exits: generation,
controller_matches: true,
controller_model_matches: true,
last_repaired_generation: generation - 1,
}
}
#[test]
fn native_success_is_never_rewritten() {
let mut input = valid_input(1);
input.status = Some(0);
assert_eq!(decide(input), Ok(Decision::NativeSuccess));
}
#[test]
fn exact_unknown_status_and_full_evidence_allow_repair() {
assert_eq!(decide(valid_input(1)), Ok(Decision::Repair));
}
#[test]
fn repair_is_exactly_gated_and_fail_closed() {
let mut input = valid_input(1);
input.repair_enabled = false;
assert_eq!(decide(input), Err(Rejection::RepairDisabled));
let mut input = valid_input(1);
input.status = Some(500);
assert_eq!(decide(input), Err(Rejection::UnsupportedStatus));
let mut input = valid_input(1);
input.category_count = 0;
assert_eq!(decide(input), Err(Rejection::ModelEmpty));
let mut input = valid_input(1);
input.controller_matches = false;
assert_eq!(decide(input), Err(Rejection::ControllerMismatch));
let mut input = valid_input(1);
input.status = None;
input.status_present = false;
assert_eq!(decide(input), Err(Rejection::NullStatus));
let mut input = valid_input(1);
input.status = None;
assert_eq!(decide(input), Err(Rejection::StatusUnreadable));
// Notifier still in flight for this generation (has not returned) is rejected:
// on the pinned build the completion only runs after the notifier unwinds.
let mut input = valid_input(1);
input.notifier_exits = 0;
assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent));
// A notifier count that does not match the current generation is rejected.
let mut input = valid_input(1);
input.notifier_entries = 2;
input.notifier_exits = 2;
assert_eq!(decide(input), Err(Rejection::NotifierNotCurrent));
let mut input = valid_input(1);
input.controller_model_matches = false;
assert_eq!(decide(input), Err(Rejection::ControllerModelMismatch));
let mut input = valid_input(1);
input.live_category_count = 0;
assert_eq!(decide(input), Err(Rejection::ModelChanged));
}
#[test]
fn each_generation_is_one_shot_but_next_lifecycle_is_allowed() {
let mut duplicate = valid_input(1);
duplicate.last_repaired_generation = 1;
assert_eq!(decide(duplicate), Err(Rejection::DuplicateGeneration));
let next = valid_input(2);
assert_eq!(decide(next), Ok(Decision::Repair));
}
#[test]
fn response_class_requires_exact_pinned_vtable() {
let base = 0x1_8000_0000usize;
let expected = base + CATEGORY_RESPONSE_VTABLE_RVA;
assert!(response_class_matches(base, expected));
// An unreadable capture (zero) never qualifies.
assert!(!response_class_matches(base, 0));
// Any other vtable (e.g. a sub-object or a freed/reused slot) is rejected.
assert!(!response_class_matches(base, expected + 8));
assert!(!response_class_matches(base, base));
}
#[test]
fn relocated_completion_branch_has_proven_layout() {
assert_eq!(COMPLETION_COPY_LEN, 14);
assert_eq!(
&COMPLETION_SIGNATURE[..12],
&[0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0xd9, 0x48, 0x85, 0xd2]
);
assert_eq!(&COMPLETION_SIGNATURE[12..14], &[0x74, 0x4e]);
assert_eq!(COMPLETION_TRAMPOLINE_LEN, 42);
}
}
+221 -12
View File
@@ -4,24 +4,30 @@
//! (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
//! defer off the loader lock and poll for it in a background thread.
//! defer off the loader lock and poll for it — the same shape as
//! `probe::install_probes_deferred` polling for anadius64.dll.
//!
//! ── Address model (static VAs; PE image base 0x180000000) ────────────────────────
//! All values below are RVAs (VA_static - 0x180000000); live = cards_base + rva.
//! See the spec for the verified disassembly behind each one.
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::System::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;
@@ -40,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;
@@ -74,14 +87,13 @@ 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);
// Full SBC state model. The live repair jumps Resolved -> Validated -> Committed;
// Intercepted/Parsed document the intermediate states but are never entered.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum RuntimeState {
@@ -135,6 +147,13 @@ enum ValidationError {
CollectionUnreadable,
CollectionNotNull,
ReadyByteNotWritable,
ModelEmpty,
ControllerMissing,
ControllerVtableMismatch,
ControllerModelMismatch,
CompletionBranchMismatch,
CompletionBranchProtectFailed,
CompletionBranchFlushFailed,
}
#[derive(Clone, Copy, Debug)]
@@ -194,7 +213,7 @@ fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationE
Ok(())
}
/// Fault-safe pointer read: returns None unless `ptr` lands
/// Fault-safe pointer read (mirrors `probe::read_ptr`): returns None unless `ptr` lands
/// in a committed, readable page and the full 8 bytes fit inside the region.
unsafe fn read_ptr(ptr: usize) -> Option<usize> {
if ptr < 0x10000 || ptr & 7 != 0 {
@@ -264,8 +283,6 @@ unsafe fn writable_u8(ptr: usize) -> bool {
.is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize))
}
// Fault-safe executable-range check retained with the address model; not currently wired.
#[allow(dead_code)]
unsafe fn executable_range(ptr: usize, len: usize) -> bool {
let Some(end) = ptr.checked_add(len) else {
return false;
@@ -296,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;
}
@@ -411,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")
@@ -421,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.
+1 -1
View File
@@ -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;
}
+25 -78
View File
@@ -29,7 +29,6 @@ pub(crate) const CATEGORY_FACTORY_RVA: usize = 0x17aa10;
pub(crate) const CATEGORY_DESERIALIZER_RVA: usize = 0x17b2b0;
const COPY_LEN: usize = 19;
const ABS_JUMP_LEN: usize = 14;
#[allow(dead_code)] // documents the relocated-prologue trampoline size (COPY_LEN + jump)
const TRAMPOLINE_LEN: usize = COPY_LEN + ABS_JUMP_LEN;
const NOTIFIER_RVA: usize = 0x17aa80;
const NOTIFIER_COPY_LEN: usize = 15;
@@ -81,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);
@@ -95,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) {
@@ -103,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)
@@ -116,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) {
@@ -140,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());
@@ -162,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,
@@ -181,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];
@@ -210,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> {
@@ -331,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;
};
@@ -349,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;
};
@@ -364,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;
};
@@ -415,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(),
@@ -597,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);
}
}
@@ -635,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)
@@ -686,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 {
@@ -885,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;
}
@@ -972,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;
}
@@ -1075,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;
}
@@ -1149,17 +1103,10 @@ fn install_notifier(enabled: bool) {
}
pub(crate) fn install() {
// The repair's evidence traces (parser, notifier, controller registration) are
// its decision inputs, so they follow the promoted repair, not an env var.
let dispatch_repair = crate::sbc_dispatch::REPAIR_PROMOTED;
let dispatch_trace =
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_DISPATCH_TRACE").ok().as_deref());
let enabled =
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
let notifier_enabled =
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
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);
-619
View File
@@ -1,619 +0,0 @@
//! Passive, behavior-preserving diagnostic traces for FIFA 17's offline-season
//! entry flow.
//!
//! RE (2026-08-19, live memory) placed the "problem communicating with the FIFA
//! Ultimate Team servers" modal in the `futOfflineSeasonEntry` ActionScript's
//! season-load path. A first trace on the load completion `FUN_1800578e0`
//! (`0x578e0`) armed but NEVER fired on an entry attempt — so the modal is raised
//! before that callback runs. These traces log the actual CardsDLL season-native
//! call sequence (which functions the entry screen reaches, and in what order) so
//! we can see exactly where the flow stops/fails. Every trace is read-only: it
//! logs, then calls the original through a trampoline; it never alters control
//! flow. Targets are chosen so their copied prologues are position-independent
//! (no rip-relative / rel32 in the copied bytes).
use core::sync::atomic::{AtomicUsize, Ordering};
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use windows_sys::Win32::System::Memory::{
VirtualAlloc, VirtualProtect, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE, PAGE_READWRITE,
};
use windows_sys::Win32::System::Threading::GetCurrentProcess;
use crate::sbc_trace::{
absolute_jump, allocate_trampoline, readable_range, target_va, validate_cards_build,
};
use crate::write_log;
static REPORTS: AtomicUsize = AtomicUsize::new(0);
unsafe fn rd_i32(addr: usize) -> Option<i32> {
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
}
unsafe fn rd_u8(addr: usize) -> Option<u8> {
readable_range(addr, 1).then(|| core::ptr::read_volatile(addr as *const u8))
}
/// Read a NUL-terminated string safely (bounded, only reads mapped bytes).
unsafe fn rd_cstr(addr: usize, max: usize) -> String {
if addr == 0 || !readable_range(addr, 1) {
return String::from("<unreadable>");
}
let mut out = Vec::new();
let mut i = 0;
while i < max && readable_range(addr + i, 1) {
let b = core::ptr::read_volatile((addr + i) as *const u8);
if b == 0 {
break;
}
out.push(b);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Generic passive detour: overwrite the first `copy_len` bytes of `target` (which
/// MUST be whole, position-independent instructions) with an absolute jump to
/// `wrapper`; the wrapper calls the trampoline (copied prologue + jump back).
unsafe fn install_detour(
base: usize,
rva: usize,
name: &str,
copy_len: usize,
signature: &[u8],
wrapper: usize,
trampoline_slot: &AtomicUsize,
) -> bool {
let Some(target) = target_va(base, rva) else {
write_log(&format!("SEASON_TRACE: {name}: VA overflow\n"));
return false;
};
if !readable_range(target, copy_len)
|| core::slice::from_raw_parts(target as *const u8, copy_len) != signature
{
write_log(&format!(
"SEASON_TRACE: {name}: prologue signature mismatch at {target:#x}; skip\n"
));
return false;
}
let Some(trampoline) = allocate_trampoline(target, copy_len) else {
write_log(&format!("SEASON_TRACE: {name}: trampoline alloc failed\n"));
return false;
};
trampoline_slot.store(trampoline, Ordering::Release);
let mut patch = [0x90u8; 24];
let jump = absolute_jump(wrapper);
patch[..jump.len()].copy_from_slice(&jump);
let mut old = 0u32;
if VirtualProtect(target as _, copy_len, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
write_log(&format!("SEASON_TRACE: {name}: VirtualProtect failed\n"));
return false;
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, copy_len);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, copy_len) != 0;
let mut ignored = 0u32;
VirtualProtect(target as _, copy_len, old, &mut ignored);
if flushed {
write_log(&format!(
"SEASON_TRACE: {name}: installed at {target:#x} (tramp {trampoline:#x})\n"
));
true
} else {
write_log(&format!("SEASON_TRACE: {name}: flush failed\n"));
false
}
}
fn log_call(name: &str, rcx: usize, rdx: usize, r8: usize) {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
write_log(&format!(
"SEASON_CALL: {name} rcx={rcx:#x} rdx={rdx:#x} r8={r8:#x}\n"
));
}
}
/// Declare a passive 4-register-arg call trace. The wrapper is entered via the
/// abs-jump patched over the target prologue (original args in rcx/rdx/r8/r9,
/// caller's return address on the stack), logs, then tail-calls the original via
/// the trampoline. A 4-arg/usize-return signature safely covers these season
/// natives (<=4 integer args, void/int returns).
macro_rules! season_call_trace {
($wrap:ident, $tramp:ident, $name:literal) => {
static $tramp: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn $wrap(rcx: usize, rdx: usize, r8: usize, r9: usize) -> usize {
log_call($name, rcx, rdx, r8);
let t = $tramp.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
};
}
season_call_trace!(
load_current_native_wrapper,
LOAD_CURRENT_NATIVE_TRAMP,
"LoadCurrentOfflineSeason_native"
);
season_call_trace!(
start_season_native_wrapper,
START_SEASON_NATIVE_TRAMP,
"StartSeason_native"
);
season_call_trace!(
get_info_native_wrapper,
GET_INFO_NATIVE_TRAMP,
"GetOfflineSeasonInfo_native"
);
// Real LoadOfflineSeasons native (FUN_18004ee10) — what _LoadCurrentSeason
// actually calls; hands the callback name to the manager's async slot 0x80.
season_call_trace!(
load_offline_real_wrapper,
LOAD_OFFLINE_REAL_TRAMP,
"LoadOfflineSeasons_native(0x4ee10)"
);
// Async LoadOfflineSeasons impl (mgr slot 0x80, FUN_180057560): reads the season
// count and invokes the LoadSeasons_Complete AS callback.
season_call_trace!(
load_offline_async_wrapper,
LOAD_OFFLINE_ASYNC_TRAMP,
"LoadOfflineSeasons_asyncimpl(0x57560)"
);
// LoadCurrentOfflineSeason IMPL (manager slot 0x20): registers the load callbacks
// and starts the async op. param_1=manager, param_2=state byte, param_3=seasonId
// string ptr. Logs those, then calls the original.
static LOAD_CURRENT_IMPL_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn load_current_impl_wrapper(
param_1: usize,
param_2: usize,
param_3: usize,
param_4: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
// param_3 -> C string season id (best-effort read of first bytes).
let sid = if param_3 != 0 && readable_range(param_3, 8) {
let p = *(param_3 as *const usize);
if p != 0 && readable_range(p, 8) {
*(p as *const u64)
} else {
0
}
} else {
0
};
write_log(&format!(
"SEASON_CALL: LoadCurrentOfflineSeason_impl mgr={param_1:#x} stateByte={param_2:#x} sidPtr={param_3:#x} sidHead={sid:#x}\n"
));
}
let t = LOAD_CURRENT_IMPL_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(param_1, param_2, param_3, param_4)
}
// Completion callback FUN_1800578e0 (0x578e0). Kept from the first pass to confirm
// whether it ever fires; logs the result fields it branches on.
static COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn completion_wrapper(
ctx: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
let status = rd_i32(result + 0x1c);
let state = rd_u8(result + 0x68);
let season_id = rd_i32(result + 0x5c);
write_log(&format!(
"SEASON_LOAD_COMPLETE: ctx={ctx:#x} result={result:#x} status(+0x1c)={} state(+0x68)={} seasonId(+0x5c)={}\n",
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
state.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
season_id.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
));
}
let t = COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(ctx, result, r8, r9)
}
// GetUsersOfflineDivision native FUN_18004eb50 (registration FUN_18004e3f0 proved
// this binding — it is NOT LoadOfflineSeasons). Called from _InitializeScreen for
// the division display, sync. Its prologue holds a rip-relative `MOV RCX,[rip+disp]`,
// so it needs the relocating installer below.
season_call_trace!(
get_users_division_wrapper,
GET_USERS_DIVISION_TRAMP,
"GetUsersOfflineDivision_native(0x4eb50)"
);
/// Find a free page within ~±1.5 GiB of `base`, so a rip-relative disp32 into
/// CardsDLL data still fits after we relocate a copied prologue into it.
unsafe fn alloc_near(base: usize, size: usize) -> Option<usize> {
const GRAN: usize = 0x10000;
let mut step = GRAN;
while step < 0x6000_0000 {
for signed in [step as isize, -(step as isize)] {
let cand = base.wrapping_add(signed as usize) & !(GRAN - 1);
if cand == 0 {
continue;
}
let p = VirtualAlloc(cand as _, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if !p.is_null() {
return Some(p as usize);
}
}
step += GRAN;
}
None
}
/// Passive detour for a target whose copied prologue contains a single
/// rip-relative operand (disp32 at `disp_off`, instruction ending at `insn_end`,
/// both within the copied bytes). The trampoline is allocated near `base` and the
/// disp32 is relocated so it resolves to the same absolute address. Read-only.
#[allow(clippy::too_many_arguments)]
unsafe fn install_detour_reloc(
base: usize,
rva: usize,
name: &str,
copy_len: usize,
signature: &[u8],
disp_off: usize,
insn_end: usize,
wrapper: usize,
trampoline_slot: &AtomicUsize,
) -> bool {
let Some(target) = target_va(base, rva) else {
write_log(&format!("SEASON_TRACE: {name}: VA overflow\n"));
return false;
};
if !readable_range(target, copy_len)
|| core::slice::from_raw_parts(target as *const u8, copy_len) != signature
{
write_log(&format!(
"SEASON_TRACE: {name}: prologue signature mismatch at {target:#x}; skip\n"
));
return false;
}
let jump = absolute_jump(wrapper);
let tramp_len = copy_len + jump.len();
let Some(tramp) = alloc_near(base, tramp_len) else {
write_log(&format!(
"SEASON_TRACE: {name}: near trampoline alloc failed\n"
));
return false;
};
core::ptr::copy_nonoverlapping(target as *const u8, tramp as *mut u8, copy_len);
// Relocate the rip-relative disp32 to keep the same absolute target.
let orig_disp = core::ptr::read_unaligned((target + disp_off) as *const i32) as i64;
let abs_target = target as i64 + insn_end as i64 + orig_disp;
let new_disp = abs_target - (tramp as i64 + insn_end as i64);
if new_disp < i32::MIN as i64 || new_disp > i32::MAX as i64 {
write_log(&format!(
"SEASON_TRACE: {name}: reloc out of range ({new_disp:#x})\n"
));
return false;
}
core::ptr::write_unaligned((tramp + disp_off) as *mut i32, new_disp as i32);
let back = absolute_jump(target + copy_len);
core::ptr::copy_nonoverlapping(back.as_ptr(), (tramp + copy_len) as *mut u8, back.len());
let mut old = 0u32;
if VirtualProtect(tramp as _, tramp_len, PAGE_EXECUTE_READ, &mut old) == 0 {
write_log(&format!(
"SEASON_TRACE: {name}: trampoline protect failed\n"
));
return false;
}
FlushInstructionCache(GetCurrentProcess(), tramp as _, tramp_len);
trampoline_slot.store(tramp, Ordering::Release);
let mut patch = [0x90u8; 24];
patch[..jump.len()].copy_from_slice(&jump);
let mut prot = 0u32;
if VirtualProtect(target as _, copy_len, PAGE_EXECUTE_READWRITE, &mut prot) == 0 {
write_log(&format!("SEASON_TRACE: {name}: VirtualProtect failed\n"));
return false;
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, copy_len);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, copy_len) != 0;
let mut ignored = 0u32;
VirtualProtect(target as _, copy_len, prot, &mut ignored);
if flushed {
write_log(&format!(
"SEASON_TRACE: {name}: installed(reloc) at {target:#x} (tramp {tramp:#x})\n"
));
true
} else {
write_log(&format!("SEASON_TRACE: {name}: flush failed\n"));
false
}
}
// FutCompetitionServiceImpl::LoadOfflineSeasons FINAL completion (FUN_1800ffe90):
// delivers the result to the AS callback LoadSeasons_Complete via
// FUN_18019fb30->slot0x20(vm,"_global",cbref, "SUCCESS" | errString). param_1 = the
// completion ctx (cbref at +0x18), param_2 = result obj (byte0=ok flag; +8 = error
// string ptr when byte0==0). Logs the EXACT status string delivered. Passive.
static FINAL_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn final_completion_wrapper(
ctx: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
// Read the delivered status: byte0==0 => failure with an error string at +8.
let flag = rd_u8(result);
let errstr = if flag == Some(0) {
let p = if readable_range(result + 8, 8) {
core::ptr::read_volatile((result + 8) as *const usize)
} else {
0
};
rd_cstr(p, 96)
} else {
String::new()
};
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
let cbref = if readable_range(ctx + 0x18, 8) {
core::ptr::read_volatile((ctx + 0x18) as *const usize)
} else {
0
};
let kind = match flag {
Some(0) => "ERROR",
Some(_) => "SUCCESS",
None => "??",
};
let shown = if flag == Some(0) {
errstr.as_str()
} else {
"SUCCESS"
};
write_log(&format!(
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
));
}
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(ctx, result, r8, r9)
}
// LoadOfflineSeasons STAGE-1 async completion (FUN_180106240): fails with
// "CACHE_PACKNAMES_FAILED" when result==0 or *(i32)(result+0x1c)!=0; else chains
// the next async stage. Logs whether the first async stage succeeded. Passive.
static STAGE1_COMPLETION_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn stage1_completion_wrapper(
param1: usize,
result: usize,
r8: usize,
r9: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
if result == 0 {
write_log("SEASONS_STAGE1: result=NULL -> CACHE_PACKNAMES_FAILED\n");
} else {
let status = rd_i32(result + 0x1c);
let verdict = if status == Some(0) {
"ok(chain next)"
} else {
"CACHE_PACKNAMES_FAILED"
};
write_log(&format!(
"SEASONS_STAGE1: result={result:#x} status(+0x1c)={} -> {verdict}\n",
status.map(|x| x.to_string()).unwrap_or_else(|| "??".into()),
));
}
}
let t = STAGE1_COMPLETION_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(param1, result, r8, r9)
}
// WEBFILE_DL download start FUN_18017ff90(url, ctx): param_1 (rcx) is the C-string
// URL of the pack-names/cards-tournament-list web file. Passive capture. Its
// prologue has a rip-relative `MOV R8,[DAT_1802e6580]`, so it uses the relocating
// installer (disp32 at copied offset 7, instruction end 11).
static URL_CAPTURE_TRAMP: AtomicUsize = AtomicUsize::new(0);
unsafe extern "system" fn url_capture_wrapper(
rcx: usize,
rdx: usize,
r8: usize,
r9: usize,
) -> usize {
let n = REPORTS.fetch_add(1, Ordering::Relaxed);
if n < 64 {
write_log(&format!(
"SEASONS_WEBFILE_URL: url={:?}\n",
rd_cstr(rcx, 256)
));
}
let t = URL_CAPTURE_TRAMP.load(Ordering::Acquire);
if t == 0 {
return 0;
}
let original: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
core::mem::transmute(t);
original(rcx, rdx, r8, r9)
}
unsafe fn worker() {
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if base == 0 || !validate_cards_build(base) {
write_log("SEASON_TRACE: CardsDLL unavailable/invalid; season trace inactive\n");
return;
}
// (rva, name, copy_len, signature, wrapper, trampoline slot)
install_detour(
base,
0x4eb70,
"LoadCurrentOfflineSeason_native",
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
load_current_native_wrapper as *const () as usize,
&LOAD_CURRENT_NATIVE_TRAMP,
);
install_detour(
base,
0x4f340,
"StartSeason_native",
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
start_season_native_wrapper as *const () as usize,
&START_SEASON_NATIVE_TRAMP,
);
install_detour(
base,
0x4e850,
"GetOfflineSeasonInfo_native",
15,
&[
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24,
0x18,
],
get_info_native_wrapper as *const () as usize,
&GET_INFO_NATIVE_TRAMP,
);
install_detour(
base,
0x57230,
"LoadCurrentOfflineSeason_impl",
19,
&[
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x81, 0xec, 0x80, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x40,
0x98, 0xfe, 0xff, 0xff, 0xff,
],
load_current_impl_wrapper as *const () as usize,
&LOAD_CURRENT_IMPL_TRAMP,
);
install_detour(
base,
0x578e0,
"LoadCurrentOfflineSeason_completion",
16,
&[
0x48, 0x8b, 0xc4, 0x57, 0x48, 0x83, 0xec, 0x70, 0x48, 0xc7, 0x40, 0xd0, 0xfe, 0xff,
0xff, 0xff,
],
completion_wrapper as *const () as usize,
&COMPLETION_TRAMP,
);
install_detour_reloc(
base,
0x4eb50,
"GetUsersOfflineDivision_native",
14,
&[
0x48, 0x83, 0xec, 0x28, 0x48, 0x8b, 0x0d, 0x75, 0x18, 0x29, 0x00, 0x48, 0x8b, 0x01,
],
7,
11,
get_users_division_wrapper as *const () as usize,
&GET_USERS_DIVISION_TRAMP,
);
install_detour(
base,
0x4ee10,
"LoadOfflineSeasons_native",
15,
&[
0x40, 0x57, 0x48, 0x83, 0xec, 0x60, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff,
0xff,
],
load_offline_real_wrapper as *const () as usize,
&LOAD_OFFLINE_REAL_TRAMP,
);
install_detour(
base,
0x57560,
"LoadOfflineSeasons_asyncimpl",
17,
&[
0x40, 0x55, 0x56, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe,
0xff, 0xff, 0xff,
],
load_offline_async_wrapper as *const () as usize,
&LOAD_OFFLINE_ASYNC_TRAMP,
);
install_detour(
base,
0xffe90,
"LoadOfflineSeasons_final_completion",
16,
&[
0x48, 0x89, 0x5c, 0x24, 0x08, 0x57, 0x48, 0x83, 0xec, 0x30, 0x80, 0x3a, 0x00, 0x48,
0x8b, 0xda,
],
final_completion_wrapper as *const () as usize,
&FINAL_COMPLETION_TRAMP,
);
install_detour(
base,
0x106240,
"LoadOfflineSeasons_stage1_completion",
15,
&[
0x48, 0x8b, 0xc4, 0x55, 0x48, 0x8d, 0x68, 0xa1, 0x48, 0x81, 0xec, 0xc0, 0x00, 0x00,
0x00,
],
stage1_completion_wrapper as *const () as usize,
&STAGE1_COMPLETION_TRAMP,
);
install_detour_reloc(
base,
0x17ff90,
"start_webfile_dl_url",
14,
&[
0x48, 0x83, 0xec, 0x38, 0x4c, 0x8b, 0x05, 0xe5, 0x65, 0x16, 0x00, 0x4c, 0x8b, 0xd1,
],
7,
11,
url_capture_wrapper as *const () as usize,
&URL_CAPTURE_TRAMP,
);
write_log("SEASON_TRACE: all season-native traces armed\n");
}
/// Arm the passive season-flow diagnostics on a deferred thread (CardsDLL is not
/// yet loaded at DllMain time). Read-only: never changes game behavior.
pub(crate) fn install() {
write_log("SEASON_TRACE: requested; deferred signature validation starting\n");
std::thread::spawn(|| unsafe { worker() });
}
+81
View File
@@ -0,0 +1,81 @@
// Runtime in-memory patch for ProtoSSL's certificate verification function inside
// EAWebKit.dll. Rather than patching the DLL on disk (offset-dependent, fragile),
// we scan the loaded module for the function's unique byte prologue and overwrite the
// first six bytes with `mov eax, 1; ret` — making every cert-chain validation call
// immediately return success.
//
// Why this is safe: the patched function (`ProtoSSL_VerifyCert` at VA 0x180a85570 in
// the shipped binary) is only used by ProtoSSL's TLS state machine to validate the
// server's certificate chain. Always returning 1 is equivalent to trusting all certs,
// which is the behaviour we want for the local self-signed bridge certificate.
use windows_sys::Win32::System::{
LibraryLoader::GetModuleHandleA,
Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE},
};
// Unique 22-byte prologue of ProtoSSL's cert-verify function.
// Confirmed present in the EA-shipped EAWebKit.dll (June 2023 build).
const PROLOGUE: &[u8] = &[
0x44, 0x89, 0x44, 0x24, 0x18, // mov [rsp+0x18], r8d
0x48, 0x89, 0x54, 0x24, 0x10, // mov [rsp+0x10], rdx
0x56, // push rsi
0x57, // push rdi
0x41, 0x55, // push r13
0x41, 0x56, // push r14
0x41, 0x57, // push r15
0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30
];
// Return 0 (PROTOSSL_ERROR_NONE = success). ProtoSSL convention: 0 = ok, negative = error.
// The function sets r15d = 0xFFFFFFFF (-1) for its own error returns, confirming 0 = success.
const PATCH: &[u8] = &[
0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE)
0xc3, // ret
0x90, 0x90, 0x90, // nop padding
];
fn patch_module(module: isize, scan_bytes: usize) -> bool {
if module == 0 {
return false;
}
let base = module as usize;
let image: &[u8] = unsafe { core::slice::from_raw_parts(base as *const u8, scan_bytes) };
let offset = match image.windows(PROLOGUE.len()).position(|w| w == PROLOGUE) {
Some(o) => o,
None => return false,
};
let target = (base + offset) as *mut u8;
let mut old_prot: u32 = 0;
unsafe {
VirtualProtect(
target as *const core::ffi::c_void,
PATCH.len(),
PAGE_EXECUTE_READWRITE,
&mut old_prot,
);
core::ptr::copy_nonoverlapping(PATCH.as_ptr(), target, PATCH.len());
VirtualProtect(
target as *const core::ffi::c_void,
PATCH.len(),
old_prot,
&mut old_prot,
);
}
true
}
/// Patch ProtoSSL cert-verify in EAWebKit.dll (call when EAWebKit is loaded).
pub unsafe fn patch_eawebkit_cert_verify() -> bool {
let module = GetModuleHandleA(b"EAWebKit.dll\0".as_ptr()) as isize;
// EAWebKit.dll is ~22 MB
patch_module(module, 24 * 1024 * 1024)
}
/// Patch ProtoSSL cert-verify compiled into FIFA23.exe itself (DirtySDK's copy).
/// The main exe is ~100 MB; confirmed present at file offset 0xf0c850.
pub unsafe fn patch_main_exe_cert_verify() -> bool {
let module = GetModuleHandleA(core::ptr::null()) as isize;
// Scan first 110 MB — the function is near offset 0xf0c850 (~15 MB in)
patch_module(module, 110 * 1024 * 1024)
}
-485
View File
@@ -1,485 +0,0 @@
//! FIFA 17 store tab-bar repair — pre-warm the purchase groups before screen-show.
//!
//! # Confirmed root cause (live, 2026-08-19)
//!
//! `FUN_18007e5e0(ctx, panel)` is the native tab binder the screen framework
//! invokes at store screen-show. It is an unrolled six-slot loop; each slot gates
//! on one hard-coded category token and either publishes that group's id as
//! `PANEL_ID` for the slot, or hides the slot:
//!
//! ```text
//! if (FUN_180014df0(_, idx)) // token present?
//! (*(panel_vtbl+0x48))(panel, slot, "PANEL_ID", FUN_180014580(_, idx));
//! else
//! (*(panel_vtbl+0xa0))(panel, slot); // hide slot
//! ```
//!
//! slot -> token, in bind order: `mypacks, bronze, silver, gold, special, points`.
//! The gate `FUN_180014df0` resolves the token through `FUN_180014380`, which scans
//! the loaded purchase groups (stride `0x108`) comparing the token at `group+0x70`.
//! So a tab appears iff a purchase group carrying that token is loaded AT BIND TIME.
//!
//! The bind detour below measured the ground truth on the retail client:
//!
//! ```text
//! STORE_TABS: bind generation=2 mask=0x00 ... <- empty at screen-show
//! STORE_TABS: rebound generation=2 mask=0x0e (...) <- groups present ~instantly after
//! ```
//!
//! `mask=0x00` at screen-show confirms the container is empty when the framework
//! binds, so all six slots hide and no tab bar is built. The store's own
//! `GET store/purchasegroup/all` only returns *after* screen-show, so re-entry works
//! (groups cached) but first entry does not. (`0x0e` = bronze|silver|gold; bit 0
//! `mypacks` is clear because an empty My Packs serves no `mypacks` group.)
//!
//! # What did NOT work, and why this module changed
//!
//! A previous version re-invoked the binder at the next render, once the groups had
//! arrived (`rebound ... mask=0x0e` above). The movie built NO tab bar from that
//! late bind: the Scaleform movie only honours the framework's OWN bind at
//! screen-show, not a later re-publish/commit. That approach is abandoned.
//!
//! # This module: make the container non-empty BEFORE the first bind
//!
//! The only publish the movie honours is the framework's bind at screen-show, and
//! re-entry proves that bind builds the bar correctly when the container is already
//! full. So the fix is to load the purchase groups BEFORE the store screen is shown.
//!
//! `FUN_180017870(storefront)` issues the store's own `GET store/purchasegroup/all`.
//! Firing it from the FUT hub event pump (a real game thread, well before the store
//! screen exists) gives the response time to arrive and populate the container, so
//! the first screen-show bind sees a full list and binds the tabs natively — exactly
//! the re-entry path, on first entry.
//!
//! The bind detour is retained purely as the SENSOR: the first-entry bind mask is
//! the safe, definitive measurement of whether the pre-warm populated the container
//! in time. `mask != 0` at first bind ⇒ pre-warm worked and the tabs bind natively;
//! `mask == 0` (with `storefront_seen=1` in the pre-warm log) ⇒ a hub-time request
//! cannot land in time and the remaining route is the extracted `StoreFront.apt`.
//!
//! # Fail-closed
//!
//! * Pre-warm fires at most once per process, claimed atomically, and only once the
//! storefront singleton is non-null; the storefront pointer is read through a
//! guarded load and the request function's signature is validated before the call.
//! * The bind detour only reads (captures pointers, probes the game's own gate with
//! a provably-dead `this`) and never mutates store state.
//! * Image plus every function signature are verified before any write and again
//! under thread suspension; one wrong byte aborts with no write and no call.
//!
//! # Promotion
//!
//! PROMOTED: armed by the build, never by an environment variable (see
//! [`REPAIR_PROMOTED`]). Rollback is a `version.dll` file swap.
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::LibraryLoader::{
GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
GET_MODULE_HANDLE_EX_FLAG_PIN,
};
use windows_sys::Win32::System::Memory::{
VirtualFree, VirtualProtect, MEM_RELEASE, PAGE_EXECUTE_READWRITE,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
/// Native tab binder `FUN_18007e5e0(ctx, panel)`, invoked by the screen framework
/// at screen-show. Detoured as the read-only sensor: captures the gate mask it saw.
const BIND_RVA: usize = 0x7e5e0;
/// Category gate `FUN_180014df0(dead_this, idx) -> bool`: maps `idx` to one of the
/// six hard-coded tokens and reports whether a loaded purchase group carries it.
const HAS_CATEGORY_RVA: usize = 0x14df0;
/// `FUN_180017870(storefront)` issues `GET store/purchasegroup/all` — the exact call
/// the store screen makes at entry (from `0x18007f25e`). Fired early to pre-warm.
const REQUEST_GROUPS_RVA: usize = 0x17870;
/// `*(base + STOREFRONT_GLOBAL_RVA)` is the storefront the store code passes to its
/// request/lookup helpers (loaded at `0x18007f25e`, right before the pack-list GET).
const STOREFRONT_GLOBAL_RVA: usize = 0x2de0d0;
/// Gate indices in slot order: `mypacks, bronze, silver, gold, special, points`.
/// Taken from the binder's unrolled call sequence, not from the index order of
/// `FUN_180014580`'s jump table (which is deliberately different).
const GATE_INDICES: [u32; 6] = [0, 2, 3, 4, 5, 1];
/// Whole-instruction prologue length relocated into the trampoline; also the number
/// of bytes overwritten by the entry detour. 15 bytes, a clean boundary covering the
/// 14-byte absolute jump.
const COPY_LEN: usize = 15;
const ABS_JUMP_LEN: usize = 14;
/// First 15 bytes of `FUN_18007e5e0`: `mov [rsp+8],rbx; mov [rsp+0x10],rbp;
/// mov [rsp+0x18],rsi` = 5 + 5 + 5.
const BIND_SIGNATURE: [u8; COPY_LEN] = [
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x6c, 0x24, 0x10, 0x48, 0x89, 0x74, 0x24, 0x18,
];
/// First 15 bytes of `FUN_180014df0`. Validated before we ever call it, so the gate
/// probe only runs on the exact build it was reversed against.
const HAS_CATEGORY_SIGNATURE: [u8; 15] = [
0x40, 0x53, 0x48, 0x83, 0xec, 0x20, 0x33, 0xdb, 0x44, 0x8b, 0xc3, 0x85, 0xd2, 0x74, 0x35,
];
/// First 18 bytes of `FUN_180017870`. Validated before we ever call it, so the
/// pre-warm only fires the genuine request on the exact build it was reversed against.
const REQUEST_GROUPS_SIGNATURE: [u8; 18] = [
0x40, 0x57, 0x48, 0x81, 0xec, 0x90, 0x00, 0x00, 0x00, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff,
0xff, 0xff,
];
type BindFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> *mut c_void;
type HasCategoryFn = unsafe extern "system" fn(*mut c_void, u32) -> u8;
type RequestGroupsFn = unsafe extern "system" fn(*mut c_void) -> usize;
/// The tab-bar repair is PROMOTED: armed by the build, never by an environment
/// variable, so every launch path (Steam, the launcher, a bare `umu-run`) behaves
/// identically. Promotion does not weaken any check — the signature gate, the image
/// validation and the thread quiesce all remain in the runtime evidence path.
pub(crate) const REPAIR_PROMOTED: bool = true;
/// Compile-time contract: the repair stays build-armed. Regressing it to an env gate
/// would silently restore the missing first-entry tab bar on a normal launch, so it
/// must be a deliberate, visible change here rather than a missing variable.
const _: () = assert!(REPAIR_PROMOTED);
static REPAIR_ENABLED: AtomicBool = AtomicBool::new(false);
static BIND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
static STORE_BASE: AtomicUsize = AtomicUsize::new(0);
static BIND_ENTRIES: AtomicU64 = AtomicU64::new(0);
/// Gate mask the framework's most recent bind observed (bit N = slot N would bind).
static LAST_BIND_MASK: AtomicU32 = AtomicU32::new(0);
static LAST_THREAD: AtomicUsize = AtomicUsize::new(0);
/// Set once the pre-warm request has been fired (or is provably unnecessary).
static PREWARM_DONE: AtomicBool = AtomicBool::new(false);
static PREWARM_ATTEMPTS: AtomicU64 = AtomicU64::new(0);
/// Highest storefront pointer observed at hub time (0 = never non-null yet). Logged
/// so a failed pre-warm can be attributed to "storefront not up at hub" vs "fired
/// but the response did not land before screen-show".
static PREWARM_STOREFRONT_SEEN: AtomicUsize = AtomicUsize::new(0);
/// Pure pre-warm decision, isolated for host tests.
///
/// Fire exactly once, and only once the storefront singleton is non-null; before
/// that, keep waiting (a null storefront early at the hub is expected).
fn should_prewarm(already_done: bool, storefront: usize) -> bool {
!already_done && storefront != 0
}
/// Probe all six category tokens with the game's own gate and return a slot mask.
///
/// `FUN_180014df0` forwards its `this` to `FUN_180014380`, which discards it and
/// fetches the group container from a singleton, so a null `this` is exactly what
/// the native code effectively passes. Called only from the bind detour, where the
/// store subsystem is provably live.
unsafe fn gate_mask() -> u8 {
let base = STORE_BASE.load(Ordering::Acquire);
if base == 0 {
return 0;
}
let Some(gate) = base.checked_add(HAS_CATEGORY_RVA) else {
return 0;
};
let gate_fn: HasCategoryFn = core::mem::transmute(gate);
let mut mask = 0u8;
for (slot, index) in GATE_INDICES.iter().enumerate() {
if gate_fn(core::ptr::null_mut(), *index) != 0 {
mask |= 1 << slot;
}
}
mask
}
/// Ask the game to load the purchase groups now, on the caller's (game) thread.
///
/// Called from the FUT event dispatcher so it runs on a real game thread well before
/// the store screen is ever shown — the same thread the store screen itself would use
/// for this call at entry. Fail-closed: base/signature/storefront all validated, at
/// most one request per process.
pub(crate) unsafe fn maybe_prewarm_groups() {
if PREWARM_DONE.load(Ordering::Acquire) || !REPAIR_ENABLED.load(Ordering::Acquire) {
return;
}
let base = STORE_BASE.load(Ordering::Acquire);
if base == 0 || !crate::sbc_trace::valid_cards_image(base) {
return;
}
let Some(storefront) = base
.checked_add(STOREFRONT_GLOBAL_RVA)
.and_then(|slot| crate::sbc_trace::guarded_usize(slot))
else {
return;
};
if storefront != 0 {
PREWARM_STOREFRONT_SEEN.store(storefront, Ordering::Release);
}
if !should_prewarm(false, storefront) {
// Storefront not up yet at the hub: keep waiting, do not consume the attempt.
return;
}
let Some(request) = base.checked_add(REQUEST_GROUPS_RVA) else {
return;
};
if !crate::sbc_trace::executable_range_in_image(base, request, REQUEST_GROUPS_SIGNATURE.len())
|| core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len())
!= REQUEST_GROUPS_SIGNATURE
{
return;
}
// Claim the single attempt before issuing it, so a re-entrant event can never
// fire a second request.
PREWARM_DONE.store(true, Ordering::Release);
PREWARM_ATTEMPTS.fetch_add(1, Ordering::Relaxed);
let request_fn: RequestGroupsFn = core::mem::transmute(request);
request_fn(storefront as *mut c_void);
crate::write_log(&format!(
"STORE_TABS: pre-warmed purchase groups at hub (storefront={storefront:#x})\n"
));
}
unsafe fn restore_entry<const N: usize>(target: usize, original: &[u8; N]) -> bool {
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return false;
}
core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0
}
unsafe fn write_entry<const N: usize>(
target: usize,
destination: usize,
original: &[u8; N],
) -> Result<(), bool> {
let mut patch = [0x90u8; N];
patch[..ABS_JUMP_LEN].copy_from_slice(&crate::sbc_trace::absolute_jump(destination));
let mut old = 0u32;
if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return Err(true);
}
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, N);
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0;
let mut ignored = 0u32;
if flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 {
Ok(())
} else {
Err(restore_entry(target, original))
}
}
/// Detour target for the native tab binder. Read-only sensor: records the gate mask
/// the framework's bind is about to act on, then runs the original unchanged. This is
/// the definitive measurement of whether the pre-warm populated the container in time.
unsafe extern "system" fn bind_wrapper(ctx: *mut c_void, panel: *mut c_void) -> *mut c_void {
let mask = gate_mask();
LAST_BIND_MASK.store(mask as u32, Ordering::Release);
LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
BIND_ENTRIES.fetch_add(1, Ordering::AcqRel);
let original: BindFn = core::mem::transmute(BIND_TRAMPOLINE.load(Ordering::Acquire));
original(ctx, panel)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InstallOutcome {
Installed,
CleanFailure,
DegradedHookActive,
DegradedProcessState,
DegradedHookAndProcess,
}
unsafe fn install_hook(base: usize) -> InstallOutcome {
let Some(bind) = crate::sbc_trace::target_va(base, BIND_RVA) else {
return InstallOutcome::CleanFailure;
};
let Some(gate) = crate::sbc_trace::target_va(base, HAS_CATEGORY_RVA) else {
return InstallOutcome::CleanFailure;
};
let Some(request) = crate::sbc_trace::target_va(base, REQUEST_GROUPS_RVA) else {
return InstallOutcome::CleanFailure;
};
// Fingerprint the image and ALL THREE functions: the one we detour and the two we
// call (gate probe, group request). A single mismatched byte aborts cleanly with
// no write and no call.
if !crate::sbc_trace::valid_cards_image(base)
|| !crate::sbc_trace::executable_range_in_image(base, bind, BIND_SIGNATURE.len())
|| !crate::sbc_trace::executable_range_in_image(base, gate, HAS_CATEGORY_SIGNATURE.len())
|| !crate::sbc_trace::executable_range_in_image(
base,
request,
REQUEST_GROUPS_SIGNATURE.len(),
)
|| core::slice::from_raw_parts(bind as *const u8, BIND_SIGNATURE.len()) != BIND_SIGNATURE
|| core::slice::from_raw_parts(gate as *const u8, HAS_CATEGORY_SIGNATURE.len())
!= HAS_CATEGORY_SIGNATURE
|| core::slice::from_raw_parts(request as *const u8, REQUEST_GROUPS_SIGNATURE.len())
!= REQUEST_GROUPS_SIGNATURE
{
return InstallOutcome::CleanFailure;
}
let mut pinned = core::ptr::null_mut();
if GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
bind as *const u8,
&mut pinned,
) == 0
|| pinned as usize != base
{
return InstallOutcome::CleanFailure;
}
let Some(trampoline) = crate::sbc_trace::allocate_trampoline(bind, COPY_LEN) else {
return InstallOutcome::CleanFailure;
};
BIND_TRAMPOLINE.store(trampoline, Ordering::Release);
STORE_BASE.store(base, Ordering::Release);
let Some(_gate_lock) = crate::sbc_trace::acquire_patch_installer_gate() else {
VirtualFree(trampoline as _, 0, MEM_RELEASE);
BIND_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
};
let mut peers = match crate::sbc_trace::suspend_peers(bind, bind) {
Ok(peers) => peers,
Err(crate::sbc_trace::QuiesceFailure::Acquire) => {
VirtualFree(trampoline as _, 0, MEM_RELEASE);
BIND_TRAMPOLINE.store(0, Ordering::Release);
return InstallOutcome::CleanFailure;
}
Err(crate::sbc_trace::QuiesceFailure::Resume) => {
return InstallOutcome::DegradedProcessState;
}
};
let final_valid = crate::sbc_trace::valid_cards_image(base)
&& core::slice::from_raw_parts(bind as *const u8, BIND_SIGNATURE.len()) == BIND_SIGNATURE;
let transaction = if !final_valid {
InstallOutcome::CleanFailure
} else {
match write_entry(bind, bind_wrapper as *const () as usize, &BIND_SIGNATURE) {
Ok(()) => InstallOutcome::Installed,
Err(true) => InstallOutcome::CleanFailure,
Err(false) => InstallOutcome::DegradedHookActive,
}
};
let resumed = peers.resume_all();
let outcome = if resumed {
transaction
} else if matches!(
transaction,
InstallOutcome::Installed | InstallOutcome::DegradedHookActive
) {
InstallOutcome::DegradedHookAndProcess
} else {
InstallOutcome::DegradedProcessState
};
if outcome == InstallOutcome::CleanFailure {
VirtualFree(trampoline as _, 0, MEM_RELEASE);
BIND_TRAMPOLINE.store(0, Ordering::Release);
}
outcome
}
unsafe fn worker() {
let _pending = crate::sbc_trace::CodeInstallerPending;
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
let outcome = if base == 0 {
InstallOutcome::CleanFailure
} else {
install_hook(base)
};
drop(_pending);
match outcome {
InstallOutcome::Installed => {
crate::write_log("STORE_TABS: bind sensor + pre-warm installed (promoted)\n")
}
InstallOutcome::CleanFailure => {
crate::write_log("STORE_TABS: clean install failure; inactive\n");
return;
}
InstallOutcome::DegradedHookActive => {
crate::write_log("STORE_TABS: DEGRADED hook may be active; terminate game now\n");
return;
}
InstallOutcome::DegradedProcessState => {
crate::write_log("STORE_TABS: DEGRADED thread state; terminate game now\n");
return;
}
InstallOutcome::DegradedHookAndProcess => {
crate::write_log("STORE_TABS: DEGRADED hook and thread state; terminate game now\n");
return;
}
}
let mut binds_seen = 0u64;
let mut reports = 0u8;
while reports < 64 {
std::thread::sleep(std::time::Duration::from_millis(250));
let binds = BIND_ENTRIES.load(Ordering::Acquire);
if binds != binds_seen {
crate::write_log(&format!(
"STORE_TABS: bind generation={} mask={:#04x} prewarm_fired={} storefront_seen={:#x} tid={}\n",
binds,
LAST_BIND_MASK.load(Ordering::Acquire),
PREWARM_ATTEMPTS.load(Ordering::Acquire),
PREWARM_STOREFRONT_SEEN.load(Ordering::Acquire),
LAST_THREAD.load(Ordering::Relaxed),
));
binds_seen = binds;
reports += 1;
}
}
crate::write_log("STORE_TABS: report cap reached; hook remains installed\n");
}
pub(crate) fn install() {
// Promoted: armed by the build. No environment variable participates.
REPAIR_ENABLED.store(REPAIR_PROMOTED, Ordering::Release);
crate::write_log(
"STORE_TABS: bind sensor + pre-warm ARMED (promoted); strict signature gate\n",
);
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gate_indices_match_the_native_slot_order() {
// mypacks, bronze, silver, gold, special, points — the order FUN_18007e5e0
// tests them in, which is NOT the index order of FUN_180014580's jump table.
assert_eq!(GATE_INDICES, [0, 2, 3, 4, 5, 1]);
}
#[test]
fn prewarms_once_the_storefront_is_up() {
assert!(should_prewarm(false, 0x1000));
}
#[test]
fn waits_while_the_storefront_is_still_null() {
assert!(!should_prewarm(false, 0));
}
#[test]
fn never_prewarms_twice() {
assert!(!should_prewarm(true, 0x1000));
}
#[test]
fn detour_signature_is_long_enough_for_the_absolute_jump() {
assert!(BIND_SIGNATURE.len() >= ABS_JUMP_LEN);
assert_eq!(COPY_LEN, BIND_SIGNATURE.len());
}
#[test]
fn request_signature_covers_the_validated_prologue() {
// 18 bytes: `push rdi; sub rsp,0x90; movq [rsp+0x20],-2`.
assert_eq!(REQUEST_GROUPS_SIGNATURE.len(), 18);
}
}
+445
View File
@@ -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));
}
}
+40
View File
@@ -0,0 +1,40 @@
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::BOOL;
// CERT_CHAIN_POLICY_STATUS.dwError offset 0 = u32 error code; 0 = success.
// We use raw pointers to avoid pulling in the full Cryptography struct tree.
type CertVerifyChainPolicyFn = unsafe extern "system" fn(
*const u8, // pszPolicyOID
*const (), // pChainContext
*const (), // pPolicyPara
*mut u32, // &mut pPolicyStatus.dwError (first field)
) -> BOOL;
static REAL: OnceLock<CertVerifyChainPolicyFn> = OnceLock::new();
pub fn set_real(f: CertVerifyChainPolicyFn) {
let _ = REAL.set(f);
}
/// Hooked CertVerifyCertificateChainPolicy — always reports success.
/// This allows the bridge's self-signed TLS cert to be accepted by the game.
pub unsafe extern "system" fn hooked_cert_verify_chain_policy(
psz_policy_oid: *const u8,
p_chain_context: *const (),
p_policy_para: *const (),
p_policy_status: *mut u32,
) -> BOOL {
if let Some(real) = REAL.get().copied() {
real(
psz_policy_oid,
p_chain_context,
p_policy_para,
p_policy_status,
);
}
// Clear the error field of CERT_CHAIN_POLICY_STATUS regardless
if !p_policy_status.is_null() {
*p_policy_status = 0;
}
1 // TRUE = verified OK
}
+227
View File
@@ -0,0 +1,227 @@
//! Milestone 0 — Blaze transport reachability observation.
//!
//! PURE LOGGING, NO NEW DETOURS. This module does not hook anything itself. It is
//! called from the three Winsock detours the hook ALREADY installs — getaddrinfo
//! (`hooks.rs`), connect/WSAConnect (`connect_hook.rs`) and ConnectEx
//! (`connectex_hook.rs`) — and, when armed, emits a single grep-friendly
//! `TRANSPORT_WATCH:` line per resolution/connect so we can answer one question:
//!
//! Does the FIFA 23 client attempt ANY Blaze-flavored transport activity across a
//! full menu+FUT session, or none at all?
//!
//! Everything here is READ-ONLY: we parse the hostname / sockaddr the game passed
//! only to describe it in the log. We never change a resolution result or a
//! connection target — that redirect logic lives in the detours themselves and is
//! untouched. The env kill switch `OPENFUT_TRANSPORT_WATCH=1` gates all output;
//! disarmed (default) this module is inert (each entry point returns immediately).
//!
//! Future-reference note (beyond-beginner, deliberately NOT done here): a
//! types-first design would model a `ConnectTarget` enum (Inet{ip,port} / NonInet /
//! Short) and a `TransportEvent` and route them through the `tracing` crate with
//! structured fields, instead of hand-formatting strings into a flat log file. That
//! buys machine-parseable logs and log levels. For a one-shot observation gate,
//! flat `write_log` lines that `grep` cleanly are the lower-ceremony choice.
use core::sync::atomic::{AtomicBool, Ordering};
/// Armed once at DLL load from `OPENFUT_TRANSPORT_WATCH`. `AtomicBool` (not a plain
/// `static mut bool`) because the detours that read it run on arbitrary game threads;
/// an atomic gives race-free reads with no `unsafe`. `Relaxed` is enough — this is a
/// standalone flag with no ordering relationship to other memory.
static ARMED: AtomicBool = AtomicBool::new(false);
/// Read the env var once, at DLL load, and log the arm state. Called from `DllMain`
/// (`install_hooks`). Reading the env in-process (rather than as a command prefix) is
/// what makes the switch actually propagate through the umu/Proton launch — the same
/// gotcha the probe switches hit; it works because the launch script `export`s it.
pub fn arm_from_env() {
let on = std::env::var("OPENFUT_TRANSPORT_WATCH")
.map(|v| v == "1")
.unwrap_or(false);
ARMED.store(on, Ordering::Relaxed);
crate::write_log(&format!(
"TRANSPORT_WATCH: {} (env OPENFUT_TRANSPORT_WATCH)\n",
if on { "ARMED" } else { "disarmed" }
));
}
fn armed() -> bool {
ARMED.load(Ordering::Relaxed)
}
/// True if `host` looks like EA/Blaze infrastructure. Broad on purpose: this is a log
/// classifier that makes a hit visually pop (`<-- BLAZE/EA-FLAVORED`), NOT a routing
/// decision. The actual redirect decision stays in `hooks::is_ea_host`, which is
/// deliberately narrower and unchanged.
fn is_blaze_flavored(host: &str) -> bool {
let h = host.to_ascii_lowercase();
[
"redirector",
"gosredirector",
"blaze",
"gosca",
"easfc",
"utas",
"fut",
"ea.com",
"easports",
]
.iter()
.any(|k| h.contains(k))
}
/// Log one getaddrinfo hostname. Self-gates on the arm flag, so the call site can be
/// unconditional. The existing `openfut_hook: getaddrinfo(...)` line stays; this adds
/// the tagged, classified line so `grep TRANSPORT_WATCH` sees the full resolution set
/// and a Blaze host stands out.
pub fn note_getaddrinfo(host: &str) {
if !armed() {
return;
}
let tag = if is_blaze_flavored(host) {
" <-- BLAZE/EA-FLAVORED"
} else {
""
};
crate::write_log(&format!(
"TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n"
));
}
const AF_INET: u16 = 2; // IPv4
const AF_INET6: u16 = 23; // IPv6 (Windows value; Linux uses 10 — we're in Wine/Win ABI)
/// Minimal view of a `sockaddr_in`; the first `u16` is the address family for ANY
/// sockaddr, so reading this layout is safe enough to classify the family even when
/// the real struct is a `sockaddr_un` or larger — we only trust the rest once we've
/// confirmed `sin_family == AF_INET`.
#[repr(C)]
struct SockaddrIn {
sin_family: u16,
sin_port: u16,
sin_addr: u32,
sin_zero: [u8; 8],
}
/// Minimal view of a `sockaddr_in6` (Win32 layout). `sin6_port` is network byte order;
/// `sin6_addr` is the 16 raw address bytes in network order. We ignore flowinfo/scope.
#[repr(C)]
struct SockaddrIn6 {
sin6_family: u16,
sin6_port: u16,
sin6_flowinfo: u32,
sin6_addr: [u8; 16],
sin6_scope_id: u32,
}
/// Is `port` a known/suspected Blaze port? SHAPE — public general knowledge; the exact
/// port for FIFA23's Blaze version is UNKNOWN. 42127 main, 10041/10744 redirector
/// variants, 3659 classic redirector.
fn is_blaze_port(port: u16) -> bool {
matches!(port, 42127 | 10744 | 3659 | 10041)
}
/// Log one outbound connect attempt. `api` names the call path (`connect` /
/// `WSAConnect` / `ConnectEx`) so we can tell which Winsock entry the client used.
///
/// SAFETY: `name` must point to at least `namelen` readable bytes — it's the sockaddr
/// the game just handed to a Winsock connect API, so that always holds at the call
/// sites. We read it read-only and never write through it. `s` is the socket handle,
/// used only to query `SO_TYPE` (TCP=1 / UDP=2) so a real Blaze TCP dial is
/// distinguishable from UDP game/voice traffic.
pub unsafe fn note_connect(api: &str, name: *const u8, namelen: i32, s: usize) {
if !armed() {
return;
}
if name.is_null() || namelen < 8 {
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} (no/short sockaddr, namelen={namelen})\n"
));
return;
}
// SAFE: name is non-null and >= 8 bytes (checked above); the first u16 is the
// address family for ANY sockaddr, so reading it is valid regardless of the real
// struct type. We only trust family-specific fields after matching the family.
let family = *(name as *const u16);
// SAFE: getsockopt is a read-only Winsock query on a valid socket handle; a bad
// handle just leaves ty=-1, which we log verbatim. TCP=1 / UDP=2.
let sock_type = {
use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE};
let mut ty: i32 = -1;
let mut len: i32 = 4;
getsockopt(
s,
SOL_SOCKET as i32,
SO_TYPE,
&mut ty as *mut i32 as *mut u8,
&mut len,
);
ty
};
match family {
AF_INET => {
// SAFE: family is AF_INET and namelen >= 8 == sizeof(sockaddr_in) fields we read.
let sa = &*(name as *const SockaddrIn);
// sin_addr holds the address in NETWORK byte order; on little-endian x86,
// to_le_bytes reproduces those 4 bytes in memory order, which IS the dotted
// quad. So b[0].b[1].b[2].b[3] is correct. (The legacy connect_hook log line
// prints these reversed — a cosmetic bug there; this M0 line is the correct
// one to trust.)
let b = sa.sin_addr.to_le_bytes();
let port = u16::from_be(sa.sin_port);
let is_loopback = b[0] == 127;
let is_lsx = matches!(port, 3216 | 3217); // known-good LSX channel; not Blaze
let mut tag = String::new();
if is_blaze_port(port) {
tag.push_str(" <-- BLAZE-PORT");
}
// A loopback connect on anything other than LSX is the situation-(a) signal.
if is_loopback && !is_lsx {
tag.push_str(" <-- LOOPBACK non-LSX");
}
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} target={}.{}.{}.{}:{port} sock_type={sock_type}{tag}\n",
b[0], b[1], b[2], b[3]
));
}
AF_INET6 => {
if namelen < 28 {
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} family=INET6 (short sockaddr, namelen={namelen})\n"
));
return;
}
// SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6).
let sa = &*(name as *const SockaddrIn6);
let a = sa.sin6_addr; // 16 bytes, network order
let port = u16::from_be(sa.sin6_port);
// Format as 8 colon-separated hex groups (not compressed — clarity over
// brevity for a log meant to be grepped).
let hex = (0..8)
.map(|i| format!("{:02x}{:02x}", a[i * 2], a[i * 2 + 1]))
.collect::<Vec<_>>()
.join(":");
// ::1 = loopback: first 15 bytes zero, last byte 1.
let is_loopback = a[..15].iter().all(|&x| x == 0) && a[15] == 1;
let mut tag = String::new();
if is_blaze_port(port) {
tag.push_str(" <-- BLAZE-PORT");
}
if is_loopback {
tag.push_str(" <-- IPv6 LOOPBACK (::1)");
}
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} target=[{hex}]:{port} sock_type={sock_type} (IPv6){tag}\n"
));
}
other => {
// AF_UNIX=1 or anything else — where a named-pipe/unix-socket-style local
// Blaze transport would surface.
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} family={other} (non-INET — possible AF_UNIX/pipe-like)\n"
));
}
}
}
-10
View File
@@ -1,10 +0,0 @@
#![cfg(windows)]
#![allow(dead_code)]
// Compile the production connect hook directly into an executable test target.
// The hook crate itself is a cdylib, whose unit-test artifact remains a DLL and
// therefore cannot be executed by the native Windows test runner.
fn write_log(_: &str) {}
#[path = "../src/connect_hook.rs"]
mod connect_hook;
-123
View File
@@ -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);
}
}
-342
View File
@@ -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();
}
}
+409 -1978
View File
File diff suppressed because it is too large Load Diff
-254
View File
@@ -1,254 +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.
#[cfg(unix)]
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.
#[cfg(unix)]
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`.
#[cfg(unix)]
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.
/// On native Windows there is nothing to arm: routing is the `openfut.cfg` the
/// client-files step writes into the game directory (read by the version.dll
/// hook), and there is no `ptrace_scope`, DNAT, or `/etc/hosts` to set. Returns
/// no changes so the launch sequence treats client preparation as satisfied.
#[cfg(windows)]
pub fn arm(_cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
Ok(Vec::new())
}
#[cfg(unix)]
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(all(test, unix))]
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"));
}
}
+24 -600
View File
@@ -1,113 +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 {
// Windows starts the executable directly (no runner); unix needs a
// runner such as umu-run.
#[cfg(windows)]
let runner_ok = true;
#[cfg(unix)]
let runner_ok = !self.runner.trim().is_empty();
runner_ok && !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> {
#[cfg(unix)]
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());
}
// Wine-prefix links and the DRM licence precondition only exist on the
// unix/Proton launch path; native Windows has neither.
#[cfg(unix)]
{
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 {
@@ -122,87 +13,10 @@ pub struct LauncherConfig {
pub bridge_tls_enabled: bool,
/// Path to the built openfut_hook.dll (Windows DLL for Proton injection).
pub hook_dll_path: String,
/// Game folder where the hook DLL (version.dll) is deployed. Empty means
/// "not configured" — the hook deploy/check is skipped until the user sets it.
/// 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 {
@@ -238,30 +52,12 @@ impl Default for LauncherConfig {
.unwrap_or_default()
.to_string_lossy()
.into(),
// Empty by default, like the server host and game profile: the
// launcher never invents a path to somebody's game install.
fifa_game_dir: String::new(),
// 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(),
fifa_game_dir: dirs::home_dir()
.map(|h| h.join(".steam/steam/steamapps/common/FIFA 23"))
.unwrap_or_default()
.to_string_lossy()
.into(),
hook_redirect_ip: "127.0.0.1".into(),
}
}
}
@@ -292,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()),
]
}
}
-224
View File
@@ -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();
}
}
-684
View File
@@ -1,684 +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;
#[cfg(unix)]
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
#[cfg(unix)]
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.
#[cfg(unix)]
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_dll_override(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(())
}
/// Windows-native launch: no Wine prefix, no `WINEDLLOVERRIDES` (the game loads
/// the `version.dll` hook from its own directory through the normal search
/// order), and no licence regeneration (the native loader handles DRM).
/// Routing is the `openfut.cfg` that the client-files step already wrote into
/// the game directory.
///
/// The launcher must itself be running elevated (its shortcut carries the
/// RunAsAdmin bit): the loader requires administrator rights, and a child
/// started with `CreateProcess` inherits the launcher's token instead of
/// raising its own UAC prompt.
#[cfg(windows)]
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());
}
let exe = game_dir.join(&profile.executable);
if !exe.is_file() {
anyhow::bail!("game executable not found: {}", exe.display());
}
let mut cmd = Command::new(&exe);
cmd.current_dir(&game_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in &profile.env {
cmd.env(k, v);
}
say(
log,
format!(
"[launcher] launching {} (cwd {})",
exe.display(),
game_dir.display()
),
);
let child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("could not start {}: {e}", exe.display()))?;
stream(
child,
log.clone(),
"[launcher] game process exited.",
on_exit,
);
Ok(())
}
/// The registry key Wine reads DLL overrides from, and the one value the hook needs.
///
/// Wine loads its own builtin `version.dll` unless an override says otherwise, so the
/// game-directory proxy is ignored by default. `WINEDLLOVERRIDES` fixes that only for
/// a process we spawn ourselves — it cannot help a player who presses Play in Steam,
/// which is why the old advice was to paste launch options by hand (see
/// `setup::STEAM_LAUNCH_OPTIONS`). Asking a player to edit launch options is exactly
/// the kind of step that makes this unusable for anyone who does not already know what
/// a DLL override is.
///
/// Persisting the override in the prefix registry removes the manual step entirely: it
/// survives restarts and applies to every launch path, including Steam. This mirrors
/// what BepInEx documents for Proton (configure the proxy in winecfg rather than the
/// environment) and what Proton itself already does in this prefix for other titles.
#[cfg(unix)]
const DLL_OVERRIDE_KEY: &str = r"HKCU\Software\Wine\DllOverrides";
#[cfg(unix)]
const HOOK_DLL_VALUE: &str = "version";
#[cfg(unix)]
const HOOK_DLL_OVERRIDE: &str = "native,builtin";
/// `reg add` argv that persists the hook's DLL override, native-first with a builtin
/// fallback. `/f` makes it idempotent, so this is safe to run on every launch and
/// repairs a prefix a player has reset or replaced.
#[cfg(unix)]
fn dll_override_args() -> [&'static str; 10] {
[
"reg",
"add",
DLL_OVERRIDE_KEY,
"/v",
HOOK_DLL_VALUE,
"/t",
"REG_SZ",
"/d",
HOOK_DLL_OVERRIDE,
"/f",
]
}
/// Persist the hook's DLL override into the prefix, so the game loads the proxy no
/// matter how it is started.
///
/// Best-effort by design: a failure here is not fatal, because a launch we spawn also
/// carries `WINEDLLOVERRIDES`. It is reported in plain language rather than as a Wine
/// error, since the player cannot act on the latter.
#[cfg(unix)]
fn ensure_dll_override(profile: &GameProfile, log: &Log) {
if profile.wine_prefix.trim().is_empty() {
return;
}
let mut cmd = Command::new(&profile.runner);
cmd.args(dll_override_args())
.current_dir(&profile.game_dir)
.stdout(Stdio::null())
.stderr(Stdio::null());
for (k, v) in &profile.env {
cmd.env(k, v);
}
cmd.env("WINEPREFIX", &profile.wine_prefix);
match cmd.status() {
Ok(status) if status.success() => {
say(log, "[launcher] game files ready (mod support enabled)");
}
Ok(_) | Err(_) => say(
log,
"[launcher] could not pre-enable mod support in the game prefix; \
launching anyway (this launch still enables it directly)",
),
}
}
#[cfg(all(test, unix))]
mod override_tests {
use super::*;
#[test]
fn dll_override_is_persisted_native_first_and_idempotently() {
let args = dll_override_args();
assert_eq!(args[0], "reg");
assert_eq!(args[1], "add");
assert_eq!(
args[2], r"HKCU\Software\Wine\DllOverrides",
"Wine reads overrides from this key; a typo silently leaves the hook unloaded"
);
assert_eq!(args[4], "version", "the hook ships as a version.dll proxy");
assert_eq!(
args[8], "native,builtin",
"native first so the proxy wins, builtin as fallback so a missing proxy \
cannot make the game unlaunchable"
);
assert_eq!(
args[9], "/f",
"idempotent, so running it on every launch repairs a reset prefix"
);
}
}
/// The `WINEDLLOVERRIDES` value the game must be started with.
///
/// The hook ships as a `version.dll` proxy inside the game directory, and Proton
/// prefers a local DLL over its own builtin ONLY when `WINEDLLOVERRIDES` names it
/// (see `setup::STEAM_LAUNCH_OPTIONS`). Steam users get that from their launch
/// options; when the launcher spawns the runner itself, nothing else supplies it.
///
/// Without it the failure is silent and badly misleading: the hook never loads, so
/// the `openfut.cfg` the launcher just wrote is inert, the game ignores the
/// configured Blaze ports, and `/etc/hosts` quietly routes it to whatever answers
/// on EA's real ports. It looks like a working launch against the configured
/// server while actually talking to a different one.
///
/// A profile that already pins `version=` wins: an operator overriding the hijack
/// deliberately must not be silently overruled.
#[cfg(unix)]
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.
#[cfg(unix)]
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.
#[cfg(unix)]
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.)
#[cfg(unix)]
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.
#[cfg(unix)]
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.
#[cfg(unix)]
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(all(test, unix))]
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
View File
@@ -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
View File
@@ -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);
}
}
-798
View File
@@ -1,798 +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},
};
#[cfg(unix)]
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 base = service.binary();
// On Windows the built companion is `openfut-lsx.exe`; a bare name without the
// extension matches neither the sibling file nor CreateProcess resolution.
#[cfg(windows)]
let name = format!("{base}.exe");
#[cfg(unix)]
let name = base.to_string();
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> {
// On Windows the ProtoSSL cert-verify patch (autopatch's job on unix, via
// /proc/PID/mem) is performed in-process by the version.dll hook, so there
// is no autopatch process to run. LSX is different: the game dials it on
// 127.0.0.1:4216, so it MUST run locally here exactly as on unix.
#[cfg(windows)]
if service == Service::Autopatch {
self.log.lock().push(
"[launcher] autopatch runs in-process on Windows (version.dll hook) — nothing to start."
.to_string(),
);
return Ok(Ensured::Reused);
}
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.
#[cfg(unix)]
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 -103
View File
@@ -1,30 +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]),
// Pair vsync with the display's VRR (G-Sync + Vsync is the recommended
// combination): frames present on the monitor's own variable refresh.
vsync: true,
.with_inner_size([780.0, 560.0])
.with_min_inner_size([600.0, 400.0]),
..Default::default()
};
@@ -34,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,
}
}
-77
View File
@@ -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\""))
}
-500
View File
@@ -1,500 +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);
#[cfg(unix)]
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.
#[cfg(unix)]
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![
ptrace_scope(),
ea_redirect(cfg),
hostname_mapping(cfg),
backend_reachable(cfg),
hook_config(cfg),
]
}
/// On native Windows the client-preparation checks (ptrace_scope, the EA
/// redirector DNAT, `/etc/hosts`) do not apply: there is no host to arm and
/// routing is entirely the `openfut.cfg` the hook reads. Only the two the game
/// truly depends on remain: the backend is reachable and the deployed hook
/// config agrees with the launcher's settings.
#[cfg(windows)]
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![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.
#[cfg(unix)]
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.
#[cfg(unix)]
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.
#[cfg(unix)]
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.
#[cfg(unix)]
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(all(test, unix))]
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
View File
@@ -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();
}
}
+43 -105
View File
@@ -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 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. Uses `version.dll` as the hijack name: the game loads it but
/// defers to the system copy, so the loader (native or Wine) picks up our local
/// one first.
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
/// Deploy openfut_hook.dll into the FIFA 23 game directory and write
/// 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, 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");
@@ -126,75 +132,7 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
game_dir.join("version.dll").exists()
}
/// Steam launch options that enable the hook's DLL override.
///
/// Kept only as a fallback to show a user who runs the game outside this launcher on
/// a prefix we have never prepared. It is NOT the normal path any more: the launcher
/// persists the override in the prefix registry itself
/// (`game_launch::ensure_dll_override`), which applies to every launch including
/// Steam's own Play button. Telling a player to paste launch options is exactly the
/// kind of manual step this launcher exists to remove.
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
// ── Game launch ───────────────────────────────────────────────────────────────
/// 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(())
}
/// 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%";
-311
View File
@@ -1,311 +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;
// egui 0.29 keeps a separate `Style` per theme (dark/light) and renders with
// whichever the theme preference resolves to. `set_style` touches only the
// currently-active theme, so a later switch to the other one would drop our
// named text styles ("Hero", "Subheading", …) and panic in `TextStyle::resolve`.
// Install the full style into BOTH themes and pin the preference to Dark so
// the branded look is stable regardless of the host's system theme.
ctx.set_style_of(egui::Theme::Dark, style.clone());
ctx.set_style_of(egui::Theme::Light, style);
ctx.set_theme(egui::ThemePreference::Dark);
}