From feaff0443fdac053ea7967f515b529d0d60b97b7 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 2 Jul 2026 16:59:27 -0700 Subject: [PATCH 1/9] hook: in-process RE instrumentation + LSX redirect + capture tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook-side tooling for the LSX/Blaze reverse-engineering effort: - probe.rs (new, `probe` feature): passive logging detours on FIFA's online-flow functions via the unhook/rehook pattern (no trampoline/relocation, works on RIP-relative prologues). Deferred install waits for anadius64.dll to load, then logs enter/return for GoOnline + GetInternetConnectedState (anadius) and the OnlineStatusEvent/Login deserializers (FIFA23.exe). Revealed that our pushed LSX events reach FIFA and parse OK, while GoOnline never fires — localizing the online gate to FIFA's game-side event consumer. - connect_hook.rs: redirect FIFA's LSX connect :3216 → :3217 so it lands on the native openfut-bridge LSX server (slips past anadius's in-process :3216 intercept); gated off under the `capture_baseline` feature. - recv_hook.rs: boundary-safe trampolines + LSX peer filtering for the capture_baseline path (log anadius's real LSX frames when the redirect is off). Build the instrumented DLL with `--features probe` (or `--features capture_baseline` for the anadius-baseline capture). Both features are off by default. Co-Authored-By: Claude Fable 5 --- openfut-hook/Cargo.toml | 10 +++ openfut-hook/src/connect_hook.rs | 11 +++ openfut-hook/src/lib.rs | 21 ++++++ openfut-hook/src/probe.rs | 125 +++++++++++++++++++++++++++++++ openfut-hook/src/recv_hook.rs | 125 ++++++++++++++++++++----------- 5 files changed, 250 insertions(+), 42 deletions(-) create mode 100644 openfut-hook/src/probe.rs diff --git a/openfut-hook/Cargo.toml b/openfut-hook/Cargo.toml index fa73019..02812d9 100644 --- a/openfut-hook/Cargo.toml +++ b/openfut-hook/Cargo.toml @@ -6,6 +6,16 @@ edition = "2021" [lib] crate-type = ["cdylib"] +[features] +# Build with `--features capture_baseline` to DISABLE the LSX 3216→3217 redirect, +# so FIFA's LSX goes to anadius's in-process server (for capturing anadius's real +# responses). Default build keeps the redirect (LSX → our bridge). +capture_baseline = [] +# Build with `--features probe` to install passive logging detours on FIFA's +# in-process online-flow functions (GoOnline, GetInternetConnectedState, event +# deserializers). Writes PROBE lines to C:\openfut_hook.log for RE. See probe.rs. +probe = [] + [dependencies] windows-sys = { version = "0.59", features = [ "Win32_Foundation", diff --git a/openfut-hook/src/connect_hook.rs b/openfut-hook/src/connect_hook.rs index 35086c7..65ef5e0 100644 --- a/openfut-hook/src/connect_hook.rs +++ b/openfut-hook/src/connect_hook.rs @@ -9,6 +9,15 @@ 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)] @@ -62,6 +71,8 @@ unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 16], i32 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, diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index 54fe334..1195a17 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -4,6 +4,10 @@ mod connectex_hook; mod hooks; mod iat; mod origin_spy; +#[cfg(feature = "probe")] +mod probe; +#[cfg(feature = "capture_baseline")] +mod recv_hook; mod ssl_patch; mod tls_bypass; @@ -61,8 +65,25 @@ unsafe fn install_hooks(module: HMODULE) { 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) => {{ diff --git a/openfut-hook/src/probe.rs b/openfut-hook/src/probe.rs new file mode 100644 index 0000000..210add5 --- /dev/null +++ b/openfut-hook/src/probe.rs @@ -0,0 +1,125 @@ +//! 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::{AtomicUsize, Ordering}; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; +use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; + +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): +/// - anadius64.dll GetInternetConnectedState / GoOnline — FIFA's in-process online +/// decision points (anadius emulates EbisuSDK in-process). +/// - FIFA23.exe OnlineStatusEvent/Login deserializers — confirm our pushed events +/// actually reach FIFA's parser, and with what timing. +const TARGETS: &[Target] = &[ + Target { module: b"anadius64.dll\0", rva: 0x27790, label: "GetInternetConnectedState", main_exe: false }, + Target { module: b"anadius64.dll\0", rva: 0x2bb90, label: "GoOnline", main_exe: false }, + Target { module: b"\0", rva: 0x278a4d0, label: "OnlineStatus.deser", main_exe: true }, + Target { module: b"\0", rva: 0x2787a30, label: "Login.deser", main_exe: true }, +]; + +const N: usize = 4; // must equal TARGETS.len() + +static ADDRS: [AtomicUsize; N] = [const { AtomicUsize::new(0) }; N]; +static mut ORIG: [[u8; 14]; N] = [[0u8; 14]; N]; + +type ProbeFn = unsafe extern "system" fn(usize, usize, usize, usize) -> usize; +const PROBE_FNS: [ProbeFn; N] = [p0, p1, p2, p3]; + +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; + // Unhook, log entry, call the real function, re-hook, log return. + restore(slot); + crate::write_log(&format!( + "PROBE {label} 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); + crate::write_log(&format!("PROBE {label} ret={r:#x}\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) } + +/// 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 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)); + } +} diff --git a/openfut-hook/src/recv_hook.rs b/openfut-hook/src/recv_hook.rs index 32b97e4..6c9015c 100644 --- a/openfut-hook/src/recv_hook.rs +++ b/openfut-hook/src/recv_hook.rs @@ -22,32 +22,41 @@ unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option { use windows_sys::Win32::System::Memory::{ VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE, }; - // Log prologue so we can diagnose if trampolines misbehave - let bytes: [u8; 14] = core::array::from_fn(|i| *orig.add(i)); - let hex: String = bytes.iter().map(|b| format!("{b:02x} ")).collect(); + // Read enough prologue to walk instruction boundaries. + let probe: [u8; 24] = core::array::from_fn(|i| *orig.add(i)); + let hex: String = probe[..14].iter().map(|b| format!("{b:02x} ")).collect(); crate::write_log(&format!("recv_hook: {name} prologue {hex}\n")); - // Walk instruction boundaries to find relative branches. - // Byte-by-byte scanning mis-identifies immediate operands (e.g. `sub rsp, 0x70`) - // as jump opcodes, so we must parse properly. - if has_rip_relative_branch(&bytes) { - crate::write_log(&format!("recv_hook: {name} has relative branch in prologue, skipping trampoline\n")); - return None; + // Copy WHOLE instructions until we've covered >= 14 bytes (the size of the JMP + // patch), so the trampoline never splits an instruction. Copying a fixed 14 + // bytes lands mid-instruction on these prologues and crashes on execution. + let mut copy_len = 0usize; + while copy_len < 14 { + let (len, branch) = decode_instr_len(&probe[copy_len..]); + if len == 0 || branch { + crate::write_log(&format!( + "recv_hook: {name} unrelocatable prologue (len={len} branch={branch}), skipping\n" + )); + return None; + } + copy_len += len; } let mem = VirtualAlloc( - core::ptr::null_mut(), 32, + 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, 14); - // JMP [RIP+0] → orig+14 - let cont = (orig as u64) + 14; - t.add(14).write(0xFF); t.add(15).write(0x25); - (t.add(16) as *mut u32).write(0); - (t.add(20) as *mut u64).write(cont); + core::ptr::copy_nonoverlapping(orig, t, copy_len); + // JMP [RIP+0] → orig+copy_len (resume at the next whole instruction) + let cont = (orig as u64) + copy_len as u64; + t.add(copy_len).write(0xFF); + t.add(copy_len + 1).write(0x25); + (t.add(copy_len + 2) as *mut u32).write(0); + (t.add(copy_len + 6) as *mut u64).write(cont); + crate::write_log(&format!("recv_hook: {name} trampoline copy_len={copy_len}\n")); Some(t as usize) } @@ -151,46 +160,78 @@ unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> { static RECV_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); -pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 { - if crate::lsx::is_lsx(s) { - return crate::lsx::on_recv(s, buf, len); - } - let t = RECV_TRAMPOLINE.load(Ordering::Relaxed); - if t == 0 { return -1; } - let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t); - f(s, buf, len, flags) +/// 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) => { RECV_TRAMPOLINE.store(t, Ordering::Relaxed); } - None => { crate::write_log("recv_hook: recv trampoline failed, hook skipped\n"); return false; } + Some(t) => REAL_RECV.store(t, Ordering::Relaxed), + None => return false, } write_jmp(ptr, hooked_recv as u64); true } -// ─── send ────────────────────────────────────────────────────────────────────── - -static SEND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); - -pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 { - if crate::lsx::is_lsx(s) { - return crate::lsx::on_send(s, buf, len); - } - let t = SEND_TRAMPOLINE.load(Ordering::Relaxed); - if t == 0 { return -1; } - let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t); - f(s, buf, len, flags) -} - pub unsafe fn install_send_hook() -> bool { let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") { Some(p) => p, None => return false }; match make_trampoline(ptr, "send") { - Some(t) => { SEND_TRAMPOLINE.store(t, Ordering::Relaxed); } - None => { crate::write_log("recv_hook: send trampoline failed, hook skipped\n"); return false; } + Some(t) => REAL_SEND.store(t, Ordering::Relaxed), + None => return false, } write_jmp(ptr, hooked_send as u64); true } + +pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 { + let t = REAL_RECV.load(Ordering::Relaxed); + if t == 0 { return -1; } + let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t); + // Pass through to anadius's real socket, then log what it sent back + // (anadius's LSX response — the ground truth we want to diff against). + let n = f(s, buf, len, flags); + if n > 0 && peer_is_lsx(s) { + let data = core::slice::from_raw_parts(buf, n as usize); + let text = core::str::from_utf8(data).unwrap_or("(binary)"); + crate::write_log(&format!("CAP recv<-anadius s={s} n={n}: {}\n", &text[..text.len().min(2400)])); + } + n +} + +pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 { + if len > 0 && peer_is_lsx(s) { + let data = core::slice::from_raw_parts(buf, len as usize); + let text = core::str::from_utf8(data).unwrap_or("(binary)"); + crate::write_log(&format!("CAP send->anadius s={s} len={len}: {}\n", &text[..text.len().min(2400)])); + } + let t = REAL_SEND.load(Ordering::Relaxed); + if t == 0 { return -1; } + let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t); + f(s, buf, len, flags) +} From 3c3dc32fc6e05e0c4b2699be355e2a24a7a8285f Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 2 Jul 2026 17:25:33 -0700 Subject: [PATCH 2/9] probe: add behavior-preserving mid-function detour to capture the live OnlineStatusEvent listener The OnlineStatusEventT::HandleMessage dispatch resolves its game-side listener only at runtime (call [rax+0x28]). openfut_listener_stub patches FIFA23.exe+0x274d4d7 to replicate the four dispatch instructions while logging the resolved vtable/fn, then resumes. Alignment-safe (saves/rounds rsp before the log call). Result: listener = FIFA23.exe+0x2751060 = ret 0, a no-op default vtable slot -> the online->auth transition is state-polled, not callback-driven. Co-Authored-By: Claude Fable 5 --- openfut-hook/src/probe.rs | 82 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/openfut-hook/src/probe.rs b/openfut-hook/src/probe.rs index 210add5..e54d83d 100644 --- a/openfut-hook/src/probe.rs +++ b/openfut-hook/src/probe.rs @@ -15,10 +15,89 @@ //! //! 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::{AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; +// ─── 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) { + 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); + 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)); +} + struct Target { /// DLL name (nul-terminated) or ignored when `main_exe` is true. module: &'static [u8], @@ -100,6 +179,7 @@ pub fn install_probes_deferred() { std::thread::sleep(std::time::Duration::from_millis(500)); } install_probes(); + install_listener_probe(); }); } From c1d1f03ec894db43e90d8ad55668b053fe68a2be Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 2 Jul 2026 17:56:31 -0700 Subject: [PATCH 3/9] probe: retarget to game-side connect layer + add guarded session-context sampler Replaces the anadius-connectivity probes with the game-side Nucleus-connect functions (nucleusConnectREST/Trusted, connect-state tick) and adds a VirtualQuery-guarded sampler thread that reads X=[0x14acd02c0] -> M=[X+0x360] -> ctx=[M+0x778] once/sec to observe the session context directly. Per-slot log cap prevents per-frame handlers flooding the log. Run 3 result: ctx is non-null but the connect functions are never called (see bridge findings). Co-Authored-By: Claude Fable 5 --- openfut-hook/src/probe.rs | 118 +++++++++++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 15 deletions(-) diff --git a/openfut-hook/src/probe.rs b/openfut-hook/src/probe.rs index e54d83d..4ce7c7c 100644 --- a/openfut-hook/src/probe.rs +++ b/openfut-hook/src/probe.rs @@ -17,7 +17,70 @@ //! r8/r9) and returns in rax. All targets here are SDK methods with few args. use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; -use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; +use windows_sys::Win32::System::Memory::{ + VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE, + PAGE_GUARD, PAGE_NOACCESS, +}; + +/// 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 ──────────────────────────────────────────── // @@ -108,22 +171,35 @@ struct Target { main_exe: bool, } -/// Probe targets. Addresses recovered by RE (see docs/connection-gate-findings.md): -/// - anadius64.dll GetInternetConnectedState / GoOnline — FIFA's in-process online -/// decision points (anadius emulates EbisuSDK in-process). -/// - FIFA23.exe OnlineStatusEvent/Login deserializers — confirm our pushed events -/// actually reach FIFA's parser, and with what timing. +/// 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] = &[ - Target { module: b"anadius64.dll\0", rva: 0x27790, label: "GetInternetConnectedState", main_exe: false }, - Target { module: b"anadius64.dll\0", rva: 0x2bb90, label: "GoOnline", main_exe: false }, - Target { module: b"\0", rva: 0x278a4d0, label: "OnlineStatus.deser", main_exe: true }, - Target { module: b"\0", rva: 0x2787a30, label: "Login.deser", main_exe: true }, + Target { module: b"\0", rva: 0x2861910, label: "nucleusConnectREST", main_exe: true }, + Target { module: b"\0", rva: 0x5078370, label: "nucleusConnectTrusted", main_exe: true }, + Target { module: b"\0", rva: 0x507d660, label: "connectState.tick", main_exe: true }, + Target { module: b"\0", rva: 0x278a4d0, label: "OnlineStatus.deser", main_exe: true }, ]; const N: usize = 4; // 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]; @@ -150,15 +226,26 @@ unsafe fn restore(slot: usize) { 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; - // Unhook, log entry, call the real function, re-hook, log return. + // 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); - crate::write_log(&format!( - "PROBE {label} enter rcx={a:#x} rdx={b:#x} r8={c:#x} r9={d:#x}\n" - )); + 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); - crate::write_log(&format!("PROBE {label} ret={r:#x}\n")); + 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 } @@ -180,6 +267,7 @@ pub fn install_probes_deferred() { } install_probes(); install_listener_probe(); + install_state_sampler(); }); } From 493b9e057366073e32bb9b7e91a8c0c3068f0759 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 2 Jul 2026 18:03:50 -0700 Subject: [PATCH 4/9] probe: run-4 connect-state lifecycle spread (8 targets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands to 8 entry probes across the connect-state lifecycle (ctor, controller accessor, four vtable steps) to distinguish entered-but-stalled from never-entered. Result: ctor fires x4, everything else 0 — subsystem created but dormant. Co-Authored-By: Claude Fable 5 --- openfut-hook/src/probe.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/openfut-hook/src/probe.rs b/openfut-hook/src/probe.rs index 4ce7c7c..fc208fa 100644 --- a/openfut-hook/src/probe.rs +++ b/openfut-hook/src/probe.rs @@ -186,13 +186,20 @@ struct Target { /// - `OnlineStatus.deser` (+0x278a4d0): confirms our pushed OnlineStatusEvent still /// arrives during the test (control signal). const TARGETS: &[Target] = &[ - Target { module: b"\0", rva: 0x2861910, label: "nucleusConnectREST", main_exe: true }, - Target { module: b"\0", rva: 0x5078370, label: "nucleusConnectTrusted", main_exe: true }, - Target { module: b"\0", rva: 0x507d660, label: "connectState.tick", main_exe: true }, - Target { module: b"\0", rva: 0x278a4d0, label: "OnlineStatus.deser", main_exe: true }, + // 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 = 4; // must equal TARGETS.len() +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]; @@ -202,7 +209,7 @@ 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]; +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; @@ -253,6 +260,10 @@ unsafe extern "system" fn p0(a: usize, b: usize, c: usize, d: usize) -> usize { 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 From ff4b5a87f501c237c96af2866122de1387b01401 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 2 Jul 2026 18:45:08 -0700 Subject: [PATCH 5/9] probe: ungate the forcing experiment (manual-only) install_force_connect() is no longer auto-called from install_probes_deferred, so normal probe builds don't poke the online flow. Kept for reference; re-enable the call to reproduce the 2026-07-02 forcing experiment. Co-Authored-By: Claude Fable 5 --- openfut-hook/src/probe.rs | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/openfut-hook/src/probe.rs b/openfut-hook/src/probe.rs index fc208fa..92482c1 100644 --- a/openfut-hook/src/probe.rs +++ b/openfut-hook/src/probe.rs @@ -161,6 +161,47 @@ pub unsafe fn install_listener_probe() { crate::write_log(&format!("PROBE listener: dispatch site patched @ {:#x}\n", target as usize)); } +/// 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), up to ~60s. + let mut fired = 0; + for i in 0..120u32 { + 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 a few seconds settled at "connecting" before poking. + if i < 60 { 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 >= 3 { break; } + std::thread::sleep(std::time::Duration::from_millis(3000)); + } + crate::write_log("FORCE: done\n"); + }); +} + struct Target { /// DLL name (nul-terminated) or ignored when `main_exe` is true. module: &'static [u8], @@ -279,6 +320,9 @@ pub fn install_probes_deferred() { install_probes(); install_listener_probe(); install_state_sampler(); + // install_force_connect(); // manual-only: forcing experiment (2026-07-02); + // re-enable to auto-invoke nucleusConnectREST(). Off by default so normal + // probe builds don't poke the online flow. }); } From 7dcf610b714cdc4075329199154da598bc01004a Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 3 Jul 2026 15:58:17 -0700 Subject: [PATCH 6/9] openfut-hook: RE instrumentation for the Blaze dial-gate investigation In-process, read-only probes and transport observation built while closing the online/FUT route from both the memory and network sides. - probe.rs / dial_notification.rs: menu-time ctx dump, connMgr enumerator, synthetic dial-notification + direct-call dial trigger, and the [element+0x40] container write-watchpoint. All env-gated, one-shot, VirtualQuery-guarded; none alter game state by default. - transport_watch.rs + connect/connectex/hooks/lib: M0 transport observation (grep-friendly TRANSPORT_WATCH logging on the existing getaddrinfo/connect/ WSAConnect/ConnectEx detours) and an IPv6 (v4-mapped) EA-redirect so the game's IPv6 :443 dials land on the bridge instead of the dead servers. Findings: the game never initiates a Blaze connection offline; the dial handler is registered by a self-registering, message-driven state machine whose container stays empty with no Blaze exchange. See openfut-bridge docs/closure-and-preservation.md. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 5 + openfut-hook/src/connect_hook.rs | 126 ++- openfut-hook/src/connectex_hook.rs | 48 +- openfut-hook/src/dial_notification.rs | 183 ++++ openfut-hook/src/hooks.rs | 2 + openfut-hook/src/lib.rs | 17 + openfut-hook/src/probe.rs | 1258 ++++++++++++++++++++++++- openfut-hook/src/transport_watch.rs | 225 +++++ 8 files changed, 1785 insertions(+), 79 deletions(-) create mode 100644 openfut-hook/src/dial_notification.rs create mode 100644 openfut-hook/src/transport_watch.rs diff --git a/.gitignore b/.gitignore index 2f7896d..10dc867 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ target/ + +# runtime SQLite DB (created when services run from this dir) +openfut.db +openfut.db-shm +openfut.db-wal diff --git a/openfut-hook/src/connect_hook.rs b/openfut-hook/src/connect_hook.rs index 65ef5e0..4dc3856 100644 --- a/openfut-hook/src/connect_hook.rs +++ b/openfut-hook/src/connect_hook.rs @@ -28,6 +28,27 @@ struct SockaddrIn { sin_zero: [u8; 8], } +const AF_INET6: u16 = 23; // Windows AF_INET6 value (we run under the Win ABI in Wine) + +/// Win32 `sockaddr_in6`. `sin6_port` is network byte order; `sin6_addr` is 16 raw +/// address bytes in network order. 28 bytes total. +#[repr(C)] +struct SockaddrIn6 { + sin6_family: u16, + sin6_port: u16, + sin6_flowinfo: u32, + sin6_addr: [u8; 16], + sin6_scope_id: u32, +} + +/// IPv4-mapped IPv6 loopback: `::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); @@ -61,40 +82,87 @@ unsafe fn restore_original(target: *mut u8) { VirtualProtect(target as _, 14, old, &mut old); } -unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 16], i32)> { - if namelen < 8 { return None; } - let sa = &*(name as *const SockaddrIn); - if sa.sin_family != AF_INET { return None; } +/// If `name` is an EA-relevant connect target, return a rewritten sockaddr pointing at +/// the local bridge (plus its byte length). Handles BOTH `AF_INET` and `AF_INET6`: the +/// game's Blaze/DirtySDK stack dials EA over IPv6 (v4-mapped) on :443, and the old +/// IPv4-only path let those slip straight past us to the real (dead) servers. +/// +/// The returned buffer is 28 bytes (enough for a `sockaddr_in6`); the second value is +/// how many of those bytes are meaningful (16 for v4, 28 for v6). `pub(crate)` so the +/// ConnectEx path can share this one implementation. +pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> { + if namelen < 8 || name.is_null() { + return None; + } + // The first u16 of any sockaddr is the address family. + let family = *(name as *const u16); + let mut buf = [0u8; 28]; - let orig = sa.sin_addr.to_le_bytes(); - let orig_port = u16::from_be(sa.sin_port); - - let new_port_nbo = match sa.sin_port { - PORT_HTTPS_NBO => PORT_BRIDGE_NBO, - #[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, - }; - - crate::write_log(&format!( - "connect_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n", - orig[3], orig[2], orig[1], orig[0], orig_port, - u16::from_be(new_port_nbo) - )); - - let mut buf = [0u8; 16]; - let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn); - out.sin_family = AF_INET; - out.sin_port = new_port_nbo; - out.sin_addr = ADDR_LOOPBACK_NBO; - Some((buf, 16)) + match family { + AF_INET => { + // SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read. + let sa = &*(name as *const SockaddrIn); + let new_port_nbo = match sa.sin_port { + PORT_HTTPS_NBO => PORT_BRIDGE_NBO, + #[cfg(not(feature = "capture_baseline"))] + PORT_LSX_NBO => PORT_LSX_TARGET_NBO, + PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, + PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, + _ => return None, + }; + // sin_addr is network order; to_le_bytes gives memory order = the dotted + // quad, so b[0].b[1].b[2].b[3] is correct (the old code printed it reversed). + let o = sa.sin_addr.to_le_bytes(); + crate::write_log(&format!( + "connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n", + o[0], o[1], o[2], o[3], u16::from_be(sa.sin_port), + u16::from_be(new_port_nbo) + )); + // SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write. + let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn); + out.sin_family = AF_INET; + out.sin_port = new_port_nbo; + out.sin_addr = ADDR_LOOPBACK_NBO; + Some((buf, 16)) + } + AF_INET6 => { + if namelen < 28 { + return None; + } + // SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6). + let sa6 = &*(name as *const SockaddrIn6); + // LSX is IPv4-only (anadius keys on it), so it is intentionally omitted here. + let new_port_nbo = match sa6.sin6_port { + PORT_HTTPS_NBO => PORT_BRIDGE_NBO, + PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, + PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, + _ => return None, + }; + let a = sa6.sin6_addr; + crate::write_log(&format!( + "connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n", + a[0], a[1], a[14], a[15], u16::from_be(sa6.sin6_port), + u16::from_be(new_port_nbo) + )); + // SAFE: buf is exactly 28 bytes == sizeof(sockaddr_in6). + let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6); + out.sin6_family = AF_INET6; + out.sin6_port = new_port_nbo; + out.sin6_flowinfo = 0; + out.sin6_addr = V4MAPPED_LOOPBACK; + out.sin6_scope_id = 0; + Some((buf, 28)) + } + _ => None, + } } pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: i32) -> i32 { let addr = CONNECT_ADDR.load(Ordering::Relaxed) as *mut u8; + // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). + crate::transport_watch::note_connect("connect", name, namelen, s); + // Log every call so we can confirm the hook fires at all if namelen >= 8 { let sa = &*(name as *const SockaddrIn); @@ -157,6 +225,8 @@ pub unsafe extern "system" fn hooked_wsa_connect( caller: *const (), callee: *const (), sqos: *const (), gqos: *const (), ) -> i32 { + // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). + crate::transport_watch::note_connect("WSAConnect", name, namelen, s); let real = REAL_WSA.get().copied().unwrap(); if let Some((buf, len)) = redirect_if_ea(name, namelen) { real(s, buf.as_ptr(), len, caller, callee, sqos, gqos) diff --git a/openfut-hook/src/connectex_hook.rs b/openfut-hook/src/connectex_hook.rs index a50b6b7..71a0070 100644 --- a/openfut-hook/src/connectex_hook.rs +++ b/openfut-hook/src/connectex_hook.rs @@ -6,12 +6,8 @@ use core::sync::atomic::{AtomicUsize, Ordering}; use core::ffi::c_void; -const AF_INET: u16 = 2; -const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian -const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian -const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian -const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian -const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian +// Address rewriting (v4 + v6) is shared from connect_hook::redirect_if_ea, so the port +// constants and sockaddr structs no longer live here. // SIO_GET_EXTENSION_FUNCTION_POINTER const SIO_GET_EXT_FN: u32 = 0xC8000006; @@ -23,14 +19,6 @@ const CONNECTEX_GUID: [u8; 16] = [ 0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E, ]; -#[repr(C)] -struct SockaddrIn { - sin_family: u16, - sin_port: u16, - sin_addr: u32, - sin_zero: [u8; 8], -} - // The real ConnectEx pointer, saved after WSAIoctl returns it static REAL_CONNECTEX: AtomicUsize = AtomicUsize::new(0); @@ -91,31 +79,13 @@ unsafe extern "system" fn hooked_connectex( ) -> i32 { let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed)); - if namelen >= 8 { - let sa = &*(name as *const SockaddrIn); - if sa.sin_family == AF_INET { - let o = sa.sin_addr.to_le_bytes(); - let orig_port = u16::from_be(sa.sin_port); - let new_port_nbo = match sa.sin_port { - PORT_HTTPS_NBO => PORT_BRIDGE_NBO, - PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, - PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, - _ => 0, - }; - if new_port_nbo != 0 { - crate::write_log(&format!( - "connectex_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n", - o[3], o[2], o[1], o[0], orig_port, - u16::from_be(new_port_nbo) - )); - let mut redirect = [0u8; 16]; - let out = &mut *(redirect.as_mut_ptr() as *mut SockaddrIn); - out.sin_family = AF_INET; - out.sin_port = new_port_nbo; - out.sin_addr = ADDR_LOOPBACK_NBO; - return real_fn(s, redirect.as_ptr(), 16, send_buf, send_data_len, bytes_sent, overlapped); - } - } + // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). + crate::transport_watch::note_connect("ConnectEx", name, namelen, s); + + // Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx + // dials get the same IPv6 handling as plain connect(). + if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) { + return real_fn(s, buf.as_ptr(), len, send_buf, send_data_len, bytes_sent, overlapped); } real_fn(s, name, namelen, send_buf, send_data_len, bytes_sent, overlapped) } diff --git a/openfut-hook/src/dial_notification.rs b/openfut-hook/src/dial_notification.rs new file mode 100644 index 0000000..60b96bf --- /dev/null +++ b/openfut-hook/src/dial_notification.rs @@ -0,0 +1,183 @@ +//! Synthetic "notification" struct for the direct-call dial trigger. +//! +//! STATIC ARTIFACT ONLY — this module builds the byte layout the dial handler +//! (FIFA23.exe+0x4f4d360) expects in its `rdx` argument, plus a do-nothing +//! completion callback. It does NOT call the game, does NOT install any detour, +//! and is NOT wired into the hook yet. The invocation phase (later) consumes +//! `build_notification()` + `completion_stub`. +//! +//! Layout contract (from the 2026-07-03 dial-branch RE report on 0x144f4d590): +//! [+0x00] byte : entry gate — MUST be non-zero (else the error path fires). => 1 +//! [+0x80] qword : completion delegate fn pointer. => &completion_stub +//! [+0x88] qword : delegate capture #1. => 0 +//! [+0x90] qword : delegate capture #2. => 0 +//! [+0xa0] dword : RpcJob key/priority (copied, never compared on dial path). => 0 +//! everything else in [0x00..0x100] : 0 +//! The RE confirmed no other offset in this range is read on the success path. +//! Total size 0x100 (256): the tail 0xa4..0x100 is zero padding — cheap insurance +//! against a read we might have missed. Any offset here is TODO/CONFIRM against the +//! RE report; if the game contradicts it at runtime, stop and re-verify. + +// This module is deliberately unused for now (the invocation phase will call into +// it). Silence "never used" warnings until then rather than sprinkle #[allow] on +// each item. Remove this once the trigger wires the API up. +#![allow(dead_code)] + +use core::sync::atomic::{AtomicU32, Ordering}; + +/// Size of the notification struct, in bytes. 0x100 = 256. +const NOTIFICATION_SIZE: usize = 0x100; + +// --- field offsets (named so the code reads like the RE contract) ------------- +const OFF_GATE: usize = 0x00; // byte, must be non-zero +const OFF_DELEGATE_FN: usize = 0x80; // qword, completion fn pointer +const OFF_DELEGATE_CAP1: usize = 0x88; // qword, capture (0) +const OFF_DELEGATE_CAP2: usize = 0x90; // qword, capture (0) +const OFF_KEY: usize = 0xa0; // dword, job key/priority (0) + +/// Counts how many times `completion_stub` has been entered. +/// +/// Why `AtomicU32` and not `static mut u32`: a `static mut` needs `unsafe` to +/// touch and, worse, gives *undefined behaviour* if two threads write it at once +/// (a data race). The completion callback may be invoked from an arbitrary game +/// thread, so a plain counter would race. `AtomicU32` makes increment a single +/// lock-free hardware instruction with well-defined concurrent semantics, and it +/// needs no `unsafe`. `Ordering::Relaxed` is enough here: we only care about the +/// count value, not about ordering it against other memory. +static COMPLETION_STUB_CALLS: AtomicU32 = AtomicU32::new(0); + +/// The completion callback the game may invoke when the RpcJob finishes. +/// +/// `extern "C"`: on the `x86_64-pc-windows-gnu` target this selects the Microsoft +/// x64 calling convention — exactly how the game invokes the pointer (`call r10`, +/// args in rcx/rdx/r8/r9, return in rax, caller cleans the stack). Matching the +/// convention is what makes it safe for the game to call us. +/// +/// We declare four pointer-sized params and ignore them. The RE showed the delegate +/// is called with e.g. an HRESULT in `rdx` and a `this`-like pointer in `rcx`; the +/// success-path completion may pass different values. Because Win64 is caller-clean +/// and puts the first four integer args in registers, declaring four ignored args is +/// safe no matter what the caller actually passes — we simply never read them. +/// +/// The body does the absolute minimum: bump the atomic counter and return 0. NO +/// logging, NO allocation, NO calls — a completion callback can fire from any game +/// context, and even a log write there could be unsafe. Observe from outside via +/// `completion_stub_call_count()` instead. +/// +/// Returns `usize` = 0, which reads as an `S_OK`-shaped HRESULT if the caller looks +/// at the return value. (Returning void would be equally fine; 0 is a safe default.) +pub extern "C" fn completion_stub(_a: usize, _b: usize, _c: usize, _d: usize) -> usize { + // `fetch_add` is a single atomic read-modify-write (lock xadd) — no lock, no + // syscall, no allocation. Safe to call from any thread/context. + COMPLETION_STUB_CALLS.fetch_add(1, Ordering::Relaxed); + 0 +} + +/// Read how many times `completion_stub` has fired. For an outside observer thread — +/// keeps all I/O out of the stub itself. +pub fn completion_stub_call_count() -> u32 { + COMPLETION_STUB_CALLS.load(Ordering::Relaxed) +} + +/// Write a little-endian u64 into `buf` starting at `offset`. +/// +/// Endianness matters because we're hand-laying a memory image the game will read +/// back as a raw pointer/integer. x86-64 is *little-endian*: the least-significant +/// byte sits at the lowest address. `value.to_le_bytes()` produces the 8 bytes in +/// exactly that order, so when the game does `mov rax,[ptr]` it reconstructs the +/// original `value`. Using the native byte order by hand (or `transmute`) would be +/// wrong on a big-endian machine; `to_le_bytes` states the intent explicitly. +/// +/// `buf[offset..offset + 8]` is an 8-byte sub-slice; `copy_from_slice` copies the +/// 8-byte array into it. Both sides are length 8, so it can't panic here. (This is +/// the standard, safe way to poke a fixed-width integer into a `[u8]`.) +fn write_u64_le(buf: &mut [u8], offset: usize, value: u64) { + buf[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +/// Write a little-endian u32 into `buf` starting at `offset`. (Same idea as +/// `write_u64_le`, 4 bytes wide.) +fn write_u32_le(buf: &mut [u8], offset: usize, value: u32) { + buf[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +/// Build the fully-populated notification struct, ready to be passed by pointer to +/// the dial handler as its `rdx` argument. +/// +/// Returns a `[u8; 0x100]` by value. Why a byte array and not a `#[repr(C)]` struct: +/// the layout is a precise *offset* contract recovered by RE, with meaningful data +/// only at 0x00/0x80/0x88/0x90/0xa0 and zeros elsewhere. A byte array makes every +/// offset literally visible and immune to any field-ordering/padding surprise. A +/// `#[repr(C)] struct` with explicit padding fields would work too, but it's easier +/// to get a padding byte wrong than to index a flat array. (For future reference: +/// the `bytemuck` crate can safely reinterpret a `#[repr(C)]` struct as `&[u8]` +/// zero-copy — worth knowing, but overkill here and an extra dependency.) +pub fn build_notification() -> [u8; NOTIFICATION_SIZE] { + // Start fully zeroed. This already satisfies every "= 0" field (caps at +0x88/ + // +0x90, the key at +0xa0, and all padding); we only need to set the non-zero + // fields below. + let mut buf = [0u8; NOTIFICATION_SIZE]; + + // [+0x00] entry gate: must be non-zero to reach the dial path. + buf[OFF_GATE] = 1; + + // [+0x80] completion delegate function pointer = &completion_stub. + // + // `completion_stub as *const ()`: a *function item* in Rust is a zero-sized, + // unique type, not a value. Casting it to a raw pointer coerces it to a function + // pointer and then to an untyped code pointer `*const ()` — i.e. the address of + // the function's machine code. The intermediate `*const ()` before `as u64` is + // the idiomatic form: it says "treat this as an address" and also avoids the + // `clippy`/rustc "direct cast of function item into an integer" lint you'd get + // from `completion_stub as u64`. + let stub_addr = completion_stub as *const () as u64; + write_u64_le(&mut buf, OFF_DELEGATE_FN, stub_addr); + + // [+0x88]/[+0x90] delegate captures = 0. Already zero from initialization; write + // them explicitly so the layout intent is visible at a glance. + write_u64_le(&mut buf, OFF_DELEGATE_CAP1, 0); + write_u64_le(&mut buf, OFF_DELEGATE_CAP2, 0); + + // [+0xa0] RpcJob key/priority dword = 0 (copied, never compared on the dial path). + write_u32_le(&mut buf, OFF_KEY, 0); + + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notification_layout() { + let n = build_notification(); + + // Total size is exactly 0x100. + assert_eq!(n.len(), NOTIFICATION_SIZE); + + // [+0x00] gate byte == 1. + assert_eq!(n[0x00], 1); + + // [+0xa0..0xa4] as u32 == 0. + // `try_into().unwrap()` turns the 4-byte slice into a `[u8; 4]` (it can only + // fail if the slice weren't length 4, which it is), and `from_le_bytes` + // reads it back the same little-endian way we wrote it. + let key = u32::from_le_bytes(n[0xa0..0xa4].try_into().unwrap()); + assert_eq!(key, 0); + + // [+0x80..0x88] as u64 == address of completion_stub. + let stub = u64::from_le_bytes(n[0x80..0x88].try_into().unwrap()); + assert_eq!(stub, completion_stub as *const () as u64); + + // [+0x88..0x90] and [+0x90..0x98] captures == 0. + assert_eq!(u64::from_le_bytes(n[0x88..0x90].try_into().unwrap()), 0); + assert_eq!(u64::from_le_bytes(n[0x90..0x98].try_into().unwrap()), 0); + } + + #[test] + fn stub_counter_increments() { + let before = completion_stub_call_count(); + let _ = completion_stub(0, 0, 0, 0); + assert_eq!(completion_stub_call_count(), before + 1); + } +} diff --git a/openfut-hook/src/hooks.rs b/openfut-hook/src/hooks.rs index 6246c4c..30842d2 100644 --- a/openfut-hook/src/hooks.rs +++ b/openfut-hook/src/hooks.rs @@ -54,6 +54,8 @@ pub unsafe extern "system" fn hooked_getaddrinfo( if !node_name.is_null() { if let Ok(host) = CStr::from_ptr(node_name as *const i8).to_str() { crate::write_log(&format!("openfut_hook: getaddrinfo({host})\n")); + // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). + crate::transport_watch::note_getaddrinfo(host); if is_ea_host(host) { // Apply the ProtoSSL cert-verify bypass the first time we see an EA // hostname — EAWebKit.dll must be loaded by now because it's calling us. diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index 1195a17..fd65dc8 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -1,6 +1,7 @@ mod config; mod connect_hook; mod connectex_hook; +mod dial_notification; mod hooks; mod iat; mod origin_spy; @@ -10,6 +11,7 @@ mod probe; mod recv_hook; mod ssl_patch; mod tls_bypass; +mod transport_watch; use windows_sys::Win32::{ Foundation::{BOOL, HMODULE, TRUE}, @@ -25,6 +27,18 @@ pub(crate) fn write_log(msg: &str) { { let _ = f.write_all(msg.as_bytes()); } } +/// Force the log to stable storage. `write_log` already opens+closes the file per line, +/// so nothing is buffered *inside our process* (a process crash can't lose a written +/// line). `sync_all` additionally flushes the OS cache to disk, for durability even +/// across a full system crash. We call this right before the dial trigger's call so the +/// pre-call log line is guaranteed on disk if the call faults. +#[allow(dead_code)] +pub(crate) fn flush_log() { + if let Ok(f) = std::fs::OpenOptions::new().append(true).open(r"C:\openfut_hook.log") { + let _ = f.sync_all(); + } +} + #[no_mangle] pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL { if reason == DLL_PROCESS_ATTACH { install_hooks(module); } @@ -33,6 +47,9 @@ pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) unsafe fn install_hooks(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); diff --git a/openfut-hook/src/probe.rs b/openfut-hook/src/probe.rs index 92482c1..c80b937 100644 --- a/openfut-hook/src/probe.rs +++ b/openfut-hook/src/probe.rs @@ -15,11 +15,12 @@ //! //! 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, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; +use windows_sys::Win32::System::Threading::GetCurrentThreadId; use windows_sys::Win32::System::Memory::{ - VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE, - PAGE_GUARD, PAGE_NOACCESS, + VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, MEM_PRIVATE, + PAGE_EXECUTE_READWRITE, PAGE_GUARD, PAGE_NOACCESS, }; /// Fault-safe pointer read: returns None unless `ptr` lands in a committed, readable @@ -104,6 +105,12 @@ 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; } @@ -155,12 +162,724 @@ pub unsafe fn install_listener_probe() { } 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 @@ -176,9 +895,9 @@ pub fn install_force_connect() { 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), up to ~60s. + // Wait for the ctx chain to be valid (FIFA past bootstrap / online), up to ~5 min. let mut fired = 0; - for i in 0..120u32 { + for i in 0..600u32 { std::thread::sleep(std::time::Duration::from_millis(500)); let ctx = read_ptr(x_slot) .filter(|&x| x != 0) @@ -187,21 +906,508 @@ pub fn install_force_connect() { .and_then(|m| read_ptr(m + 0x778)) .filter(|&c| c != 0); let Some(ctx) = ctx else { continue; }; - // Give the game a few seconds settled at "connecting" before poking. - if i < 60 { 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 >= 3 { break; } - std::thread::sleep(std::time::Duration::from_millis(3000)); + 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], @@ -320,9 +1526,23 @@ pub fn install_probes_deferred() { install_probes(); install_listener_probe(); install_state_sampler(); - // install_force_connect(); // manual-only: forcing experiment (2026-07-02); - // re-enable to auto-invoke nucleusConnectREST(). Off by default so normal - // probe builds don't poke the online flow. + 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. }); } @@ -346,3 +1566,17 @@ pub unsafe fn install_probes() { 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/transport_watch.rs b/openfut-hook/src/transport_watch.rs new file mode 100644 index 0000000..cfd573f --- /dev/null +++ b/openfut-hook/src/transport_watch.rs @@ -0,0 +1,225 @@ +//! Milestone 0 — Blaze transport reachability observation. +//! +//! PURE LOGGING, NO NEW DETOURS. This module does not hook anything itself. It is +//! called from the three Winsock detours the hook ALREADY installs — getaddrinfo +//! (`hooks.rs`), connect/WSAConnect (`connect_hook.rs`) and ConnectEx +//! (`connectex_hook.rs`) — and, when armed, emits a single grep-friendly +//! `TRANSPORT_WATCH:` line per resolution/connect so we can answer one question: +//! +//! Does the FIFA 23 client attempt ANY Blaze-flavored transport activity across a +//! full menu+FUT session, or none at all? +//! +//! Everything here is READ-ONLY: we parse the hostname / sockaddr the game passed +//! only to describe it in the log. We never change a resolution result or a +//! connection target — that redirect logic lives in the detours themselves and is +//! untouched. The env kill switch `OPENFUT_TRANSPORT_WATCH=1` gates all output; +//! disarmed (default) this module is inert (each entry point returns immediately). +//! +//! Future-reference note (beyond-beginner, deliberately NOT done here): a +//! types-first design would model a `ConnectTarget` enum (Inet{ip,port} / NonInet / +//! Short) and a `TransportEvent` and route them through the `tracing` crate with +//! structured fields, instead of hand-formatting strings into a flat log file. That +//! buys machine-parseable logs and log levels. For a one-shot observation gate, +//! flat `write_log` lines that `grep` cleanly are the lower-ceremony choice. + +use core::sync::atomic::{AtomicBool, Ordering}; + +/// Armed once at DLL load from `OPENFUT_TRANSPORT_WATCH`. `AtomicBool` (not a plain +/// `static mut bool`) because the detours that read it run on arbitrary game threads; +/// an atomic gives race-free reads with no `unsafe`. `Relaxed` is enough — this is a +/// standalone flag with no ordering relationship to other memory. +static ARMED: AtomicBool = AtomicBool::new(false); + +/// Read the env var once, at DLL load, and log the arm state. Called from `DllMain` +/// (`install_hooks`). Reading the env in-process (rather than as a command prefix) is +/// what makes the switch actually propagate through the umu/Proton launch — the same +/// gotcha the probe switches hit; it works because the launch script `export`s it. +pub fn arm_from_env() { + let on = std::env::var("OPENFUT_TRANSPORT_WATCH") + .map(|v| v == "1") + .unwrap_or(false); + ARMED.store(on, Ordering::Relaxed); + crate::write_log(&format!( + "TRANSPORT_WATCH: {} (env OPENFUT_TRANSPORT_WATCH)\n", + if on { "ARMED" } else { "disarmed" } + )); +} + +fn armed() -> bool { + ARMED.load(Ordering::Relaxed) +} + +/// True if `host` looks like EA/Blaze infrastructure. Broad on purpose: this is a log +/// classifier that makes a hit visually pop (`<-- BLAZE/EA-FLAVORED`), NOT a routing +/// decision. The actual redirect decision stays in `hooks::is_ea_host`, which is +/// deliberately narrower and unchanged. +fn is_blaze_flavored(host: &str) -> bool { + let h = host.to_ascii_lowercase(); + [ + "redirector", + "gosredirector", + "blaze", + "gosca", + "easfc", + "utas", + "fut", + "ea.com", + "easports", + ] + .iter() + .any(|k| h.contains(k)) +} + +/// Log one getaddrinfo hostname. Self-gates on the arm flag, so the call site can be +/// unconditional. The existing `openfut_hook: getaddrinfo(...)` line stays; this adds +/// the tagged, classified line so `grep TRANSPORT_WATCH` sees the full resolution set +/// and a Blaze host stands out. +pub fn note_getaddrinfo(host: &str) { + if !armed() { + return; + } + let tag = if is_blaze_flavored(host) { + " <-- BLAZE/EA-FLAVORED" + } else { + "" + }; + crate::write_log(&format!("TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n")); +} + +const AF_INET: u16 = 2; // IPv4 +const AF_INET6: u16 = 23; // IPv6 (Windows value; Linux uses 10 — we're in Wine/Win ABI) + +/// Minimal view of a `sockaddr_in`; the first `u16` is the address family for ANY +/// sockaddr, so reading this layout is safe enough to classify the family even when +/// the real struct is a `sockaddr_un` or larger — we only trust the rest once we've +/// confirmed `sin_family == AF_INET`. +#[repr(C)] +struct SockaddrIn { + sin_family: u16, + sin_port: u16, + sin_addr: u32, + sin_zero: [u8; 8], +} + +/// Minimal view of a `sockaddr_in6` (Win32 layout). `sin6_port` is network byte order; +/// `sin6_addr` is the 16 raw address bytes in network order. We ignore flowinfo/scope. +#[repr(C)] +struct SockaddrIn6 { + sin6_family: u16, + sin6_port: u16, + sin6_flowinfo: u32, + sin6_addr: [u8; 16], + sin6_scope_id: u32, +} + +/// Is `port` a known/suspected Blaze port? SHAPE — public general knowledge; the exact +/// port for FIFA23's Blaze version is UNKNOWN. 42127 main, 10041/10744 redirector +/// variants, 3659 classic redirector. +fn is_blaze_port(port: u16) -> bool { + matches!(port, 42127 | 10744 | 3659 | 10041) +} + +/// Log one outbound connect attempt. `api` names the call path (`connect` / +/// `WSAConnect` / `ConnectEx`) so we can tell which Winsock entry the client used. +/// +/// SAFETY: `name` must point to at least `namelen` readable bytes — it's the sockaddr +/// the game just handed to a Winsock connect API, so that always holds at the call +/// sites. We read it read-only and never write through it. `s` is the socket handle, +/// used only to query `SO_TYPE` (TCP=1 / UDP=2) so a real Blaze TCP dial is +/// distinguishable from UDP game/voice traffic. +pub unsafe fn note_connect(api: &str, name: *const u8, namelen: i32, s: usize) { + if !armed() { + return; + } + if name.is_null() || namelen < 8 { + crate::write_log(&format!( + "TRANSPORT_WATCH: {api} (no/short sockaddr, namelen={namelen})\n" + )); + return; + } + // SAFE: name is non-null and >= 8 bytes (checked above); the first u16 is the + // address family for ANY sockaddr, so reading it is valid regardless of the real + // struct type. We only trust family-specific fields after matching the family. + let family = *(name as *const u16); + + // SAFE: getsockopt is a read-only Winsock query on a valid socket handle; a bad + // handle just leaves ty=-1, which we log verbatim. TCP=1 / UDP=2. + let sock_type = { + use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE}; + let mut ty: i32 = -1; + let mut len: i32 = 4; + getsockopt( + s, + SOL_SOCKET as i32, + SO_TYPE, + &mut ty as *mut i32 as *mut u8, + &mut len, + ); + ty + }; + + match family { + AF_INET => { + // SAFE: family is AF_INET and namelen >= 8 == sizeof(sockaddr_in) fields we read. + let sa = &*(name as *const SockaddrIn); + // sin_addr holds the address in NETWORK byte order; on little-endian x86, + // to_le_bytes reproduces those 4 bytes in memory order, which IS the dotted + // quad. So b[0].b[1].b[2].b[3] is correct. (The legacy connect_hook log line + // prints these reversed — a cosmetic bug there; this M0 line is the correct + // one to trust.) + let b = sa.sin_addr.to_le_bytes(); + let port = u16::from_be(sa.sin_port); + let is_loopback = b[0] == 127; + let is_lsx = matches!(port, 3216 | 3217); // known-good LSX channel; not Blaze + let mut tag = String::new(); + if is_blaze_port(port) { + tag.push_str(" <-- BLAZE-PORT"); + } + // A loopback connect on anything other than LSX is the situation-(a) signal. + if is_loopback && !is_lsx { + tag.push_str(" <-- LOOPBACK non-LSX"); + } + crate::write_log(&format!( + "TRANSPORT_WATCH: {api} target={}.{}.{}.{}:{port} sock_type={sock_type}{tag}\n", + b[0], b[1], b[2], b[3] + )); + } + AF_INET6 => { + if namelen < 28 { + crate::write_log(&format!( + "TRANSPORT_WATCH: {api} family=INET6 (short sockaddr, namelen={namelen})\n" + )); + return; + } + // SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6). + let sa = &*(name as *const SockaddrIn6); + let a = sa.sin6_addr; // 16 bytes, network order + let port = u16::from_be(sa.sin6_port); + // Format as 8 colon-separated hex groups (not compressed — clarity over + // brevity for a log meant to be grepped). + let hex = (0..8) + .map(|i| format!("{:02x}{:02x}", a[i * 2], a[i * 2 + 1])) + .collect::>() + .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" + )); + } + } +} From 3d895fb7ac061c17977181b71f9ce9087475301b Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 7 Aug 2026 11:43:08 -0700 Subject: [PATCH 7/9] feat: add guarded FIFA 17 SBC diagnostics --- openfut-hook/Cargo.toml | 7 + openfut-hook/build.rs | 17 + openfut-hook/src/fifa17.rs | 108 +++ openfut-hook/src/lib.rs | 179 ++++- openfut-hook/src/sbc_hook.rs | 632 +++++++++++++++ openfut-hook/src/sbc_request_trace.rs | 420 ++++++++++ openfut-hook/src/sbc_trace.rs | 1050 +++++++++++++++++++++++++ openfut-hook/src/version_proxy.rs | 117 +++ openfut-hook/version.def | 18 + 9 files changed, 2510 insertions(+), 38 deletions(-) create mode 100644 openfut-hook/build.rs create mode 100644 openfut-hook/src/fifa17.rs create mode 100644 openfut-hook/src/sbc_hook.rs create mode 100644 openfut-hook/src/sbc_request_trace.rs create mode 100644 openfut-hook/src/sbc_trace.rs create mode 100644 openfut-hook/src/version_proxy.rs create mode 100644 openfut-hook/version.def diff --git a/openfut-hook/Cargo.toml b/openfut-hook/Cargo.toml index 02812d9..fcf2b26 100644 --- a/openfut-hook/Cargo.toml +++ b/openfut-hook/Cargo.toml @@ -15,6 +15,10 @@ capture_baseline = [] # in-process online-flow functions (GoOnline, GetInternetConnectedState, event # deserializers). Writes PROBE lines to C:\openfut_hook.log for RE. See probe.rs. probe = [] +# Build with `--features fifa17` for the FIFA 17 injection path. DllMain runs ONLY +# the minimal FIFA-17-safe logic in fifa17.rs (prove injection, dump module map, +# patch DirtySDK/ProtoSSL cert-verify) and skips ALL the FIFA-23-specific hooking. +fifa17 = [] [dependencies] windows-sys = { version = "0.59", features = [ @@ -25,6 +29,9 @@ windows-sys = { version = "0.59", features = [ "Win32_Networking_WinSock", "Win32_Security_Cryptography", "Win32_System_Threading", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Diagnostics_Debug", + "Win32_System_Kernel", ] } [profile.release] diff --git a/openfut-hook/build.rs b/openfut-hook/build.rs new file mode 100644 index 0000000..cd43c60 --- /dev/null +++ b/openfut-hook/build.rs @@ -0,0 +1,17 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=version.def"); + + // The proxy's PE export surface is part of its runtime contract. Feed an + // explicit module-definition file to the MinGW linker instead of relying + // solely on Rust symbol export attributes and linker retention heuristics. + if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu") + { + let definition = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("version.def"); + println!("cargo:rustc-link-arg={}", definition.display()); + } +} diff --git a/openfut-hook/src/fifa17.rs b/openfut-hook/src/fifa17.rs new file mode 100644 index 0000000..4119823 --- /dev/null +++ b/openfut-hook/src/fifa17.rs @@ -0,0 +1,108 @@ +//! FIFA 17 injection path (feature = "fifa17"). +//! +//! This is a *separate, minimal* entry point from the FIFA-23 `install_hooks`. +//! FIFA 17 is a different game with different in-memory structures, so we run NONE +//! of the FIFA-23 connect/LSX/origin_spy/dial logic here — that would at best +//! no-op and at worst crash. For now this proves the version.dll hijack actually +//! loads us into FIFA17.exe and dumps the module map, which we need to locate +//! DirtySDK/ProtoSSL's cert-verify function (the next milestone: patch it so the +//! secure Blaze redirector's TLS handshake succeeds against our bridge cert). +//! +//! Everything here is read-only except the (not-yet-enabled) cert-verify patch. + +use crate::write_log; +use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; +use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Module32FirstW, Module32NextW, MODULEENTRY32W, TH32CS_SNAPMODULE, + TH32CS_SNAPMODULE32, +}; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; + +/// Read the SizeOfImage from a module's in-memory PE headers. +unsafe fn size_of_image(base: usize) -> u32 { + if base == 0 { + return 0; + } + // DOS header -> e_lfanew (i32 @ 0x3c) -> PE header. SizeOfImage is in the + // optional header at offset 0x50 from the PE signature (same for PE32/PE32+). + let e_lfanew = *((base + 0x3c) as *const i32); + let pe = base + e_lfanew as usize; + // sanity: 'PE\0\0' + if *(pe as *const u32) != 0x0000_4550 { + return 0; + } + *((pe + 24 + 0x38) as *const u32) // opt header +0x38 = SizeOfImage +} + +fn wide_to_string(w: &[u16]) -> String { + let end = w.iter().position(|&c| c == 0).unwrap_or(w.len()); + String::from_utf16_lossy(&w[..end]) +} + +/// Enumerate loaded modules (name, base, size) via ToolHelp and log them. +unsafe fn dump_modules() { + let snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, 0); + if snap == INVALID_HANDLE_VALUE { + write_log("fifa17: module snapshot FAILED\n"); + return; + } + let mut me: MODULEENTRY32W = core::mem::zeroed(); + me.dwSize = core::mem::size_of::() as u32; + if Module32FirstW(snap, &mut me) != 0 { + loop { + let name = wide_to_string(&me.szModule); + let base = me.modBaseAddr as usize; + let size = me.modBaseSize; + write_log(&format!( + "fifa17: module {name:<28} base={base:#018x} size={size:#x}\n" + )); + me.dwSize = core::mem::size_of::() as u32; + if Module32NextW(snap, &mut me) == 0 { + break; + } + } + } else { + write_log("fifa17: Module32FirstW FAILED\n"); + } + CloseHandle(snap); +} + +/// Worker that runs AFTER DllMain returns (loader lock released). ToolHelp and +/// other loader-touching calls are unsafe under the loader lock, so we defer them +/// to this thread. This is what fixed the "game exits right after DllMain" issue. +unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 { + write_log("=== fifa17 hook: worker thread start ===\n"); + let main_base = GetModuleHandleA(core::ptr::null()) as usize; + let img = size_of_image(main_base); + write_log(&format!( + "fifa17: main exe base={main_base:#018x} SizeOfImage={img:#x}\n" + )); + dump_modules(); + write_log("fifa17: worker complete (injection healthy)\n"); + // SBC render intervention (inert unless OPENFUT_SBC_HOOK=1). Spawns its own deferred + // worker that waits for CardsDLL to load. See sbc_hook.rs / docs/sbc-hook-dll-spec.md. + crate::sbc_hook::install(); + // Passive transaction tracing has a separate kill switch from cache resolution. + // It currently fails closed until safe relocating trampolines are proven. + crate::sbc_trace::install(); + crate::sbc_request_trace::install(); + 0 +} + +/// Minimal FIFA-17 install. Keep DllMain itself trivial: only spawn a worker +/// thread and return immediately, so we never touch the loader lock from here. +pub unsafe fn install() { + use windows_sys::Win32::System::Threading::CreateThread; + write_log("=== fifa17 hook: DllMain ATTACH (spawning worker) ===\n"); + let h = CreateThread( + core::ptr::null(), + 0, + Some(worker), + core::ptr::null(), + 0, + core::ptr::null_mut(), + ); + if h == 0 as _ { + write_log("fifa17: CreateThread FAILED\n"); + } +} diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index fd65dc8..0c20865 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -2,6 +2,8 @@ mod config; mod connect_hook; mod connectex_hook; mod dial_notification; +#[cfg(feature = "fifa17")] +mod fifa17; mod hooks; mod iat; mod origin_spy; @@ -9,22 +11,32 @@ mod origin_spy; mod probe; #[cfg(feature = "capture_baseline")] mod recv_hook; +#[cfg(feature = "fifa17")] +mod sbc_hook; +#[cfg(feature = "fifa17")] +mod sbc_request_trace; +#[cfg(feature = "fifa17")] +mod sbc_trace; mod ssl_patch; mod tls_bypass; mod transport_watch; +mod version_proxy; use windows_sys::Win32::{ Foundation::{BOOL, HMODULE, TRUE}, - System::SystemServices::DLL_PROCESS_ATTACH, Networking::WinSock::ADDRINFOA, + System::SystemServices::DLL_PROCESS_ATTACH, }; pub(crate) fn write_log(msg: &str) { use std::io::Write; if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true).append(true) + .create(true) + .append(true) .open(r"C:\openfut_hook.log") - { let _ = f.write_all(msg.as_bytes()); } + { + let _ = f.write_all(msg.as_bytes()); + } } /// Force the log to stable storage. `write_log` already opens+closes the file per line, @@ -34,18 +46,41 @@ pub(crate) fn write_log(msg: &str) { /// 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") { + if let Ok(f) = std::fs::OpenOptions::new() + .append(true) + .open(r"C:\openfut_hook.log") + { let _ = f.sync_all(); } } #[no_mangle] pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL { - if reason == DLL_PROCESS_ATTACH { install_hooks(module); } + if reason == DLL_PROCESS_ATTACH { + // VERSION forwarding must be ready before DllMain returns. Hook setup + // may be deferred, but a caller can use any proxy export immediately. + if version_proxy::resolve() { + install_hooks(module); + } + } TRUE } unsafe fn install_hooks(module: HMODULE) { + // FIFA 17 path: run ONLY the minimal, FIFA-17-safe logic and skip every + // FIFA-23-specific hook below (they assume FIFA 23's memory layout). + #[cfg(feature = "fifa17")] + { + let _ = module; + fifa17::install(); + return; + } + #[cfg(not(feature = "fifa17"))] + install_hooks_fifa23(module) +} + +#[cfg(not(feature = "fifa17"))] +unsafe fn install_hooks_fifa23(module: HMODULE) { write_log("openfut_hook: DllMain fired\n"); // Milestone-0 transport watch: arm (or note disarmed) from env once, up front, so // the getaddrinfo/connect/ConnectEx detours below can log Blaze-flavored activity. @@ -55,38 +90,68 @@ unsafe fn install_hooks(module: HMODULE) { let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0"); if !ga.is_null() { - let f: unsafe extern "system" fn(*const u8,*const u8,*const ADDRINFOA,*mut *mut ADDRINFOA)->i32 - = std::mem::transmute(ga); + let f: unsafe extern "system" fn( + *const u8, + *const u8, + *const ADDRINFOA, + *mut *mut ADDRINFOA, + ) -> i32 = std::mem::transmute(ga); hooks::set_real(f); let n = iat::patch_iat(ga, hooks::hooked_getaddrinfo as *const ()); - let m = iat::patch_iat_in(b"EAWebKit.dll\0", ga, hooks::hooked_getaddrinfo as *const ()); + let m = iat::patch_iat_in( + b"EAWebKit.dll\0", + ga, + hooks::hooked_getaddrinfo as *const (), + ); write_log(&format!("openfut_hook: getaddrinfo IAT patched {n}+{m}\n")); } - if ssl_patch::patch_main_exe_cert_verify() { write_log("ssl: main exe cert-verify patched\n"); } - else { write_log("ssl: main exe cert-verify NOT FOUND\n"); } - if ssl_patch::patch_eawebkit_cert_verify() { write_log("ssl: EAWebKit cert-verify patched\n"); } - else { write_log("ssl: EAWebKit cert-verify deferred\n"); } + if ssl_patch::patch_main_exe_cert_verify() { + write_log("ssl: main exe cert-verify patched\n"); + } else { + write_log("ssl: main exe cert-verify NOT FOUND\n"); + } + if ssl_patch::patch_eawebkit_cert_verify() { + write_log("ssl: EAWebKit cert-verify patched\n"); + } else { + write_log("ssl: EAWebKit cert-verify deferred\n"); + } - if connect_hook::install_inline_connect_hook() { write_log("connect: inline-hooked\n"); } - else { write_log("connect: hook FAILED\n"); } + if connect_hook::install_inline_connect_hook() { + write_log("connect: inline-hooked\n"); + } else { + write_log("connect: hook FAILED\n"); + } let wp = iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0"); if !wp.is_null() { - let f: unsafe extern "system" fn(usize,*const u8,i32,*const(),*const(),*const(),*const())->i32 - = std::mem::transmute(wp); + let f: unsafe extern "system" fn( + usize, + *const u8, + i32, + *const (), + *const (), + *const (), + *const (), + ) -> i32 = std::mem::transmute(wp); connect_hook::set_real_wsa_connect(f); iat::patch_iat(wp, connect_hook::hooked_wsa_connect as *const ()); write_log("connect: WSAConnect IAT patched\n"); } - if connectex_hook::install_wsaioctl_hook() { write_log("connectex: WSAIoctl inline-hooked\n"); } - else { write_log("connectex: WSAIoctl hook FAILED\n"); } + if connectex_hook::install_wsaioctl_hook() { + write_log("connectex: WSAIoctl inline-hooked\n"); + } else { + write_log("connectex: WSAIoctl hook FAILED\n"); + } // RE instrumentation: passive logging detours on FIFA's in-process online-flow // functions (GoOnline, GetInternetConnectedState, event deserializers) to see // where FIFA stalls after our pushed LSX events. Deferred until anadius loads. #[cfg(feature = "probe")] - { probe::install_probes_deferred(); write_log("probe: deferred install scheduled\n"); } + { + 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. @@ -96,10 +161,16 @@ unsafe fn install_hooks(module: HMODULE) { // 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"); } + 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 { @@ -110,31 +181,63 @@ unsafe fn install_hooks(module: HMODULE) { origin_spy::$setter(f); iat::patch_iat(ptr, $handler as *const ()); "ok" - } else { "miss" } + } else { + "miss" + } }}; } - let ra = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExA\0", set_real_reg_a, + let ra = hook_iat!( + b"advapi32.dll\0", + b"RegQueryValueExA\0", + set_real_reg_a, origin_spy::hooked_reg_query_a, - unsafe extern "system" fn(isize,*const u8,*mut u32,*mut u32,*mut u8,*mut u32)->i32); - let rw = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExW\0", set_real_reg_w, + unsafe extern "system" fn(isize, *const u8, *mut u32, *mut u32, *mut u8, *mut u32) -> i32 + ); + let rw = hook_iat!( + b"advapi32.dll\0", + b"RegQueryValueExW\0", + set_real_reg_w, origin_spy::hooked_reg_query_w, - unsafe extern "system" fn(isize,*const u16,*mut u32,*mut u32,*mut u8,*mut u32)->i32); - let ma = hook_iat!(b"kernel32.dll\0", b"OpenMutexA\0", set_real_mutex_a, + unsafe extern "system" fn(isize, *const u16, *mut u32, *mut u32, *mut u8, *mut u32) -> i32 + ); + let ma = hook_iat!( + b"kernel32.dll\0", + b"OpenMutexA\0", + set_real_mutex_a, origin_spy::hooked_open_mutex_a, - unsafe extern "system" fn(u32,i32,*const u8)->isize); - let mw = hook_iat!(b"kernel32.dll\0", b"OpenMutexW\0", set_real_mutex_w, + unsafe extern "system" fn(u32, i32, *const u8) -> isize + ); + let mw = hook_iat!( + b"kernel32.dll\0", + b"OpenMutexW\0", + set_real_mutex_w, origin_spy::hooked_open_mutex_w, - unsafe extern "system" fn(u32,i32,*const u16)->isize); - write_log(&format!("origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n")); + unsafe extern "system" fn(u32, i32, *const u16) -> isize + ); + write_log(&format!( + "origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n" + )); let cv = iat::resolve(b"crypt32.dll\0", b"CertVerifyCertificateChainPolicy\0"); if !cv.is_null() { - let f: unsafe extern "system" fn(*const u8,*const(),*const(),*mut u32)->BOOL - = std::mem::transmute(cv); + let f: unsafe extern "system" fn(*const u8, *const (), *const (), *mut u32) -> BOOL = + std::mem::transmute(cv); tls_bypass::set_real(f); iat::patch_iat(cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); - iat::patch_iat_in(b"EAWebKit.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); - iat::patch_iat_in(b"winhttp.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); - iat::patch_iat_in(b"wininet.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); + iat::patch_iat_in( + b"EAWebKit.dll\0", + cv, + tls_bypass::hooked_cert_verify_chain_policy as *const (), + ); + iat::patch_iat_in( + b"winhttp.dll\0", + cv, + tls_bypass::hooked_cert_verify_chain_policy as *const (), + ); + iat::patch_iat_in( + b"wininet.dll\0", + cv, + tls_bypass::hooked_cert_verify_chain_policy as *const (), + ); } } diff --git a/openfut-hook/src/sbc_hook.rs b/openfut-hook/src/sbc_hook.rs new file mode 100644 index 0000000..410b7d5 --- /dev/null +++ b/openfut-hook/src/sbc_hook.rs @@ -0,0 +1,632 @@ +//! FIFA 17 SBC render intervention (feature = "fifa17"). +//! +//! Makes the FUT **SBC menu render real data** from inside the process. Full spec +//! (all addresses, RVA math, call order, crash risks, staged test plan): +//! fifa17-recon/docs/sbc-hook-dll-spec.md +//! +//! Everything here is **inert by default** and gated by env vars, so shipping the DLL +//! with this module compiled in changes nothing unless a var is set: +//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY) +//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY) +//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns) +//! +//! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we +//! defer off the loader lock and poll for it — the same shape as +//! `probe::install_probes_deferred` polling for anadius64.dll. +//! +//! ── Address model (static VAs; PE image base 0x180000000) ──────────────────────── +//! All values below are RVAs (VA_static - 0x180000000); live = cards_base + rva. +//! See the spec for the verified disassembly behind each one. + +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; +use windows_sys::Win32::System::Memory::{ + VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE, + PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY, +}; + +// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ──────────── +const IMAGE_BASE: usize = 0x180000000; + +/// FNV prologue used as the slide-proof control (must match the on-disk PE bytes). +const CTRL_RVA: usize = 0x180d00; // VA 0x180180d00 +const CTRL_BYTES: &[u8] = &[ + 0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0, +]; + +const A_SLOT_RVA: usize = 0x2e6398; // *(0x1802e6398) = A (FUT root singleton) +const A_VTABLE_RVA: usize = 0x21c2a0; +const B_OFF: usize = 0x1f9d8; // B = A + 0x1f9d8 (SBC request/ready TTL cache) +const B_VTABLE_RVA: usize = 0x1fae70; +const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate) +const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5) +const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap) +const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count +const B_DTOR_RVA: usize = 0x63040; +const B_ISVALID_RVA: usize = 0x65d40; +const B_CLEAR_RVA: usize = 0x65d20; +const B_READY_EXPECTED_BEFORE_ARM: u8 = 0; + +#[allow(dead_code)] +const AVT_M_GETTER: usize = 0x9b0; // A.vtable[+0x9b0] = 0x18011b7d0 (M lazy getter) +#[allow(dead_code)] +const AVT_B_GETTER: usize = 0x4e8; // A.vtable[+0x4e8] = 0x18011c1f0 (B getter thunk) + +// Callable RVAs (for the Tier-1 populate sequence — see spec §6/§8). Kept for +// reference/wiring; not invoked while Tier-1 is blocked. +#[allow(dead_code)] +mod rva { + pub const M_LAZY_GETTER: usize = 0x11b7d0; + pub const ISVALID: usize = 0x65d40; + pub const DESER_SBS_SETS: usize = 0x17b2b0; + pub const SAX_CTX_INIT: usize = 0x1c63e0; + pub const REGISTRY_GETTER: usize = 0xd7170; + pub const MANAGER_GETTER: usize = 0x9c80; + pub const CLEAR_M: usize = 0x15f3a0; + pub const CAT_CTOR: usize = 0x159da0; + pub const CAT_DESER: usize = 0x17ab80; + pub const CAT_FINALIZE: usize = 0x160e50; + pub const APPEND: usize = 0x15a770; + pub const CAT_DTOR: usize = 0x1105d0; + pub const IDX_REBUILD_1: usize = 0x160e00; + pub const IDX_REBUILD_2: usize = 0x160f30; + pub const IDX_REBUILD_3: usize = 0x161020; + pub const REFRESH_DISPATCH: usize = 0x1a4a70; // Scaleform events 0x756c-0x7574 +} + +static ARMED: AtomicBool = AtomicBool::new(false); +static ARM_ONLY: AtomicBool = AtomicBool::new(false); +static POPULATE: AtomicBool = AtomicBool::new(false); +static DONE: AtomicBool = AtomicBool::new(false); +static CARDS_BASE: AtomicUsize = AtomicUsize::new(0); +static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +enum RuntimeState { + Disabled, + Resolved, + Intercepted, + Parsed, + Validated, + Committed, + Failed, +} + +fn valid_transition(from: RuntimeState, to: RuntimeState) -> bool { + matches!( + (from, to), + (RuntimeState::Disabled, RuntimeState::Resolved) + | (RuntimeState::Resolved, RuntimeState::Intercepted) + | (RuntimeState::Intercepted, RuntimeState::Parsed) + | (RuntimeState::Parsed, RuntimeState::Validated) + // Resolve-only/Tier-0 validates without installing an interceptor. + | (RuntimeState::Resolved, RuntimeState::Validated) + | (RuntimeState::Validated, RuntimeState::Committed) + | (_, RuntimeState::Failed) + ) +} + +fn transition(from: RuntimeState, to: RuntimeState) -> bool { + valid_transition(from, to) + && STATE + .compare_exchange( + from as usize, + to as usize, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ValidationError { + AddressOverflow, + AUnreadable, + AVtableMismatch, + AGetterMismatch, + BVtableMismatch, + BDtorMismatch, + BIsValidMismatch, + BClearMismatch, + MSlotUnreadable, + ReadyByteUnexpected, + CollectionUnreadable, + CollectionNotNull, + ReadyByteNotWritable, +} + +#[derive(Clone, Copy, Debug)] +struct RuntimeSnapshot { + a: usize, + a_vtable: usize, + a_b_getter: usize, + b: usize, + b_vtable: usize, + b_dtor: usize, + b_isvalid: usize, + b_clear: usize, + b_ready: u8, + b_coll: usize, + m: usize, +} + +fn expected_va(base: usize, rva: usize) -> Result { + base.checked_add(rva) + .ok_or(ValidationError::AddressOverflow) +} + +fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationError> { + if s.a == 0 + || s.b + != s.a + .checked_add(B_OFF) + .ok_or(ValidationError::AddressOverflow)? + { + return Err(ValidationError::AUnreadable); + } + if s.a_vtable != expected_va(base, A_VTABLE_RVA)? { + return Err(ValidationError::AVtableMismatch); + } + if s.a_b_getter != expected_va(base, 0x11c1f0)? { + return Err(ValidationError::AGetterMismatch); + } + if s.b_vtable != expected_va(base, B_VTABLE_RVA)? { + return Err(ValidationError::BVtableMismatch); + } + if s.b_dtor != expected_va(base, B_DTOR_RVA)? { + return Err(ValidationError::BDtorMismatch); + } + if s.b_isvalid != expected_va(base, B_ISVALID_RVA)? { + return Err(ValidationError::BIsValidMismatch); + } + if s.b_clear != expected_va(base, B_CLEAR_RVA)? { + return Err(ValidationError::BClearMismatch); + } + if s.b_ready != B_READY_EXPECTED_BEFORE_ARM { + return Err(ValidationError::ReadyByteUnexpected); + } + if s.b_coll != 0 { + return Err(ValidationError::CollectionNotNull); + } + let _ = s.m; // The guarded snapshot read proves the M slot itself is readable. + Ok(()) +} + +/// Fault-safe pointer read (mirrors `probe::read_ptr`): returns None unless `ptr` lands +/// in a committed, readable page and the full 8 bytes fit inside the region. +unsafe fn read_ptr(ptr: usize) -> Option { + 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; + } + if ptr + 8 > mbi.BaseAddress as usize + mbi.RegionSize { + return None; + } + Some(core::ptr::read_volatile(ptr as *const usize)) +} + +/// Guarded byte read. +unsafe fn read_u8(ptr: usize) -> Option { + if ptr < 0x10000 { + return None; + } + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); + if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { + return None; + } + if ptr + 1 > mbi.BaseAddress as usize + mbi.RegionSize { + return None; + } + Some(core::ptr::read_volatile(ptr as *const u8)) +} + +/// A Tier-0 write is allowed only when the complete byte lies in a committed, +/// non-guarded region whose current protection explicitly permits writes. +unsafe fn writable_u8(ptr: usize) -> bool { + if ptr < 0x10000 { + return false; + } + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); + if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { + return false; + } + let protection = mbi.Protect & 0xff; + let writable = matches!( + protection, + PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY + ); + writable + && ptr + .checked_add(1) + .is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)) +} + +/// Guarded 16-bit read (M category count is a WORD). +unsafe fn read_u16(ptr: usize) -> Option { + let lo = read_u8(ptr)? as u16; + let hi = read_u8(ptr + 1)? as u16; + Some(lo | (hi << 8)) +} + +/// Resolve CardsDLL's runtime base, or 0. Tries the exact loaded name; the ToolHelp +/// fallback (name-contains "CardsDLL") lives in the spec — add it if EA ever renames. +unsafe fn resolve_cards_base() -> usize { + let h = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()); + if !h.is_null() { + return h as usize; + } + // Also try the short form some tooling reports. + let h2 = GetModuleHandleA(b"CardsDLL.dll\0".as_ptr()); + if !h2.is_null() { + return h2 as usize; + } + 0 +} + +#[inline] +fn va(base: usize, rva: usize) -> usize { + base + rva +} + +/// Prove the module didn't move: the FNV control prologue must match the on-disk PE. +unsafe fn control_matches(base: usize) -> bool { + let p = va(base, CTRL_RVA); + for (i, &want) in CTRL_BYTES.iter().enumerate() { + match read_u8(p + i) { + Some(got) if got == want => {} + _ => return false, + } + } + true +} + +/// Take one guarded identity snapshot. A failure to read any identity-bearing field is +/// distinct from a value mismatch and aborts before mutation. +unsafe fn runtime_snapshot(base: usize) -> Result { + let a_slot = expected_va(base, A_SLOT_RVA)?; + let a = read_ptr(a_slot) + .filter(|&value| value != 0) + .ok_or(ValidationError::AUnreadable)?; + let a_vtable = read_ptr(a).ok_or(ValidationError::AVtableMismatch)?; + let a_b_getter = read_ptr( + a_vtable + .checked_add(AVT_B_GETTER) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::AGetterMismatch)?; + let b = a + .checked_add(B_OFF) + .ok_or(ValidationError::AddressOverflow)?; + let b_vtable = read_ptr(b).ok_or(ValidationError::BVtableMismatch)?; + let b_dtor = read_ptr(b_vtable).ok_or(ValidationError::BDtorMismatch)?; + let b_isvalid = read_ptr( + b_vtable + .checked_add(8) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::BIsValidMismatch)?; + let b_clear = read_ptr( + b_vtable + .checked_add(16) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::BClearMismatch)?; + let b_ready = read_u8( + b.checked_add(B_READY_OFF) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::ReadyByteUnexpected)?; + let b_coll = read_ptr( + b.checked_add(B_COLL_OFF) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::CollectionUnreadable)?; + let m = read_ptr( + a.checked_add(M_CACHE_OFF) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::MSlotUnreadable)?; + Ok(RuntimeSnapshot { + a, + a_vtable, + a_b_getter, + b, + b_vtable, + b_dtor, + b_isvalid, + b_clear, + b_ready, + b_coll, + m, + }) +} + +fn set_failed(error: ValidationError) { + STATE.store(RuntimeState::Failed as usize, Ordering::Release); + crate::write_log(&format!( + "SBC_HOOK: runtime validation FAILED: {error:?} -- no write\n" + )); +} + +/// Public entry: called from `fifa17::install`. Spawns the deferred worker if +/// OPENFUT_SBC_HOOK=1; otherwise logs "disabled" and returns (fully inert). +pub fn install() { + let armed = std::env::var("OPENFUT_SBC_HOOK") + .map(|v| v == "1") + .unwrap_or(false); + ARMED.store(armed, Ordering::Relaxed); + if !armed { + STATE.store(RuntimeState::Disabled as usize, Ordering::Relaxed); + crate::write_log("SBC_HOOK: disabled (set OPENFUT_SBC_HOOK=1 to enable)\n"); + return; + } + ARM_ONLY.store( + std::env::var("OPENFUT_SBC_ARM_ONLY") + .map(|v| v == "1") + .unwrap_or(false), + Ordering::Relaxed, + ); + POPULATE.store( + std::env::var("OPENFUT_SBC_POPULATE") + .map(|v| v == "1") + .unwrap_or(false), + Ordering::Relaxed, + ); + crate::write_log("SBC_HOOK: ARMED (deferred worker spawning)\n"); + std::thread::spawn(|| unsafe { worker() }); +} + +/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when +/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm) +/// exactly once. +unsafe fn worker() { + let mut base = 0usize; + for _ in 0..600u32 { + base = resolve_cards_base(); + if base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + if base == 0 { + crate::write_log("SBC_HOOK: CardsDLL_Win64_retail.dll never loaded — giving up\n"); + return; + } + CARDS_BASE.store(base, Ordering::Relaxed); + let slide = base.wrapping_sub(IMAGE_BASE); + let ctrl_ok = control_matches(base); + crate::write_log(&format!( + "SBC_HOOK: CardsDLL base={base:#x} slide={slide:#x} CONTROL={}\n", + if ctrl_ok { "OK" } else { "MISMATCH-ABORT" } + )); + if !ctrl_ok { + STATE.store(RuntimeState::Failed as usize, Ordering::Release); + return; // module map moved -> offsets untrustworthy (spec §1) + } + + if !transition(RuntimeState::Disabled, RuntimeState::Resolved) { + crate::write_log("SBC_HOOK: invalid state transition to Resolved -- no write\n"); + STATE.store(RuntimeState::Failed as usize, Ordering::Release); + return; + } + + // Resolve and validate A -> B, M. The vtable method checks make it substantially + // harder for a coincidental heap pointer to pass after a binary/layout mismatch. + let snapshot = match runtime_snapshot(base).and_then(|snapshot| { + validate_snapshot(base, &snapshot)?; + Ok(snapshot) + }) { + Ok(snapshot) => snapshot, + Err(error) => { + set_failed(error); + return; + } + }; + if !transition(RuntimeState::Resolved, RuntimeState::Validated) { + crate::write_log("SBC_HOOK: invalid state transition to Validated -- no write\n"); + STATE.store(RuntimeState::Failed as usize, Ordering::Release); + return; + } + let a = snapshot.a; + let b = snapshot.b; + let m = snapshot.m; + let m_count = (m != 0).then(|| read_u16(m + M_COUNT_OFF)).flatten(); + crate::write_log(&format!( + "SBC_HOOK: A={a:#x} B={b:#x} B+0x28(ready)={:?} B+0x08(coll)={:?} M=*(A+0x20a68)={:?} WORD[M+0x50]={:?}\n", + Some(snapshot.b_ready), opt_hex(Some(snapshot.b_coll)), opt_hex(Some(m)), m_count, + )); + + // Tier-0 — arm-only negative control. Write ONLY BYTE[B+0x28]=1; leave B+0x08=0 so + // isValid takes the short-circuit (spec §4). Renders the menu EMPTY (M null/empty) — + // this is the baseline, NOT the fix. One-shot. + if ARM_ONLY.load(Ordering::Relaxed) { + if DONE.swap(true, Ordering::Relaxed) { + return; + } + // Re-snapshot immediately before mutation to reduce the time-of-check/time-of-use + // window. In particular, the exact patch byte must still be 0 and B+0x08 null. + let write_snapshot = match runtime_snapshot(base).and_then(|snapshot| { + validate_snapshot(base, &snapshot)?; + if !writable_u8(snapshot.b + B_READY_OFF) { + return Err(ValidationError::ReadyByteNotWritable); + } + Ok(snapshot) + }) { + Ok(snapshot) => snapshot, + Err(error) => { + set_failed(error); + return; + } + }; + crate::write_log(&format!( + "SBC_HOOK: Tier-0 arm-only -> writing BYTE[{:#x}]=1 (expect EMPTY render, no modal)\n", + write_snapshot.b + B_READY_OFF + )); + core::ptr::write_volatile((write_snapshot.b + B_READY_OFF) as *mut u8, 1u8); + match read_u8(write_snapshot.b + B_READY_OFF) { + Some(1) if transition(RuntimeState::Validated, RuntimeState::Committed) => {} + _ => { + set_failed(ValidationError::ReadyByteUnexpected); + return; + } + } + crate::write_log( + "SBC_HOOK: Tier-0 arm-only DONE (open the SBC menu; ~2 placeholder tiles expected)\n", + ); + return; + } + + // Legacy Tier-1 gate — deliberately blocked. The fresh live exchange proves FIFA + // already owns a real response and SAX reader for /sbs/sets. The next milestone is + // passive tracing of the native response-to-deserializer dispatch, not construction + // of a reader. Cold-calling with a fabricated reader would CLEAR M and/or segfault. + if POPULATE.load(Ordering::Relaxed) { + crate::write_log( + "SBC_HOOK: legacy Tier-1 populate is BLOCKED — capture the genuine response \ + and reader at the native dispatch boundary first (see client-hook plan M3/M4). \ + No deser call made; fabricated readers can clear M or crash.\n", + ); + } +} + +fn opt_hex(o: Option) -> String { + match o { + Some(v) => format!("{v:#x}"), + None => "".to_string(), + } +} + +/// Legacy Tier-1 scaffold. **Never call this with a fabricated reader.** The intended +/// implementation is now a guarded synchronous dispatch repair that borrows the genuine +/// response and reader from the real HTTP transaction on its native thread. +/// +/// Sequence once `reader` (a primed SAX reader over canned sbs/sets JSON) exists: +/// let base = CARDS_BASE.load(Relaxed); +/// let deser: unsafe extern "system" fn(*mut u8, *mut u8) -> bool = +/// transmute(va(base, rva::DESER_SBS_SETS)); +/// deser(core::ptr::null_mut(), reader); // self-locates mgr, clears+appends+finalizes+commits M +/// // then Tier-0 arm: BYTE[B+0x28]=1, leave B+0x08=0 +/// // then refresh so 0x1800b5eda re-reads WORD[M+0x50] +#[allow(dead_code)] +unsafe fn populate_m(_reader: *mut u8) { + // Intentionally unimplemented: the passive trace must prove the response/reader + // ownership and exact virtual-dispatch boundary before any parser call is enabled. + unreachable!( + "populate_m requires a proven native dispatch contract; see client-hook plan M3/M4" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_snapshot(base: usize) -> RuntimeSnapshot { + let a = 0x1000_0000usize; + RuntimeSnapshot { + a, + a_vtable: base + A_VTABLE_RVA, + a_b_getter: base + 0x11c1f0, + b: a + B_OFF, + b_vtable: base + B_VTABLE_RVA, + b_dtor: base + B_DTOR_RVA, + b_isvalid: base + B_ISVALID_RVA, + b_clear: base + B_CLEAR_RVA, + b_ready: B_READY_EXPECTED_BEFORE_ARM, + b_coll: 0, + m: 0, + } + } + + #[test] + fn accepts_exact_runtime_identity_with_null_uninitialized_m() { + let base = 0x7fff_0000_0000usize; + assert_eq!(validate_snapshot(base, &valid_snapshot(base)), Ok(())); + } + + #[test] + fn rejects_wrong_a_or_b_class_identity() { + let base = 0x7fff_0000_0000usize; + let mut snapshot = valid_snapshot(base); + snapshot.a_vtable += 8; + assert_eq!( + validate_snapshot(base, &snapshot), + Err(ValidationError::AVtableMismatch) + ); + + let mut snapshot = valid_snapshot(base); + snapshot.b_vtable += 8; + assert_eq!( + validate_snapshot(base, &snapshot), + Err(ValidationError::BVtableMismatch) + ); + } + + #[test] + fn rejects_changed_patch_byte_or_live_collection() { + let base = 0x7fff_0000_0000usize; + let mut snapshot = valid_snapshot(base); + snapshot.b_ready = 1; + assert_eq!( + validate_snapshot(base, &snapshot), + Err(ValidationError::ReadyByteUnexpected) + ); + + let mut snapshot = valid_snapshot(base); + snapshot.b_coll = 0x1234_0000; + assert_eq!( + validate_snapshot(base, &snapshot), + Err(ValidationError::CollectionNotNull) + ); + } + + #[test] + fn state_machine_is_forward_only_and_fail_closed() { + assert!(valid_transition( + RuntimeState::Disabled, + RuntimeState::Resolved + )); + assert!(valid_transition( + RuntimeState::Resolved, + RuntimeState::Validated + )); + assert!(valid_transition( + RuntimeState::Validated, + RuntimeState::Committed + )); + assert!(valid_transition(RuntimeState::Parsed, RuntimeState::Failed)); + assert!(!valid_transition( + RuntimeState::Validated, + RuntimeState::Resolved + )); + assert!(!valid_transition( + RuntimeState::Failed, + RuntimeState::Resolved + )); + assert!(!valid_transition( + RuntimeState::Resolved, + RuntimeState::Committed + )); + } +} diff --git a/openfut-hook/src/sbc_request_trace.rs b/openfut-hook/src/sbc_request_trace.rs new file mode 100644 index 0000000..fe8691f --- /dev/null +++ b/openfut-hook/src/sbc_request_trace.rs @@ -0,0 +1,420 @@ +//! Optional category-request callback tracing through its class-unique vtable. +//! +//! Unlike the entry trampolines, these probes atomically replace two aligned +//! pointer slots. The branchy callback dispatcher at 0x180154830 is never patched. + +use core::ffi::c_void; +use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use windows_sys::Win32::System::LibraryLoader::{ + GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + GET_MODULE_HANDLE_EX_FLAG_PIN, +}; +use windows_sys::Win32::System::Memory::{ + VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_GUARD, PAGE_NOACCESS, + PAGE_READWRITE, +}; +use windows_sys::Win32::System::Threading::GetCurrentThreadId; + +const REQUEST_VTABLE_RVA: usize = 0x22e5c0; +const SLOT_88: usize = 0x88; +const SLOT_90: usize = 0x90; +const ORIGINAL_88_RVA: usize = 0x1631e0; +const ORIGINAL_90_RVA: usize = 0x154830; +const ORIGINAL_88_SIGNATURE: [u8; 16] = [ + 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48, +]; +const ORIGINAL_90_SIGNATURE: [u8; 16] = [ + 0x4c, 0x8b, 0x81, 0x90, 0x00, 0x00, 0x00, 0x4d, 0x85, 0xc0, 0x74, 0x0a, 0x48, 0x81, 0xc1, 0x90, +]; + +type Callback88 = unsafe extern "system" fn(*mut c_void, *mut c_void); +type Callback90 = unsafe extern "system" fn(*mut c_void, *mut c_void); + +static ENABLED: AtomicBool = AtomicBool::new(false); +static INSTALLED: AtomicBool = AtomicBool::new(false); +static ORIGINAL_88: AtomicUsize = AtomicUsize::new(0); +static ORIGINAL_90: AtomicUsize = AtomicUsize::new(0); +static ENTER_88: AtomicU64 = AtomicU64::new(0); +static EXIT_88: AtomicU64 = AtomicU64::new(0); +static ENTER_90: AtomicU64 = AtomicU64::new(0); +static EXIT_90: AtomicU64 = AtomicU64::new(0); +static LAST_REQUEST_88: AtomicUsize = AtomicUsize::new(0); +static LAST_ARGUMENT_88: AtomicUsize = AtomicUsize::new(0); +static LAST_THREAD_88: AtomicUsize = AtomicUsize::new(0); +static LAST_REQUEST_90: AtomicUsize = AtomicUsize::new(0); +static LAST_ARGUMENT_90: AtomicUsize = AtomicUsize::new(0); +static LAST_THREAD_90: AtomicUsize = AtomicUsize::new(0); +static CALLBACK_90: AtomicUsize = AtomicUsize::new(0); +static CALLBACK_98: AtomicUsize = AtomicUsize::new(0); +static CALLBACK_A0: AtomicUsize = AtomicUsize::new(0); +static CALLBACK_A8: AtomicUsize = AtomicUsize::new(0); +static SELECTED_90: AtomicUsize = AtomicUsize::new(0); +static OWNER_88: AtomicUsize = AtomicUsize::new(0); +static OWNER_VTABLE_88: AtomicUsize = AtomicUsize::new(0); +static CONSUMER_88: AtomicUsize = AtomicUsize::new(0); +static RESPONSE_VTABLE_88: AtomicUsize = AtomicUsize::new(0); +static OWNER_SLOT_BEFORE_88: AtomicUsize = AtomicUsize::new(0); +static OWNER_SLOT_AFTER_88: AtomicUsize = AtomicUsize::new(0); + +fn enabled(value: Option<&str>) -> bool { + matches!(value, Some("1")) +} + +fn checked_va(base: usize, rva: usize) -> Option { + base.checked_add(rva) +} + +fn image_range_covered(size: usize, rva: usize, length: usize) -> bool { + rva.checked_add(length) + .map(|end| end <= size) + .unwrap_or(false) +} + +unsafe fn readable_range(address: usize, length: usize) -> bool { + let Some(end) = address.checked_add(length) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + VirtualQuery( + address as _, + &mut mbi, + core::mem::size_of::(), + ) != 0 + && mbi.State == MEM_COMMIT + && mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0 + && end <= mbi.BaseAddress as usize + mbi.RegionSize +} + +unsafe fn range_in_image_allocation(base: usize, address: usize, length: usize) -> bool { + let Some(end) = address.checked_add(length) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + VirtualQuery( + address as _, + &mut mbi, + core::mem::size_of::(), + ) != 0 + && mbi.AllocationBase as usize == base + && mbi.State == MEM_COMMIT + && mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0 + && end <= mbi.BaseAddress as usize + mbi.RegionSize +} + +unsafe fn image_size(base: usize) -> Option { + if !readable_range(base, 0x1000) || *(base as *const u16) != 0x5a4d { + return None; + } + let pe_offset = *((base + 0x3c) as *const u32) as usize; + if pe_offset > 0xf00 { + return None; + } + let pe = base.checked_add(pe_offset)?; + if *(pe as *const u32) != 0x0000_4550 { + return None; + } + let size_field = pe.checked_add(24 + 0x38)?; + Some(*(size_field as *const u32) as usize) +} + +unsafe fn signature_matches(address: usize, expected: &[u8]) -> bool { + readable_range(address, expected.len()) + && core::slice::from_raw_parts(address as *const u8, expected.len()) == expected +} + +unsafe fn guarded_ptr(address: usize) -> usize { + if address & 7 == 0 && readable_range(address, 8) { + core::ptr::read_volatile(address as *const usize) + } else { + 0 + } +} + +unsafe fn field_ptr(object: usize, offset: usize) -> usize { + object + .checked_add(offset) + .map(|address| guarded_ptr(address)) + .unwrap_or(0) +} + +unsafe extern "system" fn wrapper_88(request: *mut c_void, argument: *mut c_void) { + ENTER_88.fetch_add(1, Ordering::Relaxed); + LAST_REQUEST_88.store(request as usize, Ordering::Relaxed); + LAST_ARGUMENT_88.store(argument as usize, Ordering::Relaxed); + LAST_THREAD_88.store(GetCurrentThreadId() as usize, Ordering::Relaxed); + let request_address = request as usize; + let argument_address = argument as usize; + let owner = field_ptr(request_address, 8); + let owner_vtable = guarded_ptr(owner); + let consumer = field_ptr(owner_vtable, 0x18); + let owner_slot_before = guarded_ptr(argument_address); + let response_vtable = guarded_ptr(owner_slot_before); + OWNER_88.store(owner, Ordering::Relaxed); + OWNER_VTABLE_88.store(owner_vtable, Ordering::Relaxed); + CONSUMER_88.store(consumer, Ordering::Relaxed); + RESPONSE_VTABLE_88.store(response_vtable, Ordering::Relaxed); + OWNER_SLOT_BEFORE_88.store(owner_slot_before, Ordering::Relaxed); + let original: Callback88 = core::mem::transmute(ORIGINAL_88.load(Ordering::Acquire)); + original(request, argument); + OWNER_SLOT_AFTER_88.store(guarded_ptr(argument_address), Ordering::Relaxed); + EXIT_88.fetch_add(1, Ordering::Release); +} + +unsafe extern "system" fn wrapper_90(request: *mut c_void, argument: *mut c_void) { + ENTER_90.fetch_add(1, Ordering::Relaxed); + LAST_REQUEST_90.store(request as usize, Ordering::Relaxed); + LAST_ARGUMENT_90.store(argument as usize, Ordering::Relaxed); + LAST_THREAD_90.store(GetCurrentThreadId() as usize, Ordering::Relaxed); + let request_address = request as usize; + let callback_90 = field_ptr(request_address, 0x90); + let callback_98 = field_ptr(request_address, 0x98); + let callback_a0 = field_ptr(request_address, 0xa0); + let callback_a8 = field_ptr(request_address, 0xa8); + CALLBACK_90.store(callback_90, Ordering::Relaxed); + CALLBACK_98.store(callback_98, Ordering::Relaxed); + CALLBACK_A0.store(callback_a0, Ordering::Relaxed); + CALLBACK_A8.store(callback_a8, Ordering::Relaxed); + SELECTED_90.store( + if callback_90 != 0 { + callback_90 + } else { + callback_a0 + }, + Ordering::Relaxed, + ); + let original: Callback90 = core::mem::transmute(ORIGINAL_90.load(Ordering::Acquire)); + original(request, argument); + EXIT_90.fetch_add(1, Ordering::Release); +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SwapOutcome { + Installed, + CleanFailure, + DegradedFirstSlotActive, + DegradedProtection, +} + +unsafe fn install_slots(base: usize) -> SwapOutcome { + let Some(vtable) = checked_va(base, REQUEST_VTABLE_RVA) else { + return SwapOutcome::CleanFailure; + }; + let Some(original_88) = checked_va(base, ORIGINAL_88_RVA) else { + return SwapOutcome::CleanFailure; + }; + let Some(original_90) = checked_va(base, ORIGINAL_90_RVA) else { + return SwapOutcome::CleanFailure; + }; + let Some(slot_88) = checked_va(vtable, SLOT_88) else { + return SwapOutcome::CleanFailure; + }; + let Some(slot_90) = checked_va(vtable, SLOT_90) else { + return SwapOutcome::CleanFailure; + }; + let Some(size) = image_size(base) else { + return SwapOutcome::CleanFailure; + }; + if !image_range_covered(size, REQUEST_VTABLE_RVA, SLOT_90 + 8) + || !image_range_covered(size, ORIGINAL_88_RVA, ORIGINAL_88_SIGNATURE.len()) + || !image_range_covered(size, ORIGINAL_90_RVA, ORIGINAL_90_SIGNATURE.len()) + || slot_88 & 7 != 0 + || slot_90 & 7 != 0 + || !crate::sbc_trace::validate_cards_build(base) + || !range_in_image_allocation(base, vtable, SLOT_90 + 8) + || !range_in_image_allocation(base, original_88, ORIGINAL_88_SIGNATURE.len()) + || !range_in_image_allocation(base, original_90, ORIGINAL_90_SIGNATURE.len()) + || !signature_matches(original_88, &ORIGINAL_88_SIGNATURE) + || !signature_matches(original_90, &ORIGINAL_90_SIGNATURE) + || (slot_88 as *const AtomicUsize) + .as_ref() + .unwrap() + .load(Ordering::Acquire) + != original_88 + || (slot_90 as *const AtomicUsize) + .as_ref() + .unwrap() + .load(Ordering::Acquire) + != original_90 + { + return SwapOutcome::CleanFailure; + } + + let mut pinned = core::ptr::null_mut(); + if GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + vtable as *const u8, + &mut pinned, + ) == 0 + || pinned as usize != base + { + return SwapOutcome::CleanFailure; + } + ORIGINAL_88.store(original_88, Ordering::Release); + ORIGINAL_90.store(original_90, Ordering::Release); + + // Both slots share the same vtable page. Keep it writable only across the two + // compare/exchanges and possible rollback. + let mut old = 0u32; + if VirtualProtect(slot_88 as _, 16, PAGE_READWRITE, &mut old) == 0 { + return SwapOutcome::CleanFailure; + } + let atom_88 = &*(slot_88 as *const AtomicUsize); + let atom_90 = &*(slot_90 as *const AtomicUsize); + let first = atom_88.compare_exchange( + original_88, + wrapper_88 as *const () as usize, + Ordering::AcqRel, + Ordering::Acquire, + ); + let outcome = if first.is_err() { + SwapOutcome::CleanFailure + } else if atom_90 + .compare_exchange( + original_90, + wrapper_90 as *const () as usize, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + SwapOutcome::Installed + } else if atom_88 + .compare_exchange( + wrapper_88 as *const () as usize, + original_88, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + SwapOutcome::CleanFailure + } else { + SwapOutcome::DegradedFirstSlotActive + }; + let mut ignored = 0u32; + if VirtualProtect(slot_88 as _, 16, old, &mut ignored) == 0 { + return if outcome == SwapOutcome::DegradedFirstSlotActive { + outcome + } else { + SwapOutcome::DegradedProtection + }; + } + outcome +} + +unsafe fn worker() { + for _ in 0..700u32 { + if crate::sbc_trace::code_patch_installers_ready() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + if !crate::sbc_trace::code_patch_installers_ready() { + crate::write_log("SBC_REQUEST_TRACE: code-patch readiness timeout; inactive\n"); + return; + } + let mut base = 0usize; + for _ in 0..600u32 { + base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize; + if base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + match if base == 0 { SwapOutcome::CleanFailure } else { install_slots(base) } { + SwapOutcome::Installed => { + INSTALLED.store(true, Ordering::Release); + crate::write_log("SBC_REQUEST_TRACE: category request vtable slots +0x88/+0x90 installed\n"); + let mut seen_88 = 0u64; + let mut seen_90 = 0u64; + let mut reports = 0u8; + while reports < 32 { + std::thread::sleep(std::time::Duration::from_millis(250)); + let count_88 = ENTER_88.load(Ordering::Acquire); + let count_90 = ENTER_90.load(Ordering::Acquire); + if count_88 != seen_88 || count_90 != seen_90 { + crate::write_log(&format!( + "SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n", + count_88, + EXIT_88.load(Ordering::Acquire), + LAST_REQUEST_88.load(Ordering::Relaxed), + LAST_ARGUMENT_88.load(Ordering::Relaxed), + LAST_THREAD_88.load(Ordering::Relaxed), + OWNER_88.load(Ordering::Relaxed), + OWNER_VTABLE_88.load(Ordering::Relaxed), + CONSUMER_88.load(Ordering::Relaxed), + RESPONSE_VTABLE_88.load(Ordering::Relaxed), + OWNER_SLOT_BEFORE_88.load(Ordering::Relaxed), + OWNER_SLOT_AFTER_88.load(Ordering::Relaxed), + count_90, + EXIT_90.load(Ordering::Acquire), + LAST_REQUEST_90.load(Ordering::Relaxed), + LAST_ARGUMENT_90.load(Ordering::Relaxed), + LAST_THREAD_90.load(Ordering::Relaxed), + CALLBACK_90.load(Ordering::Relaxed), + CALLBACK_98.load(Ordering::Relaxed), + CALLBACK_A0.load(Ordering::Relaxed), + CALLBACK_A8.load(Ordering::Relaxed), + SELECTED_90.load(Ordering::Relaxed), + )); + seen_88 = count_88; + seen_90 = count_90; + reports += 1; + } + } + crate::write_log("SBC_REQUEST_TRACE: report cap reached; vtable probes remain passive\n"); + } + SwapOutcome::CleanFailure => crate::write_log("SBC_REQUEST_TRACE: clean install failure; inactive\n"), + SwapOutcome::DegradedFirstSlotActive => crate::write_log( + "SBC_REQUEST_TRACE: DEGRADED slot +0x88 may remain active; terminate game now\n", + ), + SwapOutcome::DegradedProtection => crate::write_log( + "SBC_REQUEST_TRACE: DEGRADED vtable page protection restore failed; terminate game now\n", + ), + } +} + +pub(crate) fn install() { + let armed = enabled(std::env::var("OPENFUT_SBC_REQUEST_TRACE").ok().as_deref()); + ENABLED.store(armed, Ordering::Release); + if !armed { + crate::write_log("SBC_REQUEST_TRACE: disabled\n"); + return; + } + crate::write_log("SBC_REQUEST_TRACE: requested; deferred install starting\n"); + std::thread::spawn(|| unsafe { worker() }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gate_is_exact() { + assert!(!enabled(None)); + assert!(!enabled(Some("true"))); + assert!(enabled(Some("1"))); + } + + #[test] + fn slots_are_aligned_and_class_local() { + assert_eq!((REQUEST_VTABLE_RVA + SLOT_88) & 7, 0); + assert_eq!((REQUEST_VTABLE_RVA + SLOT_90) & 7, 0); + assert_eq!(SLOT_90 - SLOT_88, 8); + } + + #[test] + fn image_coverage_is_checked_and_overflow_safe() { + assert!(image_range_covered( + 0x230000, + REQUEST_VTABLE_RVA, + SLOT_90 + 8 + )); + assert!(!image_range_covered( + REQUEST_VTABLE_RVA + SLOT_90, + REQUEST_VTABLE_RVA, + SLOT_90 + 8 + )); + assert!(!image_range_covered(usize::MAX, usize::MAX, 8)); + } +} diff --git a/openfut-hook/src/sbc_trace.rs b/openfut-hook/src/sbc_trace.rs new file mode 100644 index 0000000..cd389d2 --- /dev/null +++ b/openfut-hook/src/sbc_trace.rs @@ -0,0 +1,1050 @@ +//! Passive, behavior-preserving trace hooks for FIFA 17's SBC category response. + +use core::ffi::c_void; +use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_NO_MORE_FILES, HANDLE, INVALID_HANDLE_VALUE, +}; +use windows_sys::Win32::System::Diagnostics::Debug::{ + FlushInstructionCache, GetThreadContext, CONTEXT, CONTEXT_CONTROL_AMD64, +}; +use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, +}; +use windows_sys::Win32::System::LibraryLoader::{ + GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + GET_MODULE_HANDLE_EX_FLAG_PIN, +}; +use windows_sys::Win32::System::Memory::{ + VirtualAlloc, VirtualFree, VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, + MEM_RELEASE, MEM_RESERVE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, + PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, +}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId, OpenThread, ResumeThread, + SuspendThread, THREAD_GET_CONTEXT, THREAD_SUSPEND_RESUME, +}; + +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; +const TRAMPOLINE_LEN: usize = COPY_LEN + ABS_JUMP_LEN; +const NOTIFIER_RVA: usize = 0x17aa80; +const NOTIFIER_COPY_LEN: usize = 15; +const NOTIFIER_SIGNATURE: [u8; 32] = [ + 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48, + 0x8b, 0x59, 0x58, 0x48, 0x8b, 0x71, 0x60, 0x33, 0xff, 0x48, 0x2b, 0xf3, 0xc6, 0x81, 0x88, 0x00, +]; +const MAX_PEERS: usize = 512; +const MAX_QUIESCE_PASSES: usize = 8; +const CONTROL_RVA: usize = 0x180d00; +const CONTROL_SIGNATURE: [u8; 12] = [ + 0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0, +]; + +const FACTORY_SIGNATURE: [u8; 32] = [ + 0x48, 0x89, 0x4c, 0x24, 0x08, 0x53, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, + 0xff, 0xff, 0xff, 0x33, 0xc0, 0x89, 0x44, 0x24, 0x48, 0x48, 0x8d, 0x44, 0x24, 0x48, 0x48, 0x89, +]; +const DESERIALIZER_SIGNATURE: [u8; 32] = [ + 0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, 0xa8, 0xe8, + 0xfd, 0xff, 0xff, 0x48, 0x81, 0xec, 0xf0, 0x02, 0x00, 0x00, 0x48, 0xc7, 0x45, 0x98, 0xfe, 0xff, +]; + +type FactoryFn = unsafe extern "system" fn(*mut c_void) -> *mut c_void; +type DeserializerFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> u8; + +static ENABLED: AtomicBool = AtomicBool::new(false); +static STATE: AtomicUsize = AtomicUsize::new(TraceState::Disabled as usize); +static FACTORY_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static FACTORY_ENTRIES: AtomicU64 = AtomicU64::new(0); +static FACTORY_EXITS: AtomicU64 = AtomicU64::new(0); +static FACTORY_LAST_THIS: AtomicUsize = AtomicUsize::new(0); +static FACTORY_LAST_RESULT: AtomicUsize = AtomicUsize::new(0); +static FACTORY_LAST_THREAD: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_ENTRIES: AtomicU64 = AtomicU64::new(0); +static DESERIALIZER_EXITS: AtomicU64 = AtomicU64::new(0); +static DESERIALIZER_LAST_THIS: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_LAST_READER: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_LAST_RESULT: AtomicBool = AtomicBool::new(false); +static DESERIALIZER_LAST_THREAD: AtomicUsize = AtomicUsize::new(0); +static TRACE_BASE: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX); +static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0); +static NOTIFIER_EXITS: AtomicU64 = AtomicU64::new(0); +static NOTIFIER_CTX: AtomicUsize = AtomicUsize::new(0); +static NOTIFIER_BYTE_BEFORE: AtomicUsize = AtomicUsize::new(usize::MAX); +static NOTIFIER_BYTE_AFTER: AtomicUsize = AtomicUsize::new(usize::MAX); +static NOTIFIER_BEGIN: AtomicUsize = AtomicUsize::new(0); +static NOTIFIER_END: AtomicUsize = AtomicUsize::new(0); +static NOTIFIER_COUNT: AtomicUsize = AtomicUsize::new(usize::MAX); +static PATCH_INSTALLER_BUSY: AtomicBool = AtomicBool::new(false); +static CODE_PATCH_PENDING: AtomicUsize = AtomicUsize::new(0); + +struct PatchInstallerGate; + +impl Drop for PatchInstallerGate { + fn drop(&mut self) { + PATCH_INSTALLER_BUSY.store(false, Ordering::Release); + } +} + +fn acquire_patch_installer_gate() -> Option { + for _ in 0..200 { + if PATCH_INSTALLER_BUSY + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return Some(PatchInstallerGate); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + None +} + +struct CodeInstallerPending; + +impl Drop for CodeInstallerPending { + fn drop(&mut self) { + CODE_PATCH_PENDING.fetch_sub(1, Ordering::AcqRel); + } +} + +pub(crate) fn code_patch_installers_ready() -> bool { + CODE_PATCH_PENDING.load(Ordering::Acquire) == 0 +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +enum TraceState { + Disabled, + Requested, + Installed, + Failed, + DegradedHookActive, + DegradedProcessState, + DegradedHookAndProcess, +} + +fn env_enabled(value: Option<&str>) -> bool { + matches!(value, Some("1")) +} + +fn target_va(base: usize, rva: usize) -> Option { + base.checked_add(rva) +} + +fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] { + let mut jump = [0u8; ABS_JUMP_LEN]; + jump[..6].copy_from_slice(&[0xff, 0x25, 0, 0, 0, 0]); + jump[6..].copy_from_slice(&(destination as u64).to_le_bytes()); + jump +} + +fn instruction_pointer_in_span(rip: usize, target: usize) -> bool { + target + .checked_add(COPY_LEN) + .map(|end| rip >= target && rip < end) + .unwrap_or(true) +} + +struct SuspendedPeers { + handles: [HANDLE; MAX_PEERS], + tids: [u32; MAX_PEERS], + count: usize, +} + +impl SuspendedPeers { + fn empty() -> Self { + Self { + handles: [core::ptr::null_mut(); MAX_PEERS], + tids: [0; MAX_PEERS], + count: 0, + } + } + + fn contains_tid(&self, tid: u32) -> bool { + self.tids[..self.count].contains(&tid) + } + + unsafe fn resume_all(&mut self) -> bool { + let mut all_resumed = true; + for index in (0..self.count).rev() { + let handle = self.handles[index]; + if handle.is_null() { + continue; + } + if ResumeThread(handle) == u32::MAX { + all_resumed = false; + continue; + } + CloseHandle(handle); + self.handles[index] = core::ptr::null_mut(); + } + all_resumed + } +} + +impl Drop for SuspendedPeers { + fn drop(&mut self) { + // Emergency retry only. A handle whose thread still cannot be resumed is + // intentionally leaked rather than closed while its thread is suspended. + unsafe { + let _ = self.resume_all(); + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum QuiesceFailure { + Acquire, + Resume, +} + +/// Stop and inspect every peer thread before touching either entry point. Any +/// incomplete enumeration/access/context operation fails the transaction closed. +unsafe fn suspend_peers( + factory: usize, + deserializer: usize, +) -> Result { + let process_id = GetCurrentProcessId(); + let current_thread_id = GetCurrentThreadId(); + let mut peers = SuspendedPeers::empty(); + for _ in 0..MAX_QUIESCE_PASSES { + // Enumerate into fixed stack storage before suspending anything found in + // this pass. Later passes close the thread-creation race to a fixed point. + let mut candidates = [0u32; MAX_PEERS]; + let mut candidate_count = 0usize; + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); + if snapshot == INVALID_HANDLE_VALUE { + break; + } + let mut entry: THREADENTRY32 = core::mem::zeroed(); + entry.dwSize = core::mem::size_of::() as u32; + let mut available = Thread32First(snapshot, &mut entry) != 0; + if !available { + CloseHandle(snapshot); + break; + } + let mut enumeration_ok = true; + while available { + let tid = entry.th32ThreadID; + if entry.th32OwnerProcessID == process_id + && tid != current_thread_id + && !peers.contains_tid(tid) + { + if peers.count + candidate_count >= MAX_PEERS { + enumeration_ok = false; + break; + } + if !candidates[..candidate_count].contains(&tid) { + candidates[candidate_count] = tid; + candidate_count += 1; + } + } + entry.dwSize = core::mem::size_of::() as u32; + available = Thread32Next(snapshot, &mut entry) != 0; + if !available && GetLastError() != ERROR_NO_MORE_FILES { + enumeration_ok = false; + } + } + CloseHandle(snapshot); + if !enumeration_ok { + return Err(if peers.resume_all() { + QuiesceFailure::Acquire + } else { + QuiesceFailure::Resume + }); + } + if candidate_count == 0 { + return Ok(peers); + } + for &tid in &candidates[..candidate_count] { + let handle = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT, 0, tid); + if handle.is_null() || SuspendThread(handle) == u32::MAX { + if !handle.is_null() { + CloseHandle(handle); + } + return Err(if peers.resume_all() { + QuiesceFailure::Acquire + } else { + QuiesceFailure::Resume + }); + } + peers.handles[peers.count] = handle; + peers.tids[peers.count] = tid; + peers.count += 1; + let mut context: CONTEXT = core::mem::zeroed(); + context.ContextFlags = CONTEXT_CONTROL_AMD64; + if GetThreadContext(handle, &mut context) == 0 + || instruction_pointer_in_span(context.Rip as usize, factory) + || instruction_pointer_in_span(context.Rip as usize, deserializer) + { + return Err(if peers.resume_all() { + QuiesceFailure::Acquire + } else { + QuiesceFailure::Resume + }); + } + } + } + Err(if peers.resume_all() { + QuiesceFailure::Acquire + } else { + QuiesceFailure::Resume + }) +} + +unsafe fn executable_range(address: usize, length: usize) -> bool { + let Some(end) = address.checked_add(length) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + if VirtualQuery( + address as _, + &mut mbi, + core::mem::size_of::(), + ) == 0 + || mbi.State != MEM_COMMIT + || mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) != 0 + { + return false; + } + let protection = mbi.Protect & 0xff; + matches!( + protection, + PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY + ) && end <= mbi.BaseAddress as usize + mbi.RegionSize +} + +unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool { + let Some(end) = address.checked_add(length) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + if VirtualQuery( + address as _, + &mut mbi, + core::mem::size_of::(), + ) == 0 + { + return false; + } + executable_range(address, length) + && mbi.AllocationBase as usize == base + && end <= mbi.BaseAddress as usize + mbi.RegionSize +} + +unsafe fn readable_range(address: usize, length: usize) -> bool { + let Some(end) = address.checked_add(length) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + VirtualQuery( + address as _, + &mut mbi, + core::mem::size_of::(), + ) != 0 + && mbi.State == MEM_COMMIT + && mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0 + && end <= mbi.BaseAddress as usize + mbi.RegionSize +} + +unsafe fn guarded_usize(address: usize) -> Option { + (address & 7 == 0 && readable_range(address, 8)) + .then(|| core::ptr::read_volatile(address as *const usize)) +} + +unsafe fn guarded_u16(address: usize) -> Option { + readable_range(address, 2).then(|| core::ptr::read_volatile(address as *const u16)) +} + +unsafe fn guarded_u8(address: usize) -> Option { + readable_range(address, 1).then(|| core::ptr::read_volatile(address as *const u8)) +} + +unsafe fn valid_cards_image(base: usize) -> bool { + let Some(control) = base.checked_add(CONTROL_RVA) else { + return false; + }; + if !readable_range(base, 0x1000) || !executable_range(control, CONTROL_SIGNATURE.len()) { + return false; + } + if *(base as *const u16) != 0x5a4d { + return false; + } + let pe_off = *((base + 0x3c) as *const u32) as usize; + if pe_off > 0xf00 { + return false; + } + let Some(pe) = base.checked_add(pe_off) else { + return false; + }; + if *(pe as *const u32) != 0x0000_4550 { + return false; + } + let Some(size_field) = pe.checked_add(24 + 0x38) else { + return false; + }; + let size = *(size_field as *const u32) as usize; + let required = CATEGORY_DESERIALIZER_RVA + DESERIALIZER_SIGNATURE.len(); + size > required + && core::slice::from_raw_parts(control as *const u8, CONTROL_SIGNATURE.len()) + == CONTROL_SIGNATURE +} + +pub(crate) unsafe fn validate_cards_build(base: usize) -> bool { + valid_cards_image(base) +} + +unsafe fn signature_matches(target: usize, signature: &[u8; 32]) -> bool { + core::slice::from_raw_parts(target as *const u8, signature.len()) == signature +} + +unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option { + let trampoline_len = copy_len.checked_add(ABS_JUMP_LEN)?; + let memory = VirtualAlloc( + core::ptr::null(), + trampoline_len, + MEM_COMMIT | MEM_RESERVE, + PAGE_READWRITE, + ) as usize; + if memory == 0 { + return None; + } + core::ptr::copy_nonoverlapping(target as *const u8, memory as *mut u8, copy_len); + let Some(resume) = target.checked_add(copy_len) else { + VirtualFree(memory as _, 0, MEM_RELEASE); + return None; + }; + let jump = absolute_jump(resume); + core::ptr::copy_nonoverlapping(jump.as_ptr(), (memory + copy_len) as *mut u8, ABS_JUMP_LEN); + let mut old = 0u32; + if VirtualProtect(memory as _, trampoline_len, PAGE_EXECUTE_READ, &mut old) == 0 + || FlushInstructionCache(GetCurrentProcess(), memory as _, trampoline_len) == 0 + { + VirtualFree(memory as _, 0, MEM_RELEASE); + return None; + } + Some(memory) +} + +unsafe fn write_entry( + target: usize, + destination: usize, + original: &[u8; COPY_LEN], + published: &mut bool, +) -> Result<(), bool> { + let mut patch = [0x90u8; COPY_LEN]; + patch[..ABS_JUMP_LEN].copy_from_slice(&absolute_jump(destination)); + let mut old = 0u32; + if VirtualProtect(target as _, COPY_LEN, PAGE_EXECUTE_READWRITE, &mut old) == 0 { + return Err(true); + } + // From this point onward another processor may observe the detour, even if a + // later cache/protection operation fails and rollback restores original bytes. + *published = true; + core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, COPY_LEN); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, COPY_LEN) != 0; + let mut ignored = 0u32; + let restored = VirtualProtect(target as _, COPY_LEN, old, &mut ignored) != 0; + if flushed && restored { + Ok(()) + } else { + // `true` means the caller may safely free the trampoline. A false value + // means rollback itself failed, so executable backing must be retained. + Err(restore_entry(target, original)) + } +} + +unsafe fn restore_entry(target: usize, original: &[u8; COPY_LEN]) -> bool { + let mut old = 0u32; + if VirtualProtect(target as _, COPY_LEN, PAGE_EXECUTE_READWRITE, &mut old) == 0 { + return false; + } + core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, COPY_LEN); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, COPY_LEN) != 0; + let mut ignored = 0u32; + let restored = VirtualProtect(target as _, COPY_LEN, old, &mut ignored) != 0; + flushed && restored +} + +unsafe fn restore_notifier_entry(target: usize) -> bool { + let original: [u8; NOTIFIER_COPY_LEN] = + NOTIFIER_SIGNATURE[..NOTIFIER_COPY_LEN].try_into().unwrap(); + let mut old = 0u32; + if VirtualProtect( + target as _, + NOTIFIER_COPY_LEN, + PAGE_EXECUTE_READWRITE, + &mut old, + ) == 0 + { + return false; + } + core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, NOTIFIER_COPY_LEN); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, NOTIFIER_COPY_LEN) != 0; + let mut ignored = 0u32; + flushed && VirtualProtect(target as _, NOTIFIER_COPY_LEN, old, &mut ignored) != 0 +} + +unsafe fn write_notifier_entry( + target: usize, + destination: usize, + published: &mut bool, +) -> Result<(), bool> { + let mut patch = [0x90u8; NOTIFIER_COPY_LEN]; + patch[..ABS_JUMP_LEN].copy_from_slice(&absolute_jump(destination)); + let mut old = 0u32; + if VirtualProtect( + target as _, + NOTIFIER_COPY_LEN, + PAGE_EXECUTE_READWRITE, + &mut old, + ) == 0 + { + return Err(true); + } + *published = true; + core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, NOTIFIER_COPY_LEN); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, NOTIFIER_COPY_LEN) != 0; + let mut ignored = 0u32; + if flushed && VirtualProtect(target as _, NOTIFIER_COPY_LEN, old, &mut ignored) != 0 { + Ok(()) + } else { + Err(restore_notifier_entry(target)) + } +} + +unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) { + NOTIFIER_ENTRIES.fetch_add(1, Ordering::Relaxed); + let address = ctx as usize; + NOTIFIER_CTX.store(address, Ordering::Relaxed); + NOTIFIER_BYTE_BEFORE.store( + address + .checked_add(0x88) + .and_then(|p| guarded_u8(p)) + .map(usize::from) + .unwrap_or(usize::MAX), + Ordering::Relaxed, + ); + let begin = address + .checked_add(0x58) + .and_then(|p| guarded_usize(p)) + .unwrap_or(0); + let end = address + .checked_add(0x60) + .and_then(|p| guarded_usize(p)) + .unwrap_or(0); + let count = if end >= begin && (end - begin) & 7 == 0 { + (end - begin) / 8 + } else { + usize::MAX + }; + NOTIFIER_BEGIN.store(begin, Ordering::Relaxed); + NOTIFIER_END.store(end, Ordering::Relaxed); + NOTIFIER_COUNT.store(count, Ordering::Relaxed); + let original: unsafe extern "system" fn(*mut c_void) = + core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire)); + original(ctx); + NOTIFIER_BYTE_AFTER.store( + address + .checked_add(0x88) + .and_then(|p| guarded_u8(p)) + .map(usize::from) + .unwrap_or(usize::MAX), + Ordering::Relaxed, + ); + NOTIFIER_EXITS.fetch_add(1, Ordering::Release); +} + +unsafe extern "system" fn factory_wrapper(this: *mut c_void) -> *mut c_void { + FACTORY_ENTRIES.fetch_add(1, Ordering::Relaxed); + FACTORY_LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed); + FACTORY_LAST_THIS.store(this as usize, Ordering::Relaxed); + let address = FACTORY_TRAMPOLINE.load(Ordering::Acquire); + let original: FactoryFn = core::mem::transmute(address); + let result = original(this); + FACTORY_LAST_RESULT.store(result as usize, Ordering::Release); + FACTORY_EXITS.fetch_add(1, Ordering::Release); + result +} + +unsafe extern "system" fn deserializer_wrapper(this: *mut c_void, reader: *mut c_void) -> u8 { + DESERIALIZER_ENTRIES.fetch_add(1, Ordering::Relaxed); + DESERIALIZER_LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed); + DESERIALIZER_LAST_THIS.store(this as usize, Ordering::Relaxed); + DESERIALIZER_LAST_READER.store(reader as usize, Ordering::Relaxed); + let address = DESERIALIZER_TRAMPOLINE.load(Ordering::Acquire); + let original: DeserializerFn = core::mem::transmute(address); + let result = original(this, reader); + DESERIALIZER_LAST_RESULT.store(result != 0, Ordering::Release); + let base = TRACE_BASE.load(Ordering::Acquire); + let a = base + .checked_add(0x2e6398) + .and_then(|slot| guarded_usize(slot)) + .unwrap_or(0); + let m = a + .checked_add(0x20a68) + .and_then(|slot| guarded_usize(slot)) + .unwrap_or(0); + let count = m + .checked_add(0x50) + .and_then(|slot| guarded_u16(slot)) + .map(usize::from) + .unwrap_or(usize::MAX); + let ready = a + .checked_add(0x1f9d8 + 0x28) + .and_then(|slot| guarded_u8(slot)) + .map(usize::from) + .unwrap_or(usize::MAX); + DESERIALIZER_EXIT_M.store(m, Ordering::Relaxed); + DESERIALIZER_EXIT_COUNT.store(count, Ordering::Relaxed); + DESERIALIZER_EXIT_B_READY.store(ready, Ordering::Relaxed); + DESERIALIZER_EXITS.fetch_add(1, Ordering::Release); + result +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InstallOutcome { + Installed, + PrePatchCleanFailure, + PostPatchCleanFailure, + DegradedHookActive, + DegradedProcessState, + DegradedHookAndProcess, +} + +fn may_free_trampolines(outcome: InstallOutcome) -> bool { + outcome == InstallOutcome::PrePatchCleanFailure +} + +unsafe fn install_pair(base: usize) -> InstallOutcome { + let Some(factory) = target_va(base, CATEGORY_FACTORY_RVA) else { + return InstallOutcome::PrePatchCleanFailure; + }; + let Some(deserializer) = target_va(base, CATEGORY_DESERIALIZER_RVA) else { + return InstallOutcome::PrePatchCleanFailure; + }; + TRACE_BASE.store(base, Ordering::Release); + // Preparation phase: validate the PE/ranges/signatures and allocate executable + // backing before suspending any peer. + if !valid_cards_image(base) + || !executable_range(factory, FACTORY_SIGNATURE.len()) + || !executable_range(deserializer, DESERIALIZER_SIGNATURE.len()) + || !signature_matches(factory, &FACTORY_SIGNATURE) + || !signature_matches(deserializer, &DESERIALIZER_SIGNATURE) + { + return InstallOutcome::PrePatchCleanFailure; + } + // Pin before quiescence so the image cannot unload for the lifetime of any + // published trampoline. This loader operation is intentionally outside the + // suspended phase. + let mut pinned = core::ptr::null_mut(); + if GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + factory as *const u8, + &mut pinned, + ) == 0 + || pinned as usize != base + { + return InstallOutcome::PrePatchCleanFailure; + } + let factory_original: [u8; COPY_LEN] = FACTORY_SIGNATURE[..COPY_LEN].try_into().unwrap(); + let deserializer_original: [u8; COPY_LEN] = + DESERIALIZER_SIGNATURE[..COPY_LEN].try_into().unwrap(); + let Some(factory_trampoline) = allocate_trampoline(factory, COPY_LEN) else { + return InstallOutcome::PrePatchCleanFailure; + }; + let Some(deserializer_trampoline) = allocate_trampoline(deserializer, COPY_LEN) else { + VirtualFree(factory_trampoline as _, 0, MEM_RELEASE); + return InstallOutcome::PrePatchCleanFailure; + }; + FACTORY_TRAMPOLINE.store(factory_trampoline, Ordering::Release); + DESERIALIZER_TRAMPOLINE.store(deserializer_trampoline, Ordering::Release); + + let Some(_installer_gate) = acquire_patch_installer_gate() else { + VirtualFree(factory_trampoline as _, 0, MEM_RELEASE); + VirtualFree(deserializer_trampoline as _, 0, MEM_RELEASE); + FACTORY_TRAMPOLINE.store(0, Ordering::Release); + DESERIALIZER_TRAMPOLINE.store(0, Ordering::Release); + return InstallOutcome::PrePatchCleanFailure; + }; + + // Quiesced phase: fixed-capacity bookkeeping and Win32 code-page operations + // only. No Rust allocation, freeing, formatting, or file logging is permitted. + let mut peers = match suspend_peers(factory, deserializer) { + Ok(peers) => peers, + Err(failure) => { + return if failure == QuiesceFailure::Resume { + InstallOutcome::DegradedProcessState + } else { + VirtualFree(factory_trampoline as _, 0, MEM_RELEASE); + VirtualFree(deserializer_trampoline as _, 0, MEM_RELEASE); + FACTORY_TRAMPOLINE.store(0, Ordering::Release); + DESERIALIZER_TRAMPOLINE.store(0, Ordering::Release); + InstallOutcome::PrePatchCleanFailure + }; + } + }; + + // Final no-allocation TOCTOU gate, immediately before the first write. + // The image was pinned by address before quiescence. Avoid loader APIs here: + // a suspended peer may own the loader lock. Page and byte identity checks are + // sufficient to reject any target change immediately before publication. + let final_valid = valid_cards_image(base) + && executable_range(factory, FACTORY_SIGNATURE.len()) + && executable_range(deserializer, DESERIALIZER_SIGNATURE.len()) + && signature_matches(factory, &FACTORY_SIGNATURE) + && signature_matches(deserializer, &DESERIALIZER_SIGNATURE); + let mut published = false; + let transaction = if !final_valid { + InstallOutcome::PrePatchCleanFailure + } else if let Err(clean) = write_entry( + factory, + factory_wrapper as *const () as usize, + &factory_original, + &mut published, + ) { + if clean { + if published { + InstallOutcome::PostPatchCleanFailure + } else { + InstallOutcome::PrePatchCleanFailure + } + } else { + InstallOutcome::DegradedHookActive + } + } else if let Err(second_clean) = write_entry( + deserializer, + deserializer_wrapper as *const () as usize, + &deserializer_original, + &mut published, + ) { + let first_clean = restore_entry(factory, &factory_original); + if first_clean && second_clean { + InstallOutcome::PostPatchCleanFailure + } else { + InstallOutcome::DegradedHookActive + } + } else { + InstallOutcome::Installed + }; + + let resumed = peers.resume_all(); + let outcome = if resumed { + transaction + } else if transaction == InstallOutcome::DegradedHookActive + || transaction == InstallOutcome::Installed + { + InstallOutcome::DegradedHookAndProcess + } else { + InstallOutcome::DegradedProcessState + }; + // Post-resume phase: freeing is safe only when no hook can reference backing. + if may_free_trampolines(outcome) { + VirtualFree(factory_trampoline as _, 0, MEM_RELEASE); + VirtualFree(deserializer_trampoline as _, 0, MEM_RELEASE); + FACTORY_TRAMPOLINE.store(0, Ordering::Release); + DESERIALIZER_TRAMPOLINE.store(0, Ordering::Release); + } + outcome +} + +unsafe fn worker() { + let _pending = CodeInstallerPending; + let mut base = 0usize; + for _ in 0..600u32 { + base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize; + if base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + if base == 0 { + STATE.store(TraceState::Failed as usize, Ordering::Release); + crate::write_log("SBC_TRACE: CardsDLL unavailable; tracing inactive\n"); + return; + } + let outcome = install_pair(base); + drop(_pending); + match outcome { + InstallOutcome::Installed => { + STATE.store(TraceState::Installed as usize, Ordering::Release); + crate::write_log("SBC_TRACE: factory+deserializer passive hooks installed\n"); + } + InstallOutcome::PrePatchCleanFailure => { + STATE.store(TraceState::Failed as usize, Ordering::Release); + crate::write_log("SBC_TRACE: pre-patch clean failure; tracing inactive\n"); + return; + } + InstallOutcome::PostPatchCleanFailure => { + STATE.store(TraceState::Failed as usize, Ordering::Release); + crate::write_log("SBC_TRACE: post-publication rollback completed; trampolines retained for process lifetime; tracing inactive\n"); + return; + } + InstallOutcome::DegradedHookActive => { + STATE.store(TraceState::DegradedHookActive as usize, Ordering::Release); + crate::write_log("SBC_TRACE: DEGRADED HOOK MAY BE ACTIVE; trampolines retained; terminate game now\n"); + return; + } + InstallOutcome::DegradedProcessState => { + STATE.store(TraceState::DegradedProcessState as usize, Ordering::Release); + crate::write_log("SBC_TRACE: DEGRADED THREAD RESUME FAILURE; terminate game now\n"); + return; + } + InstallOutcome::DegradedHookAndProcess => { + STATE.store( + TraceState::DegradedHookAndProcess as usize, + Ordering::Release, + ); + crate::write_log( + "SBC_TRACE: DEGRADED HOOK ACTIVE AND THREAD RESUME FAILURE; terminate game now\n", + ); + return; + } + } + + let mut seen_factory = 0u64; + let mut seen_deserializer = 0u64; + let mut reports = 0u8; + while reports < 32 { + std::thread::sleep(std::time::Duration::from_millis(250)); + let factory_count = FACTORY_ENTRIES.load(Ordering::Acquire); + let deserializer_count = DESERIALIZER_ENTRIES.load(Ordering::Acquire); + if factory_count != seen_factory || deserializer_count != seen_deserializer { + crate::write_log(&format!( + "SBC_TRACE: factory entry={} exit={} tid={} this={:#x} result={:#x}; deser entry={} exit={} tid={} this={:#x} reader={:#x} result={} M={:#x} count={} Bready={}\n", + factory_count, + FACTORY_EXITS.load(Ordering::Acquire), + FACTORY_LAST_THREAD.load(Ordering::Relaxed), + FACTORY_LAST_THIS.load(Ordering::Relaxed), + FACTORY_LAST_RESULT.load(Ordering::Acquire), + deserializer_count, + DESERIALIZER_EXITS.load(Ordering::Acquire), + DESERIALIZER_LAST_THREAD.load(Ordering::Relaxed), + DESERIALIZER_LAST_THIS.load(Ordering::Relaxed), + DESERIALIZER_LAST_READER.load(Ordering::Relaxed), + DESERIALIZER_LAST_RESULT.load(Ordering::Acquire), + DESERIALIZER_EXIT_M.load(Ordering::Relaxed), + DESERIALIZER_EXIT_COUNT.load(Ordering::Relaxed), + DESERIALIZER_EXIT_B_READY.load(Ordering::Relaxed), + )); + seen_factory = factory_count; + seen_deserializer = deserializer_count; + reports += 1; + } + } + crate::write_log("SBC_TRACE: report cap reached; hooks remain passive\n"); +} + +unsafe fn notifier_worker() { + let _pending = CodeInstallerPending; + let mut base = 0usize; + for _ in 0..600u32 { + base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize; + if base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + let Some(target) = target_va(base, NOTIFIER_RVA) else { + crate::write_log("SBC_NOTIFIER_TRACE: target resolution failed; inactive\n"); + return; + }; + if base == 0 + || !valid_cards_image(base) + || !executable_range_in_image(base, target, NOTIFIER_SIGNATURE.len()) + || !signature_matches(target, &NOTIFIER_SIGNATURE) + { + crate::write_log("SBC_NOTIFIER_TRACE: PE/signature validation failed; inactive\n"); + return; + } + let mut pinned = core::ptr::null_mut(); + if GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + target as *const u8, + &mut pinned, + ) == 0 + || pinned as usize != base + { + crate::write_log("SBC_NOTIFIER_TRACE: module pin failed; inactive\n"); + return; + } + let Some(trampoline) = allocate_trampoline(target, NOTIFIER_COPY_LEN) else { + crate::write_log("SBC_NOTIFIER_TRACE: trampoline allocation failed; inactive\n"); + return; + }; + NOTIFIER_TRAMPOLINE.store(trampoline, Ordering::Release); + let Some(_installer_gate) = acquire_patch_installer_gate() else { + VirtualFree(trampoline as _, 0, MEM_RELEASE); + NOTIFIER_TRAMPOLINE.store(0, Ordering::Release); + crate::write_log("SBC_NOTIFIER_TRACE: installer gate timeout; inactive\n"); + return; + }; + let mut peers = match suspend_peers(target, target) { + Ok(peers) => peers, + Err(_) => { + // A failed resume can leave peers suspended, so conservatively retain + // executable backing and require termination. + crate::write_log("SBC_NOTIFIER_TRACE: quiescence failed; trampoline retained; terminate game if unresponsive\n"); + return; + } + }; + let final_valid = pinned as usize == base + && valid_cards_image(base) + && executable_range_in_image(base, target, NOTIFIER_SIGNATURE.len()) + && signature_matches(target, &NOTIFIER_SIGNATURE); + let mut published = false; + let installed = final_valid + && write_notifier_entry( + target, + notifier_wrapper as *const () as usize, + &mut published, + ) + .is_ok(); + let resumed = peers.resume_all(); + drop(_installer_gate); + drop(_pending); + if !installed || !resumed { + if !published && resumed { + VirtualFree(trampoline as _, 0, MEM_RELEASE); + NOTIFIER_TRAMPOLINE.store(0, Ordering::Release); + crate::write_log("SBC_NOTIFIER_TRACE: clean install failure; inactive\n"); + } else { + crate::write_log("SBC_NOTIFIER_TRACE: DEGRADED hook/thread state; backing retained; terminate game now\n"); + } + return; + } + crate::write_log("SBC_NOTIFIER_TRACE: category success notifier hook installed\n"); + let mut seen = 0u64; + let mut reports = 0u8; + while reports < 32 { + std::thread::sleep(std::time::Duration::from_millis(250)); + let entries = NOTIFIER_ENTRIES.load(Ordering::Acquire); + if entries != seen { + crate::write_log(&format!( + "SBC_NOTIFIER_TRACE: entry={} exit={} ctx={:#x} byte88={}->{} handlers={:#x}..{:#x} count={}\n", + entries, + NOTIFIER_EXITS.load(Ordering::Acquire), + NOTIFIER_CTX.load(Ordering::Relaxed), + NOTIFIER_BYTE_BEFORE.load(Ordering::Relaxed), + NOTIFIER_BYTE_AFTER.load(Ordering::Relaxed), + NOTIFIER_BEGIN.load(Ordering::Relaxed), + NOTIFIER_END.load(Ordering::Relaxed), + NOTIFIER_COUNT.load(Ordering::Relaxed), + )); + seen = entries; + reports += 1; + } + } + crate::write_log("SBC_NOTIFIER_TRACE: report cap reached; hook remains passive\n"); +} + +fn install_notifier(enabled: bool) { + if enabled { + crate::write_log("SBC_NOTIFIER_TRACE: requested; deferred install starting\n"); + std::thread::spawn(|| unsafe { notifier_worker() }); + } else { + crate::write_log("SBC_NOTIFIER_TRACE: disabled\n"); + } +} + +pub(crate) fn install() { + let enabled = env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref()); + let notifier_enabled = env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref()); + CODE_PATCH_PENDING.store( + enabled as usize + notifier_enabled as usize, + Ordering::Release, + ); + install_notifier(notifier_enabled); + ENABLED.store(enabled, Ordering::Release); + if !enabled { + STATE.store(TraceState::Disabled as usize, Ordering::Release); + crate::write_log("SBC_TRACE: disabled (set OPENFUT_SBC_TRACE=1 to enable)\n"); + return; + } + STATE.store(TraceState::Requested as usize, Ordering::Release); + crate::write_log("SBC_TRACE: requested; deferred signature validation starting\n"); + std::thread::spawn(|| unsafe { worker() }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trace_gate_is_exact_and_default_off() { + assert!(!env_enabled(None)); + assert!(!env_enabled(Some("0"))); + assert!(!env_enabled(Some("true"))); + assert!(env_enabled(Some("1"))); + } + + #[test] + fn target_resolution_checks_overflow() { + assert_eq!(target_va(0x1000, CATEGORY_FACTORY_RVA), Some(0x17ba10)); + assert_eq!(target_va(usize::MAX, CATEGORY_FACTORY_RVA), None); + } + + #[test] + fn absolute_jump_has_indirect_rip_encoding_and_exact_destination() { + let jump = absolute_jump(0x1234_5678_9abc_def0); + assert_eq!(&jump[..6], &[0xff, 0x25, 0, 0, 0, 0]); + assert_eq!( + u64::from_le_bytes(jump[6..].try_into().unwrap()), + 0x1234_5678_9abc_def0 + ); + } + + #[test] + fn relocation_spans_end_on_proven_instruction_boundaries() { + assert_eq!( + &FACTORY_SIGNATURE[..COPY_LEN], + &[ + 0x48, 0x89, 0x4c, 0x24, 0x08, 0x53, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, + 0x20, 0xfe, 0xff, 0xff, 0xff, + ] + ); + assert_eq!( + &NOTIFIER_SIGNATURE[..NOTIFIER_COPY_LEN], + &[ + 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, + 0x20 + ] + ); + assert_eq!( + &DESERIALIZER_SIGNATURE[..COPY_LEN], + &[ + 0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, + 0xa8, 0xe8, 0xfd, 0xff, 0xff, + ] + ); + } + + #[test] + fn instruction_span_is_half_open_and_overflow_fails_closed() { + assert!(instruction_pointer_in_span(0x1000, 0x1000)); + assert!(instruction_pointer_in_span(0x1012, 0x1000)); + assert!(!instruction_pointer_in_span(0x1013, 0x1000)); + assert!(!instruction_pointer_in_span(0x0fff, 0x1000)); + assert!(instruction_pointer_in_span(usize::MAX, usize::MAX)); + } + + #[test] + fn only_clean_failure_allows_trampoline_free() { + assert!(may_free_trampolines(InstallOutcome::PrePatchCleanFailure)); + assert!(!may_free_trampolines(InstallOutcome::PostPatchCleanFailure)); + assert!(!may_free_trampolines(InstallOutcome::Installed)); + assert!(!may_free_trampolines(InstallOutcome::DegradedHookActive)); + assert!(!may_free_trampolines(InstallOutcome::DegradedProcessState)); + assert!(!may_free_trampolines( + InstallOutcome::DegradedHookAndProcess + )); + } +} diff --git a/openfut-hook/src/version_proxy.rs b/openfut-hook/src/version_proxy.rs new file mode 100644 index 0000000..74c594a --- /dev/null +++ b/openfut-hook/src/version_proxy.rs @@ -0,0 +1,117 @@ +//! Transparent forwarding for the system `version.dll` API. +//! +//! The hook is deployed under the `version.dll` filename, so every VERSION API +//! import must continue to behave exactly as it would without OpenFUT. Resolve +//! the genuine system DLL once during process attach, then tail-jump from each +//! exported stub. A tail jump preserves the caller's complete Windows x64 ABI +//! state, including stack arguments whose signatures differ between exports. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; + +const EXPORT_COUNT: usize = 16; + +/// Keep this list in the same order as the generated stubs below. +const EXPORTS: [&[u8]; EXPORT_COUNT] = [ + b"GetFileVersionInfoA\0", + b"GetFileVersionInfoExA\0", + b"GetFileVersionInfoExW\0", + b"GetFileVersionInfoSizeA\0", + b"GetFileVersionInfoSizeExA\0", + b"GetFileVersionInfoSizeExW\0", + b"GetFileVersionInfoSizeW\0", + b"GetFileVersionInfoW\0", + b"VerFindFileA\0", + b"VerFindFileW\0", + b"VerInstallFileA\0", + b"VerInstallFileW\0", + b"VerLanguageNameA\0", + b"VerLanguageNameW\0", + b"VerQueryValueA\0", + b"VerQueryValueW\0", +]; + +/// Addresses in the genuine system DLL. Atomic storage gives the assembly +/// stubs stable, directly addressable pointer-sized slots without `static mut`. +static REAL: [AtomicUsize; EXPORT_COUNT] = [const { AtomicUsize::new(0) }; EXPORT_COUNT]; + +macro_rules! proxy_stub { + ($index:literal, $name:ident) => { + #[unsafe(no_mangle)] + #[unsafe(naked)] + pub unsafe extern "system" fn $name() { + core::arch::naked_asm!( + "jmp qword ptr [rip + {base} + {offset}]", + base = sym REAL, + offset = const $index * size_of::(), + ); + } + }; +} + +proxy_stub!(0, GetFileVersionInfoA); +proxy_stub!(1, GetFileVersionInfoExA); +proxy_stub!(2, GetFileVersionInfoExW); +proxy_stub!(3, GetFileVersionInfoSizeA); +proxy_stub!(4, GetFileVersionInfoSizeExA); +proxy_stub!(5, GetFileVersionInfoSizeExW); +proxy_stub!(6, GetFileVersionInfoSizeW); +proxy_stub!(7, GetFileVersionInfoW); +proxy_stub!(8, VerFindFileA); +proxy_stub!(9, VerFindFileW); +proxy_stub!(10, VerInstallFileA); +proxy_stub!(11, VerInstallFileW); +proxy_stub!(12, VerLanguageNameA); +proxy_stub!(13, VerLanguageNameW); +proxy_stub!(14, VerQueryValueA); +proxy_stub!(15, VerQueryValueW); + +/// Resolve forwarding targets before returning from `DLL_PROCESS_ATTACH`. +/// Calls into our exports may happen as soon as the loader releases its lock, +/// so deferring this operation to the hook worker would create a race. +pub(crate) unsafe fn resolve() -> bool { + // Loading by absolute path prevents this proxy from recursively loading + // itself. Proton/Wine exposes the Windows system directory at this path. + let path: Vec = "C:\\Windows\\System32\\version.dll\0" + .encode_utf16() + .collect(); + let module = LoadLibraryW(path.as_ptr()); + if module.is_null() { + crate::write_log("version_proxy: FATAL: system version.dll load failed\n"); + return false; + } + + let mut missing = 0; + for (slot, name) in REAL.iter().zip(EXPORTS) { + let address = GetProcAddress(module, name.as_ptr()).map_or(0, |proc| proc as usize); + slot.store(address, Ordering::Release); + if address == 0 { + missing += 1; + } + } + + if missing == 0 { + crate::write_log("version_proxy: forwarded all 16 exports\n"); + true + } else { + crate::write_log(&format!( + "version_proxy: FATAL: {missing}/16 system exports missing\n" + )); + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn export_table_is_complete_and_nul_terminated() { + assert_eq!(EXPORTS.len(), EXPORT_COUNT); + assert!(EXPORTS.iter().all(|name| name.last() == Some(&0))); + assert!(EXPORTS + .iter() + .all(|name| !name[..name.len() - 1].contains(&0))); + } +} diff --git a/openfut-hook/version.def b/openfut-hook/version.def new file mode 100644 index 0000000..5f2ae7f --- /dev/null +++ b/openfut-hook/version.def @@ -0,0 +1,18 @@ +LIBRARY version +EXPORTS + GetFileVersionInfoA + GetFileVersionInfoExA + GetFileVersionInfoExW + GetFileVersionInfoSizeA + GetFileVersionInfoSizeExA + GetFileVersionInfoSizeExW + GetFileVersionInfoSizeW + GetFileVersionInfoW + VerFindFileA + VerFindFileW + VerInstallFileA + VerInstallFileW + VerLanguageNameA + VerLanguageNameW + VerQueryValueA + VerQueryValueW From 09ed26ba163bdfc28e313c5e5f811b9b1e0f754e Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 7 Aug 2026 12:03:21 -0700 Subject: [PATCH 8/9] wip: checkpoint FIFA 17 hook diagnostics --- openfut-hook/src/connect_hook.rs | 138 +++++++----- openfut-hook/src/connectex_hook.rs | 71 +++--- openfut-hook/src/hooks.rs | 20 +- openfut-hook/src/iat.rs | 19 +- openfut-hook/src/origin_spy.rs | 53 +++-- openfut-hook/src/probe.rs | 329 +++++++++++++++++++++------- openfut-hook/src/recv_hook.rs | 148 ++++++++++--- openfut-hook/src/ssl_patch.rs | 44 ++-- openfut-hook/src/tls_bypass.rs | 15 +- openfut-hook/src/transport_watch.rs | 4 +- src/process.rs | 3 +- 11 files changed, 596 insertions(+), 248 deletions(-) diff --git a/openfut-hook/src/connect_hook.rs b/openfut-hook/src/connect_hook.rs index 4dc3856..34639b7 100644 --- a/openfut-hook/src/connect_hook.rs +++ b/openfut-hook/src/connect_hook.rs @@ -5,27 +5,27 @@ 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. +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) +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 PORT_LSX_TARGET_NBO: u16 = 0x910C; // 3217 big-endian (bridge LSX target) const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian #[repr(C)] struct SockaddrIn { sin_family: u16, - sin_port: u16, - sin_addr: u32, - sin_zero: [u8; 8], + sin_port: u16, + sin_addr: u32, + sin_zero: [u8; 8], } const AF_INET6: u16 = 23; // Windows AF_INET6 value (we run under the Win ABI in Wine) @@ -34,10 +34,10 @@ const AF_INET6: u16 = 23; // Windows AF_INET6 value (we run under the Win ABI in /// address bytes in network order. 28 bytes total. #[repr(C)] struct SockaddrIn6 { - sin6_family: u16, - sin6_port: u16, + sin6_family: u16, + sin6_port: u16, sin6_flowinfo: u32, - sin6_addr: [u8; 16], + sin6_addr: [u8; 16], sin6_scope_id: u32, } @@ -46,8 +46,7 @@ struct SockaddrIn6 { /// 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]; +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); @@ -57,18 +56,26 @@ static mut ORIGINAL_BYTES: [u8; 14] = [0u8; 14]; // For WSAConnect IAT fallback type WsaConnectFn = unsafe extern "system" fn( - s: usize, name: *const u8, namelen: i32, - caller: *const (), callee: *const (), - sqos: *const (), gqos: *const ()) -> i32; + s: usize, + name: *const u8, + namelen: i32, + caller: *const (), + callee: *const (), + sqos: *const (), + gqos: *const (), +) -> i32; static REAL_WSA: OnceLock = OnceLock::new(); -pub fn set_real_wsa_connect(f: WsaConnectFn) { let _ = REAL_WSA.set(f); } +pub fn set_real_wsa_connect(f: WsaConnectFn) { + let _ = REAL_WSA.set(f); +} unsafe fn write_hook(target: *mut u8, dest: u64) { use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; let mut old: u32 = 0; VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); // FF 25 00 00 00 00 JMP [rip+0] - target.write(0xFF); target.add(1).write(0x25); + target.write(0xFF); + target.add(1).write(0x25); (target.add(2) as *mut u32).write(0u32); (target.add(6) as *mut u64).write(dest); VirtualProtect(target as _, 14, old, &mut old); @@ -78,7 +85,7 @@ unsafe fn restore_original(target: *mut u8) { use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; let mut old: u32 = 0; VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); - core::ptr::copy_nonoverlapping(ORIGINAL_BYTES.as_ptr(), target, 14); + core::ptr::copy_nonoverlapping(core::ptr::addr_of!(ORIGINAL_BYTES) as *const u8, target, 14); VirtualProtect(target as _, 14, old, &mut old); } @@ -103,26 +110,30 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u // SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read. let sa = &*(name as *const SockaddrIn); let new_port_nbo = match sa.sin_port { - PORT_HTTPS_NBO => PORT_BRIDGE_NBO, + PORT_HTTPS_NBO => PORT_BRIDGE_NBO, #[cfg(not(feature = "capture_baseline"))] - PORT_LSX_NBO => PORT_LSX_TARGET_NBO, + 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, + 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), + 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; + out.sin_port = new_port_nbo; + out.sin_addr = ADDR_LOOPBACK_NBO; Some((buf, 16)) } AF_INET6 => { @@ -133,23 +144,27 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u let sa6 = &*(name as *const SockaddrIn6); // LSX is IPv4-only (anadius keys on it), so it is intentionally omitted here. let new_port_nbo = match sa6.sin6_port { - PORT_HTTPS_NBO => PORT_BRIDGE_NBO, + PORT_HTTPS_NBO => PORT_BRIDGE_NBO, PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, - PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, - _ => return None, + 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), + 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_family = AF_INET6; + out.sin6_port = new_port_nbo; out.sin6_flowinfo = 0; - out.sin6_addr = V4MAPPED_LOOPBACK; + out.sin6_addr = V4MAPPED_LOOPBACK; out.sin6_scope_id = 0; Some((buf, 28)) } @@ -174,7 +189,13 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE}; let mut ty: i32 = -1; let mut len: i32 = 4; - getsockopt(s, SOL_SOCKET as i32, SO_TYPE, &mut ty as *mut i32 as *mut u8, &mut len); + getsockopt( + s, + SOL_SOCKET as i32, + SO_TYPE, + &mut ty as *mut i32 as *mut u8, + &mut len, + ); ty }; crate::write_log(&format!( @@ -190,11 +211,11 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: let (call_name, call_len) = if let Some((buf, len)) = redirect_if_ea(name, namelen) { restore_original(addr); let r = { - let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 - = core::mem::transmute(addr); + let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = + core::mem::transmute(addr); f(s, buf.as_ptr(), len) }; - write_hook(addr, hooked_connect as u64); + write_hook(addr, hooked_connect as *const () as u64); return r; } else { (name, namelen) @@ -202,18 +223,19 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: restore_original(addr); let r = { - let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 - = core::mem::transmute(addr); + let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr); f(s, call_name, call_len) }; - write_hook(addr, hooked_connect as u64); + write_hook(addr, hooked_connect as *const () as u64); if namelen >= 8 { let sa = &*(call_name as *const SockaddrIn); if sa.sin_family == AF_INET { let err = if r != 0 { use windows_sys::Win32::Networking::WinSock::WSAGetLastError; WSAGetLastError() - } else { 0 }; + } else { + 0 + }; crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n")); } } @@ -221,9 +243,13 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: } pub unsafe extern "system" fn hooked_wsa_connect( - s: usize, name: *const u8, namelen: i32, - caller: *const (), callee: *const (), - sqos: *const (), gqos: *const (), + s: usize, + name: *const u8, + namelen: i32, + caller: *const (), + callee: *const (), + sqos: *const (), + gqos: *const (), ) -> i32 { // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). crate::transport_watch::note_connect("WSAConnect", name, namelen, s); @@ -240,17 +266,23 @@ pub unsafe fn install_inline_connect_hook() -> bool { use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr()); - if ws2.is_null() { return false; } + if ws2.is_null() { + return false; + } let connect_fn = match GetProcAddress(ws2, b"connect\0".as_ptr()) { Some(f) => f as *mut u8, None => return false, }; // Save original 14 bytes - core::ptr::copy_nonoverlapping(connect_fn, ORIGINAL_BYTES.as_mut_ptr(), 14); + core::ptr::copy_nonoverlapping( + connect_fn, + core::ptr::addr_of_mut!(ORIGINAL_BYTES) as *mut u8, + 14, + ); CONNECT_ADDR.store(connect_fn as usize, Ordering::Relaxed); // Overwrite first 14 bytes with absolute indirect JMP to our hook - write_hook(connect_fn, hooked_connect as u64); + write_hook(connect_fn, hooked_connect as *const () as u64); true } diff --git a/openfut-hook/src/connectex_hook.rs b/openfut-hook/src/connectex_hook.rs index 71a0070..fa3d20f 100644 --- a/openfut-hook/src/connectex_hook.rs +++ b/openfut-hook/src/connectex_hook.rs @@ -1,10 +1,10 @@ +use core::ffi::c_void; /// Intercepts ConnectEx (EA/DirtySDK's preferred async connect API). /// /// DirtySDK calls WSAIoctl(SIO_GET_EXTENSION_FUNCTION_POINTER, WSAID_CONNECTEX) once at /// startup to get a ConnectEx function pointer, bypassing all IAT hooks. We hook WSAIoctl /// inline so that when it returns a ConnectEx pointer we swap it for our own wrapper. use core::sync::atomic::{AtomicUsize, Ordering}; -use core::ffi::c_void; // Address rewriting (v4 + v6) is shared from connect_hook::redirect_if_ea, so the port // constants and sockaddr structs no longer live here. @@ -14,9 +14,7 @@ const SIO_GET_EXT_FN: u32 = 0xC8000006; // WSAID_CONNECTEX = {25A207B9-DDF3-4660-8EE9-76E58C74063E} const CONNECTEX_GUID: [u8; 16] = [ - 0xB9, 0x07, 0xA2, 0x25, - 0xF3, 0xDD, 0x60, 0x46, - 0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E, + 0xB9, 0x07, 0xA2, 0x25, 0xF3, 0xDD, 0x60, 0x46, 0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E, ]; // The real ConnectEx pointer, saved after WSAIoctl returns it @@ -53,7 +51,8 @@ unsafe fn write_hook(target: *mut u8, dest: u64) { use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; let mut old: u32 = 0; VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); - target.write(0xFF); target.add(1).write(0x25); + target.write(0xFF); + target.add(1).write(0x25); (target.add(2) as *mut u32).write(0u32); (target.add(6) as *mut u64).write(dest); VirtualProtect(target as _, 14, old, &mut old); @@ -63,7 +62,7 @@ unsafe fn restore_wsaioctl(target: *mut u8) { use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; let mut old: u32 = 0; VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); - core::ptr::copy_nonoverlapping(WSAIOCTL_ORIG.as_ptr(), target, 14); + core::ptr::copy_nonoverlapping(core::ptr::addr_of!(WSAIOCTL_ORIG) as *const u8, target, 14); VirtualProtect(target as _, 14, old, &mut old); } @@ -85,9 +84,25 @@ unsafe extern "system" fn hooked_connectex( // Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx // dials get the same IPv6 handling as plain connect(). if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) { - return real_fn(s, buf.as_ptr(), len, send_buf, send_data_len, bytes_sent, overlapped); + return real_fn( + s, + buf.as_ptr(), + len, + send_buf, + send_data_len, + bytes_sent, + overlapped, + ); } - real_fn(s, name, namelen, send_buf, send_data_len, bytes_sent, overlapped) + real_fn( + s, + name, + namelen, + send_buf, + send_data_len, + bytes_sent, + overlapped, + ) } /// Our WSAIoctl hook: when ConnectEx is requested, save the real pointer and return ours @@ -108,28 +123,28 @@ pub unsafe extern "system" fn hooked_wsaioctl( restore_wsaioctl(addr); let result = { let f: WsaIoctlFn = core::mem::transmute(addr); - f(s, code, in_buf, in_len, out_buf, out_len, bytes_ret, overlapped, completion) + f( + s, code, in_buf, in_len, out_buf, out_len, bytes_ret, overlapped, completion, + ) }; - write_hook(addr, hooked_wsaioctl as u64); + write_hook(addr, hooked_wsaioctl as *const () as u64); // If this was a ConnectEx request that succeeded, swap the pointer - if result == 0 - && code == SIO_GET_EXT_FN - && in_len == 16 - && !in_buf.is_null() - { + if result == 0 && code == SIO_GET_EXT_FN && in_len == 16 && !in_buf.is_null() { let guid = core::slice::from_raw_parts(in_buf as *const u8, 16); - if guid == CONNECTEX_GUID - && out_len >= 8 - && !out_buf.is_null() - { + if guid == CONNECTEX_GUID && out_len >= 8 && !out_buf.is_null() { let out_ptr = out_buf as *mut usize; let real_addr = *out_ptr; - if REAL_CONNECTEX.compare_exchange(0, real_addr, Ordering::Relaxed, Ordering::Relaxed).is_ok() { - crate::write_log(&format!("connectex_hook: intercepted ConnectEx @ {real_addr:#x}\n")); + if REAL_CONNECTEX + .compare_exchange(0, real_addr, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + crate::write_log(&format!( + "connectex_hook: intercepted ConnectEx @ {real_addr:#x}\n" + )); } // Return our hook instead - *out_ptr = hooked_connectex as usize; + *out_ptr = hooked_connectex as *const () as usize; } } result @@ -138,13 +153,19 @@ pub unsafe extern "system" fn hooked_wsaioctl( pub unsafe fn install_wsaioctl_hook() -> bool { use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr()); - if ws2.is_null() { return false; } + if ws2.is_null() { + return false; + } let fn_ptr = match GetProcAddress(ws2, b"WSAIoctl\0".as_ptr()) { Some(f) => f as *mut u8, None => return false, }; - core::ptr::copy_nonoverlapping(fn_ptr, WSAIOCTL_ORIG.as_mut_ptr(), 14); + core::ptr::copy_nonoverlapping( + fn_ptr, + core::ptr::addr_of_mut!(WSAIOCTL_ORIG) as *mut u8, + 14, + ); WSAIOCTL_ADDR.store(fn_ptr as usize, Ordering::Relaxed); - write_hook(fn_ptr, hooked_wsaioctl as u64); + write_hook(fn_ptr, hooked_wsaioctl as *const () as u64); true } diff --git a/openfut-hook/src/hooks.rs b/openfut-hook/src/hooks.rs index 30842d2..c9c028c 100644 --- a/openfut-hook/src/hooks.rs +++ b/openfut-hook/src/hooks.rs @@ -1,19 +1,15 @@ use std::{ ffi::CStr, sync::{ - OnceLock, atomic::{AtomicBool, Ordering}, + OnceLock, }, }; -use windows_sys::Win32::Networking::WinSock::{ADDRINFOA, getaddrinfo as sys_getaddrinfo}; +use windows_sys::Win32::Networking::WinSock::{getaddrinfo as sys_getaddrinfo, ADDRINFOA}; -type GetaddrinfoFn = unsafe extern "system" fn( - *const u8, - *const u8, - *const ADDRINFOA, - *mut *mut ADDRINFOA, -) -> i32; +type GetaddrinfoFn = + unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32; static REAL: OnceLock = OnceLock::new(); static REDIRECT_IP: OnceLock> = OnceLock::new(); @@ -62,9 +58,13 @@ pub unsafe extern "system" fn hooked_getaddrinfo( if !CERT_PATCHED.load(Ordering::Relaxed) { if crate::ssl_patch::patch_eawebkit_cert_verify() { CERT_PATCHED.store(true, Ordering::Relaxed); - crate::write_log("openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n"); + crate::write_log( + "openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n", + ); } else { - crate::write_log("openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n"); + crate::write_log( + "openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n", + ); } } diff --git a/openfut-hook/src/iat.rs b/openfut-hook/src/iat.rs index 746f3e6..78f82e3 100644 --- a/openfut-hook/src/iat.rs +++ b/openfut-hook/src/iat.rs @@ -72,7 +72,11 @@ pub unsafe fn patch_iat(original_fn: *const (), hook_fn: *const ()) -> usize { } /// Patch the IAT of a specific already-loaded DLL (e.g. b"EAWebKit.dll\0"). -pub unsafe fn patch_iat_in(module_name: &[u8], original_fn: *const (), hook_fn: *const ()) -> usize { +pub unsafe fn patch_iat_in( + module_name: &[u8], + original_fn: *const (), + hook_fn: *const (), +) -> usize { let module = GetModuleHandleA(module_name.as_ptr()); if module.is_null() { return 0; @@ -80,11 +84,7 @@ pub unsafe fn patch_iat_in(module_name: &[u8], original_fn: *const (), hook_fn: patch_module(module, original_fn, hook_fn) } -unsafe fn patch_module( - module: HMODULE, - original_fn: *const (), - hook_fn: *const (), -) -> usize { +unsafe fn patch_module(module: HMODULE, original_fn: *const (), hook_fn: *const ()) -> usize { if module.is_null() { return 0; } @@ -117,7 +117,12 @@ unsafe fn patch_module( if val == original_fn as usize { let target = iat_slot.add(i) as *const std::ffi::c_void; let mut old: u32 = 0; - VirtualProtect(target, std::mem::size_of::(), PAGE_EXECUTE_READWRITE, &mut old); + VirtualProtect( + target, + std::mem::size_of::(), + PAGE_EXECUTE_READWRITE, + &mut old, + ); *iat_slot.add(i) = hook_fn as usize; VirtualProtect(target, std::mem::size_of::(), old, &mut old); count += 1; diff --git a/openfut-hook/src/origin_spy.rs b/openfut-hook/src/origin_spy.rs index fc0040c..8974945 100644 --- a/openfut-hook/src/origin_spy.rs +++ b/openfut-hook/src/origin_spy.rs @@ -27,28 +27,49 @@ 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); } +pub fn set_real_reg_a(f: RegQueryValueExAFn) { + let _ = REAL_REG_A.set(f); +} +pub fn set_real_reg_w(f: RegQueryValueExWFn) { + let _ = REAL_REG_W.set(f); +} +pub fn set_real_mutex_a(f: OpenMutexAFn) { + let _ = REAL_MUTEX_A.set(f); +} +pub fn set_real_mutex_w(f: OpenMutexWFn) { + let _ = REAL_MUTEX_W.set(f); +} fn narrow_to_string(p: *const u8) -> String { - if p.is_null() { return "(null)".into(); } + if p.is_null() { + return "(null)".into(); + } let bytes = unsafe { std::ffi::CStr::from_ptr(p as *const i8) }; bytes.to_string_lossy().into_owned() } fn wide_to_string(p: *const u16) -> String { - if p.is_null() { return "(null)".into(); } + if p.is_null() { + return "(null)".into(); + } let mut len = 0usize; - unsafe { while *p.add(len) != 0 { len += 1; } } + unsafe { + while *p.add(len) != 0 { + len += 1; + } + } String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) }) } fn is_interesting(name: &str) -> bool { - name.contains("LSX") || name.contains("Origin") || name.contains("EAL") || - name.contains("Client") || name.contains("lsx") || name.contains("Port") || - name.contains("EA") || name.contains("Connection") + name.contains("LSX") + || name.contains("Origin") + || name.contains("EAL") + || name.contains("Client") + || name.contains("lsx") + || name.contains("Port") + || name.contains("EA") + || name.contains("Connection") } pub unsafe extern "system" fn hooked_reg_query_a( @@ -93,8 +114,10 @@ pub unsafe extern "system" fn hooked_open_mutex_a( let name = narrow_to_string(lpmutexname); let real = REAL_MUTEX_A.get().copied().unwrap(); let handle = real(dwdesiredaccess, binherithandle, lpmutexname); - crate::write_log(&format!("origin_spy: OpenMutexA({name}) → {}\n", - if handle == 0 { "NOT_FOUND" } else { "FOUND" })); + crate::write_log(&format!( + "origin_spy: OpenMutexA({name}) → {}\n", + if handle == 0 { "NOT_FOUND" } else { "FOUND" } + )); handle } @@ -106,7 +129,9 @@ pub unsafe extern "system" fn hooked_open_mutex_w( let name = wide_to_string(lpmutexname); let real = REAL_MUTEX_W.get().copied().unwrap(); let handle = real(dwdesiredaccess, binherithandle, lpmutexname); - crate::write_log(&format!("origin_spy: OpenMutexW({name}) → {}\n", - if handle == 0 { "NOT_FOUND" } else { "FOUND" })); + crate::write_log(&format!( + "origin_spy: OpenMutexW({name}) → {}\n", + if handle == 0 { "NOT_FOUND" } else { "FOUND" } + )); handle } diff --git a/openfut-hook/src/probe.rs b/openfut-hook/src/probe.rs index c80b937..49272e8 100644 --- a/openfut-hook/src/probe.rs +++ b/openfut-hook/src/probe.rs @@ -17,11 +17,11 @@ //! 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::Threading::GetCurrentThreadId; 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 @@ -31,7 +31,11 @@ unsafe fn read_ptr(ptr: usize) -> Option { return None; } let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); - let n = VirtualQuery(ptr as _, &mut mbi, core::mem::size_of::()); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); if n == 0 || mbi.State != MEM_COMMIT { return None; } @@ -165,14 +169,18 @@ pub unsafe fn install_listener_probe() { // 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); + 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); + 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", @@ -180,16 +188,25 @@ pub unsafe fn install_listener_probe() { )); // 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); + 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" } + 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)); + crate::write_log(&format!( + "PROBE listener: dispatch site patched @ {:#x}\n", + target as usize + )); } // ─── dial trigger (sub-phase B) ────────────────────────────────────────────────── @@ -262,7 +279,9 @@ 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")); + crate::write_log(&format!( + "DIAL_TRIGGER: completion stub count changed {last} → {c}\n" + )); } } @@ -329,11 +348,17 @@ unsafe fn dial_trigger_tick() { // 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"); + 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"); + log_skip( + 3, + "DIAL_TRIGGER: connMgr resolution failed (M null) — skipping\n", + ); return; }; let expected_vtable = base + 0x80200b8; @@ -367,7 +392,9 @@ unsafe fn dial_trigger_tick() { 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"), + &format!( + "DIAL_TRIGGER: ctx pointer implausible (ctx={ctx:#x} vt={ctx_vt:#x}) — skipping\n" + ), ); return; } @@ -557,7 +584,11 @@ unsafe fn connmgr_enum_tick() { 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" }, + if info.vtable0_in_text { + "in .text" + } else { + "NOT .text" + }, h(info.field_18), h(info.field_20), h(info.field_30), @@ -658,7 +689,13 @@ unsafe fn read_u32(addr: usize) -> Option { fn fourcc4(v: u32) -> String { let b = v.to_le_bytes(); b.iter() - .map(|&c| if (0x20..0x7f).contains(&c) { c as char } else { '.' }) + .map(|&c| { + if (0x20..0x7f).contains(&c) { + c as char + } else { + '.' + } + }) .collect() } @@ -668,7 +705,10 @@ fn fourcc4(v: u32) -> String { /// (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()); + 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(); @@ -677,7 +717,11 @@ fn elem_hex_dump(label: &str, start_va: usize, data: &[u8]) { if i == 7 { hex.push(' '); } - ascii.push(if (0x20..0x7f).contains(&b) { b as char } else { '.' }); + ascii.push(if (0x20..0x7f).contains(&b) { + b as char + } else { + '.' + }); } out.push_str(&format!(" +{:#05x} {:<50}|{ascii}|\n", row * 16, hex)); } @@ -752,7 +796,11 @@ unsafe fn elem_watch_tick() { // 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 }; + 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" @@ -768,11 +816,22 @@ unsafe fn elem_watch_tick() { // 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 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 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()); + 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", @@ -786,7 +845,9 @@ unsafe fn elem_watch_tick() { return; }; if array_base == 0 { - crate::write_log("ELEM_WATCH: array_base null — element array not allocated; watchpoint not armed\n"); + crate::write_log( + "ELEM_WATCH: array_base null — element array not allocated; watchpoint not armed\n", + ); return; } if idx >= count { @@ -811,13 +872,19 @@ unsafe fn elem_watch_tick() { 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(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()), + 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 @@ -872,7 +939,9 @@ fn spawn_elem_watcher(base: usize, elem: usize) { elem_hex_dump("element (after change)", elem, &bytes); } } else if changes == 6 { - crate::write_log("ELEM_WATCH: (further changes suppressed; still tracking baseline)\n"); + 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. @@ -890,11 +959,12 @@ fn spawn_elem_watcher(base: usize, elem: usize) { pub fn install_force_connect() { std::thread::spawn(|| unsafe { let base = GetModuleHandleA(core::ptr::null()); - if base.is_null() { return; } + 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); + 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 { @@ -905,17 +975,23 @@ pub fn install_force_connect() { .filter(|&m| m != 0) .and_then(|m| read_ptr(m + 0x778)) .filter(|&c| c != 0); - let Some(ctx) = ctx else { continue; }; + 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; } + 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; } + if fired >= 6 { + break; + } std::thread::sleep(std::time::Duration::from_millis(5000)); } crate::write_log("FORCE: done\n"); @@ -965,23 +1041,33 @@ pub fn install_force_netconn_pump() { // (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); + 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; } + 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. + // 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 { '.' }) + .map(|&b| { + if (0x20..0x7f).contains(&b) { + b as char + } else { + '.' + } + }) .collect() }; @@ -998,7 +1084,9 @@ pub fn install_force_netconn_pump() { 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; }; + 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. @@ -1067,7 +1155,9 @@ pub fn install_force_netconn_pump() { pub fn install_force_fut_tick() { std::thread::spawn(|| unsafe { let base = GetModuleHandleA(core::ptr::null()); - if base.is_null() { return; } + 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 = @@ -1077,9 +1167,13 @@ pub fn install_force_fut_tick() { // ~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; }; + 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 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 @@ -1146,7 +1240,11 @@ unsafe fn read_bytes(addr: usize, len: usize) -> Option> { return None; } let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); - let n = VirtualQuery(addr as _, &mut mbi, core::mem::size_of::()); + let n = VirtualQuery( + addr as _, + &mut mbi, + core::mem::size_of::(), + ); if n == 0 || mbi.State != MEM_COMMIT { return None; } @@ -1180,7 +1278,11 @@ fn hex_dump(label: &str, start_va: usize, data: &[u8]) { } // 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 { '.' }); + ascii.push(if (0x20..0x7f).contains(&b) { + b as char + } else { + '.' + }); } out.push_str(&format!(" +{:#05x} {:<50}|{ascii}|\n", row * 16, hex)); } @@ -1203,16 +1305,16 @@ fn hex_dump(label: &str, start_va: usize, data: &[u8]) { /// /// 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 { +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::()); + let n = VirtualQuery( + addr as _, + &mut mbi, + core::mem::size_of::(), + ); if n == 0 { break; // past the top of the user address space } @@ -1262,7 +1364,9 @@ unsafe fn scan_conn_mgr( /// 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); + 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; @@ -1436,14 +1540,54 @@ 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 }, + 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() @@ -1498,19 +1642,37 @@ unsafe fn generic(slot: usize, a: usize, b: usize, c: usize, d: usize) -> usize 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")); + 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) } +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 @@ -1526,23 +1688,23 @@ pub fn install_probes_deferred() { 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_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. }); } @@ -1563,7 +1725,10 @@ pub unsafe fn install_probes() { 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)); + crate::write_log(&format!( + "PROBE {} installed @ {:#x}\n", + t.label, addr as usize + )); } } diff --git a/openfut-hook/src/recv_hook.rs b/openfut-hook/src/recv_hook.rs index 6c9015c..5edd78b 100644 --- a/openfut-hook/src/recv_hook.rs +++ b/openfut-hook/src/recv_hook.rs @@ -12,7 +12,8 @@ unsafe fn write_jmp(target: *mut u8, dest: u64) { use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; let mut old: u32 = 0; VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); - target.write(0xFF); target.add(1).write(0x25); + target.write(0xFF); + target.add(1).write(0x25); (target.add(2) as *mut u32).write(0); (target.add(6) as *mut u64).write(dest); VirtualProtect(target as _, 14, old, &mut old); @@ -43,10 +44,15 @@ unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option { } let mem = VirtualAlloc( - core::ptr::null_mut(), 64, - MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE, + core::ptr::null_mut(), + 64, + MEM_COMMIT | MEM_RESERVE, + PAGE_EXECUTE_READWRITE, ); - if mem.is_null() { crate::write_log("recv_hook: VirtualAlloc failed\n"); return None; } + if mem.is_null() { + crate::write_log("recv_hook: VirtualAlloc failed\n"); + return None; + } let t = mem as *mut u8; core::ptr::copy_nonoverlapping(orig, t, copy_len); @@ -56,7 +62,9 @@ unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option { 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")); + crate::write_log(&format!( + "recv_hook: {name} trampoline copy_len={copy_len}\n" + )); Some(t as usize) } @@ -67,8 +75,12 @@ fn has_rip_relative_branch(bytes: &[u8]) -> bool { let mut pos = 0; while pos < bytes.len() { let (len, branch) = decode_instr_len(&bytes[pos..]); - if branch { return true; } - if len == 0 { break; } // unknown/truncated — stop safely + if branch { + return true; + } + if len == 0 { + break; + } // unknown/truncated — stop safely pos += len; } false @@ -78,9 +90,29 @@ fn modrm_extra(modrm: u8) -> usize { let md = (modrm >> 6) & 3; let rm = modrm & 7; match md { - 0 => if rm == 5 { 4 } else if rm == 4 { 1 } else { 0 }, - 1 => if rm == 4 { 2 } else { 1 }, - 2 => if rm == 4 { 5 } else { 4 }, + 0 => { + if rm == 5 { + 4 + } else if rm == 4 { + 1 + } else { + 0 + } + } + 1 => { + if rm == 4 { + 2 + } else { + 1 + } + } + 2 => { + if rm == 4 { + 5 + } else { + 4 + } + } _ => 0, } } @@ -88,16 +120,31 @@ fn modrm_extra(modrm: u8) -> usize { /// Returns (instruction_length_in_bytes, is_rip_relative_branch). /// Returns (0, false) for unknown/truncated. fn decode_instr_len(b: &[u8]) -> (usize, bool) { - if b.is_empty() { return (0, false); } + if b.is_empty() { + return (0, false); + } let mut i = 0; // Legacy prefixes while let Some(&p) = b.get(i) { - if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) { i += 1; } else { break; } + if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) { + i += 1; + } else { + break; + } } // REX prefix (40–4F) - if b.get(i).copied().map(|x| (0x40..=0x4F).contains(&x)).unwrap_or(false) { i += 1; } + if b.get(i) + .copied() + .map(|x| (0x40..=0x4F).contains(&x)) + .unwrap_or(false) + { + i += 1; + } - let op = match b.get(i) { Some(&x) => x, None => return (0, false) }; + let op = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; i += 1; match op { @@ -112,28 +159,45 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) { 0xE9 | 0xE8 => (i + 4, true), // 0F prefix 0x0F => { - let op2 = match b.get(i) { Some(&x) => x, None => return (0, false) }; + let op2 = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; i += 1; - if (0x80..=0x8F).contains(&op2) { return (i + 4, true); } // Jcc rel32 - // Most 0F XX: ModRM - let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) }; + if (0x80..=0x8F).contains(&op2) { + return (i + 4, true); + } // Jcc rel32 + // Most 0F XX: ModRM + let modrm = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; (i + 1 + modrm_extra(modrm), false) } // Instructions with ModRM only (no immediate) - 0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | - 0x01 | 0x03 | 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | - 0x31 | 0x33 | 0x39 | 0x3B | 0xD3 | 0xFF | 0xF7 => { - let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) }; + 0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | 0x01 | 0x03 + | 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | 0x31 | 0x33 | 0x39 | 0x3B + | 0xD3 | 0xFF | 0xF7 => { + let modrm = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; (i + 1 + modrm_extra(modrm), false) } // ModRM + imm8 0x6B | 0x80 | 0x83 | 0xC0 | 0xC1 | 0xC6 => { - let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) }; + let modrm = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; (i + 1 + modrm_extra(modrm) + 1, false) } // ModRM + imm32 0x69 | 0x81 | 0xC7 => { - let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) }; + let modrm = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; (i + 1 + modrm_extra(modrm) + 4, false) } // MOV reg, imm8/imm32 @@ -152,7 +216,9 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) { unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> { use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; let h = GetModuleHandleA(dll.as_ptr()); - if h.is_null() { return None; } + if h.is_null() { + return None; + } GetProcAddress(h, sym.as_ptr()).map(|f| f as *mut u8) } @@ -166,7 +232,9 @@ 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; } + 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 } @@ -190,7 +258,10 @@ pub fn set_real_send(f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> /// hook calls) and overwrite the entry with a JMP to `hooked_recv`. Inline hooks /// catch calls from every module and dynamically-resolved calls, unlike IAT. pub unsafe fn install_recv_hook() -> bool { - let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") { Some(p) => p, None => return false }; + let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") { + Some(p) => p, + None => return false, + }; match make_trampoline(ptr, "recv") { Some(t) => REAL_RECV.store(t, Ordering::Relaxed), None => return false, @@ -200,7 +271,10 @@ pub unsafe fn install_recv_hook() -> bool { } pub unsafe fn install_send_hook() -> bool { - let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") { Some(p) => p, None => return false }; + let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") { + Some(p) => p, + None => return false, + }; match make_trampoline(ptr, "send") { Some(t) => REAL_SEND.store(t, Ordering::Relaxed), None => return false, @@ -211,7 +285,9 @@ pub unsafe fn install_send_hook() -> bool { 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; } + 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). @@ -219,7 +295,10 @@ pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flag 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)])); + crate::write_log(&format!( + "CAP recv<-anadius s={s} n={n}: {}\n", + &text[..text.len().min(2400)] + )); } n } @@ -228,10 +307,15 @@ pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, fl 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)])); + 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; } + 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/ssl_patch.rs b/openfut-hook/src/ssl_patch.rs index 4923076..59c3e02 100644 --- a/openfut-hook/src/ssl_patch.rs +++ b/openfut-hook/src/ssl_patch.rs @@ -9,11 +9,9 @@ // server's certificate chain. Always returning 1 is equivalent to trusting all certs, // which is the behaviour we want for the local self-signed bridge certificate. -use windows_sys::Win32::{ - System::{ - LibraryLoader::GetModuleHandleA, - Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}, - }, +use windows_sys::Win32::System::{ + LibraryLoader::GetModuleHandleA, + Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}, }; // Unique 22-byte prologue of ProtoSSL's cert-verify function. @@ -21,24 +19,26 @@ use windows_sys::Win32::{ const PROLOGUE: &[u8] = &[ 0x44, 0x89, 0x44, 0x24, 0x18, // mov [rsp+0x18], r8d 0x48, 0x89, 0x54, 0x24, 0x10, // mov [rsp+0x10], rdx - 0x56, // push rsi - 0x57, // push rdi - 0x41, 0x55, // push r13 - 0x41, 0x56, // push r14 - 0x41, 0x57, // push r15 - 0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30 + 0x56, // push rsi + 0x57, // push rdi + 0x41, 0x55, // push r13 + 0x41, 0x56, // push r14 + 0x41, 0x57, // push r15 + 0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30 ]; // Return 0 (PROTOSSL_ERROR_NONE = success). ProtoSSL convention: 0 = ok, negative = error. // The function sets r15d = 0xFFFFFFFF (-1) for its own error returns, confirming 0 = success. const PATCH: &[u8] = &[ - 0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE) - 0xc3, // ret - 0x90, 0x90, 0x90, // nop padding + 0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE) + 0xc3, // ret + 0x90, 0x90, 0x90, // nop padding ]; fn patch_module(module: isize, scan_bytes: usize) -> bool { - if module == 0 { return false; } + if module == 0 { + return false; + } let base = module as usize; let image: &[u8] = unsafe { core::slice::from_raw_parts(base as *const u8, scan_bytes) }; let offset = match image.windows(PROLOGUE.len()).position(|w| w == PROLOGUE) { @@ -48,9 +48,19 @@ fn patch_module(module: isize, scan_bytes: usize) -> bool { let target = (base + offset) as *mut u8; let mut old_prot: u32 = 0; unsafe { - VirtualProtect(target as *const core::ffi::c_void, PATCH.len(), PAGE_EXECUTE_READWRITE, &mut old_prot); + VirtualProtect( + target as *const core::ffi::c_void, + PATCH.len(), + PAGE_EXECUTE_READWRITE, + &mut old_prot, + ); core::ptr::copy_nonoverlapping(PATCH.as_ptr(), target, PATCH.len()); - VirtualProtect(target as *const core::ffi::c_void, PATCH.len(), old_prot, &mut old_prot); + VirtualProtect( + target as *const core::ffi::c_void, + PATCH.len(), + old_prot, + &mut old_prot, + ); } true } diff --git a/openfut-hook/src/tls_bypass.rs b/openfut-hook/src/tls_bypass.rs index fdca730..8e53f78 100644 --- a/openfut-hook/src/tls_bypass.rs +++ b/openfut-hook/src/tls_bypass.rs @@ -4,10 +4,10 @@ use windows_sys::Win32::Foundation::BOOL; // CERT_CHAIN_POLICY_STATUS.dwError offset 0 = u32 error code; 0 = success. // We use raw pointers to avoid pulling in the full Cryptography struct tree. type CertVerifyChainPolicyFn = unsafe extern "system" fn( - *const u8, // pszPolicyOID - *const (), // pChainContext - *const (), // pPolicyPara - *mut u32, // &mut pPolicyStatus.dwError (first field) + *const u8, // pszPolicyOID + *const (), // pChainContext + *const (), // pPolicyPara + *mut u32, // &mut pPolicyStatus.dwError (first field) ) -> BOOL; static REAL: OnceLock = OnceLock::new(); @@ -25,7 +25,12 @@ pub unsafe extern "system" fn hooked_cert_verify_chain_policy( p_policy_status: *mut u32, ) -> BOOL { if let Some(real) = REAL.get().copied() { - real(psz_policy_oid, p_chain_context, p_policy_para, p_policy_status); + real( + psz_policy_oid, + p_chain_context, + p_policy_para, + p_policy_status, + ); } // Clear the error field of CERT_CHAIN_POLICY_STATUS regardless if !p_policy_status.is_null() { diff --git a/openfut-hook/src/transport_watch.rs b/openfut-hook/src/transport_watch.rs index cfd573f..a7cf2e9 100644 --- a/openfut-hook/src/transport_watch.rs +++ b/openfut-hook/src/transport_watch.rs @@ -83,7 +83,9 @@ pub fn note_getaddrinfo(host: &str) { } else { "" }; - crate::write_log(&format!("TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n")); + crate::write_log(&format!( + "TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n" + )); } const AF_INET: u16 = 2; // IPv4 diff --git a/src/process.rs b/src/process.rs index aef6869..d519e98 100644 --- a/src/process.rs +++ b/src/process.rs @@ -66,9 +66,8 @@ impl ServiceHandle { } cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); - let mut child = cmd.spawn().map_err(|e| { + let mut child = cmd.spawn().inspect_err(|e| { *self.status.lock().unwrap() = ServiceStatus::Failed(e.to_string()); - e })?; // Drain stdout From 958ff245465dd1f09e1c921178fbf82ab74131c6 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 8 Aug 2026 17:49:00 -0700 Subject: [PATCH 9/9] feat(hook): add SBC request tracing instrumentation - sbc_hook.rs: trace SBC submission/response flow with request IDs - sbc_request_trace.rs: capture request/response bodies for analysis - sbc_trace.rs: runtime trace buffer with structured logging Work in progress - needs validation against live FIFA 17 client --- openfut-hook/src/sbc_hook.rs | 236 +++++++++++++++++++++++++- openfut-hook/src/sbc_request_trace.rs | 60 ++++++- openfut-hook/src/sbc_trace.rs | 149 +++++++++++++++- 3 files changed, 441 insertions(+), 4 deletions(-) diff --git a/openfut-hook/src/sbc_hook.rs b/openfut-hook/src/sbc_hook.rs index 410b7d5..d9e3111 100644 --- a/openfut-hook/src/sbc_hook.rs +++ b/openfut-hook/src/sbc_hook.rs @@ -8,6 +8,7 @@ //! with this module compiled in changes nothing unless a var is set: //! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY) //! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY) +//! OPENFUT_SBC_COMMIT=1 -> after proven native parse success, arm populated M //! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns) //! //! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we @@ -19,11 +20,14 @@ //! See the spec for the verified disassembly behind each one. use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; use windows_sys::Win32::System::Memory::{ - VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE, - PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY, + VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, + PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, + PAGE_WRITECOPY, }; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId}; // ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ──────────── const IMAGE_BASE: usize = 0x180000000; @@ -42,6 +46,13 @@ const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate) const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5) const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap) const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count +const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820; +const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888; +const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138; +const SBC_CONTROLLER_MODEL_OFF: usize = 0x140; +const SBC_COMPLETION_STATUS_JNE_RVA: usize = 0x0b8962; +const SBC_COMPLETION_STATUS_JNE: [u8; 2] = [0x75, 0x48]; +const SBC_COMPLETION_STATUS_FALLTHROUGH: [u8; 2] = [0x90, 0x90]; const B_DTOR_RVA: usize = 0x63040; const B_ISVALID_RVA: usize = 0x65d40; const B_CLEAR_RVA: usize = 0x65d20; @@ -76,9 +87,11 @@ mod rva { static ARMED: AtomicBool = AtomicBool::new(false); static ARM_ONLY: AtomicBool = AtomicBool::new(false); +static COMMIT: AtomicBool = AtomicBool::new(false); static POPULATE: AtomicBool = AtomicBool::new(false); static DONE: AtomicBool = AtomicBool::new(false); static CARDS_BASE: AtomicUsize = AtomicUsize::new(0); +static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0); static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize); #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -134,6 +147,13 @@ enum ValidationError { CollectionUnreadable, CollectionNotNull, ReadyByteNotWritable, + ModelEmpty, + ControllerMissing, + ControllerVtableMismatch, + ControllerModelMismatch, + CompletionBranchMismatch, + CompletionBranchProtectFailed, + CompletionBranchFlushFailed, } #[derive(Clone, Copy, Debug)] @@ -263,6 +283,26 @@ unsafe fn writable_u8(ptr: usize) -> bool { .is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)) } +unsafe fn executable_range(ptr: usize, len: usize) -> bool { + let Some(end) = ptr.checked_add(len) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); + if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { + return false; + } + let protection = mbi.Protect & 0xff; + matches!( + protection, + PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY + ) && end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize) +} + /// Guarded 16-bit read (M category count is a WORD). unsafe fn read_u16(ptr: usize) -> Option { let lo = read_u8(ptr)? as u16; @@ -388,6 +428,12 @@ pub fn install() { .unwrap_or(false), Ordering::Relaxed, ); + COMMIT.store( + std::env::var("OPENFUT_SBC_COMMIT") + .map(|v| v == "1") + .unwrap_or(false), + Ordering::Relaxed, + ); POPULATE.store( std::env::var("OPENFUT_SBC_POPULATE") .map(|v| v == "1") @@ -398,6 +444,192 @@ pub fn install() { std::thread::spawn(|| unsafe { worker() }); } +/// Records the concrete SBC controller observed registering FUT_SBS_CATEGORIES. +/// The registration hook is observational; all structural checks happen again on +/// the notifier thread before this address is trusted. +pub(crate) unsafe fn note_sbc_controller(controller: usize) { + let base = CARDS_BASE.load(Ordering::Acquire); + let valid = base != 0 + && read_ptr(controller) == base.checked_add(SBC_CONTROLLER_VTABLE_RVA) + && controller + .checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF) + .and_then(|p| read_ptr(p)) + == base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA); + if valid { + SBC_CONTROLLER.store(controller, Ordering::Release); + crate::write_log(&format!( + "SBC_CONTROLLER_TRACE: captured controller={controller:#x}\n" + )); + } else { + crate::write_log(&format!( + "SBC_CONTROLLER_TRACE: rejected controller={controller:#x} (vtable mismatch)\n" + )); + } +} + +unsafe fn log_controller_model(native_model: usize) { + let controller = SBC_CONTROLLER.load(Ordering::Acquire); + let controller_model = controller + .checked_add(SBC_CONTROLLER_MODEL_OFF) + .and_then(|p| read_ptr(p)) + .unwrap_or(0); + let main_vtable = read_ptr(controller).unwrap_or(0); + let event_vtable = controller + .checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF) + .and_then(|p| read_ptr(p)) + .unwrap_or(0); + crate::write_log(&format!( + "SBC_CONTROLLER_TRACE: notifier controller={controller:#x} main_vt={main_vtable:#x} event_vt={event_vtable:#x} controller_M={controller_model:#x} parsed_M={native_model:#x} match={}\n", + controller != 0 && controller_model == native_model, + )); +} + +unsafe fn validated_sbc_controller( + base: usize, + native_model: usize, +) -> Result { + let controller = SBC_CONTROLLER.load(Ordering::Acquire); + if controller == 0 { + return Err(ValidationError::ControllerMissing); + } + if read_ptr(controller) != base.checked_add(SBC_CONTROLLER_VTABLE_RVA) + || controller + .checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF) + .and_then(|p| read_ptr(p)) + != base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA) + { + return Err(ValidationError::ControllerVtableMismatch); + } + if controller + .checked_add(SBC_CONTROLLER_MODEL_OFF) + .and_then(|p| read_ptr(p)) + != Some(native_model) + { + return Err(ValidationError::ControllerModelMismatch); + } + Ok(controller) +} + +/// Route the already-scheduled category completion through CardsDLL's own success +/// branch. The original function first rejects a non-zero status with a two-byte +/// `jne ServerErrSets`; after a separately proven native parse, that status belongs +/// to the stale scheduler completion rather than the category HTTP transaction. +unsafe fn arm_native_completion_success(base: usize) -> Result<(), ValidationError> { + let target = base + .checked_add(SBC_COMPLETION_STATUS_JNE_RVA) + .ok_or(ValidationError::AddressOverflow)?; + if !executable_range(target, SBC_COMPLETION_STATUS_JNE.len()) + || core::slice::from_raw_parts(target as *const u8, SBC_COMPLETION_STATUS_JNE.len()) + != SBC_COMPLETION_STATUS_JNE + { + return Err(ValidationError::CompletionBranchMismatch); + } + let mut old = 0u32; + if VirtualProtect( + target as _, + SBC_COMPLETION_STATUS_FALLTHROUGH.len(), + PAGE_EXECUTE_READWRITE, + &mut old, + ) == 0 + { + return Err(ValidationError::CompletionBranchProtectFailed); + } + core::ptr::copy_nonoverlapping( + SBC_COMPLETION_STATUS_FALLTHROUGH.as_ptr(), + target as *mut u8, + SBC_COMPLETION_STATUS_FALLTHROUGH.len(), + ); + let flushed = FlushInstructionCache( + GetCurrentProcess(), + target as _, + SBC_COMPLETION_STATUS_FALLTHROUGH.len(), + ) != 0; + let mut ignored = 0u32; + let protected = VirtualProtect( + target as _, + SBC_COMPLETION_STATUS_FALLTHROUGH.len(), + old, + &mut ignored, + ) != 0; + if !flushed || !protected { + return Err(ValidationError::CompletionBranchFlushFailed); + } + crate::write_log(&format!( + "SBC_HOOK: armed native completion success branch at {target:#x} tid={}\n", + GetCurrentThreadId(), + )); + Ok(()) +} + +/// Commit the already-populated native SBC model after the category success notifier. +/// +/// This is called synchronously by the passive notifier wrapper *after* the original +/// notifier returns. It never invokes a parser or constructs game objects. The only +/// mutation is the established cache-ready byte, and only when the normal parser has +/// produced at least one category and every pointer/vtable invariant still matches. +pub(crate) unsafe fn commit_after_native_parse() { + if !COMMIT.load(Ordering::Acquire) { + return; + } + let base = CARDS_BASE.load(Ordering::Acquire); + if base == 0 || !control_matches(base) { + set_failed(ValidationError::AUnreadable); + return; + } + let snapshot = match runtime_snapshot(base).and_then(|snapshot| { + validate_snapshot(base, &snapshot)?; + if snapshot.m == 0 + || read_u16(snapshot.m + M_COUNT_OFF) + .filter(|&count| count > 0) + .is_none() + { + return Err(ValidationError::ModelEmpty); + } + if !writable_u8(snapshot.b + B_READY_OFF) { + return Err(ValidationError::ReadyByteNotWritable); + } + Ok(snapshot) + }) { + Ok(snapshot) => snapshot, + Err(error) => { + set_failed(error); + return; + } + }; + let count = read_u16(snapshot.m + M_COUNT_OFF).unwrap_or(0); + log_controller_model(snapshot.m); + if DONE.swap(true, Ordering::AcqRel) { + return; + } + crate::write_log(&format!( + "SBC_HOOK: post-parse commit -> M={:#x} categories={} BYTE[{:#x}]=1\n", + snapshot.m, + count, + snapshot.b + B_READY_OFF, + )); + core::ptr::write_volatile((snapshot.b + B_READY_OFF) as *mut u8, 1); + if read_u8(snapshot.b + B_READY_OFF) != Some(1) + || !transition(RuntimeState::Validated, RuntimeState::Committed) + { + set_failed(ValidationError::ReadyByteUnexpected); + return; + } + let _controller = match validated_sbc_controller(base, snapshot.m) { + Ok(controller) => controller, + Err(error) => { + set_failed(error); + return; + } + }; + if let Err(error) = arm_native_completion_success(base) { + set_failed(error); + return; + } + crate::write_log( + "SBC_HOOK: post-parse commit DONE; awaiting CardsDLL native completion events\n", + ); +} + /// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when /// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm) /// exactly once. diff --git a/openfut-hook/src/sbc_request_trace.rs b/openfut-hook/src/sbc_request_trace.rs index fe8691f..df6a951 100644 --- a/openfut-hook/src/sbc_request_trace.rs +++ b/openfut-hook/src/sbc_request_trace.rs @@ -55,6 +55,12 @@ static CONSUMER_88: AtomicUsize = AtomicUsize::new(0); static RESPONSE_VTABLE_88: AtomicUsize = AtomicUsize::new(0); static OWNER_SLOT_BEFORE_88: AtomicUsize = AtomicUsize::new(0); static OWNER_SLOT_AFTER_88: AtomicUsize = AtomicUsize::new(0); +static OWNER_INNER_88: AtomicUsize = AtomicUsize::new(0); +static OWNER_STATE_BEFORE_88: AtomicUsize = AtomicUsize::new(usize::MAX); +static OWNER_STATE_AFTER_88: AtomicUsize = AtomicUsize::new(usize::MAX); +static OWNER_FLAGS_88: AtomicUsize = AtomicUsize::new(usize::MAX); +static OWNER_MANAGER_88: AtomicUsize = AtomicUsize::new(0); +static OWNER_MANAGER_STATE_88: AtomicUsize = AtomicUsize::new(usize::MAX); fn enabled(value: Option<&str>) -> bool { matches!(value, Some("1")) @@ -130,6 +136,22 @@ unsafe fn guarded_ptr(address: usize) -> usize { } } +unsafe fn guarded_u32(address: usize) -> Option { + if readable_range(address, 4) { + Some(core::ptr::read_volatile(address as *const u32)) + } else { + None + } +} + +unsafe fn guarded_u8(address: usize) -> Option { + if readable_range(address, 1) { + Some(core::ptr::read_volatile(address as *const u8)) + } else { + None + } +} + unsafe fn field_ptr(object: usize, offset: usize) -> usize { object .checked_add(offset) @@ -149,14 +171,44 @@ unsafe extern "system" fn wrapper_88(request: *mut c_void, argument: *mut c_void let consumer = field_ptr(owner_vtable, 0x18); let owner_slot_before = guarded_ptr(argument_address); let response_vtable = guarded_ptr(owner_slot_before); + let owner_inner = field_ptr(owner, 8); + let owner_state_before = owner_inner + .checked_add(8) + .and_then(|p| guarded_u32(p)) + .map(|v| v as usize) + .unwrap_or(usize::MAX); + let owner_flags = owner_inner + .checked_add(0x0c) + .and_then(|p| guarded_u8(p)) + .map(|v| v as usize) + .unwrap_or(usize::MAX); + let owner_manager = field_ptr(owner_inner, 0x14d0); + let owner_manager_state = owner_manager + .checked_add(0x1dc0) + .and_then(|p| guarded_u32(p)) + .map(|v| v as usize) + .unwrap_or(usize::MAX); OWNER_88.store(owner, Ordering::Relaxed); OWNER_VTABLE_88.store(owner_vtable, Ordering::Relaxed); CONSUMER_88.store(consumer, Ordering::Relaxed); RESPONSE_VTABLE_88.store(response_vtable, Ordering::Relaxed); OWNER_SLOT_BEFORE_88.store(owner_slot_before, Ordering::Relaxed); + OWNER_INNER_88.store(owner_inner, Ordering::Relaxed); + OWNER_STATE_BEFORE_88.store(owner_state_before, Ordering::Relaxed); + OWNER_FLAGS_88.store(owner_flags, Ordering::Relaxed); + OWNER_MANAGER_88.store(owner_manager, Ordering::Relaxed); + OWNER_MANAGER_STATE_88.store(owner_manager_state, Ordering::Relaxed); let original: Callback88 = core::mem::transmute(ORIGINAL_88.load(Ordering::Acquire)); original(request, argument); OWNER_SLOT_AFTER_88.store(guarded_ptr(argument_address), Ordering::Relaxed); + OWNER_STATE_AFTER_88.store( + owner_inner + .checked_add(8) + .and_then(|p| guarded_u32(p)) + .map(|v| v as usize) + .unwrap_or(usize::MAX), + Ordering::Relaxed, + ); EXIT_88.fetch_add(1, Ordering::Release); } @@ -334,7 +386,7 @@ unsafe fn worker() { let count_90 = ENTER_90.load(Ordering::Acquire); if count_88 != seen_88 || count_90 != seen_90 { crate::write_log(&format!( - "SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n", + "SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x} inner={:#x} state={}->{} flags={:#x} manager={:#x} manager_state={}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n", count_88, EXIT_88.load(Ordering::Acquire), LAST_REQUEST_88.load(Ordering::Relaxed), @@ -346,6 +398,12 @@ unsafe fn worker() { RESPONSE_VTABLE_88.load(Ordering::Relaxed), OWNER_SLOT_BEFORE_88.load(Ordering::Relaxed), OWNER_SLOT_AFTER_88.load(Ordering::Relaxed), + OWNER_INNER_88.load(Ordering::Relaxed), + OWNER_STATE_BEFORE_88.load(Ordering::Relaxed), + OWNER_STATE_AFTER_88.load(Ordering::Relaxed), + OWNER_FLAGS_88.load(Ordering::Relaxed), + OWNER_MANAGER_88.load(Ordering::Relaxed), + OWNER_MANAGER_STATE_88.load(Ordering::Relaxed), count_90, EXIT_90.load(Ordering::Acquire), LAST_REQUEST_90.load(Ordering::Relaxed), diff --git a/openfut-hook/src/sbc_trace.rs b/openfut-hook/src/sbc_trace.rs index cd389d2..35588d8 100644 --- a/openfut-hook/src/sbc_trace.rs +++ b/openfut-hook/src/sbc_trace.rs @@ -32,6 +32,12 @@ const ABS_JUMP_LEN: usize = 14; const TRAMPOLINE_LEN: usize = COPY_LEN + ABS_JUMP_LEN; const NOTIFIER_RVA: usize = 0x17aa80; const NOTIFIER_COPY_LEN: usize = 15; +const CONTROLLER_REGISTER_RVA: usize = 0x1a4a70; +const CONTROLLER_REGISTER_COPY_LEN: usize = 15; +const FUT_SBS_CATEGORIES_EVENT: u32 = 0x756c; +const CONTROLLER_REGISTER_SIGNATURE: [u8; CONTROLLER_REGISTER_COPY_LEN] = [ + 0x89, 0x54, 0x24, 0x10, 0x48, 0x83, 0xec, 0x28, 0x4c, 0x8d, 0x81, 0xc0, 0x00, 0x00, 0x00, +]; const NOTIFIER_SIGNATURE: [u8; 32] = [ 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48, 0x8b, 0x59, 0x58, 0x48, 0x8b, 0x71, 0x60, 0x33, 0xff, 0x48, 0x2b, 0xf3, 0xc6, 0x81, 0x88, 0x00, @@ -75,6 +81,7 @@ static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0); static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0); static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX); static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static CONTROLLER_REGISTER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0); static NOTIFIER_EXITS: AtomicU64 = AtomicU64::new(0); static NOTIFIER_CTX: AtomicUsize = AtomicUsize::new(0); @@ -521,6 +528,77 @@ unsafe fn write_notifier_entry( } } +unsafe fn restore_controller_register_entry(target: usize) -> bool { + let mut old = 0u32; + if VirtualProtect( + target as _, + CONTROLLER_REGISTER_COPY_LEN, + PAGE_EXECUTE_READWRITE, + &mut old, + ) == 0 + { + return false; + } + core::ptr::copy_nonoverlapping( + CONTROLLER_REGISTER_SIGNATURE.as_ptr(), + target as *mut u8, + CONTROLLER_REGISTER_COPY_LEN, + ); + let flushed = FlushInstructionCache( + GetCurrentProcess(), + target as _, + CONTROLLER_REGISTER_COPY_LEN, + ) != 0; + let mut ignored = 0u32; + flushed && VirtualProtect(target as _, CONTROLLER_REGISTER_COPY_LEN, old, &mut ignored) != 0 +} + +unsafe fn write_controller_register_entry( + target: usize, + destination: usize, + published: &mut bool, +) -> Result<(), bool> { + let mut patch = [0x90u8; CONTROLLER_REGISTER_COPY_LEN]; + patch[..ABS_JUMP_LEN].copy_from_slice(&absolute_jump(destination)); + let mut old = 0u32; + if VirtualProtect( + target as _, + CONTROLLER_REGISTER_COPY_LEN, + PAGE_EXECUTE_READWRITE, + &mut old, + ) == 0 + { + return Err(true); + } + *published = true; + core::ptr::copy_nonoverlapping( + patch.as_ptr(), + target as *mut u8, + CONTROLLER_REGISTER_COPY_LEN, + ); + let flushed = FlushInstructionCache( + GetCurrentProcess(), + target as _, + CONTROLLER_REGISTER_COPY_LEN, + ) != 0; + let mut ignored = 0u32; + if flushed && VirtualProtect(target as _, CONTROLLER_REGISTER_COPY_LEN, old, &mut ignored) != 0 + { + Ok(()) + } else { + Err(restore_controller_register_entry(target)) + } +} + +unsafe extern "system" fn controller_register_wrapper(controller: *mut c_void, event: u32) { + let original: unsafe extern "system" fn(*mut c_void, u32) = + core::mem::transmute(CONTROLLER_REGISTER_TRAMPOLINE.load(Ordering::Acquire)); + original(controller, event); + if event == FUT_SBS_CATEGORIES_EVENT { + crate::sbc_hook::note_sbc_controller(controller as usize); + } +} + unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) { NOTIFIER_ENTRIES.fetch_add(1, Ordering::Relaxed); let address = ctx as usize; @@ -552,6 +630,7 @@ unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) { let original: unsafe extern "system" fn(*mut c_void) = core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire)); original(ctx); + crate::sbc_hook::commit_after_native_parse(); NOTIFIER_BYTE_AFTER.store( address .checked_add(0x88) @@ -946,10 +1025,78 @@ unsafe fn notifier_worker() { crate::write_log("SBC_NOTIFIER_TRACE: report cap reached; hook remains passive\n"); } +unsafe fn controller_register_worker() { + let _pending = CodeInstallerPending; + let mut base = 0usize; + for _ in 0..600u32 { + base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize; + if base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + let Some(target) = target_va(base, CONTROLLER_REGISTER_RVA) else { + crate::write_log("SBC_CONTROLLER_TRACE: target resolution failed; inactive\n"); + return; + }; + if base == 0 + || !valid_cards_image(base) + || !executable_range_in_image(base, target, CONTROLLER_REGISTER_SIGNATURE.len()) + || core::slice::from_raw_parts(target as *const u8, CONTROLLER_REGISTER_SIGNATURE.len()) + != CONTROLLER_REGISTER_SIGNATURE + { + crate::write_log("SBC_CONTROLLER_TRACE: PE/signature validation failed; inactive\n"); + return; + } + let Some(trampoline) = allocate_trampoline(target, CONTROLLER_REGISTER_COPY_LEN) else { + crate::write_log("SBC_CONTROLLER_TRACE: trampoline allocation failed; inactive\n"); + return; + }; + CONTROLLER_REGISTER_TRAMPOLINE.store(trampoline, Ordering::Release); + let Some(_installer_gate) = acquire_patch_installer_gate() else { + VirtualFree(trampoline as _, 0, MEM_RELEASE); + CONTROLLER_REGISTER_TRAMPOLINE.store(0, Ordering::Release); + crate::write_log("SBC_CONTROLLER_TRACE: installer gate timeout; inactive\n"); + return; + }; + let mut peers = match suspend_peers(target, target) { + Ok(peers) => peers, + Err(_) => { + crate::write_log( + "SBC_CONTROLLER_TRACE: quiescence failed; terminate game if unresponsive\n", + ); + return; + } + }; + let mut published = false; + let installed = write_controller_register_entry( + target, + controller_register_wrapper as *const () as usize, + &mut published, + ) + .is_ok(); + let resumed = peers.resume_all(); + drop(_installer_gate); + drop(_pending); + if !installed || !resumed { + if !published && resumed { + VirtualFree(trampoline as _, 0, MEM_RELEASE); + CONTROLLER_REGISTER_TRAMPOLINE.store(0, Ordering::Release); + crate::write_log("SBC_CONTROLLER_TRACE: clean install failure; inactive\n"); + } else { + crate::write_log("SBC_CONTROLLER_TRACE: DEGRADED state; terminate game now\n"); + } + return; + } + crate::write_log("SBC_CONTROLLER_TRACE: category controller registration hook installed\n"); +} + fn install_notifier(enabled: bool) { if enabled { crate::write_log("SBC_NOTIFIER_TRACE: requested; deferred install starting\n"); std::thread::spawn(|| unsafe { notifier_worker() }); + crate::write_log("SBC_CONTROLLER_TRACE: requested; deferred install starting\n"); + std::thread::spawn(|| unsafe { controller_register_worker() }); } else { crate::write_log("SBC_NOTIFIER_TRACE: disabled\n"); } @@ -959,7 +1106,7 @@ pub(crate) fn install() { let enabled = env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref()); let notifier_enabled = env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref()); CODE_PATCH_PENDING.store( - enabled as usize + notifier_enabled as usize, + enabled as usize + (notifier_enabled as usize * 2), Ordering::Release, ); install_notifier(notifier_enabled);