From c1d1f03ec894db43e90d8ad55668b053fe68a2be Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 2 Jul 2026 17:56:31 -0700 Subject: [PATCH] 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(); }); }