openfut-adapter-fifa17: FIFA 17 Blaze adapter, oracle-tested

The second migration step: the layer above the codec, deciding WHAT to say
rather than how to encode it. Sits on openfut-protocol-blaze and supplies
what that crate deliberately refuses to know.

  blaze/ids.rs           component/command/notification tables
  blaze/config.rs        injectable identity + endpoints, nothing hardcoded
  blaze/session.rs       per-connection state
  blaze/client_config.rs the fetchClientConfig tables
  blaze/responses.rs     16 Blaze::* response bodies
  blaze/dispatch.rs      (component, command) -> Vec<Frame>

Parity is tested, not asserted. fixtures/generate.py drives the real
blaze_responder_v3b.dispatch() and records 49 request->response(s)
transactions, replayed in order against a shared session per connection so
ordering-dependent behaviour is exercised: preAuth captures the locale later
ALOC fields echo, login sets the auth code getAuthToken returns. Comparison
is byte-for-byte including frame count and order.

98 tests green across both crates; clippy clean.

MUTATION TESTED, and it found a real defect in this commit's own design.
Swapping two post-login notifications and flipping one enum inside
AccountInfo both turned the suite red as intended. Hardcoding an address in
utas_base() did NOT -- the config templating substituted raw hosts directly,
making those helpers dead code that merely looked load-bearing. The table now
templates on URL-level tokens ({utas_base}, {nucleus_base},
{pow_content_url}) so they are the single place a URL shape is defined, and
the mutation is caught.

The client config table (227-243 rows per CFID) is generated from the oracle
rather than transcribed: it is reverse-engineered data, not logic, and 400
hand-copied string literals would add a typo class no reviewer can catch. The
generator substitutes real addresses back in and diffs against the oracle for
every section before writing, so the templating is verified rather than
assumed.

Reproduces one known defect deliberately: nucleusConnect is built from BIND,
not advertise, so the live split deployment tells a client on another machine
to reach Nucleus at http://0.0.0.0:42131. Confirmed against the running
container. Reproduced because it is what the only proven-working config does;
fixing it needs live validation and is a separate change. It also implies the
Nucleus stub is not reached in the current remote flow.

Blaze carries no FUT domain state -- no coins, packs, clubs or squads on this
wire -- so Session stays a session key, locale, service name, auth code and a
flag. That boundary will need defending when UTAS is migrated.

Not wired into anything. The crate answers frames; it opens no socket and
owns no runtime. The Python backend remains the live service and the oracle,
and is unmodified (contract suite still green).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-11 01:18:29 +00:00
parent cc3ecddc06
commit cf961603fe
16 changed files with 14939 additions and 0 deletions
@@ -0,0 +1,623 @@
//! 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<String>) -> Value {
Value::String(v.into())
}
fn i(v: i64) -> Value {
Value::Int(v)
}
/// `Blaze::Util::FetchConfigResponse` @0x1448752e0 — a single `CONF`
/// map<string,string>.
///
/// 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::default()
}
#[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"
);
}
}