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
+85 -7
View File
@@ -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),