f451406058
Mandatory OpenFUT architecture audit. Two real defects found and fixed, plus
the config surface tightened so neither class can recur.
DEFECT 1 -- hidden localhost fallback. The Rust host defaulted POW hosts to
127.0.0.1 while every other URL followed OPENFUT_ADVERTISE, so a remote
deployment would emit loopback POW URLs and fail far from the cause. It also
diverged from the deployed Python entrypoint, which derives them
(POW_HOST="${POW_HOST:-$ADV:8094}"). POW endpoints now derive from the
advertised address; explicit overrides still win.
DEFECT 2 -- Default gave loopback silently. `Endpoints::default()` and
`AdapterConfig::default()` supplied 127.0.0.1, so anything constructing a
config by omission got loopback with no signal. Both `Default` impls are
REMOVED. Loopback is now `Endpoints::loopback()` / `AdapterConfig::loopback()`:
an explicit, greppable decision. Production uses `advertising(host)`.
CONFIGURABILITY. `blaze_port` and `utas_port` are now config, not literals.
The advertised Blaze port is our choice -- the client goes wherever
<serverinstanceinfo> sends it -- and 8099 is the client's own built-in default
but still deployment config. A bad port value is an error, not a silent
fallback to the previous one.
TEST-NET EVERYWHERE. Committed fixtures and tests used the lab's real LAN
address; a test that passes because its constant matches the current lab
proves nothing about relocatability. Redirector fixtures regenerated on
RFC 5737 TEST-NET-1/2/3 plus loopback. Harness scripts no longer default the
client IP to the lab address -- client-state.sh now requires it.
SEVEN REQUIRED TESTS in tests/deployment_config.rs plus host-side coverage:
remote config never silently becomes localhost; missing advertise fails
clearly; bind may differ from advertise; changing the Blaze port changes the
redirect; changing the host updates all 200+ generated URLs with no
stragglers; no helper bypasses central config; mutations are detectable.
MUTATION TESTED, and it found a hole in the audit tests themselves. Hardcoding
utas_base, reverting the POW derivation and re-hardcoding the Blaze port were
all caught. Making the redirector read `bind` instead of `advertise` was NOT:
`advertising()` sets bind == advertise, so the two sources were
indistinguishable. That is the single most likely bypass -- the oracle really
does read bind for nucleusConnect -- so the test now forces bind != advertise
and asserts the bind address never reaches the wire. Re-mutated: caught.
Wire behaviour unchanged: oracle fixtures still current, 153 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
624 lines
22 KiB
Rust
624 lines
22 KiB
Rust
//! 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::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"
|
|
);
|
|
}
|
|
}
|