//! Adapter configuration: identity and endpoints. //! //! Everything deployment-dependent lives here, injected by the caller. No //! address, port or persona is baked into the response builders — the //! client/server split exists precisely because the Python responders used to //! assume loopback, and rebuilding that assumption in Rust would undo it. //! //! Note the deliberate asymmetry between *bind* and *advertise*: an advertised //! URL must carry the address the CLIENT can reach, which on a two-machine //! deployment is not the address the server binds. /// The forged account the whole stack agrees on. /// /// Identity has to be byte-identical across LSX, Blaze, POW and UTAS or the /// client rejects the session, so this is one struct passed everywhere rather /// than constants per responder. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Identity { pub persona_id: i64, pub persona_name: String, /// blazeId / userId. Must be non-zero or login is refused. pub user_id: i64, /// XREF externalId. pub ext_id: i64, pub email: String, /// Must equal `PreAuthResponse.NASP`. pub namespace: String, /// `Blaze::ClientPlatformType`; 4 = pc. pub client_platform: i64, /// `PersonaStatus::Code`; 2 = ACTIVE. pub persona_status: i64, /// `Blaze::UserSessionType`; 0 = normal user. pub user_session_type: i64, /// Fallback locale as a packed four-char int (`'enUS'`). Overwritten per /// session by the client's own preAuth `LANG`/`LOC`. pub account_locale: i64, /// `AccountInfo.LN`, e.g. `"en_US"`. pub locale: String, /// EA offer id. pub content_id: String, pub entitlement_tag: String, /// Must contain `"FIFA17PCBoxContent"` or `"FIFA16PC"` or FUT drops the /// entitlement and the store comes up empty. pub entitlement_group: String, pub title_id: String, pub client_id: String, pub platform: String, } impl Default for Identity { /// The project's fixed synthetic offline identity. /// /// A default, not a constant: the launcher can select a different persona, /// and FUT saves are isolated per persona id. fn default() -> Identity { Identity { persona_id: 33_068_179, persona_name: "CAGE".into(), user_id: 33_068_179, ext_id: 33_068_179, email: "cage@openfut.local".into(), namespace: "cem_ea_id".into(), client_platform: 4, persona_status: 2, user_session_type: 0, account_locale: 0x656E_5553, // 'enUS' locale: "en_US".into(), content_id: "1027460".into(), entitlement_tag: "ONLINE_ACCESS".into(), entitlement_group: "FIFA17PCBoxContent".into(), title_id: "309111".into(), client_id: "FIFA17-PC-SERVER-BLAZE".into(), platform: "pc".into(), } } } /// Where the client should be told to go next. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Endpoints { /// Address handed to the CLIENT for every next hop. On a split deployment /// this is the backend's LAN address as the game machine sees it. pub advertise: String, /// Address the server binds. Not interchangeable with `advertise`. pub bind: String, /// `host:port` for POW content. pub pow_content_host: String, /// `host:port` for the POW/EASFC API. pub pow_host: String, /// Blaze port ADVERTISED to the client by the redirector. /// /// Our choice, not a protocol constant — the client goes wherever /// `` sends it. Configurable so a sidecar can be /// advertised on a different port without a rebuild. pub blaze_port: u16, /// UTAS/RS4 port in generated `FUT_RS4_*` URLs. /// /// 8099 is the client's own built-in default (`http://easw.easports.com:8099/` /// in CardsDLL), so it is the sane value — but it is still deployment /// configuration, not a constant we are entitled to bake in. pub utas_port: u16, pub telemetry_port: i64, pub ticker_port: i64, pub qos_port: i64, } // NOTE: there is deliberately NO `impl Default for Endpoints`. // // A default would silently supply loopback, and a remote deployment that forgot // to set an address would then advertise `127.0.0.1` to a client on another // machine — failing far from the cause. Choosing loopback has to be an explicit // act, so it is a named constructor. impl Endpoints { /// Endpoints for a backend the client reaches at `advertise`. /// /// POW hosts DERIVE from the advertised host, matching what the deployed /// Python entrypoint does (`POW_HOST="${POW_HOST:-$ADV:8094}"`). They must /// not fall back to loopback independently: that would leave a remote /// deployment emitting loopback POW URLs while every other URL was correct. pub fn advertising(advertise: impl Into) -> Endpoints { let advertise = advertise.into(); Endpoints { pow_content_host: format!("{advertise}:8080"), pow_host: format!("{advertise}:8094"), bind: advertise.clone(), advertise, blaze_port: 42130, utas_port: 8099, telemetry_port: 9988, ticker_port: 8999, qos_port: 17502, } } /// Explicit local-only / oracle mode: game and backend on one host. /// /// Named rather than defaulted so that "everything is loopback" is always a /// decision someone made, and greppable. pub fn loopback() -> Endpoints { Endpoints::advertising("127.0.0.1") } } /// Full adapter configuration. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AdapterConfig { pub identity: Identity, pub endpoints: Endpoints, /// `PreAuthResponse.SVER`. Carries a trailing newline in the oracle; kept /// because it is on the wire, not because it is meaningful. pub server_version: String, } /// `PreAuthResponse.SVER`. On the wire, so it is config rather than a literal. pub const DEFAULT_SERVER_VERSION: &str = "Blaze 15.1.1.3.0 (OpenFUT)\n"; // No `Default` here either, for the same reason as `Endpoints`. impl AdapterConfig { /// Adapter serving a client that reaches this backend at `advertise`. pub fn advertising(advertise: impl Into) -> AdapterConfig { AdapterConfig { identity: Identity::default(), endpoints: Endpoints::advertising(advertise), server_version: DEFAULT_SERVER_VERSION.into(), } } /// Explicit local-only / oracle mode. pub fn loopback() -> AdapterConfig { AdapterConfig::advertising("127.0.0.1") } /// `http://:8099/` — the RS4/UTAS base. /// /// The trailing slash and the scheme are both mandatory: CardsDLL's /// `ServerSettings::resolve` uses the value verbatim once it contains /// `"://"`, and the auth path breaks without the slash. pub fn utas_base(&self) -> String { format!( "http://{}:{}/", self.endpoints.advertise, self.endpoints.utas_port ) } /// `http://:42131` — the Nucleus OAuth stub. /// /// This derives from **bind**, not advertise, faithfully reproducing the /// Python oracle. On the live split deployment that makes it /// `http://0.0.0.0:42131`, which the client cannot dial — see the crate /// README and the vault. Reproduced deliberately: changing it would break /// byte parity with the only configuration ever proven to work, and the /// fix belongs in a separate, live-validated change. pub fn nucleus_base(&self) -> String { format!("http://{}:42131", self.endpoints.bind) } pub fn pow_content_url(&self) -> String { format!("http://{}", self.endpoints.pow_content_host) } } #[cfg(test)] mod tests { use super::*; #[test] fn utas_base_keeps_scheme_and_trailing_slash() { let mut cfg = AdapterConfig::loopback(); cfg.endpoints.advertise = "10.0.0.5".into(); assert_eq!(cfg.utas_base(), "http://10.0.0.5:8099/"); } #[test] fn nucleus_follows_bind_not_advertise() { // Documents the oracle's behaviour, including its consequence. let mut cfg = AdapterConfig::loopback(); cfg.endpoints.advertise = "10.0.0.5".into(); cfg.endpoints.bind = "0.0.0.0".into(); assert_eq!(cfg.nucleus_base(), "http://0.0.0.0:42131"); } #[test] fn pow_content_url_has_no_trailing_slash() { let mut cfg = AdapterConfig::loopback(); cfg.endpoints.pow_content_host = "10.0.0.5:8085".into(); assert_eq!(cfg.pow_content_url(), "http://10.0.0.5:8085"); } }