feat(fifa17): serve offline Seasons instead of an empty body

Single-player Seasons failed with "There was a problem communicating with the
FIFA Ultimate Team Servers". Two independent faults, both fixed:

1. The client never reached a season endpoint at all. It aborts on a
   prerequisite web file, captured live by the deployed trace:
     SEASONS_WEBFILE_URL: url="packs/loc/storepackdescriptions.en_us.xml"
     SEASONS_STAGE1: status(+0x1c)=999 -> CACHE_PACKNAMES_FAILED
   That is the hook's side (launcher 5294f58): the CDN base is empty in the
   emulator, so the url stays relative and never reaches the POW content server
   on 8085 that actually serves it.

2. `season/list` and `season/user` were not served. Only the EXACT tail
   "season" was classified (as FeatureOffEmpty); every sub-path fell through to
   Passthrough — the deliberately-dead Python upstream — so the mode could not
   have worked even once the web file resolved.

Adds `fut::season_wire` with the reversed element schema (parser FUN_180167740,
stride 0x318; matches elements via FUN_180167fb0) and a `Route::Season` owning
`season…` for GET plus the state-storing PUT. Anything else still proxies rather
than being claimed without evidence.

Two things the types encode because getting them wrong is fatal:

* `matches` is NEVER empty. StartSeason (FUN_1800fc500) indexes
  `matches[*(x+0x70)].teamId` off `elem+0x2e8`; an empty vector makes that a
  NULL dereference and the client dies at CardsDLL+0xfc5b5. A full ten-round
  schedule is emitted, with opponents drawn from team ids observed in this
  client's own database.
* `type` MUST serialise before `divisionId`. `serde_json::Value` is a BTreeMap
  here (no preserve_order), so `json!` sorts keys ALPHABETICALLY and emitted
  divisionId first — caught by a test written for exactly this. The wire shapes
  are therefore `#[derive(Serialize)]` structs (declaration order) rendered
  straight to text via a new `json_text_status`, never round-tripped through
  Value.

Verified on staging: season/list returns the ten-round OFFLINE season with
type before divisionId, season/user the round-1 position, history an empty
list, and the bare tail still {}.

Season progress is not yet persisted: the state-storing PUT is acknowledged
with {} (what the retail wire answers) rather than pretending a round advanced.
This commit is contained in:
funman300
2026-08-21 16:20:01 +00:00
parent ff915a306e
commit dddcfb917c
4 changed files with 296 additions and 8 deletions
+1
View File
@@ -17,6 +17,7 @@ pub mod non_economy;
pub mod owned_query;
pub mod pack_content;
pub mod sbc;
pub mod season_wire;
pub mod squad;
pub mod squad_ext;
pub mod squad_projection;
@@ -0,0 +1,209 @@
//! 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<SeasonMatch>,
}
#[derive(Debug, Serialize)]
pub struct SeasonList {
pub seasons: Vec<SeasonElement>,
}
/// 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);
}
}