probe: retarget to game-side connect layer + add guarded session-context sampler

Replaces the anadius-connectivity probes with the game-side Nucleus-connect
functions (nucleusConnectREST/Trusted, connect-state tick) and adds a
VirtualQuery-guarded sampler thread that reads X=[0x14acd02c0] -> M=[X+0x360]
-> ctx=[M+0x778] once/sec to observe the session context directly. Per-slot
log cap prevents per-frame handlers flooding the log. Run 3 result: ctx is
non-null but the connect functions are never called (see bridge findings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-02 17:56:31 -07:00
parent 3c3dc32fc6
commit c1d1f03ec8
+103 -15
View File
@@ -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<usize> {
if ptr < 0x10000 || ptr & 7 != 0 {
return None;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(ptr as _, &mut mbi, core::mem::size_of::<MEMORY_BASIC_INFORMATION>());
if n == 0 || mbi.State != MEM_COMMIT {
return None;
}
if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return None;
}
// 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=<unreadable>".to_string(),
Some(0) => "X=null".to_string(),
Some(x) => match read_ptr(x + 0x360) {
None => format!("X={x:#x} M=<unreadable>"),
Some(0) => format!("X={x:#x} M=null"),
Some(m) => match read_ptr(m + 0x778) {
None => format!("X={x:#x} M={m:#x} ctx=<unreadable>"),
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();
});
}