//! FIFA 17 Blaze response bodies. //! //! Every builder here mirrors a `Blaze::*` TDF class reversed from FIFA17.exe's //! own reflection metadata. Member counts and tags are not guesses, and the //! comments carry the class addresses so a future reader can re-derive them. //! //! Two recurring rules, both learned the hard way: //! //! * **An absent member is safe; a wrongly-typed one is fatal.** A member the //! client does not receive keeps its client-side default. A member encoded //! with the wrong wire type desynchronises the whole TDF parse. That is why //! `CGID`, `ADDR`, `CVAR` and `ULST` are omitted rather than guessed — their //! union/objid encodings are UNVERIFIED. //! * **Identity must be byte-identical across responses.** `MAIL`/`UID`/`ASRC` //! in `AccountInfo` must match `LoginResponse.SESS` and `PreAuthResponse.NASP`, //! and the session key must be the same string in three places. //! //! Member order in the source below is the oracle's for readability; the //! encoder sorts by packed tag, so source order never reaches the wire. use openfut_protocol_blaze::heat2::{Struct, TypeId, Value}; use super::client_config; use super::config::AdapterConfig; use super::ids::ADVERTISED_COMPONENT_IDS; use super::session::Session; fn s(v: impl Into) -> Value { Value::String(v.into()) } fn i(v: i64) -> Value { Value::Int(v) } /// `Blaze::Util::FetchConfigResponse` @0x1448752e0 — a single `CONF` /// map. /// /// NOT double-nested. The extra nesting exists only inside `PreAuthResponse`, /// where `CONF` is itself a `FetchConfigResponse` whose own single member is /// also called `CONF`. Easy to get wrong. pub fn fetch_config_response(cfid: &str, cfg: &AdapterConfig) -> Struct { let entries = client_config::rows_for(cfid, cfg) .into_iter() .map(|(k, v)| (Value::String(k), Value::String(v))) .collect(); Struct::new().with( "CONF", Value::Map { key: TypeId::String, val: TypeId::String, entries, }, ) } /// `Blaze::QosConfigInfo` — 4 members. /// /// FIFA 17's descriptor has no `SVID`, unlike Mirror's Edge Catalyst; do not /// add one back from another title's emulator. pub fn qos_config(cfg: &AdapterConfig) -> Struct { Struct::new() .with( "BWPS", Value::Struct( Struct::new() .with("PSA", s(&cfg.endpoints.advertise)) .with("PSP", i(cfg.endpoints.qos_port)), ), ) .with("LNP", i(10)) .with( "LTPS", Value::Map { key: TypeId::String, val: TypeId::Struct, entries: vec![], }, ) .with("TIME", i(5_000_000)) } /// `Blaze::Util::PreAuthResponse`. pub fn preauth_response(service_name: &str, cfg: &AdapterConfig) -> Struct { let id = &cfg.identity; Struct::new() .with("ASRC", s(&id.title_id)) .with( "CIDS", Value::List { elem: TypeId::Int, items: ADVERTISED_COMPONENT_IDS.iter().copied().map(i).collect(), }, ) .with("CLID", s(&id.client_id)) .with( "CONF", Value::Struct(fetch_config_response("BlazeSDK", cfg)), ) .with("ESRC", s(&id.title_id)) .with("INST", s(service_name)) // echo of CDAT.SVCN .with("MAID", i(0)) .with("MINR", i(0)) .with("NASP", s(&id.namespace)) .with("PILD", s("")) .with("PLAT", s(&id.platform)) .with("QOSS", Value::Struct(qos_config(cfg))) .with("RSRC", s(&id.title_id)) .with("SVER", s(&cfg.server_version)) } /// `Blaze::Util::PingResponse` @0x144875560 — exactly one member. /// /// v2 also sent `TIME`; that is MEC's field, not FIFA 17's. pub fn ping_response(now: i64) -> Struct { Struct::new().with("STIM", i(now)) } /// `Blaze::CensusData::SubscribeToCensusDataUpdatesResponse` — 3 TimeValues, /// encoded as INT microseconds. /// /// These must be non-zero. The client computes `delay_ms = (CNP + NTMT) / 1000` /// and re-arms a resend timer; an empty reply gives delay 0, which lands the job /// on the scheduler's ready list and produces a ~30/s re-subscribe storm that /// hangs the FUT loading screen. pub fn census_subscribe_response() -> Struct { Struct::new() .with("CNP", i(30 * 1_000_000)) .with("NTMT", i(90 * 1_000_000)) .with("RTMT", i(300 * 1_000_000)) } /// `Blaze::Authentication::PersonaDetails` @0x14487cab0 — 6 members. pub fn persona_details(now: i64, cfg: &AdapterConfig) -> Struct { let id = &cfg.identity; Struct::new() .with("DSNM", s(&id.persona_name)) .with("LAST", i(now)) .with("PID", i(id.persona_id)) .with("PLAT", i(id.client_platform)) .with("STAS", i(id.persona_status)) .with("XREF", i(id.ext_id)) } /// `Blaze::Authentication::UserLoginInfo` @0x14487cb00 — 8 members. /// /// `'1CON'` packs to 0x11, which sorts below `'A'` = 0x21, so it leads. pub fn user_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct { let id = &cfg.identity; Struct::new() .with("1CON", i(0)) .with("BUID", i(id.user_id)) // must be non-zero .with("FRST", i(0)) .with("KEY", s(&sess.session_key)) // must be non-empty .with("LLOG", i(now)) .with("MAIL", s(&id.email)) .with("PDTL", Value::Struct(persona_details(now, cfg))) .with("UID", i(id.user_id)) // must be non-zero } /// `Blaze::Authentication::LoginResponse` @0x14487d170 — exactly 5 members. /// /// Diverges from both public MEC emulators, which emit `CNTX`, `ERRC` and a /// top-level `SKEY`. FIFA 17 has none of those: `CNTX`/`ERRC` are the Blaze /// error-metadata block, and the session key lives at `SESS.KEY`. pub fn login_response(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct { Struct::new() .with("ANON", i(0)) .with("NTOS", i(0)) // 1 would divert to the legal-doc flow .with("SESS", Value::Struct(user_login_info(sess, now, cfg))) .with("SPAM", i(1)) .with("UNDR", i(0)) } /// ISO-8601 UTC, matching the oracle's `%Y-%m-%dT%H:%M:%SZ`. /// /// Hand-rolled from a Unix timestamp to keep this crate free of a date /// dependency for one format string. Proleptic Gregorian, no leap seconds — /// the same calendar `time.gmtime` uses. fn iso8601_utc(unix: i64) -> String { let days = unix.div_euclid(86_400); let secs = unix.rem_euclid(86_400); let (h, mi, sec) = (secs / 3600, (secs % 3600) / 60, secs % 60); // Civil-from-days (Howard Hinnant's algorithm), shifted to a March-based year. let z = days + 719_468; let era = z.div_euclid(146_097); let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y }; format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}Z") } /// `Blaze::Authentication::AccountInfo` @0x14487c810 — exactly 16 members. /// /// The RPC behind the "Unable to retrieve account information" popup: before it /// was implemented, the empty-reply fallback produced an AccountInfo with /// `UID=0`/`CO=""` and the popup appeared one layer later. /// /// Member tags come from the reflection tag table @0x1448775a0; wire types from /// each member's subtype descriptor (string subtype 0x144867628 covers ASRC CO /// DOB DTCR LATH LN MAIL PML; the other eight are int/enum). Enum values: /// `STAS` = AccountStatus ACTIVE = 1, `STAT` = EmailStatus VERIFIED = 2, /// `RC` = StatusReason none = 0. pub fn account_info(now: i64, cfg: &AdapterConfig) -> Struct { let id = &cfg.identity; Struct::new() .with("AMU", i(0)) .with("ASRC", s(&id.namespace)) // == PreAuthResponse.NASP .with("CO", s("US")) .with("DOB", s("1990-01-01T00:00:00Z")) .with("DTCR", s("2016-09-01T00:00:00Z")) .with("GOPT", i(0)) .with("LATH", s(iso8601_utc(now))) .with("LN", s(&id.locale)) .with("MAIL", s(&id.email)) // == LoginResponse.SESS.MAIL .with("PML", s("")) .with("RC", i(0)) .with("STAS", i(1)) .with("STAT", i(2)) .with("TPOT", i(0)) .with("UDU", i(0)) .with("UID", i(id.user_id)) // == LoginResponse.SESS.UID } /// `Blaze::Authentication::PersonaInfo` @0x14487c7c0 — 7 members. /// /// `STAS` here is PersonaStatus ACTIVE = 2 (table 0x14487ad20) — a different /// enum from AccountInfo's `STAS`, which is AccountStatus ACTIVE = 1. `LADT`'s /// wire type is a best guess (INT timestamp); it is only reachable via /// getPersona/listPersonas, off the critical getAccount path. pub fn persona_info(now: i64, cfg: &AdapterConfig) -> Struct { let id = &cfg.identity; Struct::new() .with("DSNM", s(&id.persona_name)) .with("DTCR", s("2016-09-01T00:00:00Z")) .with("LADT", i(now)) .with("NSNM", s(&id.namespace)) .with("PID", i(id.persona_id)) .with("STAS", i(2)) .with("STRC", i(0)) } /// `Blaze::Authentication::GetPersonaResponse` @0x14487d1c0 — PINF + UID. pub fn get_persona_response(now: i64, cfg: &AdapterConfig) -> Struct { Struct::new() .with("PINF", Value::Struct(persona_info(now, cfg))) .with("UID", i(cfg.identity.user_id)) } /// `Blaze::Authentication::ListPersonasResponse` @0x14487d210 — one member. pub fn list_personas_response(now: i64, cfg: &AdapterConfig) -> Struct { Struct::new().with( "PINF", Value::List { elem: TypeId::Struct, items: vec![Value::Struct(persona_info(now, cfg))], }, ) } /// `Blaze::UserSessionLoginInfo` @0x14486f920 — 16 members. /// /// A superset of `UserLoginInfo` with the persona fields flattened in rather /// than nested. `KEY` must be byte-identical to `LoginResponse.SESS.KEY`. /// /// `CGID` (a connectionGroup ObjectId) is omitted: the OBJID encoding is /// UNVERIFIED and a wrong one desynchronises the parse, while an absent member /// simply keeps its default. pub fn user_session_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct { let id = &cfg.identity; Struct::new() .with("1CON", i(0)) .with("ALOC", i(sess.account_locale)) // echo the client's own locale .with("BUID", i(id.user_id)) .with("DSNM", s(&id.persona_name)) .with("FRST", i(0)) .with("KEY", s(&sess.session_key)) // same string as LoginResponse .with("LAST", i(now)) .with("LLOG", i(now)) .with("MAIL", s(&id.email)) .with("NASP", s(&id.namespace)) .with("PID", i(id.persona_id)) .with("PLAT", i(id.client_platform)) .with("UID", i(id.user_id)) .with("USTP", i(id.user_session_type)) .with("XREF", i(id.ext_id)) } /// `Blaze::Util::NetworkQosData` @0x14486e680 — 5 members. `NATT` 0 = OPEN. pub fn network_qos_data() -> Struct { Struct::new() .with("BWHR", i(0)) .with("DBPS", i(100_000)) .with("NAHR", i(0)) .with("NATT", i(0)) .with("UBPS", i(100_000)) } /// `Blaze::UserSessionExtendedData` @0x144870390. /// /// Two FIFA-17-specific deltas from the MEC emulators: FIFA HAS `PSLM` /// (latencyList), which they lack, and FIFA carries `BPS` as a top-level string /// whereas they bury it inside the `ADDR` union. Follow FIFA's layout. /// /// `ADDR`, `CVAR` and `ULST` are omitted — unverified union/objid encodings. pub fn user_session_extended_data() -> Struct { Struct::new() .with("BPS", s("openfut")) .with("CTY", s("US")) .with( "DMAP", Value::Map { key: TypeId::Int, val: TypeId::Int, entries: vec![], }, ) .with("HWFG", i(0)) .with("ISP", s("OpenFUT")) .with( "PSLM", Value::List { elem: TypeId::Int, items: vec![i(0)], }, ) .with("QDAT", Value::Struct(network_qos_data())) .with("TZ", s("")) .with("UATT", i(0)) } /// `Blaze::UserSessionExtendedDataUpdate` @0x1448703e0 — 3 members. pub fn user_session_extended_data_update(cfg: &AdapterConfig) -> Struct { Struct::new() .with("DATA", Value::Struct(user_session_extended_data())) .with("SUBS", i(1)) .with("USID", i(cfg.identity.user_id)) } /// `Blaze::UserIdentification` @0x14486ebc0 — 9 members. pub fn user_identification(sess: &Session, cfg: &AdapterConfig) -> Struct { let id = &cfg.identity; Struct::new() .with("AID", i(id.user_id)) .with("ALOC", i(sess.account_locale)) .with("EXBB", Value::Blob(vec![])) .with("EXID", i(id.ext_id)) .with("ID", i(id.user_id)) .with("NAME", s(&id.persona_name)) .with("NASP", s(&id.namespace)) .with("ORIG", i(id.persona_id)) .with("PIDI", i(id.persona_id)) } /// `Blaze::UserData` @0x1448706b0 — payload of the `UserAdded` push. /// `FLGS` is a UserDataFlags bitfield; bit 0 = online/authenticated. pub fn user_data(sess: &Session, cfg: &AdapterConfig) -> Struct { Struct::new() .with("EDAT", Value::Struct(user_session_extended_data())) .with("FLGS", i(3)) .with("USER", Value::Struct(user_identification(sess, cfg))) } /// `Blaze::Util::PostAuthResponse` @0x144875810 — TELE, TICK, UROP. /// /// Telemetry and ticker point at dead local ports on purpose: the client gets a /// well-formed config and then fails to connect quietly, rather than resolving /// a real EA hostname. pub fn post_auth_response(sess: &Session, cfg: &AdapterConfig) -> Struct { let tele = Struct::new() .with("ADRS", s(&cfg.endpoints.advertise)) .with("ANON", i(0)) .with("DISA", s("")) .with("EDCT", i(0)) .with("FILT", s("")) .with("LOC", i(sess.account_locale)) .with("MINR", i(0)) .with("NOOK", s("")) .with("PORT", i(cfg.endpoints.telemetry_port)) .with("SDLY", i(15_000)) .with("SESS", s(&sess.session_key)) // same key as login .with("SKEY", s("")) .with("SPCT", i(75)) .with("STIM", s("")) .with("SVNM", s("telemetry-openfut")); let tick = Struct::new() .with("ADRS", s(&cfg.endpoints.advertise)) .with("PORT", i(cfg.endpoints.ticker_port)) .with("SKEY", s("")); let urop = Struct::new() .with("TMOP", i(0)) .with("UID", i(cfg.identity.user_id)); Struct::new() .with("TELE", Value::Struct(tele)) .with("TICK", Value::Struct(tick)) .with("UROP", Value::Struct(urop)) } /// `Blaze::Authentication::Entitlement` @0x14487d490 — 16 members. /// /// FUT's client-side filter (`onListEntitlements` @0x146f27440) keeps a record /// only if `GNAM` contains `"FIFA17PCBoxContent"` or `"FIFA16PC"`, `TAG` is /// non-empty, and `STAT == 1`. A plain `"FIFA17PC"` group matched neither /// needle and produced an empty store. /// /// `PRID`/`GNAM`/`TAG` must contain no `'|'` and no `'/'`: the client /// re-serialises them as `PRID|GNAM|TAG|UCNT/`. pub fn entitlement(group: &str, tag: &str, eid: i64, cfg: &AdapterConfig) -> Struct { let id = &cfg.identity; Struct::new() .with("DEVI", s("")) .with("GDAY", s("2016-09-01T00:00:00Z")) .with("GNAM", s(group)) .with("ID", i(eid)) .with("ISCO", i(0)) .with("PID", i(id.persona_id)) .with("PJID", s(&id.content_id)) .with("PRCA", i(2)) .with("PRID", s(&id.content_id)) .with("STAT", i(1)) // must be 1 or FUT drops it .with("STRC", i(0)) .with("TAG", s(tag)) // must be non-empty .with("TDAY", s("")) .with("TYPE", i(1)) .with("UCNT", i(0)) .with("VER", i(1)) } /// `Blaze::Authentication::Entitlements` @0x14487d4e0 — single member `NLST`. /// /// Emits BOTH accepted groups so the entitlement manager's "loaded" flag /// (`byte[entMgr+0x88]`) flips however the client asks. pub fn entitlements_response(cfg: &AdapterConfig) -> Struct { let tag = &cfg.identity.entitlement_tag; Struct::new().with( "NLST", Value::List { elem: TypeId::Struct, items: vec![ Value::Struct(entitlement("FIFA17PCBoxContent", tag, 1, cfg)), Value::Struct(entitlement("FIFA16PC", tag, 2, cfg)), ], }, ) } /// `Blaze::Authentication::GetAuthTokenResponse` @0x14487d080 — one member. pub fn get_auth_token_response(sess: &Session) -> Struct { Struct::new().with("AUTH", s(sess.auth_token())) } /// `Util::userSettingsLoad` response. /// /// TODO(verify): the response descriptor was never reflected. Both independent /// clean-room emulators use a single `DATA` string, and an unknown-tag payload /// is ignored rather than fatal, so an empty `DATA` is the safe minimum — the /// client falls back to its defaults. pub fn user_settings_response() -> Struct { Struct::new().with("DATA", s("")) } /// `AssociationLists::getLists` response. /// /// TODO(verify): FIFA's association-list names are NOT known — do not invent /// them. An empty list is well-formed and means "this user has no association /// lists", which is true offline. pub fn get_lists_response() -> Struct { Struct::new().with( "LMAP", Value::List { elem: TypeId::Struct, items: vec![], }, ) } #[cfg(test)] mod tests { use super::*; fn cfg() -> AdapterConfig { AdapterConfig::loopback() } #[test] fn iso8601_matches_known_instants() { assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z"); assert_eq!(iso8601_utc(1_754_870_400), "2025-08-11T00:00:00Z"); // A leap day, to exercise the civil-from-days branch. assert_eq!(iso8601_utc(1_709_164_800), "2024-02-29T00:00:00Z"); assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00Z"); } #[test] fn login_response_has_exactly_five_members() { let sess = Session::new("k", 0); assert_eq!(login_response(&sess, 0, &cfg()).len(), 5); } #[test] fn account_info_has_exactly_sixteen_members() { assert_eq!(account_info(0, &cfg()).len(), 16); } #[test] fn session_key_appears_identically_in_all_three_places() { let sess = Session::new("THE-KEY", 0); let c = cfg(); let login = login_response(&sess, 1, &c); let in_login = login .get("SESS") .and_then(Value::as_struct) .and_then(|s| s.get("KEY")) .and_then(Value::as_str) .unwrap(); let notify = user_session_login_info(&sess, 1, &c); let in_notify = notify.get("KEY").and_then(Value::as_str).unwrap(); let post = post_auth_response(&sess, &c); let in_post = post .get("TELE") .and_then(Value::as_struct) .and_then(|s| s.get("SESS")) .and_then(Value::as_str) .unwrap(); assert_eq!(in_login, "THE-KEY"); assert_eq!(in_notify, "THE-KEY"); assert_eq!(in_post, "THE-KEY"); } #[test] fn identity_is_consistent_between_login_and_account_info() { let sess = Session::new("k", 0); let c = cfg(); let acct = account_info(0, &c); let sess_info = user_login_info(&sess, 0, &c); assert_eq!( acct.get("MAIL").and_then(Value::as_str), sess_info.get("MAIL").and_then(Value::as_str) ); assert_eq!( acct.get("UID").and_then(Value::as_int), sess_info.get("UID").and_then(Value::as_int) ); assert_eq!( acct.get("ASRC").and_then(Value::as_str), Some(c.identity.namespace.as_str()) ); } #[test] fn entitlement_groups_match_the_clients_needles() { let c = cfg(); let list = entitlements_response(&c); let items = match list.get("NLST") { Some(Value::List { items, .. }) => items, _ => panic!("NLST is a list"), }; assert_eq!(items.len(), 2); for item in items { let e = item.as_struct().unwrap(); let gnam = e.get("GNAM").and_then(Value::as_str).unwrap(); assert!( gnam.contains("FIFA17PCBoxContent") || gnam.contains("FIFA16PC"), "group {gnam} matches neither client needle" ); assert_eq!(e.get("STAT").and_then(Value::as_int), Some(1)); assert!(!e.get("TAG").and_then(Value::as_str).unwrap().is_empty()); // The client re-serialises these delimited; a separator would corrupt it. for tag in ["PRID", "GNAM", "TAG"] { let v = e.get(tag).and_then(Value::as_str).unwrap(); assert!( !v.contains('|') && !v.contains('/'), "{tag} has a separator" ); } } } #[test] fn census_periods_are_non_zero() { // Zero here is the ~30/s storm that hangs the FUT loading screen. let r = census_subscribe_response(); assert!(r.get("CNP").and_then(Value::as_int).unwrap() > 0); assert!(r.get("NTMT").and_then(Value::as_int).unwrap() > 0); } #[test] fn preauth_echoes_the_requested_service_name() { let p = preauth_response("fifa-2017-pc-de", &cfg()); assert_eq!( p.get("INST").and_then(Value::as_str), Some("fifa-2017-pc-de") ); } #[test] fn extended_data_omits_the_unverified_members() { // Absent is safe; a wrong union/objid encoding breaks the whole parse. let d = user_session_extended_data(); for absent in ["ADDR", "CVAR", "ULST"] { assert!(d.get(absent).is_none(), "{absent} must stay omitted"); } assert!( d.get("PSLM").is_some(), "PSLM is FIFA-specific and required" ); } }