Files
OpenFUT/openfut-hook/src/probe.rs
T
2026-08-07 12:03:21 -07:00

1748 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! In-process RE probes: passive logging detours on FIFA's online-flow functions.
//!
//! Purpose: FIFA reacts to our pushed LSX events (OnlineStatusEvent/Login) but
//! never starts the GetAuthCode→Nucleus→Blaze chain, and that decision lives in
//! FIFA's game-side / in-process EbisuSDK logic that is invisible from the LSX
//! wire. These probes log when the key online-flow functions are called (and
//! their return values), so we can SEE where FIFA stalls after our events.
//!
//! Mechanism: the same unhook/rehook detour `connect_hook` uses — on entry we
//! restore the original bytes, log, call the real function, then re-install the
//! jump. This needs no trampoline/relocation, so it works even on prologues with
//! RIP-relative operands (e.g. GoOnline). Not thread-safe (a concurrent call
//! during the unhook window runs un-logged) but never corrupts the target — fine
//! for read-only RE.
//!
//! Signature assumption: each probed fn takes ≤4 integer args (Win64: rcx/rdx/
//! r8/r9) and returns in rax. All targets here are SDK methods with few args.
use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use windows_sys::Win32::System::Memory::{
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, MEM_PRIVATE,
PAGE_EXECUTE_READWRITE, PAGE_GUARD, PAGE_NOACCESS,
};
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
/// Fault-safe pointer read: returns None unless `ptr` lands in a committed, readable
/// page (checked via VirtualQuery). Avoids crashing FIFA when we sample pointers that
/// may be null/garbage mid-construction.
unsafe fn read_ptr(ptr: usize) -> Option<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 ────────────────────────────────────────────
//
// OnlineStatusEventT::HandleMessage dispatches the parsed bool to the game's online
// listener via a virtual call `call [rax+0x28]` at FIFA23.exe+0x274d4e2, where rax
// is the vtable of the object at [rsi-0x38]. The concrete listener is only known at
// runtime. We capture it with a behavior-preserving mid-function detour: patch the
// 14 bytes at +0x274d4d7 (which are exactly `lea rcx,[rsi-0x38]; mov rax,[rcx]; lea
// rdx,[rbp-0x49]; call [rax+0x28]`) to jump to a stub that replicates those four
// instructions but logs the resolved listener address in between, then resumes at
// +0x274d4e5. Non-volatile regs (rsi/rbp/…) are preserved by the ABI; volatiles
// match the original dispatch's clobbers.
/// Runtime absolute address to resume at after the replicated dispatch
/// (main-exe base + 0x274d4e5). Read by the asm stub.
#[no_mangle]
static mut RESUME_ADDR: u64 = 0;
static MAIN_BASE: AtomicUsize = AtomicUsize::new(0);
static LISTENER_LOGGED: AtomicBool = AtomicBool::new(false);
/// Called by the stub with the listener object's vtable and the resolved listener
/// function pointer (vtable[0x28]). Logs once (RVAs for static RE).
unsafe extern "C" fn listener_log(vtable: usize, func: usize) {
// Run the dial trigger on EVERY event dispatch — it self-gates internally (kill
// switch, one-shot latch, precondition checks). This must run BEFORE the
// LISTENER_LOGGED one-shot below, which returns on all but the very first fire.
dial_trigger_tick();
connmgr_enum_tick();
elem_watch_tick();
if LISTENER_LOGGED.swap(true, Ordering::Relaxed) {
return;
}
let base = MAIN_BASE.load(Ordering::Relaxed);
crate::write_log(&format!(
"PROBE OnlineStatus.listener: vtable={vtable:#x} (rva {:#x}) fn={func:#x} (rva {:#x})\n",
vtable.wrapping_sub(base),
func.wrapping_sub(base),
));
}
core::arch::global_asm!(
".intel_syntax noprefix",
".global openfut_listener_stub",
"openfut_listener_stub:",
"lea rcx, [rsi - 0x38]",
"mov rax, [rcx]", // rax = listener vtable
"mov rdx, [rax + 0x28]", // rdx = listener fn (arg2)
"mov rcx, rax", // rcx = vtable (arg1)
// Align the stack to 16 before the call, saving the original rsp so we can
// restore it (0x28 misaligns; SSE code in the logger then faults).
"mov r11, rsp",
"and rsp, -16",
"sub rsp, 0x30", // 0x20 shadow + 0x10 spare, stays 16-aligned
"mov [rsp + 0x20], r11", // stash original rsp
"call {log}",
"mov rsp, [rsp + 0x20]", // restore original rsp
"lea rcx, [rsi - 0x38]", // replicate the original dispatch
"mov rax, [rcx]",
"lea rdx, [rbp - 0x49]",
"call qword ptr [rax + 0x28]",
"mov r10, qword ptr [rip + {resume}]",
"jmp r10",
".att_syntax prefix",
log = sym listener_log,
resume = sym RESUME_ADDR,
);
extern "C" {
fn openfut_listener_stub();
}
/// Patch the OnlineStatusEvent dispatch site to route through the logging stub.
pub unsafe fn install_listener_probe() {
let base = GetModuleHandleA(core::ptr::null());
if base.is_null() {
crate::write_log("PROBE listener: main exe not found\n");
return;
}
let base = base as usize;
MAIN_BASE.store(base, Ordering::Relaxed);
// Arm the dial trigger from the env var, ONCE, at install (DLL-load) time. Default
// disarmed: OPENFUT_DIAL_TRIGGER must be explicitly "1". Orthogonal to the pump/ctx
// env vars.
let armed = std::env::var("OPENFUT_DIAL_TRIGGER")
.map(|v| v == "1")
.unwrap_or(false);
DIAL_ARMED.store(armed, Ordering::Relaxed);
crate::write_log(&format!(
"DIAL_TRIGGER: {} (env OPENFUT_DIAL_TRIGGER)\n",
if armed { "ARMED" } else { "disarmed" }
));
// Arm the (independent) connMgr enumeration from its own env var, once, at load.
let enum_armed = std::env::var("OPENFUT_CONNMGR_ENUM")
.map(|v| v == "1")
.unwrap_or(false);
CONNMGR_ENUM_ARMED.store(enum_armed, Ordering::Relaxed);
crate::write_log(&format!(
"CONNMGR_ENUM: {} (env OPENFUT_CONNMGR_ENUM)\n",
if enum_armed { "ARMED" } else { "disarmed" }
));
// Arm the (independent) [element+0x40] container-writer watchpoint from its own
// env var, once, at load. Orthogonal to DIAL_TRIGGER / CONNMGR_ENUM.
let elem_watch_armed = std::env::var("OPENFUT_ELEM_WATCH")
.map(|v| v == "1")
.unwrap_or(false);
ELEM_WATCH_ARMED.store(elem_watch_armed, Ordering::Relaxed);
crate::write_log(&format!(
"ELEM_WATCH: {} (env OPENFUT_ELEM_WATCH)\n",
if elem_watch_armed {
"ARMED"
} else {
"disarmed"
}
));
RESUME_ADDR = (base + 0x274d4e5) as u64;
let target = (base + 0x274d4d7) as *mut u8;
write_jmp(target, openfut_listener_stub as usize as u64);
crate::write_log(&format!(
"PROBE listener: dispatch site patched @ {:#x}\n",
target as usize
));
}
// ─── dial trigger (sub-phase B) ──────────────────────────────────────────────────
//
// Extends the listener probe (NOT a new detour): `dial_trigger_tick()` runs on every
// OnlineStatusEvent dispatch — on the game's online-servicing thread — and, once every
// precondition lines up, calls the redirector dial handler 0x144f4d360 directly with
// (connMgr, synthetic-notification). One-shot, env-gated, heavily guarded and logged.
//
// Addresses (all from prior confirmed reports; RVA = VA base, base = main-exe module):
// dial handler base+0x4f4d360 (VA 0x144f4d360) rcx=connMgr, rdx=notification
// NetConnStatus base+0xef17f0 (VA 0x140ef17f0) ecx='conn' -> eax status
// G (OriginSDK global) base+0xacd02c0 (VA 0x14acd02c0)
// connMgr vtable base+0x80200b8; M=*[G+0x360]; ctx=*[M+0x778]; connMgr scan.
/// Kill switch, read once at DLL load from OPENFUT_DIAL_TRIGGER (see install_listener_probe).
static DIAL_ARMED: AtomicBool = AtomicBool::new(false);
/// One-shot latch. Claimed (false→true) immediately BEFORE the dial call so a re-entrant
/// dispatch can't double-fire; also set on the FATAL sanity failure.
static DIAL_TRIGGER_FIRED: AtomicBool = AtomicBool::new(false);
/// Thread id of the first listener-probe fire (the online-servicing thread). 0 until seen.
static LISTENER_TID: AtomicU32 = AtomicU32::new(0);
/// Last completion-stub count we logged, so we only log on change.
static LAST_COMPLETION_COUNT: AtomicU32 = AtomicU32::new(0);
/// Rate-limit state for skip logging: the reason currently being counted, and how many.
static LAST_SKIP_REASON: AtomicU32 = AtomicU32::new(0);
static SKIP_COUNT: AtomicU32 = AtomicU32::new(0);
/// Public snapshot of trigger state, for future polling (not needed for correctness).
/// (A `#[repr(C)]` layout would matter only if C code read this; plain Rust is fine here.)
#[allow(dead_code)]
pub struct DialTriggerStatus {
pub kill_switch_armed: bool,
pub latch_fired: bool,
pub listener_thread_id: u32,
pub pump_thread_id: u32,
pub last_completion_count: u32,
}
#[allow(dead_code)]
pub fn dial_trigger_status() -> DialTriggerStatus {
DialTriggerStatus {
kill_switch_armed: DIAL_ARMED.load(Ordering::Relaxed),
latch_fired: DIAL_TRIGGER_FIRED.load(Ordering::Relaxed),
listener_thread_id: LISTENER_TID.load(Ordering::Relaxed),
pump_thread_id: netconn_thread_id(),
last_completion_count: LAST_COMPLETION_COUNT.load(Ordering::Relaxed),
}
}
/// Rate-limited skip logger: logs the first 3 skips of a given reason, then one
/// "suppressed" line, then goes silent for that reason. Counters reset when the reason
/// changes, so a *new* failure mode logs fresh.
fn log_skip(reason_id: u32, msg: &str) {
// `swap` sets the current reason and returns the previous one; if it changed, reset.
if LAST_SKIP_REASON.swap(reason_id, Ordering::Relaxed) != reason_id {
SKIP_COUNT.store(0, Ordering::Relaxed);
}
let n = SKIP_COUNT.fetch_add(1, Ordering::Relaxed);
if n < 3 {
crate::write_log(msg);
} else if n == 3 {
crate::write_log("DIAL_TRIGGER: (further skips of this reason suppressed)\n");
}
}
/// After the dial has fired, log the completion-stub counter whenever it changes — our
/// signal that the enqueued RpcJob actually ran (Tier-2 success).
fn observe_completion() {
let c = crate::dial_notification::completion_stub_call_count();
let last = LAST_COMPLETION_COUNT.swap(c, Ordering::Relaxed);
if c != last {
crate::write_log(&format!(
"DIAL_TRIGGER: completion stub count changed {last}{c}\n"
));
}
}
/// The trigger. Runs on every listener-probe fire; self-gates so it fires the dial at
/// most once, only when every precondition holds. Called from `listener_log`, which the
/// asm stub invokes with a properly-aligned stack and shadow space.
unsafe fn dial_trigger_tick() {
// Step 1 — kill switch (env value cached at load).
if !DIAL_ARMED.load(Ordering::Relaxed) {
return;
}
// Step 2 — latch. Once fired, only keep watching the completion counter.
// `SeqCst` (sequentially consistent) is the strongest, simplest-to-reason-about
// ordering; for a gate that decides whether we perform an action, we prefer that
// safety over the (subtle) minimum `Relaxed` would allow.
if DIAL_TRIGGER_FIRED.load(Ordering::SeqCst) {
observe_completion();
return;
}
let base = MAIN_BASE.load(Ordering::Relaxed);
if base == 0 {
return;
}
// Step 3 — thread capture + self-consistency + pump-contrast sanity.
// GetCurrentThreadId: Win32 FFI (no args, returns this thread's id). It's `unsafe`
// only because it's a foreign call; it has no preconditions and no side effects.
let tid = GetCurrentThreadId();
let stored = LISTENER_TID.load(Ordering::Relaxed);
if stored == 0 {
LISTENER_TID.store(tid, Ordering::Relaxed);
let pump = netconn_thread_id();
crate::write_log(&format!(
"DIAL_TRIGGER: first listener fire, thread_id={tid}, pump_id={pump}\n"
));
// Pump-contrast sanity: the listener must NOT be our own background pump thread.
// A match here means our whole thread model is broken — abort permanently.
if tid == pump && pump != 0 {
crate::write_log(
"DIAL_TRIGGER: FATAL — listener thread matches pump thread; aborting\n",
);
DIAL_TRIGGER_FIRED.store(true, Ordering::SeqCst);
return;
}
} else if stored != tid {
log_skip(
1,
&format!("DIAL_TRIGGER: listener thread varied (was {stored}, now {tid}) — skipping\n"),
);
return;
}
// Step 4 — conn state must be '+onl'. NetConnStatus(rcx='conn',0,0,0) -> eax.
let netconn_status: unsafe extern "system" fn(u32, usize, usize, usize) -> u32 =
core::mem::transmute(base + 0xef17f0);
let status = netconn_status(0x636f6e6e, 0, 0, 0);
if status != 0x2b6f6e6c {
log_skip(
2,
&format!("DIAL_TRIGGER: not +onl (conn=0x{status:08x}) — skipping\n"),
);
return;
}
// Step 5 — resolve connMgr (reuse the ctx-dump scan + tiebreaker).
let Some(g) = read_ptr(base + 0xacd02c0).filter(|&x| x != 0) else {
log_skip(
3,
"DIAL_TRIGGER: connMgr resolution failed (G null) — skipping\n",
);
return;
};
let Some(m) = read_ptr(g + 0x360).filter(|&x| x != 0) else {
log_skip(
3,
"DIAL_TRIGGER: connMgr resolution failed (M null) — skipping\n",
);
return;
};
let expected_vtable = base + 0x80200b8;
let mut stats = (0u64, 0u64);
let cands = scan_conn_mgr(m, expected_vtable, &mut stats);
// Tiebreaker: first candidate whose vtable[0] lands in .text (a real live object).
let mut conn_mgr = 0usize;
for &p in &cands {
let vt0 = read_ptr(read_ptr(p).unwrap_or(0)).unwrap_or(0);
if in_text(base, vt0) {
conn_mgr = p;
break;
}
}
if conn_mgr == 0 {
log_skip(
3,
&format!(
"DIAL_TRIGGER: connMgr resolution failed ({} candidate(s), none clean) — skipping\n",
cands.len()
),
);
return;
}
// Step 6 — ctx sanity: [connMgr+8]=M, [M+0x778]=ctx must be a live object whose
// vtable pointer lands in the module image (0x140000000..0x161000000).
let m_holder = read_ptr(conn_mgr + 8).unwrap_or(0);
let ctx = read_ptr(m_holder + 0x778).unwrap_or(0);
let ctx_vt = read_ptr(ctx).unwrap_or(0);
if ctx == 0 || !(0x140000000..0x161000000).contains(&ctx_vt) {
log_skip(
4,
&format!(
"DIAL_TRIGGER: ctx pointer implausible (ctx={ctx:#x} vt={ctx_vt:#x}) — skipping\n"
),
);
return;
}
// All preconditions hold. Claim the one-shot latch ATOMICALLY, right before the call,
// so a re-entrant OnlineStatusEvent dispatch can't double-fire the dial. `compare_
// exchange(false→true)` is a proper CAS: exactly one caller wins; a loser bails. (I
// use CAS rather than `swap`/load-then-store because it both claims and checks in one
// atomic step — the correct primitive for a one-shot "exactly one winner" latch.)
// NOTE: this sets the latch just before the call rather than just after (as the task
// sketch said) specifically to close the re-entrancy window; preconditions that fail
// above still leave the latch clear, so they retry on later fires as intended.
if DIAL_TRIGGER_FIRED
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
// Step 7 — build the notification with a 'static lifetime via Box::leak.
// `Box::new(...)` heap-allocates the [u8;0x100]; `Box::leak` converts that owned Box
// into a `&'static mut [u8;0x100]` by intentionally NOT running its destructor — the
// 256 bytes live for the whole process. We do this so the buffer outlives anything
// the dial branch might retain a pointer into. The RE showed only value-copies out of
// the notification, so a stack buffer would *probably* be safe — but a permanent
// buffer removes all doubt on the highest-stakes call in the project, and the leak
// happens exactly once, so it's harmless.
let notif: &'static mut [u8; 0x100] =
Box::leak(Box::new(crate::dial_notification::build_notification()));
let notif_ptr = notif.as_ptr();
// Step 8 — fire the dial.
// `unsafe extern "system" fn(*mut u8, *const u8) -> usize`: on 64-bit Windows the
// "system" ABI *is* the Win64 calling convention (integer args in rcx, rdx, r8, r9;
// return in rax) — exactly what the game function expects (rcx=connMgr, rdx=notif).
// We `transmute` the resolved code address into this typed fn so the compiler emits a
// correct Win64 call (right registers, 32-byte shadow space, return read from rax); a
// bare pointer carries no ABI and couldn't be called correctly. The return TYPE is an
// inference — we log whatever integer comes back regardless of what it means.
let dial: unsafe extern "system" fn(*mut u8, *const u8) -> usize =
core::mem::transmute(base + 0x4f4d360);
crate::write_log(&format!(
"DIAL_TRIGGER: calling 0x144f4d360 connMgr={conn_mgr:#x} notification={:#x}\n",
notif_ptr as usize
));
// Force the pre-call line to disk BEFORE the call — if the dial faults, this line is
// how we know we reached the call site (crash RIP would be inside the dial branch).
crate::flush_log();
// THE dial call — the most consequential unsafe in the project. We hand the game its
// own dial handler with a real connMgr (resolved + ctx-checked) and our synthetic
// notification (matching the RE'd contract). This is sound iff: we're on the online
// thread (step 3), conn=='+onl' (step 4), connMgr is a valid live object (step 5),
// ctx is populated (step 6), and the notification matches the contract (unit-tested
// dial_notification). If any of those is wrong the game may fault inside the dial
// branch — which is normal .text and therefore diagnosable. We accept that risk.
let ret = dial(conn_mgr as *mut u8, notif_ptr);
crate::write_log(&format!("DIAL_TRIGGER: dial returned {ret:#x}\n"));
// Step 10 — start watching the completion counter (Tier-2 signal on later fires).
observe_completion();
}
// ─── connMgr candidate enumeration (read-only diagnostic) ────────────────────────
//
// After sub-phase B crashed on the `[connMgr+0x18]!=0` dial sub-path, we want to know
// whether OTHER connMgr instances exist and — critically — what each one's `[+0x18]` is.
// A candidate with `[+0x18]==0` would route the dial through the crash-free "create"
// branch (0x144f4d5fd). This enumerates EVERY candidate the resolver's scan finds (not
// just the first) and logs each one's branch-relevant fields. Pure observation: no dial,
// no writes, no game calls. Reuses `scan_conn_mgr` (which already returns all matches),
// `read_ptr`, `read_bytes`, `hex_dump`, `in_text` — nothing in sub-phase B is touched.
/// Kill switch, read once at DLL load from OPENFUT_CONNMGR_ENUM (independent of the
/// dial/pump/ctx switches).
static CONNMGR_ENUM_ARMED: AtomicBool = AtomicBool::new(false);
/// One-shot latch: enumerate exactly once per process.
static CONNMGR_ENUM_DONE: AtomicBool = AtomicBool::new(false);
/// Retry counter: if the first scans find nothing (connMgr not built yet), retry a few
/// listener fires before committing to a "zero candidates" verdict (avoids a false zero
/// from a timing race).
static CONNMGR_ENUM_TRIES: AtomicU32 = AtomicU32::new(0);
/// One connMgr candidate's branch-relevant fields.
///
/// Derives: `Debug` for ad-hoc `{:?}` debugging (note it prints integers in *decimal*,
/// so the log lines below format hex explicitly). `Clone, Copy` because it's a small
/// plain-old-data struct (all `usize`/`bool`/`Option<usize>`, 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<usize>, // [P+0x18] — THE branch-selection field (0 => safe branch)
field_20: Option<usize>, // [P+0x20] — ordered-container head (expect 0 at menu)
field_30: Option<usize>, // [P+0x30] — ordered-container head (expect 0 at menu)
field_c38: Option<usize>, // [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<CandidateInfo>` 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<CandidateInfo> = Vec::new();
// Render an Option<usize> as hex, or "<unreadable>" if the field couldn't be read.
let h = |o: Option<usize>| match o {
Some(v) => format!("{v:#x}"),
None => "<unreadable>".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<u32> {
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<u32>| {
o.map(|v| v.to_string())
.unwrap_or_else(|| "<unreadable>".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(|| "<unreadable>".to_string()),
));
// Step 10 — arm Phase 2: seed the baseline and spawn the poller. We watch regardless
// of the classification: even a currently-initialized container is worth watching for
// a re-init, and an uninitialized one is exactly the transition we're hunting.
ELEM_WATCH_ELEM.store(elem, Ordering::Relaxed);
ELEM_WATCH_BASELINE.store(begin.unwrap_or(0), Ordering::Relaxed);
spawn_elem_watcher(base, elem);
}
/// Phase 2: background thread that polls [element+0x40] every 100ms and logs the moment
/// it changes. Mirrors the NetConn pump's structure (a plain `std::thread` loop). Runs
/// for the process lifetime; read-only.
fn spawn_elem_watcher(base: usize, elem: usize) {
std::thread::spawn(move || unsafe {
// NetConn status word lives at [[base+0x9fe5e50]+0x48] (same slot the pump reads).
// We read the conn fourcc straight from memory for change-time context — no game
// call from this background thread.
let netconn_slot = base + 0x9fe5e50;
let t0 = std::time::Instant::now();
let mut changes = 0u32;
crate::write_log(&format!(
"ELEM_WATCH: Phase 2 watchpoint armed on [elem+0x40] @ {:#x} (100ms poll)\n",
elem + 0x40
));
loop {
std::thread::sleep(std::time::Duration::from_millis(100));
// Guarded read; if the element's page ever goes away, skip this tick.
let Some(now) = read_ptr(elem + 0x40) else {
continue;
};
let baseline = ELEM_WATCH_BASELINE.load(Ordering::Relaxed);
if now == baseline {
continue;
}
ELEM_WATCH_BASELINE.store(now, Ordering::Relaxed);
changes += 1;
if changes <= 5 {
let end = read_ptr(elem + 0x48).unwrap_or(0);
let conn = read_ptr(netconn_slot)
.filter(|&x| x != 0)
.and_then(|nc| read_ptr(nc + 0x48))
.map(|w| w as u32)
.unwrap_or(0);
crate::write_log(&format!(
"ELEM_WATCH: [elem+0x40] CHANGED! was={baseline:#x} now={now:#x} [elem+0x48]={end:#x} \
conn=0x{conn:08x} ({}) uptime={}s\n",
fourcc4(conn),
t0.elapsed().as_secs(),
));
if let Some(bytes) = read_bytes(elem, 0x80) {
elem_hex_dump("element (after change)", elem, &bytes);
}
} else if changes == 6 {
crate::write_log(
"ELEM_WATCH: (further changes suppressed; still tracking baseline)\n",
);
}
// Beyond 6, keep updating the baseline silently so distinct future changes
// are still detected — we just stop spamming the log.
}
});
}
/// FORCING EXPERIMENT: directly invoke `nucleusConnectREST` (FIFA23.exe+0x2861910)
/// from a background thread once FIFA is at "connecting". That function is nearly
/// self-contained — it fetches X=[0x14acd02c0], M=[X+0x360], ctx=[M+0x778] from the
/// global singleton and, if ctx is non-null, sends a GetAuthCode LSX request. The
/// dormant subsystem never calls it; we call it ourselves to test whether triggering
/// the connect makes FIFA fire GetAuthCode (which the bridge answers) and advance.
/// Guarded: we only call once the ctx chain is valid, to avoid a null-deref crash.
pub fn install_force_connect() {
std::thread::spawn(|| unsafe {
let base = GetModuleHandleA(core::ptr::null());
if base.is_null() {
return;
}
let base = base as usize;
let x_slot = base + 0xacd02c0;
let rest: extern "system" fn() -> usize = core::mem::transmute(base + 0x2861910);
// Wait for the ctx chain to be valid (FIFA past bootstrap / online), up to ~5 min.
let mut fired = 0;
for i in 0..600u32 {
std::thread::sleep(std::time::Duration::from_millis(500));
let ctx = read_ptr(x_slot)
.filter(|&x| x != 0)
.and_then(|x| read_ptr(x + 0x360))
.filter(|&m| m != 0)
.and_then(|m| read_ptr(m + 0x778))
.filter(|&c| c != 0);
let Some(ctx) = ctx else {
continue;
};
// Give the game ~15s settled (ctx valid) before poking, then re-fire a few
// times spaced out (the FUT-tick pump needs a moment to reach state 2).
if i < 30 {
continue;
}
crate::write_log(&format!(
"FORCE: calling nucleusConnectREST() (ctx={ctx:#x}) attempt {fired}\n"
));
let r = rest();
crate::write_log(&format!("FORCE: nucleusConnectREST returned {r:#x}\n"));
fired += 1;
if fired >= 6 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5000));
}
crate::write_log("FORCE: done\n");
});
}
/// First thread ID the NetConn pump ever fires on; 0 until the pump fires at least
/// once. Sub-phase B reads this (via `netconn_thread_id`) to know which thread the
/// pump runs on.
///
/// Why `AtomicU32` (not `Mutex<u32>` 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<u32>` 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<Vec<u8>> {
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::<MEMORY_BASIC_INFORMATION>(),
);
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<usize> {
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::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 {
break; // past the top of the user address space
}
let region_base = mbi.BaseAddress as usize;
let region_size = mbi.RegionSize;
let next = region_base.wrapping_add(region_size);
if next <= addr {
break; // no forward progress (overflow / degenerate) — stop safely
}
let readable = mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) == 0;
let is_heap = mbi.Type == MEM_PRIVATE;
if mbi.State == MEM_COMMIT && is_heap && readable && region_size >= 0x10 {
stats.0 += 1;
stats.1 += region_size as u64;
// The whole region is committed+readable (one uniform VirtualQuery region),
// so plain reads at any 8-aligned offset are in-bounds. `*const usize` =
// read a 64-bit pointer-sized word; that's the width of both a vtable
// pointer and the M pointer we're matching.
let words = region_size / 8;
let p = region_base as *const usize;
for i in 0..words {
// Non-volatile read: we're scanning a snapshot; a torn read at worst
// fails the compare. Plain `.read()` lets the optimizer keep this hot
// loop tight (idiom: `read_volatile` would be needed only if the value
// could change under us in a way we must observe — it can't here).
let v0 = p.add(i).read();
if v0 == expected_vtable {
let cand = region_base + i * 8;
// Ensure [cand+8] is still inside this region before reading it.
if cand + 16 <= next {
let v8 = (cand + 8) as *const usize;
if v8.read() == m {
hits.push(cand);
}
}
}
}
}
addr = next;
}
hits
}
/// Read-only menu-time probe: resolve connMgr from the known algorithm, then hex-dump
/// the ctx holder (M, 128 bytes) and the ctx object (512 bytes). Kill switch:
/// env `OPENFUT_CTX_DUMP` — armed only when it equals "1" (unset/"0" = disabled).
/// Read once at install time; if disarmed we don't even spawn the thread.
pub fn install_ctx_dump() {
let armed = std::env::var("OPENFUT_CTX_DUMP")
.map(|v| v == "1")
.unwrap_or(false);
if !armed {
crate::write_log("CTXDUMP: disabled (set OPENFUT_CTX_DUMP=1 to arm)\n");
return;
}
std::thread::spawn(|| unsafe {
let base = GetModuleHandleA(core::ptr::null());
if base.is_null() {
crate::write_log("CTXDUMP: main exe not found\n");
return;
}
let base = base as usize;
let g_slot = base + 0xacd02c0; // VA 0x14acd02c0
let expected_vtable = base + 0x80200b8; // connMgr vtable (static, in .rdata)
crate::write_log(
"CTXDUMP: armed; polling until connMgr is resolvable at the menu (read-only)\n",
);
// Poll (~3 min max) until the whole chain resolves. connMgr only appears once
// the online subsystem is up (≈ main menu), so a successful resolve IS the
// "we're at the menu" signal — more robust than a blind fixed delay.
for attempt in 0..360u32 {
std::thread::sleep(std::time::Duration::from_millis(500));
// Step 1: G. Null-check every link; log which step failed, never deref null.
let Some(g) = read_ptr(g_slot).filter(|&x| x != 0) else {
if attempt % 20 == 0 {
crate::write_log("CTXDUMP: waiting (step 1: G null/unreadable)\n");
}
continue;
};
// Step 2: M (ctx holder).
let Some(m) = read_ptr(g + 0x360).filter(|&x| x != 0) else {
if attempt % 20 == 0 {
crate::write_log(&format!("CTXDUMP: waiting (step 2: M null; G={g:#x})\n"));
}
continue;
};
// Step 3: expected ctx via the global path (informational cross-check).
let expected_ctx = read_ptr(m + 0x778).unwrap_or(0);
// Step 4: heap-scan for connMgr.
let mut stats = (0u64, 0u64);
let t0 = std::time::Instant::now();
let cands = scan_conn_mgr(m, expected_vtable, &mut stats);
let scan_ms = t0.elapsed().as_millis();
if cands.is_empty() {
if attempt % 10 == 0 {
crate::write_log(&format!(
"CTXDUMP: waiting (step 4: 0 connMgr candidates; G={g:#x} M={m:#x} \
expected_ctx={expected_ctx:#x}; scanned {} regions / {} MB in {scan_ms}ms)\n",
stats.0,
stats.1 / (1024 * 1024),
));
}
continue;
}
// Resolved. Latch so we dump exactly once.
if CTX_DUMP_DONE.swap(true, Ordering::Relaxed) {
return;
}
crate::write_log(&format!(
"CTXDUMP: RESOLVED G={g:#x} M(=[G+0x360])={m:#x} expected_ctx(=[M+0x778])={expected_ctx:#x}\n\
CTXDUMP: scan found {} candidate(s) in {} regions / {} MB in {scan_ms}ms\n",
cands.len(),
stats.0,
stats.1 / (1024 * 1024),
));
// Log each candidate; pick the first with vtable[0] in .text AND a
// pointer-shaped dispatcher at [P+0xc38] (the last run's false positive had
// ASCII bytes there). All candidates share the same vtable by construction,
// so [P+0xc38] is the real disambiguator.
let mut chosen = cands[0];
for &p in &cands {
let vt = read_ptr(p).unwrap_or(0);
let vt0 = read_ptr(vt).unwrap_or(0);
let disp = read_ptr(p + 0xc38).unwrap_or(0);
let vt0_ok = in_text(base, vt0);
let disp_ok = disp >= 0x10000;
crate::write_log(&format!(
"CTXDUMP: candidate P={p:#x} [P+0]={vt:#x} vtable[0]={vt0:#x} ({}) \
[P+8]={:#x} [P+0xc38]={disp:#x} ({})\n",
if vt0_ok { "in .text" } else { "NOT .text" },
read_ptr(p + 8).unwrap_or(0),
if disp_ok { "ptr-shaped" } else { "junk/ASCII" },
));
if vt0_ok && disp_ok && chosen == cands[0] {
chosen = p;
}
}
let conn_mgr = chosen;
crate::write_log(&format!(
"CTXDUMP: connMgr = {conn_mgr:#x} [P+0]={:#x} [P+8]={:#x} [P+0xc38]={:#x}\n",
read_ptr(conn_mgr).unwrap_or(0),
read_ptr(conn_mgr + 8).unwrap_or(0),
read_ptr(conn_mgr + 0xc38).unwrap_or(0),
));
// Dump 1: M — the ctx holder at [connMgr+8] (== M by construction). 128 bytes.
let m_holder = read_ptr(conn_mgr + 8).unwrap_or(0);
match read_bytes(m_holder, 128) {
Some(b) if !b.is_empty() => {
if b.len() < 128 {
crate::write_log(&format!(
"CTXDUMP: (M dump truncated to {} bytes at region end)\n",
b.len()
));
}
hex_dump("M (ctx holder)", m_holder, &b);
}
_ => crate::write_log(&format!("CTXDUMP: M @{m_holder:#x} unreadable\n")),
}
// Dump 2: ctx = [M+0x778]. If null, that's the diagnosis (holder exists,
// ctx not yet allocated) — log and skip.
let ctx = read_ptr(m_holder + 0x778).unwrap_or(0);
if ctx == 0 {
crate::write_log(&format!(
"CTXDUMP: ctx (=[M+0x778]) is NULL at menu (holder set up, ctx object not \
allocated) — skipping ctx dump\n"
));
} else {
match read_bytes(ctx, 512) {
Some(b) if !b.is_empty() => {
if b.len() < 512 {
crate::write_log(&format!(
"CTXDUMP: (ctx dump truncated to {} bytes at region end)\n",
b.len()
));
}
hex_dump("ctx", ctx, &b);
}
_ => crate::write_log(&format!("CTXDUMP: ctx @{ctx:#x} unreadable\n")),
}
}
crate::write_log("CTXDUMP: complete (one-shot; will not fire again)\n");
return;
}
crate::write_log(
"CTXDUMP: gave up after ~3 min — connMgr never resolved (still pre-menu?)\n",
);
});
}
struct Target {
/// DLL name (nul-terminated) or ignored when `main_exe` is true.
module: &'static [u8],
/// Offset from the module base (RVA). For main_exe, VA 0x140000000.
rva: usize,
label: &'static str,
/// True → resolve against the main executable (FIFA23.exe) base.
main_exe: bool,
}
/// Probe targets. Addresses recovered by RE (see docs/connection-gate-findings.md).
///
/// Dig-2 gate: FIFA's game-side Nucleus-connect layer requests the auth code, but both
/// paths null-check the Nucleus session context `[NucleusManager+0x778]` and bail when
/// it's null. These probes tell us *where FIFA is parked*:
/// - `nucleusConnectREST` (+0x2861910): the state that would send GetAuthCode. Returns 0
/// (no request) when `[mgr+0x778]` is null. If this NEVER fires, FIFA never reached it.
/// - `nucleusConnectTrusted` (+0x5078370): sibling connect path. Returns HRESULT
/// 0x80060000 ("not ready") when the context is null — its return value is diagnostic.
/// - `connect-state.tick` (+0x507d660): the login state-machine handler that calls
/// nucleusConnectTrusted. If it ticks, FIFA entered the connect state; if not, it's
/// parked earlier.
/// - `OnlineStatus.deser` (+0x278a4d0): confirms our pushed OnlineStatusEvent still
/// arrives during the test (control signal).
const TARGETS: &[Target] = &[
// Run 4: settle "connect state entered-but-stalled" vs "never entered". If the ctor
// fires but nothing else, the connect states are created at init but never used; if
// GetByIdx / any vtable step fires, the online subsystem is iterating them.
Target {
module: b"\0",
rva: 0x5078d20,
label: "connectState.ctor",
main_exe: true,
},
Target {
module: b"\0",
rva: 0x4f46570,
label: "ctrl.GetConnState",
main_exe: true,
},
Target {
module: b"\0",
rva: 0x507cd60,
label: "connState.m_a8",
main_exe: true,
},
Target {
module: b"\0",
rva: 0x507cf90,
label: "connState.m_b0",
main_exe: true,
},
Target {
module: b"\0",
rva: 0x507d660,
label: "connState.tick_b8",
main_exe: true,
},
Target {
module: b"\0",
rva: 0x507d760,
label: "connState.m_c0",
main_exe: true,
},
Target {
module: b"\0",
rva: 0x2861910,
label: "nucleusConnectREST",
main_exe: true,
},
Target {
module: b"\0",
rva: 0x278a4d0,
label: "OnlineStatus.deser",
main_exe: true,
},
];
const N: usize = 8; // must equal TARGETS.len()
static ADDRS: [AtomicUsize; N] = [const { AtomicUsize::new(0) }; N];
static mut ORIG: [[u8; 14]; N] = [[0u8; 14]; N];
/// Per-slot call counter, used to cap logging so a per-frame handler (e.g. a state
/// tick) can't flood the log. We still call through the real function every time.
static CALLS: [AtomicUsize; N] = [const { AtomicUsize::new(0) }; N];
const LOG_CAP: usize = 24;
type ProbeFn = unsafe extern "system" fn(usize, usize, usize, usize) -> usize;
const PROBE_FNS: [ProbeFn; N] = [p0, p1, p2, p3, p4, p5, p6, p7];
unsafe fn write_jmp(addr: *mut u8, dest: u64) {
let mut old: u32 = 0;
VirtualProtect(addr as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
// FF 25 00 00 00 00 JMP [rip+0] ; then absolute dest
addr.write(0xFF);
addr.add(1).write(0x25);
(addr.add(2) as *mut u32).write(0);
(addr.add(6) as *mut u64).write(dest);
VirtualProtect(addr as _, 14, old, &mut old);
}
unsafe fn restore(slot: usize) {
let addr = ADDRS[slot].load(Ordering::Relaxed) as *mut u8;
let mut old: u32 = 0;
VirtualProtect(addr as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
core::ptr::copy_nonoverlapping((&raw const ORIG[slot]) as *const u8, addr, 14);
VirtualProtect(addr as _, 14, old, &mut old);
}
unsafe fn generic(slot: usize, a: usize, b: usize, c: usize, d: usize) -> usize {
let label = TARGETS[slot].label;
let addr = ADDRS[slot].load(Ordering::Relaxed) as *mut u8;
// Cap logging per slot so a per-frame handler can't flood the log; still call
// through every time. Log the first LOG_CAP calls (entry + return), then just the
// running count once at the cap so we know it kept firing.
let n = CALLS[slot].fetch_add(1, Ordering::Relaxed);
let log = n < LOG_CAP;
// Unhook, (maybe) log entry, call the real function, re-hook, (maybe) log return.
restore(slot);
if log {
crate::write_log(&format!(
"PROBE {label} #{n} enter rcx={a:#x} rdx={b:#x} r8={c:#x} r9={d:#x}\n"
));
}
let f: ProbeFn = core::mem::transmute(addr);
let r = f(a, b, c, d);
write_jmp(addr, PROBE_FNS[slot] as u64);
if log {
crate::write_log(&format!("PROBE {label} #{n} ret={r:#x}\n"));
} else if n == LOG_CAP {
crate::write_log(&format!(
"PROBE {label} (capped; still firing past {LOG_CAP})\n"
));
}
r
}
unsafe extern "system" fn p0(a: usize, b: usize, c: usize, d: usize) -> usize {
generic(0, a, b, c, d)
}
unsafe extern "system" fn p1(a: usize, b: usize, c: usize, d: usize) -> usize {
generic(1, a, b, c, d)
}
unsafe extern "system" fn p2(a: usize, b: usize, c: usize, d: usize) -> usize {
generic(2, a, b, c, d)
}
unsafe extern "system" fn p3(a: usize, b: usize, c: usize, d: usize) -> usize {
generic(3, a, b, c, d)
}
unsafe extern "system" fn p4(a: usize, b: usize, c: usize, d: usize) -> usize {
generic(4, a, b, c, d)
}
unsafe extern "system" fn p5(a: usize, b: usize, c: usize, d: usize) -> usize {
generic(5, a, b, c, d)
}
unsafe extern "system" fn p6(a: usize, b: usize, c: usize, d: usize) -> usize {
generic(6, a, b, c, d)
}
unsafe extern "system" fn p7(a: usize, b: usize, c: usize, d: usize) -> usize {
generic(7, a, b, c, d)
}
/// Spawn a background thread that waits for anadius64.dll to load, then installs
/// all probes. anadius may not be present when our DllMain runs, so we defer
/// off the loader lock and poll for it (up to ~30s) before installing.
pub fn install_probes_deferred() {
std::thread::spawn(|| unsafe {
for _ in 0..60 {
if !GetModuleHandleA(b"anadius64.dll\0".as_ptr()).is_null() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
install_probes();
install_listener_probe();
install_state_sampler();
install_ctx_dump(); // ENABLED 2026-07-03: READ-ONLY menu-time ctx dump (env
// OPENFUT_CTX_DUMP=1). Resolves connMgr and hex-dumps M + ctx. No game calls.
// install_force_connect(); // DISABLED 2026-07-03: re-enabling it CRASHED FIFA at
// ~15s (EXCEPTION_ACCESS_VIOLATION, RIP 0x15d5e8dd7 in FIFA's packed/anti-tamper
// region, all registers garbage). Once state 2 makes the Nucleus ctx live,
// nucleusConnectREST's `ctx->vtable[0x40]` send path runs into protected code that
// does not tolerate being called from our background thread. GetAuthCode must be
// triggered on the GAME thread (via a detour), not a bg-thread forcing call.
install_force_netconn_pump(); // RE-ENABLED 2026-07-03 (sub-phase B prereq): pump
// NetConn toward '+onl' and capture the pump thread id. Gated by env
// OPENFUT_NETCONN_PUMP=1 — completely inert unless set. This is the known-good
// pump path (never crashed); the FUT-tick pump below stays OFF (it crashes the VM).
// install_force_fut_tick(); // DISABLED 2026-07-03 for the ctx-dump build: it
// WRITES the go-online latch and drives FifaOnline toward state 2, which
// deterministically CRASHES the anti-tamper VM before the menu — so leaving it on
// would prevent this menu-time probe from ever observing. Re-enable only if we
// deliberately want the (crash-prone) state-2 path.
});
}
/// Install all probe detours. Modules must already be loaded (call late in
/// DllMain, after anadius64.dll is present).
pub unsafe fn install_probes() {
for (i, t) in TARGETS.iter().enumerate() {
let base = if t.main_exe {
GetModuleHandleA(core::ptr::null())
} else {
GetModuleHandleA(t.module.as_ptr())
};
if base.is_null() {
crate::write_log(&format!("PROBE {}: module not loaded, skipped\n", t.label));
continue;
}
let addr = (base as usize + t.rva) as *mut u8;
core::ptr::copy_nonoverlapping(addr, (&raw mut ORIG[i]) as *mut u8, 14);
ADDRS[i].store(addr as usize, Ordering::Relaxed);
write_jmp(addr, PROBE_FNS[i] as u64);
crate::write_log(&format!(
"PROBE {} installed @ {:#x}\n",
t.label, addr as usize
));
}
}
#[cfg(test)]
mod netconn_tests {
use super::*;
#[test]
fn thread_id_accessor_roundtrips() {
// Fresh process: the pump hasn't fired, so the captured id starts at 0.
assert_eq!(netconn_thread_id(), 0);
// Simulate the first capture and confirm the accessor reads the same atomic.
NETCONN_TID.store(4321, Ordering::Relaxed);
assert_eq!(netconn_thread_id(), 4321);
}
}