feat(fifa17): report verified client patch capability
Launcher side of the verified patched-client capability handshake. When
autopatch proves the CardsDLL empty-My-Packs resolver guard is active for the
CURRENT FIFA process, the launcher advertises that to the backend so the backend
may drop the synthetic 65534 sentinel for that session only. Additive and
fail-closed: any parse/registration failure leaves the backend on its default
sentinel path.
- New src/fifa17_capability.rs:
* Fifa17ClientCapabilities { empty_mypacks_resolver: Option<u32> } — per-FIFA-
process state, UNKNOWN at each launch, discarded when that process ends
(never persisted, so a prior launch's capability cannot leak).
* parse_capability_line() / parse_fifa_pid() — pure parsers for autopatch's
stdout token `[store-guard] verified capability fifa17.empty_mypacks_resolver=<v>
fifa_pid=<pid>`; the non-advertising `guard status=...` line yields None.
* register() — tiny stdlib-HTTP POST /openfut/fifa17/capability, modeled on
account_sync::sync (Connection: close, 3s timeouts, 2xx check).
- local_services::spawn: autopatch stdout reader parses each raw line; on the
first verified line it sets the shared capability sink, logs, and fires exactly
one backend register() for this FIFA process. Capability wiring is bundled in a
CapabilityWiring struct (Some for autopatch, None for LSX). LSX unchanged.
- app.rs: LauncherApp holds the shared Fifa17ClientCapabilities; it is reset to
UNKNOWN at the start of launch_game (and when autopatch is stopped) so a new
FIFA process never inherits a previous launch's capability.
- Tests: parse (verified/non-advertising/unrelated/version-2) + a register()
round-trip against an in-process listener.
Design + contract: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (superproject).
Pre-existing openfut-hook/* working-tree changes are intentionally left uncommitted.
This commit is contained in:
+31
-3
@@ -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<Mutex<crate::fifa17_capability::Fifa17ClientCapabilities>>,
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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<u32>,
|
||||
}
|
||||
|
||||
#[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=<N>` (with `<N>` a `u32`). Non-advertising
|
||||
/// lines (e.g. `guard status=UNSUPPORTED_BUILD …`) and unrelated log output
|
||||
/// return `None`. Robust to a trailing ` fifa_pid=<pid>`.
|
||||
pub fn parse_capability_line(line: &str) -> Option<u32> {
|
||||
if !line.contains("verified capability") {
|
||||
return None;
|
||||
}
|
||||
parse_u32_after(line, "fifa17.empty_mypacks_resolver=")
|
||||
}
|
||||
|
||||
/// Extract the FIFA pid from a `fifa_pid=<n>` token if present.
|
||||
pub fn parse_fifa_pid(line: &str) -> Option<u64> {
|
||||
let digits = digits_after(line, "fifa_pid=")?;
|
||||
digits.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn parse_u32_after(line: &str, marker: &str) -> Option<u32> {
|
||||
digits_after(line, marker)?.parse::<u32>().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::<u16>().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::<usize>()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
@@ -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<Mutex<Fifa17ClientCapabilities>>,
|
||||
}
|
||||
|
||||
/// 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<CapabilityWiring>,
|
||||
log: Arc<Mutex<LogBuffer>>,
|
||||
) -> anyhow::Result<Child> {
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ mod account_sync;
|
||||
mod app;
|
||||
mod arm;
|
||||
mod config;
|
||||
mod fifa17_capability;
|
||||
mod game_launch;
|
||||
mod health;
|
||||
mod local_services;
|
||||
|
||||
Reference in New Issue
Block a user