//! 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 { if ptr < 0x10000 || ptr & 7 != 0 { return None; } let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); let n = VirtualQuery( ptr as _, &mut mbi, core::mem::size_of::(), ); if n == 0 || mbi.State != MEM_COMMIT { return None; } if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { return None; } // Ensure the full 8 bytes are inside the region. if ptr + 8 > mbi.BaseAddress as usize + mbi.RegionSize { return None; } Some(core::ptr::read_volatile(ptr as *const usize)) } /// Poll the Nucleus session-context chain directly, independent of the login state /// machine: X = OriginSDK singleton [FIFA23.exe+0xacd02c0]; M = [X+0x360] (online/ /// Nucleus manager); ctx = [M+0x778] (the session context that nucleusConnectREST/ /// Trusted null-check). Logs each level so we see exactly if/when the context is /// created. All reads are VirtualQuery-guarded, so a null/garbage pointer is logged, /// never dereferenced blind. pub fn install_state_sampler() { std::thread::spawn(|| unsafe { let base = GetModuleHandleA(core::ptr::null()); if base.is_null() { crate::write_log("SAMPLE: main exe not found\n"); return; } let x_slot = base as usize + 0xacd02c0; // VA 0x14acd02c0 let mut last = String::new(); for i in 0..180u32 { std::thread::sleep(std::time::Duration::from_millis(1000)); let line = match read_ptr(x_slot) { None => "X=".to_string(), Some(0) => "X=null".to_string(), Some(x) => match read_ptr(x + 0x360) { None => format!("X={x:#x} M="), Some(0) => format!("X={x:#x} M=null"), Some(m) => match read_ptr(m + 0x778) { None => format!("X={x:#x} M={m:#x} ctx="), Some(c) => format!("X={x:#x} M={m:#x} ctx[M+0x778]={c:#x}"), }, }, }; // Only log on change (plus a heartbeat every 20s) to keep the log clean. if line != last || i % 20 == 0 { crate::write_log(&format!("SAMPLE #{i} {line}\n")); last = line; } } }); } // ─── live game-side listener capture ──────────────────────────────────────────── // // 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`, every field itself `Copy`) /// — copying is trivial and it lets us collect into a `Vec` and re-scan it for the /// summary without any borrow-checker friction. #[derive(Debug, Clone, Copy)] struct CandidateInfo { p: usize, vtable: usize, vtable0: usize, vtable0_in_text: bool, field_18: Option, // [P+0x18] — THE branch-selection field (0 => safe branch) field_20: Option, // [P+0x20] — ordered-container head (expect 0 at menu) field_30: Option, // [P+0x30] — ordered-container head (expect 0 at menu) field_c38: Option, // [P+0xc38] — dispatcher pointer } /// Enumerate all connMgr candidates and log each one's `[+0x18]`. Runs once, on the /// listener thread, gated on the pump running (menu reached) — NOT on '+onl'. unsafe fn connmgr_enum_tick() { // Step 1 — kill switch. if !CONNMGR_ENUM_ARMED.load(Ordering::Relaxed) { return; } // Step 2 — one-shot: already enumerated? if CONNMGR_ENUM_DONE.load(Ordering::Relaxed) { return; } // Step 3 — gate on the pump having fired (a cheap "we're at the menu" signal). We do // NOT gate on '+onl': connMgr exists at the menu, and enumerating early gives us more // time. Requires OPENFUT_NETCONN_PUMP=1 (else netconn_thread_id stays 0 forever). if netconn_thread_id() == 0 { return; } let base = MAIN_BASE.load(Ordering::Relaxed); if base == 0 { return; } // Step 4 — resolve M. On a null link, DON'T latch — just retry on the next fire. let Some(g) = read_ptr(base + 0xacd02c0).filter(|&x| x != 0) else { return; }; let Some(m) = read_ptr(g + 0x360).filter(|&x| x != 0) else { return; }; // Step 5 — scan for EVERY candidate. `scan_conn_mgr` already collects all matches of // `[P+0]==vtable && [P+8]==M` (the resolver just picks the first clean one on top), so // we reuse it unchanged — the enumeration and the resolver share one scan. let expected_vtable = base + 0x80200b8; let mut stats = (0u64, 0u64); let t0 = std::time::Instant::now(); let cands = scan_conn_mgr(m, expected_vtable, &mut stats); let scan_ms = t0.elapsed().as_millis(); // If empty, it may be a timing race (connMgr not built yet). Retry up to 8 fires // before accepting a genuine "zero" — `fetch_add` returns the prior count. if cands.is_empty() && CONNMGR_ENUM_TRIES.fetch_add(1, Ordering::Relaxed) < 8 { return; } // Commit exactly once. if CONNMGR_ENUM_DONE.swap(true, Ordering::Relaxed) { return; } crate::write_log(&format!( "CONNMGR_ENUM: G={g:#x} M={m:#x} scanned {} regions / {} MB in {scan_ms}ms — {} candidate(s)\n", stats.0, stats.1 / (1024 * 1024), cands.len() )); // A `Vec` is fine here: this runs exactly once, on a ~2s-cadence event // callback (not a hot path), and we're already allocating (format! strings, and // scan_conn_mgr's own Vec). A handful of candidates is trivial. (Idiomatic-but-beyond- // beginner alternatives noted for later: an `impl Iterator` scan to avoid the interim // Vec, or Rayon to parallelise the region sweep — neither is worth it for a one-shot.) let mut infos: Vec = Vec::new(); // Render an Option as hex, or "" if the field couldn't be read. let h = |o: Option| match o { Some(v) => format!("{v:#x}"), None => "".to_string(), }; for (i, &p) in cands.iter().enumerate() { // SAFE: `p` came from scan_conn_mgr, which only emits addresses inside committed, // readable heap regions (VirtualQuery-classified). read_ptr re-checks each read is // 8-aligned and committed, returning None rather than dereferencing bad memory, so // every field read below is guarded — a garbage/partial object logs, never faults. let vtable = read_ptr(p).unwrap_or(0); let vtable0 = read_ptr(vtable).unwrap_or(0); let info = CandidateInfo { p, vtable, vtable0, vtable0_in_text: in_text(base, vtable0), field_18: read_ptr(p + 0x18), field_20: read_ptr(p + 0x20), field_30: read_ptr(p + 0x30), field_c38: read_ptr(p + 0xc38), }; infos.push(info); crate::write_log(&format!( "CONNMGR_ENUM: [{i}] P={p:#x} vt={vtable:#x} vt[0]={vtable0:#x} ({}) \ [+0x18]={} [+0x20]={} [+0x30]={} [+0xc38]={}\n", if info.vtable0_in_text { "in .text" } else { "NOT .text" }, h(info.field_18), h(info.field_20), h(info.field_30), h(info.field_c38), )); // 64-byte hex+ASCII (reuses the ctx-dump helpers): offset | 16 hex | ASCII. if let Some(bytes) = read_bytes(p, 64) { hex_dump(&format!("[{i}] P"), p, &bytes); } } // Summary: count the branch-relevant split on [+0x18], and note the resolver's pick. let with_zero = infos.iter().filter(|c| c.field_18 == Some(0)).count(); let with_nonzero = infos .iter() .filter(|c| matches!(c.field_18, Some(v) if v != 0)) .count(); // Which candidate sub-phase B's resolver would pick: first with vtable[0] in .text. let pick = infos.iter().find(|c| c.vtable0_in_text).map(|c| c.p); crate::write_log(&format!( "CONNMGR_ENUM: SUMMARY total={} [+0x18]==0(safe)={} [+0x18]!=0(crash)={} subphaseB_pick={}\n", infos.len(), with_zero, with_nonzero, pick.map(|p| format!("{p:#x}")).unwrap_or_else(|| "none".to_string()), )); // One at-a-glance OUTCOME line mapping to the four expected cases. let outcome = if infos.is_empty() { "zero (unexpected — timing or bug)" } else if with_zero > 0 { "has-safe-candidate (PROMISING — a [+0x18]==0 connMgr exists → try selecting it)" } else if infos.len() == 1 { "single-all-nonzero (no alternate connMgr → needs option 2: container init)" } else { "multiple-all-nonzero (all route to crash branch → needs option 2)" }; crate::write_log(&format!("CONNMGR_ENUM: OUTCOME = {outcome}\n")); } // ─── [element+0x40] container-writer watchpoint ────────────────────────────────── // // The sub-phase B dial crashed at 0x144fd6b6c reading a garbage `begin` pointer out // of the per-connection message-handler flat-map stored at `element+0x40` (element = // [ctx+0x1a8] + target_index*0x90). Static RE proved that container is filled by the // connection *lifecycle*, not by a discrete callable init — but couldn't say *when* // or *by whom* it goes from garbage/empty to populated. This probe answers that at // runtime, purely by observation: // // Phase 1 (one-shot, on the listener/game thread): snapshot the array shape // (array_base, sub_object, count, target_index), locate `element`, hex-dump it, // and classify [element+0x40] as null-init / uninitialized / initialized. // Phase 2 (background thread, 100ms poll): watch [element+0x40] for the moment it // changes, logging the new value + surrounding bytes + live conn fourcc + uptime. // // It is a POLLING watchpoint, not a hardware one: user-mode code can't cheaply set a // debug-register / page-guard write-watch on another thread's writes without acting as // a debugger, so a 100ms read poll is the pragmatic read-only choice (a fast writer // could in theory change-then-change-back between polls, but the container fill we care // about is a one-way garbage→populated transition, which a poll catches reliably). // // Read-only throughout: no writes, no dial, no game calls (the conn fourcc is read from // the NetConn status word in memory, not via NetConnStatus). Reuses scan_conn_mgr / // read_ptr / read_bytes. /// Kill switch, read once at DLL load from OPENFUT_ELEM_WATCH (independent of the /// dial / pump / enum / ctx switches). static ELEM_WATCH_ARMED: AtomicBool = AtomicBool::new(false); /// One-shot latch: take the snapshot (and arm the watcher) exactly once per process. static ELEM_WATCH_DONE: AtomicBool = AtomicBool::new(false); /// Retry counter: if the first scans can't resolve connMgr yet, retry a few listener /// fires before giving up (avoids a false "unresolved" from a timing race). static ELEM_WATCH_TRIES: AtomicU32 = AtomicU32::new(0); /// Absolute VA of the watched element (set by the snapshot, read by the watcher). 0 = /// not yet armed / no valid element. static ELEM_WATCH_ELEM: AtomicUsize = AtomicUsize::new(0); /// Last-seen value of [element+0x40]. Seeded by the snapshot; the watcher compares each /// poll against it and updates it on a change. `AtomicUsize` (not a plain `usize`) /// because the snapshot writes it on the game thread and the watcher reads+writes it on /// its own thread — the atomic gives well-defined cross-thread access with no lock. /// `Relaxed` is enough: we only compare the value, with no ordering vs other memory. static ELEM_WATCH_BASELINE: AtomicUsize = AtomicUsize::new(0); /// Read a 32-bit little-endian value at `addr`, VirtualQuery-guarded via `read_bytes`. /// `read_ptr` can't be reused for these fields: it reads 8 bytes and *requires 8-byte /// alignment*, but `sub_object+0x51c` (the count) is 4-aligned only. `read_bytes` has no /// alignment requirement and clamps to the committed region, so it's the safe primitive. unsafe fn read_u32(addr: usize) -> Option { let b = read_bytes(addr, 4)?; if b.len() < 4 { return None; // region ended mid-field — treat as unreadable } Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) } /// Render a 32-bit fourcc (e.g. NetConn's `'+onl'` = 0x2b6f6e6c) as its 4 ASCII chars, /// low byte first (matching how the game packs `('+','o','n','l')`). Non-printable bytes /// show as '.'. Purely for readable logs. fn fourcc4(v: u32) -> String { let b = v.to_le_bytes(); b.iter() .map(|&c| { if (0x20..0x7f).contains(&c) { c as char } else { '.' } }) .collect() } /// Classic hex dump for the ELEM_WATCH lines. Deliberately a small copy of `hex_dump`'s /// body rather than a call to it: `hex_dump` hard-codes a `"CTXDUMP"` log prefix, and we /// want an `"ELEM_WATCH"` prefix so these lines grep together with the rest of the phase. /// (Refactoring `hex_dump` to take a prefix would touch the stable ctx-dump/enum probes /// for no real gain; a ~10-line duplicate is the lower-risk choice.) fn elem_hex_dump(label: &str, start_va: usize, data: &[u8]) { let mut out = format!( "ELEM_WATCH {label} @{start_va:#x} ({} bytes):\n", data.len() ); for (row, chunk) in data.chunks(16).enumerate() { let mut hex = String::new(); let mut ascii = String::new(); for (i, &b) in chunk.iter().enumerate() { hex.push_str(&format!("{b:02x} ")); if i == 7 { hex.push(' '); } ascii.push(if (0x20..0x7f).contains(&b) { b as char } else { '.' }); } out.push_str(&format!(" +{:#05x} {:<50}|{ascii}|\n", row * 16, hex)); } crate::write_log(&out); } /// Phase 1: snapshot the container element + arm the Phase-2 watcher. Runs on the /// listener (game) thread, once, gated on the pump running (menu reached) — NOT on /// '+onl' (the element exists at the menu; earlier snapshot = more watch time). unsafe fn elem_watch_tick() { // Step 1 — kill switch. if !ELEM_WATCH_ARMED.load(Ordering::Relaxed) { return; } // Step 2 — one-shot: already snapshotted? if ELEM_WATCH_DONE.load(Ordering::Relaxed) { return; } // Step 3 — gate on the pump having fired ("we're at the menu"). Requires // OPENFUT_NETCONN_PUMP=1 (else netconn_thread_id stays 0 forever). if netconn_thread_id() == 0 { return; } let base = MAIN_BASE.load(Ordering::Relaxed); if base == 0 { return; } // Step 4 — resolve M (G = OriginSDK singleton, M = [G+0x360]). Null link => don't // latch, just retry on the next fire. let Some(g) = read_ptr(base + 0xacd02c0).filter(|&x| x != 0) else { return; }; let Some(m) = read_ptr(g + 0x360).filter(|&x| x != 0) else { return; }; // Step 5 — resolve connMgr: reuse the same scan + "first candidate whose vtable[0] // is in .text" tiebreak that sub-phase B's dial resolver uses, so we snapshot the // exact object the dial would have operated on. let expected_vtable = base + 0x80200b8; let mut stats = (0u64, 0u64); let cands = scan_conn_mgr(m, expected_vtable, &mut stats); let mut conn_mgr = 0usize; for &p in &cands { let vt0 = read_ptr(read_ptr(p).unwrap_or(0)).unwrap_or(0); if in_text(base, vt0) { conn_mgr = p; break; } } if conn_mgr == 0 { // Not resolvable yet — retry up to 8 fires before giving up (timing race). if ELEM_WATCH_TRIES.fetch_add(1, Ordering::Relaxed) < 8 { return; } if ELEM_WATCH_DONE.swap(true, Ordering::Relaxed) { return; } crate::write_log("ELEM_WATCH: connMgr unresolved after retries — snapshot aborted\n"); return; } // connMgr is resolved: commit the one-shot latch NOW. Everything below is a single // observation of whatever state exists — including "not set up yet", which is a // valid RESULT, not a reason to retry. (If we retried on an uninitialized element we // would loop forever, since offline it may never populate.) if ELEM_WATCH_DONE.swap(true, Ordering::Relaxed) { return; } // Step 6 — walk connMgr -> M -> ctx. (M here is re-read from [connMgr+8]; it should // equal the global M we scanned with.) let cm_m = read_ptr(conn_mgr + 8).unwrap_or(0); let ctx = if cm_m != 0 { read_ptr(cm_m + 0x778).unwrap_or(0) } else { 0 }; if cm_m == 0 || ctx == 0 { crate::write_log(&format!( "ELEM_WATCH: chain broke (connMgr={conn_mgr:#x} M={cm_m:#x} ctx={ctx:#x}) — watchpoint not armed\n" )); return; } // Step 7 — read the array shape. Each field is guarded (read_ptr/read_u32 return // None rather than fault). Contract (from the 0x145057430 getter RE): // array_base = [ctx+0x1a8] (base of the 0x90-byte element array) // sub_object = [ctx+0x20] (holds the element count) // count = [sub_object+0x51c] (u32) // target_index = [[M+0x7b0]+0x650] (u32; the dial read this with `mov edx,...`) let array_base = read_ptr(ctx + 0x1a8).unwrap_or(0); let sub_object = read_ptr(ctx + 0x20).unwrap_or(0); let count = if sub_object != 0 { read_u32(sub_object + 0x51c) } else { None }; let m7b0 = read_ptr(cm_m + 0x7b0).unwrap_or(0); let target_index = if m7b0 != 0 { read_u32(m7b0 + 0x650) } else { None }; let fmt_u = |o: Option| { o.map(|v| v.to_string()) .unwrap_or_else(|| "".to_string()) }; crate::write_log(&format!( "ELEM_WATCH: SNAPSHOT ctx={ctx:#x} array_base={array_base:#x} sub_object={sub_object:#x} \ count={} [M+0x7b0]={m7b0:#x} target_index={}\n", fmt_u(count), fmt_u(target_index), )); // Step 8 — need array_base + count + target_index to locate the element. let (Some(count), Some(idx)) = (count, target_index) else { crate::write_log("ELEM_WATCH: count or target_index unreadable — cannot locate element; watchpoint not armed\n"); return; }; if array_base == 0 { crate::write_log( "ELEM_WATCH: array_base null — element array not allocated; watchpoint not armed\n", ); return; } if idx >= count { crate::write_log(&format!( "ELEM_WATCH: target_index {idx} >= count {count} (OUT OF BOUNDS — array likely uninitialized garbage); watchpoint not armed\n" )); return; } let elem = array_base + (idx as usize) * 0x90; crate::write_log(&format!( "ELEM_WATCH: element[{idx}] @ {elem:#x} (array_base + {idx}*0x90)\n" )); // Step 9 — hex-dump the element head, then classify [element+0x40] (the flat-map's // `begin` pointer). This is the exact word the crashed dial dereferenced as garbage. if let Some(bytes) = read_bytes(elem, 0x80) { elem_hex_dump("element (snapshot)", elem, &bytes); } let begin = read_ptr(elem + 0x40); let end = read_ptr(elem + 0x48).unwrap_or(0); let interp = match begin { None => "unreadable", Some(0) => "container null-init (default-constructed empty vector — begin==end==0)", Some(v) if v < 0x10000 => { "container UNINITIALIZED (small non-pointer sentinel — this is the crash shape)" } Some(v) if read_bytes(v, 8).is_some() => { "container appears INITIALIZED (begin is a readable heap pointer)" } Some(_) => "container has a non-null but UNREADABLE begin (dangling / mid-construction?)", }; crate::write_log(&format!( "ELEM_WATCH: [elem+0x40]={} [elem+0x48]={end:#x} => {interp}\n", begin .map(|v| format!("{v:#x}")) .unwrap_or_else(|| "".to_string()), )); // Step 10 — arm Phase 2: seed the baseline and spawn the poller. We watch regardless // of the classification: even a currently-initialized container is worth watching for // a re-init, and an uninitialized one is exactly the transition we're hunting. ELEM_WATCH_ELEM.store(elem, Ordering::Relaxed); ELEM_WATCH_BASELINE.store(begin.unwrap_or(0), Ordering::Relaxed); spawn_elem_watcher(base, elem); } /// Phase 2: background thread that polls [element+0x40] every 100ms and logs the moment /// it changes. Mirrors the NetConn pump's structure (a plain `std::thread` loop). Runs /// for the process lifetime; read-only. fn spawn_elem_watcher(base: usize, elem: usize) { std::thread::spawn(move || unsafe { // NetConn status word lives at [[base+0x9fe5e50]+0x48] (same slot the pump reads). // We read the conn fourcc straight from memory for change-time context — no game // call from this background thread. let netconn_slot = base + 0x9fe5e50; let t0 = std::time::Instant::now(); let mut changes = 0u32; crate::write_log(&format!( "ELEM_WATCH: Phase 2 watchpoint armed on [elem+0x40] @ {:#x} (100ms poll)\n", elem + 0x40 )); loop { std::thread::sleep(std::time::Duration::from_millis(100)); // Guarded read; if the element's page ever goes away, skip this tick. let Some(now) = read_ptr(elem + 0x40) else { continue; }; let baseline = ELEM_WATCH_BASELINE.load(Ordering::Relaxed); if now == baseline { continue; } ELEM_WATCH_BASELINE.store(now, Ordering::Relaxed); changes += 1; if changes <= 5 { let end = read_ptr(elem + 0x48).unwrap_or(0); let conn = read_ptr(netconn_slot) .filter(|&x| x != 0) .and_then(|nc| read_ptr(nc + 0x48)) .map(|w| w as u32) .unwrap_or(0); crate::write_log(&format!( "ELEM_WATCH: [elem+0x40] CHANGED! was={baseline:#x} now={now:#x} [elem+0x48]={end:#x} \ conn=0x{conn:08x} ({}) uptime={}s\n", fourcc4(conn), t0.elapsed().as_secs(), )); if let Some(bytes) = read_bytes(elem, 0x80) { elem_hex_dump("element (after change)", elem, &bytes); } } else if changes == 6 { crate::write_log( "ELEM_WATCH: (further changes suppressed; still tracking baseline)\n", ); } // Beyond 6, keep updating the baseline silently so distinct future changes // are still detected — we just stop spamming the log. } }); } /// FORCING EXPERIMENT: directly invoke `nucleusConnectREST` (FIFA23.exe+0x2861910) /// from a background thread once FIFA is at "connecting". That function is nearly /// self-contained — it fetches X=[0x14acd02c0], M=[X+0x360], ctx=[M+0x778] from the /// 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` or `static mut u32`): this is a write-once, /// read-many value. An atomic gives lock-free, data-race-free access with NO `unsafe`; /// a `Mutex` is overkill for one integer, and a `static mut` would require `unsafe` and /// risks undefined behaviour under concurrent access. (For a strictly write-once value /// `std::sync::OnceLock` is the most idiomatic modern form — noted for future /// reference; a plain atomic is simpler and sufficient here.) static NETCONN_TID: AtomicU32 = AtomicU32::new(0); /// The thread ID the NetConn pump fires on, or 0 if it hasn't fired yet. `Relaxed` is /// sufficient: we only need the value itself to be visible to the reader, not ordered /// against any other memory (there's no "publish data then set flag" handoff here). pub fn netconn_thread_id() -> u32 { NETCONN_TID.load(Ordering::Relaxed) } /// Pump DirtySDK's NetConnIdle ourselves to break the offline "go-online" bootstrap. /// /// RE finding (openfut-bridge/docs/connection-gate-findings.md, 2026-07-02): the app's /// go-online handler (main_exe+0x4f4d360) dials the redirector only when /// NetConnStatus('conn')=='+onl'. That status is the conn-module cached field /// [NetConn+0x48], promoted '~con'->'+onl' by the conn tick (main_exe+0xf05430). With /// live field values the tick WOULD promote (all preconditions met) and fire the /// state-change notification the handler reacts to — but the tick, though registered in /// the NetConnIdle callback table, is never run: its pump (NetConnIdle core, /// main_exe+0xf16a50) is driven ONLY by online connect/wait loops, which never run /// offline at the menu. Bootstrap circularity. Calling the pump ourselves runs the /// game's own promotion logic (it self-guards on 'open'=[NetConn+0xcd], already =1), so /// it is coherent, not a faked state. /// /// NOTE: like `install_force_connect`, this runs on a spawned thread and DirtySDK state /// is not formally thread-safe — this is a forcing EXPERIMENT. If it destabilises FIFA, /// move the `pump()` call onto a game-thread detour instead of a background thread. /// Off by default (see install_probes_deferred); enable manually to test. pub fn install_force_netconn_pump() { std::thread::spawn(|| unsafe { // Kill switch: only run when OPENFUT_NETCONN_PUMP=1. Read once at thread start // (an env var is fixed for the process lifetime). Unset or "0" => short-circuit: // log and return, so the pump is wired in but completely inert — a safe default // that can be flipped without a rebuild. let enabled = std::env::var("OPENFUT_NETCONN_PUMP") .map(|v| v == "1") .unwrap_or(false); if !enabled { crate::write_log("NETCONN_PUMP: disabled (set OPENFUT_NETCONN_PUMP=1 to enable)\n"); return; } let base = GetModuleHandleA(core::ptr::null()); if base.is_null() { return; } let base = base as usize; let netconn_slot = base + 0x9fe5e50; // VA 0x149fe5e50 -> NetConn global (X) // NetConnIdle core pump; takes no args (reads globals, sets its own rcx). Win64. let pump: extern "system" fn() = core::mem::transmute(base + 0xf16a50); // Render a 4-char status code the way DirtySDK stores it (e.g. 0x2b6f6e6c="+onl"). let fourcc = |v: u32| -> String { [(v >> 24) as u8, (v >> 16) as u8, (v >> 8) as u8, v as u8] .iter() .map(|&b| { if (0x20..0x7f).contains(&b) { b as char } else { '.' } }) .collect() }; // Pump CONTINUOUSLY, ~every 100ms, for up to ~30 min. Do NOT stop at +onl: // promoting the status is not enough — the actual redirector/Blaze connection // (ProtoSSLConnect -> getaddrinfo -> dial) only progresses while the idle loop // keeps ticking, exactly like the game's own connect-wait loops (0x14508a500, // which pump 0x140f16a50 repeatedly). Log only on status CHANGE, plus a // heartbeat, so the log doesn't flood. let mut pumped = 0u32; let mut last_status = 0u32; // Count of thread-id samples logged so far. Plain local (not atomic): only THIS // pump thread ever touches it, so there's no cross-thread race to guard against. let mut samples = 0u32; for _ in 0..18000u32 { std::thread::sleep(std::time::Duration::from_millis(100)); let Some(nc) = read_ptr(netconn_slot).filter(|&x| x != 0) else { continue; }; // Read the conn status dword at [nc+0x48] (8-aligned; read_ptr is guarded). let status = read_ptr(nc + 0x48).map(|w| w as u32).unwrap_or(0); // Drive the idle loop. Self-guards on 'open'; a no-op if not yet open. pump(); pumped += 1; // Thread-id capture: log the first 5 fires, then stop (5 samples is enough to // see whether it's stable or varying). NOTE: this is OUR spawned pump thread, // NOT necessarily the game's online-servicing thread — it will therefore be a // single stable value. See the report / fn docs before using it as sub-phase // B's "known-good" comparison. if samples < 5 { // GetCurrentThreadId is a Win32 FFI call (no args; returns the OS thread // id as a DWORD/u32). Any foreign call is `unsafe` because Rust can't // verify the callee's contract — we're already inside the closure's // `unsafe` block. This one is trivially safe: it only reads the current // thread's id and has no preconditions or side effects. let tid = GetCurrentThreadId(); // First-write-wins: record the first id we ever see. compare_exchange // flips 0 -> tid exactly once and no-ops thereafter. Only this thread // writes it, so a plain store would also work; the CAS documents the // "first wins" intent and stays correct even if several threads pumped. // `Relaxed` on both success/failure: value-only, no ordering needed. let _ = NETCONN_TID.compare_exchange(0, tid, Ordering::Relaxed, Ordering::Relaxed); samples += 1; crate::write_log(&format!( "NETCONN_PUMP: fired (sample {samples}/5), thread_id={tid}\n" )); if samples == 5 { crate::write_log(&format!( "NETCONN_PUMP: thread ID captured = {} (further pump fires will not log)\n", NETCONN_TID.load(Ordering::Relaxed) )); } } if status != last_status || pumped % 100 == 0 { crate::write_log(&format!( "PUMP #{pumped}: NetConn={nc:#x} conn[+0x48]={status:#x} ({})\n", fourcc(status) )); last_status = status; } } crate::write_log("PUMP: done (30 min elapsed)\n"); }); } /// Pump the FUT online manager's update tick to break the "go-online" bootstrap. /// /// RE finding (2026-07-03, docs/connection-gate-findings.md): the FUT online→auth chain /// (GetAuthCode→Nucleus→Blaze) is gated on the FifaOnline manager advancing its state /// machine. mgr = *[FIFA23.exe+0xa199608]; state @mgr+0x1bb8 (0=idle, 1=connecting, /// 2=online — the `==2` check is inlined everywhere). The 0→1 transition is driven by /// the manager's update tick (main_exe+0x1b3f290) consuming a "go-online request" latch /// byte @mgr+0x1bbc, which the event-0 handler (main_exe+0x1b0bbb0) normally sets. At the /// "connecting to EA servers" screen the tick is DORMANT: setting the latch by hand, it /// is never consumed (state stays 0) — the same primed-but-unpumped pattern as /// NetConnIdle. Only the FUT auth requester (main_exe+0x1b02790), reached once state /// advances, issues GetAuthCode. /// /// We post the latch and call the tick ourselves. The tick self-gates /// (main_exe+0x7e5c80: singleton [0x14acd02c0]!=0 && byte [0x14acd02ef]==0, both already /// satisfied) and takes a lock at mgr+0x5a98 — but since the game isn't calling it, our /// thread is the sole caller, so no contention. Advancing to state 1 kicks the connect /// job, which should fire GetAuthCode (the bridge answers it). Signature is a Win64 /// method rcx=this(mgr); we pass rdx=0 (update dt/flag default). Forcing EXPERIMENT. pub fn install_force_fut_tick() { std::thread::spawn(|| unsafe { let base = GetModuleHandleA(core::ptr::null()); if base.is_null() { return; } let base = base as usize; let mgr_slot = base + 0xa199608; // VA 0x14a199608 -> FUT online manager ptr let tick: extern "system" fn(usize, usize) -> usize = core::mem::transmute(base + 0x1b3f290); let mut ticked = 0u32; let mut last = (u32::MAX, 0u8); // ~15 min at 250ms. The tick is heavy (locks + sub-updates); don't spin at 100ms. for _ in 0..3600u32 { std::thread::sleep(std::time::Duration::from_millis(250)); let Some(mgr) = read_ptr(mgr_slot).filter(|&m| m != 0) else { continue; }; // state @+0x1bb8 (low32) + latch byte @+0x1bbc share one 8-aligned qword. let Some(w) = read_ptr(mgr + 0x1bb8) else { continue; }; let state = w as u32; let latch = ((w >> 32) & 0xff) as u8; // Post the go-online request ONLY at state 0 (mimics event-0 delivery) to // advance 0->1. Do NOT re-post at state 1: with latch!=0 the tick takes its // teardown path (call main_exe+0x1a85ef0 resets the connection) and cycles // state 1->0->1 every tick, so the connect never completes -> "EA servers // down". At state 1 we call the tick with latch==0 so it drives the // connecting state (the 0x1b3f549 path) toward state 2. if state == 0 { core::ptr::write_volatile((mgr + 0x1bbc) as *mut u8, 1); } let _ = tick(mgr, 0); ticked += 1; let now = (state, latch); if now != last || ticked % 40 == 0 { crate::write_log(&format!( "FUTTICK #{ticked}: mgr={mgr:#x} state[+0x1bb8]={state} latch[+0x1bbc]={latch}\n" )); last = now; } } crate::write_log("FUTTICK: done\n"); }); } // ─── menu-time ctx dump (READ-ONLY) ───────────────────────────────────────────── // // Goal (2026-07-03): confirm whether the Nucleus/connect `ctx` reachable from the // redirector connMgr is *populated* at the main menu (offline), or an empty shell // that would fault on a dispatch even with the correct `this`. This is pure // observation — it never writes game memory and never calls a game function, so // unlike the forcing pumps it can run from a background thread with no risk. // // Resolution algorithm (confirmed live on 2026-07-03): // G = *[base + 0xacd02c0] (an OriginSDK global singleton) // M = *[G + 0x360] (the ctx holder / online manager) // ctx = *[M + 0x778] (the connect/session context) // connMgr P: a heap object with [P+0] == base+0x80200b8 (its vtable, a static // .rdata address) AND [P+8] == M. Confirmed via vtable[0] landing in // .text and an embedded dispatcher pointer at [P+0xc38]. // // All offsets here are the CONFIRMED values from the connMgr-resolution run; if a // future disassembly contradicts one, stop and re-verify rather than adjust blindly. /// Latches true the moment the dump actually runs, so it fires at most once per /// process. (There is only ever one probe thread, so this is belt-and-suspenders.) static CTX_DUMP_DONE: AtomicBool = AtomicBool::new(false); /// Is `addr` inside one of FIFA23.exe's two `.text` (code) sections? Used as the /// false-positive filter on a candidate's first virtual method (vtable[0]): a real /// object's vtable points at real code. Ranges are RVAs from the section map /// (docs): first .text [0x1000, 0x72a7800), second .text [0xbe37000, 0xc1cd000). fn in_text(base: usize, addr: usize) -> bool { let rva = addr.wrapping_sub(base); (0x1000..0x72a7800).contains(&rva) || (0xbe37000..0xc1cd000).contains(&rva) } /// Read up to `len` bytes starting at `addr` into a Vec, but never past the end of /// the single VirtualQuery region `addr` lives in (so we can't wander off committed /// memory). Returns None if `addr` isn't in a committed, readable page. The returned /// Vec may be SHORTER than `len` if the region ends first — the caller notes that. unsafe fn read_bytes(addr: usize, len: usize) -> Option> { if addr < 0x10000 { return None; } let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); let n = VirtualQuery( addr as _, &mut mbi, core::mem::size_of::(), ); if n == 0 || mbi.State != MEM_COMMIT { return None; } if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { return None; } // VirtualQuery guarantees the whole [BaseAddress, BaseAddress+RegionSize) range // shares one protection, so clamping to the region end keeps every copied byte // inside committed+readable memory. let region_end = mbi.BaseAddress as usize + mbi.RegionSize; let take = len.min(region_end.saturating_sub(addr)); let mut buf = vec![0u8; take]; // SAFE: src is committed+readable for `take` bytes (clamped above); dst is our // freshly-allocated Vec of exactly `take` bytes; the ranges don't overlap. core::ptr::copy_nonoverlapping(addr as *const u8, buf.as_mut_ptr(), take); Some(buf) } /// Format `data` as a classic hex dump (16 bytes/line: relative offset, hex, ASCII) /// and append it to the log under one header line. `start_va` is only used to print /// the object's base address in the header; offsets are relative (`+0x000`, …). fn hex_dump(label: &str, start_va: usize, data: &[u8]) { let mut out = format!("CTXDUMP {label} @{start_va:#x} ({} bytes):\n", data.len()); for (row, chunk) in data.chunks(16).enumerate() { let mut hex = String::new(); let mut ascii = String::new(); for (i, &b) in chunk.iter().enumerate() { hex.push_str(&format!("{b:02x} ")); if i == 7 { hex.push(' '); // gap between the two 8-byte halves, easier to read } // Printable ASCII stays; everything else shows as '.' so pointer bytes // don't corrupt the log line. ascii.push(if (0x20..0x7f).contains(&b) { b as char } else { '.' }); } out.push_str(&format!(" +{:#05x} {:<50}|{ascii}|\n", row * 16, hex)); } crate::write_log(&out); } /// Scan committed private (heap) memory for connMgr candidates: objects `P` with /// `[P+0] == expected_vtable` AND `[P+8] == m`. /// /// How the scan works (conceptually): the address space is a series of regions. /// `VirtualQuery(addr)` describes the region containing `addr` — its base, size, /// commit state, protection, and type (private heap vs mapped file vs image). We /// walk region-by-region (jumping to base+size each step), and for every region that /// is COMMITTED, PRIVATE (heap, not an EXE/DLL image or file mapping), and readable, /// we sweep it at 8-byte stride looking for a word equal to `expected_vtable`. The /// vtable is a single fixed value, so that first compare rejects almost every slot /// instantly; only on a hit do we read `[P+8]` and compare to `m`. Restricting to /// MEM_PRIVATE skips the executable/DLL images and mapped files entirely, which is /// most of the address space and where connMgr can't live. /// /// Returns every matching `P`. Read-only throughout. Also fills `stats` with /// (regions_scanned, bytes_scanned) so we can report the cost. unsafe fn scan_conn_mgr(m: usize, expected_vtable: usize, stats: &mut (u64, u64)) -> Vec { let mut hits = Vec::new(); let mut addr: usize = 0x10000; // user space starts here; skip the null-guard page loop { let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); let n = VirtualQuery( addr as _, &mut mbi, core::mem::size_of::(), ); if n == 0 { break; // past the top of the user address space } let region_base = mbi.BaseAddress as usize; let region_size = mbi.RegionSize; let next = region_base.wrapping_add(region_size); if next <= addr { break; // no forward progress (overflow / degenerate) — stop safely } let readable = mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) == 0; let is_heap = mbi.Type == MEM_PRIVATE; if mbi.State == MEM_COMMIT && is_heap && readable && region_size >= 0x10 { stats.0 += 1; stats.1 += region_size as u64; // The whole region is committed+readable (one uniform VirtualQuery region), // so plain reads at any 8-aligned offset are in-bounds. `*const usize` = // read a 64-bit pointer-sized word; that's the width of both a vtable // pointer and the M pointer we're matching. let words = region_size / 8; let p = region_base as *const usize; for i in 0..words { // Non-volatile read: we're scanning a snapshot; a torn read at worst // fails the compare. Plain `.read()` lets the optimizer keep this hot // loop tight (idiom: `read_volatile` would be needed only if the value // could change under us in a way we must observe — it can't here). let v0 = p.add(i).read(); if v0 == expected_vtable { let cand = region_base + i * 8; // Ensure [cand+8] is still inside this region before reading it. if cand + 16 <= next { let v8 = (cand + 8) as *const usize; if v8.read() == m { hits.push(cand); } } } } } addr = next; } hits } /// Read-only menu-time probe: resolve connMgr from the known algorithm, then hex-dump /// the ctx holder (M, 128 bytes) and the ctx object (512 bytes). Kill switch: /// env `OPENFUT_CTX_DUMP` — armed only when it equals "1" (unset/"0" = disabled). /// Read once at install time; if disarmed we don't even spawn the thread. pub fn install_ctx_dump() { let armed = std::env::var("OPENFUT_CTX_DUMP") .map(|v| v == "1") .unwrap_or(false); if !armed { crate::write_log("CTXDUMP: disabled (set OPENFUT_CTX_DUMP=1 to arm)\n"); return; } std::thread::spawn(|| unsafe { let base = GetModuleHandleA(core::ptr::null()); if base.is_null() { crate::write_log("CTXDUMP: main exe not found\n"); return; } let base = base as usize; let g_slot = base + 0xacd02c0; // VA 0x14acd02c0 let expected_vtable = base + 0x80200b8; // connMgr vtable (static, in .rdata) crate::write_log( "CTXDUMP: armed; polling until connMgr is resolvable at the menu (read-only)\n", ); // Poll (~3 min max) until the whole chain resolves. connMgr only appears once // the online subsystem is up (≈ main menu), so a successful resolve IS the // "we're at the menu" signal — more robust than a blind fixed delay. for attempt in 0..360u32 { std::thread::sleep(std::time::Duration::from_millis(500)); // Step 1: G. Null-check every link; log which step failed, never deref null. let Some(g) = read_ptr(g_slot).filter(|&x| x != 0) else { if attempt % 20 == 0 { crate::write_log("CTXDUMP: waiting (step 1: G null/unreadable)\n"); } continue; }; // Step 2: M (ctx holder). let Some(m) = read_ptr(g + 0x360).filter(|&x| x != 0) else { if attempt % 20 == 0 { crate::write_log(&format!("CTXDUMP: waiting (step 2: M null; G={g:#x})\n")); } continue; }; // Step 3: expected ctx via the global path (informational cross-check). let expected_ctx = read_ptr(m + 0x778).unwrap_or(0); // Step 4: heap-scan for connMgr. let mut stats = (0u64, 0u64); let t0 = std::time::Instant::now(); let cands = scan_conn_mgr(m, expected_vtable, &mut stats); let scan_ms = t0.elapsed().as_millis(); if cands.is_empty() { if attempt % 10 == 0 { crate::write_log(&format!( "CTXDUMP: waiting (step 4: 0 connMgr candidates; G={g:#x} M={m:#x} \ expected_ctx={expected_ctx:#x}; scanned {} regions / {} MB in {scan_ms}ms)\n", stats.0, stats.1 / (1024 * 1024), )); } continue; } // Resolved. Latch so we dump exactly once. if CTX_DUMP_DONE.swap(true, Ordering::Relaxed) { return; } crate::write_log(&format!( "CTXDUMP: RESOLVED G={g:#x} M(=[G+0x360])={m:#x} expected_ctx(=[M+0x778])={expected_ctx:#x}\n\ CTXDUMP: scan found {} candidate(s) in {} regions / {} MB in {scan_ms}ms\n", cands.len(), stats.0, stats.1 / (1024 * 1024), )); // Log each candidate; pick the first with vtable[0] in .text AND a // pointer-shaped dispatcher at [P+0xc38] (the last run's false positive had // ASCII bytes there). All candidates share the same vtable by construction, // so [P+0xc38] is the real disambiguator. let mut chosen = cands[0]; for &p in &cands { let vt = read_ptr(p).unwrap_or(0); let vt0 = read_ptr(vt).unwrap_or(0); let disp = read_ptr(p + 0xc38).unwrap_or(0); let vt0_ok = in_text(base, vt0); let disp_ok = disp >= 0x10000; crate::write_log(&format!( "CTXDUMP: candidate P={p:#x} [P+0]={vt:#x} vtable[0]={vt0:#x} ({}) \ [P+8]={:#x} [P+0xc38]={disp:#x} ({})\n", if vt0_ok { "in .text" } else { "NOT .text" }, read_ptr(p + 8).unwrap_or(0), if disp_ok { "ptr-shaped" } else { "junk/ASCII" }, )); if vt0_ok && disp_ok && chosen == cands[0] { chosen = p; } } let conn_mgr = chosen; crate::write_log(&format!( "CTXDUMP: connMgr = {conn_mgr:#x} [P+0]={:#x} [P+8]={:#x} [P+0xc38]={:#x}\n", read_ptr(conn_mgr).unwrap_or(0), read_ptr(conn_mgr + 8).unwrap_or(0), read_ptr(conn_mgr + 0xc38).unwrap_or(0), )); // Dump 1: M — the ctx holder at [connMgr+8] (== M by construction). 128 bytes. let m_holder = read_ptr(conn_mgr + 8).unwrap_or(0); match read_bytes(m_holder, 128) { Some(b) if !b.is_empty() => { if b.len() < 128 { crate::write_log(&format!( "CTXDUMP: (M dump truncated to {} bytes at region end)\n", b.len() )); } hex_dump("M (ctx holder)", m_holder, &b); } _ => crate::write_log(&format!("CTXDUMP: M @{m_holder:#x} unreadable\n")), } // Dump 2: ctx = [M+0x778]. If null, that's the diagnosis (holder exists, // ctx not yet allocated) — log and skip. let ctx = read_ptr(m_holder + 0x778).unwrap_or(0); if ctx == 0 { crate::write_log(&format!( "CTXDUMP: ctx (=[M+0x778]) is NULL at menu (holder set up, ctx object not \ allocated) — skipping ctx dump\n" )); } else { match read_bytes(ctx, 512) { Some(b) if !b.is_empty() => { if b.len() < 512 { crate::write_log(&format!( "CTXDUMP: (ctx dump truncated to {} bytes at region end)\n", b.len() )); } hex_dump("ctx", ctx, &b); } _ => crate::write_log(&format!("CTXDUMP: ctx @{ctx:#x} unreadable\n")), } } crate::write_log("CTXDUMP: complete (one-shot; will not fire again)\n"); return; } crate::write_log( "CTXDUMP: gave up after ~3 min — connMgr never resolved (still pre-menu?)\n", ); }); } struct Target { /// DLL name (nul-terminated) or ignored when `main_exe` is true. module: &'static [u8], /// 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); } }