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:
@@ -0,0 +1,187 @@
|
||||
//! 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,
|
||||
pub telemetry_port: i64,
|
||||
pub ticker_port: i64,
|
||||
pub qos_port: i64,
|
||||
}
|
||||
|
||||
impl Default for Endpoints {
|
||||
/// Loopback, matching the oracle's own defaults for a single-host run.
|
||||
///
|
||||
/// A remote deployment MUST override `advertise`; the Python entrypoint
|
||||
/// refuses to start without it, and this default is only appropriate when
|
||||
/// game and backend share a host.
|
||||
fn default() -> Endpoints {
|
||||
Endpoints {
|
||||
advertise: "127.0.0.1".into(),
|
||||
bind: "127.0.0.1".into(),
|
||||
pow_content_host: "127.0.0.1:8080".into(),
|
||||
pow_host: "127.0.0.1:8094".into(),
|
||||
telemetry_port: 9988,
|
||||
ticker_port: 8999,
|
||||
qos_port: 17502,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Default for AdapterConfig {
|
||||
fn default() -> AdapterConfig {
|
||||
AdapterConfig {
|
||||
identity: Identity::default(),
|
||||
endpoints: Endpoints::default(),
|
||||
server_version: "Blaze 15.1.1.3.0 (OpenFUT)\n".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdapterConfig {
|
||||
/// `http://<advertise>: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://{}:8099/", self.endpoints.advertise)
|
||||
}
|
||||
|
||||
/// `http://<bind>: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::default();
|
||||
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::default();
|
||||
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::default();
|
||||
cfg.endpoints.pow_content_host = "10.0.0.5:8085".into();
|
||||
assert_eq!(cfg.pow_content_url(), "http://10.0.0.5:8085");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user