/// EA App LSX server (TCP port 3216). /// /// FIFA 23 opens two concurrent connections to this port at startup. /// Protocol: server speaks first — sends an XML greeting with a challenge /// key; client replies with ChallengeResponse; server replies with /// ChallengeAccepted; then an AES-128-ECB encrypted session loop follows. use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit}; use aes::Aes128; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tracing::{debug, info, warn}; const GREETING_KEY: &str = "cacf897a20b6d612ad0c05e011df52bb"; const AES_KEY: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; /// The async `ONLINE_STATUS_EVENT` push FIFA waits for after `GoOnline`. /// /// FIFA calls GoOnline (handled in-process by EbisuSDK/anadius), gets success, /// then blocks waiting for an unsolicited `OnlineStatusEvent` push on the LSX /// socket — the M2 wall. Now that our bridge owns FIFA's LSX socket we can send /// it. Format RE'd from FIFA23.exe's OriginSDK deserializer (authoritative): /// element `OnlineStatusEvent` (RTTI `Origin::EventHandler`, parser FIFA23.exe+0x274d4ae), single bool attribute `isOnline` /// (deserializer FIFA23.exe+0x28a4d0). See docs/connection-gate-findings.md. /// /// The value is the string literal `true`/`false` (not `1`/`0`): FIFA's bool /// parser (FIFA23.exe+0x28fb50) string-compares the attribute against `"false"`, /// matching the OriginSDK convention every other bool in our LSX responses uses. /// An initial `isOnline="1"` push was delivered but parsed as not-online (FIFA /// kept polling), which pinned the encoding. Re-push cadence below still /// TODO/CONFIRM (single push may suffice once value is correct). const ONLINE_STATUS_EVENT: &str = r#""#; /// The authenticated-session `Login` event. OnlineStatusEvent alone set FIFA's /// *connectivity* (presence went INGAME) but not an authenticated session, so FIFA /// never started the GetAuthCode→Nucleus→Blaze chain. FIFA's Origin event set has a /// separate `Login` event (`Origin::EventHandler`) that /// carries the login state. Format RE'd from FIFA23.exe's LoginT deserializer /// (0x142787a30): fields `UserIndex` (int, primary user 0), `IsLoggedIn` (bool, /// parsed by the same true/false helper as isOnline), `LoginReasonCode` (int/enum). /// /// Pushed BEFORE OnlineStatusEvent each cycle (log in, then go online). /// TODO/CONFIRM `LoginReasonCode` — 0 is the natural "no error / normal" value; if /// FIFA switches on a specific success code, RE the consumer and adjust. const LOGIN_EVENT: &str = r#""#; /// Events pushed on the LSX socket each interval, in order, to drive FIFA online. const ONLINE_PUSH_EVENTS: &[&str] = &[LOGIN_EVENT, ONLINE_STATUS_EVENT]; /// Delay before the first `OnlineStatusEvent` push (and the re-push interval). /// Long enough that FIFA has finished LSX bootstrap and subscribed its event /// listeners + issued GoOnline (GetInternetConnectedState was seen ~2s in), short /// enough to fire while FIFA is still polling and waiting. const ONLINE_PUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2000); // Account identity + locale that anadius's in-process LSX emu reports (recovered // from anadius.cfg — the config the emu reads). FIFA carries these values into // its online/loading state, so our bridge must report the SAME ones anadius does; // fabricated values crash FIFA before the loading screen. See the LSX-regression // section in docs/connection-gate-findings.md. const PERSONA_ID: &str = "1144668899"; const USER_ID: &str = "1000200030000"; const USERNAME: &str = "fun"; const INSTALLED_LANGUAGE: &str = "en_US"; const LANGUAGES: &str = "pt_PT,tr_TR,ko_KR,cs_CZ,zh_CN,zh_HK,da_DK,no_NO,sv_SE,en_US,pt_BR,de_DE,\ es_ES,fr_FR,it_IT,ja_JP,es_MX,nl_NL,pl_PL,ru_RU,ar_SA"; /// The full game-info attribute set anadius's `GetAllGameInfo` handler emits, /// as (GameInfoId key, response attribute name, value). Recovered by /// disassembling anadius64.dll's builder (attribute names + order) plus /// anadius.cfg (version/language values). Our old `` /// was empty — FIFA reads these fields during load and crashes without them. /// TODO/CONFIRM the exact values for the entitlement/group fields (the names are /// certain from the disassembly; a few values are best-effort defaults). const GAME_INFO: &[(&str, &str, &str)] = &[ ("DISPLAY_NAME", "DisplayName", "FIFA 23"), ("INSTALLED_VERSION", "InstalledVersion", "1.0.82.43747"), ("AVAILABLE_VERSION", "AvailableVersion", "1.0.82.43747"), ("UPTODATE", "UpToDate", "true"), ("FULLGAME_IS_RELEASED", "FullGameReleased", "true"), ("FULLGAME_RELEASE_DATE", "FullGameReleaseDate", "2022-09-30T00:00:00"), ("FULLGAME_PURCHASED", "FullGamePurchased", "true"), ("FREETRIAL", "FreeTrial", "false"), ("EXPIRATION", "Expiration", "0000-00-00T00:00:00"), ("ENTITLEMENT_SOURCE", "EntitlementSource", "NORMAL"), ("MAX_GROUP_SIZE", "MaxGroupSize", "0"), ("LANGUAGES", "Languages", LANGUAGES), ("INSTALLED_LANGUAGE", "InstalledLanguage", INSTALLED_LANGUAGE), ]; pub async fn start_server(addr: &str) -> anyhow::Result<()> { let listener = TcpListener::bind(addr).await?; info!("LSX server listening on {addr}"); loop { let (stream, peer) = listener.accept().await?; debug!("LSX: connection from {peer}"); tokio::spawn(async move { if let Err(e) = handle(stream).await { warn!("LSX: connection error: {e}"); } }); } } async fn handle(mut stream: TcpStream) -> anyhow::Result<()> { // 1. Server speaks first — send greeting let greeting = format!( "\r\n \r\n \r\n \r\n\0" ); debug!("LSX: sending greeting"); stream.write_all(greeting.as_bytes()).await?; // 2. Receive ChallengeResponse let raw = read_msg(&mut stream).await?; let text = String::from_utf8_lossy(&raw); debug!("LSX: got ChallengeResponse: {}", &text[..text.len().min(300)]); let parts: Vec<&str> = text.split('"').collect(); let id = parts.get(3).copied().unwrap_or("1"); let key = parts.get(7).copied().unwrap_or(""); debug!("LSX: challenge id={id} key={key}"); let our_response = make_challenge_response(key); let seed = compute_seed(&our_response); debug!("LSX: response={our_response} seed={seed}"); // 3. Send ChallengeAccepted let accepted = format!( "\r\n \r\n \r\n \r\n\0" ); stream.write_all(accepted.as_bytes()).await?; info!("LSX: handshake complete"); // 4. Session loop — multiplexes request handling with an unsolicited // OnlineStatusEvent push. A single task keeps owning the stream; the // `select!` services reads and lets a timer fire the push, so no socket // split / shared writer is needed. let mut push_timer = tokio::time::interval(ONLINE_PUSH_INTERVAL); // interval fires immediately on the first tick — consume it so the first // push lands after ONLINE_PUSH_INTERVAL (giving FIFA time to subscribe). push_timer.tick().await; loop { tokio::select! { read = read_msg(&mut stream) => { let raw = match read { Ok(b) => b, Err(_) => break, }; // Dump the exact wire bytes so the session seed can be brute-forced // offline against this ciphertext (see docs/connection-gate-findings.md). debug!("LSX: session raw ({} bytes): {}", raw.len(), bytes_to_hex(&raw)); let out = process_frame(&raw, seed); if out.is_empty() { continue; } if stream.write_all(&out).await.is_err() { break; } } _ = push_timer.tick() => { // Push the Login (authenticated session) then OnlineStatusEvent // (connectivity) so FIFA leaves the "connecting" wait and starts the // GetAuthCode→Nucleus→Blaze chain. Each is encrypted + null-terminated // like any session frame. Re-pushed each interval to cover the // subscribe-timing race until FIFA advances (or the socket closes). let mut push_err = false; for ev in ONLINE_PUSH_EVENTS { let mut frame = lsx_encrypt(ev, seed).into_bytes(); frame.push(0); debug!("LSX: pushing event: {ev}"); if stream.write_all(&frame).await.is_err() { push_err = true; break; } } if push_err { break; } } } } debug!("LSX: client disconnected"); Ok(()) } /// Process one raw socket read, which may contain SEVERAL pipelined LSX messages. /// /// FIFA batches multiple LSX messages into a single TCP segment — each a /// null-terminated block of AES-ciphertext-as-ASCII-hex (e.g. `SetPresence` /// immediately followed by a `GetGameInfo GameInfoId="LANGUAGES"` query). The /// old loop decrypted the whole read as one blob and dispatched only the FIRST /// message, silently dropping the rest. A dropped request leaves FIFA waiting on /// a response that never arrives; it times out ~15s later and crashes before the /// loading screen. So we split on the null terminators and answer EVERY message, /// returning the concatenated null-terminated encrypted responses (one per /// request), preserving order. fn process_frame(raw: &[u8], seed: u16) -> Vec { let mut out = Vec::new(); for chunk in raw.split(|&b| b == 0) { let hex = String::from_utf8_lossy(chunk); let hex = hex.trim(); if hex.is_empty() { continue; } let decrypted = lsx_decrypt(hex, seed); let trimmed = decrypted.trim(); if trimmed.is_empty() { continue; } debug!("LSX: request: {}", &trimmed[..trimmed.len().min(300)]); let response_xml = dispatch(trimmed); debug!("LSX: response: {}", &response_xml[..response_xml.len().min(300)]); let encrypted = lsx_encrypt(&response_xml, seed); out.extend_from_slice(encrypted.as_bytes()); out.push(0); } out } /// Read a null-terminated or fixed-size LSX message. async fn read_msg(stream: &mut TcpStream) -> anyhow::Result> { let mut buf = vec![0u8; 65536]; let n = stream.read(&mut buf).await?; if n == 0 { return Err(anyhow::anyhow!("connection closed")); } buf.truncate(n); Ok(buf) } // ─── dispatcher ────────────────────────────────────────────────────────────── fn dispatch(xml: &str) -> String { let parts: Vec<&str> = xml.split('"').collect(); let id = parts.get(3).copied().unwrap_or("1"); let req_type = parts.get(4).copied().unwrap_or(""); debug!("LSX: dispatch id={id} type={req_type}"); match req_type { ">") } } } fn get_config(id: &str) -> String { format!(r#" "#) } fn get_auth_code(id: &str) -> String { format!(r#" "#) } fn get_internet_state(id: &str) -> String { format!(r#" "#) } fn get_profile(id: &str) -> String { format!(r#" "#) } fn get_setting(id: &str, setting: &str) -> String { let value = match setting { "ENVIRONMENT" => "production", _ => "false" }; format!(r#" "#) } fn query_entitlements(id: &str) -> String { format!(r#" "#) } fn request_license(id: &str) -> String { format!(r#" "#) } fn query_content(id: &str) -> String { format!(r#" "#) } fn get_block_list(id: &str) -> String { format!(r#""#) } fn query_friends(id: &str) -> String { format!(r#""#) } fn query_presence(id: &str) -> String { format!(r#""#) } fn set_presence(id: &str) -> String { format!(r#""#) } fn get_presence_visibility(id: &str) -> String { format!(r#""#) } fn get_wallet_balance(id: &str) -> String { format!(r#""#) } fn get_all_game_info(id: &str) -> String { // Populated with the full attribute set anadius emits (see GAME_INFO), plus // the literal `HasExpiration="false"` the disassembly showed. Empty responses // crash FIFA during load. let mut attrs = String::new(); for (_key, attr, value) in GAME_INFO { attrs.push_str(&format!(r#"{attr}="{value}" "#)); } format!( r#""# ) } /// GetGameInfo — FIFA queries per-key EbisuSDK "game info" values during the LSX /// session (observed keys: `FREETRIAL`, `LANGUAGES`, `INSTALLED_LANGUAGE`). /// /// The response element is `GetGameInfoResponse` with a **single fixed attribute /// literally named `GameInfo`** holding the requested value — NOT a per-key /// attribute (`InstalledLanguage`, `Languages`, …) and NOT a generic `Value`. /// Recovered by disassembling the response builder in `anadius64.dll`: it writes /// `<` + `GetGameInfoResponse` + ` ` + `GameInfo` + `="` + + `"` + ` />`, /// with `GameInfo` a fixed 8-byte string constant regardless of the GameInfoId. /// The value is looked up per key (from `anadius.cfg`); the attribute name never /// changes. /// /// This was the loading-screen crash: we were sending an attribute FIFA never /// reads (first `Value=`, then a per-key name), so it always saw a null /// installed-language, re-queried `INSTALLED_LANGUAGE` three times, and crashed. /// That also explains why changing the value string (empty → `en_US`) had zero /// effect — FIFA wasn't reading our attribute at all. fn get_game_info(id: &str, game_info_id: &str) -> String { // Strip surrounding quotes the positional parser can leave on the value. let key = game_info_id.trim_matches('"'); debug!("LSX: GetGameInfo GameInfoId={key}"); // The value is per-key; the attribute name is always the literal `GameInfo`. let value = GAME_INFO .iter() .find(|(k, _, _)| *k == key) .map(|(_, _, v)| *v) .unwrap_or(""); format!( r#""# ) } /// IsProgressiveInstallationAvailable — FIFA asks this early in the LSX session. /// anadius emits `IsProgressiveInstallationAvailableResponse` with an `Available` /// attribute (recovered from `anadius64.dll`); we previously fell through to the /// generic ``. The game is fully installed, so progressive/streaming install /// is not available. /// /// The handling service is `PROGRESSIVE_INSTALLATION`, whose Name in anadius's /// service registry is `PI` (see `get_config`), so the response is sent from /// `sender="PI"`. This service MUST also be declared in the `GetConfig` response: /// FIFA builds its service registry from GetConfig and resolves the /// `PROGRESSIVE_INSTALLATION` handle from it. Omitting the service left FIFA with a /// null handle it dereferenced during online-init, crashing at FIFA23.exe+0x133dfc5 /// (`mov rax,[r13+0x18]`, r13=NULL) ~2s after LSX — the invariant loading-screen /// crash across every prior run. fn is_progressive_installation_available(id: &str) -> String { format!( r#""# ) } // ─── crypto ────────────────────────────────────────────────────────────────── /// AES-128-ECB encrypt with PKCS#7 padding, backed by the `aes` crate. /// ECB has no per-message state, so we drive the block cipher over each /// 16-byte chunk ourselves. fn aes_ecb_pkcs7_encrypt(key: &[u8; 16], plaintext: &[u8]) -> Vec { let cipher = Aes128::new(GenericArray::from_slice(key)); let pad = 16 - (plaintext.len() % 16); let mut padded = plaintext.to_vec(); padded.resize(plaintext.len() + pad, pad as u8); for chunk in padded.chunks_mut(16) { cipher.encrypt_block(GenericArray::from_mut_slice(chunk)); } padded } /// AES-128-ECB decrypt of whole blocks, then a *lenient* PKCS#7 strip. /// Kept lenient (won't error on invalid padding) on purpose: a wrong session /// key yields garbage we want to log and move past, not panic on. This is why /// we don't use the crate's strict `Pkcs7` unpadder. fn aes_ecb_decrypt_nopad(key: &[u8; 16], data: &[u8]) -> Vec { let cipher = Aes128::new(GenericArray::from_slice(key)); let mut out = Vec::with_capacity(data.len()); for chunk in data.chunks(16) { if chunk.len() < 16 { break; } let mut block = *GenericArray::from_slice(chunk); cipher.decrypt_block(&mut block); out.extend_from_slice(&block); } if let Some(&pad) = out.last() { let pad = pad as usize; if pad <= 16 && out.len() >= pad { out.truncate(out.len() - pad); } } out } struct CRandom { seed: u32 } impl CRandom { fn new() -> Self { Self { seed: 0 } } fn seed_with(&mut self, s: u32) { self.seed = s; } fn rand(&mut self) -> u32 { self.seed=self.seed.wrapping_mul(214013).wrapping_add(2531011); (self.seed>>16)&0xFFFF } } fn get_lsx_key(seed: u16) -> [u8; 16] { let mut rng=CRandom::new(); rng.seed_with(7); let next=rng.rand(); rng.seed_with(next.wrapping_add(seed as u32)); let mut k=[0u8;16]; for b in &mut k { *b=rng.rand() as u8; } k } fn hex_to_bytes(s: &str) -> Vec { let s: String=s.chars().filter(|c|c.is_ascii_hexdigit()).collect(); if s.len()%2!=0 { return Vec::new(); } (0..s.len()/2).filter_map(|i|u8::from_str_radix(&s[2*i..2*i+2],16).ok()).collect() } fn bytes_to_hex(b: &[u8]) -> String { b.iter().map(|x|format!("{x:02x}")).collect() } fn compute_seed(hex: &str) -> u16 { // The session seed is the first TWO ASCII characters of the ChallengeAccepted // response hex string, taken as their raw byte values — NOT the leading hex // pair parsed into a byte. Confirmed 2026-07-02 by brute-forcing the seed // against a captured FIFA session message (see docs/connection-gate-findings.md): // response "0f73…" → seed 0x3066 ('0','f'), not 0x0F73. let b = hex.as_bytes(); let b0 = b.first().copied().unwrap_or(0); let b1 = b.get(1).copied().unwrap_or(0); ((b0 as u16) << 8) | (b1 as u16) } fn make_challenge_response(key: &str) -> String { bytes_to_hex(&aes_ecb_pkcs7_encrypt(&AES_KEY, key.as_bytes())) } fn lsx_decrypt(hex_data: &str, seed: u16) -> String { let key=get_lsx_key(seed); let ct=hex_to_bytes(hex_data); if ct.is_empty() { return String::new(); } String::from_utf8_lossy(&aes_ecb_decrypt_nopad(&key, &ct)).trim_matches('\0').to_string() } fn lsx_encrypt(text: &str, seed: u16) -> String { let key=get_lsx_key(seed); bytes_to_hex(&aes_ecb_pkcs7_encrypt(&key, text.as_bytes())) } #[cfg(test)] mod tests { use super::*; /// FIPS-197 known-answer vector. The placeholder `AES_KEY` (00 01 .. 0f) is /// exactly the FIPS-197 example key, so encrypting the FIPS example block /// must yield the published ciphertext. This proves the `aes`-crate swap is /// standard, interop-compatible AES-128 — not merely self-consistent. #[test] fn aes128_matches_fips197_and_round_trips() { // FIPS-197 §C.1: plaintext 00112233445566778899aabbccddeeff let plaintext = [ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, ]; // ...under key 000102..0f gives ciphertext 69c4e0d86a7b0430d8cdb78070b4c55a let expected_ct = [ 0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5, 0x5a, ]; // aes_ecb_pkcs7_encrypt pads a full extra block, so only assert block 0. let out = aes_ecb_pkcs7_encrypt(&AES_KEY, &plaintext); assert_eq!(&out[..16], &expected_ct, "AES-128-ECB block 0 must match FIPS-197"); // Encrypt→decrypt round-trips back to the exact plaintext (padding stripped). let round_tripped = aes_ecb_decrypt_nopad(&AES_KEY, &out); assert_eq!(round_tripped, plaintext, "decrypt must invert encrypt"); } /// The load-bearing regression test: when FIFA pipelines two LSX messages into /// one frame (as it does with SetPresence + a GetGameInfo LANGUAGES query), /// BOTH must be answered. Dropping the second hangs FIFA into a ~15s timeout /// and a crash before the loading screen (the 2026-07-02 root cause). #[test] fn process_frame_answers_every_pipelined_message() { let seed: u16 = 0x3066; let m1 = lsx_encrypt( r#""#, seed, ); let m2 = lsx_encrypt( r#""#, seed, ); // Two null-terminated ciphertext messages in a single read, as FIFA sends. let mut raw = Vec::new(); raw.extend_from_slice(m1.as_bytes()); raw.push(0); raw.extend_from_slice(m2.as_bytes()); raw.push(0); let out = process_frame(&raw, seed); let responses: Vec<&[u8]> = out.split(|&b| b == 0).filter(|c| !c.is_empty()).collect(); assert_eq!(responses.len(), 2, "both pipelined requests must be answered"); let r1 = lsx_decrypt(&String::from_utf8_lossy(responses[0]), seed); let r2 = lsx_decrypt(&String::from_utf8_lossy(responses[1]), seed); assert!(r1.contains(r#"id="1""#), "first response is for request id 1, got: {r1}"); assert!(r2.contains(r#"id="2""#), "second response is for request id 2, got: {r2}"); assert!(r2.contains("GetGameInfoResponse"), "the piggybacked LANGUAGES query must be answered, got: {r2}"); } /// Pins the `split('"')` index parsing that routes GetGameInfo, so the fragile /// positional extraction doesn't silently regress to the generic `` path. /// GetConfig must declare the PROGRESSIVE_INSTALLATION service (Name="PI"), /// recovered from anadius's service-registration table in anadius64.dll. FIFA /// builds its service registry from GetConfig and resolves this handle during /// online-init; omitting it left a null handle that crashed FIFA at /// FIFA23.exe+0x133dfc5 ~2s after LSX in every run (2026-07-02). #[test] fn get_config_declares_progressive_installation_service() { let resp = dispatch(r#""#); assert!( resp.contains(r#"Facility="PROGRESSIVE_INSTALLATION" Name="PI""#), "GetConfig must declare PROGRESSIVE_INSTALLATION→PI, got: {resp}" ); assert!( resp.contains(r#"Facility="PROGRESSIVE_INSTALLATION_EVENT" Name="PI""#), "GetConfig must declare PROGRESSIVE_INSTALLATION_EVENT→PI, got: {resp}" ); } #[test] fn dispatch_routes_get_game_info() { let req = r#""#; let resp = dispatch(req); assert!(resp.contains(""#); assert!(installed.contains(r#"GameInfo="en_US""#), "got: {installed}"); let languages = dispatch(r#""#); assert!(languages.contains(r#"GameInfo=""#), "LANGUAGES must use the fixed GameInfo attribute, got: {languages}"); assert!(languages.contains("en_US") && languages.contains("fr_FR"), "LANGUAGES must be the full locale list, got: {languages}"); } }