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,464 @@
|
||||
//! Blaze RPC dispatch: inbound frame → outbound frames.
|
||||
//!
|
||||
//! Three behaviours here are load-bearing and none of them are obvious from the
|
||||
//! individual response shapes:
|
||||
//!
|
||||
//! * **Login answers with four frames, in order**: the reply first, then
|
||||
//! `UserAuthenticated`, `UserSessionExtendedDataUpdate`, `UserAdded`.
|
||||
//! * **An unimplemented RPC still gets an empty reply.** Silence makes the
|
||||
//! client wait for a timeout; an empty reply lets every field fall back to a
|
||||
//! client-side default and the boot continues.
|
||||
//! * **Non-request message types get nothing at all** — answering a reply or a
|
||||
//! notification would desynchronise the client's own correlation.
|
||||
//!
|
||||
//! No error replies are emitted. `msgType` 3 exists, but the error-code
|
||||
//! placement is UNRESOLVED — three clean-room sources disagree between
|
||||
//! `header[14:16]`, a metadata `ERRC`, and a payload `CNTX`/`ERRC` — so
|
||||
//! emitting one would be a guess on the wire. Do not add one without a capture.
|
||||
|
||||
use openfut_protocol_blaze::fire2::{Frame, Header, MsgType};
|
||||
use openfut_protocol_blaze::heat2::{self, Struct, Value};
|
||||
|
||||
use super::config::AdapterConfig;
|
||||
use super::ids::{association_lists, auth, census_data, component, user_sessions, util};
|
||||
use super::responses as r;
|
||||
use super::session::Session;
|
||||
|
||||
/// Everything needed to answer one RPC.
|
||||
pub struct Adapter {
|
||||
pub config: AdapterConfig,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
pub fn new(config: AdapterConfig) -> Adapter {
|
||||
Adapter { config }
|
||||
}
|
||||
|
||||
/// Answer one inbound frame.
|
||||
///
|
||||
/// `now` is passed in rather than read from the clock so responses are
|
||||
/// reproducible: several bodies stamp a timestamp, and a hidden clock read
|
||||
/// would make every fixture unrepeatable.
|
||||
pub fn dispatch(
|
||||
&self,
|
||||
header: &Header,
|
||||
body: &Struct,
|
||||
session: &mut Session,
|
||||
now: i64,
|
||||
) -> Vec<Frame> {
|
||||
// Transport-level ping, whatever the component/command.
|
||||
if header.msg_type == MsgType::Ping {
|
||||
return vec![reply(header, Vec::new(), MsgType::PingReply)];
|
||||
}
|
||||
// Only requests are answered.
|
||||
if header.msg_type != MsgType::Message {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let cfg = &self.config;
|
||||
match (header.component, header.command) {
|
||||
// ------------------------------------------------------- Util
|
||||
(component::UTIL, util::PRE_AUTH) => {
|
||||
// preAuth is where the session learns who it is talking to:
|
||||
// the service name is echoed back, and the locale is captured
|
||||
// for every later ALOC field.
|
||||
session.service_name = service_name_of(body);
|
||||
if let Some(loc) =
|
||||
find_nested_int(body, "LANG").or_else(|| find_nested_int(body, "LOC"))
|
||||
{
|
||||
session.account_locale = loc;
|
||||
}
|
||||
let svc = session.service_name.clone();
|
||||
reply_tdf(header, &r::preauth_response(&svc, cfg))
|
||||
}
|
||||
|
||||
(component::UTIL, util::PING) => reply_tdf(header, &r::ping_response(now)),
|
||||
|
||||
(component::UTIL, util::FETCH_CLIENT_CONFIG) => {
|
||||
let cfid = get_str(body, "CFID");
|
||||
reply_tdf(header, &r::fetch_config_response(&cfid, cfg))
|
||||
}
|
||||
|
||||
(component::UTIL, util::POST_AUTH) => {
|
||||
reply_tdf(header, &r::post_auth_response(session, cfg))
|
||||
}
|
||||
|
||||
(component::UTIL, util::FETCH_QOS_CONFIG) => reply_tdf(header, &r::qos_config(cfg)),
|
||||
|
||||
(component::UTIL, util::USER_SETTINGS_LOAD) => {
|
||||
reply_tdf(header, &r::user_settings_response())
|
||||
}
|
||||
|
||||
// Accepted and discarded; the client only needs the ack.
|
||||
(component::UTIL, util::USER_SETTINGS_SAVE)
|
||||
| (component::UTIL, util::SET_CLIENT_STATE)
|
||||
| (component::UTIL, util::SET_CLIENT_METRICS) => empty_reply(header),
|
||||
|
||||
// --------------------------------------------- Authentication
|
||||
(component::AUTHENTICATION, auth::LOGIN) => {
|
||||
session.auth_code = get_str(body, "AUTH");
|
||||
session.logged_in = true;
|
||||
session.login_time = now;
|
||||
self.login_burst(header, session, now)
|
||||
}
|
||||
|
||||
// Same forged session; the request fields differ and are ignored.
|
||||
(component::AUTHENTICATION, auth::TRUSTED_LOGIN)
|
||||
| (component::AUTHENTICATION, auth::EXPRESS_LOGIN) => {
|
||||
session.logged_in = true;
|
||||
session.login_time = now;
|
||||
self.login_burst(header, session, now)
|
||||
}
|
||||
|
||||
// Receiving logout is NORMAL, not a failure: the OSDK state table
|
||||
// orders Connect -> Logout -> VersionCheck -> PCLogin, so this is
|
||||
// the routine "drop any stale session" step before login. It is
|
||||
// only a symptom if login never follows.
|
||||
(component::AUTHENTICATION, auth::LOGOUT) => empty_reply(header),
|
||||
|
||||
(component::AUTHENTICATION, auth::LIST_USER_ENTITLEMENTS2)
|
||||
| (component::AUTHENTICATION, auth::LIST_ENTITLEMENTS)
|
||||
| (component::AUTHENTICATION, auth::LIST_PERSONA_ENTITLEMENTS2)
|
||||
| (component::AUTHENTICATION, auth::GRANT_ENTITLEMENT2) => {
|
||||
reply_tdf(header, &r::entitlements_response(cfg))
|
||||
}
|
||||
|
||||
(component::AUTHENTICATION, auth::GET_AUTH_TOKEN) => {
|
||||
reply_tdf(header, &r::get_auth_token_response(session))
|
||||
}
|
||||
(component::AUTHENTICATION, auth::GET_ACCOUNT) => {
|
||||
reply_tdf(header, &r::account_info(now, cfg))
|
||||
}
|
||||
(component::AUTHENTICATION, auth::GET_PERSONA) => {
|
||||
reply_tdf(header, &r::get_persona_response(now, cfg))
|
||||
}
|
||||
(component::AUTHENTICATION, auth::LIST_PERSONAS) => {
|
||||
reply_tdf(header, &r::list_personas_response(now, cfg))
|
||||
}
|
||||
|
||||
// ---------------------------------------------- UserSessions
|
||||
(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO) => {
|
||||
// Ack, then re-push the extended data so the client's cached
|
||||
// copy reflects the network info it just reported.
|
||||
vec![
|
||||
reply(header, Vec::new(), MsgType::Reply),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
||||
&r::user_session_extended_data_update(cfg),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ------------------------------------------ AssociationLists
|
||||
(component::ASSOCIATION_LISTS, association_lists::GET_LISTS) => {
|
||||
reply_tdf(header, &r::get_lists_response())
|
||||
}
|
||||
|
||||
// ----------------------------------------------- CensusData
|
||||
(component::CENSUS_DATA, census_data::SUBSCRIBE_TO_CENSUS_DATA_UPDATES) => {
|
||||
reply_tdf(header, &r::census_subscribe_response())
|
||||
}
|
||||
|
||||
// An empty reply, never silence: see the module docs.
|
||||
_ => empty_reply(header),
|
||||
}
|
||||
}
|
||||
|
||||
/// Login reply followed by the three UserSessions pushes, in order.
|
||||
///
|
||||
/// The order is the oracle's ("pamplona" order: reply first). The
|
||||
/// alternative ("grid-blaze": notifications first) is also reported to
|
||||
/// work, but only this one is proven against our client, so it is the one
|
||||
/// reproduced.
|
||||
fn login_burst(&self, header: &Header, session: &Session, now: i64) -> Vec<Frame> {
|
||||
let cfg = &self.config;
|
||||
vec![
|
||||
reply(
|
||||
header,
|
||||
heat2::encode(&r::login_response(session, now, cfg)),
|
||||
MsgType::Reply,
|
||||
),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::USER_AUTHENTICATED,
|
||||
&r::user_session_login_info(session, now, cfg),
|
||||
),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
||||
&r::user_session_extended_data_update(cfg),
|
||||
),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::USER_ADDED,
|
||||
&r::user_data(session, cfg),
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
fn reply(request: &Header, payload: Vec<u8>, msg_type: MsgType) -> Frame {
|
||||
let mut frame = Frame::new(
|
||||
request.component,
|
||||
request.command,
|
||||
request.msg_num,
|
||||
msg_type,
|
||||
payload,
|
||||
);
|
||||
// A reply echoes routing verbatim and changes only the msgType bits.
|
||||
frame.header.user_index = request.user_index;
|
||||
frame
|
||||
}
|
||||
|
||||
fn reply_tdf(request: &Header, body: &Struct) -> Vec<Frame> {
|
||||
vec![reply(request, heat2::encode(body), MsgType::Reply)]
|
||||
}
|
||||
|
||||
fn empty_reply(request: &Header) -> Vec<Frame> {
|
||||
vec![reply(request, Vec::new(), MsgType::Reply)]
|
||||
}
|
||||
|
||||
fn notify(component: u16, notify_id: u16, body: &Struct) -> Frame {
|
||||
Frame::notification(component, notify_id, heat2::encode(body))
|
||||
}
|
||||
|
||||
/// `PreAuthRequest.CDAT.SVCN`, echoed back as `INST`.
|
||||
fn service_name_of(body: &Struct) -> String {
|
||||
body.get("CDAT")
|
||||
.and_then(Value::as_struct)
|
||||
.and_then(|c| c.get("SVCN"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(super::session::DEFAULT_SERVICE_NAME)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Depth-first search for an INT member anywhere in a decoded body.
|
||||
///
|
||||
/// The client has moved which struct carries `LANG`/`LOC` between builds, so
|
||||
/// the oracle searches rather than addressing a fixed path.
|
||||
fn find_nested_int(body: &Struct, tag: &str) -> Option<i64> {
|
||||
for (t, v) in body.iter() {
|
||||
if t.to_label() == tag {
|
||||
if let Value::Int(n) = v {
|
||||
return Some(*n);
|
||||
}
|
||||
}
|
||||
if let Value::Struct(inner) = v {
|
||||
if let Some(found) = find_nested_int(inner, tag) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_str(body: &Struct, tag: &str) -> String {
|
||||
body.get(tag)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openfut_protocol_blaze::heat2::Struct as S;
|
||||
|
||||
fn adapter() -> Adapter {
|
||||
Adapter::new(AdapterConfig::default())
|
||||
}
|
||||
|
||||
fn req(component: u16, command: u16) -> Header {
|
||||
Header::new(component, command, 7, MsgType::Message)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_answers_with_reply_then_three_pushes_in_order() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let out = a.dispatch(
|
||||
&req(component::AUTHENTICATION, auth::LOGIN),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(out.len(), 4);
|
||||
assert_eq!(out[0].header.msg_type, MsgType::Reply);
|
||||
let ids: Vec<u16> = out[1..].iter().map(|f| f.header.command).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
user_sessions::notify::USER_AUTHENTICATED,
|
||||
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
||||
user_sessions::notify::USER_ADDED,
|
||||
]
|
||||
);
|
||||
for f in &out[1..] {
|
||||
assert_eq!(f.header.msg_type, MsgType::Notification);
|
||||
assert_eq!(f.header.msg_num, 0, "notifications are uncorrelated");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unimplemented_rpcs_get_an_empty_reply_not_silence() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let out = a.dispatch(&req(0x1234, 0x0001), &S::new(), &mut sess, 1);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].header.msg_type, MsgType::Reply);
|
||||
assert!(out[0].payload.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_requests_are_ignored_entirely() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
for mt in [
|
||||
MsgType::Reply,
|
||||
MsgType::Notification,
|
||||
MsgType::ErrorReply,
|
||||
MsgType::PingReply,
|
||||
] {
|
||||
let h = Header::new(component::UTIL, util::PING, 1, mt);
|
||||
assert!(a.dispatch(&h, &S::new(), &mut sess, 1).is_empty(), "{mt:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_ping_gets_an_empty_ping_reply() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let h = Header::new(component::UTIL, util::PING, 1, MsgType::Ping);
|
||||
let out = a.dispatch(&h, &S::new(), &mut sess, 1);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].header.msg_type, MsgType::PingReply);
|
||||
assert!(out[0].payload.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_echo_routing_including_user_index() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let mut h = req(component::UTIL, util::PING);
|
||||
h.user_index = 7;
|
||||
h.msg_num = 0x4242;
|
||||
let out = a.dispatch(&h, &S::new(), &mut sess, 1);
|
||||
assert_eq!(out[0].header.user_index, 7);
|
||||
assert_eq!(out[0].header.msg_num, 0x4242);
|
||||
assert_eq!(out[0].header.component, component::UTIL);
|
||||
assert_eq!(out[0].header.command, util::PING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preauth_captures_locale_and_service_name() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0x656E5553);
|
||||
let body = S::new().with(
|
||||
"CDAT",
|
||||
Value::Struct(
|
||||
S::new()
|
||||
.with("LANG", Value::Int(0x64654445))
|
||||
.with("SVCN", Value::String("fifa-2017-pc-de".into())),
|
||||
),
|
||||
);
|
||||
a.dispatch(&req(component::UTIL, util::PRE_AUTH), &body, &mut sess, 1);
|
||||
assert_eq!(sess.account_locale, 0x64654445);
|
||||
assert_eq!(sess.service_name, "fifa-2017-pc-de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preauth_without_svcn_falls_back_to_the_default() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
a.dispatch(
|
||||
&req(component::UTIL, util::PRE_AUTH),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
sess.service_name,
|
||||
super::super::session::DEFAULT_SERVICE_NAME
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_records_the_auth_code_for_later_get_auth_token() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let body = S::new().with("AUTH", Value::String("CODE-123".into()));
|
||||
a.dispatch(
|
||||
&req(component::AUTHENTICATION, auth::LOGIN),
|
||||
&body,
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
|
||||
let out = a.dispatch(
|
||||
&req(component::AUTHENTICATION, auth::GET_AUTH_TOKEN),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
let decoded = heat2::decode(&out[0].payload).unwrap();
|
||||
assert_eq!(
|
||||
decoded.get("AUTH").and_then(Value::as_str),
|
||||
Some("CODE-123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_network_info_acks_then_pushes() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let out = a.dispatch(
|
||||
&req(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(out[0].payload.is_empty());
|
||||
assert_eq!(out[1].header.msg_type, MsgType::Notification);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_entitlement_aliases_agree() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let bodies: Vec<Vec<u8>> = [
|
||||
auth::LIST_USER_ENTITLEMENTS2,
|
||||
auth::LIST_ENTITLEMENTS,
|
||||
auth::LIST_PERSONA_ENTITLEMENTS2,
|
||||
auth::GRANT_ENTITLEMENT2,
|
||||
]
|
||||
.iter()
|
||||
.map(|&cmd| {
|
||||
a.dispatch(
|
||||
&req(component::AUTHENTICATION, cmd),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
)[0]
|
||||
.payload
|
||||
.clone()
|
||||
})
|
||||
.collect();
|
||||
assert!(bodies.windows(2).all(|w| w[0] == w[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_a_nested_int_at_any_depth() {
|
||||
let body = S::new().with(
|
||||
"A",
|
||||
Value::Struct(S::new().with("B", Value::Struct(S::new().with("LANG", Value::Int(42))))),
|
||||
);
|
||||
assert_eq!(find_nested_int(&body, "LANG"), Some(42));
|
||||
assert_eq!(find_nested_int(&body, "NOPE"), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user