diff --git a/src/app.rs b/src/app.rs index 3bfce37..a6d204a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -50,6 +50,11 @@ pub struct LauncherApp { /// Result of the last "Arm client" click, shown inline beneath the button so /// the outcome appears where the user acted — not on another tab. arm_status: Option<(bool, String)>, + + /// Capabilities verified for the *current* FIFA process (shared with the + /// autopatch stdout reader). Reset to unknown at each launch so a new FIFA + /// process never inherits a previous launch's capability. + fifa17_caps: Arc>, } impl LauncherApp { @@ -86,6 +91,7 @@ impl LauncherApp { local_services_message: None, preflight: None, arm_status: None, + fifa17_caps: Arc::new(Mutex::new(Default::default())), } } @@ -314,6 +320,9 @@ impl LauncherApp { } else if ap_running { if ui.button("Stop").clicked() { self.autopatch.stop(&self.game_logs, Service::Autopatch); + // The verified capability belongs to the FIFA process + // autopatch was serving; drop it when autopatch stops. + *self.fifa17_caps.lock().unwrap() = Default::default(); } } else if ui.button("Start").clicked() { let _ = self.start_local_service(Service::Autopatch); @@ -448,6 +457,9 @@ impl LauncherApp { } fn launch_game(&mut self) { + // A new FIFA process starts UNKNOWN: never inherit a prior launch's + // verified capability. The autopatch stdout reader re-populates this. + *self.fifa17_caps.lock().unwrap() = Default::default(); if let Err(message) = self.config.validate_launch_config() { self.game_logs .lock() @@ -538,6 +550,21 @@ impl LauncherApp { use crate::local_services::spawn; let py = self.config.fifa17_python.clone(); let dir = self.config.fifa17_tools_dir.clone(); + let persona_id = self.config.fut_persona_id; + let persona_name = self.config.fut_persona_name.clone(); + let logs = Arc::clone(&self.game_logs); + // Only autopatch advertises the verified resolver guard, so only it + // receives the shared capability sink; LSX passes None. + let capability = match which { + crate::local_services::Service::Autopatch => { + Some(crate::local_services::CapabilityWiring { + server_host: self.config.openfut_server_host.clone(), + account_sync_port: self.config.openfut_account_sync_port, + sink: Arc::clone(&self.fifa17_caps), + }) + } + crate::local_services::Service::Lsx => None, + }; let slot = match which { crate::local_services::Service::Lsx => &mut self.lsx, crate::local_services::Service::Autopatch => &mut self.autopatch, @@ -546,9 +573,10 @@ impl LauncherApp { which, &py, &dir, - self.config.fut_persona_id, - &self.config.fut_persona_name, - Arc::clone(&self.game_logs), + persona_id, + &persona_name, + capability, + logs, ) { Ok(child) => { *slot = crate::local_services::ManagedService::from_child(child); diff --git a/src/fifa17_capability.rs b/src/fifa17_capability.rs new file mode 100644 index 0000000..6d95e0d --- /dev/null +++ b/src/fifa17_capability.rs @@ -0,0 +1,224 @@ +//! 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(); + } +} diff --git a/src/local_services.rs b/src/local_services.rs index 66c533b..22f755c 100644 --- a/src/local_services.rs +++ b/src/local_services.rs @@ -23,6 +23,9 @@ use std::{ use std::os::unix::process::CommandExt; +use crate::fifa17_capability::{ + parse_capability_line, parse_fifa_pid, register, Fifa17ClientCapabilities, +}; use crate::logs::LogBuffer; #[derive(Debug, PartialEq, Eq)] @@ -212,15 +215,29 @@ impl Drop for ManagedService { } } +/// Backend-registration wiring handed to the autopatch stdout reader so a +/// verified resolver-guard line can advertise the per-FIFA-process capability to +/// the backend. `Some(..)` for autopatch; `None` for LSX. +pub struct CapabilityWiring { + pub server_host: String, + pub account_sync_port: u16, + pub sink: Arc>, +} + /// Spawn a companion service. `python` is the interpreter, `tools_dir` the /// directory holding the responder scripts. Streams stdout+stderr into `log`. /// Returns an error (without spawning) if the tools dir or script is missing. +/// +/// `capability` is the backend-registration wiring + shared per-FIFA-process +/// capability sink — `Some(..)` for autopatch (whose stdout advertises the +/// verified resolver guard) and `None` for LSX. pub fn spawn( service: Service, python: &str, tools_dir: &str, persona_id: u64, persona_name: &str, + capability: Option, log: Arc>, ) -> anyhow::Result { use std::io::{BufRead, BufReader}; @@ -279,9 +296,53 @@ pub fn spawn( if let Some(out) = child.stdout.take() { let buf = Arc::clone(&log); let lbl = label.to_string(); + // Only autopatch carries capability wiring; LSX passes `None`. + let cap_wiring = capability; + let cap_persona = persona_id; std::thread::spawn(move || { + // Fires the backend registration at most once per FIFA process. + let mut registered = false; for line in BufReader::new(out).lines().map_while(Result::ok) { + // Every raw line is still mirrored into the log, as before. buf.lock().unwrap().push(format!("[{lbl}] {line}")); + + let Some(wiring) = cap_wiring.as_ref() else { + continue; + }; + if registered { + continue; + } + let Some(version) = parse_capability_line(&line) else { + continue; + }; + registered = true; + let fifa_pid = parse_fifa_pid(&line).unwrap_or(0); + wiring.sink.lock().unwrap().empty_mypacks_resolver = Some(version); + { + let mut log = buf.lock().unwrap(); + log.push(format!( + "[fifa17] resolver capability verified for FIFA pid {fifa_pid}" + )); + log.push(format!( + "[fifa17] registering capability for session (persona {cap_persona})" + )); + } + match register( + &wiring.server_host, + wiring.account_sync_port, + cap_persona, + fifa_pid, + version, + ) { + Ok(()) => buf + .lock() + .unwrap() + .push("[fifa17] capability registered with backend".to_string()), + Err(error) => buf + .lock() + .unwrap() + .push(format!("[fifa17] capability registration failed: {error}")), + } } }); } diff --git a/src/main.rs b/src/main.rs index d6140bf..116be67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod account_sync; mod app; mod arm; mod config; +mod fifa17_capability; mod game_launch; mod health; mod local_services;