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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
Submodule openfut-launcher updated: 7edf682291...5294f589ad
@@ -64,6 +64,7 @@ use openfut_adapter_fifa17::fut::owned_query::{
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::pack_content::GeneratedCandidate;
|
||||
use openfut_adapter_fifa17::fut::sbc as fifa17_sbc;
|
||||
use openfut_adapter_fifa17::fut::season_wire;
|
||||
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{
|
||||
build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION,
|
||||
@@ -159,6 +160,10 @@ pub enum Route {
|
||||
/// reads return `{}`, byte-identical to the Python oracle with `FUT_MODES` /
|
||||
/// `FUT_CLUB_IDENTITY` off. Club rename is a separate Rust-owned route.
|
||||
FeatureOffEmpty,
|
||||
/// `…/season…` — FIFA 17 offline Seasons. Rust-owned: the schedule the
|
||||
/// client needs before it will open the mode at all, plus the user's
|
||||
/// position in it. See [`openfut_adapter_fifa17::fut::season_wire`].
|
||||
Season,
|
||||
/// `GET …/item/resource`, `…/defid` — FUT item-definition lookups. Rust builds
|
||||
/// `{itemData:[…]}` (one placeholder-or-Ronaldo def per queried id), mirroring
|
||||
/// the oracle's `defs_route`/`item_def`. The client renders the real card from
|
||||
@@ -223,12 +228,18 @@ pub fn classify(method: &str, path: &str) -> Route {
|
||||
Some("hub") if get => Route::Hub,
|
||||
Some("store") => Route::StaticAck,
|
||||
Some("match/keepalive") => Route::StaticAck,
|
||||
Some("captcha") if get => Route::StaticAck,
|
||||
Some("tfa") => Route::StaticAck,
|
||||
Some("livemessage") => Route::StaticAck,
|
||||
Some("activeMessage") => Route::StaticAck,
|
||||
Some("watchList") => Route::WatchList,
|
||||
Some("season") if get => Route::FeatureOffEmpty,
|
||||
// `season…` is Rust-owned for the methods the client actually uses: the
|
||||
// GETs that drive the mode, and the PUT that stores season state. The
|
||||
// bare tail keeps the old empty body. Matching the PREFIX (not just
|
||||
// `"season"`) matters — `season/list` used to fall through to
|
||||
// Passthrough, i.e. the deliberately-dead Python upstream. Anything else
|
||||
// still proxies rather than being claimed without evidence.
|
||||
Some(t)
|
||||
if (get || method.eq_ignore_ascii_case("PUT"))
|
||||
&& (t == "season" || t.starts_with("season/")) =>
|
||||
{
|
||||
Route::Season
|
||||
}
|
||||
Some("tournament") if get => Route::FeatureOffEmpty,
|
||||
Some("champion") if get => Route::FeatureOffEmpty,
|
||||
Some("clubUser") if get => Route::FeatureOffEmpty,
|
||||
@@ -3531,6 +3542,7 @@ impl Server {
|
||||
Route::WatchList => self.handle_watchlist(method),
|
||||
Route::User => self.handle_user(),
|
||||
Route::FeatureOffEmpty => self.handle_feature_off_empty(path),
|
||||
Route::Season => self.handle_season(path),
|
||||
Route::ItemDefs => self.handle_item_defs(target),
|
||||
Route::MarketData => self.handle_marketdata(path, target),
|
||||
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
|
||||
@@ -4014,6 +4026,45 @@ impl Server {
|
||||
json_status(200, &non_economy::feature_off_body())
|
||||
}
|
||||
|
||||
/// `…/season…` — FIFA 17 offline Seasons.
|
||||
///
|
||||
/// The client will not open the mode until it has a schedule: `season/list`
|
||||
/// must carry a NON-EMPTY `matches` array or `StartSeason` dereferences NULL
|
||||
/// (`CardsDLL+0xfc5b5`). Shapes live in the adapter; this only routes.
|
||||
///
|
||||
/// A season is currently a fixed division-10 ladder at round 1 — the client
|
||||
/// renders and starts from that. Persisting progress across matches is a
|
||||
/// separate piece of work, so the PUT that stores season state is
|
||||
/// acknowledged (`{}`, which is what the retail wire answers) without
|
||||
/// pretending the round advanced.
|
||||
fn handle_season(&self, path: &str) -> WireResponse {
|
||||
const SEASON_ID: i64 = 1;
|
||||
const DIVISION_ID: i64 = 10;
|
||||
|
||||
let tail = ut_tail(path).unwrap_or("");
|
||||
let sub = tail
|
||||
.strip_prefix("season")
|
||||
.unwrap_or("")
|
||||
.trim_start_matches('/');
|
||||
let (kind, body) = match sub {
|
||||
"list" => (
|
||||
"list",
|
||||
season_wire::season_list_body(SEASON_ID, DIVISION_ID),
|
||||
),
|
||||
"user" => (
|
||||
"user",
|
||||
season_wire::season_user_body(SEASON_ID, DIVISION_ID, 1, 0),
|
||||
),
|
||||
s if s.starts_with("user/history") => ("history", season_wire::season_history_body()),
|
||||
// Bare `season`, the state-storing PUT, and anything not yet
|
||||
// reversed: an empty object, exactly as before. Logged with its tail
|
||||
// so an unhandled sub-path is visible rather than silent.
|
||||
_ => ("ack", String::from("{}")),
|
||||
};
|
||||
eprintln!("utas-host owner=RUST route=season kind={kind} tail={tail} status=200");
|
||||
json_text_status(200, body)
|
||||
}
|
||||
|
||||
/// `GET …/item/resource`, `…/defid` — FUT item-definition lookup. Builds
|
||||
/// `{itemData:[…]}` for every integer id (≥ 3 digits) in the query, mirroring
|
||||
/// the oracle's `defs_route` (`re.findall(r"\d{3,}")` over the raw query).
|
||||
@@ -4189,6 +4240,25 @@ fn json_status(status: u16, v: &Value) -> WireResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// A JSON response from ALREADY-SERIALISED wire text.
|
||||
///
|
||||
/// Use when key ORDER is part of the contract. `Value` is a `BTreeMap` here
|
||||
/// (no `preserve_order` feature), so routing a body through it silently
|
||||
/// re-sorts keys alphabetically — which breaks the FIFA 17 season element,
|
||||
/// whose parser needs `type` before `divisionId`.
|
||||
fn json_text_status(status: u16, body: String) -> WireResponse {
|
||||
let body = body.into_bytes();
|
||||
WireResponse {
|
||||
status,
|
||||
headers: vec![
|
||||
("Content-Type".to_string(), "application/json".to_string()),
|
||||
("Content-Length".to_string(), body.len().to_string()),
|
||||
],
|
||||
body,
|
||||
transport: ResponseTransport::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn core_sbc_error_response(error: CoreError) -> WireResponse {
|
||||
let status = match &error {
|
||||
CoreError::Status(400) => 400,
|
||||
@@ -5311,7 +5381,15 @@ mod tests {
|
||||
Route::ClientData,
|
||||
),
|
||||
("POST", "/openfut/account/sync", Route::AccountSync),
|
||||
("GET", "/ut/game/fifa17/season", Route::FeatureOffEmpty),
|
||||
("GET", "/ut/game/fifa17/season", Route::Season),
|
||||
("GET", "/ut/game/fifa17/season/list", Route::Season),
|
||||
("GET", "/ut/game/fifa17/season/user", Route::Season),
|
||||
("GET", "/ut/game/fifa17/season/user/history", Route::Season),
|
||||
(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/season/1/division/10/user",
|
||||
Route::Season,
|
||||
),
|
||||
("GET", "/ut/game/fifa17/tournament", Route::FeatureOffEmpty),
|
||||
("GET", "/ut/game/fifa17/champion", Route::FeatureOffEmpty),
|
||||
("GET", "/ut/game/fifa17/clubUser", Route::FeatureOffEmpty),
|
||||
|
||||
Reference in New Issue
Block a user