openfut-adapter-fifa17: FIFA 17 Blaze adapter, oracle-tested

The second migration step: the layer above the codec, deciding WHAT to say
rather than how to encode it. Sits on openfut-protocol-blaze and supplies
what that crate deliberately refuses to know.

  blaze/ids.rs           component/command/notification tables
  blaze/config.rs        injectable identity + endpoints, nothing hardcoded
  blaze/session.rs       per-connection state
  blaze/client_config.rs the fetchClientConfig tables
  blaze/responses.rs     16 Blaze::* response bodies
  blaze/dispatch.rs      (component, command) -> Vec<Frame>

Parity is tested, not asserted. fixtures/generate.py drives the real
blaze_responder_v3b.dispatch() and records 49 request->response(s)
transactions, replayed in order against a shared session per connection so
ordering-dependent behaviour is exercised: preAuth captures the locale later
ALOC fields echo, login sets the auth code getAuthToken returns. Comparison
is byte-for-byte including frame count and order.

98 tests green across both crates; clippy clean.

MUTATION TESTED, and it found a real defect in this commit's own design.
Swapping two post-login notifications and flipping one enum inside
AccountInfo both turned the suite red as intended. Hardcoding an address in
utas_base() did NOT -- the config templating substituted raw hosts directly,
making those helpers dead code that merely looked load-bearing. The table now
templates on URL-level tokens ({utas_base}, {nucleus_base},
{pow_content_url}) so they are the single place a URL shape is defined, and
the mutation is caught.

The client config table (227-243 rows per CFID) is generated from the oracle
rather than transcribed: it is reverse-engineered data, not logic, and 400
hand-copied string literals would add a typo class no reviewer can catch. The
generator substitutes real addresses back in and diffs against the oracle for
every section before writing, so the templating is verified rather than
assumed.

Reproduces one known defect deliberately: nucleusConnect is built from BIND,
not advertise, so the live split deployment tells a client on another machine
to reach Nucleus at http://0.0.0.0:42131. Confirmed against the running
container. Reproduced because it is what the only proven-working config does;
fixing it needs live validation and is a separate change. It also implies the
Nucleus stub is not reached in the current remote flow.

Blaze carries no FUT domain state -- no coins, packs, clubs or squads on this
wire -- so Session stays a session key, locale, service name, auth code and a
flag. That boundary will need defending when UTAS is migrated.

Not wired into anything. The crate answers frames; it opens no socket and
owns no runtime. The Python backend remains the live service and the oracle,
and is unmodified (contract suite still green).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-11 01:18:29 +00:00
parent cc3ecddc06
commit cf961603fe
16 changed files with 14939 additions and 0 deletions
@@ -0,0 +1,160 @@
//! `Util::fetchClientConfig` tables.
//!
//! Between 227 and 243 key/value rows per CFID, overwhelmingly the same RS4
//! base URL repeated across 212 endpoint keys. The client resolves a per-call
//! key (`FUT_RS4_URL_<CALL>`) before a per-module one
//! (`FUT_RS4_APIURL_<MODULE>`), and any call left unresolved falls back to a
//! real (dead) EA host — which is what produced "there has been an error
//! connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset
//! was served. The table has to be complete, not representative.
//!
//! # Why this is data and not code
//!
//! The rows are reverse-engineered *configuration*, not logic. They live in
//! `fixtures/client_config.json`, derived mechanically from the Python oracle
//! and templated on `{advertise}`, `{bind}`, `{pow_content_host}` and
//! `{pow_host}` so the adapter stays deployable anywhere. Hand-transcribing 400
//! string literals would add a class of silent typo no reviewer can catch, and
//! `openfut-core` already loads its content from `data/` for the same reason.
//!
//! The generator does not take its own templating on trust: it substitutes real
//! addresses back in and diffs against the oracle for every section before
//! writing the file.
use std::collections::BTreeMap;
use std::sync::OnceLock;
use super::config::AdapterConfig;
/// Rows for every known CFID, plus `__default__` for unknown ones.
const TABLE_JSON: &str = include_str!("../../fixtures/client_config.json");
type Table = BTreeMap<String, Vec<(String, String)>>;
fn table() -> &'static Table {
static TABLE: OnceLock<Table> = OnceLock::new();
TABLE.get_or_init(|| {
serde_json::from_str(TABLE_JSON).expect("bundled client_config.json is valid")
})
}
/// Resolve the rows for a CFID, with addresses substituted in.
///
/// Unknown CFIDs deliberately still receive the shared FUT/RS4/POW rows: those
/// consumers read a merged `_all` store and which section contributes is
/// unproven, so a present-but-shared table is safer than an empty one.
pub fn rows_for(cfid: &str, cfg: &AdapterConfig) -> Vec<(String, String)> {
let t = table();
let rows = t
.get(cfid)
.or_else(|| t.get("__default__"))
.expect("client_config.json always carries a __default__ section");
// URL-level tokens resolve through AdapterConfig so those helpers are the
// single place a URL shape is defined. Host-level tokens cover the values
// that are not one of the three standard URLs (roster, POW API).
let utas_base = cfg.utas_base();
let nucleus_base = cfg.nucleus_base();
let pow_content_url = cfg.pow_content_url();
rows.iter()
.map(|(k, v)| {
let v = if v.contains('{') {
v.replace("{utas_base}", &utas_base)
.replace("{nucleus_base}", &nucleus_base)
.replace("{pow_content_url}", &pow_content_url)
.replace("{advertise}", &cfg.endpoints.advertise)
.replace("{bind}", &cfg.endpoints.bind)
.replace("{pow_content_host}", &cfg.endpoints.pow_content_host)
.replace("{pow_host}", &cfg.endpoints.pow_host)
} else {
v.clone()
};
debug_assert!(!v.contains('{'), "unsubstituted token left in {k}: {v}");
(k.clone(), v)
})
.collect()
}
/// Every CFID with its own section. Unknown CFIDs are still valid requests.
pub fn known_sections() -> Vec<&'static str> {
table()
.keys()
.filter(|k| k.as_str() != "__default__")
.map(String::as_str)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> AdapterConfig {
let mut c = AdapterConfig::default();
c.endpoints.advertise = "198.51.100.7".into();
c.endpoints.bind = "0.0.0.0".into();
c.endpoints.pow_content_host = "198.51.100.7:8085".into();
c.endpoints.pow_host = "198.51.100.7:8094".into();
c
}
#[test]
fn bundled_table_parses() {
assert!(table().contains_key("__default__"));
assert!(table().contains_key("BlazeSDK"));
assert!(known_sections().len() >= 10);
}
#[test]
fn default_section_is_the_shared_fut_base() {
let rows = rows_for("literally-anything", &cfg());
assert_eq!(rows.len(), 227);
assert!(rows.iter().any(|(k, _)| k == "FUT_RS4_BASE_URL"));
}
#[test]
fn addresses_are_substituted_not_baked() {
let rows = rows_for("BlazeSDK", &cfg());
let base = rows
.iter()
.find(|(k, _)| k == "FUT_RS4_BASE_URL")
.expect("base url present");
assert_eq!(base.1, "http://198.51.100.7:8099/");
assert!(
!rows.iter().any(|(_, v)| v.contains('{')),
"a template token survived substitution"
);
}
#[test]
fn nucleus_follows_bind_reproducing_the_oracle() {
let rows = rows_for("BlazeSDK", &cfg());
let n = rows.iter().find(|(k, _)| k == "nucleusConnect").unwrap();
assert_eq!(n.1, "http://0.0.0.0:42131");
}
#[test]
fn roster_section_carries_the_roster_urls() {
let rows = rows_for("OSDK_ROSTER", &cfg());
let r = rows.iter().find(|(k, _)| k == "ROSTER_URL").unwrap();
assert_eq!(r.1, "https://198.51.100.7:8081/fifa17/roster/");
}
#[test]
fn rows_are_sorted_as_the_wire_requires() {
// The oracle sorts; the TDF map encoder does not, so order is ours to keep.
let rows = rows_for("BlazeSDK", &cfg());
let mut sorted = rows.clone();
sorted.sort();
assert_eq!(rows, sorted);
}
#[test]
fn every_known_section_substitutes_cleanly() {
for cfid in known_sections() {
for (k, v) in rows_for(cfid, &cfg()) {
assert!(!v.contains('{'), "{cfid}/{k} kept a token: {v}");
}
}
}
}