From d74aee33f74b640693459bd11ca02186962a8485 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 21 Aug 2026 21:47:13 +0000 Subject: [PATCH] feat(fifa17): opt-in commerce settings, the server half of the transfer-list fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Place on Transfer List" is greyed for two reasons. This crate already fixes one (owned copies emit `untradeable: false`). The other is `tradingEnabled`: the client's struct defaults it to 0 — it is not a flag we have been overwriting, it is a flag nobody has ever sent — and it gates the service half of the TO_TRADE_PILE predicate (vtable slot +0x270, gate byte 0x1fd2e, measured 0 live). `GET /settings` has always answered `{"configs": []}`. The schema is high-confidence: FutGetSettingsServerResponse (deser 0x18013c6d0, read end to end) has a single `configs` key holding `{type, value}` rows, and the key ladder holds nothing else. `type` is the setting NAME. The row set is ported from the shape the Python oracle would emit rather than invented. Default OFF (`OPENFUT_FIFA17_COMMERCE_SETTINGS=1` opts in), because the flags are RECOVERED BUT UNTESTED and the empty list is the live-proven body — the house rule is that a flag defaults to the live-proven value. This also moves the capability out of the oracle we are retiring and into Rust, where it can actually be reached once Python is gone. Verified against a real host on both settings: OFF returns {"configs":[]} byte-identical to today, ON returns the 8-row body with tradingEnabled. It explains why the menu entry is greyed; it does not promise the market works. --- openfut-adapter-fifa17/src/fut/non_economy.rs | 81 ++++++++++++++++++- openfut-utas-host/src/lib.rs | 19 ++++- 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/openfut-adapter-fifa17/src/fut/non_economy.rs b/openfut-adapter-fifa17/src/fut/non_economy.rs index 6c4d587..2ffd0b8 100644 --- a/openfut-adapter-fifa17/src/fut/non_economy.rs +++ b/openfut-adapter-fifa17/src/fut/non_economy.rs @@ -24,9 +24,50 @@ pub fn accountinfo_body() -> Value { json!({}) } -/// `GET …/settings` — production oracle returns an empty config list. -pub fn settings_body() -> Value { - json!({ "configs": [] }) +/// The commerce flags the client leaves OFF unless a `configs` row turns them on. +/// +/// `FutGetSettingsServerResponse` (deser `0x18013c6d0`, read end to end) has a +/// single wrapper key `configs` (0xa2) holding an array of +/// `{ type (0x354), value (0x377) }`, and nothing else. `type` is the setting +/// NAME, not an index. +/// +/// These matter because the struct's defaults are NOT uniform: several fields +/// default to 1, but `tradingEnabled` defaults to **0**. It is not a flag we have +/// been overwriting — it is a flag nobody has ever sent. It gates the service +/// half of the `TO_TRADE_PILE` predicate (vtable slot `+0x270` = +/// `FUN_18011c670`, reading gate byte `0x1fd2e`, measured 0 in the live client), +/// which is one of the two reasons "Place on Transfer List" is greyed. The other +/// reason — `untradeable` — this crate already handles: [`crate::fut::item`] +/// emits `false` for owned copies. +const COMMERCE_SETTINGS: [&str; 8] = [ + "storeEnabled", + "storeEnabled_JP", + "coinEnabled", + "coinEnabled_JP", + "cardPackStoreEnabled", + "cardPackStoreEnabled_JP", + "pointsPackStoreEnabled", + "tradingEnabled", +]; + +/// `GET …/settings` — an empty config list by default, matching the production +/// oracle and the live-proven behaviour. +/// +/// `enable_commerce` opts in to [`COMMERCE_SETTINGS`]. It defaults OFF because +/// the flags are RECOVERED BUT UNTESTED: the schema is high-confidence and this +/// is the exact row shape the oracle would emit, but no launch has yet confirmed +/// what the client does with them. Turning it on is the server half of the +/// transfer-list fix; it explains why the menu entry is greyed and does NOT +/// promise that the transfer market behind it works. +pub fn settings_body(enable_commerce: bool) -> Value { + if !enable_commerce { + return json!({ "configs": [] }); + } + let rows: Vec = COMMERCE_SETTINGS + .iter() + .map(|name| json!({ "type": name, "value": 1 })) + .collect(); + json!({ "configs": rows }) } /// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an @@ -426,12 +467,44 @@ mod tests { #[test] fn static_bodies_match_oracle() { assert_eq!(accountinfo_body(), json!({})); - assert_eq!(settings_body(), json!({ "configs": [] })); + assert_eq!(settings_body(false), json!({ "configs": [] })); assert_eq!(leaderboard_options_body(), json!({})); assert_eq!(match_reset_body(), json!({})); assert_eq!(club_stats_staff_body(), json!({})); } + /// The default MUST stay the live-proven empty list, and the opt-in body must + /// match the schema exactly: `configs` holding `{type, value}` rows where + /// `type` is the setting NAME. `tradingEnabled` is the one that matters — the + /// client defaults it to 0 and it gates the transfer-list menu entry. + #[test] + fn commerce_settings_are_opt_in_and_shaped_to_the_schema() { + assert_eq!( + settings_body(false), + json!({ "configs": [] }), + "default must remain the live-proven body" + ); + + let on = settings_body(true); + let rows = on["configs"].as_array().expect("configs is an array"); + assert_eq!(rows.len(), COMMERCE_SETTINGS.len()); + for row in rows { + let obj = row.as_object().expect("each config row is an OBJECT"); + assert_eq!( + obj.keys().collect::>(), + vec!["type", "value"], + "the deserializer knows exactly two keys; an extra one is skipped \ + at best and a type desync at worst" + ); + assert!(obj["type"].is_string(), "type is the setting NAME"); + assert_eq!(obj["value"], 1); + } + assert!( + rows.iter().any(|r| r["type"] == "tradingEnabled"), + "the whole point of the opt-in" + ); + } + #[test] fn action_parse() { assert_eq!( diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index df86a3e..acf9e10 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -3926,8 +3926,9 @@ impl Server { json_status(200, &non_economy::accountinfo_body()) } Route::Settings => { - eprintln!("utas-host owner=RUST route=settings status=200"); - json_status(200, &non_economy::settings_body()) + let commerce = commerce_settings_enabled(); + eprintln!("utas-host owner=RUST route=settings status=200 commerce={commerce}"); + json_status(200, &non_economy::settings_body(commerce)) } Route::LeaderboardOptions => { eprintln!("utas-host owner=RUST route=leaderboards-options status=200"); @@ -4771,6 +4772,20 @@ fn fifa17_sidlog(sid: &str) -> String { } } +/// Whether `GET …/settings` should turn the client's commerce flags on. +/// +/// OFF unless `OPENFUT_FIFA17_COMMERCE_SETTINGS=1`, because the flags are +/// recovered but UNTESTED and the empty config list is the live-proven body. The +/// house rule is that a flag defaults to the live-proven value. +/// +/// Turning it on is the SERVER half of the transfer-list fix — the client's +/// `tradingEnabled` gate defaults to 0 and nothing has ever sent it. It needs a +/// launch to confirm, and it does not promise the market behind the menu works. +fn commerce_settings_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_COMMERCE_SETTINGS").as_deref() == Ok("1")) +} + /// A JSON response with an explicit status. fn json_status(status: u16, v: &Value) -> WireResponse { let body = serde_json::to_vec(v).unwrap_or_default();