From 00ad631034d20c58a6ec0d6b695e2992f826a940 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 20 Aug 2026 20:56:52 +0000 Subject: [PATCH] refactor(launcher): retire FIFA 23; keep the hook game-generic by feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIFA 23 is not in development and was never a valid template for FIFA 17 (different game, different in-memory layout). Remove it as a build target and as scaffolding, while preserving the per-game feature architecture so future games plug in as new modules — never by copying retired reverse-engineering. Hook (openfut-hook): - Delete install_hooks_fifa23 and every FIFA23-only module: config, hooks, transport_watch, ssl_patch, origin_spy, tls_bypass, dial_notification, probe (+ probe feature), recv_hook (+ capture_baseline feature), plus the orphan FIFA23 LSX/Origin files lsx.rs and ea_stub.rs. ~3.6k lines; git + Vault retain the research. - lib.rs is now game-generic: a per-game feature selects that game's module and install_hooks dispatches to it. No game feature => compile_error!("select a game, e.g. --features fifa17"). --features fifa17 remains the build invariant. - Drop the crate-wide blanket (it existed only to hide the compiled-but-unused FIFA23 modules). Replace with narrow, justified #[allow(dead_code)] on the three FIFA17 SBC RE-scaffolding items it was masking, so the candidate stays behavior-identical. - connect_hook: the redirect is now always the config-driven path (openfut-common target from openfut.cfg); the hardcoded-loopback rewrite and its dead consts are gone. Removed the FIFA23-era transport_watch diagnostics from the shared connect/WSAConnect/ConnectEx detours. Deleted unused iat::patch_iat_in. Launcher: - fifa_game_dir no longer defaults to a hardcoded '.../FIFA 23' Steam path; it is empty by default, matching the launcher's own rule that it never invents a path to somebody's game install (like openfut_server_host and game_profile). - Generalise the remaining 'FIFA 23' doc literals in config.rs / setup.rs. Proof: fifa17 clippy -D warnings clean; no-game build fails with the documented compile_error; launcher 75 tests pass unchanged; launcher + hook cross-build x86_64-pc-windows-gnu; cargo fmt --check clean; zero FIFA23 symbols/literals remain. FIFA17 armed-module set unchanged (redirect + SBC/store/season). --- openfut-hook/Cargo.toml | 15 +- openfut-hook/src/config.rs | 32 - openfut-hook/src/connect_hook.rs | 127 +- openfut-hook/src/connectex_hook.rs | 4 - openfut-hook/src/dial_notification.rs | 183 --- openfut-hook/src/ea_stub.rs | 179 --- openfut-hook/src/fifa17.rs | 16 +- openfut-hook/src/hooks.rs | 82 -- openfut-hook/src/iat.rs | 13 - openfut-hook/src/lib.rs | 223 +--- openfut-hook/src/lsx.rs | 548 -------- openfut-hook/src/origin_spy.rs | 137 -- openfut-hook/src/probe.rs | 1747 ------------------------- openfut-hook/src/recv_hook.rs | 321 ----- openfut-hook/src/sbc_hook.rs | 10 +- openfut-hook/src/sbc_trace.rs | 1 + openfut-hook/src/ssl_patch.rs | 81 -- openfut-hook/src/tls_bypass.rs | 40 - openfut-hook/src/transport_watch.rs | 227 ---- src/config.rs | 15 +- src/setup.rs | 14 +- 21 files changed, 59 insertions(+), 3956 deletions(-) delete mode 100644 openfut-hook/src/config.rs delete mode 100644 openfut-hook/src/dial_notification.rs delete mode 100644 openfut-hook/src/ea_stub.rs delete mode 100644 openfut-hook/src/hooks.rs delete mode 100644 openfut-hook/src/lsx.rs delete mode 100644 openfut-hook/src/origin_spy.rs delete mode 100644 openfut-hook/src/probe.rs delete mode 100644 openfut-hook/src/recv_hook.rs delete mode 100644 openfut-hook/src/ssl_patch.rs delete mode 100644 openfut-hook/src/tls_bypass.rs delete mode 100644 openfut-hook/src/transport_watch.rs diff --git a/openfut-hook/Cargo.toml b/openfut-hook/Cargo.toml index 2d9ab23..c3d2133 100644 --- a/openfut-hook/Cargo.toml +++ b/openfut-hook/Cargo.toml @@ -13,17 +13,10 @@ edition = "2021" 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. +# Per-game selection: each supported game is a feature enabling its module. Exactly +# one MUST be set (the crate emits a compile_error otherwise). Build the deployed +# artifact with `--features fifa17`. Add a future game as a new feature here plus a +# `mod ;` + dispatch arm in lib.rs — never by copying a retired game's code. fifa17 = [] [dependencies] diff --git a/openfut-hook/src/config.rs b/openfut-hook/src/config.rs deleted file mode 100644 index f50dc3b..0000000 --- a/openfut-hook/src/config.rs +++ /dev/null @@ -1,32 +0,0 @@ -/// Reads openfut.cfg from the same directory as this DLL. -/// -/// The file contains a single line: the IP the hook should redirect EA -/// hostnames to, e.g. "192.168.1.10" or "127.0.0.1". -/// Falls back to 127.0.0.1 if the file is missing or unreadable. -use windows_sys::Win32::System::LibraryLoader::GetModuleFileNameA; - -pub fn read_redirect_ip(module: windows_sys::Win32::Foundation::HMODULE) -> String { - if let Some(cfg_path) = config_path(module) { - if let Ok(content) = std::fs::read_to_string(&cfg_path) { - let ip = content.trim().to_string(); - if !ip.is_empty() { - return ip; - } - } - } - "127.0.0.1".to_string() -} - -fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option { - let mut buf = vec![0u8; 512]; - let len = unsafe { GetModuleFileNameA(module, buf.as_mut_ptr(), buf.len() as u32) }; - if len == 0 { - return None; - } - let path = std::ffi::CStr::from_bytes_until_nul(&buf[..len as usize + 1]) - .ok()? - .to_str() - .ok()?; - let dll_path = std::path::Path::new(path); - Some(dll_path.parent()?.join("openfut.cfg")) -} diff --git a/openfut-hook/src/connect_hook.rs b/openfut-hook/src/connect_hook.rs index fd06bfd..6e0b3ff 100644 --- a/openfut-hook/src/connect_hook.rs +++ b/openfut-hook/src/connect_hook.rs @@ -5,20 +5,6 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::OnceLock; const AF_INET: u16 = 2; -const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian -const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian -const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian -const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian - // EA App LSX. anadius handles :3216 in-process before it reaches the host TCP - // stack (keyed on port 3216 specifically), so redirecting FIFA's LSX connect to a - // *different* host port (:3217) slips past that interception and lands on the - // native openfut-bridge LSX server. This is the load-bearing redirect that routes - // LSX to our bridge; without it FIFA uses anadius's in-process emu instead. -#[allow(dead_code)] // unused when built with the `capture_baseline` feature -const PORT_LSX_NBO: u16 = 0x900C; // 3216 big-endian (EA App LSX) -#[allow(dead_code)] -const PORT_LSX_TARGET_NBO: u16 = 0x910C; // 3217 big-endian (bridge LSX target) -const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian #[repr(C)] struct SockaddrIn { @@ -41,13 +27,6 @@ struct SockaddrIn6 { sin6_scope_id: u32, } -/// IPv4-mapped IPv6 loopback: `::ffff:127.0.0.1`. An `AF_INET6` socket connecting to -/// this sends real IPv4 packets to 127.0.0.1, so the connection lands on the bridge's -/// existing IPv4 listener on :8443 — no separate IPv6 listener needed. The game's own -/// EA dials already use v4-mapped addresses (`::ffff:x.x.x.x`), so its sockets are not -/// `IPV6_V6ONLY` and will accept this target. -const V4MAPPED_LOOPBACK: [u8; 16] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1]; - // Address of ws2_32!connect (set at hook installation) static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0); @@ -89,92 +68,9 @@ unsafe fn restore_original(target: *mut u8) { VirtualProtect(target as _, 14, old, &mut old); } -/// 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. -unsafe fn redirect_loopback(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> { - if namelen < 8 || name.is_null() { - return None; - } - // The first u16 of any sockaddr is the address family. - let family = *(name as *const u16); - let mut buf = [0u8; 28]; - - match family { - AF_INET => { - // SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read. - let sa = &*(name as *const SockaddrIn); - let new_port_nbo = match sa.sin_port { - PORT_HTTPS_NBO => PORT_BRIDGE_NBO, - #[cfg(not(feature = "capture_baseline"))] - PORT_LSX_NBO => PORT_LSX_TARGET_NBO, - PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, - PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, - _ => return None, - }; - // sin_addr is network order; to_le_bytes gives memory order = the dotted - // quad, so b[0].b[1].b[2].b[3] is correct (the old code printed it reversed). - let o = sa.sin_addr.to_le_bytes(); - crate::write_log(&format!( - "connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n", - o[0], - o[1], - o[2], - o[3], - u16::from_be(sa.sin_port), - u16::from_be(new_port_nbo) - )); - // SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write. - let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn); - out.sin_family = AF_INET; - out.sin_port = new_port_nbo; - out.sin_addr = ADDR_LOOPBACK_NBO; - Some((buf, 16)) - } - AF_INET6 => { - if namelen < 28 { - return None; - } - // SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6). - let sa6 = &*(name as *const SockaddrIn6); - // LSX is IPv4-only (anadius keys on it), so it is intentionally omitted here. - let new_port_nbo = match sa6.sin6_port { - PORT_HTTPS_NBO => PORT_BRIDGE_NBO, - PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, - PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, - _ => return None, - }; - let a = sa6.sin6_addr; - crate::write_log(&format!( - "connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n", - a[0], - a[1], - a[14], - a[15], - u16::from_be(sa6.sin6_port), - u16::from_be(new_port_nbo) - )); - // SAFE: buf is exactly 28 bytes == sizeof(sockaddr_in6). - let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6); - out.sin6_family = AF_INET6; - out.sin6_port = new_port_nbo; - out.sin6_flowinfo = 0; - out.sin6_addr = V4MAPPED_LOOPBACK; - out.sin6_scope_id = 0; - Some((buf, 28)) - } - _ => None, - } -} - -/// The armed FIFA17 redirect target, resolved once from `openfut.cfg` via -/// `openfut-common`. When set, `redirect_if_ea` rewrites matched EA connections -/// to this configured server; when unset, the legacy loopback path is used. +/// The armed redirect target, resolved once from `openfut.cfg` via `openfut-common`. +/// When set, `redirect_if_ea` rewrites matched EA connections to this configured +/// server; when unset, matched connections are left untouched (no redirect). static REDIRECT: OnceLock = OnceLock::new(); /// Arm the config-driven redirect (FIFA17). Idempotent: the first call wins. @@ -182,16 +78,16 @@ pub fn set_redirect(server: openfut_common::ResolvedServer) { let _ = REDIRECT.set(server); } -/// Dispatch: config-driven (FIFA17, shared `openfut-common` map + configured -/// host) when armed, else the legacy hardcoded-loopback rewrite. +/// If `name` is a matched EA connect target, return a rewritten sockaddr pointing +/// at the configured OpenFUT server (plus its meaningful byte length: 16 for v4, +/// 28 for v6). The target is armed once from `openfut.cfg` via `set_redirect`; +/// when unset — or when the port is not a known EA route — the connection is left +/// untouched. Shared by the connect / WSAConnect / ConnectEx detours. pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> { if namelen < 8 || name.is_null() { return None; } - match REDIRECT.get() { - Some(server) => redirect_configured(server, name, namelen), - None => redirect_loopback(name, namelen), - } + redirect_configured(REDIRECT.get()?, name, namelen) } /// FIFA17 config-driven rewrite. Destination host+port come from `openfut.cfg` @@ -258,9 +154,6 @@ unsafe fn redirect_configured( pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: i32) -> i32 { let addr = CONNECT_ADDR.load(Ordering::Relaxed) as *mut u8; - // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). - crate::transport_watch::note_connect("connect", name, namelen, s); - // Log every call so we can confirm the hook fires at all if namelen >= 8 { let sa = &*(name as *const SockaddrIn); @@ -334,8 +227,6 @@ 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) diff --git a/openfut-hook/src/connectex_hook.rs b/openfut-hook/src/connectex_hook.rs index 513eaf1..21ea66e 100644 --- a/openfut-hook/src/connectex_hook.rs +++ b/openfut-hook/src/connectex_hook.rs @@ -77,10 +77,6 @@ unsafe extern "system" fn hooked_connectex( overlapped: *mut c_void, ) -> i32 { let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed)); - - // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). - crate::transport_watch::note_connect("ConnectEx", name, namelen, s); - // Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx // dials get the same IPv6 handling as plain connect(). if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) { diff --git a/openfut-hook/src/dial_notification.rs b/openfut-hook/src/dial_notification.rs deleted file mode 100644 index 60b96bf..0000000 --- a/openfut-hook/src/dial_notification.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! 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); - } -} diff --git a/openfut-hook/src/ea_stub.rs b/openfut-hook/src/ea_stub.rs deleted file mode 100644 index 58c1a8c..0000000 --- a/openfut-hook/src/ea_stub.rs +++ /dev/null @@ -1,179 +0,0 @@ -/// In-process LSX server (port 3216 / EA App Local Services Exchange). -/// -/// Runs in a background thread inside FIFA's process so Wine's wineserver -/// routes FIFA's connect() directly here without needing any external process. -/// -/// Protocol: server speaks first (sends XML greeting with challenge key), -/// then both sides do an AES-128-ECB challenge/response handshake, then -/// all subsequent messages are AES-128-ECB encrypted. -use windows_sys::Win32::Networking::WinSock::{ - WSAStartup, WSACleanup, socket, bind, listen, accept, recv, send, - closesocket, setsockopt, - WSADATA, SOCKADDR, SOCKET, SOCKET_ERROR, INVALID_SOCKET, - AF_INET, SOCK_STREAM, IPPROTO_TCP, SOMAXCONN, - SO_REUSEADDR, SOL_SOCKET, -}; - -const PORT: u16 = 3216; -const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb"; - -fn server_loop() { - unsafe { - let mut wsa = core::mem::zeroed::(); - if WSAStartup(0x0202, &mut wsa) != 0 { - crate::write_log("ea_stub: WSAStartup failed\n"); - return; - } - - let srv = socket(AF_INET as i32, SOCK_STREAM, IPPROTO_TCP as i32); - if srv == INVALID_SOCKET { - crate::write_log("ea_stub: socket() failed\n"); - WSACleanup(); - return; - } - - let yes: i32 = 1; - setsockopt(srv, SOL_SOCKET as i32, SO_REUSEADDR, &yes as *const i32 as *const u8, 4); - - // sockaddr_in: sin_family(u16-LE) + sin_port(u16-BE) + sin_addr(u32) + padding - let mut addr = [0u8; 16]; - let family = AF_INET as u16; - addr[0] = (family & 0xFF) as u8; - addr[1] = (family >> 8) as u8; - addr[2] = (PORT >> 8) as u8; - addr[3] = (PORT & 0xFF) as u8; - - if bind(srv, addr.as_ptr() as *const SOCKADDR, addr.len() as i32) == SOCKET_ERROR { - crate::write_log("ea_stub: bind() failed — port 3216 in use\n"); - closesocket(srv); - WSACleanup(); - return; - } - - listen(srv, SOMAXCONN as i32); - crate::write_log("ea_stub: listening on port 3216\n"); - - loop { - crate::write_log("ea_stub: calling accept...\n"); - let client = accept(srv, core::ptr::null_mut(), core::ptr::null_mut()); - if client == INVALID_SOCKET { - use windows_sys::Win32::Networking::WinSock::WSAGetLastError; - let e = WSAGetLastError(); - crate::write_log(&format!("ea_stub: accept FAILED wsa_err={e}\n")); - break; - } - crate::write_log("ea_stub: connection accepted\n"); - handle_lsx(client); - } - - closesocket(srv); - WSACleanup(); - } -} - -unsafe fn lsx_send(sock: SOCKET, msg: &str) -> bool { - // LSX messages are null-terminated - let mut buf = msg.as_bytes().to_vec(); - buf.push(0); - let n = send(sock, buf.as_ptr(), buf.len() as i32, 0); - if n == SOCKET_ERROR { - use windows_sys::Win32::Networking::WinSock::WSAGetLastError; - let e = WSAGetLastError(); - crate::write_log(&format!("ea_stub: send FAILED wsa_err={e}\n")); - false - } else { - crate::write_log(&format!("ea_stub: sent {n} bytes\n")); - true - } -} - -unsafe fn lsx_recv(sock: SOCKET) -> Option { - let mut buf = vec![0u8; 8192]; - let n = recv(sock, buf.as_mut_ptr(), buf.len() as i32, 0); - if n <= 0 { - use windows_sys::Win32::Networking::WinSock::WSAGetLastError; - let e = WSAGetLastError(); - crate::write_log(&format!("ea_stub: recv returned {n} wsa_err={e}\n")); - return None; - } - let text = String::from_utf8_lossy(&buf[..n as usize]) - .trim_matches('\0') - .to_string(); - crate::write_log(&format!("ea_stub: recv {n} bytes: {}\n", &text[..text.len().min(300)])); - Some(text) -} - -unsafe fn handle_lsx(sock: SOCKET) { - // ── 1. Send greeting (server speaks first) ──────────────────────────── - let greeting = format!( - "\r\n \r\n \r\n \r\n" - ); - crate::write_log("ea_stub: sending LSX greeting\n"); - if !lsx_send(sock, &greeting) { - closesocket(sock); - return; - } - - // ── 2. Receive FIFA's ChallengeResponse ─────────────────────────────── - let challenge_xml = match lsx_recv(sock) { - Some(s) => s, - None => { closesocket(sock); return; } - }; - - // Parse: split on '"' — EAappEmulater style - // - let parts: Vec<&str> = challenge_xml.split('"').collect(); - let id = parts.get(3).copied().unwrap_or("1"); - let key = parts.get(7).copied().unwrap_or(""); - crate::write_log(&format!("ea_stub: challenge id={id} key={key}\n")); - - let our_response = crate::lsx::make_challenge_response(key); - let seed = compute_seed(&our_response); - crate::write_log(&format!("ea_stub: our_response={our_response} seed={seed}\n")); - - // ── 3. Send ChallengeAccepted ───────────────────────────────────────── - let accepted = format!( - "\r\n \r\n \r\n \r\n" - ); - crate::write_log("ea_stub: sending ChallengeAccepted\n"); - if !lsx_send(sock, &accepted) { - closesocket(sock); - return; - } - - // ── 4. Session loop ─────────────────────────────────────────────────── - loop { - let encrypted = match lsx_recv(sock) { - Some(s) => s, - None => break, - }; - if encrypted.trim().is_empty() { continue; } - - let request = crate::lsx::lsx_decrypt(&encrypted, seed); - crate::write_log(&format!("ea_stub: request: {}\n", &request[..request.len().min(300)])); - - if request.trim().is_empty() { - crate::write_log("ea_stub: empty decrypted request — skipping\n"); - continue; - } - - let response_xml = crate::lsx::dispatch(request.trim()); - crate::write_log(&format!("ea_stub: response: {}\n", &response_xml[..response_xml.len().min(300)])); - - let encrypted_resp = crate::lsx::lsx_encrypt(&response_xml, seed); - if !lsx_send(sock, &encrypted_resp) { break; } - } - - closesocket(sock); - crate::write_log("ea_stub: client disconnected\n"); -} - -fn compute_seed(hex: &str) -> u16 { - let b0 = u8::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0); - let b1 = u8::from_str_radix(&hex[2..4.min(hex.len())], 16).unwrap_or(0); - ((b0 as u16) << 8) | (b1 as u16) -} - -pub fn start() { - std::thread::spawn(server_loop); -} diff --git a/openfut-hook/src/fifa17.rs b/openfut-hook/src/fifa17.rs index 20d8500..de2f56c 100644 --- a/openfut-hook/src/fifa17.rs +++ b/openfut-hook/src/fifa17.rs @@ -1,14 +1,12 @@ //! FIFA 17 injection path (feature = "fifa17"). //! -//! This is a *separate, minimal* entry point from the FIFA-23 `install_hooks`. -//! FIFA 17 is a different game with different in-memory structures, so we run NONE -//! of the FIFA-23 connect/LSX/origin_spy/dial logic here — that would at best -//! no-op and at worst crash. For now this proves the version.dll hijack actually -//! loads us into FIFA17.exe and dumps the module map, which we need to locate -//! DirtySDK/ProtoSSL's cert-verify function (the next milestone: patch it so the -//! secure Blaze redirector's TLS handshake succeeds against our bridge cert). -//! -//! Everything here is read-only except the (not-yet-enabled) cert-verify patch. +//! This is the game module selected by the `fifa17` feature: `install()` spawns a +//! worker (off the loader lock) that dumps the module map, arms the config-driven +//! network redirect (connect / WSAConnect / ConnectEx, target from `openfut.cfg` +//! via `openfut-common`), and installs the FIFA-17 SBC dispatch repair plus the +//! store/season hooks. Structures and RVAs here are specific to FIFA17.exe / +//! CardsDLL_Win64_retail.dll; a future game gets its own module, never a copy of +//! this one. use crate::write_log; use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; diff --git a/openfut-hook/src/hooks.rs b/openfut-hook/src/hooks.rs deleted file mode 100644 index d29e19d..0000000 --- a/openfut-hook/src/hooks.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::{ - ffi::CStr, - sync::{ - atomic::{AtomicBool, Ordering}, - OnceLock, - }, -}; - -use windows_sys::Win32::Networking::WinSock::{getaddrinfo as sys_getaddrinfo, ADDRINFOA}; - -type GetaddrinfoFn = - unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32; - -static REAL: OnceLock = OnceLock::new(); -static REDIRECT_IP: OnceLock> = OnceLock::new(); - -// Flipped to true the first time we successfully apply the runtime cert patch. -// The patch is deferred to here (rather than DllMain) because EAWebKit.dll may -// not be loaded yet when the hook DLL is injected. -static CERT_PATCHED: AtomicBool = AtomicBool::new(false); - -pub fn set_real(f: GetaddrinfoFn) { - let _ = REAL.set(f); -} - -pub fn set_redirect_ip(ip: String) { - let mut bytes = ip.into_bytes(); - bytes.push(0); - let _ = REDIRECT_IP.set(bytes); -} - -/// Returns true if `host` is an EA / EA-Sports domain that should be redirected -/// to the local OpenFUT bridge. -fn is_ea_host(host: &str) -> bool { - let h = host.to_ascii_lowercase(); - h.ends_with(".ea.com") - || h == "ea.com" - || h.ends_with(".easports.com") - || h == "easports.com" - || h.ends_with(".ugc.footapi.com") - || h.ends_with(".footapi.com") -} - -pub unsafe extern "system" fn hooked_getaddrinfo( - node_name: *const u8, - service_name: *const u8, - hints: *const ADDRINFOA, - result: *mut *mut ADDRINFOA, -) -> i32 { - if !node_name.is_null() { - if let Ok(host) = CStr::from_ptr(node_name as *const i8).to_str() { - crate::write_log(&format!("openfut_hook: getaddrinfo({host})\n")); - // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). - crate::transport_watch::note_getaddrinfo(host); - if is_ea_host(host) { - // Apply the ProtoSSL cert-verify bypass the first time we see an EA - // hostname — EAWebKit.dll must be loaded by now because it's calling us. - if !CERT_PATCHED.load(Ordering::Relaxed) { - if crate::ssl_patch::patch_eawebkit_cert_verify() { - CERT_PATCHED.store(true, Ordering::Relaxed); - crate::write_log( - "openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n", - ); - } else { - crate::write_log( - "openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n", - ); - } - } - - let redirect = REDIRECT_IP - .get() - .map(|v| v.as_ptr()) - .unwrap_or(c"127.0.0.1".as_ptr().cast()); - let real = REAL.get().copied().unwrap_or(sys_getaddrinfo); - return real(redirect, service_name, hints, result); - } - } - } - let real = REAL.get().copied().unwrap_or(sys_getaddrinfo); - real(node_name, service_name, hints, result) -} diff --git a/openfut-hook/src/iat.rs b/openfut-hook/src/iat.rs index 78f82e3..966f2bb 100644 --- a/openfut-hook/src/iat.rs +++ b/openfut-hook/src/iat.rs @@ -71,19 +71,6 @@ pub unsafe fn patch_iat(original_fn: *const (), hook_fn: *const ()) -> usize { patch_module(module, original_fn, hook_fn) } -/// Patch the IAT of a specific already-loaded DLL (e.g. b"EAWebKit.dll\0"). -pub unsafe fn patch_iat_in( - module_name: &[u8], - original_fn: *const (), - hook_fn: *const (), -) -> usize { - let module = GetModuleHandleA(module_name.as_ptr()); - if module.is_null() { - return 0; - } - patch_module(module, original_fn, hook_fn) -} - unsafe fn patch_module(module: HMODULE, original_fn: *const (), hook_fn: *const ()) -> usize { if module.is_null() { return 0; diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index 1d97a9c..1acb9e5 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -1,25 +1,21 @@ -// The `fifa17` feature compiles this shared crate but activates only the FIFA-17 -// injection path (fifa17.rs + sbc_*): install_hooks() routes to fifa17::install() -// and the FIFA-23 hook modules are reached solely via install_hooks_fifa23(), which -// is itself `#[cfg(not(feature = "fifa17"))]`. Those modules are therefore compiled -// but unused under `fifa17` (the linker strips them from the cdylib). Scope the -// resulting dead-code/unused-import lints to that feature so both builds stay -// `-D warnings` clean without dropping code the default (FIFA-23) build needs. -#![cfg_attr(feature = "fifa17", allow(dead_code, unused_imports))] +// openfut-hook: the version.dll proxy that injects OpenFUT's client-side +// compatibility hooks into an EA FUT client. +// +// GAME-GENERIC BY FEATURE: each supported game is its own module, selected by a +// per-game Cargo feature (currently only `fifa17`). `install_hooks` dispatches to +// the selected game's `install()`. Generic infrastructure — the version proxy, +// the connect/WSAConnect/ConnectEx redirect, IAT primitives, and the shared +// `openfut-common` config — stays game-neutral. Add a future game with its own +// `mod ;` behind a feature plus a dispatch arm; never by copying a retired +// game's reverse-engineering. +#[cfg(not(any(feature = "fifa17")))] +compile_error!("select a game, e.g. --features fifa17"); -mod config; mod connect_hook; mod connectex_hook; -mod dial_notification; #[cfg(feature = "fifa17")] mod fifa17; -mod hooks; mod iat; -mod origin_spy; -#[cfg(feature = "probe")] -mod probe; -#[cfg(feature = "capture_baseline")] -mod recv_hook; #[cfg(feature = "fifa17")] mod sbc_dispatch; #[cfg(feature = "fifa17")] @@ -30,16 +26,12 @@ mod sbc_request_trace; mod sbc_trace; #[cfg(feature = "fifa17")] mod season_trace; -mod ssl_patch; #[cfg(feature = "fifa17")] mod store_entry; -mod tls_bypass; -mod transport_watch; mod version_proxy; use windows_sys::Win32::{ Foundation::{BOOL, HMODULE, TRUE}, - Networking::WinSock::ADDRINFOA, System::SystemServices::DLL_PROCESS_ATTACH, }; @@ -54,21 +46,6 @@ 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(); - } -} - /// # Safety /// /// This is the DLL entry point invoked by the Windows loader; it MUST NOT be @@ -88,177 +65,9 @@ pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) TRUE } -unsafe fn install_hooks(module: HMODULE) { - // FIFA 17 path: run ONLY the minimal, FIFA-17-safe logic and skip every - // FIFA-23-specific hook below (they assume FIFA 23's memory layout). +/// Dispatch to the selected game's install path. Exactly one game feature must be +/// enabled (enforced by the crate-level `compile_error!` above). +unsafe fn install_hooks(_module: HMODULE) { #[cfg(feature = "fifa17")] - { - let _ = module; - fifa17::install(); - } - #[cfg(not(feature = "fifa17"))] - install_hooks_fifa23(module) -} - -#[cfg(not(feature = "fifa17"))] -unsafe fn install_hooks_fifa23(module: HMODULE) { - write_log("openfut_hook: DllMain fired\n"); - // Milestone-0 transport watch: arm (or note disarmed) from env once, up front, so - // the getaddrinfo/connect/ConnectEx detours below can log Blaze-flavored activity. - transport_watch::arm_from_env(); - let ip = config::read_redirect_ip(module); - hooks::set_redirect_ip(ip); - - let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0"); - if !ga.is_null() { - let f: unsafe extern "system" fn( - *const u8, - *const u8, - *const ADDRINFOA, - *mut *mut ADDRINFOA, - ) -> i32 = std::mem::transmute(ga); - hooks::set_real(f); - let n = iat::patch_iat(ga, hooks::hooked_getaddrinfo as *const ()); - let m = iat::patch_iat_in( - b"EAWebKit.dll\0", - ga, - hooks::hooked_getaddrinfo as *const (), - ); - write_log(&format!("openfut_hook: getaddrinfo IAT patched {n}+{m}\n")); - } - - if ssl_patch::patch_main_exe_cert_verify() { - write_log("ssl: main exe cert-verify patched\n"); - } else { - write_log("ssl: main exe cert-verify NOT FOUND\n"); - } - if ssl_patch::patch_eawebkit_cert_verify() { - write_log("ssl: EAWebKit cert-verify patched\n"); - } else { - write_log("ssl: EAWebKit cert-verify deferred\n"); - } - - if connect_hook::install_inline_connect_hook() { - write_log("connect: inline-hooked\n"); - } else { - write_log("connect: hook FAILED\n"); - } - let wp = iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0"); - if !wp.is_null() { - let f: unsafe extern "system" fn( - usize, - *const u8, - i32, - *const (), - *const (), - *const (), - *const (), - ) -> i32 = std::mem::transmute(wp); - connect_hook::set_real_wsa_connect(f); - iat::patch_iat(wp, connect_hook::hooked_wsa_connect as *const ()); - write_log("connect: WSAConnect IAT patched\n"); - } - - if connectex_hook::install_wsaioctl_hook() { - write_log("connectex: WSAIoctl inline-hooked\n"); - } else { - write_log("connectex: WSAIoctl hook FAILED\n"); - } - - // RE instrumentation: passive logging detours on FIFA's in-process online-flow - // functions (GoOnline, GetInternetConnectedState, event deserializers) to see - // where FIFA stalls after our pushed LSX events. Deferred until anadius loads. - #[cfg(feature = "probe")] - { - probe::install_probes_deferred(); - write_log("probe: deferred install scheduled\n"); - } - - // recv/send hooks removed — LSX is now handled by the native openfut-bridge - // LSX server (port 3216), so in-process interception is no longer needed. - // - // Except in the `capture_baseline` build: with the LSX redirect off, FIFA talks - // to anadius directly, and these hooks log anadius's real LSX request/response - // frames (pass-through, no emulation) so we can diff them against our bridge. - #[cfg(feature = "capture_baseline")] - { - if recv_hook::install_recv_hook() { - write_log("CAP: recv inline-hooked\n"); - } else { - write_log("CAP: recv hook FAILED\n"); - } - if recv_hook::install_send_hook() { - write_log("CAP: send inline-hooked\n"); - } else { - write_log("CAP: send hook FAILED\n"); - } - } - - macro_rules! hook_iat { - ($dll:expr, $sym:expr, $setter:ident, $handler:expr, $ty:ty) => {{ - let ptr = iat::resolve($dll, $sym); - if !ptr.is_null() { - let f: $ty = std::mem::transmute(ptr); - origin_spy::$setter(f); - iat::patch_iat(ptr, $handler as *const ()); - "ok" - } else { - "miss" - } - }}; - } - let ra = hook_iat!( - b"advapi32.dll\0", - b"RegQueryValueExA\0", - set_real_reg_a, - origin_spy::hooked_reg_query_a, - unsafe extern "system" fn(isize, *const u8, *mut u32, *mut u32, *mut u8, *mut u32) -> i32 - ); - let rw = hook_iat!( - b"advapi32.dll\0", - b"RegQueryValueExW\0", - set_real_reg_w, - origin_spy::hooked_reg_query_w, - unsafe extern "system" fn(isize, *const u16, *mut u32, *mut u32, *mut u8, *mut u32) -> i32 - ); - let ma = hook_iat!( - b"kernel32.dll\0", - b"OpenMutexA\0", - set_real_mutex_a, - origin_spy::hooked_open_mutex_a, - unsafe extern "system" fn(u32, i32, *const u8) -> isize - ); - let mw = hook_iat!( - b"kernel32.dll\0", - b"OpenMutexW\0", - set_real_mutex_w, - origin_spy::hooked_open_mutex_w, - unsafe extern "system" fn(u32, i32, *const u16) -> isize - ); - write_log(&format!( - "origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n" - )); - - let cv = iat::resolve(b"crypt32.dll\0", b"CertVerifyCertificateChainPolicy\0"); - if !cv.is_null() { - let f: unsafe extern "system" fn(*const u8, *const (), *const (), *mut u32) -> BOOL = - std::mem::transmute(cv); - tls_bypass::set_real(f); - iat::patch_iat(cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); - iat::patch_iat_in( - b"EAWebKit.dll\0", - cv, - tls_bypass::hooked_cert_verify_chain_policy as *const (), - ); - iat::patch_iat_in( - b"winhttp.dll\0", - cv, - tls_bypass::hooked_cert_verify_chain_policy as *const (), - ); - iat::patch_iat_in( - b"wininet.dll\0", - cv, - tls_bypass::hooked_cert_verify_chain_policy as *const (), - ); - } + fifa17::install(); } diff --git a/openfut-hook/src/lsx.rs b/openfut-hook/src/lsx.rs deleted file mode 100644 index 91882f1..0000000 --- a/openfut-hook/src/lsx.rs +++ /dev/null @@ -1,548 +0,0 @@ -/// EA App LSX protocol emulator (port 3216). -/// -/// FIFA 23 opens two concurrent connections to port 3216 (one for EbisuSDK, -/// one for the login service). We track up to 4 sockets in LSX_POOL with -/// independent state per connection. -use core::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Mutex; - -// ─── per-connection slot ───────────────────────────────────────────────────── - -struct LsxSlot { - socket: AtomicUsize, // usize::MAX = empty - state: AtomicUsize, - seed: AtomicUsize, - pending: Mutex>>, -} - -const MAX_LSX: usize = 4; - -macro_rules! empty_slot { - () => { LsxSlot { - socket: AtomicUsize::new(usize::MAX), - state: AtomicUsize::new(0), - seed: AtomicUsize::new(0), - pending: Mutex::new(None), - }}; -} - -static POOL: [LsxSlot; MAX_LSX] = [ - empty_slot!(), empty_slot!(), empty_slot!(), empty_slot!(), -]; - -fn find_slot(s: usize) -> Option<&'static LsxSlot> { - POOL.iter().find(|sl| sl.socket.load(Ordering::Relaxed) == s) -} - -// ─── public API ────────────────────────────────────────────────────────────── - -pub fn set_lsx_socket(s: usize) { - // Try to reuse an existing slot for this socket first - if find_slot(s).is_some() { return; } - // Find a free slot - for sl in &POOL { - if sl.socket.load(Ordering::Relaxed) == usize::MAX { - sl.state.store(0, Ordering::Relaxed); - sl.seed.store(0, Ordering::Relaxed); - if let Ok(mut g) = sl.pending.lock() { *g = None; } - sl.socket.store(s, Ordering::Relaxed); - crate::write_log(&format!("lsx: socket registered s={s}\n")); - return; - } - } - // All slots full — evict the first one - let sl = &POOL[0]; - sl.state.store(0, Ordering::Relaxed); - sl.seed.store(0, Ordering::Relaxed); - if let Ok(mut g) = sl.pending.lock() { *g = None; } - sl.socket.store(s, Ordering::Relaxed); - crate::write_log(&format!("lsx: socket registered s={s} (evicted old slot)\n")); -} - -pub fn is_lsx(s: usize) -> bool { - find_slot(s).is_some() -} - -pub fn current_socket() -> usize { - // Return any active LSX socket (used by select hook if needed) - POOL.iter() - .map(|sl| sl.socket.load(Ordering::Relaxed)) - .find(|&s| s != usize::MAX) - .unwrap_or(usize::MAX) -} - -const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb"; -const AES_KEY: [u8; 16] = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]; - -pub unsafe fn on_recv(s: usize, buf: *mut u8, len: i32) -> i32 { - let sl = match find_slot(s) { Some(x) => x, None => return -1 }; - let state = sl.state.load(Ordering::Relaxed); - crate::write_log(&format!("lsx: recv s={s} state={state}\n")); - - let payload: Vec = match state { - 0 => { - let xml = format!( - "\r\n \r\n \r\n \r\n\0" - ); - sl.state.store(1, Ordering::Relaxed); - xml.into_bytes() - } - _ => { - let mut guard = sl.pending.lock().unwrap_or_else(|e| e.into_inner()); - match guard.take() { - Some(pb) => pb, - None => { - // No pending data — return 0. - // For the state-1 probe recv (FIFA checking if there is more - // greeting data), 0 is the correct "no more data" signal and - // FIFA proceeds to send the ChallengeResponse. - return 0; - } - } - } - }; - - let n = payload.len().min(len as usize); - core::ptr::copy_nonoverlapping(payload.as_ptr(), buf, n); - crate::write_log(&format!("lsx: recv -> {n} bytes\n")); - n as i32 -} - -pub unsafe fn on_send(s: usize, buf: *const u8, len: i32) -> i32 { - let sl = match find_slot(s) { Some(x) => x, None => return len }; - let state = sl.state.load(Ordering::Relaxed); - let data = core::slice::from_raw_parts(buf, len as usize); - let text = core::str::from_utf8(data).unwrap_or("(binary)"); - crate::write_log(&format!("lsx: send s={s} state={state} len={len} data={}\n", - &text[..text.len().min(300)])); - - let response = match state { - 1 => handle_challenge(sl, data), - st => handle_request(sl, data, st), - }; - - if let Some(payload) = response { - let mut guard = sl.pending.lock().unwrap_or_else(|e| e.into_inner()); - *guard = Some(payload); - } - sl.state.fetch_add(1, Ordering::Relaxed); - len -} - -// ─── handshake ─────────────────────────────────────────────────────────────── - -fn handle_challenge(sl: &LsxSlot, raw: &[u8]) -> Option> { - let text = core::str::from_utf8(raw).unwrap_or("").trim_end_matches('\0'); - let parts: Vec<&str> = text.split('"').collect(); - let id = parts.get(3).copied().unwrap_or("1"); - let key = parts.get(7).copied().unwrap_or(""); - crate::write_log(&format!("lsx: challenge id={id} key={key}\n")); - - let our_response = make_challenge_response(key); - let seed = compute_seed(&our_response); - sl.seed.store(seed as usize, Ordering::Relaxed); - crate::write_log(&format!("lsx: response={our_response} seed={seed}\n")); - - let xml = format!( - "\r\n \r\n \r\n \r\n\0" - ); - Some(xml.into_bytes()) -} - -fn compute_seed(hex: &str) -> u16 { - let b0 = u8::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0); - let b1 = u8::from_str_radix(&hex[2..4.min(hex.len())], 16).unwrap_or(0); - ((b0 as u16) << 8) | (b1 as u16) -} - -fn handle_request(sl: &LsxSlot, raw: &[u8], _state: usize) -> Option> { - let seed = sl.seed.load(Ordering::Relaxed) as u16; - let text = core::str::from_utf8(raw).unwrap_or("").trim_end_matches('\0'); - - let decrypted = lsx_decrypt(text, seed); - crate::write_log(&format!("lsx: request decrypted={}\n", &decrypted[..decrypted.len().min(300)])); - - let response_xml = dispatch_request(decrypted.trim()); - crate::write_log(&format!("lsx: response={}\n", &response_xml[..response_xml.len().min(300)])); - - let encrypted = lsx_encrypt(&response_xml, seed); - let payload = format!("{encrypted}\0"); - Some(payload.into_bytes()) -} - -// ─── session dispatcher ─────────────────────────────────────────────────────── - -pub fn dispatch(xml: &str) -> String { dispatch_request(xml) } - -fn dispatch_request(xml: &str) -> String { - let parts: Vec<&str> = xml.split('"').collect(); - let id = parts.get(3).copied().unwrap_or("1"); - let req_type = parts.get(4).copied().unwrap_or(""); - crate::write_log(&format!("lsx: dispatch id={id} type={req_type}\n")); - - match req_type { - ">\0") - } - } -} - -// ─── LSX response templates ─────────────────────────────────────────────────── - -fn get_config(id: &str) -> String { - format!(r#" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"#) -} - -fn get_auth_code(id: &str) -> String { - format!(r#" - - - -"#) -} - -fn get_internet_state(id: &str) -> String { - format!(r#" - - - -"#) -} - -fn get_profile(id: &str) -> String { - format!(r#" - - - -"#) -} - -fn get_setting(id: &str, setting: &str) -> String { - let value = match setting { "ENVIRONMENT" => "production", _ => "false" }; - format!(r#" - - - -"#) -} - -fn query_entitlements(id: &str) -> String { - format!(r#" - - - - - - -"#) -} - -fn request_license(id: &str) -> String { - format!(r#" - - - -"#) -} - -fn query_content(id: &str) -> String { - format!(r#" - - - - - -"#) -} - -fn get_block_list(id: &str) -> String { - format!(r#""#) -} - -fn query_friends(id: &str) -> String { - format!(r#""#) -} - -fn query_presence(id: &str) -> String { - format!(r#""#) -} - -fn set_presence(id: &str) -> String { - format!(r#""#) -} - -fn get_presence_visibility(id: &str) -> String { - format!(r#""#) -} - -fn get_wallet_balance(id: &str) -> String { - format!(r#""#) -} - -fn get_all_game_info(id: &str) -> String { - format!(r#""#) -} - -// ─── AES-128-ECB (pure Rust) ────────────────────────────────────────────────── - -#[rustfmt::skip] -const SBOX: [u8; 256] = [ - 0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76, - 0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0, - 0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15, - 0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75, - 0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84, - 0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf, - 0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8, - 0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2, - 0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73, - 0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb, - 0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79, - 0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08, - 0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a, - 0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e, - 0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf, - 0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16, -]; - -fn xtime(a: u8) -> u8 { if a & 0x80 != 0 { (a << 1) ^ 0x1b } else { a << 1 } } -fn mul(mut a: u8, mut b: u8) -> u8 { - let mut r = 0u8; - while b > 0 { if b & 1 != 0 { r ^= a; } a = xtime(a); b >>= 1; } - r -} - -fn sub_bytes(s: &mut [u8; 16]) { for b in s.iter_mut() { *b = SBOX[*b as usize]; } } -fn shift_rows(s: &mut [u8; 16]) { - let t = s[1]; s[1]=s[5]; s[5]=s[9]; s[9]=s[13]; s[13]=t; - s.swap(2,10); s.swap(6,14); - let t = s[15]; s[15]=s[11]; s[11]=s[7]; s[7]=s[3]; s[3]=t; -} -fn mix_col(s: &mut [u8; 16], c: usize) { - let (a,b,c2,d) = (s[c],s[c+4],s[c+8],s[c+12]); - s[c] = mul(2,a)^mul(3,b)^c2^d; - s[c+4] = a^mul(2,b)^mul(3,c2)^d; - s[c+8] = a^b^mul(2,c2)^mul(3,d); - s[c+12] = mul(3,a)^b^c2^mul(2,d); -} -fn mix_columns(s: &mut [u8; 16]) { for c in 0..4 { mix_col(s,c); } } -fn add_round_key(s: &mut [u8; 16], rk: &[u8; 16]) { for i in 0..16 { s[i] ^= rk[i]; } } - -fn expand_key(key: &[u8; 16]) -> [[u8; 16]; 11] { - let rcon: [u8; 10] = [0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36]; - let mut w = [[0u8; 4]; 44]; - for i in 0..4 { w[i] = [key[4*i],key[4*i+1],key[4*i+2],key[4*i+3]]; } - for i in 4..44 { - let mut t = w[i-1]; - if i % 4 == 0 { - t.rotate_left(1); - for b in &mut t { *b = SBOX[*b as usize]; } - t[0] ^= rcon[i/4-1]; - } - w[i] = [w[i-4][0]^t[0], w[i-4][1]^t[1], w[i-4][2]^t[2], w[i-4][3]^t[3]]; - } - let mut rk = [[0u8; 16]; 11]; - for r in 0..11 { for c in 0..4 { rk[r][4*c..4*c+4].copy_from_slice(&w[r*4+c]); } } - rk -} - -fn aes_block_encrypt(block: &[u8; 16], rk: &[[u8; 16]; 11]) -> [u8; 16] { - let mut s = *block; - add_round_key(&mut s, &rk[0]); - for r in 1..10 { sub_bytes(&mut s); shift_rows(&mut s); mix_columns(&mut s); add_round_key(&mut s, &rk[r]); } - sub_bytes(&mut s); shift_rows(&mut s); add_round_key(&mut s, &rk[10]); - s -} - -fn aes_ecb_pkcs7_encrypt(key: &[u8; 16], plaintext: &[u8]) -> Vec { - let rk = expand_key(key); - let pad = 16 - (plaintext.len() % 16); - let mut padded = plaintext.to_vec(); - padded.resize(plaintext.len() + pad, pad as u8); - let mut out = Vec::with_capacity(padded.len()); - for chunk in padded.chunks(16) { - let mut b = [0u8; 16]; b.copy_from_slice(chunk); - out.extend_from_slice(&aes_block_encrypt(&b, &rk)); - } - out -} - -#[rustfmt::skip] -const INV_SBOX: [u8; 256] = [ - 0x52,0x09,0x6a,0xd5,0x30,0x36,0xa5,0x38,0xbf,0x40,0xa3,0x9e,0x81,0xf3,0xd7,0xfb, - 0x7c,0xe3,0x39,0x82,0x9b,0x2f,0xff,0x87,0x34,0x8e,0x43,0x44,0xc4,0xde,0xe9,0xcb, - 0x54,0x7b,0x94,0x32,0xa6,0xc2,0x23,0x3d,0xee,0x4c,0x95,0x0b,0x42,0xfa,0xc3,0x4e, - 0x08,0x2e,0xa1,0x66,0x28,0xd9,0x24,0xb2,0x76,0x5b,0xa2,0x49,0x6d,0x8b,0xd1,0x25, - 0x72,0xf8,0xf6,0x64,0x86,0x68,0x98,0x16,0xd4,0xa4,0x5c,0xcc,0x5d,0x65,0xb6,0x92, - 0x6c,0x70,0x48,0x50,0xfd,0xed,0xb9,0xda,0x5e,0x15,0x46,0x57,0xa7,0x8d,0x9d,0x84, - 0x90,0xd8,0xab,0x00,0x8c,0xbc,0xd3,0x0a,0xf7,0xe4,0x58,0x05,0xb8,0xb3,0x45,0x06, - 0xd0,0x2c,0x1e,0x8f,0xca,0x3f,0x0f,0x02,0xc1,0xaf,0xbd,0x03,0x01,0x13,0x8a,0x6b, - 0x3a,0x91,0x11,0x41,0x4f,0x67,0xdc,0xea,0x97,0xf2,0xcf,0xce,0xf0,0xb4,0xe6,0x73, - 0x96,0xac,0x74,0x22,0xe7,0xad,0x35,0x85,0xe2,0xf9,0x37,0xe8,0x1c,0x75,0xdf,0x6e, - 0x47,0xf1,0x1a,0x71,0x1d,0x29,0xc5,0x89,0x6f,0xb7,0x62,0x0e,0xaa,0x18,0xbe,0x1b, - 0xfc,0x56,0x3e,0x4b,0xc6,0xd2,0x79,0x20,0x9a,0xdb,0xc0,0xfe,0x78,0xcd,0x5a,0xf4, - 0x1f,0xdd,0xa8,0x33,0x88,0x07,0xc7,0x31,0xb1,0x12,0x10,0x59,0x27,0x80,0xec,0x5f, - 0x60,0x51,0x7f,0xa9,0x19,0xb5,0x4a,0x0d,0x2d,0xe5,0x7a,0x9f,0x93,0xc9,0x9c,0xef, - 0xa0,0xe0,0x3b,0x4d,0xae,0x2a,0xf5,0xb0,0xc8,0xeb,0xbb,0x3c,0x83,0x53,0x99,0x61, - 0x17,0x2b,0x04,0x7e,0xba,0x77,0xd6,0x26,0xe1,0x69,0x14,0x63,0x55,0x21,0x0c,0x7d, -]; - -fn inv_sub_bytes(s: &mut [u8; 16]) { for b in s.iter_mut() { *b = INV_SBOX[*b as usize]; } } -fn inv_shift_rows(s: &mut [u8; 16]) { - let t = s[13]; s[13]=s[9]; s[9]=s[5]; s[5]=s[1]; s[1]=t; - s.swap(2,10); s.swap(6,14); - let t = s[3]; s[3]=s[7]; s[7]=s[11]; s[11]=s[15]; s[15]=t; -} -fn inv_mix_col(s: &mut [u8; 16], c: usize) { - let (a,b,c2,d) = (s[c],s[c+4],s[c+8],s[c+12]); - s[c] = mul(0x0e,a)^mul(0x0b,b)^mul(0x0d,c2)^mul(0x09,d); - s[c+4] = mul(0x09,a)^mul(0x0e,b)^mul(0x0b,c2)^mul(0x0d,d); - s[c+8] = mul(0x0d,a)^mul(0x09,b)^mul(0x0e,c2)^mul(0x0b,d); - s[c+12] = mul(0x0b,a)^mul(0x0d,b)^mul(0x09,c2)^mul(0x0e,d); -} -fn inv_mix_columns(s: &mut [u8; 16]) { for c in 0..4 { inv_mix_col(s,c); } } - -fn aes_ecb_decrypt_nopad(key: &[u8; 16], data: &[u8]) -> Vec { - let rk = expand_key(key); - let mut out = Vec::with_capacity(data.len()); - for chunk in data.chunks(16) { - if chunk.len() < 16 { break; } - let mut b = [0u8; 16]; b.copy_from_slice(chunk); - add_round_key(&mut b, &rk[10]); - inv_shift_rows(&mut b); inv_sub_bytes(&mut b); - for r in (1..10).rev() { - add_round_key(&mut b, &rk[r]); - inv_mix_columns(&mut b); inv_shift_rows(&mut b); inv_sub_bytes(&mut b); - } - add_round_key(&mut b, &rk[0]); - out.extend_from_slice(&b); - } - if let Some(&pad) = out.last() { - let pad = pad as usize; - if pad <= 16 && out.len() >= pad { out.truncate(out.len() - pad); } - } - out -} - -// ─── CRandom ───────────────────────────────────────────────────────────────── - -struct CRandom { seed: u32 } -impl CRandom { - fn new() -> Self { Self { seed: 0 } } - fn seed_with(&mut self, s: u32) { self.seed = s; } - fn rand(&mut self) -> u32 { - self.seed = self.seed.wrapping_mul(214013).wrapping_add(2531011); - (self.seed >> 16) & 0xFFFF - } -} - -fn get_lsx_key(seed: u16) -> [u8; 16] { - let mut rng = CRandom::new(); - rng.seed_with(7); - let next = rng.rand(); - rng.seed_with(next.wrapping_add(seed as u32)); - let mut k = [0u8; 16]; - for b in &mut k { *b = rng.rand() as u8; } - k -} - -// ─── session encrypt/decrypt ───────────────────────────────────────────────── - -fn hex_to_bytes(s: &str) -> Vec { - let s: String = s.chars().filter(|c| c.is_ascii_hexdigit()).collect(); - if s.len() % 2 != 0 { return Vec::new(); } - (0..s.len()/2).filter_map(|i| u8::from_str_radix(&s[2*i..2*i+2], 16).ok()).collect() -} - -fn bytes_to_hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect() -} - -pub fn lsx_decrypt(hex_data: &str, seed: u16) -> String { - let key = get_lsx_key(seed); - let ct = hex_to_bytes(hex_data); - if ct.is_empty() { return String::new(); } - let plain = aes_ecb_decrypt_nopad(&key, &ct); - String::from_utf8_lossy(&plain).trim_matches('\0').to_string() -} - -pub fn lsx_encrypt(text: &str, seed: u16) -> String { - let key = get_lsx_key(seed); - bytes_to_hex(&aes_ecb_pkcs7_encrypt(&key, text.as_bytes())) -} - -pub fn make_challenge_response(key: &str) -> String { - bytes_to_hex(&aes_ecb_pkcs7_encrypt(&AES_KEY, key.as_bytes())) -} diff --git a/openfut-hook/src/origin_spy.rs b/openfut-hook/src/origin_spy.rs deleted file mode 100644 index 8974945..0000000 --- a/openfut-hook/src/origin_spy.rs +++ /dev/null @@ -1,137 +0,0 @@ -/// Hooks RegQueryValueExA/W and OpenMutexA/W to log what the Origin SDK is checking. -use std::sync::OnceLock; - -type RegQueryValueExAFn = unsafe extern "system" fn( - hkey: isize, - lpvaluename: *const u8, - lpreserved: *mut u32, - lptype: *mut u32, - lpdata: *mut u8, - lpcbdata: *mut u32, -) -> i32; - -type RegQueryValueExWFn = unsafe extern "system" fn( - hkey: isize, - lpvaluename: *const u16, - lpreserved: *mut u32, - lptype: *mut u32, - lpdata: *mut u8, - lpcbdata: *mut u32, -) -> i32; - -type OpenMutexAFn = unsafe extern "system" fn(u32, i32, *const u8) -> isize; -type OpenMutexWFn = unsafe extern "system" fn(u32, i32, *const u16) -> isize; - -static REAL_REG_A: OnceLock = OnceLock::new(); -static REAL_REG_W: OnceLock = OnceLock::new(); -static REAL_MUTEX_A: OnceLock = OnceLock::new(); -static REAL_MUTEX_W: OnceLock = OnceLock::new(); - -pub fn set_real_reg_a(f: RegQueryValueExAFn) { - let _ = REAL_REG_A.set(f); -} -pub fn set_real_reg_w(f: RegQueryValueExWFn) { - let _ = REAL_REG_W.set(f); -} -pub fn set_real_mutex_a(f: OpenMutexAFn) { - let _ = REAL_MUTEX_A.set(f); -} -pub fn set_real_mutex_w(f: OpenMutexWFn) { - let _ = REAL_MUTEX_W.set(f); -} - -fn narrow_to_string(p: *const u8) -> String { - if p.is_null() { - return "(null)".into(); - } - let bytes = unsafe { std::ffi::CStr::from_ptr(p as *const i8) }; - bytes.to_string_lossy().into_owned() -} - -fn wide_to_string(p: *const u16) -> String { - if p.is_null() { - return "(null)".into(); - } - let mut len = 0usize; - unsafe { - while *p.add(len) != 0 { - len += 1; - } - } - String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) }) -} - -fn is_interesting(name: &str) -> bool { - name.contains("LSX") - || name.contains("Origin") - || name.contains("EAL") - || name.contains("Client") - || name.contains("lsx") - || name.contains("Port") - || name.contains("EA") - || name.contains("Connection") -} - -pub unsafe extern "system" fn hooked_reg_query_a( - hkey: isize, - lpvaluename: *const u8, - lpreserved: *mut u32, - lptype: *mut u32, - lpdata: *mut u8, - lpcbdata: *mut u32, -) -> i32 { - let name = narrow_to_string(lpvaluename); - let real = REAL_REG_A.get().copied().unwrap(); - let ret = real(hkey, lpvaluename, lpreserved, lptype, lpdata, lpcbdata); - if is_interesting(&name) { - crate::write_log(&format!("origin_spy: RegQueryValueExA({name}) → {ret}\n")); - } - ret -} - -pub unsafe extern "system" fn hooked_reg_query_w( - hkey: isize, - lpvaluename: *const u16, - lpreserved: *mut u32, - lptype: *mut u32, - lpdata: *mut u8, - lpcbdata: *mut u32, -) -> i32 { - let name = wide_to_string(lpvaluename); - let real = REAL_REG_W.get().copied().unwrap(); - let ret = real(hkey, lpvaluename, lpreserved, lptype, lpdata, lpcbdata); - if is_interesting(&name) { - crate::write_log(&format!("origin_spy: RegQueryValueExW({name}) → {ret}\n")); - } - ret -} - -pub unsafe extern "system" fn hooked_open_mutex_a( - dwdesiredaccess: u32, - binherithandle: i32, - lpmutexname: *const u8, -) -> isize { - let name = narrow_to_string(lpmutexname); - let real = REAL_MUTEX_A.get().copied().unwrap(); - let handle = real(dwdesiredaccess, binherithandle, lpmutexname); - crate::write_log(&format!( - "origin_spy: OpenMutexA({name}) → {}\n", - if handle == 0 { "NOT_FOUND" } else { "FOUND" } - )); - handle -} - -pub unsafe extern "system" fn hooked_open_mutex_w( - dwdesiredaccess: u32, - binherithandle: i32, - lpmutexname: *const u16, -) -> isize { - let name = wide_to_string(lpmutexname); - let real = REAL_MUTEX_W.get().copied().unwrap(); - let handle = real(dwdesiredaccess, binherithandle, lpmutexname); - crate::write_log(&format!( - "origin_spy: OpenMutexW({name}) → {}\n", - if handle == 0 { "NOT_FOUND" } else { "FOUND" } - )); - handle -} diff --git a/openfut-hook/src/probe.rs b/openfut-hook/src/probe.rs deleted file mode 100644 index 49272e8..0000000 --- a/openfut-hook/src/probe.rs +++ /dev/null @@ -1,1747 +0,0 @@ -//! In-process RE probes: passive logging detours on FIFA's online-flow functions. -//! -//! Purpose: FIFA reacts to our pushed LSX events (OnlineStatusEvent/Login) but -//! never starts the GetAuthCode→Nucleus→Blaze chain, and that decision lives in -//! FIFA's game-side / in-process EbisuSDK logic that is invisible from the LSX -//! wire. These probes log when the key online-flow functions are called (and -//! their return values), so we can SEE where FIFA stalls after our events. -//! -//! Mechanism: the same unhook/rehook detour `connect_hook` uses — on entry we -//! restore the original bytes, log, call the real function, then re-install the -//! jump. This needs no trampoline/relocation, so it works even on prologues with -//! RIP-relative operands (e.g. GoOnline). Not thread-safe (a concurrent call -//! during the unhook window runs un-logged) but never corrupts the target — fine -//! for read-only RE. -//! -//! Signature assumption: each probed fn takes ≤4 integer args (Win64: rcx/rdx/ -//! r8/r9) and returns in rax. All targets here are SDK methods with few args. -use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; -use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; -use windows_sys::Win32::System::Memory::{ - VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, MEM_PRIVATE, - PAGE_EXECUTE_READWRITE, PAGE_GUARD, PAGE_NOACCESS, -}; -use windows_sys::Win32::System::Threading::GetCurrentThreadId; - -/// Fault-safe pointer read: returns None unless `ptr` lands in a committed, readable -/// page (checked via VirtualQuery). Avoids crashing FIFA when we sample pointers that -/// may be null/garbage mid-construction. -unsafe fn read_ptr(ptr: usize) -> Option { - 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::(), - ); - if n == 0 || mbi.State != MEM_COMMIT { - return None; - } - if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { - return None; - } - // Ensure the full 8 bytes are inside the region. - if ptr + 8 > mbi.BaseAddress as usize + mbi.RegionSize { - return None; - } - Some(core::ptr::read_volatile(ptr as *const usize)) -} - -/// Poll the Nucleus session-context chain directly, independent of the login state -/// machine: X = OriginSDK singleton [FIFA23.exe+0xacd02c0]; M = [X+0x360] (online/ -/// Nucleus manager); ctx = [M+0x778] (the session context that nucleusConnectREST/ -/// Trusted null-check). Logs each level so we see exactly if/when the context is -/// created. All reads are VirtualQuery-guarded, so a null/garbage pointer is logged, -/// never dereferenced blind. -pub fn install_state_sampler() { - std::thread::spawn(|| unsafe { - let base = GetModuleHandleA(core::ptr::null()); - if base.is_null() { - crate::write_log("SAMPLE: main exe not found\n"); - return; - } - let x_slot = base as usize + 0xacd02c0; // VA 0x14acd02c0 - let mut last = String::new(); - for i in 0..180u32 { - std::thread::sleep(std::time::Duration::from_millis(1000)); - let line = match read_ptr(x_slot) { - None => "X=".to_string(), - Some(0) => "X=null".to_string(), - Some(x) => match read_ptr(x + 0x360) { - None => format!("X={x:#x} M="), - Some(0) => format!("X={x:#x} M=null"), - Some(m) => match read_ptr(m + 0x778) { - None => format!("X={x:#x} M={m:#x} ctx="), - Some(c) => format!("X={x:#x} M={m:#x} ctx[M+0x778]={c:#x}"), - }, - }, - }; - // Only log on change (plus a heartbeat every 20s) to keep the log clean. - if line != last || i % 20 == 0 { - crate::write_log(&format!("SAMPLE #{i} {line}\n")); - last = line; - } - } - }); -} - -// ─── live game-side listener capture ──────────────────────────────────────────── -// -// OnlineStatusEventT::HandleMessage dispatches the parsed bool to the game's online -// listener via a virtual call `call [rax+0x28]` at FIFA23.exe+0x274d4e2, where rax -// is the vtable of the object at [rsi-0x38]. The concrete listener is only known at -// runtime. We capture it with a behavior-preserving mid-function detour: patch the -// 14 bytes at +0x274d4d7 (which are exactly `lea rcx,[rsi-0x38]; mov rax,[rcx]; lea -// rdx,[rbp-0x49]; call [rax+0x28]`) to jump to a stub that replicates those four -// instructions but logs the resolved listener address in between, then resumes at -// +0x274d4e5. Non-volatile regs (rsi/rbp/…) are preserved by the ABI; volatiles -// match the original dispatch's clobbers. - -/// Runtime absolute address to resume at after the replicated dispatch -/// (main-exe base + 0x274d4e5). Read by the asm stub. -#[no_mangle] -static mut RESUME_ADDR: u64 = 0; -static MAIN_BASE: AtomicUsize = AtomicUsize::new(0); -static LISTENER_LOGGED: AtomicBool = AtomicBool::new(false); - -/// Called by the stub with the listener object's vtable and the resolved listener -/// function pointer (vtable[0x28]). Logs once (RVAs for static RE). -unsafe extern "C" fn listener_log(vtable: usize, func: usize) { - // Run the dial trigger on EVERY event dispatch — it self-gates internally (kill - // switch, one-shot latch, precondition checks). This must run BEFORE the - // LISTENER_LOGGED one-shot below, which returns on all but the very first fire. - dial_trigger_tick(); - connmgr_enum_tick(); - elem_watch_tick(); - if LISTENER_LOGGED.swap(true, Ordering::Relaxed) { - return; - } - let base = MAIN_BASE.load(Ordering::Relaxed); - crate::write_log(&format!( - "PROBE OnlineStatus.listener: vtable={vtable:#x} (rva {:#x}) fn={func:#x} (rva {:#x})\n", - vtable.wrapping_sub(base), - func.wrapping_sub(base), - )); -} - -core::arch::global_asm!( - ".intel_syntax noprefix", - ".global openfut_listener_stub", - "openfut_listener_stub:", - "lea rcx, [rsi - 0x38]", - "mov rax, [rcx]", // rax = listener vtable - "mov rdx, [rax + 0x28]", // rdx = listener fn (arg2) - "mov rcx, rax", // rcx = vtable (arg1) - // Align the stack to 16 before the call, saving the original rsp so we can - // restore it (0x28 misaligns; SSE code in the logger then faults). - "mov r11, rsp", - "and rsp, -16", - "sub rsp, 0x30", // 0x20 shadow + 0x10 spare, stays 16-aligned - "mov [rsp + 0x20], r11", // stash original rsp - "call {log}", - "mov rsp, [rsp + 0x20]", // restore original rsp - "lea rcx, [rsi - 0x38]", // replicate the original dispatch - "mov rax, [rcx]", - "lea rdx, [rbp - 0x49]", - "call qword ptr [rax + 0x28]", - "mov r10, qword ptr [rip + {resume}]", - "jmp r10", - ".att_syntax prefix", - log = sym listener_log, - resume = sym RESUME_ADDR, -); - -extern "C" { - fn openfut_listener_stub(); -} - -/// Patch the OnlineStatusEvent dispatch site to route through the logging stub. -pub unsafe fn install_listener_probe() { - let base = GetModuleHandleA(core::ptr::null()); - if base.is_null() { - crate::write_log("PROBE listener: main exe not found\n"); - return; - } - let base = base as usize; - MAIN_BASE.store(base, Ordering::Relaxed); - // Arm the dial trigger from the env var, ONCE, at install (DLL-load) time. Default - // disarmed: OPENFUT_DIAL_TRIGGER must be explicitly "1". Orthogonal to the pump/ctx - // env vars. - let armed = std::env::var("OPENFUT_DIAL_TRIGGER") - .map(|v| v == "1") - .unwrap_or(false); - DIAL_ARMED.store(armed, Ordering::Relaxed); - crate::write_log(&format!( - "DIAL_TRIGGER: {} (env OPENFUT_DIAL_TRIGGER)\n", - if armed { "ARMED" } else { "disarmed" } - )); - // Arm the (independent) connMgr enumeration from its own env var, once, at load. - let enum_armed = std::env::var("OPENFUT_CONNMGR_ENUM") - .map(|v| v == "1") - .unwrap_or(false); - CONNMGR_ENUM_ARMED.store(enum_armed, Ordering::Relaxed); - crate::write_log(&format!( - "CONNMGR_ENUM: {} (env OPENFUT_CONNMGR_ENUM)\n", - if enum_armed { "ARMED" } else { "disarmed" } - )); - // Arm the (independent) [element+0x40] container-writer watchpoint from its own - // env var, once, at load. Orthogonal to DIAL_TRIGGER / CONNMGR_ENUM. - let elem_watch_armed = std::env::var("OPENFUT_ELEM_WATCH") - .map(|v| v == "1") - .unwrap_or(false); - ELEM_WATCH_ARMED.store(elem_watch_armed, Ordering::Relaxed); - crate::write_log(&format!( - "ELEM_WATCH: {} (env OPENFUT_ELEM_WATCH)\n", - if elem_watch_armed { - "ARMED" - } else { - "disarmed" - } - )); - RESUME_ADDR = (base + 0x274d4e5) as u64; - let target = (base + 0x274d4d7) as *mut u8; - write_jmp(target, openfut_listener_stub as usize as u64); - crate::write_log(&format!( - "PROBE listener: dispatch site patched @ {:#x}\n", - target as usize - )); -} - -// ─── dial trigger (sub-phase B) ────────────────────────────────────────────────── -// -// Extends the listener probe (NOT a new detour): `dial_trigger_tick()` runs on every -// OnlineStatusEvent dispatch — on the game's online-servicing thread — and, once every -// precondition lines up, calls the redirector dial handler 0x144f4d360 directly with -// (connMgr, synthetic-notification). One-shot, env-gated, heavily guarded and logged. -// -// Addresses (all from prior confirmed reports; RVA = VA − base, base = main-exe module): -// dial handler base+0x4f4d360 (VA 0x144f4d360) rcx=connMgr, rdx=notification -// NetConnStatus base+0xef17f0 (VA 0x140ef17f0) ecx='conn' -> eax status -// G (OriginSDK global) base+0xacd02c0 (VA 0x14acd02c0) -// connMgr vtable base+0x80200b8; M=*[G+0x360]; ctx=*[M+0x778]; connMgr scan. - -/// Kill switch, read once at DLL load from OPENFUT_DIAL_TRIGGER (see install_listener_probe). -static DIAL_ARMED: AtomicBool = AtomicBool::new(false); -/// One-shot latch. Claimed (false→true) immediately BEFORE the dial call so a re-entrant -/// dispatch can't double-fire; also set on the FATAL sanity failure. -static DIAL_TRIGGER_FIRED: AtomicBool = AtomicBool::new(false); -/// Thread id of the first listener-probe fire (the online-servicing thread). 0 until seen. -static LISTENER_TID: AtomicU32 = AtomicU32::new(0); -/// Last completion-stub count we logged, so we only log on change. -static LAST_COMPLETION_COUNT: AtomicU32 = AtomicU32::new(0); -/// Rate-limit state for skip logging: the reason currently being counted, and how many. -static LAST_SKIP_REASON: AtomicU32 = AtomicU32::new(0); -static SKIP_COUNT: AtomicU32 = AtomicU32::new(0); - -/// Public snapshot of trigger state, for future polling (not needed for correctness). -/// (A `#[repr(C)]` layout would matter only if C code read this; plain Rust is fine here.) -#[allow(dead_code)] -pub struct DialTriggerStatus { - pub kill_switch_armed: bool, - pub latch_fired: bool, - pub listener_thread_id: u32, - pub pump_thread_id: u32, - pub last_completion_count: u32, -} - -#[allow(dead_code)] -pub fn dial_trigger_status() -> DialTriggerStatus { - DialTriggerStatus { - kill_switch_armed: DIAL_ARMED.load(Ordering::Relaxed), - latch_fired: DIAL_TRIGGER_FIRED.load(Ordering::Relaxed), - listener_thread_id: LISTENER_TID.load(Ordering::Relaxed), - pump_thread_id: netconn_thread_id(), - last_completion_count: LAST_COMPLETION_COUNT.load(Ordering::Relaxed), - } -} - -/// Rate-limited skip logger: logs the first 3 skips of a given reason, then one -/// "suppressed" line, then goes silent for that reason. Counters reset when the reason -/// changes, so a *new* failure mode logs fresh. -fn log_skip(reason_id: u32, msg: &str) { - // `swap` sets the current reason and returns the previous one; if it changed, reset. - if LAST_SKIP_REASON.swap(reason_id, Ordering::Relaxed) != reason_id { - SKIP_COUNT.store(0, Ordering::Relaxed); - } - let n = SKIP_COUNT.fetch_add(1, Ordering::Relaxed); - if n < 3 { - crate::write_log(msg); - } else if n == 3 { - crate::write_log("DIAL_TRIGGER: (further skips of this reason suppressed)\n"); - } -} - -/// After the dial has fired, log the completion-stub counter whenever it changes — our -/// signal that the enqueued RpcJob actually ran (Tier-2 success). -fn observe_completion() { - let c = crate::dial_notification::completion_stub_call_count(); - let last = LAST_COMPLETION_COUNT.swap(c, Ordering::Relaxed); - if c != last { - crate::write_log(&format!( - "DIAL_TRIGGER: completion stub count changed {last} → {c}\n" - )); - } -} - -/// The trigger. Runs on every listener-probe fire; self-gates so it fires the dial at -/// most once, only when every precondition holds. Called from `listener_log`, which the -/// asm stub invokes with a properly-aligned stack and shadow space. -unsafe fn dial_trigger_tick() { - // Step 1 — kill switch (env value cached at load). - if !DIAL_ARMED.load(Ordering::Relaxed) { - return; - } - // Step 2 — latch. Once fired, only keep watching the completion counter. - // `SeqCst` (sequentially consistent) is the strongest, simplest-to-reason-about - // ordering; for a gate that decides whether we perform an action, we prefer that - // safety over the (subtle) minimum `Relaxed` would allow. - if DIAL_TRIGGER_FIRED.load(Ordering::SeqCst) { - observe_completion(); - return; - } - let base = MAIN_BASE.load(Ordering::Relaxed); - if base == 0 { - return; - } - - // Step 3 — thread capture + self-consistency + pump-contrast sanity. - // GetCurrentThreadId: Win32 FFI (no args, returns this thread's id). It's `unsafe` - // only because it's a foreign call; it has no preconditions and no side effects. - let tid = GetCurrentThreadId(); - let stored = LISTENER_TID.load(Ordering::Relaxed); - if stored == 0 { - LISTENER_TID.store(tid, Ordering::Relaxed); - let pump = netconn_thread_id(); - crate::write_log(&format!( - "DIAL_TRIGGER: first listener fire, thread_id={tid}, pump_id={pump}\n" - )); - // Pump-contrast sanity: the listener must NOT be our own background pump thread. - // A match here means our whole thread model is broken — abort permanently. - if tid == pump && pump != 0 { - crate::write_log( - "DIAL_TRIGGER: FATAL — listener thread matches pump thread; aborting\n", - ); - DIAL_TRIGGER_FIRED.store(true, Ordering::SeqCst); - return; - } - } else if stored != tid { - log_skip( - 1, - &format!("DIAL_TRIGGER: listener thread varied (was {stored}, now {tid}) — skipping\n"), - ); - return; - } - - // Step 4 — conn state must be '+onl'. NetConnStatus(rcx='conn',0,0,0) -> eax. - let netconn_status: unsafe extern "system" fn(u32, usize, usize, usize) -> u32 = - core::mem::transmute(base + 0xef17f0); - let status = netconn_status(0x636f6e6e, 0, 0, 0); - if status != 0x2b6f6e6c { - log_skip( - 2, - &format!("DIAL_TRIGGER: not +onl (conn=0x{status:08x}) — skipping\n"), - ); - return; - } - - // Step 5 — resolve connMgr (reuse the ctx-dump scan + tiebreaker). - let Some(g) = read_ptr(base + 0xacd02c0).filter(|&x| x != 0) else { - log_skip( - 3, - "DIAL_TRIGGER: connMgr resolution failed (G null) — skipping\n", - ); - return; - }; - let Some(m) = read_ptr(g + 0x360).filter(|&x| x != 0) else { - log_skip( - 3, - "DIAL_TRIGGER: connMgr resolution failed (M null) — skipping\n", - ); - return; - }; - let expected_vtable = base + 0x80200b8; - let mut stats = (0u64, 0u64); - let cands = scan_conn_mgr(m, expected_vtable, &mut stats); - // Tiebreaker: first candidate whose vtable[0] lands in .text (a real live object). - let mut conn_mgr = 0usize; - for &p in &cands { - let vt0 = read_ptr(read_ptr(p).unwrap_or(0)).unwrap_or(0); - if in_text(base, vt0) { - conn_mgr = p; - break; - } - } - if conn_mgr == 0 { - log_skip( - 3, - &format!( - "DIAL_TRIGGER: connMgr resolution failed ({} candidate(s), none clean) — skipping\n", - cands.len() - ), - ); - return; - } - - // Step 6 — ctx sanity: [connMgr+8]=M, [M+0x778]=ctx must be a live object whose - // vtable pointer lands in the module image (0x140000000..0x161000000). - let m_holder = read_ptr(conn_mgr + 8).unwrap_or(0); - let ctx = read_ptr(m_holder + 0x778).unwrap_or(0); - let ctx_vt = read_ptr(ctx).unwrap_or(0); - if ctx == 0 || !(0x140000000..0x161000000).contains(&ctx_vt) { - log_skip( - 4, - &format!( - "DIAL_TRIGGER: ctx pointer implausible (ctx={ctx:#x} vt={ctx_vt:#x}) — skipping\n" - ), - ); - return; - } - - // All preconditions hold. Claim the one-shot latch ATOMICALLY, right before the call, - // so a re-entrant OnlineStatusEvent dispatch can't double-fire the dial. `compare_ - // exchange(false→true)` is a proper CAS: exactly one caller wins; a loser bails. (I - // use CAS rather than `swap`/load-then-store because it both claims and checks in one - // atomic step — the correct primitive for a one-shot "exactly one winner" latch.) - // NOTE: this sets the latch just before the call rather than just after (as the task - // sketch said) specifically to close the re-entrancy window; preconditions that fail - // above still leave the latch clear, so they retry on later fires as intended. - if DIAL_TRIGGER_FIRED - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return; - } - - // Step 7 — build the notification with a 'static lifetime via Box::leak. - // `Box::new(...)` heap-allocates the [u8;0x100]; `Box::leak` converts that owned Box - // into a `&'static mut [u8;0x100]` by intentionally NOT running its destructor — the - // 256 bytes live for the whole process. We do this so the buffer outlives anything - // the dial branch might retain a pointer into. The RE showed only value-copies out of - // the notification, so a stack buffer would *probably* be safe — but a permanent - // buffer removes all doubt on the highest-stakes call in the project, and the leak - // happens exactly once, so it's harmless. - let notif: &'static mut [u8; 0x100] = - Box::leak(Box::new(crate::dial_notification::build_notification())); - let notif_ptr = notif.as_ptr(); - - // Step 8 — fire the dial. - // `unsafe extern "system" fn(*mut u8, *const u8) -> usize`: on 64-bit Windows the - // "system" ABI *is* the Win64 calling convention (integer args in rcx, rdx, r8, r9; - // return in rax) — exactly what the game function expects (rcx=connMgr, rdx=notif). - // We `transmute` the resolved code address into this typed fn so the compiler emits a - // correct Win64 call (right registers, 32-byte shadow space, return read from rax); a - // bare pointer carries no ABI and couldn't be called correctly. The return TYPE is an - // inference — we log whatever integer comes back regardless of what it means. - let dial: unsafe extern "system" fn(*mut u8, *const u8) -> usize = - core::mem::transmute(base + 0x4f4d360); - crate::write_log(&format!( - "DIAL_TRIGGER: calling 0x144f4d360 connMgr={conn_mgr:#x} notification={:#x}\n", - notif_ptr as usize - )); - // Force the pre-call line to disk BEFORE the call — if the dial faults, this line is - // how we know we reached the call site (crash RIP would be inside the dial branch). - crate::flush_log(); - // THE dial call — the most consequential unsafe in the project. We hand the game its - // own dial handler with a real connMgr (resolved + ctx-checked) and our synthetic - // notification (matching the RE'd contract). This is sound iff: we're on the online - // thread (step 3), conn=='+onl' (step 4), connMgr is a valid live object (step 5), - // ctx is populated (step 6), and the notification matches the contract (unit-tested - // dial_notification). If any of those is wrong the game may fault inside the dial - // branch — which is normal .text and therefore diagnosable. We accept that risk. - let ret = dial(conn_mgr as *mut u8, notif_ptr); - crate::write_log(&format!("DIAL_TRIGGER: dial returned {ret:#x}\n")); - - // Step 10 — start watching the completion counter (Tier-2 signal on later fires). - observe_completion(); -} - -// ─── connMgr candidate enumeration (read-only diagnostic) ──────────────────────── -// -// After sub-phase B crashed on the `[connMgr+0x18]!=0` dial sub-path, we want to know -// whether OTHER connMgr instances exist and — critically — what each one's `[+0x18]` is. -// A candidate with `[+0x18]==0` would route the dial through the crash-free "create" -// branch (0x144f4d5fd). This enumerates EVERY candidate the resolver's scan finds (not -// just the first) and logs each one's branch-relevant fields. Pure observation: no dial, -// no writes, no game calls. Reuses `scan_conn_mgr` (which already returns all matches), -// `read_ptr`, `read_bytes`, `hex_dump`, `in_text` — nothing in sub-phase B is touched. - -/// Kill switch, read once at DLL load from OPENFUT_CONNMGR_ENUM (independent of the -/// dial/pump/ctx switches). -static CONNMGR_ENUM_ARMED: AtomicBool = AtomicBool::new(false); -/// One-shot latch: enumerate exactly once per process. -static CONNMGR_ENUM_DONE: AtomicBool = AtomicBool::new(false); -/// Retry counter: if the first scans find nothing (connMgr not built yet), retry a few -/// listener fires before committing to a "zero candidates" verdict (avoids a false zero -/// from a timing race). -static CONNMGR_ENUM_TRIES: AtomicU32 = AtomicU32::new(0); - -/// One connMgr candidate's branch-relevant fields. -/// -/// Derives: `Debug` for ad-hoc `{:?}` debugging (note it prints integers in *decimal*, -/// so the log lines below format hex explicitly). `Clone, Copy` because it's a small -/// plain-old-data struct (all `usize`/`bool`/`Option`, every field itself `Copy`) -/// — copying is trivial and it lets us collect into a `Vec` and re-scan it for the -/// summary without any borrow-checker friction. -#[derive(Debug, Clone, Copy)] -struct CandidateInfo { - p: usize, - vtable: usize, - vtable0: usize, - vtable0_in_text: bool, - field_18: Option, // [P+0x18] — THE branch-selection field (0 => safe branch) - field_20: Option, // [P+0x20] — ordered-container head (expect 0 at menu) - field_30: Option, // [P+0x30] — ordered-container head (expect 0 at menu) - field_c38: Option, // [P+0xc38] — dispatcher pointer -} - -/// Enumerate all connMgr candidates and log each one's `[+0x18]`. Runs once, on the -/// listener thread, gated on the pump running (menu reached) — NOT on '+onl'. -unsafe fn connmgr_enum_tick() { - // Step 1 — kill switch. - if !CONNMGR_ENUM_ARMED.load(Ordering::Relaxed) { - return; - } - // Step 2 — one-shot: already enumerated? - if CONNMGR_ENUM_DONE.load(Ordering::Relaxed) { - return; - } - // Step 3 — gate on the pump having fired (a cheap "we're at the menu" signal). We do - // NOT gate on '+onl': connMgr exists at the menu, and enumerating early gives us more - // time. Requires OPENFUT_NETCONN_PUMP=1 (else netconn_thread_id stays 0 forever). - if netconn_thread_id() == 0 { - return; - } - let base = MAIN_BASE.load(Ordering::Relaxed); - if base == 0 { - return; - } - - // Step 4 — resolve M. On a null link, DON'T latch — just retry on the next fire. - let Some(g) = read_ptr(base + 0xacd02c0).filter(|&x| x != 0) else { - return; - }; - let Some(m) = read_ptr(g + 0x360).filter(|&x| x != 0) else { - return; - }; - - // Step 5 — scan for EVERY candidate. `scan_conn_mgr` already collects all matches of - // `[P+0]==vtable && [P+8]==M` (the resolver just picks the first clean one on top), so - // we reuse it unchanged — the enumeration and the resolver share one scan. - let expected_vtable = base + 0x80200b8; - let mut stats = (0u64, 0u64); - let t0 = std::time::Instant::now(); - let cands = scan_conn_mgr(m, expected_vtable, &mut stats); - let scan_ms = t0.elapsed().as_millis(); - - // If empty, it may be a timing race (connMgr not built yet). Retry up to 8 fires - // before accepting a genuine "zero" — `fetch_add` returns the prior count. - if cands.is_empty() && CONNMGR_ENUM_TRIES.fetch_add(1, Ordering::Relaxed) < 8 { - return; - } - // Commit exactly once. - if CONNMGR_ENUM_DONE.swap(true, Ordering::Relaxed) { - return; - } - - crate::write_log(&format!( - "CONNMGR_ENUM: G={g:#x} M={m:#x} scanned {} regions / {} MB in {scan_ms}ms — {} candidate(s)\n", - stats.0, - stats.1 / (1024 * 1024), - cands.len() - )); - - // A `Vec` is fine here: this runs exactly once, on a ~2s-cadence event - // callback (not a hot path), and we're already allocating (format! strings, and - // scan_conn_mgr's own Vec). A handful of candidates is trivial. (Idiomatic-but-beyond- - // beginner alternatives noted for later: an `impl Iterator` scan to avoid the interim - // Vec, or Rayon to parallelise the region sweep — neither is worth it for a one-shot.) - let mut infos: Vec = Vec::new(); - // Render an Option as hex, or "" if the field couldn't be read. - let h = |o: Option| match o { - Some(v) => format!("{v:#x}"), - None => "".to_string(), - }; - for (i, &p) in cands.iter().enumerate() { - // SAFE: `p` came from scan_conn_mgr, which only emits addresses inside committed, - // readable heap regions (VirtualQuery-classified). read_ptr re-checks each read is - // 8-aligned and committed, returning None rather than dereferencing bad memory, so - // every field read below is guarded — a garbage/partial object logs, never faults. - let vtable = read_ptr(p).unwrap_or(0); - let vtable0 = read_ptr(vtable).unwrap_or(0); - let info = CandidateInfo { - p, - vtable, - vtable0, - vtable0_in_text: in_text(base, vtable0), - field_18: read_ptr(p + 0x18), - field_20: read_ptr(p + 0x20), - field_30: read_ptr(p + 0x30), - field_c38: read_ptr(p + 0xc38), - }; - infos.push(info); - crate::write_log(&format!( - "CONNMGR_ENUM: [{i}] P={p:#x} vt={vtable:#x} vt[0]={vtable0:#x} ({}) \ - [+0x18]={} [+0x20]={} [+0x30]={} [+0xc38]={}\n", - if info.vtable0_in_text { - "in .text" - } else { - "NOT .text" - }, - h(info.field_18), - h(info.field_20), - h(info.field_30), - h(info.field_c38), - )); - // 64-byte hex+ASCII (reuses the ctx-dump helpers): offset | 16 hex | ASCII. - if let Some(bytes) = read_bytes(p, 64) { - hex_dump(&format!("[{i}] P"), p, &bytes); - } - } - - // Summary: count the branch-relevant split on [+0x18], and note the resolver's pick. - let with_zero = infos.iter().filter(|c| c.field_18 == Some(0)).count(); - let with_nonzero = infos - .iter() - .filter(|c| matches!(c.field_18, Some(v) if v != 0)) - .count(); - // Which candidate sub-phase B's resolver would pick: first with vtable[0] in .text. - let pick = infos.iter().find(|c| c.vtable0_in_text).map(|c| c.p); - crate::write_log(&format!( - "CONNMGR_ENUM: SUMMARY total={} [+0x18]==0(safe)={} [+0x18]!=0(crash)={} subphaseB_pick={}\n", - infos.len(), - with_zero, - with_nonzero, - pick.map(|p| format!("{p:#x}")).unwrap_or_else(|| "none".to_string()), - )); - // One at-a-glance OUTCOME line mapping to the four expected cases. - let outcome = if infos.is_empty() { - "zero (unexpected — timing or bug)" - } else if with_zero > 0 { - "has-safe-candidate (PROMISING — a [+0x18]==0 connMgr exists → try selecting it)" - } else if infos.len() == 1 { - "single-all-nonzero (no alternate connMgr → needs option 2: container init)" - } else { - "multiple-all-nonzero (all route to crash branch → needs option 2)" - }; - crate::write_log(&format!("CONNMGR_ENUM: OUTCOME = {outcome}\n")); -} - -// ─── [element+0x40] container-writer watchpoint ────────────────────────────────── -// -// The sub-phase B dial crashed at 0x144fd6b6c reading a garbage `begin` pointer out -// of the per-connection message-handler flat-map stored at `element+0x40` (element = -// [ctx+0x1a8] + target_index*0x90). Static RE proved that container is filled by the -// connection *lifecycle*, not by a discrete callable init — but couldn't say *when* -// or *by whom* it goes from garbage/empty to populated. This probe answers that at -// runtime, purely by observation: -// -// Phase 1 (one-shot, on the listener/game thread): snapshot the array shape -// (array_base, sub_object, count, target_index), locate `element`, hex-dump it, -// and classify [element+0x40] as null-init / uninitialized / initialized. -// Phase 2 (background thread, 100ms poll): watch [element+0x40] for the moment it -// changes, logging the new value + surrounding bytes + live conn fourcc + uptime. -// -// It is a POLLING watchpoint, not a hardware one: user-mode code can't cheaply set a -// debug-register / page-guard write-watch on another thread's writes without acting as -// a debugger, so a 100ms read poll is the pragmatic read-only choice (a fast writer -// could in theory change-then-change-back between polls, but the container fill we care -// about is a one-way garbage→populated transition, which a poll catches reliably). -// -// Read-only throughout: no writes, no dial, no game calls (the conn fourcc is read from -// the NetConn status word in memory, not via NetConnStatus). Reuses scan_conn_mgr / -// read_ptr / read_bytes. - -/// Kill switch, read once at DLL load from OPENFUT_ELEM_WATCH (independent of the -/// dial / pump / enum / ctx switches). -static ELEM_WATCH_ARMED: AtomicBool = AtomicBool::new(false); -/// One-shot latch: take the snapshot (and arm the watcher) exactly once per process. -static ELEM_WATCH_DONE: AtomicBool = AtomicBool::new(false); -/// Retry counter: if the first scans can't resolve connMgr yet, retry a few listener -/// fires before giving up (avoids a false "unresolved" from a timing race). -static ELEM_WATCH_TRIES: AtomicU32 = AtomicU32::new(0); -/// Absolute VA of the watched element (set by the snapshot, read by the watcher). 0 = -/// not yet armed / no valid element. -static ELEM_WATCH_ELEM: AtomicUsize = AtomicUsize::new(0); -/// Last-seen value of [element+0x40]. Seeded by the snapshot; the watcher compares each -/// poll against it and updates it on a change. `AtomicUsize` (not a plain `usize`) -/// because the snapshot writes it on the game thread and the watcher reads+writes it on -/// its own thread — the atomic gives well-defined cross-thread access with no lock. -/// `Relaxed` is enough: we only compare the value, with no ordering vs other memory. -static ELEM_WATCH_BASELINE: AtomicUsize = AtomicUsize::new(0); - -/// Read a 32-bit little-endian value at `addr`, VirtualQuery-guarded via `read_bytes`. -/// `read_ptr` can't be reused for these fields: it reads 8 bytes and *requires 8-byte -/// alignment*, but `sub_object+0x51c` (the count) is 4-aligned only. `read_bytes` has no -/// alignment requirement and clamps to the committed region, so it's the safe primitive. -unsafe fn read_u32(addr: usize) -> Option { - let b = read_bytes(addr, 4)?; - if b.len() < 4 { - return None; // region ended mid-field — treat as unreadable - } - Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) -} - -/// Render a 32-bit fourcc (e.g. NetConn's `'+onl'` = 0x2b6f6e6c) as its 4 ASCII chars, -/// low byte first (matching how the game packs `('+','o','n','l')`). Non-printable bytes -/// show as '.'. Purely for readable logs. -fn fourcc4(v: u32) -> String { - let b = v.to_le_bytes(); - b.iter() - .map(|&c| { - if (0x20..0x7f).contains(&c) { - c as char - } else { - '.' - } - }) - .collect() -} - -/// Classic hex dump for the ELEM_WATCH lines. Deliberately a small copy of `hex_dump`'s -/// body rather than a call to it: `hex_dump` hard-codes a `"CTXDUMP"` log prefix, and we -/// want an `"ELEM_WATCH"` prefix so these lines grep together with the rest of the phase. -/// (Refactoring `hex_dump` to take a prefix would touch the stable ctx-dump/enum probes -/// for no real gain; a ~10-line duplicate is the lower-risk choice.) -fn elem_hex_dump(label: &str, start_va: usize, data: &[u8]) { - let mut out = format!( - "ELEM_WATCH {label} @{start_va:#x} ({} bytes):\n", - data.len() - ); - for (row, chunk) in data.chunks(16).enumerate() { - let mut hex = String::new(); - let mut ascii = String::new(); - for (i, &b) in chunk.iter().enumerate() { - hex.push_str(&format!("{b:02x} ")); - if i == 7 { - hex.push(' '); - } - ascii.push(if (0x20..0x7f).contains(&b) { - b as char - } else { - '.' - }); - } - out.push_str(&format!(" +{:#05x} {:<50}|{ascii}|\n", row * 16, hex)); - } - crate::write_log(&out); -} - -/// Phase 1: snapshot the container element + arm the Phase-2 watcher. Runs on the -/// listener (game) thread, once, gated on the pump running (menu reached) — NOT on -/// '+onl' (the element exists at the menu; earlier snapshot = more watch time). -unsafe fn elem_watch_tick() { - // Step 1 — kill switch. - if !ELEM_WATCH_ARMED.load(Ordering::Relaxed) { - return; - } - // Step 2 — one-shot: already snapshotted? - if ELEM_WATCH_DONE.load(Ordering::Relaxed) { - return; - } - // Step 3 — gate on the pump having fired ("we're at the menu"). Requires - // OPENFUT_NETCONN_PUMP=1 (else netconn_thread_id stays 0 forever). - if netconn_thread_id() == 0 { - return; - } - let base = MAIN_BASE.load(Ordering::Relaxed); - if base == 0 { - return; - } - - // Step 4 — resolve M (G = OriginSDK singleton, M = [G+0x360]). Null link => don't - // latch, just retry on the next fire. - let Some(g) = read_ptr(base + 0xacd02c0).filter(|&x| x != 0) else { - return; - }; - let Some(m) = read_ptr(g + 0x360).filter(|&x| x != 0) else { - return; - }; - - // Step 5 — resolve connMgr: reuse the same scan + "first candidate whose vtable[0] - // is in .text" tiebreak that sub-phase B's dial resolver uses, so we snapshot the - // exact object the dial would have operated on. - let expected_vtable = base + 0x80200b8; - let mut stats = (0u64, 0u64); - let cands = scan_conn_mgr(m, expected_vtable, &mut stats); - let mut conn_mgr = 0usize; - for &p in &cands { - let vt0 = read_ptr(read_ptr(p).unwrap_or(0)).unwrap_or(0); - if in_text(base, vt0) { - conn_mgr = p; - break; - } - } - if conn_mgr == 0 { - // Not resolvable yet — retry up to 8 fires before giving up (timing race). - if ELEM_WATCH_TRIES.fetch_add(1, Ordering::Relaxed) < 8 { - return; - } - if ELEM_WATCH_DONE.swap(true, Ordering::Relaxed) { - return; - } - crate::write_log("ELEM_WATCH: connMgr unresolved after retries — snapshot aborted\n"); - return; - } - - // connMgr is resolved: commit the one-shot latch NOW. Everything below is a single - // observation of whatever state exists — including "not set up yet", which is a - // valid RESULT, not a reason to retry. (If we retried on an uninitialized element we - // would loop forever, since offline it may never populate.) - if ELEM_WATCH_DONE.swap(true, Ordering::Relaxed) { - return; - } - - // Step 6 — walk connMgr -> M -> ctx. (M here is re-read from [connMgr+8]; it should - // equal the global M we scanned with.) - let cm_m = read_ptr(conn_mgr + 8).unwrap_or(0); - let ctx = if cm_m != 0 { - read_ptr(cm_m + 0x778).unwrap_or(0) - } else { - 0 - }; - if cm_m == 0 || ctx == 0 { - crate::write_log(&format!( - "ELEM_WATCH: chain broke (connMgr={conn_mgr:#x} M={cm_m:#x} ctx={ctx:#x}) — watchpoint not armed\n" - )); - return; - } - - // Step 7 — read the array shape. Each field is guarded (read_ptr/read_u32 return - // None rather than fault). Contract (from the 0x145057430 getter RE): - // array_base = [ctx+0x1a8] (base of the 0x90-byte element array) - // sub_object = [ctx+0x20] (holds the element count) - // count = [sub_object+0x51c] (u32) - // target_index = [[M+0x7b0]+0x650] (u32; the dial read this with `mov edx,...`) - let array_base = read_ptr(ctx + 0x1a8).unwrap_or(0); - let sub_object = read_ptr(ctx + 0x20).unwrap_or(0); - let count = if sub_object != 0 { - read_u32(sub_object + 0x51c) - } else { - None - }; - let m7b0 = read_ptr(cm_m + 0x7b0).unwrap_or(0); - let target_index = if m7b0 != 0 { - read_u32(m7b0 + 0x650) - } else { - None - }; - - let fmt_u = |o: Option| { - o.map(|v| v.to_string()) - .unwrap_or_else(|| "".to_string()) - }; - crate::write_log(&format!( - "ELEM_WATCH: SNAPSHOT ctx={ctx:#x} array_base={array_base:#x} sub_object={sub_object:#x} \ - count={} [M+0x7b0]={m7b0:#x} target_index={}\n", - fmt_u(count), - fmt_u(target_index), - )); - - // Step 8 — need array_base + count + target_index to locate the element. - let (Some(count), Some(idx)) = (count, target_index) else { - crate::write_log("ELEM_WATCH: count or target_index unreadable — cannot locate element; watchpoint not armed\n"); - return; - }; - if array_base == 0 { - crate::write_log( - "ELEM_WATCH: array_base null — element array not allocated; watchpoint not armed\n", - ); - return; - } - if idx >= count { - crate::write_log(&format!( - "ELEM_WATCH: target_index {idx} >= count {count} (OUT OF BOUNDS — array likely uninitialized garbage); watchpoint not armed\n" - )); - return; - } - - let elem = array_base + (idx as usize) * 0x90; - crate::write_log(&format!( - "ELEM_WATCH: element[{idx}] @ {elem:#x} (array_base + {idx}*0x90)\n" - )); - - // Step 9 — hex-dump the element head, then classify [element+0x40] (the flat-map's - // `begin` pointer). This is the exact word the crashed dial dereferenced as garbage. - if let Some(bytes) = read_bytes(elem, 0x80) { - elem_hex_dump("element (snapshot)", elem, &bytes); - } - let begin = read_ptr(elem + 0x40); - let end = read_ptr(elem + 0x48).unwrap_or(0); - let interp = match begin { - None => "unreadable", - Some(0) => "container null-init (default-constructed empty vector — begin==end==0)", - Some(v) if v < 0x10000 => { - "container UNINITIALIZED (small non-pointer sentinel — this is the crash shape)" - } - Some(v) if read_bytes(v, 8).is_some() => { - "container appears INITIALIZED (begin is a readable heap pointer)" - } - Some(_) => "container has a non-null but UNREADABLE begin (dangling / mid-construction?)", - }; - crate::write_log(&format!( - "ELEM_WATCH: [elem+0x40]={} [elem+0x48]={end:#x} => {interp}\n", - begin - .map(|v| format!("{v:#x}")) - .unwrap_or_else(|| "".to_string()), - )); - - // Step 10 — arm Phase 2: seed the baseline and spawn the poller. We watch regardless - // of the classification: even a currently-initialized container is worth watching for - // a re-init, and an uninitialized one is exactly the transition we're hunting. - ELEM_WATCH_ELEM.store(elem, Ordering::Relaxed); - ELEM_WATCH_BASELINE.store(begin.unwrap_or(0), Ordering::Relaxed); - spawn_elem_watcher(base, elem); -} - -/// Phase 2: background thread that polls [element+0x40] every 100ms and logs the moment -/// it changes. Mirrors the NetConn pump's structure (a plain `std::thread` loop). Runs -/// for the process lifetime; read-only. -fn spawn_elem_watcher(base: usize, elem: usize) { - std::thread::spawn(move || unsafe { - // NetConn status word lives at [[base+0x9fe5e50]+0x48] (same slot the pump reads). - // We read the conn fourcc straight from memory for change-time context — no game - // call from this background thread. - let netconn_slot = base + 0x9fe5e50; - let t0 = std::time::Instant::now(); - let mut changes = 0u32; - crate::write_log(&format!( - "ELEM_WATCH: Phase 2 watchpoint armed on [elem+0x40] @ {:#x} (100ms poll)\n", - elem + 0x40 - )); - loop { - std::thread::sleep(std::time::Duration::from_millis(100)); - // Guarded read; if the element's page ever goes away, skip this tick. - let Some(now) = read_ptr(elem + 0x40) else { - continue; - }; - let baseline = ELEM_WATCH_BASELINE.load(Ordering::Relaxed); - if now == baseline { - continue; - } - ELEM_WATCH_BASELINE.store(now, Ordering::Relaxed); - changes += 1; - if changes <= 5 { - let end = read_ptr(elem + 0x48).unwrap_or(0); - let conn = read_ptr(netconn_slot) - .filter(|&x| x != 0) - .and_then(|nc| read_ptr(nc + 0x48)) - .map(|w| w as u32) - .unwrap_or(0); - crate::write_log(&format!( - "ELEM_WATCH: [elem+0x40] CHANGED! was={baseline:#x} now={now:#x} [elem+0x48]={end:#x} \ - conn=0x{conn:08x} ({}) uptime={}s\n", - fourcc4(conn), - t0.elapsed().as_secs(), - )); - if let Some(bytes) = read_bytes(elem, 0x80) { - elem_hex_dump("element (after change)", elem, &bytes); - } - } else if changes == 6 { - crate::write_log( - "ELEM_WATCH: (further changes suppressed; still tracking baseline)\n", - ); - } - // Beyond 6, keep updating the baseline silently so distinct future changes - // are still detected — we just stop spamming the log. - } - }); -} - -/// FORCING EXPERIMENT: directly invoke `nucleusConnectREST` (FIFA23.exe+0x2861910) -/// from a background thread once FIFA is at "connecting". That function is nearly -/// self-contained — it fetches X=[0x14acd02c0], M=[X+0x360], ctx=[M+0x778] from the -/// global singleton and, if ctx is non-null, sends a GetAuthCode LSX request. The -/// dormant subsystem never calls it; we call it ourselves to test whether triggering -/// the connect makes FIFA fire GetAuthCode (which the bridge answers) and advance. -/// Guarded: we only call once the ctx chain is valid, to avoid a null-deref crash. -pub fn install_force_connect() { - std::thread::spawn(|| unsafe { - let base = GetModuleHandleA(core::ptr::null()); - if base.is_null() { - return; - } - let base = base as usize; - let x_slot = base + 0xacd02c0; - let rest: extern "system" fn() -> usize = core::mem::transmute(base + 0x2861910); - // Wait for the ctx chain to be valid (FIFA past bootstrap / online), up to ~5 min. - let mut fired = 0; - for i in 0..600u32 { - std::thread::sleep(std::time::Duration::from_millis(500)); - let ctx = read_ptr(x_slot) - .filter(|&x| x != 0) - .and_then(|x| read_ptr(x + 0x360)) - .filter(|&m| m != 0) - .and_then(|m| read_ptr(m + 0x778)) - .filter(|&c| c != 0); - let Some(ctx) = ctx else { - continue; - }; - // Give the game ~15s settled (ctx valid) before poking, then re-fire a few - // times spaced out (the FUT-tick pump needs a moment to reach state 2). - if i < 30 { - continue; - } - crate::write_log(&format!( - "FORCE: calling nucleusConnectREST() (ctx={ctx:#x}) attempt {fired}\n" - )); - let r = rest(); - crate::write_log(&format!("FORCE: nucleusConnectREST returned {r:#x}\n")); - fired += 1; - if fired >= 6 { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5000)); - } - crate::write_log("FORCE: done\n"); - }); -} - -/// First thread ID the NetConn pump ever fires on; 0 until the pump fires at least -/// once. Sub-phase B reads this (via `netconn_thread_id`) to know which thread the -/// pump runs on. -/// -/// Why `AtomicU32` (not `Mutex` or `static mut u32`): this is a write-once, -/// read-many value. An atomic gives lock-free, data-race-free access with NO `unsafe`; -/// a `Mutex` is overkill for one integer, and a `static mut` would require `unsafe` and -/// risks undefined behaviour under concurrent access. (For a strictly write-once value -/// `std::sync::OnceLock` is the most idiomatic modern form — noted for future -/// reference; a plain atomic is simpler and sufficient here.) -static NETCONN_TID: AtomicU32 = AtomicU32::new(0); - -/// The thread ID the NetConn pump fires on, or 0 if it hasn't fired yet. `Relaxed` is -/// sufficient: we only need the value itself to be visible to the reader, not ordered -/// against any other memory (there's no "publish data then set flag" handoff here). -pub fn netconn_thread_id() -> u32 { - NETCONN_TID.load(Ordering::Relaxed) -} - -/// Pump DirtySDK's NetConnIdle ourselves to break the offline "go-online" bootstrap. -/// -/// RE finding (openfut-bridge/docs/connection-gate-findings.md, 2026-07-02): the app's -/// go-online handler (main_exe+0x4f4d360) dials the redirector only when -/// NetConnStatus('conn')=='+onl'. That status is the conn-module cached field -/// [NetConn+0x48], promoted '~con'->'+onl' by the conn tick (main_exe+0xf05430). With -/// live field values the tick WOULD promote (all preconditions met) and fire the -/// state-change notification the handler reacts to — but the tick, though registered in -/// the NetConnIdle callback table, is never run: its pump (NetConnIdle core, -/// main_exe+0xf16a50) is driven ONLY by online connect/wait loops, which never run -/// offline at the menu. Bootstrap circularity. Calling the pump ourselves runs the -/// game's own promotion logic (it self-guards on 'open'=[NetConn+0xcd], already =1), so -/// it is coherent, not a faked state. -/// -/// NOTE: like `install_force_connect`, this runs on a spawned thread and DirtySDK state -/// is not formally thread-safe — this is a forcing EXPERIMENT. If it destabilises FIFA, -/// move the `pump()` call onto a game-thread detour instead of a background thread. -/// Off by default (see install_probes_deferred); enable manually to test. -pub fn install_force_netconn_pump() { - std::thread::spawn(|| unsafe { - // Kill switch: only run when OPENFUT_NETCONN_PUMP=1. Read once at thread start - // (an env var is fixed for the process lifetime). Unset or "0" => short-circuit: - // log and return, so the pump is wired in but completely inert — a safe default - // that can be flipped without a rebuild. - let enabled = std::env::var("OPENFUT_NETCONN_PUMP") - .map(|v| v == "1") - .unwrap_or(false); - if !enabled { - crate::write_log("NETCONN_PUMP: disabled (set OPENFUT_NETCONN_PUMP=1 to enable)\n"); - return; - } - let base = GetModuleHandleA(core::ptr::null()); - if base.is_null() { - return; - } - let base = base as usize; - let netconn_slot = base + 0x9fe5e50; // VA 0x149fe5e50 -> NetConn global (X) - // NetConnIdle core pump; takes no args (reads globals, sets its own rcx). Win64. - let pump: extern "system" fn() = core::mem::transmute(base + 0xf16a50); - - // Render a 4-char status code the way DirtySDK stores it (e.g. 0x2b6f6e6c="+onl"). - let fourcc = |v: u32| -> String { - [(v >> 24) as u8, (v >> 16) as u8, (v >> 8) as u8, v as u8] - .iter() - .map(|&b| { - if (0x20..0x7f).contains(&b) { - b as char - } else { - '.' - } - }) - .collect() - }; - - // Pump CONTINUOUSLY, ~every 100ms, for up to ~30 min. Do NOT stop at +onl: - // promoting the status is not enough — the actual redirector/Blaze connection - // (ProtoSSLConnect -> getaddrinfo -> dial) only progresses while the idle loop - // keeps ticking, exactly like the game's own connect-wait loops (0x14508a500, - // which pump 0x140f16a50 repeatedly). Log only on status CHANGE, plus a - // heartbeat, so the log doesn't flood. - let mut pumped = 0u32; - let mut last_status = 0u32; - // Count of thread-id samples logged so far. Plain local (not atomic): only THIS - // pump thread ever touches it, so there's no cross-thread race to guard against. - let mut samples = 0u32; - for _ in 0..18000u32 { - std::thread::sleep(std::time::Duration::from_millis(100)); - let Some(nc) = read_ptr(netconn_slot).filter(|&x| x != 0) else { - continue; - }; - // Read the conn status dword at [nc+0x48] (8-aligned; read_ptr is guarded). - let status = read_ptr(nc + 0x48).map(|w| w as u32).unwrap_or(0); - // Drive the idle loop. Self-guards on 'open'; a no-op if not yet open. - pump(); - pumped += 1; - // Thread-id capture: log the first 5 fires, then stop (5 samples is enough to - // see whether it's stable or varying). NOTE: this is OUR spawned pump thread, - // NOT necessarily the game's online-servicing thread — it will therefore be a - // single stable value. See the report / fn docs before using it as sub-phase - // B's "known-good" comparison. - if samples < 5 { - // GetCurrentThreadId is a Win32 FFI call (no args; returns the OS thread - // id as a DWORD/u32). Any foreign call is `unsafe` because Rust can't - // verify the callee's contract — we're already inside the closure's - // `unsafe` block. This one is trivially safe: it only reads the current - // thread's id and has no preconditions or side effects. - let tid = GetCurrentThreadId(); - // First-write-wins: record the first id we ever see. compare_exchange - // flips 0 -> tid exactly once and no-ops thereafter. Only this thread - // writes it, so a plain store would also work; the CAS documents the - // "first wins" intent and stays correct even if several threads pumped. - // `Relaxed` on both success/failure: value-only, no ordering needed. - let _ = NETCONN_TID.compare_exchange(0, tid, Ordering::Relaxed, Ordering::Relaxed); - samples += 1; - crate::write_log(&format!( - "NETCONN_PUMP: fired (sample {samples}/5), thread_id={tid}\n" - )); - if samples == 5 { - crate::write_log(&format!( - "NETCONN_PUMP: thread ID captured = {} (further pump fires will not log)\n", - NETCONN_TID.load(Ordering::Relaxed) - )); - } - } - if status != last_status || pumped % 100 == 0 { - crate::write_log(&format!( - "PUMP #{pumped}: NetConn={nc:#x} conn[+0x48]={status:#x} ({})\n", - fourcc(status) - )); - last_status = status; - } - } - crate::write_log("PUMP: done (30 min elapsed)\n"); - }); -} - -/// Pump the FUT online manager's update tick to break the "go-online" bootstrap. -/// -/// RE finding (2026-07-03, docs/connection-gate-findings.md): the FUT online→auth chain -/// (GetAuthCode→Nucleus→Blaze) is gated on the FifaOnline manager advancing its state -/// machine. mgr = *[FIFA23.exe+0xa199608]; state @mgr+0x1bb8 (0=idle, 1=connecting, -/// 2=online — the `==2` check is inlined everywhere). The 0→1 transition is driven by -/// the manager's update tick (main_exe+0x1b3f290) consuming a "go-online request" latch -/// byte @mgr+0x1bbc, which the event-0 handler (main_exe+0x1b0bbb0) normally sets. At the -/// "connecting to EA servers" screen the tick is DORMANT: setting the latch by hand, it -/// is never consumed (state stays 0) — the same primed-but-unpumped pattern as -/// NetConnIdle. Only the FUT auth requester (main_exe+0x1b02790), reached once state -/// advances, issues GetAuthCode. -/// -/// We post the latch and call the tick ourselves. The tick self-gates -/// (main_exe+0x7e5c80: singleton [0x14acd02c0]!=0 && byte [0x14acd02ef]==0, both already -/// satisfied) and takes a lock at mgr+0x5a98 — but since the game isn't calling it, our -/// thread is the sole caller, so no contention. Advancing to state 1 kicks the connect -/// job, which should fire GetAuthCode (the bridge answers it). Signature is a Win64 -/// method rcx=this(mgr); we pass rdx=0 (update dt/flag default). Forcing EXPERIMENT. -pub fn install_force_fut_tick() { - std::thread::spawn(|| unsafe { - let base = GetModuleHandleA(core::ptr::null()); - if base.is_null() { - return; - } - let base = base as usize; - let mgr_slot = base + 0xa199608; // VA 0x14a199608 -> FUT online manager ptr - let tick: extern "system" fn(usize, usize) -> usize = - core::mem::transmute(base + 0x1b3f290); - let mut ticked = 0u32; - let mut last = (u32::MAX, 0u8); - // ~15 min at 250ms. The tick is heavy (locks + sub-updates); don't spin at 100ms. - for _ in 0..3600u32 { - std::thread::sleep(std::time::Duration::from_millis(250)); - let Some(mgr) = read_ptr(mgr_slot).filter(|&m| m != 0) else { - continue; - }; - // state @+0x1bb8 (low32) + latch byte @+0x1bbc share one 8-aligned qword. - let Some(w) = read_ptr(mgr + 0x1bb8) else { - continue; - }; - let state = w as u32; - let latch = ((w >> 32) & 0xff) as u8; - // Post the go-online request ONLY at state 0 (mimics event-0 delivery) to - // advance 0->1. Do NOT re-post at state 1: with latch!=0 the tick takes its - // teardown path (call main_exe+0x1a85ef0 resets the connection) and cycles - // state 1->0->1 every tick, so the connect never completes -> "EA servers - // down". At state 1 we call the tick with latch==0 so it drives the - // connecting state (the 0x1b3f549 path) toward state 2. - if state == 0 { - core::ptr::write_volatile((mgr + 0x1bbc) as *mut u8, 1); - } - let _ = tick(mgr, 0); - ticked += 1; - let now = (state, latch); - if now != last || ticked % 40 == 0 { - crate::write_log(&format!( - "FUTTICK #{ticked}: mgr={mgr:#x} state[+0x1bb8]={state} latch[+0x1bbc]={latch}\n" - )); - last = now; - } - } - crate::write_log("FUTTICK: done\n"); - }); -} - -// ─── menu-time ctx dump (READ-ONLY) ───────────────────────────────────────────── -// -// Goal (2026-07-03): confirm whether the Nucleus/connect `ctx` reachable from the -// redirector connMgr is *populated* at the main menu (offline), or an empty shell -// that would fault on a dispatch even with the correct `this`. This is pure -// observation — it never writes game memory and never calls a game function, so -// unlike the forcing pumps it can run from a background thread with no risk. -// -// Resolution algorithm (confirmed live on 2026-07-03): -// G = *[base + 0xacd02c0] (an OriginSDK global singleton) -// M = *[G + 0x360] (the ctx holder / online manager) -// ctx = *[M + 0x778] (the connect/session context) -// connMgr P: a heap object with [P+0] == base+0x80200b8 (its vtable, a static -// .rdata address) AND [P+8] == M. Confirmed via vtable[0] landing in -// .text and an embedded dispatcher pointer at [P+0xc38]. -// -// All offsets here are the CONFIRMED values from the connMgr-resolution run; if a -// future disassembly contradicts one, stop and re-verify rather than adjust blindly. - -/// Latches true the moment the dump actually runs, so it fires at most once per -/// process. (There is only ever one probe thread, so this is belt-and-suspenders.) -static CTX_DUMP_DONE: AtomicBool = AtomicBool::new(false); - -/// Is `addr` inside one of FIFA23.exe's two `.text` (code) sections? Used as the -/// false-positive filter on a candidate's first virtual method (vtable[0]): a real -/// object's vtable points at real code. Ranges are RVAs from the section map -/// (docs): first .text [0x1000, 0x72a7800), second .text [0xbe37000, 0xc1cd000). -fn in_text(base: usize, addr: usize) -> bool { - let rva = addr.wrapping_sub(base); - (0x1000..0x72a7800).contains(&rva) || (0xbe37000..0xc1cd000).contains(&rva) -} - -/// Read up to `len` bytes starting at `addr` into a Vec, but never past the end of -/// the single VirtualQuery region `addr` lives in (so we can't wander off committed -/// memory). Returns None if `addr` isn't in a committed, readable page. The returned -/// Vec may be SHORTER than `len` if the region ends first — the caller notes that. -unsafe fn read_bytes(addr: usize, len: usize) -> Option> { - if addr < 0x10000 { - return None; - } - let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); - let n = VirtualQuery( - addr as _, - &mut mbi, - core::mem::size_of::(), - ); - if n == 0 || mbi.State != MEM_COMMIT { - return None; - } - if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { - return None; - } - // VirtualQuery guarantees the whole [BaseAddress, BaseAddress+RegionSize) range - // shares one protection, so clamping to the region end keeps every copied byte - // inside committed+readable memory. - let region_end = mbi.BaseAddress as usize + mbi.RegionSize; - let take = len.min(region_end.saturating_sub(addr)); - let mut buf = vec![0u8; take]; - // SAFE: src is committed+readable for `take` bytes (clamped above); dst is our - // freshly-allocated Vec of exactly `take` bytes; the ranges don't overlap. - core::ptr::copy_nonoverlapping(addr as *const u8, buf.as_mut_ptr(), take); - Some(buf) -} - -/// Format `data` as a classic hex dump (16 bytes/line: relative offset, hex, ASCII) -/// and append it to the log under one header line. `start_va` is only used to print -/// the object's base address in the header; offsets are relative (`+0x000`, …). -fn hex_dump(label: &str, start_va: usize, data: &[u8]) { - let mut out = format!("CTXDUMP {label} @{start_va:#x} ({} bytes):\n", data.len()); - for (row, chunk) in data.chunks(16).enumerate() { - let mut hex = String::new(); - let mut ascii = String::new(); - for (i, &b) in chunk.iter().enumerate() { - hex.push_str(&format!("{b:02x} ")); - if i == 7 { - hex.push(' '); // gap between the two 8-byte halves, easier to read - } - // Printable ASCII stays; everything else shows as '.' so pointer bytes - // don't corrupt the log line. - ascii.push(if (0x20..0x7f).contains(&b) { - b as char - } else { - '.' - }); - } - out.push_str(&format!(" +{:#05x} {:<50}|{ascii}|\n", row * 16, hex)); - } - crate::write_log(&out); -} - -/// Scan committed private (heap) memory for connMgr candidates: objects `P` with -/// `[P+0] == expected_vtable` AND `[P+8] == m`. -/// -/// How the scan works (conceptually): the address space is a series of regions. -/// `VirtualQuery(addr)` describes the region containing `addr` — its base, size, -/// commit state, protection, and type (private heap vs mapped file vs image). We -/// walk region-by-region (jumping to base+size each step), and for every region that -/// is COMMITTED, PRIVATE (heap, not an EXE/DLL image or file mapping), and readable, -/// we sweep it at 8-byte stride looking for a word equal to `expected_vtable`. The -/// vtable is a single fixed value, so that first compare rejects almost every slot -/// instantly; only on a hit do we read `[P+8]` and compare to `m`. Restricting to -/// MEM_PRIVATE skips the executable/DLL images and mapped files entirely, which is -/// most of the address space and where connMgr can't live. -/// -/// Returns every matching `P`. Read-only throughout. Also fills `stats` with -/// (regions_scanned, bytes_scanned) so we can report the cost. -unsafe fn scan_conn_mgr(m: usize, expected_vtable: usize, stats: &mut (u64, u64)) -> Vec { - let mut hits = Vec::new(); - let mut addr: usize = 0x10000; // user space starts here; skip the null-guard page - loop { - let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); - let n = VirtualQuery( - addr as _, - &mut mbi, - core::mem::size_of::(), - ); - if n == 0 { - break; // past the top of the user address space - } - let region_base = mbi.BaseAddress as usize; - let region_size = mbi.RegionSize; - let next = region_base.wrapping_add(region_size); - if next <= addr { - break; // no forward progress (overflow / degenerate) — stop safely - } - - let readable = mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) == 0; - let is_heap = mbi.Type == MEM_PRIVATE; - if mbi.State == MEM_COMMIT && is_heap && readable && region_size >= 0x10 { - stats.0 += 1; - stats.1 += region_size as u64; - // The whole region is committed+readable (one uniform VirtualQuery region), - // so plain reads at any 8-aligned offset are in-bounds. `*const usize` = - // read a 64-bit pointer-sized word; that's the width of both a vtable - // pointer and the M pointer we're matching. - let words = region_size / 8; - let p = region_base as *const usize; - for i in 0..words { - // Non-volatile read: we're scanning a snapshot; a torn read at worst - // fails the compare. Plain `.read()` lets the optimizer keep this hot - // loop tight (idiom: `read_volatile` would be needed only if the value - // could change under us in a way we must observe — it can't here). - let v0 = p.add(i).read(); - if v0 == expected_vtable { - let cand = region_base + i * 8; - // Ensure [cand+8] is still inside this region before reading it. - if cand + 16 <= next { - let v8 = (cand + 8) as *const usize; - if v8.read() == m { - hits.push(cand); - } - } - } - } - } - addr = next; - } - hits -} - -/// Read-only menu-time probe: resolve connMgr from the known algorithm, then hex-dump -/// the ctx holder (M, 128 bytes) and the ctx object (512 bytes). Kill switch: -/// env `OPENFUT_CTX_DUMP` — armed only when it equals "1" (unset/"0" = disabled). -/// Read once at install time; if disarmed we don't even spawn the thread. -pub fn install_ctx_dump() { - let armed = std::env::var("OPENFUT_CTX_DUMP") - .map(|v| v == "1") - .unwrap_or(false); - if !armed { - crate::write_log("CTXDUMP: disabled (set OPENFUT_CTX_DUMP=1 to arm)\n"); - return; - } - std::thread::spawn(|| unsafe { - let base = GetModuleHandleA(core::ptr::null()); - if base.is_null() { - crate::write_log("CTXDUMP: main exe not found\n"); - return; - } - let base = base as usize; - let g_slot = base + 0xacd02c0; // VA 0x14acd02c0 - let expected_vtable = base + 0x80200b8; // connMgr vtable (static, in .rdata) - crate::write_log( - "CTXDUMP: armed; polling until connMgr is resolvable at the menu (read-only)\n", - ); - - // Poll (~3 min max) until the whole chain resolves. connMgr only appears once - // the online subsystem is up (≈ main menu), so a successful resolve IS the - // "we're at the menu" signal — more robust than a blind fixed delay. - for attempt in 0..360u32 { - std::thread::sleep(std::time::Duration::from_millis(500)); - - // Step 1: G. Null-check every link; log which step failed, never deref null. - let Some(g) = read_ptr(g_slot).filter(|&x| x != 0) else { - if attempt % 20 == 0 { - crate::write_log("CTXDUMP: waiting (step 1: G null/unreadable)\n"); - } - continue; - }; - // Step 2: M (ctx holder). - let Some(m) = read_ptr(g + 0x360).filter(|&x| x != 0) else { - if attempt % 20 == 0 { - crate::write_log(&format!("CTXDUMP: waiting (step 2: M null; G={g:#x})\n")); - } - continue; - }; - // Step 3: expected ctx via the global path (informational cross-check). - let expected_ctx = read_ptr(m + 0x778).unwrap_or(0); - // Step 4: heap-scan for connMgr. - let mut stats = (0u64, 0u64); - let t0 = std::time::Instant::now(); - let cands = scan_conn_mgr(m, expected_vtable, &mut stats); - let scan_ms = t0.elapsed().as_millis(); - if cands.is_empty() { - if attempt % 10 == 0 { - crate::write_log(&format!( - "CTXDUMP: waiting (step 4: 0 connMgr candidates; G={g:#x} M={m:#x} \ - expected_ctx={expected_ctx:#x}; scanned {} regions / {} MB in {scan_ms}ms)\n", - stats.0, - stats.1 / (1024 * 1024), - )); - } - continue; - } - - // Resolved. Latch so we dump exactly once. - if CTX_DUMP_DONE.swap(true, Ordering::Relaxed) { - return; - } - crate::write_log(&format!( - "CTXDUMP: RESOLVED G={g:#x} M(=[G+0x360])={m:#x} expected_ctx(=[M+0x778])={expected_ctx:#x}\n\ - CTXDUMP: scan found {} candidate(s) in {} regions / {} MB in {scan_ms}ms\n", - cands.len(), - stats.0, - stats.1 / (1024 * 1024), - )); - - // Log each candidate; pick the first with vtable[0] in .text AND a - // pointer-shaped dispatcher at [P+0xc38] (the last run's false positive had - // ASCII bytes there). All candidates share the same vtable by construction, - // so [P+0xc38] is the real disambiguator. - let mut chosen = cands[0]; - for &p in &cands { - let vt = read_ptr(p).unwrap_or(0); - let vt0 = read_ptr(vt).unwrap_or(0); - let disp = read_ptr(p + 0xc38).unwrap_or(0); - let vt0_ok = in_text(base, vt0); - let disp_ok = disp >= 0x10000; - crate::write_log(&format!( - "CTXDUMP: candidate P={p:#x} [P+0]={vt:#x} vtable[0]={vt0:#x} ({}) \ - [P+8]={:#x} [P+0xc38]={disp:#x} ({})\n", - if vt0_ok { "in .text" } else { "NOT .text" }, - read_ptr(p + 8).unwrap_or(0), - if disp_ok { "ptr-shaped" } else { "junk/ASCII" }, - )); - if vt0_ok && disp_ok && chosen == cands[0] { - chosen = p; - } - } - let conn_mgr = chosen; - crate::write_log(&format!( - "CTXDUMP: connMgr = {conn_mgr:#x} [P+0]={:#x} [P+8]={:#x} [P+0xc38]={:#x}\n", - read_ptr(conn_mgr).unwrap_or(0), - read_ptr(conn_mgr + 8).unwrap_or(0), - read_ptr(conn_mgr + 0xc38).unwrap_or(0), - )); - - // Dump 1: M — the ctx holder at [connMgr+8] (== M by construction). 128 bytes. - let m_holder = read_ptr(conn_mgr + 8).unwrap_or(0); - match read_bytes(m_holder, 128) { - Some(b) if !b.is_empty() => { - if b.len() < 128 { - crate::write_log(&format!( - "CTXDUMP: (M dump truncated to {} bytes at region end)\n", - b.len() - )); - } - hex_dump("M (ctx holder)", m_holder, &b); - } - _ => crate::write_log(&format!("CTXDUMP: M @{m_holder:#x} unreadable\n")), - } - - // Dump 2: ctx = [M+0x778]. If null, that's the diagnosis (holder exists, - // ctx not yet allocated) — log and skip. - let ctx = read_ptr(m_holder + 0x778).unwrap_or(0); - if ctx == 0 { - crate::write_log(&format!( - "CTXDUMP: ctx (=[M+0x778]) is NULL at menu (holder set up, ctx object not \ - allocated) — skipping ctx dump\n" - )); - } else { - match read_bytes(ctx, 512) { - Some(b) if !b.is_empty() => { - if b.len() < 512 { - crate::write_log(&format!( - "CTXDUMP: (ctx dump truncated to {} bytes at region end)\n", - b.len() - )); - } - hex_dump("ctx", ctx, &b); - } - _ => crate::write_log(&format!("CTXDUMP: ctx @{ctx:#x} unreadable\n")), - } - } - - crate::write_log("CTXDUMP: complete (one-shot; will not fire again)\n"); - return; - } - crate::write_log( - "CTXDUMP: gave up after ~3 min — connMgr never resolved (still pre-menu?)\n", - ); - }); -} - -struct Target { - /// DLL name (nul-terminated) or ignored when `main_exe` is true. - module: &'static [u8], - /// Offset from the module base (RVA). For main_exe, VA − 0x140000000. - rva: usize, - label: &'static str, - /// True → resolve against the main executable (FIFA23.exe) base. - main_exe: bool, -} - -/// Probe targets. Addresses recovered by RE (see docs/connection-gate-findings.md). -/// -/// Dig-2 gate: FIFA's game-side Nucleus-connect layer requests the auth code, but both -/// paths null-check the Nucleus session context `[NucleusManager+0x778]` and bail when -/// it's null. These probes tell us *where FIFA is parked*: -/// - `nucleusConnectREST` (+0x2861910): the state that would send GetAuthCode. Returns 0 -/// (no request) when `[mgr+0x778]` is null. If this NEVER fires, FIFA never reached it. -/// - `nucleusConnectTrusted` (+0x5078370): sibling connect path. Returns HRESULT -/// 0x80060000 ("not ready") when the context is null — its return value is diagnostic. -/// - `connect-state.tick` (+0x507d660): the login state-machine handler that calls -/// nucleusConnectTrusted. If it ticks, FIFA entered the connect state; if not, it's -/// parked earlier. -/// - `OnlineStatus.deser` (+0x278a4d0): confirms our pushed OnlineStatusEvent still -/// arrives during the test (control signal). -const TARGETS: &[Target] = &[ - // Run 4: settle "connect state entered-but-stalled" vs "never entered". If the ctor - // fires but nothing else, the connect states are created at init but never used; if - // GetByIdx / any vtable step fires, the online subsystem is iterating them. - Target { - module: b"\0", - rva: 0x5078d20, - label: "connectState.ctor", - main_exe: true, - }, - Target { - module: b"\0", - rva: 0x4f46570, - label: "ctrl.GetConnState", - main_exe: true, - }, - Target { - module: b"\0", - rva: 0x507cd60, - label: "connState.m_a8", - main_exe: true, - }, - Target { - module: b"\0", - rva: 0x507cf90, - label: "connState.m_b0", - main_exe: true, - }, - Target { - module: b"\0", - rva: 0x507d660, - label: "connState.tick_b8", - main_exe: true, - }, - Target { - module: b"\0", - rva: 0x507d760, - label: "connState.m_c0", - main_exe: true, - }, - Target { - module: b"\0", - rva: 0x2861910, - label: "nucleusConnectREST", - main_exe: true, - }, - Target { - module: b"\0", - rva: 0x278a4d0, - label: "OnlineStatus.deser", - main_exe: true, - }, -]; - -const N: usize = 8; // must equal TARGETS.len() - -static ADDRS: [AtomicUsize; N] = [const { AtomicUsize::new(0) }; N]; -static mut ORIG: [[u8; 14]; N] = [[0u8; 14]; N]; -/// Per-slot call counter, used to cap logging so a per-frame handler (e.g. a state -/// tick) can't flood the log. We still call through the real function every time. -static CALLS: [AtomicUsize; N] = [const { AtomicUsize::new(0) }; N]; -const LOG_CAP: usize = 24; - -type ProbeFn = unsafe extern "system" fn(usize, usize, usize, usize) -> usize; -const PROBE_FNS: [ProbeFn; N] = [p0, p1, p2, p3, p4, p5, p6, p7]; - -unsafe fn write_jmp(addr: *mut u8, dest: u64) { - let mut old: u32 = 0; - VirtualProtect(addr as _, 14, PAGE_EXECUTE_READWRITE, &mut old); - // FF 25 00 00 00 00 JMP [rip+0] ; then absolute dest - addr.write(0xFF); - addr.add(1).write(0x25); - (addr.add(2) as *mut u32).write(0); - (addr.add(6) as *mut u64).write(dest); - VirtualProtect(addr as _, 14, old, &mut old); -} - -unsafe fn restore(slot: usize) { - let addr = ADDRS[slot].load(Ordering::Relaxed) as *mut u8; - let mut old: u32 = 0; - VirtualProtect(addr as _, 14, PAGE_EXECUTE_READWRITE, &mut old); - core::ptr::copy_nonoverlapping((&raw const ORIG[slot]) as *const u8, addr, 14); - VirtualProtect(addr as _, 14, old, &mut old); -} - -unsafe fn generic(slot: usize, a: usize, b: usize, c: usize, d: usize) -> usize { - let label = TARGETS[slot].label; - let addr = ADDRS[slot].load(Ordering::Relaxed) as *mut u8; - // Cap logging per slot so a per-frame handler can't flood the log; still call - // through every time. Log the first LOG_CAP calls (entry + return), then just the - // running count once at the cap so we know it kept firing. - let n = CALLS[slot].fetch_add(1, Ordering::Relaxed); - let log = n < LOG_CAP; - // Unhook, (maybe) log entry, call the real function, re-hook, (maybe) log return. - restore(slot); - if log { - crate::write_log(&format!( - "PROBE {label} #{n} enter rcx={a:#x} rdx={b:#x} r8={c:#x} r9={d:#x}\n" - )); - } - let f: ProbeFn = core::mem::transmute(addr); - let r = f(a, b, c, d); - write_jmp(addr, PROBE_FNS[slot] as u64); - if log { - crate::write_log(&format!("PROBE {label} #{n} ret={r:#x}\n")); - } else if n == LOG_CAP { - crate::write_log(&format!( - "PROBE {label} (capped; still firing past {LOG_CAP})\n" - )); - } - r -} - -unsafe extern "system" fn p0(a: usize, b: usize, c: usize, d: usize) -> usize { - generic(0, a, b, c, d) -} -unsafe extern "system" fn p1(a: usize, b: usize, c: usize, d: usize) -> usize { - generic(1, a, b, c, d) -} -unsafe extern "system" fn p2(a: usize, b: usize, c: usize, d: usize) -> usize { - generic(2, a, b, c, d) -} -unsafe extern "system" fn p3(a: usize, b: usize, c: usize, d: usize) -> usize { - generic(3, a, b, c, d) -} -unsafe extern "system" fn p4(a: usize, b: usize, c: usize, d: usize) -> usize { - generic(4, a, b, c, d) -} -unsafe extern "system" fn p5(a: usize, b: usize, c: usize, d: usize) -> usize { - generic(5, a, b, c, d) -} -unsafe extern "system" fn p6(a: usize, b: usize, c: usize, d: usize) -> usize { - generic(6, a, b, c, d) -} -unsafe extern "system" fn p7(a: usize, b: usize, c: usize, d: usize) -> usize { - generic(7, a, b, c, d) -} - -/// Spawn a background thread that waits for anadius64.dll to load, then installs -/// all probes. anadius may not be present when our DllMain runs, so we defer -/// off the loader lock and poll for it (up to ~30s) before installing. -pub fn install_probes_deferred() { - std::thread::spawn(|| unsafe { - for _ in 0..60 { - if !GetModuleHandleA(b"anadius64.dll\0".as_ptr()).is_null() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(500)); - } - install_probes(); - install_listener_probe(); - install_state_sampler(); - install_ctx_dump(); // ENABLED 2026-07-03: READ-ONLY menu-time ctx dump (env - // OPENFUT_CTX_DUMP=1). Resolves connMgr and hex-dumps M + ctx. No game calls. - // install_force_connect(); // DISABLED 2026-07-03: re-enabling it CRASHED FIFA at - // ~15s (EXCEPTION_ACCESS_VIOLATION, RIP 0x15d5e8dd7 in FIFA's packed/anti-tamper - // region, all registers garbage). Once state 2 makes the Nucleus ctx live, - // nucleusConnectREST's `ctx->vtable[0x40]` send path runs into protected code that - // does not tolerate being called from our background thread. GetAuthCode must be - // triggered on the GAME thread (via a detour), not a bg-thread forcing call. - install_force_netconn_pump(); // RE-ENABLED 2026-07-03 (sub-phase B prereq): pump - // NetConn toward '+onl' and capture the pump thread id. Gated by env - // OPENFUT_NETCONN_PUMP=1 — completely inert unless set. This is the known-good - // pump path (never crashed); the FUT-tick pump below stays OFF (it crashes the VM). - // install_force_fut_tick(); // DISABLED 2026-07-03 for the ctx-dump build: it - // WRITES the go-online latch and drives FifaOnline toward state 2, which - // deterministically CRASHES the anti-tamper VM before the menu — so leaving it on - // would prevent this menu-time probe from ever observing. Re-enable only if we - // deliberately want the (crash-prone) state-2 path. - }); -} - -/// Install all probe detours. Modules must already be loaded (call late in -/// DllMain, after anadius64.dll is present). -pub unsafe fn install_probes() { - for (i, t) in TARGETS.iter().enumerate() { - let base = if t.main_exe { - GetModuleHandleA(core::ptr::null()) - } else { - GetModuleHandleA(t.module.as_ptr()) - }; - if base.is_null() { - crate::write_log(&format!("PROBE {}: module not loaded, skipped\n", t.label)); - continue; - } - let addr = (base as usize + t.rva) as *mut u8; - core::ptr::copy_nonoverlapping(addr, (&raw mut ORIG[i]) as *mut u8, 14); - ADDRS[i].store(addr as usize, Ordering::Relaxed); - write_jmp(addr, PROBE_FNS[i] as u64); - crate::write_log(&format!( - "PROBE {} installed @ {:#x}\n", - t.label, addr as usize - )); - } -} - -#[cfg(test)] -mod netconn_tests { - use super::*; - - #[test] - fn thread_id_accessor_roundtrips() { - // Fresh process: the pump hasn't fired, so the captured id starts at 0. - assert_eq!(netconn_thread_id(), 0); - // Simulate the first capture and confirm the accessor reads the same atomic. - NETCONN_TID.store(4321, Ordering::Relaxed); - assert_eq!(netconn_thread_id(), 4321); - } -} diff --git a/openfut-hook/src/recv_hook.rs b/openfut-hook/src/recv_hook.rs deleted file mode 100644 index 5edd78b..0000000 --- a/openfut-hook/src/recv_hook.rs +++ /dev/null @@ -1,321 +0,0 @@ -/// Inline hooks on ws2_32!recv and ws2_32!send only. -/// -/// WSARecv/WSASend are NOT hooked — their prologues contain RIP-relative -/// (short conditional jump) instructions that would break trampolines. -/// FIFA's LSX client uses plain recv/send, which is confirmed by prior logs. -/// -/// Trampolines allow multiple threads to call the original function -/// concurrently without locks or unhook/rehook races. -use core::sync::atomic::{AtomicUsize, Ordering}; - -unsafe fn write_jmp(target: *mut u8, dest: u64) { - use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; - let mut old: u32 = 0; - VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); - target.write(0xFF); - target.add(1).write(0x25); - (target.add(2) as *mut u32).write(0); - (target.add(6) as *mut u64).write(dest); - VirtualProtect(target as _, 14, old, &mut old); -} - -unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option { - use windows_sys::Win32::System::Memory::{ - VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE, - }; - // Read enough prologue to walk instruction boundaries. - let probe: [u8; 24] = core::array::from_fn(|i| *orig.add(i)); - let hex: String = probe[..14].iter().map(|b| format!("{b:02x} ")).collect(); - crate::write_log(&format!("recv_hook: {name} prologue {hex}\n")); - - // Copy WHOLE instructions until we've covered >= 14 bytes (the size of the JMP - // patch), so the trampoline never splits an instruction. Copying a fixed 14 - // bytes lands mid-instruction on these prologues and crashes on execution. - let mut copy_len = 0usize; - while copy_len < 14 { - let (len, branch) = decode_instr_len(&probe[copy_len..]); - if len == 0 || branch { - crate::write_log(&format!( - "recv_hook: {name} unrelocatable prologue (len={len} branch={branch}), skipping\n" - )); - return None; - } - copy_len += len; - } - - let mem = VirtualAlloc( - core::ptr::null_mut(), - 64, - MEM_COMMIT | MEM_RESERVE, - PAGE_EXECUTE_READWRITE, - ); - if mem.is_null() { - crate::write_log("recv_hook: VirtualAlloc failed\n"); - return None; - } - - let t = mem as *mut u8; - core::ptr::copy_nonoverlapping(orig, t, copy_len); - // JMP [RIP+0] → orig+copy_len (resume at the next whole instruction) - let cont = (orig as u64) + copy_len as u64; - t.add(copy_len).write(0xFF); - t.add(copy_len + 1).write(0x25); - (t.add(copy_len + 2) as *mut u32).write(0); - (t.add(copy_len + 6) as *mut u64).write(cont); - crate::write_log(&format!( - "recv_hook: {name} trampoline copy_len={copy_len}\n" - )); - Some(t as usize) -} - -/// Walk x86-64 instruction boundaries and return true if any relative branch -/// (JE/JNE/JCC rel8, JMP rel8, JMP/CALL rel32, Jcc rel32) is encountered. -/// Correctly skips over immediate operands so `sub rsp, 0x70` doesn't trigger. -fn has_rip_relative_branch(bytes: &[u8]) -> bool { - let mut pos = 0; - while pos < bytes.len() { - let (len, branch) = decode_instr_len(&bytes[pos..]); - if branch { - return true; - } - if len == 0 { - break; - } // unknown/truncated — stop safely - pos += len; - } - false -} - -fn modrm_extra(modrm: u8) -> usize { - let md = (modrm >> 6) & 3; - let rm = modrm & 7; - match md { - 0 => { - if rm == 5 { - 4 - } else if rm == 4 { - 1 - } else { - 0 - } - } - 1 => { - if rm == 4 { - 2 - } else { - 1 - } - } - 2 => { - if rm == 4 { - 5 - } else { - 4 - } - } - _ => 0, - } -} - -/// Returns (instruction_length_in_bytes, is_rip_relative_branch). -/// Returns (0, false) for unknown/truncated. -fn decode_instr_len(b: &[u8]) -> (usize, bool) { - if b.is_empty() { - return (0, false); - } - let mut i = 0; - // Legacy prefixes - while let Some(&p) = b.get(i) { - if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) { - i += 1; - } else { - break; - } - } - // REX prefix (40–4F) - if b.get(i) - .copied() - .map(|x| (0x40..=0x4F).contains(&x)) - .unwrap_or(false) - { - i += 1; - } - - let op = match b.get(i) { - Some(&x) => x, - None => return (0, false), - }; - i += 1; - - match op { - // push/pop reg (50-5F): no extra bytes - 0x50..=0x5F => (i, false), - // nop - 0x90 => (i, false), - // Short Jcc (70-7F): 1 byte operand, IS a relative branch - x if (0x70..=0x7F).contains(&x) => (i + 1, true), - // JMP rel8, JMP rel32, CALL rel32 - 0xEB => (i + 1, true), - 0xE9 | 0xE8 => (i + 4, true), - // 0F prefix - 0x0F => { - let op2 = match b.get(i) { - Some(&x) => x, - None => return (0, false), - }; - i += 1; - if (0x80..=0x8F).contains(&op2) { - return (i + 4, true); - } // Jcc rel32 - // Most 0F XX: ModRM - let modrm = match b.get(i) { - Some(&x) => x, - None => return (0, false), - }; - (i + 1 + modrm_extra(modrm), false) - } - // Instructions with ModRM only (no immediate) - 0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | 0x01 | 0x03 - | 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | 0x31 | 0x33 | 0x39 | 0x3B - | 0xD3 | 0xFF | 0xF7 => { - let modrm = match b.get(i) { - Some(&x) => x, - None => return (0, false), - }; - (i + 1 + modrm_extra(modrm), false) - } - // ModRM + imm8 - 0x6B | 0x80 | 0x83 | 0xC0 | 0xC1 | 0xC6 => { - let modrm = match b.get(i) { - Some(&x) => x, - None => return (0, false), - }; - (i + 1 + modrm_extra(modrm) + 1, false) - } - // ModRM + imm32 - 0x69 | 0x81 | 0xC7 => { - let modrm = match b.get(i) { - Some(&x) => x, - None => return (0, false), - }; - (i + 1 + modrm_extra(modrm) + 4, false) - } - // MOV reg, imm8/imm32 - 0xB0..=0xB7 => (i + 1, false), - 0xB8..=0xBF => (i + 4, false), - // PUSH imm - 0x6A => (i + 1, false), - 0x68 => (i + 4, false), - // RET - 0xC2 => (i + 2, false), - 0xC3 => (i, false), - _ => (0, false), // unknown — stop - } -} - -unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> { - use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; - let h = GetModuleHandleA(dll.as_ptr()); - if h.is_null() { - return None; - } - GetProcAddress(h, sym.as_ptr()).map(|f| f as *mut u8) -} - -// ─── recv ────────────────────────────────────────────────────────────────────── - -static RECV_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); - -/// True if socket `s` is connected to the EA App LSX port (127.0.0.1:3216). -/// Used in capture mode to tap only the LSX conversation. -unsafe fn peer_is_lsx(s: usize) -> bool { - use windows_sys::Win32::Networking::WinSock::getpeername; - let mut sa = [0u8; 16]; - let mut sl: i32 = 16; - if getpeername(s, sa.as_mut_ptr() as *mut _, &mut sl) != 0 { - return false; - } - // sockaddr_in: sa_family (2 bytes) then sin_port (2 bytes, network order). - u16::from_be_bytes([sa[2], sa[3]]) == 3216 -} - -// IAT-hook approach (no inline trampoline — FIFA's `recv`/`send` prologues have -// instructions that straddle the 14-byte patch boundary, so an inline trampoline -// corrupts them and crashes. IAT hooking only swaps import-table pointers and -// never touches the function body). The real fns are resolved in lib.rs and set -// here; our hooks call them directly. -static REAL_RECV: AtomicUsize = AtomicUsize::new(0); -static REAL_SEND: AtomicUsize = AtomicUsize::new(0); - -pub fn set_real_recv(f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32) { - REAL_RECV.store(f as usize, Ordering::Relaxed); -} -pub fn set_real_send(f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32) { - REAL_SEND.store(f as usize, Ordering::Relaxed); -} - -/// Inline-hook ws2_32!recv: build a boundary-safe trampoline (the "real" fn our -/// hook calls) and overwrite the entry with a JMP to `hooked_recv`. Inline hooks -/// catch calls from every module and dynamically-resolved calls, unlike IAT. -pub unsafe fn install_recv_hook() -> bool { - let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") { - Some(p) => p, - None => return false, - }; - match make_trampoline(ptr, "recv") { - Some(t) => REAL_RECV.store(t, Ordering::Relaxed), - None => return false, - } - write_jmp(ptr, hooked_recv as u64); - true -} - -pub unsafe fn install_send_hook() -> bool { - let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") { - Some(p) => p, - None => return false, - }; - match make_trampoline(ptr, "send") { - Some(t) => REAL_SEND.store(t, Ordering::Relaxed), - None => return false, - } - write_jmp(ptr, hooked_send as u64); - true -} - -pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 { - let t = REAL_RECV.load(Ordering::Relaxed); - if t == 0 { - return -1; - } - let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t); - // Pass through to anadius's real socket, then log what it sent back - // (anadius's LSX response — the ground truth we want to diff against). - let n = f(s, buf, len, flags); - if n > 0 && peer_is_lsx(s) { - let data = core::slice::from_raw_parts(buf, n as usize); - let text = core::str::from_utf8(data).unwrap_or("(binary)"); - crate::write_log(&format!( - "CAP recv<-anadius s={s} n={n}: {}\n", - &text[..text.len().min(2400)] - )); - } - n -} - -pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 { - if len > 0 && peer_is_lsx(s) { - let data = core::slice::from_raw_parts(buf, len as usize); - let text = core::str::from_utf8(data).unwrap_or("(binary)"); - crate::write_log(&format!( - "CAP send->anadius s={s} len={len}: {}\n", - &text[..text.len().min(2400)] - )); - } - let t = REAL_SEND.load(Ordering::Relaxed); - if t == 0 { - return -1; - } - let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t); - f(s, buf, len, flags) -} diff --git a/openfut-hook/src/sbc_hook.rs b/openfut-hook/src/sbc_hook.rs index d3976a3..4c70629 100644 --- a/openfut-hook/src/sbc_hook.rs +++ b/openfut-hook/src/sbc_hook.rs @@ -10,8 +10,7 @@ //! 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. +//! defer off the loader lock and poll for it in a background thread. //! //! ── Address model (static VAs; PE image base 0x180000000) ──────────────────────── //! All values below are RVAs (VA_static - 0x180000000); live = cards_base + rva. @@ -80,6 +79,9 @@ static DONE: AtomicBool = AtomicBool::new(false); static CARDS_BASE: AtomicUsize = AtomicUsize::new(0); static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize); +// Full SBC state model. The live repair jumps Resolved -> Validated -> Committed; +// Intercepted/Parsed document the intermediate states but are never entered. +#[allow(dead_code)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(usize)] enum RuntimeState { @@ -192,7 +194,7 @@ fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationE Ok(()) } -/// Fault-safe pointer read (mirrors `probe::read_ptr`): returns None unless `ptr` lands +/// Fault-safe pointer read: 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 { if ptr < 0x10000 || ptr & 7 != 0 { @@ -262,6 +264,8 @@ unsafe fn writable_u8(ptr: usize) -> bool { .is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)) } +// Fault-safe executable-range check retained with the address model; not currently wired. +#[allow(dead_code)] unsafe fn executable_range(ptr: usize, len: usize) -> bool { let Some(end) = ptr.checked_add(len) else { return false; diff --git a/openfut-hook/src/sbc_trace.rs b/openfut-hook/src/sbc_trace.rs index 18c8ee1..af7b7d1 100644 --- a/openfut-hook/src/sbc_trace.rs +++ b/openfut-hook/src/sbc_trace.rs @@ -29,6 +29,7 @@ pub(crate) const CATEGORY_FACTORY_RVA: usize = 0x17aa10; pub(crate) const CATEGORY_DESERIALIZER_RVA: usize = 0x17b2b0; const COPY_LEN: usize = 19; const ABS_JUMP_LEN: usize = 14; +#[allow(dead_code)] // documents the relocated-prologue trampoline size (COPY_LEN + jump) const TRAMPOLINE_LEN: usize = COPY_LEN + ABS_JUMP_LEN; const NOTIFIER_RVA: usize = 0x17aa80; const NOTIFIER_COPY_LEN: usize = 15; diff --git a/openfut-hook/src/ssl_patch.rs b/openfut-hook/src/ssl_patch.rs deleted file mode 100644 index 892899f..0000000 --- a/openfut-hook/src/ssl_patch.rs +++ /dev/null @@ -1,81 +0,0 @@ -// Runtime in-memory patch for ProtoSSL's certificate verification function inside -// EAWebKit.dll. Rather than patching the DLL on disk (offset-dependent, fragile), -// we scan the loaded module for the function's unique byte prologue and overwrite the -// first six bytes with `mov eax, 1; ret` — making every cert-chain validation call -// immediately return success. -// -// Why this is safe: the patched function (`ProtoSSL_VerifyCert` at VA 0x180a85570 in -// the shipped binary) is only used by ProtoSSL's TLS state machine to validate the -// server's certificate chain. Always returning 1 is equivalent to trusting all certs, -// which is the behaviour we want for the local self-signed bridge certificate. - -use windows_sys::Win32::System::{ - LibraryLoader::GetModuleHandleA, - Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}, -}; - -// Unique 22-byte prologue of ProtoSSL's cert-verify function. -// Confirmed present in the EA-shipped EAWebKit.dll (June 2023 build). -const PROLOGUE: &[u8] = &[ - 0x44, 0x89, 0x44, 0x24, 0x18, // mov [rsp+0x18], r8d - 0x48, 0x89, 0x54, 0x24, 0x10, // mov [rsp+0x10], rdx - 0x56, // push rsi - 0x57, // push rdi - 0x41, 0x55, // push r13 - 0x41, 0x56, // push r14 - 0x41, 0x57, // push r15 - 0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30 -]; - -// Return 0 (PROTOSSL_ERROR_NONE = success). ProtoSSL convention: 0 = ok, negative = error. -// The function sets r15d = 0xFFFFFFFF (-1) for its own error returns, confirming 0 = success. -const PATCH: &[u8] = &[ - 0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE) - 0xc3, // ret - 0x90, 0x90, 0x90, // nop padding -]; - -fn patch_module(module: isize, scan_bytes: usize) -> bool { - if module == 0 { - return false; - } - let base = module as usize; - let image: &[u8] = unsafe { core::slice::from_raw_parts(base as *const u8, scan_bytes) }; - let offset = match image.windows(PROLOGUE.len()).position(|w| w == PROLOGUE) { - Some(o) => o, - None => return false, - }; - let target = (base + offset) as *mut u8; - let mut old_prot: u32 = 0; - unsafe { - VirtualProtect( - target as *const core::ffi::c_void, - PATCH.len(), - PAGE_EXECUTE_READWRITE, - &mut old_prot, - ); - core::ptr::copy_nonoverlapping(PATCH.as_ptr(), target, PATCH.len()); - VirtualProtect( - target as *const core::ffi::c_void, - PATCH.len(), - old_prot, - &mut old_prot, - ); - } - true -} - -/// Patch ProtoSSL cert-verify in EAWebKit.dll (call when EAWebKit is loaded). -pub unsafe fn patch_eawebkit_cert_verify() -> bool { - let module = GetModuleHandleA(c"EAWebKit.dll".as_ptr().cast()) as isize; - // EAWebKit.dll is ~22 MB - patch_module(module, 24 * 1024 * 1024) -} - -/// Patch ProtoSSL cert-verify compiled into FIFA23.exe itself (DirtySDK's copy). -/// The main exe is ~100 MB; confirmed present at file offset 0xf0c850. -pub unsafe fn patch_main_exe_cert_verify() -> bool { - let module = GetModuleHandleA(core::ptr::null()) as isize; - // Scan first 110 MB — the function is near offset 0xf0c850 (~15 MB in) - patch_module(module, 110 * 1024 * 1024) -} diff --git a/openfut-hook/src/tls_bypass.rs b/openfut-hook/src/tls_bypass.rs deleted file mode 100644 index 8e53f78..0000000 --- a/openfut-hook/src/tls_bypass.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::sync::OnceLock; -use windows_sys::Win32::Foundation::BOOL; - -// CERT_CHAIN_POLICY_STATUS.dwError offset 0 = u32 error code; 0 = success. -// We use raw pointers to avoid pulling in the full Cryptography struct tree. -type CertVerifyChainPolicyFn = unsafe extern "system" fn( - *const u8, // pszPolicyOID - *const (), // pChainContext - *const (), // pPolicyPara - *mut u32, // &mut pPolicyStatus.dwError (first field) -) -> BOOL; - -static REAL: OnceLock = OnceLock::new(); - -pub fn set_real(f: CertVerifyChainPolicyFn) { - let _ = REAL.set(f); -} - -/// Hooked CertVerifyCertificateChainPolicy — always reports success. -/// This allows the bridge's self-signed TLS cert to be accepted by the game. -pub unsafe extern "system" fn hooked_cert_verify_chain_policy( - psz_policy_oid: *const u8, - p_chain_context: *const (), - p_policy_para: *const (), - p_policy_status: *mut u32, -) -> BOOL { - if let Some(real) = REAL.get().copied() { - real( - psz_policy_oid, - p_chain_context, - p_policy_para, - p_policy_status, - ); - } - // Clear the error field of CERT_CHAIN_POLICY_STATUS regardless - if !p_policy_status.is_null() { - *p_policy_status = 0; - } - 1 // TRUE = verified OK -} diff --git a/openfut-hook/src/transport_watch.rs b/openfut-hook/src/transport_watch.rs deleted file mode 100644 index 93f79f4..0000000 --- a/openfut-hook/src/transport_watch.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Milestone 0 — Blaze transport reachability observation. -//! -//! PURE LOGGING, NO NEW DETOURS. This module does not hook anything itself. It is -//! called from the three Winsock detours the hook ALREADY installs — getaddrinfo -//! (`hooks.rs`), connect/WSAConnect (`connect_hook.rs`) and ConnectEx -//! (`connectex_hook.rs`) — and, when armed, emits a single grep-friendly -//! `TRANSPORT_WATCH:` line per resolution/connect so we can answer one question: -//! -//! Does the FIFA 23 client attempt ANY Blaze-flavored transport activity across a -//! full menu+FUT session, or none at all? -//! -//! Everything here is READ-ONLY: we parse the hostname / sockaddr the game passed -//! only to describe it in the log. We never change a resolution result or a -//! connection target — that redirect logic lives in the detours themselves and is -//! untouched. The env kill switch `OPENFUT_TRANSPORT_WATCH=1` gates all output; -//! disarmed (default) this module is inert (each entry point returns immediately). -//! -//! Future-reference note (beyond-beginner, deliberately NOT done here): a -//! types-first design would model a `ConnectTarget` enum (Inet{ip,port} / NonInet / -//! Short) and a `TransportEvent` and route them through the `tracing` crate with -//! structured fields, instead of hand-formatting strings into a flat log file. That -//! buys machine-parseable logs and log levels. For a one-shot observation gate, -//! flat `write_log` lines that `grep` cleanly are the lower-ceremony choice. - -use core::sync::atomic::{AtomicBool, Ordering}; - -/// Armed once at DLL load from `OPENFUT_TRANSPORT_WATCH`. `AtomicBool` (not a plain -/// `static mut bool`) because the detours that read it run on arbitrary game threads; -/// an atomic gives race-free reads with no `unsafe`. `Relaxed` is enough — this is a -/// standalone flag with no ordering relationship to other memory. -static ARMED: AtomicBool = AtomicBool::new(false); - -/// Read the env var once, at DLL load, and log the arm state. Called from `DllMain` -/// (`install_hooks`). Reading the env in-process (rather than as a command prefix) is -/// what makes the switch actually propagate through the umu/Proton launch — the same -/// gotcha the probe switches hit; it works because the launch script `export`s it. -pub fn arm_from_env() { - let on = std::env::var("OPENFUT_TRANSPORT_WATCH") - .map(|v| v == "1") - .unwrap_or(false); - ARMED.store(on, Ordering::Relaxed); - crate::write_log(&format!( - "TRANSPORT_WATCH: {} (env OPENFUT_TRANSPORT_WATCH)\n", - if on { "ARMED" } else { "disarmed" } - )); -} - -fn armed() -> bool { - ARMED.load(Ordering::Relaxed) -} - -/// True if `host` looks like EA/Blaze infrastructure. Broad on purpose: this is a log -/// classifier that makes a hit visually pop (`<-- BLAZE/EA-FLAVORED`), NOT a routing -/// decision. The actual redirect decision stays in `hooks::is_ea_host`, which is -/// deliberately narrower and unchanged. -fn is_blaze_flavored(host: &str) -> bool { - let h = host.to_ascii_lowercase(); - [ - "redirector", - "gosredirector", - "blaze", - "gosca", - "easfc", - "utas", - "fut", - "ea.com", - "easports", - ] - .iter() - .any(|k| h.contains(k)) -} - -/// Log one getaddrinfo hostname. Self-gates on the arm flag, so the call site can be -/// unconditional. The existing `openfut_hook: getaddrinfo(...)` line stays; this adds -/// the tagged, classified line so `grep TRANSPORT_WATCH` sees the full resolution set -/// and a Blaze host stands out. -pub fn note_getaddrinfo(host: &str) { - if !armed() { - return; - } - let tag = if is_blaze_flavored(host) { - " <-- BLAZE/EA-FLAVORED" - } else { - "" - }; - crate::write_log(&format!( - "TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n" - )); -} - -const AF_INET: u16 = 2; // IPv4 -const AF_INET6: u16 = 23; // IPv6 (Windows value; Linux uses 10 — we're in Wine/Win ABI) - -/// Minimal view of a `sockaddr_in`; the first `u16` is the address family for ANY -/// sockaddr, so reading this layout is safe enough to classify the family even when -/// the real struct is a `sockaddr_un` or larger — we only trust the rest once we've -/// confirmed `sin_family == AF_INET`. -#[repr(C)] -struct SockaddrIn { - sin_family: u16, - sin_port: u16, - sin_addr: u32, - sin_zero: [u8; 8], -} - -/// Minimal view of a `sockaddr_in6` (Win32 layout). `sin6_port` is network byte order; -/// `sin6_addr` is the 16 raw address bytes in network order. We ignore flowinfo/scope. -#[repr(C)] -struct SockaddrIn6 { - sin6_family: u16, - sin6_port: u16, - sin6_flowinfo: u32, - sin6_addr: [u8; 16], - sin6_scope_id: u32, -} - -/// Is `port` a known/suspected Blaze port? SHAPE — public general knowledge; the exact -/// port for FIFA23's Blaze version is UNKNOWN. 42127 main, 10041/10744 redirector -/// variants, 3659 classic redirector. -fn is_blaze_port(port: u16) -> bool { - matches!(port, 42127 | 10744 | 3659 | 10041) -} - -/// Log one outbound connect attempt. `api` names the call path (`connect` / -/// `WSAConnect` / `ConnectEx`) so we can tell which Winsock entry the client used. -/// -/// SAFETY: `name` must point to at least `namelen` readable bytes — it's the sockaddr -/// the game just handed to a Winsock connect API, so that always holds at the call -/// sites. We read it read-only and never write through it. `s` is the socket handle, -/// used only to query `SO_TYPE` (TCP=1 / UDP=2) so a real Blaze TCP dial is -/// distinguishable from UDP game/voice traffic. -pub unsafe fn note_connect(api: &str, name: *const u8, namelen: i32, s: usize) { - if !armed() { - return; - } - if name.is_null() || namelen < 8 { - crate::write_log(&format!( - "TRANSPORT_WATCH: {api} (no/short sockaddr, namelen={namelen})\n" - )); - return; - } - // SAFE: name is non-null and >= 8 bytes (checked above); the first u16 is the - // address family for ANY sockaddr, so reading it is valid regardless of the real - // struct type. We only trust family-specific fields after matching the family. - let family = *(name as *const u16); - - // SAFE: getsockopt is a read-only Winsock query on a valid socket handle; a bad - // handle just leaves ty=-1, which we log verbatim. TCP=1 / UDP=2. - let sock_type = { - use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE}; - let mut ty: i32 = -1; - let mut len: i32 = 4; - getsockopt( - s, - SOL_SOCKET, - SO_TYPE, - &mut ty as *mut i32 as *mut u8, - &mut len, - ); - ty - }; - - match family { - AF_INET => { - // SAFE: family is AF_INET and namelen >= 8 == sizeof(sockaddr_in) fields we read. - let sa = &*(name as *const SockaddrIn); - // sin_addr holds the address in NETWORK byte order; on little-endian x86, - // to_le_bytes reproduces those 4 bytes in memory order, which IS the dotted - // quad. So b[0].b[1].b[2].b[3] is correct. (The legacy connect_hook log line - // prints these reversed — a cosmetic bug there; this M0 line is the correct - // one to trust.) - let b = sa.sin_addr.to_le_bytes(); - let port = u16::from_be(sa.sin_port); - let is_loopback = b[0] == 127; - let is_lsx = matches!(port, 3216 | 3217); // known-good LSX channel; not Blaze - let mut tag = String::new(); - if is_blaze_port(port) { - tag.push_str(" <-- BLAZE-PORT"); - } - // A loopback connect on anything other than LSX is the situation-(a) signal. - if is_loopback && !is_lsx { - tag.push_str(" <-- LOOPBACK non-LSX"); - } - crate::write_log(&format!( - "TRANSPORT_WATCH: {api} target={}.{}.{}.{}:{port} sock_type={sock_type}{tag}\n", - b[0], b[1], b[2], b[3] - )); - } - AF_INET6 => { - if namelen < 28 { - crate::write_log(&format!( - "TRANSPORT_WATCH: {api} family=INET6 (short sockaddr, namelen={namelen})\n" - )); - return; - } - // SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6). - let sa = &*(name as *const SockaddrIn6); - let a = sa.sin6_addr; // 16 bytes, network order - let port = u16::from_be(sa.sin6_port); - // Format as 8 colon-separated hex groups (not compressed — clarity over - // brevity for a log meant to be grepped). - let hex = (0..8) - .map(|i| format!("{:02x}{:02x}", a[i * 2], a[i * 2 + 1])) - .collect::>() - .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" - )); - } - } -} diff --git a/src/config.rs b/src/config.rs index 1bc7b24..2587efd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -84,7 +84,9 @@ impl GameProfile { } 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()); + 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!( @@ -120,7 +122,8 @@ pub struct LauncherConfig { pub bridge_tls_enabled: bool, /// Path to the built openfut_hook.dll (Windows DLL for Proton injection). pub hook_dll_path: String, - /// FIFA 23 game folder inside the Proton prefix (where the DLL is deployed). + /// Game folder where the hook DLL (version.dll) is deployed. Empty means + /// "not configured" — the hook deploy/check is skipped until the user sets it. 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. @@ -235,11 +238,9 @@ impl Default for LauncherConfig { .unwrap_or_default() .to_string_lossy() .into(), - fifa_game_dir: dirs::home_dir() - .map(|h| h.join(".steam/steam/steamapps/common/FIFA 23")) - .unwrap_or_default() - .to_string_lossy() - .into(), + // Empty by default, like the server host and game profile: the + // launcher never invents a path to somebody's game install. + fifa_game_dir: String::new(), // No server configured by default — the user MUST enter one. There // is deliberately no loopback/localhost default. openfut_server_host: String::new(), diff --git a/src/setup.rs b/src/setup.rs index 2b7ef9c..3aab8a4 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -70,13 +70,13 @@ pub(crate) fn run_elevated(script: &str) -> anyhow::Result<()> { /// The file the injected hook reads its server address from, in the game dir. pub const HOOK_CFG_FILE: &str = "openfut.cfg"; -/// Deploy openfut_hook.dll into the FIFA 23 game directory and write -/// openfut.cfg with the structured server configuration the hook reads. -/// `cfg_contents` must be the full `openfut.cfg` body (see -/// `LauncherConfig::hook_cfg_contents`) — this function does not invent any -/// address itself, so a missing server can never silently become loopback. -/// 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. +/// Deploy openfut_hook.dll into the game directory and write openfut.cfg with the +/// structured server configuration the hook reads. `cfg_contents` must be the full +/// `openfut.cfg` body (see `LauncherConfig::hook_cfg_contents`) — this function +/// does not invent any address itself, so a missing server can never silently +/// become loopback. Uses `version.dll` as the hijack name: the game loads it but +/// defers to the system copy, so the loader (native or Wine) picks up our local +/// one first. pub fn deploy_hook_dll(dll_src: &Path, game_dir: &Path, cfg_contents: &str) -> anyhow::Result<()> { if !dll_src.exists() { anyhow::bail!(