//! PUSHED EVENTS -- the whole reason v2 exists, plus the per-connection state //! they need. //! //! Frame shape is identical to the server-initiated `` that already //! works, i.e. ``. No `id` //! attribute (the Challenge has none; the matcher never reads one). use std::io::{self, Write}; use std::net::{SocketAddr, TcpStream}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; use parking_lot::{Mutex, RwLock}; use crate::crypto::{self, Key}; use crate::{env_flag, log, ConfigError, Env}; /// `sender` is strcmp'd against the handler's registered service name. A mismatch /// is SILENTLY DROPPED -- it costs us nothing -- so we emit every candidate. /// /// Event handlers are keyed on `serviceNames[facility]` (registrar 0x14710df80); /// with our empty GetConfigResponse those names are "", so the handlers expect /// `sender=""`. "" first; the named variants are harmless no-ops (dropped /// silently) and become correct once GetConfigResponse populates the table. /// "LOGIN_EVENT" is table index 14 (the one the event-handler factory uses) and /// "LOGIN" is index 8 (the plain service name); exactly one of the three will /// match. pub const LOGIN_EVENT_SENDERS: [&str; 3] = ["", "LOGIN_EVENT", "LOGIN"]; pub const ONLINE_EVENT_SENDERS: [&str; 2] = ["", "ONLINE_STATUS_EVENT"]; pub fn event(sender: &str, element: &str) -> String { format!(r#"<{element}/>"#) } /// The frames that flip `OriginMgr.m_isLoggedIn` ([OriginMgr+0x13]) to 1. /// /// `IsLoggedIn` is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE. Keep the /// value literally "true" anyway: it is what a real Origin client sends and it /// keeps the log readable. pub fn login_event_frames() -> Vec { let mut out = Vec::with_capacity(LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len()); out.extend(LOGIN_EVENT_SENDERS.iter().map(|s| event(s, r#"Login IsLoggedIn="true""#))); out.extend( ONLINE_EVENT_SENDERS .iter() .map(|s| event(s, r#"OnlineStatusEvent isOnline="true""#)), ); out } /// Event tuning. /// /// Pushes are idempotent state notifications, so re-sending is harmless and is /// cheap insurance against FIFA registering its `` handler later than our /// first push. Set `OPENFUT_LSX_EVENTS=0` to fall back to v1 behaviour (useful as /// an A/B control if you want to prove the events are what moved the needle). #[derive(Debug, Clone, PartialEq)] pub struct EventConfig { /// `OPENFUT_LSX_EVENTS != "0"` (default on). pub enabled: bool, /// `OPENFUT_LSX_EVENT_PERIOD` seconds (default 5). pub period_secs: f64, /// `OPENFUT_LSX_EVENT_COUNT` heartbeat repeats (default 24). pub count: i64, /// EXPERIMENT (`OPENFUT_LSX_LOGIN_PLAINTEXT`, default off): push the Login /// Event in PLAINTEXT right after ChallengeAccepted (before the stream goes /// encrypted) instead of via the encrypted heartbeat. Tests the workflow's /// strongest remaining hypothesis -- that FIFA drops encrypted mid-session /// Events (the emu's only Event, the Challenge, is plaintext and pre-key). /// See REPACK_INTEL.md sec.4 step 2. pub login_plaintext: bool, } impl Default for EventConfig { fn default() -> Self { Self { enabled: true, period_secs: 5.0, count: 24, login_plaintext: false, } } } impl EventConfig { pub fn from_env() -> Result { Self::from_vars(&crate::os_env) } pub fn from_vars(env: Env<'_>) -> Result { let period_secs = match env("OPENFUT_LSX_EVENT_PERIOD") { Some(v) => v.trim().parse::().map_err(|_| ConfigError { var: "OPENFUT_LSX_EVENT_PERIOD", value: v.clone(), expected: "a number of seconds", })?, None => 5.0, }; let count = match env("OPENFUT_LSX_EVENT_COUNT") { Some(v) => v.trim().parse::().map_err(|_| ConfigError { var: "OPENFUT_LSX_EVENT_COUNT", value: v.clone(), expected: "an integer", })?, None => 24, }; Ok(Self { enabled: env_flag(env, "OPENFUT_LSX_EVENTS", true), period_secs, count, login_plaintext: env_flag(env, "OPENFUT_LSX_LOGIN_PLAINTEXT", false), }) } } /// Render a period the way Python's `float` repr does, so the startup line reads /// `period=5.0s` and not `period=5s`. pub fn fmt_secs(secs: f64) -> String { if secs.is_finite() && secs.fract() == 0.0 && secs.abs() < 1e16 { format!("{secs:.1}") } else { format!("{secs}") } } /// Socket + session key + a send lock. /// /// The lock matters: pushes come from a heartbeat thread while the request loop /// may be writing a Response. LSX frames are NUL-delimited, so two interleaved /// writes would corrupt the stream and the client would drop the connection /// (which would look exactly like a protocol bug). pub struct Conn { pub peer: SocketAddr, /// A `try_clone` of the accepted socket: the request loop reads from the /// original while this half is serialised behind the send lock. writer: Mutex, key: RwLock>, alive: AtomicBool, pushed_login: AtomicBool, /// Set once GetAuthCode has been issued, so the heartbeat stops re-pushing /// Login/OnlineStatus events. Re-pushing after the auth code is granted /// re-enters FIFA's state-mutating Origin event dispatcher (case 2 /// @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts on the FE /// bus) ~24 more times DURING Blaze login, which we do not want. stop_events: AtomicBool, events: EventConfig, } impl Conn { pub fn new(sock: &TcpStream, peer: SocketAddr, events: EventConfig) -> io::Result> { Ok(Arc::new(Self { peer, writer: Mutex::new(sock.try_clone()?), key: RwLock::new(None), alive: AtomicBool::new(true), pushed_login: AtomicBool::new(false), stop_events: AtomicBool::new(false), events, })) } pub fn events(&self) -> &EventConfig { &self.events } pub fn set_key(&self, key: Key) { *self.key.write() = Some(key); } pub fn key(&self) -> Option { *self.key.read() } // These flags are advisory hand-offs between the request loop and the // heartbeat thread; no data travels with them, so Relaxed is enough. pub fn is_alive(&self) -> bool { self.alive.load(Ordering::Relaxed) } pub fn mark_dead(&self) { self.alive.store(false, Ordering::Relaxed); } pub fn stop_events(&self) { self.stop_events.store(true, Ordering::Relaxed); } pub fn events_stopped(&self) -> bool { self.stop_events.load(Ordering::Relaxed) } pub fn send_plain(&self, xml: &str) -> io::Result<()> { let mut buf = Vec::with_capacity(xml.len() + 1); buf.extend_from_slice(xml.as_bytes()); buf.push(0); self.writer.lock().write_all(&buf) } pub fn send_enc(&self, xml: &str) -> io::Result<()> { let key = self .key() .ok_or_else(|| io::Error::other("session key not initialised"))?; let frame = crypto::lsx_encrypt(xml, &key); self.writer.lock().write_all(&frame) } pub fn push_login_state(&self, why: &str) { if !self.events.enabled { return; } for frame in login_event_frames() { if let Err(e) = self.send_enc(&frame) { self.mark_dead(); log!("push failed: {e}"); return; } log!("PUSH ({why}) >> {frame}"); } if !self.pushed_login.swap(true, Ordering::Relaxed) { log!( "*** first pushed. Watch for \ GetAuthCode next. ***" ); } } /// Re-push the login state a bounded number of times. /// /// FIFA builds its Origin event handlers lazily; if our first push lands /// before the `` handler is registered the matcher simply finds no /// handler and drops it. Re-pushing removes that race without needing to /// guess the exact registration moment. pub fn heartbeat(&self) { let period = match Duration::try_from_secs_f64(self.events.period_secs) { Ok(d) => d, Err(e) => { // Python's time.sleep() would raise here and kill just this // thread; the connection keeps serving requests either way. log!( "heartbeat disabled: OPENFUT_LSX_EVENT_PERIOD={} is not a usable delay ({e})", self.events.period_secs ); return; } }; for _ in 0..self.events.count.max(0) { std::thread::sleep(period); if !self.is_alive() || self.events_stopped() { return; } self.push_login_state("heartbeat"); } } } #[cfg(test)] mod tests { use super::*; use crate::crypto::{derive_session_key, lsx_decrypt, lsx_encrypt}; use std::collections::HashMap; fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { let map: HashMap = pairs .iter() .map(|(k, v)| ((*k).to_string(), (*v).to_string())) .collect(); move |k: &str| map.get(k).cloned() } #[test] fn frames_are_the_five_candidate_pushes_with_the_empty_sender_first() { let frames = login_event_frames(); assert_eq!( frames.len(), LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len() ); assert_eq!( frames, vec![ r#""#, r#""#, r#""#, r#""#, r#""#, ] ); } #[test] fn frames_round_trip_through_the_session_codec() { let k = derive_session_key(&crate::crypto::challenge_response( "18a70055a3541fb27ab8e0f47afad18c", "", ) .unwrap()); for f in login_event_frames() { assert_eq!(lsx_decrypt(&lsx_encrypt(&f, &k), &k).unwrap(), f); } } #[test] fn defaults_match_the_python() { let cfg = EventConfig::from_vars(&env_of(&[])).unwrap(); assert_eq!(cfg, EventConfig::default()); assert!(cfg.enabled); assert_eq!(cfg.period_secs, 5.0); assert_eq!(cfg.count, 24); assert!(!cfg.login_plaintext); } #[test] fn events_are_disabled_only_by_the_literal_zero() { for (value, enabled) in [("0", false), ("1", true), ("", true), ("false", true)] { let cfg = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENTS", value)])).unwrap(); assert_eq!(cfg.enabled, enabled, "OPENFUT_LSX_EVENTS={value:?}"); } } #[test] fn login_plaintext_is_enabled_by_anything_but_zero() { for (value, on) in [("0", false), ("1", true), ("", true), ("no", true)] { let cfg = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_LOGIN_PLAINTEXT", value)])).unwrap(); assert_eq!(cfg.login_plaintext, on, "OPENFUT_LSX_LOGIN_PLAINTEXT={value:?}"); } } #[test] fn period_and_count_are_overridable() { let cfg = EventConfig::from_vars(&env_of(&[ ("OPENFUT_LSX_EVENT_PERIOD", "0.25"), ("OPENFUT_LSX_EVENT_COUNT", "3"), ])) .unwrap(); assert_eq!(cfg.period_secs, 0.25); assert_eq!(cfg.count, 3); } #[test] fn bad_period_is_refused() { let err = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_PERIOD", "soon")])) .unwrap_err(); assert_eq!(err.var, "OPENFUT_LSX_EVENT_PERIOD"); let err = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_COUNT", "many")])).unwrap_err(); assert_eq!(err.var, "OPENFUT_LSX_EVENT_COUNT"); } #[test] fn period_renders_like_a_python_float() { assert_eq!(fmt_secs(5.0), "5.0"); assert_eq!(fmt_secs(0.25), "0.25"); assert_eq!(fmt_secs(0.0), "0.0"); } }