1748 lines
80 KiB
Rust
1748 lines
80 KiB
Rust
//! 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);
|
||
}
|
||
}
|