diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index fc5d4a3..65c42b8 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -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; diff --git a/openfut-adapter-fifa17/src/fut/season_wire.rs b/openfut-adapter-fifa17/src/fut/season_wire.rs new file mode 100644 index 0000000..39f941d --- /dev/null +++ b/openfut-adapter-fifa17/src/fut/season_wire.rs @@ -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, +} + +#[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); + } +} diff --git a/openfut-launcher b/openfut-launcher index 7edf682..5294f58 160000 --- a/openfut-launcher +++ b/openfut-launcher @@ -1 +1 @@ -Subproject commit 7edf682291096583b5b7b4e0d04fec078b739faf +Subproject commit 5294f589ad31014d610adc1e32eb04f693704773 diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 38f6d7d..4f7b14c 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -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),