10 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
funman300 958ff24546 feat(hook): add SBC request tracing instrumentation
- sbc_hook.rs: trace SBC submission/response flow with request IDs
- sbc_request_trace.rs: capture request/response bodies for analysis
- sbc_trace.rs: runtime trace buffer with structured logging

Work in progress - needs validation against live FIFA 17 client
2026-08-08 17:49:00 -07:00
funman300 09ed26ba16 wip: checkpoint FIFA 17 hook diagnostics 2026-08-07 12:03:21 -07:00
funman300 3d895fb7ac feat: add guarded FIFA 17 SBC diagnostics 2026-08-07 11:43:08 -07:00
funman300 7dcf610b71 openfut-hook: RE instrumentation for the Blaze dial-gate investigation
In-process, read-only probes and transport observation built while closing
the online/FUT route from both the memory and network sides.

- probe.rs / dial_notification.rs: menu-time ctx dump, connMgr enumerator,
  synthetic dial-notification + direct-call dial trigger, and the
  [element+0x40] container write-watchpoint. All env-gated, one-shot,
  VirtualQuery-guarded; none alter game state by default.
- transport_watch.rs + connect/connectex/hooks/lib: M0 transport observation
  (grep-friendly TRANSPORT_WATCH logging on the existing getaddrinfo/connect/
  WSAConnect/ConnectEx detours) and an IPv6 (v4-mapped) EA-redirect so the
  game's IPv6 :443 dials land on the bridge instead of the dead servers.

Findings: the game never initiates a Blaze connection offline; the dial
handler is registered by a self-registering, message-driven state machine
whose container stays empty with no Blaze exchange. See openfut-bridge
docs/closure-and-preservation.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:58:17 -07:00
funman300 ff4b5a87f5 probe: ungate the forcing experiment (manual-only)
install_force_connect() is no longer auto-called from install_probes_deferred,
so normal probe builds don't poke the online flow. Kept for reference; re-enable
the call to reproduce the 2026-07-02 forcing experiment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:45:08 -07:00
funman300 493b9e0573 probe: run-4 connect-state lifecycle spread (8 targets)
Expands to 8 entry probes across the connect-state lifecycle (ctor, controller
accessor, four vtable steps) to distinguish entered-but-stalled from never-entered.
Result: ctor fires x4, everything else 0 — subsystem created but dormant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:03:50 -07:00
funman300 c1d1f03ec8 probe: retarget to game-side connect layer + add guarded session-context sampler
Replaces the anadius-connectivity probes with the game-side Nucleus-connect
functions (nucleusConnectREST/Trusted, connect-state tick) and adds a
VirtualQuery-guarded sampler thread that reads X=[0x14acd02c0] -> M=[X+0x360]
-> ctx=[M+0x778] once/sec to observe the session context directly. Per-slot
log cap prevents per-frame handlers flooding the log. Run 3 result: ctx is
non-null but the connect functions are never called (see bridge findings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:56:31 -07:00
funman300 3c3dc32fc6 probe: add behavior-preserving mid-function detour to capture the live OnlineStatusEvent listener
The OnlineStatusEventT::HandleMessage dispatch resolves its game-side
listener only at runtime (call [rax+0x28]). openfut_listener_stub patches
FIFA23.exe+0x274d4d7 to replicate the four dispatch instructions while
logging the resolved vtable/fn, then resumes. Alignment-safe (saves/rounds
rsp before the log call). Result: listener = FIFA23.exe+0x2751060 = ret 0,
a no-op default vtable slot -> the online->auth transition is state-polled,
not callback-driven.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:25:33 -07:00
funman300 feaff0443f hook: in-process RE instrumentation + LSX redirect + capture tooling
Hook-side tooling for the LSX/Blaze reverse-engineering effort:

- probe.rs (new, `probe` feature): passive logging detours on FIFA's online-flow
  functions via the unhook/rehook pattern (no trampoline/relocation, works on
  RIP-relative prologues). Deferred install waits for anadius64.dll to load, then
  logs enter/return for GoOnline + GetInternetConnectedState (anadius) and the
  OnlineStatusEvent/Login deserializers (FIFA23.exe). Revealed that our pushed LSX
  events reach FIFA and parse OK, while GoOnline never fires — localizing the online
  gate to FIFA's game-side event consumer.
- connect_hook.rs: redirect FIFA's LSX connect :3216 → :3217 so it lands on the
  native openfut-bridge LSX server (slips past anadius's in-process :3216 intercept);
  gated off under the `capture_baseline` feature.
- recv_hook.rs: boundary-safe trampolines + LSX peer filtering for the
  capture_baseline path (log anadius's real LSX frames when the redirect is off).

Build the instrumented DLL with `--features probe` (or `--features capture_baseline`
for the anadius-baseline capture). Both features are off by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:59:27 -07:00
37 changed files with 6813 additions and 3912 deletions
+8
View File
@@ -1 +1,9 @@
target/
# runtime SQLite DB (created when services run from this dir)
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",
-1
View File
@@ -12,4 +12,3 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
chrono = { version = "0.4", features = ["serde"] }
openfut-common = { path = "openfut-common" }
-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",
]
+17 -3
View File
@@ -6,10 +6,21 @@ edition = "2021"
[lib]
crate-type = ["cdylib"]
[features]
# Build with `--features capture_baseline` to DISABLE the LSX 3216→3217 redirect,
# so FIFA's LSX goes to anadius's in-process server (for capturing anadius's real
# responses). Default build keeps the redirect (LSX → our bridge).
capture_baseline = []
# Build with `--features probe` to install passive logging detours on FIFA's
# in-process online-flow functions (GoOnline, GetInternetConnectedState, event
# deserializers). Writes PROBE lines to C:\openfut_hook.log for RE. See probe.rs.
probe = []
# Build with `--features fifa17` for the FIFA 17 injection path. DllMain runs ONLY
# the minimal FIFA-17-safe logic in fifa17.rs (prove injection, dump module map,
# patch DirtySDK/ProtoSSL cert-verify) and skips ALL the FIFA-23-specific hooking.
fifa17 = []
[dependencies]
# Shared, dependency-free source of truth for the OpenFUT destination
# (host + ports) and the sockaddr byte-order helpers. Keeps all three hooks
# consistent and keeps this logic host-testable outside WinSock.
openfut-common = { path = "../openfut-common" }
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
@@ -19,6 +30,9 @@ windows-sys = { version = "0.59", features = [
"Win32_Networking_WinSock",
"Win32_Security_Cryptography",
"Win32_System_Threading",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Diagnostics_Debug",
"Win32_System_Kernel",
] }
[profile.release]
+17
View File
@@ -0,0 +1,17 @@
use std::env;
use std::path::PathBuf;
fn main() {
println!("cargo:rerun-if-changed=version.def");
// The proxy's PE export surface is part of its runtime contract. Feed an
// explicit module-definition file to the MinGW linker instead of relying
// solely on Rust symbol export attributes and linker retention heuristics.
if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu")
{
let definition =
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("version.def");
println!("cargo:rustc-link-arg={}", definition.display());
}
}
+33 -9
View File
@@ -1,18 +1,16 @@
//! Loads `openfut.cfg` (next to this DLL) into a shared [`ServerConfig`].
//!
//! There is intentionally **no loopback fallback**: if the file is missing,
//! empty, or invalid, this returns a [`ConfigError`] and the caller logs it and
//! declines to redirect. Missing configuration is an error, never `127.0.0.1`.
//! 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;
/// Read and parse `openfut.cfg` from the same directory as this DLL.
pub fn load_config(
module: windows_sys::Win32::Foundation::HMODULE,
) -> Result<ServerConfig, ConfigError> {
let path = config_path(module).ok_or(ConfigError::ConfigMissing)?;
let contents = std::fs::read_to_string(&path).map_err(|_| ConfigError::ConfigMissing)?;
ServerConfig::parse(&contents)
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> {
@@ -28,3 +26,29 @@ fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option<std::p
let dll_path = std::path::Path::new(path);
Some(dll_path.parent()?.join("openfut.cfg"))
}
/// Read a raw feature-flag value (`key=value`) from `openfut.cfg` beside the DLL.
///
/// Returns the trimmed value, or `None` if the file or key is absent. This reads the
/// SAME config file as [`load_config`] but does NOT go through the strict
/// [`ServerConfig`] parser (which owns host/port validation and hard-errors on bad
/// input) — optional client feature flags must never be able to break server config.
pub fn feature_value(
module: windows_sys::Win32::Foundation::HMODULE,
key: &str,
) -> Option<String> {
let cfg_path = config_path(module)?;
let content = std::fs::read_to_string(&cfg_path).ok()?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
if k.trim() == key {
return Some(v.trim().to_string());
}
}
}
None
}
+178 -55
View File
@@ -1,16 +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.
///
/// The redirect destination (IP + port) comes entirely from the shared
/// [`crate::server`] state, which is populated once from `openfut.cfg`. This
/// hook does NOT choose an address itself — no hardcoded loopback, no per-hook
/// redirect IP. EA *source* ports are recognised via `openfut-common`'s port
/// map; the matching OpenFUT *destination* port + configured IP are substituted.
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 {
@@ -20,6 +68,22 @@ struct SockaddrIn {
sin_zero: [u8; 8],
}
const AF_INET6: u16 = 23; // Windows AF_INET6 value (we run under the Win ABI in Wine)
/// Win32 `sockaddr_in6`. `sin6_port` is network byte order; `sin6_addr` is 16 raw
/// address bytes in network order. 28 bytes total.
#[repr(C)]
struct SockaddrIn6 {
sin6_family: u16,
sin6_port: u16,
sin6_flowinfo: u32,
sin6_addr: [u8; 16],
sin6_scope_id: u32,
}
/// IPv4-mapped IPv6 loopback 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);
@@ -57,57 +121,114 @@ unsafe fn restore_original(target: *mut u8) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
core::ptr::copy_nonoverlapping(ORIGINAL_BYTES.as_ptr(), target, 14);
core::ptr::copy_nonoverlapping(core::ptr::addr_of!(ORIGINAL_BYTES) as *const u8, target, 14);
VirtualProtect(target as _, 14, old, &mut old);
}
unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 16], i32)> {
if namelen < 8 {
/// 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;
}
let sa = &*(name as *const SockaddrIn);
if sa.sin_family != AF_INET {
return None;
// 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 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",
o[0],
o[1],
o[2],
o[3],
u16::from_be(sa.sin_port),
t[0],
t[1],
t[2],
t[3],
u16::from_be(new_port_nbo)
));
// SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write.
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
out.sin_family = AF_INET;
out.sin_port = new_port_nbo;
out.sin_addr = 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);
// 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 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n",
a[0],
a[1],
a[14],
a[15],
u16::from_be(sa6.sin6_port),
u16::from_be(new_port_nbo)
));
// SAFE: buf is exactly 28 bytes == sizeof(sockaddr_in6).
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6);
out.sin6_family = AF_INET6;
out.sin6_port = new_port_nbo;
out.sin6_flowinfo = 0;
out.sin6_addr = target_v4mapped();
out.sin6_scope_id = 0;
Some((buf, 28))
}
_ => None,
}
let orig = sa.sin_addr.to_le_bytes();
let orig_port = u16::from_be(sa.sin_port);
// Map the EA source port to an OpenFUT destination port from shared config.
// Returns None if this port isn't intercepted or no server is configured —
// in which case we leave the connection untouched (no loopback fallback).
let new_port_nbo = crate::server::dest_port_nbo_from_source_nbo(sa.sin_port)?;
// The destination IP is the configured/resolved OpenFUT server — never a
// hardcoded address. If unset, dest_port_nbo_from_source_nbo already
// returned None above, so this is guaranteed Some here.
let new_addr = crate::server::sin_addr()?;
let ni = new_addr.to_le_bytes();
crate::write_log(&format!(
"connect_hook: {}.{}.{}.{}:{}{}.{}.{}.{}:{} (configured OpenFUT server)\n",
orig[3],
orig[2],
orig[1],
orig[0],
orig_port,
ni[0],
ni[1],
ni[2],
ni[3],
u16::from_be(new_port_nbo)
));
let mut buf = [0u8; 16];
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
out.sin_family = AF_INET;
out.sin_port = new_port_nbo;
out.sin_addr = new_addr;
Some((buf, 16))
}
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);
@@ -145,17 +266,13 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
core::mem::transmute(addr);
f(s, buf.as_ptr(), len)
};
// connect() communicates nonblocking progress through WSAGetLastError.
// Reinstalling the detour calls VirtualProtect, which may overwrite that
// thread-local value before FIFA reads it. Preserve the real call's value
// across all hook maintenance and logging.
let wsa_error = if r != 0 {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
WSAGetLastError()
} else {
0
};
write_hook(addr, hooked_connect as u64);
write_hook(addr, hooked_connect as *const () as u64);
if r != 0 {
use windows_sys::Win32::Networking::WinSock::WSASetLastError;
WSASetLastError(wsa_error);
@@ -176,7 +293,7 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
} else {
0
};
write_hook(addr, hooked_connect as u64);
write_hook(addr, hooked_connect as *const () as u64);
if namelen >= 8 {
let sa = &*(call_name as *const SockaddrIn);
if sa.sin_family == AF_INET {
@@ -199,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)
@@ -221,10 +340,14 @@ pub unsafe fn install_inline_connect_hook() -> bool {
};
// Save original 14 bytes
core::ptr::copy_nonoverlapping(connect_fn, ORIGINAL_BYTES.as_mut_ptr(), 14);
core::ptr::copy_nonoverlapping(
connect_fn,
core::ptr::addr_of_mut!(ORIGINAL_BYTES) as *mut u8,
14,
);
CONNECT_ADDR.store(connect_fn as usize, Ordering::Relaxed);
// Overwrite first 14 bytes with absolute indirect JMP to our hook
write_hook(connect_fn, hooked_connect as u64);
write_hook(connect_fn, hooked_connect as *const () as u64);
true
}
+27 -57
View File
@@ -6,7 +6,8 @@ use core::ffi::c_void;
/// inline so that when it returns a ConnectEx pointer we swap it for our own wrapper.
use core::sync::atomic::{AtomicUsize, Ordering};
const AF_INET: u16 = 2;
// Address rewriting (v4 + v6) is shared from connect_hook::redirect_if_ea, so the port
// constants and sockaddr structs no longer live here.
// SIO_GET_EXTENSION_FUNCTION_POINTER
const SIO_GET_EXT_FN: u32 = 0xC8000006;
@@ -16,14 +17,6 @@ const CONNECTEX_GUID: [u8; 16] = [
0xB9, 0x07, 0xA2, 0x25, 0xF3, 0xDD, 0x60, 0x46, 0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E,
];
#[repr(C)]
struct SockaddrIn {
sin_family: u16,
sin_port: u16,
sin_addr: u32,
sin_zero: [u8; 8],
}
// The real ConnectEx pointer, saved after WSAIoctl returns it
static REAL_CONNECTEX: AtomicUsize = AtomicUsize::new(0);
@@ -69,11 +62,11 @@ unsafe fn restore_wsaioctl(target: *mut u8) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
core::ptr::copy_nonoverlapping(WSAIOCTL_ORIG.as_ptr(), target, 14);
core::ptr::copy_nonoverlapping(core::ptr::addr_of!(WSAIOCTL_ORIG) as *const u8, target, 14);
VirtualProtect(target as _, 14, old, &mut old);
}
/// Our ConnectEx wrapper: redirects intercepted EA endpoints to the configured OpenFUT server
/// Our ConnectEx wrapper: redirects EA ports to 127.0.0.1
unsafe extern "system" fn hooked_connectex(
s: usize,
name: *const u8,
@@ -85,48 +78,21 @@ unsafe extern "system" fn hooked_connectex(
) -> i32 {
let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed));
if namelen >= 8 {
let sa = &*(name as *const SockaddrIn);
if sa.sin_family == AF_INET {
let o = sa.sin_addr.to_le_bytes();
let orig_port = u16::from_be(sa.sin_port);
// Destination port + IP come from the shared configured server —
// never a hardcoded loopback. None => not intercepted / unconfigured,
// so the original connection is passed through untouched.
if let (Some(new_port_nbo), Some(new_addr)) = (
crate::server::dest_port_nbo_from_source_nbo(sa.sin_port),
crate::server::sin_addr(),
) {
let ni = new_addr.to_le_bytes();
crate::write_log(&format!(
"connectex_hook: {}.{}.{}.{}:{}{}.{}.{}.{}:{} (configured OpenFUT server)\n",
o[3],
o[2],
o[1],
o[0],
orig_port,
ni[0],
ni[1],
ni[2],
ni[3],
u16::from_be(new_port_nbo)
));
let mut redirect = [0u8; 16];
let out = &mut *(redirect.as_mut_ptr() as *mut SockaddrIn);
out.sin_family = AF_INET;
out.sin_port = new_port_nbo;
out.sin_addr = new_addr;
return real_fn(
s,
redirect.as_ptr(),
16,
send_buf,
send_data_len,
bytes_sent,
overlapped,
);
}
}
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("ConnectEx", name, namelen, s);
// Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx
// dials get the same IPv6 handling as plain connect().
if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) {
return real_fn(
s,
buf.as_ptr(),
len,
send_buf,
send_data_len,
bytes_sent,
overlapped,
);
}
real_fn(
s,
@@ -161,7 +127,7 @@ pub unsafe extern "system" fn hooked_wsaioctl(
s, code, in_buf, in_len, out_buf, out_len, bytes_ret, overlapped, completion,
)
};
write_hook(addr, hooked_wsaioctl as u64);
write_hook(addr, hooked_wsaioctl as *const () as u64);
// If this was a ConnectEx request that succeeded, swap the pointer
if result == 0 && code == SIO_GET_EXT_FN && in_len == 16 && !in_buf.is_null() {
@@ -178,7 +144,7 @@ pub unsafe extern "system" fn hooked_wsaioctl(
));
}
// Return our hook instead
*out_ptr = hooked_connectex as usize;
*out_ptr = hooked_connectex as *const () as usize;
}
}
result
@@ -194,8 +160,12 @@ pub unsafe fn install_wsaioctl_hook() -> bool {
Some(f) => f as *mut u8,
None => return false,
};
core::ptr::copy_nonoverlapping(fn_ptr, WSAIOCTL_ORIG.as_mut_ptr(), 14);
core::ptr::copy_nonoverlapping(
fn_ptr,
core::ptr::addr_of_mut!(WSAIOCTL_ORIG) as *mut u8,
14,
);
WSAIOCTL_ADDR.store(fn_ptr as usize, Ordering::Relaxed);
write_hook(fn_ptr, hooked_wsaioctl as u64);
write_hook(fn_ptr, hooked_wsaioctl as *const () as u64);
true
}
+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);
}
}
+199
View File
@@ -0,0 +1,199 @@
//! FIFA 17 injection path (feature = "fifa17").
//!
//! This is a *separate, minimal* entry point from the FIFA-23 `install_hooks`.
//! FIFA 17 is a different game with different in-memory structures, so we run NONE
//! of the FIFA-23 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};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Module32FirstW, Module32NextW, MODULEENTRY32W, TH32CS_SNAPMODULE,
TH32CS_SNAPMODULE32,
};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
/// Read the SizeOfImage from a module's in-memory PE headers.
unsafe fn size_of_image(base: usize) -> u32 {
if base == 0 {
return 0;
}
// DOS header -> e_lfanew (i32 @ 0x3c) -> PE header. SizeOfImage is in the
// optional header at offset 0x50 from the PE signature (same for PE32/PE32+).
let e_lfanew = *((base + 0x3c) as *const i32);
let pe = base + e_lfanew as usize;
// sanity: 'PE\0\0'
if *(pe as *const u32) != 0x0000_4550 {
return 0;
}
*((pe + 24 + 0x38) as *const u32) // opt header +0x38 = SizeOfImage
}
fn wide_to_string(w: &[u16]) -> String {
let end = w.iter().position(|&c| c == 0).unwrap_or(w.len());
String::from_utf16_lossy(&w[..end])
}
/// Enumerate loaded modules (name, base, size) via ToolHelp and log them.
unsafe fn dump_modules() {
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, 0);
if snap == INVALID_HANDLE_VALUE {
write_log("fifa17: module snapshot FAILED\n");
return;
}
let mut me: MODULEENTRY32W = core::mem::zeroed();
me.dwSize = core::mem::size_of::<MODULEENTRY32W>() as u32;
if Module32FirstW(snap, &mut me) != 0 {
loop {
let name = wide_to_string(&me.szModule);
let base = me.modBaseAddr as usize;
let size = me.modBaseSize;
write_log(&format!(
"fifa17: module {name:<28} base={base:#018x} size={size:#x}\n"
));
me.dwSize = core::mem::size_of::<MODULEENTRY32W>() as u32;
if Module32NextW(snap, &mut me) == 0 {
break;
}
}
} else {
write_log("fifa17: Module32FirstW FAILED\n");
}
CloseHandle(snap);
}
/// Worker that runs AFTER DllMain returns (loader lock released). ToolHelp and
/// other loader-touching calls are unsafe under the loader lock, so we defer them
/// to this thread. This is what fixed the "game exits right after DllMain" issue.
unsafe extern "system" fn worker(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);
write_log(&format!(
"fifa17: main exe base={main_base:#018x} SizeOfImage={img:#x}\n"
));
dump_modules();
// Install the generic network redirect. `param` carries our own DLL's
// HMODULE so config::load_config can find openfut.cfg beside the DLL.
let dll_module = param as windows_sys::Win32::Foundation::HMODULE;
let server = match crate::config::load_config(dll_module).and_then(|c| c.resolve()) {
Ok(server) => server,
Err(e) => {
write_log(&format!(
"fifa17: invalid/missing openfut.cfg ({e}); network redirect DISABLED\n"
));
return 0;
}
};
write_log(&format!(
"fifa17: OpenFUT server={} https={} blaze_redir={} blaze_main={}\n",
server.redirect_ip,
server.ports.https,
server.ports.blaze_redirector,
server.ports.blaze_main
));
install_network_redirect(server);
write_log("fifa17: worker complete (injection healthy)\n");
// 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");
}
// 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.
/// `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),
module as *const core::ffi::c_void,
0,
core::ptr::null_mut(),
);
if h == 0 as _ {
write_log("fifa17: CreateThread FAILED\n");
}
}
+25 -24
View File
@@ -12,12 +12,8 @@ type GetaddrinfoFn =
unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32;
static REAL: OnceLock<GetaddrinfoFn> = OnceLock::new();
// NUL-terminated dotted-quad of the resolved OpenFUT server, built once at init
// from the SAME shared config the socket hooks use. getaddrinfo redirects EA
// hostnames here so DNS resolves to the configured server. If configuration was
// missing/invalid this stays empty and EA hostnames are NOT redirected (no
// loopback fallback).
static REDIRECT_HOST: OnceLock<Vec<u8>> = 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
@@ -28,12 +24,22 @@ pub fn set_real(f: GetaddrinfoFn) {
let _ = REAL.set(f);
}
/// Install the resolved redirect IPv4 (dotted-quad) getaddrinfo will hand back
/// for EA hostnames. Called once at init from the shared resolved server.
pub fn set_redirect_ip(ip: std::net::Ipv4Addr) {
let mut bytes = ip.to_string().into_bytes();
pub fn set_redirect_ip(ip: String) {
let mut bytes = ip.clone().into_bytes();
bytes.push(0);
let _ = REDIRECT_HOST.set(bytes);
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
@@ -57,6 +63,8 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
if !node_name.is_null() {
if let Ok(host) = CStr::from_ptr(node_name as *const i8).to_str() {
crate::write_log(&format!("openfut_hook: getaddrinfo({host})\n"));
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_getaddrinfo(host);
if is_ea_host(host) {
// Apply the ProtoSSL cert-verify bypass the first time we see an EA
// hostname — EAWebKit.dll must be loaded by now because it's calling us.
@@ -73,20 +81,13 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
}
}
// Only redirect when a server was configured & resolved. If not,
// fall through to the real resolver — we never invent a loopback
// destination here.
match REDIRECT_HOST.get() {
Some(redirect) => {
let real = REAL.get().copied().unwrap_or(sys_getaddrinfo);
return real(redirect.as_ptr(), service_name, hints, result);
}
None => {
crate::write_log(
"openfut_hook: EA host seen but no OpenFUT server configured — NOT redirecting\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",
);
}
}
}
+84 -17
View File
@@ -1,12 +1,29 @@
mod config;
mod connect_hook;
mod connectex_hook;
mod dial_notification;
#[cfg(feature = "fifa17")]
mod fifa17;
mod hooks;
mod iat;
mod origin_spy;
mod server;
#[cfg(feature = "probe")]
mod probe;
#[cfg(feature = "capture_baseline")]
mod recv_hook;
mod resolver_hook;
#[cfg(feature = "fifa17")]
mod sbc_hook;
#[cfg(feature = "fifa17")]
mod sbc_request_trace;
#[cfg(feature = "fifa17")]
mod sbc_trace;
#[cfg(feature = "fifa17")]
mod store_hook;
mod ssl_patch;
mod tls_bypass;
mod transport_watch;
mod version_proxy;
use windows_sys::Win32::{
Foundation::{BOOL, HMODULE, TRUE},
@@ -25,35 +42,59 @@ pub(crate) fn write_log(msg: &str) {
}
}
/// 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 {
install_hooks(module);
// VERSION forwarding must be ready before DllMain returns. Hook setup
// may be deferred, but a caller can use any proxy export immediately.
if version_proxy::resolve() {
install_hooks(module);
}
}
TRUE
}
unsafe fn install_hooks(module: HMODULE) {
write_log("openfut_hook: DllMain fired\n");
// 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(module);
return;
}
#[cfg(not(feature = "fifa17"))]
install_hooks_fifa23(module)
}
// Load the single source of truth for the OpenFUT destination. If it's
// missing/invalid we log and install NO redirection — traffic is left alone
// rather than silently sent to loopback.
#[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(resolved) => {
server::set(resolved);
hooks::set_redirect_ip(resolved.redirect_ip);
let o = resolved.redirect_ip.octets();
write_log(&format!(
"openfut_hook: OpenFUT server = {}.{}.{}.{} (https={} blaze_redir={} blaze_main={})\n",
o[0], o[1], o[2], o[3],
resolved.ports.https, resolved.ports.blaze_redirector, resolved.ports.blaze_main
));
Ok(server) => {
hooks::set_redirect_ip(server.redirect_ip.to_string());
connect_hook::set_server(server);
}
Err(e) => {
write_log(&format!(
"openfut_hook: NO OpenFUT server configured ({e}); redirection DISABLED. \
Configure a server in the launcher and relaunch.\n"
"openfut_hook: invalid/missing openfut.cfg ({e}); redirection DISABLED\n"
));
}
}
@@ -114,8 +155,34 @@ unsafe fn install_hooks(module: HMODULE) {
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) => {{
File diff suppressed because it is too large Load Diff
+191 -66
View File
@@ -12,7 +12,8 @@ unsafe fn write_jmp(target: *mut u8, dest: u64) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
target.write(0xFF); target.add(1).write(0x25);
target.write(0xFF);
target.add(1).write(0x25);
(target.add(2) as *mut u32).write(0);
(target.add(6) as *mut u64).write(dest);
VirtualProtect(target as _, 14, old, &mut old);
@@ -22,32 +23,48 @@ unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option<usize> {
use windows_sys::Win32::System::Memory::{
VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE,
};
// Log prologue so we can diagnose if trampolines misbehave
let bytes: [u8; 14] = core::array::from_fn(|i| *orig.add(i));
let hex: String = bytes.iter().map(|b| format!("{b:02x} ")).collect();
// Read enough prologue to walk instruction boundaries.
let probe: [u8; 24] = core::array::from_fn(|i| *orig.add(i));
let hex: String = probe[..14].iter().map(|b| format!("{b:02x} ")).collect();
crate::write_log(&format!("recv_hook: {name} prologue {hex}\n"));
// Walk instruction boundaries to find relative branches.
// Byte-by-byte scanning mis-identifies immediate operands (e.g. `sub rsp, 0x70`)
// as jump opcodes, so we must parse properly.
if has_rip_relative_branch(&bytes) {
crate::write_log(&format!("recv_hook: {name} has relative branch in prologue, skipping trampoline\n"));
return None;
// Copy WHOLE instructions until we've covered >= 14 bytes (the size of the JMP
// patch), so the trampoline never splits an instruction. Copying a fixed 14
// bytes lands mid-instruction on these prologues and crashes on execution.
let mut copy_len = 0usize;
while copy_len < 14 {
let (len, branch) = decode_instr_len(&probe[copy_len..]);
if len == 0 || branch {
crate::write_log(&format!(
"recv_hook: {name} unrelocatable prologue (len={len} branch={branch}), skipping\n"
));
return None;
}
copy_len += len;
}
let mem = VirtualAlloc(
core::ptr::null_mut(), 32,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE,
core::ptr::null_mut(),
64,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE,
);
if mem.is_null() { crate::write_log("recv_hook: VirtualAlloc failed\n"); return None; }
if mem.is_null() {
crate::write_log("recv_hook: VirtualAlloc failed\n");
return None;
}
let t = mem as *mut u8;
core::ptr::copy_nonoverlapping(orig, t, 14);
// JMP [RIP+0] → orig+14
let cont = (orig as u64) + 14;
t.add(14).write(0xFF); t.add(15).write(0x25);
(t.add(16) as *mut u32).write(0);
(t.add(20) as *mut u64).write(cont);
core::ptr::copy_nonoverlapping(orig, t, copy_len);
// JMP [RIP+0] → orig+copy_len (resume at the next whole instruction)
let cont = (orig as u64) + copy_len as u64;
t.add(copy_len).write(0xFF);
t.add(copy_len + 1).write(0x25);
(t.add(copy_len + 2) as *mut u32).write(0);
(t.add(copy_len + 6) as *mut u64).write(cont);
crate::write_log(&format!(
"recv_hook: {name} trampoline copy_len={copy_len}\n"
));
Some(t as usize)
}
@@ -58,8 +75,12 @@ fn has_rip_relative_branch(bytes: &[u8]) -> bool {
let mut pos = 0;
while pos < bytes.len() {
let (len, branch) = decode_instr_len(&bytes[pos..]);
if branch { return true; }
if len == 0 { break; } // unknown/truncated — stop safely
if branch {
return true;
}
if len == 0 {
break;
} // unknown/truncated — stop safely
pos += len;
}
false
@@ -69,9 +90,29 @@ fn modrm_extra(modrm: u8) -> usize {
let md = (modrm >> 6) & 3;
let rm = modrm & 7;
match md {
0 => if rm == 5 { 4 } else if rm == 4 { 1 } else { 0 },
1 => if rm == 4 { 2 } else { 1 },
2 => if rm == 4 { 5 } else { 4 },
0 => {
if rm == 5 {
4
} else if rm == 4 {
1
} else {
0
}
}
1 => {
if rm == 4 {
2
} else {
1
}
}
2 => {
if rm == 4 {
5
} else {
4
}
}
_ => 0,
}
}
@@ -79,16 +120,31 @@ fn modrm_extra(modrm: u8) -> usize {
/// Returns (instruction_length_in_bytes, is_rip_relative_branch).
/// Returns (0, false) for unknown/truncated.
fn decode_instr_len(b: &[u8]) -> (usize, bool) {
if b.is_empty() { return (0, false); }
if b.is_empty() {
return (0, false);
}
let mut i = 0;
// Legacy prefixes
while let Some(&p) = b.get(i) {
if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) { i += 1; } else { break; }
if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) {
i += 1;
} else {
break;
}
}
// REX prefix (404F)
if b.get(i).copied().map(|x| (0x40..=0x4F).contains(&x)).unwrap_or(false) { i += 1; }
if b.get(i)
.copied()
.map(|x| (0x40..=0x4F).contains(&x))
.unwrap_or(false)
{
i += 1;
}
let op = match b.get(i) { Some(&x) => x, None => return (0, false) };
let op = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
i += 1;
match op {
@@ -103,28 +159,45 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) {
0xE9 | 0xE8 => (i + 4, true),
// 0F prefix
0x0F => {
let op2 = match b.get(i) { Some(&x) => x, None => return (0, false) };
let op2 = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
i += 1;
if (0x80..=0x8F).contains(&op2) { return (i + 4, true); } // Jcc rel32
// Most 0F XX: ModRM
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
if (0x80..=0x8F).contains(&op2) {
return (i + 4, true);
} // Jcc rel32
// Most 0F XX: ModRM
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm), false)
}
// Instructions with ModRM only (no immediate)
0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F |
0x01 | 0x03 | 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B |
0x31 | 0x33 | 0x39 | 0x3B | 0xD3 | 0xFF | 0xF7 => {
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | 0x01 | 0x03
| 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | 0x31 | 0x33 | 0x39 | 0x3B
| 0xD3 | 0xFF | 0xF7 => {
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm), false)
}
// ModRM + imm8
0x6B | 0x80 | 0x83 | 0xC0 | 0xC1 | 0xC6 => {
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm) + 1, false)
}
// ModRM + imm32
0x69 | 0x81 | 0xC7 => {
let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) };
let modrm = match b.get(i) {
Some(&x) => x,
None => return (0, false),
};
(i + 1 + modrm_extra(modrm) + 4, false)
}
// MOV reg, imm8/imm32
@@ -143,7 +216,9 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) {
unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> {
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
let h = GetModuleHandleA(dll.as_ptr());
if h.is_null() { return None; }
if h.is_null() {
return None;
}
GetProcAddress(h, sym.as_ptr()).map(|f| f as *mut u8)
}
@@ -151,46 +226,96 @@ unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> {
static RECV_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
if crate::lsx::is_lsx(s) {
return crate::lsx::on_recv(s, buf, len);
/// True if socket `s` is connected to the EA App LSX port (127.0.0.1:3216).
/// Used in capture mode to tap only the LSX conversation.
unsafe fn peer_is_lsx(s: usize) -> bool {
use windows_sys::Win32::Networking::WinSock::getpeername;
let mut sa = [0u8; 16];
let mut sl: i32 = 16;
if getpeername(s, sa.as_mut_ptr() as *mut _, &mut sl) != 0 {
return false;
}
let t = RECV_TRAMPOLINE.load(Ordering::Relaxed);
if t == 0 { return -1; }
let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t);
f(s, buf, len, flags)
// sockaddr_in: sa_family (2 bytes) then sin_port (2 bytes, network order).
u16::from_be_bytes([sa[2], sa[3]]) == 3216
}
// IAT-hook approach (no inline trampoline — FIFA's `recv`/`send` prologues have
// instructions that straddle the 14-byte patch boundary, so an inline trampoline
// corrupts them and crashes. IAT hooking only swaps import-table pointers and
// never touches the function body). The real fns are resolved in lib.rs and set
// here; our hooks call them directly.
static REAL_RECV: AtomicUsize = AtomicUsize::new(0);
static REAL_SEND: AtomicUsize = AtomicUsize::new(0);
pub fn set_real_recv(f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32) {
REAL_RECV.store(f as usize, Ordering::Relaxed);
}
pub fn set_real_send(f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32) {
REAL_SEND.store(f as usize, Ordering::Relaxed);
}
/// Inline-hook ws2_32!recv: build a boundary-safe trampoline (the "real" fn our
/// hook calls) and overwrite the entry with a JMP to `hooked_recv`. Inline hooks
/// catch calls from every module and dynamically-resolved calls, unlike IAT.
pub unsafe fn install_recv_hook() -> bool {
let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") { Some(p) => p, None => return false };
let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") {
Some(p) => p,
None => return false,
};
match make_trampoline(ptr, "recv") {
Some(t) => { RECV_TRAMPOLINE.store(t, Ordering::Relaxed); }
None => { crate::write_log("recv_hook: recv trampoline failed, hook skipped\n"); return false; }
Some(t) => REAL_RECV.store(t, Ordering::Relaxed),
None => return false,
}
write_jmp(ptr, hooked_recv as u64);
true
}
// ─── send ──────────────────────────────────────────────────────────────────────
static SEND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
if crate::lsx::is_lsx(s) {
return crate::lsx::on_send(s, buf, len);
}
let t = SEND_TRAMPOLINE.load(Ordering::Relaxed);
if t == 0 { return -1; }
let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t);
f(s, buf, len, flags)
}
pub unsafe fn install_send_hook() -> bool {
let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") { Some(p) => p, None => return false };
let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") {
Some(p) => p,
None => return false,
};
match make_trampoline(ptr, "send") {
Some(t) => { SEND_TRAMPOLINE.store(t, Ordering::Relaxed); }
None => { crate::write_log("recv_hook: send trampoline failed, hook skipped\n"); return false; }
Some(t) => REAL_SEND.store(t, Ordering::Relaxed),
None => return false,
}
write_jmp(ptr, hooked_send as u64);
true
}
pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
let t = REAL_RECV.load(Ordering::Relaxed);
if t == 0 {
return -1;
}
let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t);
// Pass through to anadius's real socket, then log what it sent back
// (anadius's LSX response — the ground truth we want to diff against).
let n = f(s, buf, len, flags);
if n > 0 && peer_is_lsx(s) {
let data = core::slice::from_raw_parts(buf, n as usize);
let text = core::str::from_utf8(data).unwrap_or("(binary)");
crate::write_log(&format!(
"CAP recv<-anadius s={s} n={n}: {}\n",
&text[..text.len().min(2400)]
));
}
n
}
pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
if len > 0 && peer_is_lsx(s) {
let data = core::slice::from_raw_parts(buf, len as usize);
let text = core::str::from_utf8(data).unwrap_or("(binary)");
crate::write_log(&format!(
"CAP send->anadius s={s} len={len}: {}\n",
&text[..text.len().min(2400)]
));
}
let t = REAL_SEND.load(Ordering::Relaxed);
if t == 0 {
return -1;
}
let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t);
f(s, buf, len, flags)
}
+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)
}
+864
View File
@@ -0,0 +1,864 @@
//! FIFA 17 SBC render intervention (feature = "fifa17").
//!
//! Makes the FUT **SBC menu render real data** from inside the process. Full spec
//! (all addresses, RVA math, call order, crash risks, staged test plan):
//! fifa17-recon/docs/sbc-hook-dll-spec.md
//!
//! Everything here is **inert by default** and gated by env vars, 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 — 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::{
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;
/// FNV prologue used as the slide-proof control (must match the on-disk PE bytes).
const CTRL_RVA: usize = 0x180d00; // VA 0x180180d00
const CTRL_BYTES: &[u8] = &[
0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0,
];
const A_SLOT_RVA: usize = 0x2e6398; // *(0x1802e6398) = A (FUT root singleton)
const A_VTABLE_RVA: usize = 0x21c2a0;
const B_OFF: usize = 0x1f9d8; // B = A + 0x1f9d8 (SBC request/ready TTL cache)
const B_VTABLE_RVA: usize = 0x1fae70;
const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate)
const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5)
const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap)
const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count
const 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;
const B_READY_EXPECTED_BEFORE_ARM: u8 = 0;
#[allow(dead_code)]
const AVT_M_GETTER: usize = 0x9b0; // A.vtable[+0x9b0] = 0x18011b7d0 (M lazy getter)
#[allow(dead_code)]
const AVT_B_GETTER: usize = 0x4e8; // A.vtable[+0x4e8] = 0x18011c1f0 (B getter thunk)
// Callable RVAs (for the Tier-1 populate sequence — see spec §6/§8). Kept for
// reference/wiring; not invoked while Tier-1 is blocked.
#[allow(dead_code)]
mod rva {
pub const M_LAZY_GETTER: usize = 0x11b7d0;
pub const ISVALID: usize = 0x65d40;
pub const DESER_SBS_SETS: usize = 0x17b2b0;
pub const SAX_CTX_INIT: usize = 0x1c63e0;
pub const REGISTRY_GETTER: usize = 0xd7170;
pub const MANAGER_GETTER: usize = 0x9c80;
pub const CLEAR_M: usize = 0x15f3a0;
pub const CAT_CTOR: usize = 0x159da0;
pub const CAT_DESER: usize = 0x17ab80;
pub const CAT_FINALIZE: usize = 0x160e50;
pub const APPEND: usize = 0x15a770;
pub const CAT_DTOR: usize = 0x1105d0;
pub const IDX_REBUILD_1: usize = 0x160e00;
pub const IDX_REBUILD_2: usize = 0x160f30;
pub const IDX_REBUILD_3: usize = 0x161020;
pub const REFRESH_DISPATCH: usize = 0x1a4a70; // Scaleform events 0x756c-0x7574
}
static ARMED: AtomicBool = AtomicBool::new(false);
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
static COMMIT: AtomicBool = AtomicBool::new(false);
static POPULATE: AtomicBool = AtomicBool::new(false);
static DONE: AtomicBool = AtomicBool::new(false);
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum RuntimeState {
Disabled,
Resolved,
Intercepted,
Parsed,
Validated,
Committed,
Failed,
}
fn valid_transition(from: RuntimeState, to: RuntimeState) -> bool {
matches!(
(from, to),
(RuntimeState::Disabled, RuntimeState::Resolved)
| (RuntimeState::Resolved, RuntimeState::Intercepted)
| (RuntimeState::Intercepted, RuntimeState::Parsed)
| (RuntimeState::Parsed, RuntimeState::Validated)
// Resolve-only/Tier-0 validates without installing an interceptor.
| (RuntimeState::Resolved, RuntimeState::Validated)
| (RuntimeState::Validated, RuntimeState::Committed)
| (_, RuntimeState::Failed)
)
}
fn transition(from: RuntimeState, to: RuntimeState) -> bool {
valid_transition(from, to)
&& STATE
.compare_exchange(
from as usize,
to as usize,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ValidationError {
AddressOverflow,
AUnreadable,
AVtableMismatch,
AGetterMismatch,
BVtableMismatch,
BDtorMismatch,
BIsValidMismatch,
BClearMismatch,
MSlotUnreadable,
ReadyByteUnexpected,
CollectionUnreadable,
CollectionNotNull,
ReadyByteNotWritable,
ModelEmpty,
ControllerMissing,
ControllerVtableMismatch,
ControllerModelMismatch,
CompletionBranchMismatch,
CompletionBranchProtectFailed,
CompletionBranchFlushFailed,
}
#[derive(Clone, Copy, Debug)]
struct RuntimeSnapshot {
a: usize,
a_vtable: usize,
a_b_getter: usize,
b: usize,
b_vtable: usize,
b_dtor: usize,
b_isvalid: usize,
b_clear: usize,
b_ready: u8,
b_coll: usize,
m: usize,
}
fn expected_va(base: usize, rva: usize) -> Result<usize, ValidationError> {
base.checked_add(rva)
.ok_or(ValidationError::AddressOverflow)
}
fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationError> {
if s.a == 0
|| s.b
!= s.a
.checked_add(B_OFF)
.ok_or(ValidationError::AddressOverflow)?
{
return Err(ValidationError::AUnreadable);
}
if s.a_vtable != expected_va(base, A_VTABLE_RVA)? {
return Err(ValidationError::AVtableMismatch);
}
if s.a_b_getter != expected_va(base, 0x11c1f0)? {
return Err(ValidationError::AGetterMismatch);
}
if s.b_vtable != expected_va(base, B_VTABLE_RVA)? {
return Err(ValidationError::BVtableMismatch);
}
if s.b_dtor != expected_va(base, B_DTOR_RVA)? {
return Err(ValidationError::BDtorMismatch);
}
if s.b_isvalid != expected_va(base, B_ISVALID_RVA)? {
return Err(ValidationError::BIsValidMismatch);
}
if s.b_clear != expected_va(base, B_CLEAR_RVA)? {
return Err(ValidationError::BClearMismatch);
}
if s.b_ready != B_READY_EXPECTED_BEFORE_ARM {
return Err(ValidationError::ReadyByteUnexpected);
}
if s.b_coll != 0 {
return Err(ValidationError::CollectionNotNull);
}
let _ = s.m; // The guarded snapshot read proves the M slot itself is readable.
Ok(())
}
/// Fault-safe pointer read (mirrors `probe::read_ptr`): returns None unless `ptr` lands
/// in a committed, readable page and the full 8 bytes fit inside the region.
unsafe fn read_ptr(ptr: usize) -> Option<usize> {
if ptr < 0x10000 || ptr & 7 != 0 {
return None;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT {
return None;
}
if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return None;
}
if ptr + 8 > mbi.BaseAddress as usize + mbi.RegionSize {
return None;
}
Some(core::ptr::read_volatile(ptr as *const usize))
}
/// Guarded byte read.
unsafe fn read_u8(ptr: usize) -> Option<u8> {
if ptr < 0x10000 {
return None;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return None;
}
if ptr + 1 > mbi.BaseAddress as usize + mbi.RegionSize {
return None;
}
Some(core::ptr::read_volatile(ptr as *const u8))
}
/// A Tier-0 write is allowed only when the complete byte lies in a committed,
/// non-guarded region whose current protection explicitly permits writes.
unsafe fn writable_u8(ptr: usize) -> bool {
if ptr < 0x10000 {
return false;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return false;
}
let protection = mbi.Protect & 0xff;
let writable = matches!(
protection,
PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
);
writable
&& ptr
.checked_add(1)
.is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize))
}
unsafe fn executable_range(ptr: usize, len: usize) -> bool {
let Some(end) = ptr.checked_add(len) else {
return false;
};
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return false;
}
let protection = mbi.Protect & 0xff;
matches!(
protection,
PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
) && end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)
}
/// Guarded 16-bit read (M category count is a WORD).
unsafe fn read_u16(ptr: usize) -> Option<u16> {
let lo = read_u8(ptr)? as u16;
let hi = read_u8(ptr + 1)? as u16;
Some(lo | (hi << 8))
}
/// Resolve CardsDLL's runtime base, or 0. Tries the exact loaded name; the ToolHelp
/// fallback (name-contains "CardsDLL") lives in the spec — add it if EA ever renames.
unsafe fn resolve_cards_base() -> usize {
let h = GetModuleHandleA(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(b"CardsDLL.dll\0".as_ptr());
if !h2.is_null() {
return h2 as usize;
}
0
}
#[inline]
fn va(base: usize, rva: usize) -> usize {
base + rva
}
/// Prove the module didn't move: the FNV control prologue must match the on-disk PE.
unsafe fn control_matches(base: usize) -> bool {
let p = va(base, CTRL_RVA);
for (i, &want) in CTRL_BYTES.iter().enumerate() {
match read_u8(p + i) {
Some(got) if got == want => {}
_ => return false,
}
}
true
}
/// Take one guarded identity snapshot. A failure to read any identity-bearing field is
/// distinct from a value mismatch and aborts before mutation.
unsafe fn runtime_snapshot(base: usize) -> Result<RuntimeSnapshot, ValidationError> {
let a_slot = expected_va(base, A_SLOT_RVA)?;
let a = read_ptr(a_slot)
.filter(|&value| value != 0)
.ok_or(ValidationError::AUnreadable)?;
let a_vtable = read_ptr(a).ok_or(ValidationError::AVtableMismatch)?;
let a_b_getter = read_ptr(
a_vtable
.checked_add(AVT_B_GETTER)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::AGetterMismatch)?;
let b = a
.checked_add(B_OFF)
.ok_or(ValidationError::AddressOverflow)?;
let b_vtable = read_ptr(b).ok_or(ValidationError::BVtableMismatch)?;
let b_dtor = read_ptr(b_vtable).ok_or(ValidationError::BDtorMismatch)?;
let b_isvalid = read_ptr(
b_vtable
.checked_add(8)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::BIsValidMismatch)?;
let b_clear = read_ptr(
b_vtable
.checked_add(16)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::BClearMismatch)?;
let b_ready = read_u8(
b.checked_add(B_READY_OFF)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::ReadyByteUnexpected)?;
let b_coll = read_ptr(
b.checked_add(B_COLL_OFF)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::CollectionUnreadable)?;
let m = read_ptr(
a.checked_add(M_CACHE_OFF)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::MSlotUnreadable)?;
Ok(RuntimeSnapshot {
a,
a_vtable,
a_b_getter,
b,
b_vtable,
b_dtor,
b_isvalid,
b_clear,
b_ready,
b_coll,
m,
})
}
fn set_failed(error: ValidationError) {
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
crate::write_log(&format!(
"SBC_HOOK: runtime validation FAILED: {error:?} -- no write\n"
));
}
/// Public entry: called from `fifa17::install`. Spawns the deferred worker if
/// OPENFUT_SBC_HOOK=1; otherwise logs "disabled" and returns (fully inert).
pub fn install() {
let armed = std::env::var("OPENFUT_SBC_HOOK")
.map(|v| v == "1")
.unwrap_or(false);
ARMED.store(armed, Ordering::Relaxed);
if !armed {
STATE.store(RuntimeState::Disabled as usize, Ordering::Relaxed);
crate::write_log("SBC_HOOK: disabled (set OPENFUT_SBC_HOOK=1 to enable)\n");
return;
}
ARM_ONLY.store(
std::env::var("OPENFUT_SBC_ARM_ONLY")
.map(|v| v == "1")
.unwrap_or(false),
Ordering::Relaxed,
);
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")
.unwrap_or(false),
Ordering::Relaxed,
);
crate::write_log("SBC_HOOK: ARMED (deferred worker spawning)\n");
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.
unsafe fn worker() {
let mut base = 0usize;
for _ in 0..600u32 {
base = resolve_cards_base();
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if base == 0 {
crate::write_log("SBC_HOOK: CardsDLL_Win64_retail.dll never loaded — giving up\n");
return;
}
CARDS_BASE.store(base, Ordering::Relaxed);
let slide = base.wrapping_sub(IMAGE_BASE);
let ctrl_ok = control_matches(base);
crate::write_log(&format!(
"SBC_HOOK: CardsDLL base={base:#x} slide={slide:#x} CONTROL={}\n",
if ctrl_ok { "OK" } else { "MISMATCH-ABORT" }
));
if !ctrl_ok {
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
return; // module map moved -> offsets untrustworthy (spec §1)
}
if !transition(RuntimeState::Disabled, RuntimeState::Resolved) {
crate::write_log("SBC_HOOK: invalid state transition to Resolved -- no write\n");
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
return;
}
// Resolve and validate A -> B, M. The vtable method checks make it substantially
// harder for a coincidental heap pointer to pass after a binary/layout mismatch.
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
validate_snapshot(base, &snapshot)?;
Ok(snapshot)
}) {
Ok(snapshot) => snapshot,
Err(error) => {
set_failed(error);
return;
}
};
if !transition(RuntimeState::Resolved, RuntimeState::Validated) {
crate::write_log("SBC_HOOK: invalid state transition to Validated -- no write\n");
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
return;
}
let a = snapshot.a;
let b = snapshot.b;
let m = snapshot.m;
let m_count = (m != 0).then(|| read_u16(m + M_COUNT_OFF)).flatten();
crate::write_log(&format!(
"SBC_HOOK: A={a:#x} B={b:#x} B+0x28(ready)={:?} B+0x08(coll)={:?} M=*(A+0x20a68)={:?} WORD[M+0x50]={:?}\n",
Some(snapshot.b_ready), opt_hex(Some(snapshot.b_coll)), opt_hex(Some(m)), m_count,
));
// Tier-0 — arm-only negative control. Write ONLY BYTE[B+0x28]=1; leave B+0x08=0 so
// isValid takes the short-circuit (spec §4). Renders the menu EMPTY (M null/empty) —
// this is the baseline, NOT the fix. One-shot.
if ARM_ONLY.load(Ordering::Relaxed) {
if DONE.swap(true, Ordering::Relaxed) {
return;
}
// Re-snapshot immediately before mutation to reduce the time-of-check/time-of-use
// window. In particular, the exact patch byte must still be 0 and B+0x08 null.
let write_snapshot = match runtime_snapshot(base).and_then(|snapshot| {
validate_snapshot(base, &snapshot)?;
if !writable_u8(snapshot.b + B_READY_OFF) {
return Err(ValidationError::ReadyByteNotWritable);
}
Ok(snapshot)
}) {
Ok(snapshot) => snapshot,
Err(error) => {
set_failed(error);
return;
}
};
crate::write_log(&format!(
"SBC_HOOK: Tier-0 arm-only -> writing BYTE[{:#x}]=1 (expect EMPTY render, no modal)\n",
write_snapshot.b + B_READY_OFF
));
core::ptr::write_volatile((write_snapshot.b + B_READY_OFF) as *mut u8, 1u8);
match read_u8(write_snapshot.b + B_READY_OFF) {
Some(1) if transition(RuntimeState::Validated, RuntimeState::Committed) => {}
_ => {
set_failed(ValidationError::ReadyByteUnexpected);
return;
}
}
crate::write_log(
"SBC_HOOK: Tier-0 arm-only DONE (open the SBC menu; ~2 placeholder tiles expected)\n",
);
return;
}
// Legacy Tier-1 gate — deliberately blocked. The fresh live exchange proves FIFA
// already owns a real response and SAX reader for /sbs/sets. The next milestone is
// passive tracing of the native response-to-deserializer dispatch, not construction
// of a reader. Cold-calling with a fabricated reader would CLEAR M and/or segfault.
if POPULATE.load(Ordering::Relaxed) {
crate::write_log(
"SBC_HOOK: legacy Tier-1 populate is BLOCKED — capture the genuine response \
and reader at the native dispatch boundary first (see client-hook plan M3/M4). \
No deser call made; fabricated readers can clear M or crash.\n",
);
}
}
fn opt_hex(o: Option<usize>) -> String {
match o {
Some(v) => format!("{v:#x}"),
None => "<unreadable>".to_string(),
}
}
/// Legacy Tier-1 scaffold. **Never call this with a fabricated reader.** The intended
/// implementation is now a guarded synchronous dispatch repair that borrows the genuine
/// response and reader from the real HTTP transaction on its native thread.
///
/// Sequence once `reader` (a primed SAX reader over canned sbs/sets JSON) exists:
/// let base = CARDS_BASE.load(Relaxed);
/// let deser: unsafe extern "system" fn(*mut u8, *mut u8) -> bool =
/// transmute(va(base, rva::DESER_SBS_SETS));
/// deser(core::ptr::null_mut(), reader); // self-locates mgr, clears+appends+finalizes+commits M
/// // then Tier-0 arm: BYTE[B+0x28]=1, leave B+0x08=0
/// // then refresh so 0x1800b5eda re-reads WORD[M+0x50]
#[allow(dead_code)]
unsafe fn populate_m(_reader: *mut u8) {
// Intentionally unimplemented: the passive trace must prove the response/reader
// ownership and exact virtual-dispatch boundary before any parser call is enabled.
unreachable!(
"populate_m requires a proven native dispatch contract; see client-hook plan M3/M4"
);
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_snapshot(base: usize) -> RuntimeSnapshot {
let a = 0x1000_0000usize;
RuntimeSnapshot {
a,
a_vtable: base + A_VTABLE_RVA,
a_b_getter: base + 0x11c1f0,
b: a + B_OFF,
b_vtable: base + B_VTABLE_RVA,
b_dtor: base + B_DTOR_RVA,
b_isvalid: base + B_ISVALID_RVA,
b_clear: base + B_CLEAR_RVA,
b_ready: B_READY_EXPECTED_BEFORE_ARM,
b_coll: 0,
m: 0,
}
}
#[test]
fn accepts_exact_runtime_identity_with_null_uninitialized_m() {
let base = 0x7fff_0000_0000usize;
assert_eq!(validate_snapshot(base, &valid_snapshot(base)), Ok(()));
}
#[test]
fn rejects_wrong_a_or_b_class_identity() {
let base = 0x7fff_0000_0000usize;
let mut snapshot = valid_snapshot(base);
snapshot.a_vtable += 8;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::AVtableMismatch)
);
let mut snapshot = valid_snapshot(base);
snapshot.b_vtable += 8;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::BVtableMismatch)
);
}
#[test]
fn rejects_changed_patch_byte_or_live_collection() {
let base = 0x7fff_0000_0000usize;
let mut snapshot = valid_snapshot(base);
snapshot.b_ready = 1;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::ReadyByteUnexpected)
);
let mut snapshot = valid_snapshot(base);
snapshot.b_coll = 0x1234_0000;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::CollectionNotNull)
);
}
#[test]
fn state_machine_is_forward_only_and_fail_closed() {
assert!(valid_transition(
RuntimeState::Disabled,
RuntimeState::Resolved
));
assert!(valid_transition(
RuntimeState::Resolved,
RuntimeState::Validated
));
assert!(valid_transition(
RuntimeState::Validated,
RuntimeState::Committed
));
assert!(valid_transition(RuntimeState::Parsed, RuntimeState::Failed));
assert!(!valid_transition(
RuntimeState::Validated,
RuntimeState::Resolved
));
assert!(!valid_transition(
RuntimeState::Failed,
RuntimeState::Resolved
));
assert!(!valid_transition(
RuntimeState::Resolved,
RuntimeState::Committed
));
}
}
+478
View File
@@ -0,0 +1,478 @@
//! Optional category-request callback tracing through its class-unique vtable.
//!
//! Unlike the entry trampolines, these probes atomically replace two aligned
//! pointer slots. The branchy callback dispatcher at 0x180154830 is never patched.
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::{
GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
GET_MODULE_HANDLE_EX_FLAG_PIN,
};
use windows_sys::Win32::System::Memory::{
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_GUARD, PAGE_NOACCESS,
PAGE_READWRITE,
};
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
const REQUEST_VTABLE_RVA: usize = 0x22e5c0;
const SLOT_88: usize = 0x88;
const SLOT_90: usize = 0x90;
const ORIGINAL_88_RVA: usize = 0x1631e0;
const ORIGINAL_90_RVA: usize = 0x154830;
const ORIGINAL_88_SIGNATURE: [u8; 16] = [
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48,
];
const ORIGINAL_90_SIGNATURE: [u8; 16] = [
0x4c, 0x8b, 0x81, 0x90, 0x00, 0x00, 0x00, 0x4d, 0x85, 0xc0, 0x74, 0x0a, 0x48, 0x81, 0xc1, 0x90,
];
type Callback88 = unsafe extern "system" fn(*mut c_void, *mut c_void);
type Callback90 = unsafe extern "system" fn(*mut c_void, *mut c_void);
static ENABLED: AtomicBool = AtomicBool::new(false);
static INSTALLED: AtomicBool = AtomicBool::new(false);
static ORIGINAL_88: AtomicUsize = AtomicUsize::new(0);
static ORIGINAL_90: AtomicUsize = AtomicUsize::new(0);
static ENTER_88: AtomicU64 = AtomicU64::new(0);
static EXIT_88: AtomicU64 = AtomicU64::new(0);
static ENTER_90: AtomicU64 = AtomicU64::new(0);
static EXIT_90: AtomicU64 = AtomicU64::new(0);
static LAST_REQUEST_88: AtomicUsize = AtomicUsize::new(0);
static LAST_ARGUMENT_88: AtomicUsize = AtomicUsize::new(0);
static LAST_THREAD_88: AtomicUsize = AtomicUsize::new(0);
static LAST_REQUEST_90: AtomicUsize = AtomicUsize::new(0);
static LAST_ARGUMENT_90: AtomicUsize = AtomicUsize::new(0);
static LAST_THREAD_90: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_90: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_98: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_A0: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_A8: AtomicUsize = AtomicUsize::new(0);
static SELECTED_90: AtomicUsize = AtomicUsize::new(0);
static OWNER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_VTABLE_88: AtomicUsize = AtomicUsize::new(0);
static CONSUMER_88: AtomicUsize = AtomicUsize::new(0);
static RESPONSE_VTABLE_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_SLOT_BEFORE_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_SLOT_AFTER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_INNER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_STATE_BEFORE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_STATE_AFTER_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_FLAGS_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_MANAGER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_MANAGER_STATE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
fn enabled(value: Option<&str>) -> bool {
matches!(value, Some("1"))
}
fn checked_va(base: usize, rva: usize) -> Option<usize> {
base.checked_add(rva)
}
fn image_range_covered(size: usize, rva: usize, length: usize) -> bool {
rva.checked_add(length)
.map(|end| end <= size)
.unwrap_or(false)
}
unsafe fn readable_range(address: usize, length: usize) -> bool {
let Some(end) = address.checked_add(length) else {
return false;
};
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
VirtualQuery(
address as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
) != 0
&& mbi.State == MEM_COMMIT
&& mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn range_in_image_allocation(base: usize, address: usize, length: usize) -> bool {
let Some(end) = address.checked_add(length) else {
return false;
};
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
VirtualQuery(
address as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
) != 0
&& mbi.AllocationBase as usize == base
&& mbi.State == MEM_COMMIT
&& mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn image_size(base: usize) -> Option<usize> {
if !readable_range(base, 0x1000) || *(base as *const u16) != 0x5a4d {
return None;
}
let pe_offset = *((base + 0x3c) as *const u32) as usize;
if pe_offset > 0xf00 {
return None;
}
let pe = base.checked_add(pe_offset)?;
if *(pe as *const u32) != 0x0000_4550 {
return None;
}
let size_field = pe.checked_add(24 + 0x38)?;
Some(*(size_field as *const u32) as usize)
}
unsafe fn signature_matches(address: usize, expected: &[u8]) -> bool {
readable_range(address, expected.len())
&& core::slice::from_raw_parts(address as *const u8, expected.len()) == expected
}
unsafe fn guarded_ptr(address: usize) -> usize {
if address & 7 == 0 && readable_range(address, 8) {
core::ptr::read_volatile(address as *const usize)
} else {
0
}
}
unsafe fn guarded_u32(address: usize) -> Option<u32> {
if readable_range(address, 4) {
Some(core::ptr::read_volatile(address as *const u32))
} else {
None
}
}
unsafe fn guarded_u8(address: usize) -> Option<u8> {
if readable_range(address, 1) {
Some(core::ptr::read_volatile(address as *const u8))
} else {
None
}
}
unsafe fn field_ptr(object: usize, offset: usize) -> usize {
object
.checked_add(offset)
.map(|address| guarded_ptr(address))
.unwrap_or(0)
}
unsafe extern "system" fn wrapper_88(request: *mut c_void, argument: *mut c_void) {
ENTER_88.fetch_add(1, Ordering::Relaxed);
LAST_REQUEST_88.store(request as usize, Ordering::Relaxed);
LAST_ARGUMENT_88.store(argument as usize, Ordering::Relaxed);
LAST_THREAD_88.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
let request_address = request as usize;
let argument_address = argument as usize;
let owner = field_ptr(request_address, 8);
let owner_vtable = guarded_ptr(owner);
let consumer = field_ptr(owner_vtable, 0x18);
let owner_slot_before = guarded_ptr(argument_address);
let response_vtable = guarded_ptr(owner_slot_before);
let owner_inner = field_ptr(owner, 8);
let owner_state_before = owner_inner
.checked_add(8)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
let owner_flags = owner_inner
.checked_add(0x0c)
.and_then(|p| guarded_u8(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
let owner_manager = field_ptr(owner_inner, 0x14d0);
let owner_manager_state = owner_manager
.checked_add(0x1dc0)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
OWNER_88.store(owner, Ordering::Relaxed);
OWNER_VTABLE_88.store(owner_vtable, Ordering::Relaxed);
CONSUMER_88.store(consumer, Ordering::Relaxed);
RESPONSE_VTABLE_88.store(response_vtable, Ordering::Relaxed);
OWNER_SLOT_BEFORE_88.store(owner_slot_before, Ordering::Relaxed);
OWNER_INNER_88.store(owner_inner, Ordering::Relaxed);
OWNER_STATE_BEFORE_88.store(owner_state_before, Ordering::Relaxed);
OWNER_FLAGS_88.store(owner_flags, Ordering::Relaxed);
OWNER_MANAGER_88.store(owner_manager, Ordering::Relaxed);
OWNER_MANAGER_STATE_88.store(owner_manager_state, Ordering::Relaxed);
let original: Callback88 = core::mem::transmute(ORIGINAL_88.load(Ordering::Acquire));
original(request, argument);
OWNER_SLOT_AFTER_88.store(guarded_ptr(argument_address), Ordering::Relaxed);
OWNER_STATE_AFTER_88.store(
owner_inner
.checked_add(8)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX),
Ordering::Relaxed,
);
EXIT_88.fetch_add(1, Ordering::Release);
}
unsafe extern "system" fn wrapper_90(request: *mut c_void, argument: *mut c_void) {
ENTER_90.fetch_add(1, Ordering::Relaxed);
LAST_REQUEST_90.store(request as usize, Ordering::Relaxed);
LAST_ARGUMENT_90.store(argument as usize, Ordering::Relaxed);
LAST_THREAD_90.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
let request_address = request as usize;
let callback_90 = field_ptr(request_address, 0x90);
let callback_98 = field_ptr(request_address, 0x98);
let callback_a0 = field_ptr(request_address, 0xa0);
let callback_a8 = field_ptr(request_address, 0xa8);
CALLBACK_90.store(callback_90, Ordering::Relaxed);
CALLBACK_98.store(callback_98, Ordering::Relaxed);
CALLBACK_A0.store(callback_a0, Ordering::Relaxed);
CALLBACK_A8.store(callback_a8, Ordering::Relaxed);
SELECTED_90.store(
if callback_90 != 0 {
callback_90
} else {
callback_a0
},
Ordering::Relaxed,
);
let original: Callback90 = core::mem::transmute(ORIGINAL_90.load(Ordering::Acquire));
original(request, argument);
EXIT_90.fetch_add(1, Ordering::Release);
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SwapOutcome {
Installed,
CleanFailure,
DegradedFirstSlotActive,
DegradedProtection,
}
unsafe fn install_slots(base: usize) -> SwapOutcome {
let Some(vtable) = checked_va(base, REQUEST_VTABLE_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(original_88) = checked_va(base, ORIGINAL_88_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(original_90) = checked_va(base, ORIGINAL_90_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(slot_88) = checked_va(vtable, SLOT_88) else {
return SwapOutcome::CleanFailure;
};
let Some(slot_90) = checked_va(vtable, SLOT_90) else {
return SwapOutcome::CleanFailure;
};
let Some(size) = image_size(base) else {
return SwapOutcome::CleanFailure;
};
if !image_range_covered(size, REQUEST_VTABLE_RVA, SLOT_90 + 8)
|| !image_range_covered(size, ORIGINAL_88_RVA, ORIGINAL_88_SIGNATURE.len())
|| !image_range_covered(size, ORIGINAL_90_RVA, ORIGINAL_90_SIGNATURE.len())
|| slot_88 & 7 != 0
|| slot_90 & 7 != 0
|| !crate::sbc_trace::validate_cards_build(base)
|| !range_in_image_allocation(base, vtable, SLOT_90 + 8)
|| !range_in_image_allocation(base, original_88, ORIGINAL_88_SIGNATURE.len())
|| !range_in_image_allocation(base, original_90, ORIGINAL_90_SIGNATURE.len())
|| !signature_matches(original_88, &ORIGINAL_88_SIGNATURE)
|| !signature_matches(original_90, &ORIGINAL_90_SIGNATURE)
|| (slot_88 as *const AtomicUsize)
.as_ref()
.unwrap()
.load(Ordering::Acquire)
!= original_88
|| (slot_90 as *const AtomicUsize)
.as_ref()
.unwrap()
.load(Ordering::Acquire)
!= original_90
{
return SwapOutcome::CleanFailure;
}
let mut pinned = core::ptr::null_mut();
if GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
vtable as *const u8,
&mut pinned,
) == 0
|| pinned as usize != base
{
return SwapOutcome::CleanFailure;
}
ORIGINAL_88.store(original_88, Ordering::Release);
ORIGINAL_90.store(original_90, Ordering::Release);
// Both slots share the same vtable page. Keep it writable only across the two
// compare/exchanges and possible rollback.
let mut old = 0u32;
if VirtualProtect(slot_88 as _, 16, PAGE_READWRITE, &mut old) == 0 {
return SwapOutcome::CleanFailure;
}
let atom_88 = &*(slot_88 as *const AtomicUsize);
let atom_90 = &*(slot_90 as *const AtomicUsize);
let first = atom_88.compare_exchange(
original_88,
wrapper_88 as *const () as usize,
Ordering::AcqRel,
Ordering::Acquire,
);
let outcome = if first.is_err() {
SwapOutcome::CleanFailure
} else if atom_90
.compare_exchange(
original_90,
wrapper_90 as *const () as usize,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
SwapOutcome::Installed
} else if atom_88
.compare_exchange(
wrapper_88 as *const () as usize,
original_88,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
SwapOutcome::CleanFailure
} else {
SwapOutcome::DegradedFirstSlotActive
};
let mut ignored = 0u32;
if VirtualProtect(slot_88 as _, 16, old, &mut ignored) == 0 {
return if outcome == SwapOutcome::DegradedFirstSlotActive {
outcome
} else {
SwapOutcome::DegradedProtection
};
}
outcome
}
unsafe fn worker() {
for _ in 0..700u32 {
if crate::sbc_trace::code_patch_installers_ready() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if !crate::sbc_trace::code_patch_installers_ready() {
crate::write_log("SBC_REQUEST_TRACE: code-patch readiness timeout; inactive\n");
return;
}
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
match if base == 0 { SwapOutcome::CleanFailure } else { install_slots(base) } {
SwapOutcome::Installed => {
INSTALLED.store(true, Ordering::Release);
crate::write_log("SBC_REQUEST_TRACE: category request vtable slots +0x88/+0x90 installed\n");
let mut seen_88 = 0u64;
let mut seen_90 = 0u64;
let mut reports = 0u8;
while reports < 32 {
std::thread::sleep(std::time::Duration::from_millis(250));
let count_88 = ENTER_88.load(Ordering::Acquire);
let count_90 = ENTER_90.load(Ordering::Acquire);
if count_88 != seen_88 || count_90 != seen_90 {
crate::write_log(&format!(
"SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x} inner={:#x} state={}->{} flags={:#x} manager={:#x} manager_state={}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n",
count_88,
EXIT_88.load(Ordering::Acquire),
LAST_REQUEST_88.load(Ordering::Relaxed),
LAST_ARGUMENT_88.load(Ordering::Relaxed),
LAST_THREAD_88.load(Ordering::Relaxed),
OWNER_88.load(Ordering::Relaxed),
OWNER_VTABLE_88.load(Ordering::Relaxed),
CONSUMER_88.load(Ordering::Relaxed),
RESPONSE_VTABLE_88.load(Ordering::Relaxed),
OWNER_SLOT_BEFORE_88.load(Ordering::Relaxed),
OWNER_SLOT_AFTER_88.load(Ordering::Relaxed),
OWNER_INNER_88.load(Ordering::Relaxed),
OWNER_STATE_BEFORE_88.load(Ordering::Relaxed),
OWNER_STATE_AFTER_88.load(Ordering::Relaxed),
OWNER_FLAGS_88.load(Ordering::Relaxed),
OWNER_MANAGER_88.load(Ordering::Relaxed),
OWNER_MANAGER_STATE_88.load(Ordering::Relaxed),
count_90,
EXIT_90.load(Ordering::Acquire),
LAST_REQUEST_90.load(Ordering::Relaxed),
LAST_ARGUMENT_90.load(Ordering::Relaxed),
LAST_THREAD_90.load(Ordering::Relaxed),
CALLBACK_90.load(Ordering::Relaxed),
CALLBACK_98.load(Ordering::Relaxed),
CALLBACK_A0.load(Ordering::Relaxed),
CALLBACK_A8.load(Ordering::Relaxed),
SELECTED_90.load(Ordering::Relaxed),
));
seen_88 = count_88;
seen_90 = count_90;
reports += 1;
}
}
crate::write_log("SBC_REQUEST_TRACE: report cap reached; vtable probes remain passive\n");
}
SwapOutcome::CleanFailure => crate::write_log("SBC_REQUEST_TRACE: clean install failure; inactive\n"),
SwapOutcome::DegradedFirstSlotActive => crate::write_log(
"SBC_REQUEST_TRACE: DEGRADED slot +0x88 may remain active; terminate game now\n",
),
SwapOutcome::DegradedProtection => crate::write_log(
"SBC_REQUEST_TRACE: DEGRADED vtable page protection restore failed; terminate game now\n",
),
}
}
pub(crate) fn install() {
let armed = enabled(std::env::var("OPENFUT_SBC_REQUEST_TRACE").ok().as_deref());
ENABLED.store(armed, Ordering::Release);
if !armed {
crate::write_log("SBC_REQUEST_TRACE: disabled\n");
return;
}
crate::write_log("SBC_REQUEST_TRACE: requested; deferred install starting\n");
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gate_is_exact() {
assert!(!enabled(None));
assert!(!enabled(Some("true")));
assert!(enabled(Some("1")));
}
#[test]
fn slots_are_aligned_and_class_local() {
assert_eq!((REQUEST_VTABLE_RVA + SLOT_88) & 7, 0);
assert_eq!((REQUEST_VTABLE_RVA + SLOT_90) & 7, 0);
assert_eq!(SLOT_90 - SLOT_88, 8);
}
#[test]
fn image_coverage_is_checked_and_overflow_safe() {
assert!(image_range_covered(
0x230000,
REQUEST_VTABLE_RVA,
SLOT_90 + 8
));
assert!(!image_range_covered(
REQUEST_VTABLE_RVA + SLOT_90,
REQUEST_VTABLE_RVA,
SLOT_90 + 8
));
assert!(!image_range_covered(usize::MAX, usize::MAX, 8));
}
}
File diff suppressed because it is too large Load Diff
-45
View File
@@ -1,45 +0,0 @@
//! The single, process-wide resolved OpenFUT destination.
//!
//! All three interception layers — `getaddrinfo`, `connect`, and `ConnectEx` —
//! read the destination from HERE. There is no per-hook redirect state. The
//! value is set exactly once during DLL init (after `openfut.cfg` is parsed and
//! the host resolved) and is never mutated afterward.
//!
//! If configuration was missing/invalid, this is never populated, and every
//! hook leaves traffic untouched (no loopback fallback).
use std::net::Ipv4Addr;
use std::sync::OnceLock;
use openfut_common::{sin_addr_from_ipv4, sin_port_nbo, ResolvedServer};
static SERVER: OnceLock<ResolvedServer> = OnceLock::new();
/// Install the resolved destination. Called once from DLL init. Ignores
/// subsequent calls (OnceLock semantics).
pub fn set(resolved: ResolvedServer) {
let _ = SERVER.set(resolved);
}
/// The resolved destination, if configuration succeeded.
pub fn get() -> Option<ResolvedServer> {
SERVER.get().copied()
}
/// The resolved redirect IPv4, if configured.
pub fn redirect_ip() -> Option<Ipv4Addr> {
SERVER.get().map(|s| s.redirect_ip)
}
/// `sockaddr_in.sin_addr` value (native-endian u32) for the configured server.
pub fn sin_addr() -> Option<u32> {
SERVER.get().map(|s| sin_addr_from_ipv4(s.redirect_ip))
}
/// Given an EA *source* port (network byte order, as seen in `sockaddr_in`),
/// return the OpenFUT *destination* port in network byte order — or `None` if
/// this port isn't intercepted or no server is configured.
pub fn dest_port_nbo_from_source_nbo(source_port_nbo: u16) -> Option<u16> {
let s = SERVER.get()?;
let source_host = u16::from_be(source_port_nbo);
s.ports.map_source_port(source_host).map(sin_port_nbo)
}
+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));
}
}
+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"
));
}
}
}
+117
View File
@@ -0,0 +1,117 @@
//! Transparent forwarding for the system `version.dll` API.
//!
//! The hook is deployed under the `version.dll` filename, so every VERSION API
//! import must continue to behave exactly as it would without OpenFUT. Resolve
//! the genuine system DLL once during process attach, then tail-jump from each
//! exported stub. A tail jump preserves the caller's complete Windows x64 ABI
//! state, including stack arguments whose signatures differ between exports.
use std::sync::atomic::{AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
const EXPORT_COUNT: usize = 16;
/// Keep this list in the same order as the generated stubs below.
const EXPORTS: [&[u8]; EXPORT_COUNT] = [
b"GetFileVersionInfoA\0",
b"GetFileVersionInfoExA\0",
b"GetFileVersionInfoExW\0",
b"GetFileVersionInfoSizeA\0",
b"GetFileVersionInfoSizeExA\0",
b"GetFileVersionInfoSizeExW\0",
b"GetFileVersionInfoSizeW\0",
b"GetFileVersionInfoW\0",
b"VerFindFileA\0",
b"VerFindFileW\0",
b"VerInstallFileA\0",
b"VerInstallFileW\0",
b"VerLanguageNameA\0",
b"VerLanguageNameW\0",
b"VerQueryValueA\0",
b"VerQueryValueW\0",
];
/// Addresses in the genuine system DLL. Atomic storage gives the assembly
/// stubs stable, directly addressable pointer-sized slots without `static mut`.
static REAL: [AtomicUsize; EXPORT_COUNT] = [const { AtomicUsize::new(0) }; EXPORT_COUNT];
macro_rules! proxy_stub {
($index:literal, $name:ident) => {
#[unsafe(no_mangle)]
#[unsafe(naked)]
pub unsafe extern "system" fn $name() {
core::arch::naked_asm!(
"jmp qword ptr [rip + {base} + {offset}]",
base = sym REAL,
offset = const $index * size_of::<usize>(),
);
}
};
}
proxy_stub!(0, GetFileVersionInfoA);
proxy_stub!(1, GetFileVersionInfoExA);
proxy_stub!(2, GetFileVersionInfoExW);
proxy_stub!(3, GetFileVersionInfoSizeA);
proxy_stub!(4, GetFileVersionInfoSizeExA);
proxy_stub!(5, GetFileVersionInfoSizeExW);
proxy_stub!(6, GetFileVersionInfoSizeW);
proxy_stub!(7, GetFileVersionInfoW);
proxy_stub!(8, VerFindFileA);
proxy_stub!(9, VerFindFileW);
proxy_stub!(10, VerInstallFileA);
proxy_stub!(11, VerInstallFileW);
proxy_stub!(12, VerLanguageNameA);
proxy_stub!(13, VerLanguageNameW);
proxy_stub!(14, VerQueryValueA);
proxy_stub!(15, VerQueryValueW);
/// Resolve forwarding targets before returning from `DLL_PROCESS_ATTACH`.
/// Calls into our exports may happen as soon as the loader releases its lock,
/// so deferring this operation to the hook worker would create a race.
pub(crate) unsafe fn resolve() -> bool {
// Loading by absolute path prevents this proxy from recursively loading
// itself. Proton/Wine exposes the Windows system directory at this path.
let path: Vec<u16> = "C:\\Windows\\System32\\version.dll\0"
.encode_utf16()
.collect();
let module = LoadLibraryW(path.as_ptr());
if module.is_null() {
crate::write_log("version_proxy: FATAL: system version.dll load failed\n");
return false;
}
let mut missing = 0;
for (slot, name) in REAL.iter().zip(EXPORTS) {
let address = GetProcAddress(module, name.as_ptr()).map_or(0, |proc| proc as usize);
slot.store(address, Ordering::Release);
if address == 0 {
missing += 1;
}
}
if missing == 0 {
crate::write_log("version_proxy: forwarded all 16 exports\n");
true
} else {
crate::write_log(&format!(
"version_proxy: FATAL: {missing}/16 system exports missing\n"
));
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn export_table_is_complete_and_nul_terminated() {
assert_eq!(EXPORTS.len(), EXPORT_COUNT);
assert!(EXPORTS.iter().all(|name| name.last() == Some(&0)));
assert!(EXPORTS
.iter()
.all(|name| !name[..name.len() - 1].contains(&0)));
}
}
+18
View File
@@ -0,0 +1,18 @@
LIBRARY version
EXPORTS
GetFileVersionInfoA
GetFileVersionInfoExA
GetFileVersionInfoExW
GetFileVersionInfoSizeA
GetFileVersionInfoSizeExA
GetFileVersionInfoSizeExW
GetFileVersionInfoSizeW
GetFileVersionInfoW
VerFindFileA
VerFindFileW
VerInstallFileA
VerInstallFileW
VerLanguageNameA
VerLanguageNameW
VerQueryValueA
VerQueryValueW
-177
View File
@@ -1,177 +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);
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountSyncRequest<'a> {
persona_id: u64,
persona_name: &'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, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountSummary {
pub persona_id: u64,
pub persona_name: String,
pub level: u32,
pub experience: u32,
pub account_funds: 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 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(&AccountSyncRequest {
persona_id: config.fut_persona_id,
persona_name: config.fut_persona_name.trim(),
level: config.fut_account_level,
experience: config.fut_account_experience,
experience_max: config.fut_account_experience_max,
account_funds: config.fut_account_funds,
account_funds_cap: config.fut_account_funds_cap,
})
.map_err(|error| format!("cannot encode account sync request: {error}"))?;
let 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 body = &response[separator + 4..];
if !(200..300).contains(&status) {
let detail = String::from_utf8_lossy(body);
return Err(format!(
"account server rejected sync (HTTP {status}): {detail}"
));
}
let envelope: AccountSyncResult = serde_json::from_slice(body)
.map_err(|error| format!("account server returned invalid JSON: {error}"))?;
if envelope.account.persona_id != config.fut_persona_id {
return Err(format!(
"account server selected persona {} instead of {}",
envelope.account.persona_id, config.fut_persona_id
));
}
Ok(envelope.account)
}
#[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();
}
}
+321 -807
View File
File diff suppressed because it is too large Load Diff
-239
View File
@@ -1,239 +0,0 @@
//! One-click client arming — the GUI equivalent of `client_arm.sh`, driven by
//! [`LauncherConfig`] so it repairs exactly what [`crate::preflight`] checks.
//!
//! Everything the game reaches by a routable address is redirected to the
//! OpenFUT server; two things are inherently local and are NOT touched here (they
//! are managed as child processes, see [`crate::local_services`]): the LSX/Origin
//! emulator on loopback `:4216` and `autopatch`.
//!
//! The three privileged steps run in ONE elevated batch (a single `pkexec`
//! prompt), mirroring the volatile state `client_arm.sh` set by hand:
//!
//! 1. `kernel.yama.ptrace_scope=0` — so `autopatch` can write FIFA's `/proc/PID/mem`.
//! 2. DNAT EA's hardcoded redirector IP → `server:redirector_port` (+ MASQUERADE
//! on the reply path, required for a DNAT to a remote host).
//! 3. Point each dead EA hostname at the server in `/etc/hosts`.
//!
//! All of it is idempotent: the DNAT deletes any prior copy before adding, and
//! every `/etc/hosts` line for a managed hostname is removed first — including a
//! foreign single-machine-era `127.0.0.1 easw.easports.com` shadow that
//! `client_arm.sh` could not remove, because it only deleted its own `# openfut`
//! lines and glibc returns the FIRST match.
use crate::config::LauncherConfig;
/// Accept only hostname/IP characters. These values come from config fields that
/// are ever only IPs or hostnames, so a surprising character is a bug — reject it
/// rather than try to escape it into an elevated shell command.
fn safe_host(s: &str) -> anyhow::Result<&str> {
let t = s.trim();
if t.is_empty() {
anyhow::bail!("empty host/address");
}
if t.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b':' | b'-' | b'_'))
{
Ok(t)
} else {
anyhow::bail!("refusing to arm with an unexpected character in {t:?}");
}
}
/// Build the privileged arming script. Pure and unit-tested; the effectful part
/// ([`arm`]) only validates config and hands this to the elevated runner.
pub(crate) fn arming_script(
server: &str,
redirector_port: u16,
ea_ip: &str,
hostnames: &[String],
) -> anyhow::Result<String> {
let server = safe_host(server)?;
let ea_ip = safe_host(ea_ip)?;
let mut s = String::from("set -eu\n");
// 1) ptrace_scope for autopatch's /proc/PID/mem write.
s.push_str("sysctl -q kernel.yama.ptrace_scope=0\n");
// 2) DNAT EA's hardcoded redirector IP to the server; SNAT the redirected
// flow (a DNAT from OUTPUT to a remote host needs a matching MASQUERADE or
// the server's replies won't match the game's conntrack entry). Both are
// delete-then-add so re-running and IP changes stay clean.
s.push_str(&format!(
"while iptables -t nat -D OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port} 2>/dev/null; do :; done\n\
iptables -t nat -A OUTPUT -p tcp -d {ea_ip} -j DNAT --to-destination {server}:{redirector_port}\n\
while iptables -t nat -D POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE 2>/dev/null; do :; done\n\
iptables -t nat -A POSTROUTING -p tcp -d {server} --dport {redirector_port} -j MASQUERADE\n"
));
// 3) Every dead EA hostname resolves to the server. Delete ALL existing lines
// listing the name (foreign shadow included) BEFORE writing ours, so the
// first-match-wins resolution can never land on a stale loopback line.
for host in hostnames {
let host = safe_host(host)?;
let re = host.replace('.', "\\.");
s.push_str(&format!(
"sed -ri '/[[:space:]]{re}([[:space:]]|$)/d' /etc/hosts\n\
printf '%s\\t%s\\t# openfut\\n' '{server}' '{host}' >> /etc/hosts\n"
));
}
Ok(s)
}
/// Human-readable list of what [`arm`] changed, in the order the script applies
/// it. Logged by the UI so the user sees exactly what was set — not just that
/// "something" ran under `pkexec`.
pub(crate) fn arming_summary(
server: &str,
redirector_port: u16,
ea_ip: &str,
hostnames: &[String],
) -> Vec<String> {
let mut out = vec![
"kernel.yama.ptrace_scope = 0 (autopatch can attach)".to_string(),
format!("DNAT {ea_ip} -> {server}:{redirector_port} (+ MASQUERADE reply path)"),
];
for host in hostnames {
out.push(format!("hosts: {host} -> {server}"));
}
out
}
/// Arm the client from config, under one elevated prompt. Requires the same
/// fields preflight reads; a missing one is a clear error, never a silent
/// loopback fallback. Returns the applied changes for the UI to surface.
pub fn arm(cfg: &LauncherConfig) -> anyhow::Result<Vec<String>> {
let server = cfg.openfut_server_host.trim();
if server.is_empty() {
anyhow::bail!("Set the OpenFUT server host in the Config tab before arming.");
}
let ea_ip = cfg.ea_redirect_probe_ip.trim();
if ea_ip.is_empty() {
anyhow::bail!("Set the EA redirector IP (Config tab) before arming.");
}
if cfg.ea_hostnames.is_empty() {
anyhow::bail!("Add at least one EA hostname (e.g. easw.easports.com) in the Config tab before arming.");
}
let redirector_port = cfg.openfut_blaze_redirector_port;
let script = arming_script(server, redirector_port, ea_ip, &cfg.ea_hostnames)?;
crate::setup::run_elevated(&script)?;
Ok(arming_summary(
server,
redirector_port,
ea_ip,
&cfg.ea_hostnames,
))
}
#[cfg(test)]
mod tests {
use super::*;
fn script() -> String {
arming_script(
"10.10.0.120",
42127,
"159.153.51.20",
&["easw.easports.com".to_string()],
)
.unwrap()
}
#[test]
fn sets_ptrace_scope_zero() {
assert!(script().contains("sysctl -q kernel.yama.ptrace_scope=0"));
}
#[test]
fn dnats_ea_ip_to_server_and_masquerades() {
let s = script();
assert!(s.contains(
"iptables -t nat -A OUTPUT -p tcp -d 159.153.51.20 -j DNAT --to-destination 10.10.0.120:42127"
));
assert!(s.contains(
"iptables -t nat -A POSTROUTING -p tcp -d 10.10.0.120 --dport 42127 -j MASQUERADE"
));
}
#[test]
fn dnat_is_delete_then_add_for_idempotence() {
let s = script();
// The delete loop precedes the add, so re-arming never stacks duplicates.
let del = s.find("-D OUTPUT").unwrap();
let add = s.find("-A OUTPUT").unwrap();
assert!(del < add, "delete must run before add");
}
#[test]
fn removes_shadowing_hosts_line_before_writing_ours() {
let s = script();
// Deletes any existing easw.easports.com line (foreign shadow included)…
assert!(
s.contains("sed -ri '/[[:space:]]easw\\.easports\\.com([[:space:]]|$)/d' /etc/hosts")
);
// …then appends the OpenFUT-tagged mapping to the server.
assert!(s.contains(
"printf '%s\\t%s\\t# openfut\\n' '10.10.0.120' 'easw.easports.com' >> /etc/hosts"
));
let del = s.find("sed -ri").unwrap();
let add = s.find("printf").unwrap();
assert!(del < add, "shadow removal must precede our line");
}
#[test]
fn multiple_hostnames_each_get_a_mapping() {
let s = arming_script(
"10.10.0.120",
42127,
"159.153.51.20",
&["easw.easports.com".into(), "utas.fut.ea.com".into()],
)
.unwrap();
assert!(s.contains("'easw.easports.com' >> /etc/hosts"));
assert!(s.contains("'utas.fut.ea.com' >> /etc/hosts"));
}
#[test]
fn rejects_shell_metacharacters_in_config() {
assert!(arming_script("10.0.0.1; rm -rf /", 42127, "159.153.51.20", &[]).is_err());
assert!(arming_script("10.0.0.1", 42127, "$(evil)", &[]).is_err());
assert!(
arming_script("10.0.0.1", 42127, "159.153.51.20", &["a b`c`".into()]).is_err(),
"a hostname with a backtick is rejected"
);
}
#[test]
fn arm_requires_server_ea_ip_and_hostname() {
let mut c = LauncherConfig::default();
assert!(arm(&c).unwrap_err().to_string().contains("server host"));
c.openfut_server_host = "10.10.0.120".into();
assert!(arm(&c)
.unwrap_err()
.to_string()
.contains("EA redirector IP"));
c.ea_redirect_probe_ip = "159.153.51.20".into();
assert!(arm(&c).unwrap_err().to_string().contains("EA hostname"));
}
#[test]
fn summary_lists_ptrace_dnat_and_each_host() {
let s = arming_summary(
"10.10.0.120",
42127,
"159.153.51.20",
&["easw.easports.com".into(), "utas.fut.ea.com".into()],
);
assert!(s.iter().any(|l| l.contains("ptrace_scope = 0")));
assert!(s
.iter()
.any(|l| l.contains("DNAT 159.153.51.20 -> 10.10.0.120:42127")));
assert!(s
.iter()
.any(|l| l == "hosts: easw.easports.com -> 10.10.0.120"));
assert!(s
.iter()
.any(|l| l == "hosts: utas.fut.ea.com -> 10.10.0.120"));
}
}
+18 -605
View File
@@ -1,101 +1,4 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// One `dosdevices` entry to create inside the Wine prefix before launching.
/// `link` is relative to the prefix (e.g. `dosdevices/w:`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrefixLink {
pub link: String,
pub target: String,
}
/// A DRM licence file the game refuses to start without, and the executable
/// that recreates it. A crashed launch deletes the licence, so this is a
/// per-launch precondition rather than a one-time setup step.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LicenseCheck {
/// Absolute, or relative to the Wine prefix.
pub path: String,
/// Executable run through the profile's runner to regenerate it.
pub generator: String,
#[serde(default = "default_license_timeout")]
pub timeout_secs: u64,
}
/// Everything needed to start one game, as data.
///
/// This is what keeps the launcher game-independent: FIFA 17's runner, prefix,
/// executable, `w:` drive and licence id live here in the user's config, never
/// in launcher code. An unconfigured profile means "fall back to
/// `game_launch_command`", so upgrading cannot break a working setup.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GameProfile {
/// Program that starts the game (e.g. `umu-run`). Empty = profile unused.
#[serde(default)]
pub runner: String,
/// Argument passed to the runner (e.g. `FIFA17.exe`).
#[serde(default)]
pub executable: String,
/// Working directory the runner is started from.
#[serde(default)]
pub game_dir: String,
/// `WINEPREFIX` for the game. Exported automatically when set.
#[serde(default)]
pub wine_prefix: String,
/// Extra environment for the runner (`GAMEID`, `PROTONPATH`, …).
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub prefix_links: Vec<PrefixLink>,
#[serde(default)]
pub license: Option<LicenseCheck>,
}
impl GameProfile {
/// Whether this profile is filled in enough to launch from.
pub fn configured(&self) -> bool {
!self.runner.trim().is_empty()
&& !self.executable.trim().is_empty()
&& !self.game_dir.trim().is_empty()
}
/// Reject a half-filled profile rather than launching something surprising.
pub fn validate(&self) -> Result<(), String> {
if self.runner.trim().is_empty() {
return Err("Game profile has no runner (e.g. umu-run).".into());
}
if self.executable.trim().is_empty() {
return Err("Game profile has no executable.".into());
}
if self.game_dir.trim().is_empty() {
return Err("Game profile has no game directory.".into());
}
if !self.prefix_links.is_empty() && self.wine_prefix.trim().is_empty() {
return Err("Game profile defines prefix links but no wine_prefix.".into());
}
for l in &self.prefix_links {
if l.link.trim().is_empty() || l.target.trim().is_empty() {
return Err("Game profile has a prefix link with an empty link or target.".into());
}
if std::path::Path::new(&l.link).is_absolute() {
return Err(format!(
"Prefix link {:?} must be relative to the Wine prefix.",
l.link
));
}
}
if let Some(lic) = &self.license {
if lic.path.trim().is_empty() || lic.generator.trim().is_empty() {
return Err("Game profile licence needs both a path and a generator.".into());
}
}
Ok(())
}
}
fn default_license_timeout() -> u64 {
60
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LauncherConfig {
@@ -112,104 +15,8 @@ pub struct LauncherConfig {
pub hook_dll_path: String,
/// FIFA 23 game folder inside the Proton prefix (where the DLL is deployed).
pub fifa_game_dir: String,
/// The OpenFUT server FIFA's EA traffic is redirected to. IPv4 literal or
/// hostname. Empty means "not configured" — launching is blocked until set.
/// There is intentionally NO loopback default.
#[serde(default, alias = "hook_redirect_ip")]
pub openfut_server_host: String,
/// OpenFUT destination port for intercepted EA :443 (bridge HTTPS).
#[serde(default = "default_https_port")]
pub openfut_https_port: u16,
/// OpenFUT destination port for intercepted EA :10041 (Blaze redirector).
#[serde(default = "default_blaze_redirector_port")]
pub openfut_blaze_redirector_port: u16,
/// OpenFUT destination port for intercepted EA :42127 (Blaze main).
#[serde(default = "default_blaze_main_port")]
pub openfut_blaze_main_port: u16,
/// Plain HTTP UTAS/control-plane port used to select the active account.
#[serde(default = "default_account_sync_port")]
pub openfut_account_sync_port: u16,
/// EA/Origin persona selected for this local single-player profile.
#[serde(default)]
pub fut_persona_id: u64,
#[serde(default)]
pub fut_persona_name: String,
/// EASFC/POW account-bar state (separate from FUT club coins).
#[serde(default = "default_account_level")]
pub fut_account_level: u32,
#[serde(default)]
pub fut_account_experience: u32,
#[serde(default = "default_account_experience_max")]
pub fut_account_experience_max: u32,
#[serde(default)]
pub fut_account_funds: u32,
#[serde(default = "default_account_funds_cap")]
pub fut_account_funds_cap: u32,
/// Shell command the launcher runs to start the game. Run via `sh -c`, from
/// `game_launch_workdir` if set. Empty means "not configured" — the Launch
/// Game button is disabled until the user provides one. This keeps the
/// launcher agnostic to Steam vs umu-run vs a custom script.
#[serde(default)]
pub game_launch_command: String,
/// Optional working directory for `game_launch_command`. Empty = inherit.
#[serde(default)]
pub game_launch_workdir: String,
/// Native launch definition. When [`GameProfile::configured`], the launcher
/// starts the game itself and `game_launch_command` is not used; the command
/// remains as a fallback so an existing setup keeps working after upgrade.
#[serde(default)]
pub game_profile: GameProfile,
// ── Pre-launch checks (see `preflight`) ─────────────────────────────────
/// EA's hardcoded redirector IP, probed to confirm the client-side DNAT is
/// armed. Empty = the check is skipped. A game fact, so it is configuration.
#[serde(default)]
pub ea_redirect_probe_ip: String,
/// Dead EA hostnames that must resolve to `openfut_server_host`.
#[serde(default)]
pub ea_hostnames: Vec<String>,
// ── FIFA 17 local companion services (client-side, run on THIS machine) ──
// FIFA 17's FUT flow needs two pieces that are inherently local to the game
// box and cannot move to the server: the LSX Origin emulator (the game dials
// it on the hardcoded loopback 127.0.0.1:4216) and autopatch (patches
// FIFA17.exe process memory for ProtoSSL cert-verify). The launcher manages
// both as child processes. The heavy responders (Blaze/UTAS/roster/POW) run
// in the server container; these two stay here.
/// Directory holding the FIFA 17 Python responders (fifa17-recon `tools/`).
/// Empty means the local-services feature is unconfigured and its controls
/// stay disabled.
#[serde(default)]
pub fifa17_tools_dir: String,
/// Python interpreter used to run the local companion services.
#[serde(default = "default_python")]
pub fifa17_python: String,
}
fn default_python() -> String {
"python3".to_string()
}
fn default_https_port() -> u16 {
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 {
@@ -250,32 +57,7 @@ impl Default for LauncherConfig {
.unwrap_or_default()
.to_string_lossy()
.into(),
// No server configured by default — the user MUST enter one. There
// is deliberately no loopback/localhost default.
openfut_server_host: String::new(),
openfut_https_port: default_https_port(),
openfut_blaze_redirector_port: default_blaze_redirector_port(),
openfut_blaze_main_port: default_blaze_main_port(),
openfut_account_sync_port: default_account_sync_port(),
fut_persona_id: 0,
fut_persona_name: String::new(),
fut_account_level: default_account_level(),
fut_account_experience: 0,
fut_account_experience_max: default_account_experience_max(),
fut_account_funds: 0,
fut_account_funds_cap: default_account_funds_cap(),
game_launch_command: String::new(),
game_launch_workdir: String::new(),
// Empty by default, exactly like the server host: the launcher must
// never invent a path to somebody's game install.
game_profile: GameProfile::default(),
ea_redirect_probe_ip: String::new(),
ea_hostnames: Vec::new(),
fifa17_tools_dir: base
.join("fifa17-recon/tools")
.to_string_lossy()
.into(),
fifa17_python: default_python(),
hook_redirect_ip: "127.0.0.1".into(),
}
}
}
@@ -306,391 +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()),
]
}
/// 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 the client-local FIFA 17 service configuration. Filesystem
/// existence is checked by the process launcher immediately before spawn;
/// this ensures required user configuration is never silently invented.
pub fn validate_local_services(&self) -> Result<(), String> {
if self.fifa17_tools_dir.trim().is_empty() {
return Err("No FIFA 17 tools dir configured. Set it in the Config tab.".into());
}
if self.fifa17_python.trim().is_empty() {
return Err("No Python interpreter configured. Set it in the Config tab.".into());
}
Ok(())
}
/// Validate every configuration value required by the one-button FIFA 17
/// launch path. Runtime state such as hook deployment is checked by the UI.
pub fn validate_launch_config(&self) -> Result<(), String> {
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 the Config tab."
.into(),
);
}
self.validate_local_services()
}
pub fn validate_account(&self) -> Result<(), String> {
if self.fut_persona_id == 0 {
return Err("No EA persona ID configured. Set the account in the Config tab.".into());
}
if self.fut_persona_name.trim().is_empty() {
return Err("No EA persona name configured. Set the account in the Config tab.".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(())
}
/// 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 local_services_require_tools_dir_and_python() {
let mut c = LauncherConfig::default();
c.fifa17_tools_dir.clear();
assert!(c
.validate_local_services()
.unwrap_err()
.contains("tools dir"));
c.fifa17_tools_dir = "/tmp/fifa17-tools".into();
c.fifa17_python.clear();
assert!(c.validate_local_services().unwrap_err().contains("Python"));
}
#[test]
fn local_services_accept_explicit_configuration() {
let c = LauncherConfig {
fifa17_tools_dir: "/tmp/fifa17-tools".into(),
fifa17_python: "/usr/bin/python3".into(),
..LauncherConfig::default()
};
assert!(c.validate_local_services().is_ok());
}
#[test]
fn launch_config_requires_server_local_services_and_command() {
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();
c.fifa17_tools_dir.clear();
assert!(c
.validate_launch_config()
.unwrap_err()
.contains("tools dir"));
c.fifa17_tools_dir = "/home/alex/Documents/OpenFUT/fifa17-recon/tools".into();
c.fifa17_python = "/usr/bin/python3".into();
assert!(c.validate_launch_config().is_ok());
}
/// 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(),
fifa17_tools_dir: "/tmp/tools".into(),
fifa17_python: "/usr/bin/python3".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(),
fifa17_tools_dir: "/tmp/tools".into(),
fifa17_python: "/usr/bin/python3".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();
assert!(c.validate_account().unwrap_err().contains("persona ID"));
c.fut_persona_id = 12345678;
assert!(c.validate_account().unwrap_err().contains("persona name"));
c.fut_persona_name = "TEST_USER".into();
assert!(c.validate_account().is_ok());
c.fut_account_experience = 1001;
assert!(c.validate_account().unwrap_err().contains("XP"));
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();
}
}
-449
View File
@@ -1,449 +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 std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
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().unwrap().push(msg.into());
}
/// Prepare the prefix, satisfy the licence precondition, and start the game.
///
/// Returns once the game process has been spawned; its output continues to
/// stream into `log` on background threads.
pub fn launch(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
profile.validate().map_err(anyhow::Error::msg)?;
let game_dir = PathBuf::from(&profile.game_dir);
if !game_dir.is_dir() {
anyhow::bail!("game_dir does not exist: {}", game_dir.display());
}
prepare_prefix(profile, log)?;
ensure_license(profile, log)?;
let mut cmd = Command::new(&profile.runner);
cmd.arg(&profile.executable)
.current_dir(&game_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in &profile.env {
cmd.env(k, v);
}
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.");
Ok(())
}
/// Create the Wine prefix's `dosdevices` entries the profile asks for.
///
/// Equivalent to `mkdir -p $WINEPREFIX/dosdevices && ln -sfn <target> <link>`:
/// an existing link is replaced, so re-running is harmless.
fn prepare_prefix(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
if profile.wine_prefix.trim().is_empty() || profile.prefix_links.is_empty() {
return Ok(());
}
let prefix = PathBuf::from(&profile.wine_prefix);
for link in &profile.prefix_links {
let path = prefix.join(&link.link);
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("prefix link has no parent: {}", link.link))?;
std::fs::create_dir_all(parent)?;
// Replace rather than fail: `ln -sfn` semantics. Only ever remove a
// symlink — refusing on a real file avoids destroying prefix contents
// if a profile is misconfigured.
match std::fs::symlink_metadata(&path) {
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_file(&path)?,
Ok(_) => anyhow::bail!(
"refusing to replace {}: it exists and is not a symlink",
path.display()
),
Err(_) => {}
}
std::os::unix::fs::symlink(&link.target, &path)?;
say(
log,
format!(
"[launcher] prefix link {} -> {}",
path.display(),
link.target
),
);
}
Ok(())
}
/// Make sure the DRM licence file exists, running the generator if it does not.
///
/// A crashed or failed launch deletes the licence, so this runs before every
/// launch rather than only on first setup — that is the behaviour the shell
/// script proved, and it is why a crash is normally self-healing on the next try.
fn ensure_license(profile: &GameProfile, log: &Log) -> anyhow::Result<()> {
let Some(lic) = &profile.license else {
return Ok(());
};
let path = resolve_under_prefix(&profile.wine_prefix, &lic.path);
if non_empty_file(&path) {
return Ok(());
}
say(
log,
format!(
"[launcher] licence missing ({}) — running {} to regenerate it",
path.display(),
lic.generator
),
);
let mut cmd = Command::new(&profile.runner);
cmd.arg(&lic.generator)
.current_dir(&profile.game_dir)
.stdout(Stdio::null())
.stderr(Stdio::null());
for (k, v) in &profile.env {
cmd.env(k, v);
}
if !profile.wine_prefix.trim().is_empty() {
cmd.env("WINEPREFIX", &profile.wine_prefix);
}
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("could not start licence generator: {e}"))?;
let deadline = Instant::now() + Duration::from_secs(lic.timeout_secs.max(1));
while Instant::now() < deadline {
if non_empty_file(&path) {
stop_generator(&mut child, lic, log);
say(log, "[launcher] licence regenerated.");
return Ok(());
}
std::thread::sleep(Duration::from_millis(500));
}
stop_generator(&mut child, lic, log);
anyhow::bail!(
"{} did not create {} within {}s. Run it manually, choose GENERATE, then launch again.",
lic.generator,
path.display(),
lic.timeout_secs
)
}
/// Stop the licence generator and the Windows process it started.
///
/// Killing the runner is not enough: it launches the executable through Proton,
/// so the `.exe` outlives its parent. The shell script used `pkill -f` for this
/// and it is reproduced deliberately — the pattern is a Windows executable name,
/// which cannot match the launcher or a shell running it. (A `pkill -f` pattern
/// that *can* match its own caller is a real hazard; this one cannot.)
fn stop_generator(child: &mut Child, lic: &crate::config::LicenseCheck, log: &Log) {
let _ = child.kill();
let _ = child.wait();
match Command::new("pkill").arg("-f").arg(&lic.generator).status() {
Ok(_) => {}
Err(e) => say(
log,
format!(
"[launcher] note: could not run pkill for {}: {e}",
lic.generator
),
),
}
}
/// A relative licence path is taken as relative to the Wine prefix; an absolute
/// one is used as given.
fn resolve_under_prefix(prefix: &str, path: &str) -> PathBuf {
let p = Path::new(path);
if p.is_absolute() || prefix.trim().is_empty() {
p.to_path_buf()
} else {
Path::new(prefix).join(p)
}
}
/// The script's `[[ -s FILE ]]`: present *and* non-empty. A zero-byte licence is
/// as useless as a missing one, and treating it as valid would skip the
/// regeneration that fixes it.
fn non_empty_file(path: &Path) -> bool {
std::fs::metadata(path)
.map(|m| m.len() > 0)
.unwrap_or(false)
}
/// Pump a child's stdout and stderr into the log buffer and reap it.
pub fn stream(mut child: Child, log: Log, exit_msg: &'static str) {
if let Some(out) = child.stdout.take() {
let buf = Arc::clone(&log);
std::thread::spawn(move || {
for line in BufReader::new(out).lines().map_while(Result::ok) {
buf.lock().unwrap().push(line);
}
});
}
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().unwrap().push(line);
}
});
}
std::thread::spawn(move || {
let _ = child.wait();
log.lock().unwrap().push(exit_msg.to_string());
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{LicenseCheck, PrefixLink};
fn log() -> Log {
Arc::new(Mutex::new(LogBuffer::new()))
}
fn tmpdir(tag: &str) -> PathBuf {
let d =
std::env::temp_dir().join(format!("openfut-launch-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_relative_licence_path_is_resolved_under_the_prefix() {
assert_eq!(
resolve_under_prefix("/p", "drive_c/lic.dlf"),
PathBuf::from("/p/drive_c/lic.dlf")
);
// Absolute wins, so a profile can point outside the prefix.
assert_eq!(
resolve_under_prefix("/p", "/elsewhere/lic.dlf"),
PathBuf::from("/elsewhere/lic.dlf")
);
}
#[test]
fn a_zero_byte_licence_does_not_count_as_present() {
let d = tmpdir("empty-lic");
let f = d.join("lic.dlf");
std::fs::write(&f, b"").unwrap();
assert!(
!non_empty_file(&f),
"an empty licence must trigger regeneration"
);
std::fs::write(&f, b"x").unwrap();
assert!(non_empty_file(&f));
}
#[test]
fn prefix_links_are_created_and_are_idempotent() {
let d = tmpdir("links");
let prefix = d.join("prefix");
let target = d.join("target");
std::fs::create_dir_all(&target).unwrap();
let profile = GameProfile {
runner: "true".into(),
executable: "x.exe".into(),
game_dir: d.to_string_lossy().into(),
wine_prefix: prefix.to_string_lossy().into(),
prefix_links: vec![PrefixLink {
link: "dosdevices/w:".into(),
target: target.to_string_lossy().into(),
}],
..GameProfile::default()
};
prepare_prefix(&profile, &log()).expect("first run creates the link");
let link = prefix.join("dosdevices/w:");
assert!(std::fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink());
// Re-running must not fail — the launcher prepares the prefix on EVERY
// launch, so a second launch would break if this were not idempotent.
prepare_prefix(&profile, &log()).expect("second run replaces the link");
assert_eq!(std::fs::read_link(&link).unwrap(), target);
}
#[test]
fn a_real_file_where_a_link_belongs_is_refused_not_deleted() {
let d = tmpdir("clobber");
let prefix = d.join("prefix");
std::fs::create_dir_all(prefix.join("dosdevices")).unwrap();
let occupied = prefix.join("dosdevices/w:");
std::fs::write(&occupied, b"important").unwrap();
let profile = GameProfile {
runner: "true".into(),
executable: "x.exe".into(),
game_dir: d.to_string_lossy().into(),
wine_prefix: prefix.to_string_lossy().into(),
prefix_links: vec![PrefixLink {
link: "dosdevices/w:".into(),
target: "/tmp".into(),
}],
..GameProfile::default()
};
assert!(prepare_prefix(&profile, &log()).is_err());
assert_eq!(
std::fs::read(&occupied).unwrap(),
b"important",
"a misconfigured profile must not destroy prefix contents"
);
}
#[test]
fn a_present_licence_skips_the_generator_entirely() {
let d = tmpdir("lic-present");
let lic = d.join("lic.dlf");
std::fs::write(&lic, b"valid").unwrap();
let profile = GameProfile {
runner: "/nonexistent/runner".into(), // would fail if it were run
executable: "x.exe".into(),
game_dir: d.to_string_lossy().into(),
wine_prefix: d.to_string_lossy().into(),
license: Some(LicenseCheck {
path: "lic.dlf".into(),
generator: "_gen.exe".into(),
timeout_secs: 1,
}),
..GameProfile::default()
};
// Proves the skip: the runner path is invalid, so reaching the generator
// would error. Ok() means it never tried.
ensure_license(&profile, &log()).expect("present licence must short-circuit");
}
/// The whole point of the licence step: a missing licence must actually run
/// the generator and wait for it. Without this, deleting `ensure_license`
/// entirely would still pass every other test in this file.
#[test]
fn a_missing_licence_runs_the_generator_and_waits_for_it() {
let d = tmpdir("lic-regen");
let lic = d.join("lic.dlf");
let gen = d.join("gen.sh");
// Sleeps first, so passing requires actually waiting rather than
// happening to observe a file that was already there. The target path
// is baked in: `generator` is passed as ONE argument, exactly as
// `umu-run "_fifa17.exe"` is.
std::fs::write(
&gen,
format!(
"#!/bin/sh\nsleep 1\nprintf licensed > '{}'\n",
lic.display()
),
)
.unwrap();
let profile = GameProfile {
runner: "/bin/sh".into(),
executable: "unused".into(),
game_dir: d.to_string_lossy().into(),
wine_prefix: d.to_string_lossy().into(),
license: Some(LicenseCheck {
generator: gen.to_string_lossy().into(),
path: "lic.dlf".into(),
timeout_secs: 10,
}),
..GameProfile::default()
};
assert!(!non_empty_file(&lic));
ensure_license(&profile, &log()).expect("generator should produce the licence");
assert!(non_empty_file(&lic), "licence was not created");
}
#[test]
fn a_generator_that_never_delivers_times_out_with_an_actionable_error() {
let d = tmpdir("lic-timeout");
let gen = d.join("gen.sh");
std::fs::write(&gen, "#!/bin/sh\nexit 0\n").unwrap();
let profile = GameProfile {
runner: "/bin/sh".into(),
executable: "unused".into(),
game_dir: d.to_string_lossy().into(),
wine_prefix: d.to_string_lossy().into(),
license: Some(LicenseCheck {
generator: gen.to_string_lossy().into(), // runs, writes nothing
path: "lic.dlf".into(),
timeout_secs: 1,
}),
..GameProfile::default()
};
let err = ensure_license(&profile, &log()).unwrap_err().to_string();
assert!(err.contains("did not create"), "{err}");
assert!(
err.contains("GENERATE"),
"the error must say what to do: {err}"
);
}
#[test]
fn launch_refuses_a_missing_game_dir_before_touching_anything() {
let profile = GameProfile {
runner: "true".into(),
executable: "x.exe".into(),
game_dir: "/definitely/not/here".into(),
..GameProfile::default()
};
let err = launch(&profile, &log()).unwrap_err().to_string();
assert!(err.contains("game_dir does not exist"), "{err}");
}
}
-133
View File
@@ -1,133 +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 std::{
net::{TcpStream, ToSocketAddrs},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
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().unwrap().clone();
match target {
None => {
*t_state.lock().unwrap() = HealthState::default();
}
Some((host, port)) => {
let snapshot = probe(&host, port);
*t_state.lock().unwrap() = 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().unwrap() = target;
}
pub fn snapshot(&self) -> HealthState {
self.state.lock().unwrap().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,
}
}
-429
View File
@@ -1,429 +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 std::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4, TcpListener},
path::Path,
process::{Child, Command, Stdio},
sync::{mpsc, Arc, Mutex},
time::{Duration, Instant},
};
use std::os::unix::process::CommandExt;
use crate::fifa17_capability::{
parse_capability_line, parse_fifa_pid, register, Fifa17ClientCapabilities,
};
use crate::logs::LogBuffer;
#[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, 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 responder script filename inside the tools dir.
fn script(self) -> &'static str {
match self {
Service::Lsx => "lsx_responder_v2.py",
Service::Autopatch => "autopatch.py",
}
}
}
fn command_parts(service: Service, python: &str, tools_dir: &Path) -> CommandParts {
let mut args = vec![tools_dir
.join(service.script())
.to_string_lossy()
.into_owned()];
if service == Service::Autopatch {
args.extend(["--launcher-pid".to_string(), std::process::id().to_string()]);
}
CommandParts {
program: python.to_string(),
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()
.unwrap()
.push(format!("[launcher] {label} stopped."));
self.stopping = None;
return false;
}
Ok(Err(error)) => {
log.lock()
.unwrap()
.push(format!("[launcher] failed to stop {label}: {error}"));
self.stopping = None;
return false;
}
Err(mpsc::TryRecvError::Empty) => return true,
Err(mpsc::TryRecvError::Disconnected) => {
log.lock().unwrap().push(format!(
"[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()
.unwrap()
.push(format!("[launcher] {label} exited ({status})."));
self.child = None;
false
}
Err(_) => true,
},
}
}
pub fn stopping(&self) -> bool {
self.stopping.is_some()
}
/// 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()
.unwrap()
.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>>,
}
/// Spawn a companion service. `python` is the interpreter, `tools_dir` the
/// directory holding the responder scripts. Streams stdout+stderr into `log`.
/// Returns an error (without spawning) if the tools dir or script is missing.
///
/// `capability` is the backend-registration wiring + shared per-FIFA-process
/// capability sink — `Some(..)` for autopatch (whose stdout advertises the
/// verified resolver guard) and `None` for LSX.
pub fn spawn(
service: Service,
python: &str,
tools_dir: &str,
persona_id: u64,
persona_name: &str,
capability: Option<CapabilityWiring>,
log: Arc<Mutex<LogBuffer>>,
) -> anyhow::Result<Child> {
use std::io::{BufRead, BufReader};
let dir = Path::new(tools_dir);
if !dir.is_dir() {
anyhow::bail!(
"FIFA 17 tools dir not found: {} (set it in the Config tab)",
dir.display()
);
}
let script_path = dir.join(service.script());
if !script_path.exists() {
anyhow::bail!(
"{} not found in tools dir: {}",
service.script(),
script_path.display()
);
}
let label = service.label();
// Both services use the configured interpreter and absolute script path;
// neither invents a Python installation path. Autopatch receives launcher
// ownership and a per-user runtime log so stale root-owned /tmp files cannot
// block startup.
let parts = command_parts(service, python, dir);
let mut cmd = Command::new(&parts.program);
cmd.args(&parts.args);
if service == Service::Lsx {
cmd.env("FUT_PERSONA_ID", persona_id.to_string())
.env("FUT_PERSONA_NAME", persona_name);
} else if service == Service::Autopatch {
let log_path = std::env::var_os("XDG_RUNTIME_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join("openfut-autopatch.log");
cmd.env("OPENFUT_AUTOPATCH_LOG", log_path);
}
// Put each companion in its own process group for lifecycle isolation.
cmd.process_group(0);
cmd.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
log.lock().unwrap().push(format!(
"[launcher] starting {label}: {} {}",
python,
script_path.display(),
));
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("failed to start {label} ({}): {e}", service.script()))?;
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().unwrap().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().unwrap().empty_mypacks_resolver = Some(version);
{
let mut log = buf.lock().unwrap();
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()
.unwrap()
.push("[fifa17] capability registered with backend".to_string()),
Err(error) => buf
.lock()
.unwrap()
.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().unwrap().push(format!("[{lbl}] {line}"));
}
});
}
if service == Service::Lsx {
let address = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4216));
if let Err(error) = wait_for_listener_ready(&mut child, address, Duration::from_secs(3)) {
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
log.lock()
.unwrap()
.push("[launcher] LSX ready on 127.0.0.1:4216".to_string());
}
Ok(child)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lsx_runs_python_directly() {
let parts = command_parts(Service::Lsx, "/usr/bin/python3", Path::new("/tmp/tools"));
assert_eq!(parts.program, "/usr/bin/python3");
assert_eq!(parts.args, vec!["/tmp/tools/lsx_responder_v2.py"]);
}
#[test]
fn autopatch_runs_python_directly_with_launcher_ownership() {
let parts = command_parts(
Service::Autopatch,
"/usr/bin/python3",
Path::new("/tmp/tools"),
);
assert_eq!(parts.program, "/usr/bin/python3");
assert_eq!(
parts.args,
vec![
"/tmp/tools/autopatch.py",
"--launcher-pid",
&std::process::id().to_string(),
]
);
}
#[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"));
}
}
+1 -8
View File
@@ -1,14 +1,7 @@
mod account_sync;
mod app;
mod arm;
mod config;
mod fifa17_capability;
mod game_launch;
mod health;
mod local_services;
mod logs;
mod netcheck;
mod preflight;
mod process;
mod setup;
fn main() -> eframe::Result<()> {
-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\""))
}
-398
View File
@@ -1,398 +0,0 @@
//! Pre-launch checks for the client-side state FIFA depends on.
//!
//! # Why
//!
//! On 2026-08-11 the game machine rebooted. Everything `client_arm.sh` sets —
//! `ptrace_scope=0`, the DNAT of EA's hardcoded redirector IP, the
//! `easw.easports.com` mapping — is volatile and was silently gone. The launcher
//! started, the local services started, the game started, and forty minutes later
//! the only symptom was FIFA's own dialog: *"the servers for this title have been
//! shut down"*. Nothing in the stack said anything, because nothing was looking.
//!
//! Every one of those conditions is observable **without privilege**. This module
//! looks, and reports before the user clicks Launch.
//!
//! # Deliberately not checked here
//!
//! Certificate parity across the FIFA-facing TLS services — the fault that cost
//! three redirector gates — is the single most valuable check available, but the
//! launcher has no TLS dependency (`account_sync` speaks plaintext HTTP by hand)
//! and adding one is a decision, not a detail. `scripts/check-tls-parity.sh` on
//! the server covers it in the meantime.
//!
//! # Advisory, not a gate
//!
//! Results colour the UI; they never disable Launch. A preflight that is itself
//! wrong must not be able to lock the user out of their own game.
use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs};
use std::time::Duration;
use crate::config::LauncherConfig;
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
const PTRACE_SCOPE: &str = "/proc/sys/kernel/yama/ptrace_scope";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Pass,
/// Genuinely wrong, but something else in the stack covers it, so the game
/// can still work. Kept distinct from [`State::Fail`] because a checker that
/// cries "this will fail" and is then contradicted by a working game teaches
/// the user to ignore it — which is worse than not checking at all.
Warn,
Fail,
/// Not configured, so there is nothing to assert. Never reported as a pass:
/// "we did not look" and "we looked and it was fine" must not look alike.
Skipped,
}
#[derive(Debug, Clone)]
pub struct Check {
pub name: String,
pub state: State,
pub detail: String,
}
impl Check {
fn pass(name: &str, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
state: State::Pass,
detail: detail.into(),
}
}
fn fail(name: &str, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
state: State::Fail,
detail: detail.into(),
}
}
fn warn(name: &str, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
state: State::Warn,
detail: detail.into(),
}
}
fn skip(name: &str, detail: impl Into<String>) -> Self {
Self {
name: name.into(),
state: State::Skipped,
detail: detail.into(),
}
}
}
/// Run every applicable check. Order is the order the game exercises them.
pub fn run(cfg: &LauncherConfig) -> Vec<Check> {
vec![
ptrace_scope(cfg),
ea_redirect(cfg),
hostname_mapping(cfg),
backend_reachable(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.
fn ptrace_scope(cfg: &LauncherConfig) -> Check {
const NAME: &str = "ptrace_scope (autopatch)";
// `fifa17_tools_dir` carries a conventional default, so a non-empty value
// does not mean the tools are installed. Key off the directory actually
// existing: that is what decides whether autopatch will run at all, and it
// keeps this from failing on a machine that never uses local services.
let tools = cfg.fifa17_tools_dir.trim();
if tools.is_empty() || !std::path::Path::new(tools).is_dir() {
return Check::skip(NAME, "no local services installed");
}
match std::fs::read_to_string(PTRACE_SCOPE) {
Ok(v) => ptrace_verdict(&v),
// Not every kernel has Yama. Absent means unenforced, which is what we want.
Err(_) => Check::skip(NAME, "Yama not present on this kernel"),
}
}
/// The decision, split from the file read so it can be tested.
///
/// Reading `/proc` in a test would assert facts about the machine running the
/// suite rather than about this code — and left inline, "any value is fine"
/// was a mutation no test could catch.
fn ptrace_verdict(raw: &str) -> Check {
const NAME: &str = "ptrace_scope (autopatch)";
let v = raw.trim();
if v == "0" {
Check::pass(NAME, "0 — autopatch can attach")
} else {
Check::fail(
NAME,
format!("{v} — autopatch cannot patch FIFA. Click 'Arm client'."),
)
}
}
/// FIFA dials EA's redirector by hardcoded IP. Armed, that address is DNAT'd to
/// the OpenFUT server and connects instantly; unarmed it leaves the LAN and
/// times out — which is exactly the "servers have been shut down" dialog.
///
/// This tests the *effect* rather than reading firewall rules, so it needs no
/// privilege and stays honest about what the game will actually experience.
fn ea_redirect(cfg: &LauncherConfig) -> Check {
const NAME: &str = "EA redirector IP is redirected";
let ip = cfg.ea_redirect_probe_ip.trim();
if ip.is_empty() {
return Check::skip(NAME, "no probe IP configured");
}
let Ok(addr) = ip.parse::<IpAddr>() else {
return Check::fail(NAME, format!("ea_redirect_probe_ip is not an IP: {ip:?}"));
};
let port = cfg.openfut_blaze_redirector_port;
match TcpStream::connect_timeout(&SocketAddr::new(addr, port), PROBE_TIMEOUT) {
Ok(_) => Check::pass(NAME, format!("{ip}:{port} answered — redirect is in place")),
Err(e) => Check::fail(
NAME,
format!("{ip}:{port} did not answer ({e}). Click 'Arm client'."),
),
}
}
/// The dead EA hostnames should resolve to the OpenFUT server.
///
/// Resolution is done with `getaddrinfo`, the same call the game makes, so a
/// duplicate `/etc/hosts` line that shadows the OpenFUT one is caught by its
/// effect. Parsing `/etc/hosts` would miss it: the file can contain the right
/// line and still resolve to the wrong address, because the first match wins.
///
/// # Why a warning and not a failure
///
/// Measured, not assumed. On 2026-08-11 this reported `easw.easports.com ->
/// ::1,127.0.0.1` and the game reached the FUT hub regardless. The reason is in
/// `client_arm.sh`'s own header: the responders run with `OPENFUT_ADVERTISE`
/// set, so after the first redirected contact the game is handed the server's
/// *address* for every later hop and stops using the hostname. The name is only
/// CardsDLL's built-in fallback.
///
/// So this is a real misconfiguration worth fixing and not a reason to expect
/// failure. Reporting it as fatal, and then being contradicted by a working
/// game, is how a checklist trains its user to ignore it.
fn hostname_mapping(cfg: &LauncherConfig) -> Check {
const NAME: &str = "EA hostnames point at OpenFUT";
if cfg.ea_hostnames.is_empty() {
return Check::skip(NAME, "no EA hostnames configured");
}
let server = cfg.openfut_server_host.trim();
if server.is_empty() {
return Check::skip(NAME, "no OpenFUT server configured");
}
let want = match resolve(server) {
Ok(ips) if !ips.is_empty() => ips,
_ => {
return Check::fail(
NAME,
format!("cannot resolve the OpenFUT server {server:?}"),
)
}
};
let mut wrong = Vec::new();
for host in &cfg.ea_hostnames {
match resolve(host) {
Ok(got) if got.iter().any(|ip| want.contains(ip)) => {}
Ok(got) => wrong.push(format!(
"{host} -> {} (expected {})",
join(&got),
join(&want)
)),
Err(e) => wrong.push(format!("{host} -> unresolvable ({e})")),
}
}
if wrong.is_empty() {
Check::pass(
NAME,
format!("{} host(s) resolve to {server}", cfg.ea_hostnames.len()),
)
} else {
Check::warn(
NAME,
format!(
"{}. Look for an earlier /etc/hosts line shadowing it. \
Usually survivable: the server advertises its address, so the \
game stops using this name after the first hop.",
wrong.join("; ")
),
)
}
}
/// The server side of the same question: are the ports the game will use open?
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(", ")))
}
}
fn connects(host: &str, port: u16) -> bool {
match (host, port).to_socket_addrs() {
Ok(mut addrs) => addrs.any(|a| TcpStream::connect_timeout(&a, PROBE_TIMEOUT).is_ok()),
Err(_) => false,
}
}
fn resolve(host: &str) -> std::io::Result<Vec<IpAddr>> {
Ok((host, 0u16).to_socket_addrs()?.map(|a| a.ip()).collect())
}
fn join(ips: &[IpAddr]) -> String {
ips.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",")
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> LauncherConfig {
LauncherConfig::default()
}
#[test]
fn an_unconfigured_launcher_skips_rather_than_passes() {
// The distinction that matters: a fresh config must not display four
// green ticks. "Not checked" is not "checked and fine".
let mut c = cfg();
// `default()` points this at a conventional path whose existence varies
// by machine. Pin it so the assertion is about the code, not this box.
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
let checks = run(&c);
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 ptrace_is_skipped_when_the_tools_dir_does_not_exist() {
// Regression: the gate used to be "is the field non-empty", and the
// field has a default — so this check ran (and failed) on machines that
// never use autopatch at all.
let mut c = cfg();
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
assert_eq!(ptrace_scope(&c).state, State::Skipped);
}
#[test]
fn a_malformed_probe_ip_fails_loudly_instead_of_being_skipped() {
let mut c = cfg();
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.
#[test]
fn a_shadowed_hostname_is_a_warning_not_a_failure() {
let mut c = cfg();
c.openfut_server_host = "127.0.0.2".into();
c.ea_hostnames = vec!["localhost".into()];
c.fifa17_tools_dir = "/nonexistent/openfut-tools".into();
let checks = run(&c);
assert_eq!(failures(&checks), 0, "must not be reported as fatal");
assert_eq!(warnings(&checks), 1);
}
#[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 ptrace_check_is_skipped_when_local_services_are_not_configured() {
let mut c = cfg();
c.fifa17_tools_dir.clear();
assert_eq!(ptrace_scope(&c).state, State::Skipped);
}
#[test]
fn a_dead_backend_port_is_reported_as_a_failure() {
let mut c = cfg();
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);
}
}
+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();
}
}
+37 -79
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 {
@@ -68,13 +90,10 @@ pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> {
// ── DLL hook deployment ───────────────────────────────────────────────────────
/// Deploy openfut_hook.dll into the FIFA 23 game directory and write
/// openfut.cfg with the structured server configuration the hook reads.
/// `cfg_contents` must be the full `openfut.cfg` body (see
/// `LauncherConfig::hook_cfg_contents`) — this function does not invent any
/// address itself, so a missing server can never silently become loopback.
/// openfut.cfg with the redirect IP the hook will use.
/// Uses `version.dll` as the hijack name — FIFA 23 loads it but defers to
/// the system copy, so Proton picks up our local one first.
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> {
pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, redirect_ip: &str) -> anyhow::Result<()> {
if !dll_src.exists() {
anyhow::bail!(
"Hook DLL not found at {}. Build it first with:\n\
@@ -85,18 +104,17 @@ pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> a
}
std::fs::create_dir_all(game_dir)?;
std::fs::copy(dll_src, game_dir.join("version.dll"))?;
std::fs::write(game_dir.join("openfut.cfg"), cfg_contents)?;
std::fs::write(game_dir.join("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<()> {
/// 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(())
}
@@ -116,65 +134,5 @@ pub fn hook_dll_deployed(game_dir: &Path) -> bool {
/// The Steam launch options the user needs to paste in to enable the override.
/// Proton loads local DLLs named in WINEDLLOVERRIDES ahead of system ones.
pub const STEAM_LAUNCH_OPTIONS: &str = "WINEDLLOVERRIDES=\"version=n,b\" %command%";
// ── Game launch ───────────────────────────────────────────────────────────────
/// Launch the game via the user-provided shell command. Runs `sh -c <command>`
/// (optionally from `workdir`), streaming stdout+stderr into `log_buf` on a
/// background thread. The launcher does not assume Steam vs umu-run vs a custom
/// script — whatever the user configured is what runs.
pub fn launch_game(
command: &str,
workdir: &str,
log_buf: std::sync::Arc<std::sync::Mutex<crate::logs::LogBuffer>>,
) -> anyhow::Result<()> {
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
if command.trim().is_empty() {
anyhow::bail!("No game launch command configured (set it in the Config tab).");
}
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()
.unwrap()
.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().unwrap().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().unwrap().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.
std::thread::spawn(move || {
let _ = child.wait();
log_buf
.lock()
.unwrap()
.push("[launcher] game process exited.".to_string());
});
Ok(())
}
pub const STEAM_LAUNCH_OPTIONS: &str =
"WINEDLLOVERRIDES=\"version=n,b\" %command%";