//! FIFA 17 offline-Seasons wire shapes. //! //! Reversed from `CardsDLL_Win64_retail.dll`, not guessed. The `season/list` //! per-element parser is `FUN_180167740` (element stride 0x318) and the response //! deserialiser root is `FUN_1801683f0` (an object with the single key //! `seasons`). Element fields land at: //! //! | wire key | atom | element offset | //! |--------------|-------|--------------------------------| //! | `id` | 0x15c | +0x1b0 | //! | `divisionId` | 0x0dc | +0x1f8, as `(0xb - value)` | //! | `type` | — | +0x1b4 (int, switch) | //! | `matches` | 0x1b8 | vector at +0x2e8/+0x2f0/+0x2f8 | //! //! Each `matches` element is 16 bytes, parsed by `FUN_180167fb0`: //! `teamId`(0x305) int@+0x0, `difficulty`(0xd4) byte@+0x4, `roundId`(0x291) //! byte@+0x5, `rewardMult`(0x28b) int@+0x8, `coins`(0x95) int@+0xc. //! //! WHY `matches` MUST BE NON-EMPTY: `StartSeason` (`FUN_1800fc500`) reads //! `matches[*(x+0x70)].teamId` through `*(elem+0x2e8 + index*0x10)`. With an //! empty vector `elem+0x2e8` is NULL and the client dereferences address 0 — //! a hard crash at `CardsDLL+0xfc5b5`. Emitting a full round set is therefore a //! correctness requirement, not a nicety. //! //! WHY STRUCTS AND NOT `json!`: `type` must precede `divisionId` — the element //! parser binds the competition type before it maps the division. `serde_json`'s //! `Value` is a `BTreeMap` without the `preserve_order` feature, so `json!` //! silently reorders keys ALPHABETICALLY and would emit `divisionId` first. //! A `#[derive(Serialize)]` struct serialises in declaration order, so these //! types ARE the wire contract. For the same reason every body here is rendered //! straight to a `String` and never round-tripped through `Value`. use serde::Serialize; /// Rounds in one FIFA 17 offline season. The division ladder is ten matches. pub const SEASON_ROUNDS: i64 = 10; /// Opponent team ids used for the round schedule. /// /// These are real team ids observed in this client's own database (they appear /// as the `teamid` of club players in the live kit-item trace), so every round /// resolves to a team the client can actually render. They are cycled rather /// than randomised so a season's schedule is stable across reloads — the client /// re-reads `season/list` and a shifting schedule would renumber fixtures. const OPPONENT_TEAM_IDS: &[i64] = &[21, 73, 240, 241, 243]; /// One scheduled offline-season round. #[derive(Debug, Serialize)] pub struct SeasonMatch { #[serde(rename = "teamId")] pub team_id: i64, pub difficulty: i64, #[serde(rename = "roundId")] pub round_id: i64, #[serde(rename = "rewardMult")] pub reward_mult: i64, pub coins: i64, } /// One offline competition. FIELD ORDER IS THE WIRE CONTRACT — `type` first. #[derive(Debug, Serialize)] pub struct SeasonElement { #[serde(rename = "type")] pub kind: &'static str, pub id: i64, #[serde(rename = "divisionId")] pub division_id: i64, pub matches: Vec, } #[derive(Debug, Serialize)] pub struct SeasonList { pub seasons: Vec, } /// The club's position in its current season. #[derive(Debug, Serialize)] pub struct SeasonUser { #[serde(rename = "seasonId")] pub season_id: i64, #[serde(rename = "divisionId")] pub division_id: i64, pub round: i64, #[serde(rename = "userPoints")] pub user_points: i64, /// Opaque client blob; the client round-trips it and never requires server /// interpretation. #[serde(rename = "dataVersion")] pub data_version: &'static str, pub data: &'static str, } fn round(index: i64) -> SeasonMatch { SeasonMatch { team_id: OPPONENT_TEAM_IDS[(index as usize) % OPPONENT_TEAM_IDS.len()], // Difficulty and reward multiplier are per-round bytes; a flat schedule // is the honest default until the retail ladder is captured. difficulty: 1, round_id: index, reward_mult: 1, coins: 400, } } /// `GET …/season/list` — the offline competitions the club can enter, as wire /// text (see the module note on key order). pub fn season_list_body(season_id: i64, division_id: i64) -> String { let list = SeasonList { seasons: vec![SeasonElement { kind: "OFFLINE", id: season_id, division_id, matches: (0..SEASON_ROUNDS).map(round).collect(), }], }; serde_json::to_string(&list).expect("season list serialises") } /// `GET …/season/user` — where the club currently is in its season. pub fn season_user_body(season_id: i64, division_id: i64, round: i64, user_points: i64) -> String { let user = SeasonUser { season_id, division_id, round, user_points, data_version: "1", data: "", }; serde_json::to_string(&user).expect("season user serialises") } /// `GET …/season/user/history` — completed seasons. Empty until a season ends; /// the client renders an empty history without complaint. pub fn season_history_body() -> String { String::from(r#"{"seasons":[]}"#) } #[cfg(test)] mod tests { use super::*; use serde_json::Value; fn parsed(text: &str) -> Value { serde_json::from_str(text).expect("valid json") } #[test] fn list_emits_a_full_round_schedule() { let body = parsed(&season_list_body(1, 10)); let season = &body["seasons"][0]; assert_eq!(season["type"], "OFFLINE"); assert_eq!(season["id"], 1); assert_eq!(season["divisionId"], 10); assert_eq!( season["matches"].as_array().unwrap().len(), SEASON_ROUNDS as usize ); } /// An empty `matches` vector makes StartSeason dereference NULL /// (CardsDLL+0xfc5b5), so the schedule can never be empty. #[test] fn matches_are_never_empty_and_every_round_has_a_team() { let body = parsed(&season_list_body(3, 7)); let matches = body["seasons"][0]["matches"].as_array().unwrap(); assert!(!matches.is_empty()); for (i, m) in matches.iter().enumerate() { assert_eq!(m["roundId"], i as i64, "rounds are 0..n and in order"); assert!( m["teamId"].as_i64().is_some_and(|t| t > 0), "round {i} must name a real opponent team: {m}" ); assert!(m["coins"].as_i64().is_some()); assert!(m["rewardMult"].as_i64().is_some()); assert!(m["difficulty"].as_i64().is_some()); } } /// The element parser binds the competition type before mapping the /// division, so `type` MUST serialise before `divisionId`. `json!` would /// order them alphabetically and break this. #[test] fn type_is_serialised_before_division_id() { let text = season_list_body(1, 10); let type_at = text.find("\"type\"").expect("type key"); let division_at = text.find("\"divisionId\"").expect("divisionId key"); assert!( type_at < division_at, "type must precede divisionId on the wire: {text}" ); } #[test] fn user_state_carries_the_season_position() { let body = parsed(&season_user_body(1, 10, 3, 6)); assert_eq!(body["seasonId"], 1); assert_eq!(body["divisionId"], 10); assert_eq!(body["round"], 3); assert_eq!(body["userPoints"], 6); assert_eq!(body["dataVersion"], "1"); assert_eq!(body["data"], ""); } #[test] fn history_is_an_empty_season_list() { let body = parsed(&season_history_body()); assert_eq!(body["seasons"].as_array().unwrap().len(), 0); } }