7dcf610b71
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 <noreply@anthropic.com>
226 lines
9.5 KiB
Rust
226 lines
9.5 KiB
Rust
//! 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::<Vec<_>>()
|
|
.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"
|
|
));
|
|
}
|
|
}
|
|
}
|