//! FIFA 17 verified patched-client capability negotiation (launcher side). //! //! The FIFA 17 backend suppresses its synthetic empty-My-Packs sentinel (pack id //! 65534) only when the *current* FIFA process has positively verified the //! CardsDLL resolver guard. autopatch proves that at runtime and advertises it on //! its stdout; the launcher parses that line, records the capability for the live //! FIFA process, and registers it with the backend over the same tiny stdlib-HTTP //! transport used by [`crate::account_sync`]. See //! `docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md`. //! //! Everything here is fail-closed: a line we cannot parse, or a registration POST //! that fails, simply leaves the backend on its default active-sentinel path. use serde::Serialize; use std::io::{Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::time::Duration; const CAPABILITY_NAME: &str = "empty_mypacks_resolver"; const CAPABILITY_PATH: &str = "/openfut/fifa17/capability"; const TIMEOUT: Duration = Duration::from_secs(3); /// Capabilities verified for the *current* FIFA process. Starts UNKNOWN at each /// launch and is discarded when that FIFA process ends — it is never persisted, /// so a previous launch's capability can never leak into a later one. #[derive(Debug, Clone, Default)] pub struct Fifa17ClientCapabilities { /// `Some(version)` once autopatch has verified the resolver guard for the /// live FIFA process; `None` while unknown / unverified. pub empty_mypacks_resolver: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct CapabilityRegistration<'a> { capability: &'a str, version: u32, persona_id: u64, fifa_pid: u64, } /// Pure parser for an autopatch stdout line. Returns `Some(version)` iff the raw /// line advertises the capability — it must contain both `verified capability` /// and `fifa17.empty_mypacks_resolver=` (with `` a `u32`). Non-advertising /// lines (e.g. `guard status=UNSUPPORTED_BUILD …`) and unrelated log output /// return `None`. Robust to a trailing ` fifa_pid=`. pub fn parse_capability_line(line: &str) -> Option { if !line.contains("verified capability") { return None; } parse_u32_after(line, "fifa17.empty_mypacks_resolver=") } /// Extract the FIFA pid from a `fifa_pid=` token if present. pub fn parse_fifa_pid(line: &str) -> Option { let digits = digits_after(line, "fifa_pid=")?; digits.parse::().ok() } fn parse_u32_after(line: &str, marker: &str) -> Option { digits_after(line, marker)?.parse::().ok() } fn digits_after<'a>(line: &'a str, marker: &str) -> Option<&'a str> { let start = line.find(marker)? + marker.len(); let rest = &line[start..]; let end = rest .find(|c: char| !c.is_ascii_digit()) .unwrap_or(rest.len()); if end == 0 { None } else { Some(&rest[..end]) } } /// Register the verified capability with the backend via `POST /// /openfut/fifa17/capability`. Modeled exactly on [`crate::account_sync::sync`]: /// a tiny stdlib `TcpStream` client, `Connection: close`, 3s timeouts, status /// line parsed, and any non-2xx (or connect/IO error) returned as `Err`. The /// caller logs the outcome; a failure is fail-closed — the backend records /// nothing and keeps the sentinel. pub fn register( host: &str, port: u16, persona_id: u64, fifa_pid: u64, version: u32, ) -> Result<(), String> { let host = host.trim(); let address = (host, port) .to_socket_addrs() .map_err(|error| format!("cannot resolve capability server {host}:{port}: {error}"))? .next() .ok_or_else(|| format!("capability server {host}:{port} resolved to no addresses"))?; let mut stream = TcpStream::connect_timeout(&address, TIMEOUT) .map_err(|error| format!("cannot connect to capability server {host}:{port}: {error}"))?; stream .set_read_timeout(Some(TIMEOUT)) .map_err(|error| format!("cannot set capability timeout: {error}"))?; stream .set_write_timeout(Some(TIMEOUT)) .map_err(|error| format!("cannot set capability timeout: {error}"))?; let payload = serde_json::to_vec(&CapabilityRegistration { capability: CAPABILITY_NAME, version, persona_id, fifa_pid, }) .map_err(|error| format!("cannot encode capability request: {error}"))?; let request = format!( "POST {CAPABILITY_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", payload.len() ); stream .write_all(request.as_bytes()) .and_then(|()| stream.write_all(&payload)) .map_err(|error| format!("cannot send capability request: {error}"))?; let mut response = Vec::new(); stream .read_to_end(&mut response) .map_err(|error| format!("cannot read capability response: {error}"))?; let separator = response .windows(4) .position(|window| window == b"\r\n\r\n") .ok_or_else(|| "capability server returned a malformed HTTP response".to_string())?; let headers = std::str::from_utf8(&response[..separator]) .map_err(|_| "capability server returned non-UTF-8 headers".to_string())?; let status = headers .lines() .next() .and_then(|line| line.split_whitespace().nth(1)) .and_then(|value| value.parse::().ok()) .ok_or_else(|| "capability server returned a malformed status line".to_string())?; if !(200..300).contains(&status) { let detail = String::from_utf8_lossy(&response[separator + 4..]); return Err(format!( "capability server rejected registration (HTTP {status}): {detail}" )); } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::net::TcpListener; use std::thread; #[test] fn parses_the_verified_capability_line() { let line = "[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242"; assert_eq!(parse_capability_line(line), Some(1)); assert_eq!(parse_fifa_pid(line), Some(4242)); } #[test] fn non_advertising_status_line_yields_none() { let line = "[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=4242 (no capability advertised)"; assert_eq!(parse_capability_line(line), None); } #[test] fn unrelated_log_line_yields_none() { let line = "[autopatch] patched /proc/4242/mem at rva 0x14858"; assert_eq!(parse_capability_line(line), None); } #[test] fn version_gating_is_left_to_the_backend() { let line = "[store-guard] verified capability fifa17.empty_mypacks_resolver=2 fifa_pid=7"; assert_eq!(parse_capability_line(line), Some(2)); } #[test] fn register_posts_capability_to_the_backend() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let server = thread::spawn(move || { let (mut socket, _) = listener.accept().unwrap(); let mut request = Vec::new(); loop { let mut chunk = [0; 1024]; let count = socket.read(&mut chunk).unwrap(); assert!(count > 0); request.extend_from_slice(&chunk[..count]); if let Some(separator) = request.windows(4).position(|w| w == b"\r\n\r\n") { let headers = String::from_utf8_lossy(&request[..separator]); let length = headers .lines() .find_map(|line| line.strip_prefix("Content-Length: ")) .unwrap() .parse::() .unwrap(); if request.len() >= separator + 4 + length { break; } } } let request = String::from_utf8_lossy(&request); assert!(request.starts_with("POST /openfut/fifa17/capability HTTP/1.1")); assert!(request.contains("\"capability\":\"empty_mypacks_resolver\"")); assert!(request.contains("\"version\":1")); assert!(request.contains("\"personaId\":12345678")); assert!(request.contains("\"fifaPid\":4242")); let body = r#"{"status":"OK"}"#; write!( socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ) .unwrap(); }); register("127.0.0.1", port, 12345678, 4242, 1).unwrap(); server.join().unwrap(); } }