From 7dcf610b714cdc4075329199154da598bc01004a Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 3 Jul 2026 15:58:17 -0700 Subject: [PATCH] openfut-hook: RE instrumentation for the Blaze dial-gate investigation In-process, read-only probes and transport observation built while closing the online/FUT route from both the memory and network sides. - probe.rs / dial_notification.rs: menu-time ctx dump, connMgr enumerator, synthetic dial-notification + direct-call dial trigger, and the [element+0x40] container write-watchpoint. All env-gated, one-shot, VirtualQuery-guarded; none alter game state by default. - transport_watch.rs + connect/connectex/hooks/lib: M0 transport observation (grep-friendly TRANSPORT_WATCH logging on the existing getaddrinfo/connect/ WSAConnect/ConnectEx detours) and an IPv6 (v4-mapped) EA-redirect so the game's IPv6 :443 dials land on the bridge instead of the dead servers. Findings: the game never initiates a Blaze connection offline; the dial handler is registered by a self-registering, message-driven state machine whose container stays empty with no Blaze exchange. See openfut-bridge docs/closure-and-preservation.md. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 5 + openfut-hook/src/connect_hook.rs | 126 ++- openfut-hook/src/connectex_hook.rs | 48 +- openfut-hook/src/dial_notification.rs | 183 ++++ openfut-hook/src/hooks.rs | 2 + openfut-hook/src/lib.rs | 17 + openfut-hook/src/probe.rs | 1258 ++++++++++++++++++++++++- openfut-hook/src/transport_watch.rs | 225 +++++ 8 files changed, 1785 insertions(+), 79 deletions(-) create mode 100644 openfut-hook/src/dial_notification.rs create mode 100644 openfut-hook/src/transport_watch.rs diff --git a/.gitignore b/.gitignore index 2f7896d..10dc867 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ target/ + +# runtime SQLite DB (created when services run from this dir) +openfut.db +openfut.db-shm +openfut.db-wal diff --git a/openfut-hook/src/connect_hook.rs b/openfut-hook/src/connect_hook.rs index 65ef5e0..4dc3856 100644 --- a/openfut-hook/src/connect_hook.rs +++ b/openfut-hook/src/connect_hook.rs @@ -28,6 +28,27 @@ struct SockaddrIn { sin_zero: [u8; 8], } +const AF_INET6: u16 = 23; // Windows AF_INET6 value (we run under the Win ABI in Wine) + +/// Win32 `sockaddr_in6`. `sin6_port` is network byte order; `sin6_addr` is 16 raw +/// address bytes in network order. 28 bytes total. +#[repr(C)] +struct SockaddrIn6 { + sin6_family: u16, + sin6_port: u16, + sin6_flowinfo: u32, + sin6_addr: [u8; 16], + sin6_scope_id: u32, +} + +/// IPv4-mapped IPv6 loopback: `::ffff:127.0.0.1`. An `AF_INET6` socket connecting to +/// this sends real IPv4 packets to 127.0.0.1, so the connection lands on the bridge's +/// existing IPv4 listener on :8443 — no separate IPv6 listener needed. The game's own +/// EA dials already use v4-mapped addresses (`::ffff:x.x.x.x`), so its sockets are not +/// `IPV6_V6ONLY` and will accept this target. +const V4MAPPED_LOOPBACK: [u8; 16] = + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1]; + // Address of ws2_32!connect (set at hook installation) static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0); @@ -61,40 +82,87 @@ unsafe fn restore_original(target: *mut u8) { VirtualProtect(target as _, 14, old, &mut old); } -unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 16], i32)> { - if namelen < 8 { return None; } - let sa = &*(name as *const SockaddrIn); - if sa.sin_family != AF_INET { return None; } +/// If `name` is an EA-relevant connect target, return a rewritten sockaddr pointing at +/// the local bridge (plus its byte length). Handles BOTH `AF_INET` and `AF_INET6`: the +/// game's Blaze/DirtySDK stack dials EA over IPv6 (v4-mapped) on :443, and the old +/// IPv4-only path let those slip straight past us to the real (dead) servers. +/// +/// The returned buffer is 28 bytes (enough for a `sockaddr_in6`); the second value is +/// how many of those bytes are meaningful (16 for v4, 28 for v6). `pub(crate)` so the +/// ConnectEx path can share this one implementation. +pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> { + if namelen < 8 || name.is_null() { + return None; + } + // The first u16 of any sockaddr is the address family. + let family = *(name as *const u16); + let mut buf = [0u8; 28]; - let orig = sa.sin_addr.to_le_bytes(); - let orig_port = u16::from_be(sa.sin_port); - - let new_port_nbo = match sa.sin_port { - PORT_HTTPS_NBO => PORT_BRIDGE_NBO, - #[cfg(not(feature = "capture_baseline"))] - PORT_LSX_NBO => PORT_LSX_TARGET_NBO, - PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, - PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, - _ => return None, - }; - - crate::write_log(&format!( - "connect_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n", - orig[3], orig[2], orig[1], orig[0], orig_port, - u16::from_be(new_port_nbo) - )); - - let mut buf = [0u8; 16]; - let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn); - out.sin_family = AF_INET; - out.sin_port = new_port_nbo; - out.sin_addr = ADDR_LOOPBACK_NBO; - Some((buf, 16)) + match family { + AF_INET => { + // SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read. + let sa = &*(name as *const SockaddrIn); + let new_port_nbo = match sa.sin_port { + PORT_HTTPS_NBO => PORT_BRIDGE_NBO, + #[cfg(not(feature = "capture_baseline"))] + PORT_LSX_NBO => PORT_LSX_TARGET_NBO, + PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, + PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, + _ => return None, + }; + // sin_addr is network order; to_le_bytes gives memory order = the dotted + // quad, so b[0].b[1].b[2].b[3] is correct (the old code printed it reversed). + let o = sa.sin_addr.to_le_bytes(); + crate::write_log(&format!( + "connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n", + o[0], o[1], o[2], o[3], u16::from_be(sa.sin_port), + u16::from_be(new_port_nbo) + )); + // SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write. + let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn); + out.sin_family = AF_INET; + out.sin_port = new_port_nbo; + out.sin_addr = ADDR_LOOPBACK_NBO; + Some((buf, 16)) + } + AF_INET6 => { + if namelen < 28 { + return None; + } + // SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6). + let sa6 = &*(name as *const SockaddrIn6); + // LSX is IPv4-only (anadius keys on it), so it is intentionally omitted here. + let new_port_nbo = match sa6.sin6_port { + PORT_HTTPS_NBO => PORT_BRIDGE_NBO, + PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, + PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, + _ => return None, + }; + let a = sa6.sin6_addr; + crate::write_log(&format!( + "connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n", + a[0], a[1], a[14], a[15], u16::from_be(sa6.sin6_port), + u16::from_be(new_port_nbo) + )); + // SAFE: buf is exactly 28 bytes == sizeof(sockaddr_in6). + let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6); + out.sin6_family = AF_INET6; + out.sin6_port = new_port_nbo; + out.sin6_flowinfo = 0; + out.sin6_addr = V4MAPPED_LOOPBACK; + out.sin6_scope_id = 0; + Some((buf, 28)) + } + _ => None, + } } pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: i32) -> i32 { let addr = CONNECT_ADDR.load(Ordering::Relaxed) as *mut u8; + // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). + crate::transport_watch::note_connect("connect", name, namelen, s); + // Log every call so we can confirm the hook fires at all if namelen >= 8 { let sa = &*(name as *const SockaddrIn); @@ -157,6 +225,8 @@ pub unsafe extern "system" fn hooked_wsa_connect( caller: *const (), callee: *const (), sqos: *const (), gqos: *const (), ) -> i32 { + // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). + crate::transport_watch::note_connect("WSAConnect", name, namelen, s); let real = REAL_WSA.get().copied().unwrap(); if let Some((buf, len)) = redirect_if_ea(name, namelen) { real(s, buf.as_ptr(), len, caller, callee, sqos, gqos) diff --git a/openfut-hook/src/connectex_hook.rs b/openfut-hook/src/connectex_hook.rs index a50b6b7..71a0070 100644 --- a/openfut-hook/src/connectex_hook.rs +++ b/openfut-hook/src/connectex_hook.rs @@ -6,12 +6,8 @@ use core::sync::atomic::{AtomicUsize, Ordering}; use core::ffi::c_void; -const AF_INET: u16 = 2; -const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian -const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian -const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian -const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian -const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian +// Address rewriting (v4 + v6) is shared from connect_hook::redirect_if_ea, so the port +// constants and sockaddr structs no longer live here. // SIO_GET_EXTENSION_FUNCTION_POINTER const SIO_GET_EXT_FN: u32 = 0xC8000006; @@ -23,14 +19,6 @@ const CONNECTEX_GUID: [u8; 16] = [ 0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E, ]; -#[repr(C)] -struct SockaddrIn { - sin_family: u16, - sin_port: u16, - sin_addr: u32, - sin_zero: [u8; 8], -} - // The real ConnectEx pointer, saved after WSAIoctl returns it static REAL_CONNECTEX: AtomicUsize = AtomicUsize::new(0); @@ -91,31 +79,13 @@ unsafe extern "system" fn hooked_connectex( ) -> i32 { let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed)); - if namelen >= 8 { - let sa = &*(name as *const SockaddrIn); - if sa.sin_family == AF_INET { - let o = sa.sin_addr.to_le_bytes(); - let orig_port = u16::from_be(sa.sin_port); - let new_port_nbo = match sa.sin_port { - PORT_HTTPS_NBO => PORT_BRIDGE_NBO, - PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, - PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, - _ => 0, - }; - if new_port_nbo != 0 { - crate::write_log(&format!( - "connectex_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n", - o[3], o[2], o[1], o[0], orig_port, - u16::from_be(new_port_nbo) - )); - let mut redirect = [0u8; 16]; - let out = &mut *(redirect.as_mut_ptr() as *mut SockaddrIn); - out.sin_family = AF_INET; - out.sin_port = new_port_nbo; - out.sin_addr = ADDR_LOOPBACK_NBO; - return real_fn(s, redirect.as_ptr(), 16, send_buf, send_data_len, bytes_sent, overlapped); - } - } + // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). + crate::transport_watch::note_connect("ConnectEx", name, namelen, s); + + // Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx + // dials get the same IPv6 handling as plain connect(). + if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) { + return real_fn(s, buf.as_ptr(), len, send_buf, send_data_len, bytes_sent, overlapped); } real_fn(s, name, namelen, send_buf, send_data_len, bytes_sent, overlapped) } diff --git a/openfut-hook/src/dial_notification.rs b/openfut-hook/src/dial_notification.rs new file mode 100644 index 0000000..60b96bf --- /dev/null +++ b/openfut-hook/src/dial_notification.rs @@ -0,0 +1,183 @@ +//! Synthetic "notification" struct for the direct-call dial trigger. +//! +//! STATIC ARTIFACT ONLY — this module builds the byte layout the dial handler +//! (FIFA23.exe+0x4f4d360) expects in its `rdx` argument, plus a do-nothing +//! completion callback. It does NOT call the game, does NOT install any detour, +//! and is NOT wired into the hook yet. The invocation phase (later) consumes +//! `build_notification()` + `completion_stub`. +//! +//! Layout contract (from the 2026-07-03 dial-branch RE report on 0x144f4d590): +//! [+0x00] byte : entry gate — MUST be non-zero (else the error path fires). => 1 +//! [+0x80] qword : completion delegate fn pointer. => &completion_stub +//! [+0x88] qword : delegate capture #1. => 0 +//! [+0x90] qword : delegate capture #2. => 0 +//! [+0xa0] dword : RpcJob key/priority (copied, never compared on dial path). => 0 +//! everything else in [0x00..0x100] : 0 +//! The RE confirmed no other offset in this range is read on the success path. +//! Total size 0x100 (256): the tail 0xa4..0x100 is zero padding — cheap insurance +//! against a read we might have missed. Any offset here is TODO/CONFIRM against the +//! RE report; if the game contradicts it at runtime, stop and re-verify. + +// This module is deliberately unused for now (the invocation phase will call into +// it). Silence "never used" warnings until then rather than sprinkle #[allow] on +// each item. Remove this once the trigger wires the API up. +#![allow(dead_code)] + +use core::sync::atomic::{AtomicU32, Ordering}; + +/// Size of the notification struct, in bytes. 0x100 = 256. +const NOTIFICATION_SIZE: usize = 0x100; + +// --- field offsets (named so the code reads like the RE contract) ------------- +const OFF_GATE: usize = 0x00; // byte, must be non-zero +const OFF_DELEGATE_FN: usize = 0x80; // qword, completion fn pointer +const OFF_DELEGATE_CAP1: usize = 0x88; // qword, capture (0) +const OFF_DELEGATE_CAP2: usize = 0x90; // qword, capture (0) +const OFF_KEY: usize = 0xa0; // dword, job key/priority (0) + +/// Counts how many times `completion_stub` has been entered. +/// +/// Why `AtomicU32` and not `static mut u32`: a `static mut` needs `unsafe` to +/// touch and, worse, gives *undefined behaviour* if two threads write it at once +/// (a data race). The completion callback may be invoked from an arbitrary game +/// thread, so a plain counter would race. `AtomicU32` makes increment a single +/// lock-free hardware instruction with well-defined concurrent semantics, and it +/// needs no `unsafe`. `Ordering::Relaxed` is enough here: we only care about the +/// count value, not about ordering it against other memory. +static COMPLETION_STUB_CALLS: AtomicU32 = AtomicU32::new(0); + +/// The completion callback the game may invoke when the RpcJob finishes. +/// +/// `extern "C"`: on the `x86_64-pc-windows-gnu` target this selects the Microsoft +/// x64 calling convention — exactly how the game invokes the pointer (`call r10`, +/// args in rcx/rdx/r8/r9, return in rax, caller cleans the stack). Matching the +/// convention is what makes it safe for the game to call us. +/// +/// We declare four pointer-sized params and ignore them. The RE showed the delegate +/// is called with e.g. an HRESULT in `rdx` and a `this`-like pointer in `rcx`; the +/// success-path completion may pass different values. Because Win64 is caller-clean +/// and puts the first four integer args in registers, declaring four ignored args is +/// safe no matter what the caller actually passes — we simply never read them. +/// +/// The body does the absolute minimum: bump the atomic counter and return 0. NO +/// logging, NO allocation, NO calls — a completion callback can fire from any game +/// context, and even a log write there could be unsafe. Observe from outside via +/// `completion_stub_call_count()` instead. +/// +/// Returns `usize` = 0, which reads as an `S_OK`-shaped HRESULT if the caller looks +/// at the return value. (Returning void would be equally fine; 0 is a safe default.) +pub extern "C" fn completion_stub(_a: usize, _b: usize, _c: usize, _d: usize) -> usize { + // `fetch_add` is a single atomic read-modify-write (lock xadd) — no lock, no + // syscall, no allocation. Safe to call from any thread/context. + COMPLETION_STUB_CALLS.fetch_add(1, Ordering::Relaxed); + 0 +} + +/// Read how many times `completion_stub` has fired. For an outside observer thread — +/// keeps all I/O out of the stub itself. +pub fn completion_stub_call_count() -> u32 { + COMPLETION_STUB_CALLS.load(Ordering::Relaxed) +} + +/// Write a little-endian u64 into `buf` starting at `offset`. +/// +/// Endianness matters because we're hand-laying a memory image the game will read +/// back as a raw pointer/integer. x86-64 is *little-endian*: the least-significant +/// byte sits at the lowest address. `value.to_le_bytes()` produces the 8 bytes in +/// exactly that order, so when the game does `mov rax,[ptr]` it reconstructs the +/// original `value`. Using the native byte order by hand (or `transmute`) would be +/// wrong on a big-endian machine; `to_le_bytes` states the intent explicitly. +/// +/// `buf[offset..offset + 8]` is an 8-byte sub-slice; `copy_from_slice` copies the +/// 8-byte array into it. Both sides are length 8, so it can't panic here. (This is +/// the standard, safe way to poke a fixed-width integer into a `[u8]`.) +fn write_u64_le(buf: &mut [u8], offset: usize, value: u64) { + buf[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +/// Write a little-endian u32 into `buf` starting at `offset`. (Same idea as +/// `write_u64_le`, 4 bytes wide.) +fn write_u32_le(buf: &mut [u8], offset: usize, value: u32) { + buf[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +/// Build the fully-populated notification struct, ready to be passed by pointer to +/// the dial handler as its `rdx` argument. +/// +/// Returns a `[u8; 0x100]` by value. Why a byte array and not a `#[repr(C)]` struct: +/// the layout is a precise *offset* contract recovered by RE, with meaningful data +/// only at 0x00/0x80/0x88/0x90/0xa0 and zeros elsewhere. A byte array makes every +/// offset literally visible and immune to any field-ordering/padding surprise. A +/// `#[repr(C)] struct` with explicit padding fields would work too, but it's easier +/// to get a padding byte wrong than to index a flat array. (For future reference: +/// the `bytemuck` crate can safely reinterpret a `#[repr(C)]` struct as `&[u8]` +/// zero-copy — worth knowing, but overkill here and an extra dependency.) +pub fn build_notification() -> [u8; NOTIFICATION_SIZE] { + // Start fully zeroed. This already satisfies every "= 0" field (caps at +0x88/ + // +0x90, the key at +0xa0, and all padding); we only need to set the non-zero + // fields below. + let mut buf = [0u8; NOTIFICATION_SIZE]; + + // [+0x00] entry gate: must be non-zero to reach the dial path. + buf[OFF_GATE] = 1; + + // [+0x80] completion delegate function pointer = &completion_stub. + // + // `completion_stub as *const ()`: a *function item* in Rust is a zero-sized, + // unique type, not a value. Casting it to a raw pointer coerces it to a function + // pointer and then to an untyped code pointer `*const ()` — i.e. the address of + // the function's machine code. The intermediate `*const ()` before `as u64` is + // the idiomatic form: it says "treat this as an address" and also avoids the + // `clippy`/rustc "direct cast of function item into an integer" lint you'd get + // from `completion_stub as u64`. + let stub_addr = completion_stub as *const () as u64; + write_u64_le(&mut buf, OFF_DELEGATE_FN, stub_addr); + + // [+0x88]/[+0x90] delegate captures = 0. Already zero from initialization; write + // them explicitly so the layout intent is visible at a glance. + write_u64_le(&mut buf, OFF_DELEGATE_CAP1, 0); + write_u64_le(&mut buf, OFF_DELEGATE_CAP2, 0); + + // [+0xa0] RpcJob key/priority dword = 0 (copied, never compared on the dial path). + write_u32_le(&mut buf, OFF_KEY, 0); + + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notification_layout() { + let n = build_notification(); + + // Total size is exactly 0x100. + assert_eq!(n.len(), NOTIFICATION_SIZE); + + // [+0x00] gate byte == 1. + assert_eq!(n[0x00], 1); + + // [+0xa0..0xa4] as u32 == 0. + // `try_into().unwrap()` turns the 4-byte slice into a `[u8; 4]` (it can only + // fail if the slice weren't length 4, which it is), and `from_le_bytes` + // reads it back the same little-endian way we wrote it. + let key = u32::from_le_bytes(n[0xa0..0xa4].try_into().unwrap()); + assert_eq!(key, 0); + + // [+0x80..0x88] as u64 == address of completion_stub. + let stub = u64::from_le_bytes(n[0x80..0x88].try_into().unwrap()); + assert_eq!(stub, completion_stub as *const () as u64); + + // [+0x88..0x90] and [+0x90..0x98] captures == 0. + assert_eq!(u64::from_le_bytes(n[0x88..0x90].try_into().unwrap()), 0); + assert_eq!(u64::from_le_bytes(n[0x90..0x98].try_into().unwrap()), 0); + } + + #[test] + fn stub_counter_increments() { + let before = completion_stub_call_count(); + let _ = completion_stub(0, 0, 0, 0); + assert_eq!(completion_stub_call_count(), before + 1); + } +} diff --git a/openfut-hook/src/hooks.rs b/openfut-hook/src/hooks.rs index 6246c4c..30842d2 100644 --- a/openfut-hook/src/hooks.rs +++ b/openfut-hook/src/hooks.rs @@ -54,6 +54,8 @@ pub unsafe extern "system" fn hooked_getaddrinfo( if !node_name.is_null() { if let Ok(host) = CStr::from_ptr(node_name as *const i8).to_str() { crate::write_log(&format!("openfut_hook: getaddrinfo({host})\n")); + // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). + crate::transport_watch::note_getaddrinfo(host); if is_ea_host(host) { // Apply the ProtoSSL cert-verify bypass the first time we see an EA // hostname — EAWebKit.dll must be loaded by now because it's calling us. diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index 1195a17..fd65dc8 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -1,6 +1,7 @@ mod config; mod connect_hook; mod connectex_hook; +mod dial_notification; mod hooks; mod iat; mod origin_spy; @@ -10,6 +11,7 @@ mod probe; mod recv_hook; mod ssl_patch; mod tls_bypass; +mod transport_watch; use windows_sys::Win32::{ Foundation::{BOOL, HMODULE, TRUE}, @@ -25,6 +27,18 @@ pub(crate) fn write_log(msg: &str) { { let _ = f.write_all(msg.as_bytes()); } } +/// Force the log to stable storage. `write_log` already opens+closes the file per line, +/// so nothing is buffered *inside our process* (a process crash can't lose a written +/// line). `sync_all` additionally flushes the OS cache to disk, for durability even +/// across a full system crash. We call this right before the dial trigger's call so the +/// pre-call log line is guaranteed on disk if the call faults. +#[allow(dead_code)] +pub(crate) fn flush_log() { + if let Ok(f) = std::fs::OpenOptions::new().append(true).open(r"C:\openfut_hook.log") { + let _ = f.sync_all(); + } +} + #[no_mangle] pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL { if reason == DLL_PROCESS_ATTACH { install_hooks(module); } @@ -33,6 +47,9 @@ pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) unsafe fn install_hooks(module: HMODULE) { write_log("openfut_hook: DllMain fired\n"); + // Milestone-0 transport watch: arm (or note disarmed) from env once, up front, so + // the getaddrinfo/connect/ConnectEx detours below can log Blaze-flavored activity. + transport_watch::arm_from_env(); let ip = config::read_redirect_ip(module); hooks::set_redirect_ip(ip); diff --git a/openfut-hook/src/probe.rs b/openfut-hook/src/probe.rs index 92482c1..c80b937 100644 --- a/openfut-hook/src/probe.rs +++ b/openfut-hook/src/probe.rs @@ -15,11 +15,12 @@ //! //! 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, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; +use windows_sys::Win32::System::Threading::GetCurrentThreadId; use windows_sys::Win32::System::Memory::{ - VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE, - PAGE_GUARD, PAGE_NOACCESS, + VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, MEM_PRIVATE, + PAGE_EXECUTE_READWRITE, PAGE_GUARD, PAGE_NOACCESS, }; /// Fault-safe pointer read: returns None unless `ptr` lands in a committed, readable @@ -104,6 +105,12 @@ 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; } @@ -155,12 +162,724 @@ pub unsafe fn install_listener_probe() { } 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 @@ -176,9 +895,9 @@ pub fn install_force_connect() { 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), up to ~60s. + // Wait for the ctx chain to be valid (FIFA past bootstrap / online), up to ~5 min. let mut fired = 0; - for i in 0..120u32 { + for i in 0..600u32 { std::thread::sleep(std::time::Duration::from_millis(500)); let ctx = read_ptr(x_slot) .filter(|&x| x != 0) @@ -187,21 +906,508 @@ pub fn install_force_connect() { .and_then(|m| read_ptr(m + 0x778)) .filter(|&c| c != 0); let Some(ctx) = ctx else { continue; }; - // Give the game a few seconds settled at "connecting" before poking. - if i < 60 { 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 >= 3 { break; } - std::thread::sleep(std::time::Duration::from_millis(3000)); + 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], @@ -320,9 +1526,23 @@ pub fn install_probes_deferred() { install_probes(); install_listener_probe(); install_state_sampler(); - // install_force_connect(); // manual-only: forcing experiment (2026-07-02); - // re-enable to auto-invoke nucleusConnectREST(). Off by default so normal - // probe builds don't poke the online flow. + 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. }); } @@ -346,3 +1566,17 @@ pub unsafe fn install_probes() { 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); + } +} diff --git a/openfut-hook/src/transport_watch.rs b/openfut-hook/src/transport_watch.rs new file mode 100644 index 0000000..cfd573f --- /dev/null +++ b/openfut-hook/src/transport_watch.rs @@ -0,0 +1,225 @@ +//! Milestone 0 — Blaze transport reachability observation. +//! +//! PURE LOGGING, NO NEW DETOURS. This module does not hook anything itself. It is +//! called from the three Winsock detours the hook ALREADY installs — getaddrinfo +//! (`hooks.rs`), connect/WSAConnect (`connect_hook.rs`) and ConnectEx +//! (`connectex_hook.rs`) — and, when armed, emits a single grep-friendly +//! `TRANSPORT_WATCH:` line per resolution/connect so we can answer one question: +//! +//! Does the FIFA 23 client attempt ANY Blaze-flavored transport activity across a +//! full menu+FUT session, or none at all? +//! +//! Everything here is READ-ONLY: we parse the hostname / sockaddr the game passed +//! only to describe it in the log. We never change a resolution result or a +//! connection target — that redirect logic lives in the detours themselves and is +//! untouched. The env kill switch `OPENFUT_TRANSPORT_WATCH=1` gates all output; +//! disarmed (default) this module is inert (each entry point returns immediately). +//! +//! Future-reference note (beyond-beginner, deliberately NOT done here): a +//! types-first design would model a `ConnectTarget` enum (Inet{ip,port} / NonInet / +//! Short) and a `TransportEvent` and route them through the `tracing` crate with +//! structured fields, instead of hand-formatting strings into a flat log file. That +//! buys machine-parseable logs and log levels. For a one-shot observation gate, +//! flat `write_log` lines that `grep` cleanly are the lower-ceremony choice. + +use core::sync::atomic::{AtomicBool, Ordering}; + +/// Armed once at DLL load from `OPENFUT_TRANSPORT_WATCH`. `AtomicBool` (not a plain +/// `static mut bool`) because the detours that read it run on arbitrary game threads; +/// an atomic gives race-free reads with no `unsafe`. `Relaxed` is enough — this is a +/// standalone flag with no ordering relationship to other memory. +static ARMED: AtomicBool = AtomicBool::new(false); + +/// Read the env var once, at DLL load, and log the arm state. Called from `DllMain` +/// (`install_hooks`). Reading the env in-process (rather than as a command prefix) is +/// what makes the switch actually propagate through the umu/Proton launch — the same +/// gotcha the probe switches hit; it works because the launch script `export`s it. +pub fn arm_from_env() { + let on = std::env::var("OPENFUT_TRANSPORT_WATCH") + .map(|v| v == "1") + .unwrap_or(false); + ARMED.store(on, Ordering::Relaxed); + crate::write_log(&format!( + "TRANSPORT_WATCH: {} (env OPENFUT_TRANSPORT_WATCH)\n", + if on { "ARMED" } else { "disarmed" } + )); +} + +fn armed() -> bool { + ARMED.load(Ordering::Relaxed) +} + +/// True if `host` looks like EA/Blaze infrastructure. Broad on purpose: this is a log +/// classifier that makes a hit visually pop (`<-- BLAZE/EA-FLAVORED`), NOT a routing +/// decision. The actual redirect decision stays in `hooks::is_ea_host`, which is +/// deliberately narrower and unchanged. +fn is_blaze_flavored(host: &str) -> bool { + let h = host.to_ascii_lowercase(); + [ + "redirector", + "gosredirector", + "blaze", + "gosca", + "easfc", + "utas", + "fut", + "ea.com", + "easports", + ] + .iter() + .any(|k| h.contains(k)) +} + +/// Log one getaddrinfo hostname. Self-gates on the arm flag, so the call site can be +/// unconditional. The existing `openfut_hook: getaddrinfo(...)` line stays; this adds +/// the tagged, classified line so `grep TRANSPORT_WATCH` sees the full resolution set +/// and a Blaze host stands out. +pub fn note_getaddrinfo(host: &str) { + if !armed() { + return; + } + let tag = if is_blaze_flavored(host) { + " <-- BLAZE/EA-FLAVORED" + } else { + "" + }; + crate::write_log(&format!("TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n")); +} + +const AF_INET: u16 = 2; // IPv4 +const AF_INET6: u16 = 23; // IPv6 (Windows value; Linux uses 10 — we're in Wine/Win ABI) + +/// Minimal view of a `sockaddr_in`; the first `u16` is the address family for ANY +/// sockaddr, so reading this layout is safe enough to classify the family even when +/// the real struct is a `sockaddr_un` or larger — we only trust the rest once we've +/// confirmed `sin_family == AF_INET`. +#[repr(C)] +struct SockaddrIn { + sin_family: u16, + sin_port: u16, + sin_addr: u32, + sin_zero: [u8; 8], +} + +/// Minimal view of a `sockaddr_in6` (Win32 layout). `sin6_port` is network byte order; +/// `sin6_addr` is the 16 raw address bytes in network order. We ignore flowinfo/scope. +#[repr(C)] +struct SockaddrIn6 { + sin6_family: u16, + sin6_port: u16, + sin6_flowinfo: u32, + sin6_addr: [u8; 16], + sin6_scope_id: u32, +} + +/// Is `port` a known/suspected Blaze port? SHAPE — public general knowledge; the exact +/// port for FIFA23's Blaze version is UNKNOWN. 42127 main, 10041/10744 redirector +/// variants, 3659 classic redirector. +fn is_blaze_port(port: u16) -> bool { + matches!(port, 42127 | 10744 | 3659 | 10041) +} + +/// Log one outbound connect attempt. `api` names the call path (`connect` / +/// `WSAConnect` / `ConnectEx`) so we can tell which Winsock entry the client used. +/// +/// SAFETY: `name` must point to at least `namelen` readable bytes — it's the sockaddr +/// the game just handed to a Winsock connect API, so that always holds at the call +/// sites. We read it read-only and never write through it. `s` is the socket handle, +/// used only to query `SO_TYPE` (TCP=1 / UDP=2) so a real Blaze TCP dial is +/// distinguishable from UDP game/voice traffic. +pub unsafe fn note_connect(api: &str, name: *const u8, namelen: i32, s: usize) { + if !armed() { + return; + } + if name.is_null() || namelen < 8 { + crate::write_log(&format!( + "TRANSPORT_WATCH: {api} (no/short sockaddr, namelen={namelen})\n" + )); + return; + } + // SAFE: name is non-null and >= 8 bytes (checked above); the first u16 is the + // address family for ANY sockaddr, so reading it is valid regardless of the real + // struct type. We only trust family-specific fields after matching the family. + let family = *(name as *const u16); + + // SAFE: getsockopt is a read-only Winsock query on a valid socket handle; a bad + // handle just leaves ty=-1, which we log verbatim. TCP=1 / UDP=2. + let sock_type = { + use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE}; + let mut ty: i32 = -1; + let mut len: i32 = 4; + getsockopt( + s, + SOL_SOCKET as i32, + SO_TYPE, + &mut ty as *mut i32 as *mut u8, + &mut len, + ); + ty + }; + + match family { + AF_INET => { + // SAFE: family is AF_INET and namelen >= 8 == sizeof(sockaddr_in) fields we read. + let sa = &*(name as *const SockaddrIn); + // sin_addr holds the address in NETWORK byte order; on little-endian x86, + // to_le_bytes reproduces those 4 bytes in memory order, which IS the dotted + // quad. So b[0].b[1].b[2].b[3] is correct. (The legacy connect_hook log line + // prints these reversed — a cosmetic bug there; this M0 line is the correct + // one to trust.) + let b = sa.sin_addr.to_le_bytes(); + let port = u16::from_be(sa.sin_port); + let is_loopback = b[0] == 127; + let is_lsx = matches!(port, 3216 | 3217); // known-good LSX channel; not Blaze + let mut tag = String::new(); + if is_blaze_port(port) { + tag.push_str(" <-- BLAZE-PORT"); + } + // A loopback connect on anything other than LSX is the situation-(a) signal. + if is_loopback && !is_lsx { + tag.push_str(" <-- LOOPBACK non-LSX"); + } + crate::write_log(&format!( + "TRANSPORT_WATCH: {api} target={}.{}.{}.{}:{port} sock_type={sock_type}{tag}\n", + b[0], b[1], b[2], b[3] + )); + } + AF_INET6 => { + if namelen < 28 { + crate::write_log(&format!( + "TRANSPORT_WATCH: {api} family=INET6 (short sockaddr, namelen={namelen})\n" + )); + return; + } + // SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6). + let sa = &*(name as *const SockaddrIn6); + let a = sa.sin6_addr; // 16 bytes, network order + let port = u16::from_be(sa.sin6_port); + // Format as 8 colon-separated hex groups (not compressed — clarity over + // brevity for a log meant to be grepped). + let hex = (0..8) + .map(|i| format!("{:02x}{:02x}", a[i * 2], a[i * 2 + 1])) + .collect::>() + .join(":"); + // ::1 = loopback: first 15 bytes zero, last byte 1. + let is_loopback = a[..15].iter().all(|&x| x == 0) && a[15] == 1; + let mut tag = String::new(); + if is_blaze_port(port) { + tag.push_str(" <-- BLAZE-PORT"); + } + if is_loopback { + tag.push_str(" <-- IPv6 LOOPBACK (::1)"); + } + crate::write_log(&format!( + "TRANSPORT_WATCH: {api} target=[{hex}]:{port} sock_type={sock_type} (IPv6){tag}\n" + )); + } + other => { + // AF_UNIX=1 or anything else — where a named-pipe/unix-socket-style local + // Blaze transport would surface. + crate::write_log(&format!( + "TRANSPORT_WATCH: {api} family={other} (non-INET — possible AF_UNIX/pipe-like)\n" + )); + } + } +}