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:
@@ -3,6 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"openfut-core",
|
||||
"openfut-protocol-blaze",
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-bridge",
|
||||
"openfut-launcher",
|
||||
"openfut-launcher/openfut-hook",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "openfut-adapter-fifa17"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "FIFA 17 game adapter: Blaze command tables, response bodies and dispatch"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
openfut-protocol-blaze = { path = "../openfut-protocol-blaze" }
|
||||
# Reads the bundled fetchClientConfig table (227-243 rows per CFID), which is
|
||||
# generated from the Python oracle rather than transcribed by hand. Unlike the
|
||||
# protocol crate below it, this crate is ordinary server-side code, so a real
|
||||
# JSON parser is the right call — hand-rolling one to preserve a zero-dependency
|
||||
# streak would be reinventing a solved problem in the riskiest possible place.
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
# Differential fixtures are JSONL; the runtime dependency already covers it.
|
||||
@@ -0,0 +1,115 @@
|
||||
# openfut-adapter-fifa17
|
||||
|
||||
The FIFA 17 game adapter. Everything true of *FIFA 17 specifically* lives here,
|
||||
so that neither OpenFUT Core nor the generic protocol crates have to know about
|
||||
it.
|
||||
|
||||
```
|
||||
openfut-protocol-blaze generic Blaze: Fire2 framing, Heat2/TDF codec
|
||||
▲
|
||||
openfut-adapter-fifa17 THIS: command tables, response bodies, dispatch order
|
||||
▲
|
||||
OpenFUT Core game-independent FUT domain (not yet wired)
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
| Surface | Port | State |
|
||||
|---|---|---|
|
||||
| **Blaze / Fire2 RPC** | 42130 | **Implemented**, byte-for-byte parity-tested |
|
||||
| Redirector (HTTPS + XML) | 42127 | Python only |
|
||||
| Nucleus OAuth stub | 42131 | Python only |
|
||||
| LSX / Origin | 4216 | Python only |
|
||||
| Roster XML | 8081 | Python only |
|
||||
| UTAS / RS4 | 8099 | Python only |
|
||||
| POW / EASFC | 8094 / 8080 | Python only |
|
||||
|
||||
**Nothing here is wired into the running backend.** The crate answers frames; it
|
||||
opens no socket, terminates no TLS and owns no runtime. The Python backend
|
||||
remains the live service and the behavioural oracle.
|
||||
|
||||
## What the adapter owns, and what it must not
|
||||
|
||||
Owns: component/command/notification IDs, response body shapes, dispatch
|
||||
ordering, session identity, the `fetchClientConfig` tables.
|
||||
|
||||
Must not own: FUT domain state. Blaze is an auth/session/config protocol — no
|
||||
coins, packs, clubs or squads appear on this wire — so `Session` holds a session
|
||||
key, a locale, a service name, an auth code and a flag, and that is all. When
|
||||
UTAS is migrated that boundary will need active defending; here it comes free.
|
||||
|
||||
## Parity
|
||||
|
||||
```bash
|
||||
./check-parity.sh # oracle freshness + byte-for-byte replay
|
||||
./check-parity.sh --regen # after an intentional oracle change
|
||||
```
|
||||
|
||||
`fixtures/blaze_transactions.jsonl` holds 49 request→response(s) transactions
|
||||
produced by calling the real `blaze_responder_v3b.dispatch()`. They replay in
|
||||
order against a shared session per connection, so ordering-dependent behaviour
|
||||
is exercised rather than assumed: preAuth captures the locale that later `ALOC`
|
||||
fields echo, and login sets the auth code `getAuthToken` returns afterwards.
|
||||
|
||||
Comparison is byte-for-byte including frame count and order — a missing
|
||||
post-login notification or a reply where the oracle stays silent fails here.
|
||||
|
||||
The suite was **mutation-tested**: swapping two post-login notifications,
|
||||
flipping one enum deep inside `AccountInfo`, and hardcoding an address in
|
||||
`utas_base()`/`nucleus_base()` were each verified to turn it red. The third
|
||||
initially did *not*, because the config templating had made those helpers dead
|
||||
code; the table now templates on URL-level tokens so they are the single place a
|
||||
URL shape is defined.
|
||||
|
||||
## Three behaviours that are easy to get wrong
|
||||
|
||||
* **Login answers with four frames, in order**: reply, 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.**
|
||||
|
||||
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.
|
||||
|
||||
## The client config table
|
||||
|
||||
`fixtures/client_config.json` carries 227–243 rows per CFID, generated from the
|
||||
Python oracle and templated on `{utas_base}`, `{nucleus_base}`,
|
||||
`{pow_content_url}`, `{advertise}`, `{bind}`, `{pow_host}`. It is
|
||||
reverse-engineered *data*, not logic, and deriving it mechanically removes a
|
||||
class of transcription typo no reviewer could catch. 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.
|
||||
|
||||
The table must be *complete*, not representative. The client resolves a per-call
|
||||
key (`FUT_RS4_URL_<CALL>`) before a per-module one, and any unresolved call falls
|
||||
back to a real, dead EA host — that is what produced "there has been an error
|
||||
connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset was
|
||||
served.
|
||||
|
||||
## Known defect reproduced deliberately
|
||||
|
||||
`nucleusConnect` and `nucleusConnectTrusted` are built from the **bind** address,
|
||||
not the advertised one. On the live split deployment that means the backend
|
||||
tells a client on another machine to reach Nucleus at `http://0.0.0.0:42131`,
|
||||
which it cannot. Verified against the running container, not inferred.
|
||||
|
||||
This is reproduced exactly, because it is what the only proven-working
|
||||
configuration does and changing it would break parity. It also implies the
|
||||
Nucleus stub is not actually reached in the current remote flow. Fixing it is a
|
||||
separate change that needs live validation — see the vault.
|
||||
|
||||
## Configuration
|
||||
|
||||
Nothing is hardcoded. `AdapterConfig` carries `Identity` (persona, ids, email,
|
||||
namespace, entitlement group, …) and `Endpoints` (advertise, bind, POW hosts,
|
||||
telemetry/ticker/QoS ports). `Default` gives the project's synthetic offline
|
||||
identity on loopback; a remote deployment must override `advertise`.
|
||||
|
||||
Bind and advertise are deliberately distinct: an advertised URL must carry the
|
||||
address the *client* can reach, which on a two-machine deployment is not the
|
||||
address the server binds.
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Differential check: the Rust FIFA 17 Blaze adapter vs the Python responder.
|
||||
#
|
||||
# 1. assert the committed fixtures still match what the Python oracle emits
|
||||
# 2. replay every recorded transaction through the Rust adapter, byte-for-byte
|
||||
#
|
||||
# Read-only with respect to the running backend: the oracle is imported as a
|
||||
# library, no responder is started, no port is bound, no live service is
|
||||
# touched. Safe to run while the Python backend is serving a live FIFA client.
|
||||
#
|
||||
# Use --regen to rewrite the fixtures after an intentional oracle change.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
if [[ "${1:-}" == "--regen" ]]; then
|
||||
echo "==> regenerating fixtures from the Python oracle"
|
||||
python3 fixtures/generate.py
|
||||
else
|
||||
echo "==> checking committed fixtures against the Python oracle"
|
||||
python3 fixtures/generate.py --check
|
||||
fi
|
||||
|
||||
echo "==> replaying transactions through the Rust adapter"
|
||||
cargo test -p openfut-adapter-fifa17
|
||||
|
||||
echo
|
||||
echo "PARITY OK — the adapter reproduces the Python dispatcher byte-for-byte."
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Freeze the Python Blaze responder's DISPATCH contract as replayable fixtures.
|
||||
|
||||
The crate-level fixtures in `openfut-protocol-blaze` pin the *codec*: given a
|
||||
field tree, what bytes come out. This file pins the layer above: given an
|
||||
inbound Fire2 frame and a session, **which frames go back, in what order**.
|
||||
|
||||
That is the whole contract of a Blaze adapter, and it is the thing a rewrite can
|
||||
silently get wrong in ways a codec test cannot see — a missing post-login
|
||||
notification, a reply where the oracle stays silent, notifications in the wrong
|
||||
order, session state not carried between RPCs.
|
||||
|
||||
Every transaction is produced by calling the real
|
||||
`blaze_responder_v3b.dispatch()`. Session state is threaded across a scripted
|
||||
connection exactly as it would be on a live socket, so ordering-dependent
|
||||
behaviour (preAuth captures the locale; login sets the auth code that
|
||||
getAuthToken later returns) is captured rather than assumed.
|
||||
|
||||
Determinism: the oracle's clock is pinned and its PRNG seeded, and the
|
||||
deployment-dependent addresses are set before import (the responder reads them
|
||||
at import time). See the sibling generator in openfut-protocol-blaze.
|
||||
|
||||
NO SECRETS. The identity here (persona 33068179 / "CAGE") is the project's fixed
|
||||
synthetic offline identity. Session keys are minted from a seeded PRNG.
|
||||
|
||||
Usage: python3 fixtures/generate.py (write)
|
||||
python3 fixtures/generate.py --check (verify committed files are current)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
TOOLS = os.path.normpath(os.path.join(HERE, "..", "..", "fifa17-recon", "tools"))
|
||||
if not os.path.isdir(TOOLS):
|
||||
sys.exit("cannot find the Python oracle at %s" % TOOLS)
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
CHECK_ONLY = "--check" in sys.argv[1:]
|
||||
# Internal mode: re-exec of this script with sentinel addresses, used to derive
|
||||
# the templated client-config table (see emit_config_table).
|
||||
CONFIG_TABLE_MODE = "--_config_table" in sys.argv[1:]
|
||||
sys.argv = [sys.argv[0]]
|
||||
|
||||
# Sentinels substituted back into template tokens. Deliberately not IP-shaped so
|
||||
# a stray literal cannot be mistaken for a real address.
|
||||
SENTINELS = [
|
||||
("ADVERTISE-SENTINEL", "{advertise}"),
|
||||
("BIND-SENTINEL", "{bind}"),
|
||||
("POWCONTENT-SENTINEL", "{pow_content_host}"),
|
||||
("POWHOST-SENTINEL", "{pow_host}"),
|
||||
]
|
||||
|
||||
if CONFIG_TABLE_MODE:
|
||||
os.environ["OPENFUT_ADVERTISE"] = "ADVERTISE-SENTINEL"
|
||||
os.environ["OPENFUT_BIND"] = "BIND-SENTINEL"
|
||||
os.environ["POW_CONTENT_HOST"] = "POWCONTENT-SENTINEL"
|
||||
os.environ["POW_HOST"] = "POWHOST-SENTINEL"
|
||||
import blaze_responder_v3b as _B # noqa: E402
|
||||
out = {cfid: _B.client_config_for(cfid) for cfid in sorted(_B.CLIENT_CONFIGS)}
|
||||
out["__default__"] = _B.client_config_for("__no_such_section__")
|
||||
print(json.dumps(out))
|
||||
raise SystemExit(0)
|
||||
|
||||
# Pin deployment config BEFORE import — the responder snapshots these at import
|
||||
# time into module globals used by the response builders.
|
||||
#
|
||||
# Distinct, obviously-fake values on purpose: if the Rust adapter hardcoded an
|
||||
# address instead of reading its config, these make the failure loud rather than
|
||||
# accidentally matching a loopback default.
|
||||
ADVERTISE = "198.51.100.7"
|
||||
BIND = "0.0.0.0"
|
||||
POW_CONTENT_HOST = "198.51.100.7:8085"
|
||||
POW_HOST = "198.51.100.7:8094"
|
||||
|
||||
os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
|
||||
os.environ["OPENFUT_BIND"] = BIND
|
||||
os.environ["POW_CONTENT_HOST"] = POW_CONTENT_HOST
|
||||
os.environ["POW_HOST"] = POW_HOST
|
||||
|
||||
import heat2 # noqa: E402
|
||||
import blaze_responder_v3b as B # noqa: E402
|
||||
from fut_account import ACCOUNT # noqa: E402
|
||||
|
||||
FIXED_NOW = 1754870400
|
||||
INT, STRING, STRUCT, LIST, MAP, BLOB = (
|
||||
heat2.INT, heat2.STRING, heat2.STRUCT, heat2.LIST, heat2.MAP, heat2.BLOB)
|
||||
|
||||
RECORDS = []
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def req_frame(component, command, fields=None, msg_num=1, msg_type=None,
|
||||
user_index=0):
|
||||
"""Build an inbound request frame the way the client would."""
|
||||
msg_type = B.MESSAGE if msg_type is None else msg_type
|
||||
payload = heat2.encode_tdf(fields) if fields else b""
|
||||
return B.fire2(component, command, msg_num, msg_type, payload,
|
||||
user_index=user_index)
|
||||
|
||||
|
||||
def tx(session, name, frame, note=""):
|
||||
"""Run one frame through the real dispatcher and record what came back."""
|
||||
hdr = B.parse_fire2_header(frame)
|
||||
body = frame[16 + hdr["metadata_len"]:]
|
||||
fields = heat2.decode_tdf(body) if body else OrderedDict()
|
||||
out = B.dispatch(hdr, fields, body, session["sess"])
|
||||
|
||||
RECORDS.append(OrderedDict((
|
||||
("kind", "tx"),
|
||||
("session", session["id"]),
|
||||
("name", name),
|
||||
("note", note),
|
||||
("request_hex", frame.hex()),
|
||||
("responses", [f.hex() for f in out]),
|
||||
)))
|
||||
return out
|
||||
|
||||
|
||||
def new_session(sid):
|
||||
s = {"id": sid, "sess": B.Session()}
|
||||
RECORDS.append(OrderedDict((
|
||||
("kind", "session"),
|
||||
("id", sid),
|
||||
# Minted per connection by the oracle; the Rust side must be able to
|
||||
# inject it, because it appears in LoginResponse.SESS.KEY, the
|
||||
# UserAuthenticated push and PostAuthResponse.TELE.SESS and all three
|
||||
# must be the same string.
|
||||
("session_key", s["sess"].session_key),
|
||||
("account_locale", s["sess"].account_locale),
|
||||
("service_name", s["sess"].service_name),
|
||||
)))
|
||||
return s
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ script
|
||||
|
||||
def build():
|
||||
RECORDS.append(OrderedDict((
|
||||
("kind", "config"),
|
||||
("advertise", ADVERTISE),
|
||||
("bind", BIND),
|
||||
("pow_content_host", POW_CONTENT_HOST),
|
||||
("pow_host", POW_HOST),
|
||||
("now", FIXED_NOW),
|
||||
("identity", OrderedDict((
|
||||
("persona_id", ACCOUNT.persona_id),
|
||||
("persona_name", ACCOUNT.persona_name),
|
||||
("user_id", ACCOUNT.user_id),
|
||||
("ext_id", ACCOUNT.ext_id),
|
||||
("email", ACCOUNT.email),
|
||||
("namespace", ACCOUNT.NAMESPACE),
|
||||
("client_platform", ACCOUNT.CLIENT_PLATFORM),
|
||||
("persona_status", ACCOUNT.PERSONA_STATUS),
|
||||
("user_session_type", ACCOUNT.USER_SESSION_TYPE),
|
||||
("account_locale_int", ACCOUNT.account_locale_int),
|
||||
("locale", ACCOUNT.locale),
|
||||
("content_id", ACCOUNT.CONTENT_ID),
|
||||
("entitlement_tag", ACCOUNT.ENTITLEMENT_TAG),
|
||||
("entitlement_group", ACCOUNT.ENTITLEMENT_GROUP),
|
||||
("title_id", ACCOUNT.TITLE_ID),
|
||||
("client_id", ACCOUNT.CLIENT_ID),
|
||||
("platform", ACCOUNT.PLATFORM),
|
||||
("server_version", B.SERVER_VERSION),
|
||||
))),
|
||||
)))
|
||||
|
||||
# ================= main connection: the real boot order =================
|
||||
#
|
||||
# Mirrors what FIFA 17 actually does, because ordering is load-bearing:
|
||||
# preAuth captures the locale that later ALOC fields echo, and login sets
|
||||
# the auth code that getAuthToken returns afterwards.
|
||||
m = new_session("main")
|
||||
|
||||
tx(m, "preauth", req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
|
||||
("CDAT", (STRUCT, OrderedDict([
|
||||
("IITO", (INT, 0)),
|
||||
("LANG", (INT, 0x656E5553)), # 'enUS'
|
||||
("SVCN", (STRING, "fifa-2017-pc")), # echoed back as INST
|
||||
("TYPE", (INT, 0)),
|
||||
]))),
|
||||
("CINF", (STRUCT, OrderedDict([
|
||||
("BSDK", (STRING, "15.1.1.3.0")),
|
||||
("CLNT", (STRING, "FIFA17")),
|
||||
("ENV", (STRING, "prod")),
|
||||
("LOC", (INT, 0x656E5553)),
|
||||
]))),
|
||||
("FCCR", (STRUCT, OrderedDict([("CFID", (STRING, "BlazeSDK"))]))),
|
||||
])), "first RPC; echoes SVCN as INST and captures LANG for ALOC")
|
||||
|
||||
tx(m, "ping", req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=2),
|
||||
"Util::ping -> STIM only")
|
||||
|
||||
# Every section the responder knows, plus unknown ones. The known sections
|
||||
# each add their own rows on top of the shared FUT/RS4 base — OSDK_ROSTER in
|
||||
# particular carries the roster URL, itself a documented loading gate — so
|
||||
# covering only "BlazeSDK" would leave most of the table unverified.
|
||||
for cfid in ("BlazeSDK", "netres", "IdentityParams", "OSDK_CORE",
|
||||
"OSDK_CLIENT", "OSDK_NUCLEUS", "OSDK_ROSTER", "OSDK_TICKER",
|
||||
"OSDK_WEBOFFER", "OSDK_POW", "OSDK_ABUSE_REPORTING",
|
||||
"OSDK_XMS_ABUSE_REPORTING", "UTAS", "FUT", "",
|
||||
"TOTALLY_UNKNOWN"):
|
||||
tx(m, "fetch_config_%s" % (cfid or "empty"),
|
||||
req_frame(B.COMP_UTIL, B.CMD_FETCHCLIENTCONFIG,
|
||||
OrderedDict([("CFID", (STRING, cfid))]), msg_num=3),
|
||||
"unknown CFIDs still get the shared FUT/POW rows")
|
||||
|
||||
tx(m, "get_auth_token_before_login",
|
||||
req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=4),
|
||||
"no auth code yet -> synthesised OPENFUT-<key[:16]> token")
|
||||
|
||||
tx(m, "logout_before_login", req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=5),
|
||||
"routine LoginStateLogout (state 500), NOT a failure; empty reply")
|
||||
|
||||
tx(m, "login", req_frame(B.COMP_AUTH, B.CMD_LOGIN, OrderedDict([
|
||||
("AUTH", (STRING, "OPENFUT-TEST-AUTHCODE")),
|
||||
("EXTB", (BLOB, b"")),
|
||||
("PNAM", (STRING, "")),
|
||||
]), msg_num=6),
|
||||
"reply THEN three UserSessions pushes, in that order")
|
||||
|
||||
tx(m, "get_auth_token_after_login",
|
||||
req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=7),
|
||||
"now echoes the login's AUTH verbatim")
|
||||
|
||||
tx(m, "get_account", req_frame(B.COMP_AUTH, B.CMD_GETACCOUNT, msg_num=8),
|
||||
"the RPC behind 'Unable to retrieve account information'")
|
||||
tx(m, "get_persona", req_frame(B.COMP_AUTH, B.CMD_GETPERSONA, msg_num=9))
|
||||
tx(m, "list_personas", req_frame(B.COMP_AUTH, B.CMD_LISTPERSONAS, msg_num=10))
|
||||
|
||||
for cmd, label in ((B.CMD_LISTUSERENTITLEMENTS2, "listUserEntitlements2"),
|
||||
(0x20, "listEntitlements"),
|
||||
(0x30, "listPersonaEntitlements2"),
|
||||
(0x27, "grantEntitlement2")):
|
||||
tx(m, "entitlements_%s" % label,
|
||||
req_frame(B.COMP_AUTH, cmd, msg_num=11),
|
||||
"all four aliases return the same two ONLINE_ACCESS records")
|
||||
|
||||
tx(m, "post_auth", req_frame(B.COMP_UTIL, B.CMD_POSTAUTH, msg_num=12),
|
||||
"TELE/TICK/UROP; TELE.SESS must equal the login session key")
|
||||
tx(m, "fetch_qos_config", req_frame(B.COMP_UTIL, 0x15, msg_num=13))
|
||||
tx(m, "user_settings_load",
|
||||
req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSLOAD, msg_num=14))
|
||||
tx(m, "user_settings_save",
|
||||
req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSSAVE, msg_num=15),
|
||||
"accepted and discarded; empty reply")
|
||||
tx(m, "set_client_state",
|
||||
req_frame(B.COMP_UTIL, B.CMD_SETCLIENTSTATE, msg_num=16))
|
||||
tx(m, "set_client_metrics",
|
||||
req_frame(B.COMP_UTIL, B.CMD_SETCLIENTMETRICS, msg_num=17))
|
||||
|
||||
tx(m, "update_network_info",
|
||||
req_frame(B.COMP_USERSESSIONS, B.CMD_UPDATENETWORKINFO, msg_num=18),
|
||||
"empty reply PLUS an unsolicited ExtendedDataUpdate push")
|
||||
|
||||
tx(m, "get_lists", req_frame(B.COMP_ASSOCLISTS, B.CMD_GETLISTS, msg_num=19))
|
||||
|
||||
tx(m, "census_subscribe",
|
||||
req_frame(B.COMP_CENSUSDATA, B.CMD_SUBSCRIBETOCENSUSDATAUPDATES,
|
||||
OrderedDict([("RSUB", (INT, 1))]), msg_num=20),
|
||||
"non-zero TimeValues or the client storms at ~30/s and hangs the FUT load")
|
||||
|
||||
tx(m, "logout_after_login",
|
||||
req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=21),
|
||||
"session teardown after a login; still an empty reply")
|
||||
|
||||
# ============================ fallback behaviour ========================
|
||||
f = new_session("fallbacks")
|
||||
|
||||
tx(f, "transport_ping",
|
||||
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=30, msg_type=B.PING),
|
||||
"msgType PING -> PING_REPLY with an empty body, whatever the command")
|
||||
|
||||
for mt, label in ((B.REPLY, "reply"), (B.NOTIFICATION, "notification"),
|
||||
(B.ERROR_REPLY, "error_reply"),
|
||||
(B.PING_REPLY, "ping_reply")):
|
||||
tx(f, "ignores_%s" % label,
|
||||
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=31, msg_type=mt),
|
||||
"not a request -> NO frames at all")
|
||||
|
||||
tx(f, "unknown_command",
|
||||
req_frame(B.COMP_UTIL, 0x0FFF, msg_num=32),
|
||||
"unimplemented RPC still gets an EMPTY reply so the client cannot hang")
|
||||
tx(f, "unknown_component",
|
||||
req_frame(0x1234, 0x0001, msg_num=33),
|
||||
"same fallback for an entirely unknown component")
|
||||
|
||||
tx(f, "user_index_is_echoed",
|
||||
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=34, user_index=7),
|
||||
"a reply echoes component/command/msgNum/userIndex verbatim")
|
||||
|
||||
# ================= locale echo on a non-default client ==================
|
||||
loc = new_session("locale")
|
||||
tx(loc, "preauth_de_locale",
|
||||
req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
|
||||
("CDAT", (STRUCT, OrderedDict([
|
||||
("LANG", (INT, 0x64654445)), # 'deDE'
|
||||
("SVCN", (STRING, "fifa-2017-pc-de")),
|
||||
]))),
|
||||
])),
|
||||
"a non-enUS client: SVCN echo AND the captured locale must both change")
|
||||
tx(loc, "login_with_de_locale",
|
||||
req_frame(B.COMP_AUTH, B.CMD_LOGIN, msg_num=41),
|
||||
"UserAuthenticated.ALOC must carry the captured deDE locale")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- output
|
||||
|
||||
def emit_config_table():
|
||||
"""Derive the fetchClientConfig tables as address-TEMPLATED data.
|
||||
|
||||
These are 227-243 key/value rows per CFID, almost all of them the same URL.
|
||||
Hand-transcribing them into Rust would be 400 lines of string literals that
|
||||
nobody can review and one typo can break; deriving them mechanically from
|
||||
the oracle removes that whole class of error and keeps them regenerable.
|
||||
They are reverse-engineered *data*, not logic — the same reason
|
||||
`openfut-core` loads its content from `data/` rather than from source.
|
||||
|
||||
The values are templated on {advertise}/{bind}/{pow_content_host}/{pow_host}
|
||||
so the adapter stays configurable; baking an address in here would recreate
|
||||
exactly the hardcoding the client/server split removed.
|
||||
|
||||
Correctness is not assumed: the caller substitutes real addresses back in
|
||||
and diffs against the oracle. See verify_config_table.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
raw = subprocess.run(
|
||||
[sys.executable, os.path.abspath(__file__), "--_config_table"],
|
||||
capture_output=True, text=True, check=True,
|
||||
# Inherit nothing address-shaped; the child sets its own sentinels.
|
||||
env={k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("OPENFUT_", "POW_", "FUT_"))},
|
||||
).stdout
|
||||
table = json.loads(raw)
|
||||
|
||||
def templatise(value):
|
||||
for sentinel, token in SENTINELS:
|
||||
value = value.replace(sentinel, token)
|
||||
# Collapse whole URLs to URL-level tokens where one exists, so the Rust
|
||||
# side builds them in exactly one place (AdapterConfig::utas_base and
|
||||
# friends) instead of re-deriving the shape here. Without this the
|
||||
# helpers become dead code and a hardcoded address in them goes
|
||||
# undetected — verified by mutation testing. Longest first.
|
||||
for whole, token in (
|
||||
("http://{advertise}:8099/", "{utas_base}"),
|
||||
("http://{bind}:42131", "{nucleus_base}"),
|
||||
("http://{pow_content_host}", "{pow_content_url}"),
|
||||
):
|
||||
if value == whole:
|
||||
return token
|
||||
return value
|
||||
|
||||
return {cfid: [[k, templatise(v)] for k, v in rows]
|
||||
for cfid, rows in table.items()}
|
||||
|
||||
|
||||
def verify_config_table(table):
|
||||
"""Substitute the real addresses back and require the oracle's exact rows.
|
||||
|
||||
This is what makes the templated table trustworthy rather than plausible.
|
||||
"""
|
||||
subst = {
|
||||
"{utas_base}": "http://%s:8099/" % ADVERTISE,
|
||||
"{nucleus_base}": "http://%s:42131" % BIND,
|
||||
"{pow_content_url}": "http://%s" % POW_CONTENT_HOST,
|
||||
"{advertise}": ADVERTISE,
|
||||
"{bind}": BIND,
|
||||
"{pow_content_host}": POW_CONTENT_HOST,
|
||||
"{pow_host}": POW_HOST,
|
||||
}
|
||||
|
||||
def render(v):
|
||||
for token, real in subst.items():
|
||||
v = v.replace(token, real)
|
||||
return v
|
||||
|
||||
for cfid, rows in table.items():
|
||||
expected = B.client_config_for(
|
||||
"__no_such_section__" if cfid == "__default__" else cfid)
|
||||
got = [(k, render(v)) for k, v in rows]
|
||||
if got != [(k, v) for k, v in expected]:
|
||||
for (gk, gv), (ek, ev) in zip(got, expected):
|
||||
if (gk, gv) != (ek, ev):
|
||||
sys.exit("config template mismatch in %s: %r -> %r, oracle "
|
||||
"has %r -> %r" % (cfid, gk, gv, ek, ev))
|
||||
sys.exit("config template row-count mismatch in %s: %d vs %d"
|
||||
% (cfid, len(got), len(expected)))
|
||||
print("config table verified against the oracle for %d sections"
|
||||
% len(table))
|
||||
|
||||
|
||||
def frozen_clock():
|
||||
import time as _time
|
||||
original = _time.time
|
||||
_time.time = lambda: float(FIXED_NOW)
|
||||
return original, _time
|
||||
|
||||
|
||||
def write(path, records):
|
||||
body = "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in records)
|
||||
if CHECK_ONLY:
|
||||
if not os.path.exists(path):
|
||||
sys.exit("MISSING: %s has never been generated" % path)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
if fh.read() != body:
|
||||
sys.exit("STALE: %s does not match the oracle; re-run without "
|
||||
"--check" % path)
|
||||
print("current: %s (%d records)" % (os.path.basename(path), len(records)))
|
||||
return
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
print("wrote %s (%d records)" % (os.path.basename(path), len(records)))
|
||||
|
||||
|
||||
def write_json(path, obj):
|
||||
body = json.dumps(obj, indent=1, sort_keys=True) + "\n"
|
||||
if CHECK_ONLY:
|
||||
if not os.path.exists(path):
|
||||
sys.exit("MISSING: %s has never been generated" % path)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
if fh.read() != body:
|
||||
sys.exit("STALE: %s does not match the oracle" % path)
|
||||
print("current: %s" % os.path.basename(path))
|
||||
return
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
print("wrote %s (%d sections)" % (os.path.basename(path), len(obj)))
|
||||
|
||||
|
||||
def main():
|
||||
# The oracle logs every dispatch to stdout; useful live, pure noise here.
|
||||
B.log = lambda *_a, **_k: None
|
||||
|
||||
table = emit_config_table()
|
||||
verify_config_table(table)
|
||||
write_json(os.path.join(HERE, "client_config.json"), table)
|
||||
|
||||
random.seed(0xB1A2E)
|
||||
original_time, time_mod = frozen_clock()
|
||||
try:
|
||||
build()
|
||||
finally:
|
||||
time_mod.time = original_time
|
||||
|
||||
write(os.path.join(HERE, "blaze_transactions.jsonl"), RECORDS)
|
||||
txs = [r for r in RECORDS if r["kind"] == "tx"]
|
||||
frames = sum(len(r["responses"]) for r in txs)
|
||||
print("%d transactions, %d response frames, %d sessions"
|
||||
% (len(txs), frames,
|
||||
len([r for r in RECORDS if r["kind"] == "session"])))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! FIFA 17 Blaze component, command and notification IDs.
|
||||
//!
|
||||
//! This is exactly the knowledge that must NOT live in
|
||||
//! `openfut-protocol-blaze`: the generic layer routes on numbers, and what
|
||||
//! those numbers mean is per-title.
|
||||
//!
|
||||
//! # Provenance
|
||||
//!
|
||||
//! The Util table was recovered from FIFA17.exe's own `getCommandName` switch
|
||||
//! (jump table `0x141b17af4`). The Authentication table could not be recovered
|
||||
//! statically — the name pool is Denuvo-mutated — so it was obtained by CALLING
|
||||
//! the client's own `getCommandName` (`0x146e0d2a0`) in-process over ids 1..320,
|
||||
//! validated by reproducing the known Util names, and cross-checked against a
|
||||
//! static REST-binding struct (`0x143896a80` → `trustedLogin = 0x0B`).
|
||||
//! UserSessions notification ids come from the clean, unmutated
|
||||
//! `getNotificationName` jump table at `0x141b03f70`.
|
||||
//!
|
||||
//! Names are for diagnostics only. Dispatch matches on the numeric constants.
|
||||
|
||||
pub mod component {
|
||||
pub const AUTHENTICATION: u16 = 0x0001;
|
||||
pub const GAME_MANAGER: u16 = 0x0004;
|
||||
pub const REDIRECTOR: u16 = 0x0005;
|
||||
pub const STATS: u16 = 0x0007;
|
||||
pub const UTIL: u16 = 0x0009;
|
||||
pub const CENSUS_DATA: u16 = 0x000A;
|
||||
pub const CLUBS: u16 = 0x000B;
|
||||
pub const MESSAGING: u16 = 0x000F;
|
||||
pub const ASSOCIATION_LISTS: u16 = 0x0019;
|
||||
pub const GAME_REPORTING: u16 = 0x001C;
|
||||
pub const SPONSORED_EVENTS: u16 = 0x081C;
|
||||
pub const OSDK_SETTINGS: u16 = 0x08C9;
|
||||
pub const USER_SESSIONS: u16 = 0x7802;
|
||||
}
|
||||
|
||||
pub mod util {
|
||||
pub const FETCH_CLIENT_CONFIG: u16 = 0x0001;
|
||||
pub const PING: u16 = 0x0002;
|
||||
pub const PRE_AUTH: u16 = 0x0007;
|
||||
pub const POST_AUTH: u16 = 0x0008;
|
||||
pub const USER_SETTINGS_LOAD: u16 = 0x000A;
|
||||
pub const USER_SETTINGS_SAVE: u16 = 0x000B;
|
||||
pub const FETCH_QOS_CONFIG: u16 = 0x0015;
|
||||
pub const SET_CLIENT_METRICS: u16 = 0x0016;
|
||||
pub const SET_CLIENT_STATE: u16 = 0x001C;
|
||||
}
|
||||
|
||||
pub mod auth {
|
||||
pub const LOGIN: u16 = 0x000A;
|
||||
pub const TRUSTED_LOGIN: u16 = 0x000B;
|
||||
pub const LIST_USER_ENTITLEMENTS2: u16 = 0x001D;
|
||||
pub const GET_ACCOUNT: u16 = 0x001E;
|
||||
pub const LIST_ENTITLEMENTS: u16 = 0x0020;
|
||||
pub const GET_AUTH_TOKEN: u16 = 0x0024;
|
||||
pub const GRANT_ENTITLEMENT2: u16 = 0x0027;
|
||||
pub const LIST_PERSONA_ENTITLEMENTS2: u16 = 0x0030;
|
||||
pub const EXPRESS_LOGIN: u16 = 0x003C;
|
||||
/// Routine "drop any stale session" step before PCLogin, NOT a failure.
|
||||
pub const LOGOUT: u16 = 0x0046;
|
||||
pub const GET_PERSONA: u16 = 0x005A;
|
||||
pub const LIST_PERSONAS: u16 = 0x0064;
|
||||
}
|
||||
|
||||
pub mod user_sessions {
|
||||
pub const UPDATE_NETWORK_INFO: u16 = 0x0014;
|
||||
|
||||
/// Notification ids live in a separate number space from commands.
|
||||
pub mod notify {
|
||||
pub const EXTENDED_DATA_UPDATE: u16 = 0x0001;
|
||||
pub const USER_ADDED: u16 = 0x0002;
|
||||
pub const USER_REMOVED: u16 = 0x0003;
|
||||
pub const USER_UPDATED: u16 = 0x0005;
|
||||
pub const USER_AUTHENTICATED: u16 = 0x0008;
|
||||
pub const USER_UNAUTHENTICATED: u16 = 0x0009;
|
||||
pub const SERVER_DRAINING: u16 = 0x000C;
|
||||
}
|
||||
}
|
||||
|
||||
pub mod association_lists {
|
||||
pub const GET_LISTS: u16 = 0x0006;
|
||||
}
|
||||
|
||||
pub mod census_data {
|
||||
pub const SUBSCRIBE_TO_CENSUS_DATA_UPDATES: u16 = 0x0005;
|
||||
}
|
||||
|
||||
/// Components advertised in `PreAuthResponse.CIDS`.
|
||||
///
|
||||
/// Order is the oracle's and is preserved: `CIDS` is a TDF list, and list
|
||||
/// elements are NOT reordered by the encoder the way struct members are.
|
||||
pub const ADVERTISED_COMPONENT_IDS: [i64; 9] = [
|
||||
component::AUTHENTICATION as i64,
|
||||
component::GAME_MANAGER as i64,
|
||||
component::REDIRECTOR as i64,
|
||||
component::STATS as i64,
|
||||
component::UTIL as i64,
|
||||
component::MESSAGING as i64,
|
||||
component::ASSOCIATION_LISTS as i64,
|
||||
component::GAME_REPORTING as i64,
|
||||
component::USER_SESSIONS as i64,
|
||||
];
|
||||
|
||||
pub fn component_name(component: u16) -> Option<&'static str> {
|
||||
Some(match component {
|
||||
component::AUTHENTICATION => "Authentication",
|
||||
component::GAME_MANAGER => "GameManager",
|
||||
component::REDIRECTOR => "Redirector",
|
||||
component::STATS => "Stats",
|
||||
component::UTIL => "Util",
|
||||
component::CENSUS_DATA => "CensusData",
|
||||
component::CLUBS => "Clubs",
|
||||
component::MESSAGING => "Messaging",
|
||||
component::ASSOCIATION_LISTS => "AssociationLists",
|
||||
component::GAME_REPORTING => "GameReporting",
|
||||
component::SPONSORED_EVENTS => "SponsoredEvents",
|
||||
component::OSDK_SETTINGS => "OSDKSettings",
|
||||
component::USER_SESSIONS => "UserSessions",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn command_name(component: u16, command: u16) -> Option<&'static str> {
|
||||
Some(match (component, command) {
|
||||
(component::UTIL, 0x01) => "fetchClientConfig",
|
||||
(component::UTIL, 0x02) => "ping",
|
||||
(component::UTIL, 0x03) => "setClientData",
|
||||
(component::UTIL, 0x04) => "localizeStrings",
|
||||
(component::UTIL, 0x05) => "getTelemetryServer",
|
||||
(component::UTIL, 0x06) => "getTickerServer",
|
||||
(component::UTIL, 0x07) => "preAuth",
|
||||
(component::UTIL, 0x08) => "postAuth",
|
||||
(component::UTIL, 0x0A) => "userSettingsLoad",
|
||||
(component::UTIL, 0x0B) => "userSettingsSave",
|
||||
(component::UTIL, 0x0C) => "userSettingsLoadAll",
|
||||
(component::UTIL, 0x0E) => "userSettingsDelete",
|
||||
(component::UTIL, 0x0F) => "userSettingsLoadAllForUser",
|
||||
(component::UTIL, 0x14) => "filterForProfanity",
|
||||
(component::UTIL, 0x15) => "fetchQosConfig",
|
||||
(component::UTIL, 0x16) => "setClientMetrics",
|
||||
(component::UTIL, 0x17) => "setConnectionState",
|
||||
(component::UTIL, 0x19) => "getUserOptions",
|
||||
(component::UTIL, 0x1A) => "setUserOptions",
|
||||
(component::UTIL, 0x1B) => "suspendUserPing",
|
||||
(component::UTIL, 0x1C) => "setClientState",
|
||||
|
||||
(component::AUTHENTICATION, 0x0A) => "login",
|
||||
(component::AUTHENTICATION, 0x0B) => "trustedLogin",
|
||||
(component::AUTHENTICATION, 0x14) => "updateAccount",
|
||||
(component::AUTHENTICATION, 0x15) => "upgradeAccount",
|
||||
(component::AUTHENTICATION, 0x1D) => "listUserEntitlements2",
|
||||
(component::AUTHENTICATION, 0x1E) => "getAccount",
|
||||
(component::AUTHENTICATION, 0x1F) => "grantEntitlement",
|
||||
(component::AUTHENTICATION, 0x20) => "listEntitlements",
|
||||
(component::AUTHENTICATION, 0x22) => "getUseCount",
|
||||
(component::AUTHENTICATION, 0x23) => "decrementUseCount",
|
||||
(component::AUTHENTICATION, 0x24) => "getAuthToken",
|
||||
(component::AUTHENTICATION, 0x26) => "getPasswordRules",
|
||||
(component::AUTHENTICATION, 0x27) => "grantEntitlement2",
|
||||
(component::AUTHENTICATION, 0x2B) => "modifyEntitlement2",
|
||||
(component::AUTHENTICATION, 0x2C) => "consumecode",
|
||||
(component::AUTHENTICATION, 0x2D) => "passwordForgot",
|
||||
(component::AUTHENTICATION, 0x2F) => "getPrivacyPolicyContent",
|
||||
(component::AUTHENTICATION, 0x30) => "listPersonaEntitlements2",
|
||||
(component::AUTHENTICATION, 0x33) => "checkAgeReq",
|
||||
(component::AUTHENTICATION, 0x34) => "getOptIn",
|
||||
(component::AUTHENTICATION, 0x35) => "enableOptIn",
|
||||
(component::AUTHENTICATION, 0x36) => "disableOptIn",
|
||||
(component::AUTHENTICATION, 0x3C) => "expressLogin",
|
||||
(component::AUTHENTICATION, 0x46) => "logout",
|
||||
(component::AUTHENTICATION, 0x5A) => "getPersona",
|
||||
(component::AUTHENTICATION, 0x64) => "listPersonas",
|
||||
(component::AUTHENTICATION, 0x65) => "expressCreateAccount",
|
||||
(component::AUTHENTICATION, 0xE6) => "createWalUserSession",
|
||||
(component::AUTHENTICATION, 0xF1) => "acceptLegalDocs",
|
||||
(component::AUTHENTICATION, 0xF2) => "getEmailOptInSettings",
|
||||
(component::AUTHENTICATION, 0xF6) => "getTermsOfServiceContent",
|
||||
(component::AUTHENTICATION, 0x104) => "getOriginPersona",
|
||||
(component::AUTHENTICATION, 0x10E) => "checkEmail",
|
||||
(component::AUTHENTICATION, 0x118) => "getPersonaNameSuggestions",
|
||||
(component::AUTHENTICATION, 0x122) => "guestLogin",
|
||||
|
||||
(component::CENSUS_DATA, 0x01) => "subscribeToCensusData",
|
||||
(component::CENSUS_DATA, 0x02) => "unsubscribeFromCensusData",
|
||||
(component::CENSUS_DATA, 0x03) => "getRegionCounts",
|
||||
(component::CENSUS_DATA, 0x04) => "getLatestCensusData",
|
||||
(component::CENSUS_DATA, 0x05) => "subscribeToCensusDataUpdates",
|
||||
|
||||
(component::USER_SESSIONS, 0x14) => "updateNetworkInfo",
|
||||
(component::ASSOCIATION_LISTS, 0x06) => "getLists",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn notification_name(component: u16, notify_id: u16) -> Option<&'static str> {
|
||||
if component != component::USER_SESSIONS {
|
||||
return None;
|
||||
}
|
||||
Some(match notify_id {
|
||||
0x01 => "UserSessionExtendedDataUpdate",
|
||||
0x02 => "UserAdded",
|
||||
0x03 => "UserRemoved",
|
||||
0x05 => "UserUpdated",
|
||||
0x08 => "UserAuthenticated",
|
||||
0x09 => "UserUnauthenticated",
|
||||
0x0C => "ServerDraining",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Human-readable label for a route, for logs and captures.
|
||||
pub fn describe(component: u16, command: u16, is_notification: bool) -> String {
|
||||
let comp = component_name(component)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("Component:0x{component:04x}"));
|
||||
if is_notification {
|
||||
if let Some(n) = notification_name(component, command) {
|
||||
return format!("{comp}::<{n}>");
|
||||
}
|
||||
return format!("{comp}::<notify:0x{command:04x}>");
|
||||
}
|
||||
match command_name(component, command) {
|
||||
Some(name) => format!("{comp}::{name}"),
|
||||
None => format!("{comp}::cmd:0x{command:04x}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn describes_the_first_rpc_fifa_sends() {
|
||||
assert_eq!(
|
||||
describe(component::UTIL, util::PRE_AUTH, false),
|
||||
"Util::preAuth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_notifications_are_separate_number_spaces() {
|
||||
// 0x0002 is UserSessions "UserAdded" as a notification, but is not a
|
||||
// known UserSessions *command*.
|
||||
assert_eq!(
|
||||
describe(component::USER_SESSIONS, 0x0002, true),
|
||||
"UserSessions::<UserAdded>"
|
||||
);
|
||||
assert_eq!(
|
||||
describe(component::USER_SESSIONS, 0x0002, false),
|
||||
"UserSessions::cmd:0x0002"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_routes_degrade_to_numbers() {
|
||||
assert_eq!(
|
||||
describe(0x1234, 0x0001, false),
|
||||
"Component:0x1234::cmd:0x0001"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertised_components_are_in_oracle_order() {
|
||||
// A list, not a struct: the encoder will NOT sort these, so the order
|
||||
// here is the order on the wire.
|
||||
assert_eq!(ADVERTISED_COMPONENT_IDS[0], 0x0001);
|
||||
assert_eq!(ADVERTISED_COMPONENT_IDS[8], 0x7802);
|
||||
assert_eq!(ADVERTISED_COMPONENT_IDS.len(), 9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! FIFA 17 Blaze adapter.
|
||||
//!
|
||||
//! Sits on `openfut-protocol-blaze` (Fire2 framing + Heat2/TDF) and supplies
|
||||
//! everything the generic layer deliberately refuses to know: which component
|
||||
//! and command numbers mean what, what each response body must contain, and in
|
||||
//! what order frames leave the server.
|
||||
//!
|
||||
//! ```text
|
||||
//! FIFA 17 client
|
||||
//! │ Fire2 frames
|
||||
//! openfut-protocol-blaze generic: framing + codec
|
||||
//! │ Header + Struct
|
||||
//! blaze::Adapter THIS: FIFA 17 ids, bodies, ordering
|
||||
//! │ (future) semantic calls
|
||||
//! OpenFUT Core game-independent FUT domain
|
||||
//! ```
|
||||
//!
|
||||
//! Blaze is an auth/session/config protocol: no coins, packs, clubs or squads
|
||||
//! appear on this wire, so the adapter carries no FUT domain state and has no
|
||||
//! reason to grow into a second backend.
|
||||
|
||||
pub mod client_config;
|
||||
pub mod config;
|
||||
pub mod dispatch;
|
||||
pub mod ids;
|
||||
pub mod responses;
|
||||
pub mod session;
|
||||
|
||||
pub use config::{AdapterConfig, Endpoints, Identity};
|
||||
pub use dispatch::Adapter;
|
||||
pub use session::Session;
|
||||
@@ -0,0 +1,623 @@
|
||||
//! FIFA 17 Blaze response bodies.
|
||||
//!
|
||||
//! Every builder here mirrors a `Blaze::*` TDF class reversed from FIFA17.exe's
|
||||
//! own reflection metadata. Member counts and tags are not guesses, and the
|
||||
//! comments carry the class addresses so a future reader can re-derive them.
|
||||
//!
|
||||
//! Two recurring rules, both learned the hard way:
|
||||
//!
|
||||
//! * **An absent member is safe; a wrongly-typed one is fatal.** A member the
|
||||
//! client does not receive keeps its client-side default. A member encoded
|
||||
//! with the wrong wire type desynchronises the whole TDF parse. That is why
|
||||
//! `CGID`, `ADDR`, `CVAR` and `ULST` are omitted rather than guessed — their
|
||||
//! union/objid encodings are UNVERIFIED.
|
||||
//! * **Identity must be byte-identical across responses.** `MAIL`/`UID`/`ASRC`
|
||||
//! in `AccountInfo` must match `LoginResponse.SESS` and `PreAuthResponse.NASP`,
|
||||
//! and the session key must be the same string in three places.
|
||||
//!
|
||||
//! Member order in the source below is the oracle's for readability; the
|
||||
//! encoder sorts by packed tag, so source order never reaches the wire.
|
||||
|
||||
use openfut_protocol_blaze::heat2::{Struct, TypeId, Value};
|
||||
|
||||
use super::client_config;
|
||||
use super::config::AdapterConfig;
|
||||
use super::ids::ADVERTISED_COMPONENT_IDS;
|
||||
use super::session::Session;
|
||||
|
||||
fn s(v: impl Into<String>) -> Value {
|
||||
Value::String(v.into())
|
||||
}
|
||||
|
||||
fn i(v: i64) -> Value {
|
||||
Value::Int(v)
|
||||
}
|
||||
|
||||
/// `Blaze::Util::FetchConfigResponse` @0x1448752e0 — a single `CONF`
|
||||
/// map<string,string>.
|
||||
///
|
||||
/// NOT double-nested. The extra nesting exists only inside `PreAuthResponse`,
|
||||
/// where `CONF` is itself a `FetchConfigResponse` whose own single member is
|
||||
/// also called `CONF`. Easy to get wrong.
|
||||
pub fn fetch_config_response(cfid: &str, cfg: &AdapterConfig) -> Struct {
|
||||
let entries = client_config::rows_for(cfid, cfg)
|
||||
.into_iter()
|
||||
.map(|(k, v)| (Value::String(k), Value::String(v)))
|
||||
.collect();
|
||||
Struct::new().with(
|
||||
"CONF",
|
||||
Value::Map {
|
||||
key: TypeId::String,
|
||||
val: TypeId::String,
|
||||
entries,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `Blaze::QosConfigInfo` — 4 members.
|
||||
///
|
||||
/// FIFA 17's descriptor has no `SVID`, unlike Mirror's Edge Catalyst; do not
|
||||
/// add one back from another title's emulator.
|
||||
pub fn qos_config(cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with(
|
||||
"BWPS",
|
||||
Value::Struct(
|
||||
Struct::new()
|
||||
.with("PSA", s(&cfg.endpoints.advertise))
|
||||
.with("PSP", i(cfg.endpoints.qos_port)),
|
||||
),
|
||||
)
|
||||
.with("LNP", i(10))
|
||||
.with(
|
||||
"LTPS",
|
||||
Value::Map {
|
||||
key: TypeId::String,
|
||||
val: TypeId::Struct,
|
||||
entries: vec![],
|
||||
},
|
||||
)
|
||||
.with("TIME", i(5_000_000))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::PreAuthResponse`.
|
||||
pub fn preauth_response(service_name: &str, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("ASRC", s(&id.title_id))
|
||||
.with(
|
||||
"CIDS",
|
||||
Value::List {
|
||||
elem: TypeId::Int,
|
||||
items: ADVERTISED_COMPONENT_IDS.iter().copied().map(i).collect(),
|
||||
},
|
||||
)
|
||||
.with("CLID", s(&id.client_id))
|
||||
.with(
|
||||
"CONF",
|
||||
Value::Struct(fetch_config_response("BlazeSDK", cfg)),
|
||||
)
|
||||
.with("ESRC", s(&id.title_id))
|
||||
.with("INST", s(service_name)) // echo of CDAT.SVCN
|
||||
.with("MAID", i(0))
|
||||
.with("MINR", i(0))
|
||||
.with("NASP", s(&id.namespace))
|
||||
.with("PILD", s(""))
|
||||
.with("PLAT", s(&id.platform))
|
||||
.with("QOSS", Value::Struct(qos_config(cfg)))
|
||||
.with("RSRC", s(&id.title_id))
|
||||
.with("SVER", s(&cfg.server_version))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::PingResponse` @0x144875560 — exactly one member.
|
||||
///
|
||||
/// v2 also sent `TIME`; that is MEC's field, not FIFA 17's.
|
||||
pub fn ping_response(now: i64) -> Struct {
|
||||
Struct::new().with("STIM", i(now))
|
||||
}
|
||||
|
||||
/// `Blaze::CensusData::SubscribeToCensusDataUpdatesResponse` — 3 TimeValues,
|
||||
/// encoded as INT microseconds.
|
||||
///
|
||||
/// These must be non-zero. The client computes `delay_ms = (CNP + NTMT) / 1000`
|
||||
/// and re-arms a resend timer; an empty reply gives delay 0, which lands the job
|
||||
/// on the scheduler's ready list and produces a ~30/s re-subscribe storm that
|
||||
/// hangs the FUT loading screen.
|
||||
pub fn census_subscribe_response() -> Struct {
|
||||
Struct::new()
|
||||
.with("CNP", i(30 * 1_000_000))
|
||||
.with("NTMT", i(90 * 1_000_000))
|
||||
.with("RTMT", i(300 * 1_000_000))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::PersonaDetails` @0x14487cab0 — 6 members.
|
||||
pub fn persona_details(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("DSNM", s(&id.persona_name))
|
||||
.with("LAST", i(now))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("PLAT", i(id.client_platform))
|
||||
.with("STAS", i(id.persona_status))
|
||||
.with("XREF", i(id.ext_id))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::UserLoginInfo` @0x14487cb00 — 8 members.
|
||||
///
|
||||
/// `'1CON'` packs to 0x11, which sorts below `'A'` = 0x21, so it leads.
|
||||
pub fn user_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("1CON", i(0))
|
||||
.with("BUID", i(id.user_id)) // must be non-zero
|
||||
.with("FRST", i(0))
|
||||
.with("KEY", s(&sess.session_key)) // must be non-empty
|
||||
.with("LLOG", i(now))
|
||||
.with("MAIL", s(&id.email))
|
||||
.with("PDTL", Value::Struct(persona_details(now, cfg)))
|
||||
.with("UID", i(id.user_id)) // must be non-zero
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::LoginResponse` @0x14487d170 — exactly 5 members.
|
||||
///
|
||||
/// Diverges from both public MEC emulators, which emit `CNTX`, `ERRC` and a
|
||||
/// top-level `SKEY`. FIFA 17 has none of those: `CNTX`/`ERRC` are the Blaze
|
||||
/// error-metadata block, and the session key lives at `SESS.KEY`.
|
||||
pub fn login_response(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("ANON", i(0))
|
||||
.with("NTOS", i(0)) // 1 would divert to the legal-doc flow
|
||||
.with("SESS", Value::Struct(user_login_info(sess, now, cfg)))
|
||||
.with("SPAM", i(1))
|
||||
.with("UNDR", i(0))
|
||||
}
|
||||
|
||||
/// ISO-8601 UTC, matching the oracle's `%Y-%m-%dT%H:%M:%SZ`.
|
||||
///
|
||||
/// Hand-rolled from a Unix timestamp to keep this crate free of a date
|
||||
/// dependency for one format string. Proleptic Gregorian, no leap seconds —
|
||||
/// the same calendar `time.gmtime` uses.
|
||||
fn iso8601_utc(unix: i64) -> String {
|
||||
let days = unix.div_euclid(86_400);
|
||||
let secs = unix.rem_euclid(86_400);
|
||||
let (h, mi, sec) = (secs / 3600, (secs % 3600) / 60, secs % 60);
|
||||
|
||||
// Civil-from-days (Howard Hinnant's algorithm), shifted to a March-based year.
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
|
||||
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}Z")
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::AccountInfo` @0x14487c810 — exactly 16 members.
|
||||
///
|
||||
/// The RPC behind the "Unable to retrieve account information" popup: before it
|
||||
/// was implemented, the empty-reply fallback produced an AccountInfo with
|
||||
/// `UID=0`/`CO=""` and the popup appeared one layer later.
|
||||
///
|
||||
/// Member tags come from the reflection tag table @0x1448775a0; wire types from
|
||||
/// each member's subtype descriptor (string subtype 0x144867628 covers ASRC CO
|
||||
/// DOB DTCR LATH LN MAIL PML; the other eight are int/enum). Enum values:
|
||||
/// `STAS` = AccountStatus ACTIVE = 1, `STAT` = EmailStatus VERIFIED = 2,
|
||||
/// `RC` = StatusReason none = 0.
|
||||
pub fn account_info(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("AMU", i(0))
|
||||
.with("ASRC", s(&id.namespace)) // == PreAuthResponse.NASP
|
||||
.with("CO", s("US"))
|
||||
.with("DOB", s("1990-01-01T00:00:00Z"))
|
||||
.with("DTCR", s("2016-09-01T00:00:00Z"))
|
||||
.with("GOPT", i(0))
|
||||
.with("LATH", s(iso8601_utc(now)))
|
||||
.with("LN", s(&id.locale))
|
||||
.with("MAIL", s(&id.email)) // == LoginResponse.SESS.MAIL
|
||||
.with("PML", s(""))
|
||||
.with("RC", i(0))
|
||||
.with("STAS", i(1))
|
||||
.with("STAT", i(2))
|
||||
.with("TPOT", i(0))
|
||||
.with("UDU", i(0))
|
||||
.with("UID", i(id.user_id)) // == LoginResponse.SESS.UID
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::PersonaInfo` @0x14487c7c0 — 7 members.
|
||||
///
|
||||
/// `STAS` here is PersonaStatus ACTIVE = 2 (table 0x14487ad20) — a different
|
||||
/// enum from AccountInfo's `STAS`, which is AccountStatus ACTIVE = 1. `LADT`'s
|
||||
/// wire type is a best guess (INT timestamp); it is only reachable via
|
||||
/// getPersona/listPersonas, off the critical getAccount path.
|
||||
pub fn persona_info(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("DSNM", s(&id.persona_name))
|
||||
.with("DTCR", s("2016-09-01T00:00:00Z"))
|
||||
.with("LADT", i(now))
|
||||
.with("NSNM", s(&id.namespace))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("STAS", i(2))
|
||||
.with("STRC", i(0))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::GetPersonaResponse` @0x14487d1c0 — PINF + UID.
|
||||
pub fn get_persona_response(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("PINF", Value::Struct(persona_info(now, cfg)))
|
||||
.with("UID", i(cfg.identity.user_id))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::ListPersonasResponse` @0x14487d210 — one member.
|
||||
pub fn list_personas_response(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new().with(
|
||||
"PINF",
|
||||
Value::List {
|
||||
elem: TypeId::Struct,
|
||||
items: vec![Value::Struct(persona_info(now, cfg))],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `Blaze::UserSessionLoginInfo` @0x14486f920 — 16 members.
|
||||
///
|
||||
/// A superset of `UserLoginInfo` with the persona fields flattened in rather
|
||||
/// than nested. `KEY` must be byte-identical to `LoginResponse.SESS.KEY`.
|
||||
///
|
||||
/// `CGID` (a connectionGroup ObjectId) is omitted: the OBJID encoding is
|
||||
/// UNVERIFIED and a wrong one desynchronises the parse, while an absent member
|
||||
/// simply keeps its default.
|
||||
pub fn user_session_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("1CON", i(0))
|
||||
.with("ALOC", i(sess.account_locale)) // echo the client's own locale
|
||||
.with("BUID", i(id.user_id))
|
||||
.with("DSNM", s(&id.persona_name))
|
||||
.with("FRST", i(0))
|
||||
.with("KEY", s(&sess.session_key)) // same string as LoginResponse
|
||||
.with("LAST", i(now))
|
||||
.with("LLOG", i(now))
|
||||
.with("MAIL", s(&id.email))
|
||||
.with("NASP", s(&id.namespace))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("PLAT", i(id.client_platform))
|
||||
.with("UID", i(id.user_id))
|
||||
.with("USTP", i(id.user_session_type))
|
||||
.with("XREF", i(id.ext_id))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::NetworkQosData` @0x14486e680 — 5 members. `NATT` 0 = OPEN.
|
||||
pub fn network_qos_data() -> Struct {
|
||||
Struct::new()
|
||||
.with("BWHR", i(0))
|
||||
.with("DBPS", i(100_000))
|
||||
.with("NAHR", i(0))
|
||||
.with("NATT", i(0))
|
||||
.with("UBPS", i(100_000))
|
||||
}
|
||||
|
||||
/// `Blaze::UserSessionExtendedData` @0x144870390.
|
||||
///
|
||||
/// Two FIFA-17-specific deltas from the MEC emulators: FIFA HAS `PSLM`
|
||||
/// (latencyList), which they lack, and FIFA carries `BPS` as a top-level string
|
||||
/// whereas they bury it inside the `ADDR` union. Follow FIFA's layout.
|
||||
///
|
||||
/// `ADDR`, `CVAR` and `ULST` are omitted — unverified union/objid encodings.
|
||||
pub fn user_session_extended_data() -> Struct {
|
||||
Struct::new()
|
||||
.with("BPS", s("openfut"))
|
||||
.with("CTY", s("US"))
|
||||
.with(
|
||||
"DMAP",
|
||||
Value::Map {
|
||||
key: TypeId::Int,
|
||||
val: TypeId::Int,
|
||||
entries: vec![],
|
||||
},
|
||||
)
|
||||
.with("HWFG", i(0))
|
||||
.with("ISP", s("OpenFUT"))
|
||||
.with(
|
||||
"PSLM",
|
||||
Value::List {
|
||||
elem: TypeId::Int,
|
||||
items: vec![i(0)],
|
||||
},
|
||||
)
|
||||
.with("QDAT", Value::Struct(network_qos_data()))
|
||||
.with("TZ", s(""))
|
||||
.with("UATT", i(0))
|
||||
}
|
||||
|
||||
/// `Blaze::UserSessionExtendedDataUpdate` @0x1448703e0 — 3 members.
|
||||
pub fn user_session_extended_data_update(cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("DATA", Value::Struct(user_session_extended_data()))
|
||||
.with("SUBS", i(1))
|
||||
.with("USID", i(cfg.identity.user_id))
|
||||
}
|
||||
|
||||
/// `Blaze::UserIdentification` @0x14486ebc0 — 9 members.
|
||||
pub fn user_identification(sess: &Session, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("AID", i(id.user_id))
|
||||
.with("ALOC", i(sess.account_locale))
|
||||
.with("EXBB", Value::Blob(vec![]))
|
||||
.with("EXID", i(id.ext_id))
|
||||
.with("ID", i(id.user_id))
|
||||
.with("NAME", s(&id.persona_name))
|
||||
.with("NASP", s(&id.namespace))
|
||||
.with("ORIG", i(id.persona_id))
|
||||
.with("PIDI", i(id.persona_id))
|
||||
}
|
||||
|
||||
/// `Blaze::UserData` @0x1448706b0 — payload of the `UserAdded` push.
|
||||
/// `FLGS` is a UserDataFlags bitfield; bit 0 = online/authenticated.
|
||||
pub fn user_data(sess: &Session, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("EDAT", Value::Struct(user_session_extended_data()))
|
||||
.with("FLGS", i(3))
|
||||
.with("USER", Value::Struct(user_identification(sess, cfg)))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::PostAuthResponse` @0x144875810 — TELE, TICK, UROP.
|
||||
///
|
||||
/// Telemetry and ticker point at dead local ports on purpose: the client gets a
|
||||
/// well-formed config and then fails to connect quietly, rather than resolving
|
||||
/// a real EA hostname.
|
||||
pub fn post_auth_response(sess: &Session, cfg: &AdapterConfig) -> Struct {
|
||||
let tele = Struct::new()
|
||||
.with("ADRS", s(&cfg.endpoints.advertise))
|
||||
.with("ANON", i(0))
|
||||
.with("DISA", s(""))
|
||||
.with("EDCT", i(0))
|
||||
.with("FILT", s(""))
|
||||
.with("LOC", i(sess.account_locale))
|
||||
.with("MINR", i(0))
|
||||
.with("NOOK", s(""))
|
||||
.with("PORT", i(cfg.endpoints.telemetry_port))
|
||||
.with("SDLY", i(15_000))
|
||||
.with("SESS", s(&sess.session_key)) // same key as login
|
||||
.with("SKEY", s(""))
|
||||
.with("SPCT", i(75))
|
||||
.with("STIM", s(""))
|
||||
.with("SVNM", s("telemetry-openfut"));
|
||||
|
||||
let tick = Struct::new()
|
||||
.with("ADRS", s(&cfg.endpoints.advertise))
|
||||
.with("PORT", i(cfg.endpoints.ticker_port))
|
||||
.with("SKEY", s(""));
|
||||
|
||||
let urop = Struct::new()
|
||||
.with("TMOP", i(0))
|
||||
.with("UID", i(cfg.identity.user_id));
|
||||
|
||||
Struct::new()
|
||||
.with("TELE", Value::Struct(tele))
|
||||
.with("TICK", Value::Struct(tick))
|
||||
.with("UROP", Value::Struct(urop))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::Entitlement` @0x14487d490 — 16 members.
|
||||
///
|
||||
/// FUT's client-side filter (`onListEntitlements` @0x146f27440) keeps a record
|
||||
/// only if `GNAM` contains `"FIFA17PCBoxContent"` or `"FIFA16PC"`, `TAG` is
|
||||
/// non-empty, and `STAT == 1`. A plain `"FIFA17PC"` group matched neither
|
||||
/// needle and produced an empty store.
|
||||
///
|
||||
/// `PRID`/`GNAM`/`TAG` must contain no `'|'` and no `'/'`: the client
|
||||
/// re-serialises them as `PRID|GNAM|TAG|UCNT/`.
|
||||
pub fn entitlement(group: &str, tag: &str, eid: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("DEVI", s(""))
|
||||
.with("GDAY", s("2016-09-01T00:00:00Z"))
|
||||
.with("GNAM", s(group))
|
||||
.with("ID", i(eid))
|
||||
.with("ISCO", i(0))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("PJID", s(&id.content_id))
|
||||
.with("PRCA", i(2))
|
||||
.with("PRID", s(&id.content_id))
|
||||
.with("STAT", i(1)) // must be 1 or FUT drops it
|
||||
.with("STRC", i(0))
|
||||
.with("TAG", s(tag)) // must be non-empty
|
||||
.with("TDAY", s(""))
|
||||
.with("TYPE", i(1))
|
||||
.with("UCNT", i(0))
|
||||
.with("VER", i(1))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::Entitlements` @0x14487d4e0 — single member `NLST`.
|
||||
///
|
||||
/// Emits BOTH accepted groups so the entitlement manager's "loaded" flag
|
||||
/// (`byte[entMgr+0x88]`) flips however the client asks.
|
||||
pub fn entitlements_response(cfg: &AdapterConfig) -> Struct {
|
||||
let tag = &cfg.identity.entitlement_tag;
|
||||
Struct::new().with(
|
||||
"NLST",
|
||||
Value::List {
|
||||
elem: TypeId::Struct,
|
||||
items: vec![
|
||||
Value::Struct(entitlement("FIFA17PCBoxContent", tag, 1, cfg)),
|
||||
Value::Struct(entitlement("FIFA16PC", tag, 2, cfg)),
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::GetAuthTokenResponse` @0x14487d080 — one member.
|
||||
pub fn get_auth_token_response(sess: &Session) -> Struct {
|
||||
Struct::new().with("AUTH", s(sess.auth_token()))
|
||||
}
|
||||
|
||||
/// `Util::userSettingsLoad` response.
|
||||
///
|
||||
/// TODO(verify): the response descriptor was never reflected. Both independent
|
||||
/// clean-room emulators use a single `DATA` string, and an unknown-tag payload
|
||||
/// is ignored rather than fatal, so an empty `DATA` is the safe minimum — the
|
||||
/// client falls back to its defaults.
|
||||
pub fn user_settings_response() -> Struct {
|
||||
Struct::new().with("DATA", s(""))
|
||||
}
|
||||
|
||||
/// `AssociationLists::getLists` response.
|
||||
///
|
||||
/// TODO(verify): FIFA's association-list names are NOT known — do not invent
|
||||
/// them. An empty list is well-formed and means "this user has no association
|
||||
/// lists", which is true offline.
|
||||
pub fn get_lists_response() -> Struct {
|
||||
Struct::new().with(
|
||||
"LMAP",
|
||||
Value::List {
|
||||
elem: TypeId::Struct,
|
||||
items: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> AdapterConfig {
|
||||
AdapterConfig::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_matches_known_instants() {
|
||||
assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso8601_utc(1_754_870_400), "2025-08-11T00:00:00Z");
|
||||
// A leap day, to exercise the civil-from-days branch.
|
||||
assert_eq!(iso8601_utc(1_709_164_800), "2024-02-29T00:00:00Z");
|
||||
assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_response_has_exactly_five_members() {
|
||||
let sess = Session::new("k", 0);
|
||||
assert_eq!(login_response(&sess, 0, &cfg()).len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_info_has_exactly_sixteen_members() {
|
||||
assert_eq!(account_info(0, &cfg()).len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_key_appears_identically_in_all_three_places() {
|
||||
let sess = Session::new("THE-KEY", 0);
|
||||
let c = cfg();
|
||||
|
||||
let login = login_response(&sess, 1, &c);
|
||||
let in_login = login
|
||||
.get("SESS")
|
||||
.and_then(Value::as_struct)
|
||||
.and_then(|s| s.get("KEY"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap();
|
||||
|
||||
let notify = user_session_login_info(&sess, 1, &c);
|
||||
let in_notify = notify.get("KEY").and_then(Value::as_str).unwrap();
|
||||
|
||||
let post = post_auth_response(&sess, &c);
|
||||
let in_post = post
|
||||
.get("TELE")
|
||||
.and_then(Value::as_struct)
|
||||
.and_then(|s| s.get("SESS"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(in_login, "THE-KEY");
|
||||
assert_eq!(in_notify, "THE-KEY");
|
||||
assert_eq!(in_post, "THE-KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_is_consistent_between_login_and_account_info() {
|
||||
let sess = Session::new("k", 0);
|
||||
let c = cfg();
|
||||
let acct = account_info(0, &c);
|
||||
let sess_info = user_login_info(&sess, 0, &c);
|
||||
|
||||
assert_eq!(
|
||||
acct.get("MAIL").and_then(Value::as_str),
|
||||
sess_info.get("MAIL").and_then(Value::as_str)
|
||||
);
|
||||
assert_eq!(
|
||||
acct.get("UID").and_then(Value::as_int),
|
||||
sess_info.get("UID").and_then(Value::as_int)
|
||||
);
|
||||
assert_eq!(
|
||||
acct.get("ASRC").and_then(Value::as_str),
|
||||
Some(c.identity.namespace.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entitlement_groups_match_the_clients_needles() {
|
||||
let c = cfg();
|
||||
let list = entitlements_response(&c);
|
||||
let items = match list.get("NLST") {
|
||||
Some(Value::List { items, .. }) => items,
|
||||
_ => panic!("NLST is a list"),
|
||||
};
|
||||
assert_eq!(items.len(), 2);
|
||||
for item in items {
|
||||
let e = item.as_struct().unwrap();
|
||||
let gnam = e.get("GNAM").and_then(Value::as_str).unwrap();
|
||||
assert!(
|
||||
gnam.contains("FIFA17PCBoxContent") || gnam.contains("FIFA16PC"),
|
||||
"group {gnam} matches neither client needle"
|
||||
);
|
||||
assert_eq!(e.get("STAT").and_then(Value::as_int), Some(1));
|
||||
assert!(!e.get("TAG").and_then(Value::as_str).unwrap().is_empty());
|
||||
// The client re-serialises these delimited; a separator would corrupt it.
|
||||
for tag in ["PRID", "GNAM", "TAG"] {
|
||||
let v = e.get(tag).and_then(Value::as_str).unwrap();
|
||||
assert!(
|
||||
!v.contains('|') && !v.contains('/'),
|
||||
"{tag} has a separator"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn census_periods_are_non_zero() {
|
||||
// Zero here is the ~30/s storm that hangs the FUT loading screen.
|
||||
let r = census_subscribe_response();
|
||||
assert!(r.get("CNP").and_then(Value::as_int).unwrap() > 0);
|
||||
assert!(r.get("NTMT").and_then(Value::as_int).unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preauth_echoes_the_requested_service_name() {
|
||||
let p = preauth_response("fifa-2017-pc-de", &cfg());
|
||||
assert_eq!(
|
||||
p.get("INST").and_then(Value::as_str),
|
||||
Some("fifa-2017-pc-de")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_data_omits_the_unverified_members() {
|
||||
// Absent is safe; a wrong union/objid encoding breaks the whole parse.
|
||||
let d = user_session_extended_data();
|
||||
for absent in ["ADDR", "CVAR", "ULST"] {
|
||||
assert!(d.get(absent).is_none(), "{absent} must stay omitted");
|
||||
}
|
||||
assert!(
|
||||
d.get("PSLM").is_some(),
|
||||
"PSLM is FIFA-specific and required"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Per-connection Blaze session state.
|
||||
//!
|
||||
//! Note how little there is: a session key, the client's locale, the echoed
|
||||
//! service name, an auth code and a logged-in flag. That is the whole of it.
|
||||
//!
|
||||
//! This is the point of the adapter boundary. Blaze is an auth/session/config
|
||||
//! protocol — coins, packs, clubs, squads and the rest of the FUT domain never
|
||||
//! appear on this wire, so there is nothing here tempting the adapter into
|
||||
//! becoming a second backend. When UTAS is migrated that discipline will need
|
||||
//! actively defending; here it comes for free.
|
||||
|
||||
/// State carried across RPCs on one Blaze connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Session {
|
||||
/// Minted once per connection. Must appear byte-identically in
|
||||
/// `LoginResponse.SESS.KEY`, the `UserAuthenticated` push, and
|
||||
/// `PostAuthResponse.TELE.SESS`.
|
||||
pub session_key: String,
|
||||
/// Whatever `LoginRequest.AUTH` carried; echoed back by `getAuthToken`.
|
||||
pub auth_code: String,
|
||||
/// Packed four-char locale, seeded from config and overwritten by the
|
||||
/// client's own preAuth `LANG`/`LOC`.
|
||||
pub account_locale: i64,
|
||||
/// Echoed back as `PreAuthResponse.INST`.
|
||||
pub service_name: String,
|
||||
pub logged_in: bool,
|
||||
pub login_time: i64,
|
||||
}
|
||||
|
||||
/// The oracle's default service name when preAuth carries no `CDAT.SVCN`.
|
||||
pub const DEFAULT_SERVICE_NAME: &str = "fifa-2017-pc";
|
||||
|
||||
impl Session {
|
||||
/// Start a session with an explicit key.
|
||||
///
|
||||
/// The key is injected rather than generated internally so it can be made
|
||||
/// deterministic for differential tests — it appears verbatim in three
|
||||
/// different responses, so a self-generated one would make every login
|
||||
/// fixture unreproducible.
|
||||
pub fn new(session_key: impl Into<String>, account_locale: i64) -> Session {
|
||||
Session {
|
||||
session_key: session_key.into(),
|
||||
auth_code: String::new(),
|
||||
account_locale,
|
||||
service_name: DEFAULT_SERVICE_NAME.into(),
|
||||
logged_in: false,
|
||||
login_time: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The token `getAuthToken` returns.
|
||||
///
|
||||
/// Before login there is no auth code, so the oracle synthesises one from
|
||||
/// the session key. Reproduced exactly, including the 16-character slice.
|
||||
pub fn auth_token(&self) -> String {
|
||||
if !self.auth_code.is_empty() {
|
||||
return self.auth_code.clone();
|
||||
}
|
||||
// Byte slicing is safe here in practice (session keys are ASCII), but
|
||||
// char_indices keeps it correct for any injected key.
|
||||
let cut = self
|
||||
.session_key
|
||||
.char_indices()
|
||||
.nth(16)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(self.session_key.len());
|
||||
format!("OPENFUT-{}", &self.session_key[..cut])
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a Blaze-shaped session key: 16 hex, an underscore, then 44 alphanumerics.
|
||||
///
|
||||
/// The client never validates the format — one public emulator ships the
|
||||
/// literal `"0"` — so this only has to be stable within a connection. Callers
|
||||
/// supply the randomness so this crate needs no RNG dependency and stays
|
||||
/// deterministic under test.
|
||||
pub fn format_session_key(high_bits: u64, tail: &str) -> String {
|
||||
format!("{high_bits:016x}_{tail}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn synthesises_a_token_before_login() {
|
||||
let s = Session::new("0123456789abcdef_TAIL", 0);
|
||||
assert_eq!(s.auth_token(), "OPENFUT-0123456789abcdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn echoes_the_login_auth_code_afterwards() {
|
||||
let mut s = Session::new("0123456789abcdef_TAIL", 0);
|
||||
s.auth_code = "REAL-CODE".into();
|
||||
assert_eq!(s.auth_token(), "REAL-CODE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_session_keys_do_not_panic() {
|
||||
assert_eq!(Session::new("abc", 0).auth_token(), "OPENFUT-abc");
|
||||
assert_eq!(Session::new("", 0).auth_token(), "OPENFUT-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_key_has_the_blaze_shape() {
|
||||
let k = format_session_key(0x0123456789abcdef, &"x".repeat(44));
|
||||
assert_eq!(k.len(), 16 + 1 + 44);
|
||||
assert!(k.starts_with("0123456789abcdef_"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! # openfut-adapter-fifa17
|
||||
//!
|
||||
//! The FIFA 17 game adapter: everything that is true of *FIFA 17 specifically*
|
||||
//! and must therefore stay out of OpenFUT Core and out of the generic protocol
|
||||
//! crates.
|
||||
//!
|
||||
//! ## Layering
|
||||
//!
|
||||
//! ```text
|
||||
//! openfut-protocol-blaze generic Blaze: Fire2 framing, Heat2/TDF codec
|
||||
//! ▲
|
||||
//! openfut-adapter-fifa17 THIS: FIFA 17 command tables, response bodies,
|
||||
//! ▲ dispatch ordering, session identity
|
||||
//! OpenFUT Core game-independent FUT domain (not yet wired)
|
||||
//! ```
|
||||
//!
|
||||
//! A second title gets its own adapter crate and reuses the protocol layer
|
||||
//! underneath. Nothing here is written to be shared with one; if something in
|
||||
//! this crate turns out to be title-independent, it belongs one layer down.
|
||||
//!
|
||||
//! ## Modules
|
||||
//!
|
||||
//! * [`blaze`] — the Blaze/Fire2 RPC surface. Implemented.
|
||||
//!
|
||||
//! Still served only by the Python backend, each a separate future module:
|
||||
//! the redirector (HTTPS + XML `getServerInstance`), the Nucleus OAuth stub,
|
||||
//! LSX/Origin (`:4216`), roster XML (`:8081`), UTAS/RS4 (`:8099`) and POW/EASFC
|
||||
//! (`:8094`).
|
||||
//!
|
||||
//! ## Provenance
|
||||
//!
|
||||
//! Ported from `fifa17-recon/tools/blaze_responder_v3b.py`, the implementation
|
||||
//! that drove a retail FIFA 17 client from Origin login to an opened FUT pack.
|
||||
//! Parity is tested, not asserted: `fixtures/blaze_transactions.jsonl` records
|
||||
//! real request→response(s) transactions produced by the Python dispatcher, and
|
||||
//! `tests/oracle_parity.rs` replays them byte-for-byte.
|
||||
//!
|
||||
//! ## Not a server
|
||||
//!
|
||||
//! This crate answers frames. It opens no socket, terminates no TLS and owns no
|
||||
//! runtime. Hosting it is a separate, later decision — the Python backend
|
||||
//! remains the live runtime and nothing here is wired into it.
|
||||
//!
|
||||
//! ```
|
||||
//! use openfut_adapter_fifa17::blaze::{Adapter, AdapterConfig, Session};
|
||||
//! use openfut_protocol_blaze::fire2::{Header, MsgType};
|
||||
//! use openfut_protocol_blaze::heat2::Struct;
|
||||
//!
|
||||
//! let adapter = Adapter::new(AdapterConfig::default());
|
||||
//! let mut session = Session::new("session-key", 0x656E5553);
|
||||
//!
|
||||
//! // Util::ping
|
||||
//! let request = Header::new(0x0009, 0x0002, 1, MsgType::Message);
|
||||
//! let out = adapter.dispatch(&request, &Struct::new(), &mut session, 1_754_870_400);
|
||||
//!
|
||||
//! assert_eq!(out.len(), 1);
|
||||
//! assert_eq!(out[0].header.msg_type, MsgType::Reply);
|
||||
//! ```
|
||||
|
||||
pub mod blaze;
|
||||
|
||||
pub use blaze::{Adapter, AdapterConfig, Session};
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Differential tests: the Rust adapter against the Python Blaze responder.
|
||||
//!
|
||||
//! `openfut-protocol-blaze` proves the *codec* matches. This proves the layer
|
||||
//! that decides **what to say**: for each inbound frame, the exact frames that
|
||||
//! go back and their order.
|
||||
//!
|
||||
//! Every vector in `fixtures/blaze_transactions.jsonl` was produced by calling
|
||||
//! the real `blaze_responder_v3b.dispatch()`. Transactions replay in file order
|
||||
//! against a shared session per connection, so ordering-dependent behaviour is
|
||||
//! exercised rather than assumed — preAuth captures the locale that later ALOC
|
||||
//! fields echo, and login sets the auth code getAuthToken returns afterwards.
|
||||
//!
|
||||
//! Comparison is byte-for-byte, including frame count and order. A missing
|
||||
//! post-login notification or a reply where the oracle stays silent is a
|
||||
//! failure here, which is the whole point.
|
||||
//!
|
||||
//! Regenerate after any oracle change: python3 fixtures/generate.py
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use openfut_adapter_fifa17::blaze::{Adapter, AdapterConfig, Endpoints, Identity, Session};
|
||||
use openfut_protocol_blaze::fire2::Frame;
|
||||
use openfut_protocol_blaze::heat2;
|
||||
use serde_json::Value as J;
|
||||
|
||||
fn records() -> Vec<J> {
|
||||
let path = format!(
|
||||
"{}/fixtures/blaze_transactions.jsonl",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|e| panic!("cannot read {path}: {e}\nrun: python3 fixtures/generate.py"));
|
||||
text.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| serde_json::from_str(l).expect("fixture line is valid JSON"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
fn st(j: &J, k: &str) -> String {
|
||||
j[k].as_str()
|
||||
.unwrap_or_else(|| panic!("{k} is a string"))
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn n(j: &J, k: &str) -> i64 {
|
||||
j[k].as_i64().unwrap_or_else(|| panic!("{k} is a number"))
|
||||
}
|
||||
|
||||
/// Rebuild the exact configuration the fixtures were generated under.
|
||||
///
|
||||
/// The generator uses deliberately non-loopback addresses, so an adapter that
|
||||
/// hardcoded one instead of reading its config fails loudly here rather than
|
||||
/// coincidentally matching a default.
|
||||
fn config_from(record: &J) -> (AdapterConfig, i64) {
|
||||
let id = &record["identity"];
|
||||
let identity = Identity {
|
||||
persona_id: n(id, "persona_id"),
|
||||
persona_name: st(id, "persona_name"),
|
||||
user_id: n(id, "user_id"),
|
||||
ext_id: n(id, "ext_id"),
|
||||
email: st(id, "email"),
|
||||
namespace: st(id, "namespace"),
|
||||
client_platform: n(id, "client_platform"),
|
||||
persona_status: n(id, "persona_status"),
|
||||
user_session_type: n(id, "user_session_type"),
|
||||
account_locale: n(id, "account_locale_int"),
|
||||
locale: st(id, "locale"),
|
||||
content_id: st(id, "content_id"),
|
||||
entitlement_tag: st(id, "entitlement_tag"),
|
||||
entitlement_group: st(id, "entitlement_group"),
|
||||
title_id: st(id, "title_id"),
|
||||
client_id: st(id, "client_id"),
|
||||
platform: st(id, "platform"),
|
||||
};
|
||||
let endpoints = Endpoints {
|
||||
advertise: st(record, "advertise"),
|
||||
bind: st(record, "bind"),
|
||||
pow_content_host: st(record, "pow_content_host"),
|
||||
pow_host: st(record, "pow_host"),
|
||||
..Endpoints::default()
|
||||
};
|
||||
let cfg = AdapterConfig {
|
||||
identity,
|
||||
endpoints,
|
||||
server_version: st(&record["identity"], "server_version"),
|
||||
};
|
||||
(cfg, n(record, "now"))
|
||||
}
|
||||
|
||||
struct Replay {
|
||||
adapter: Adapter,
|
||||
now: i64,
|
||||
sessions: HashMap<String, Session>,
|
||||
records: Vec<J>,
|
||||
}
|
||||
|
||||
fn setup() -> Replay {
|
||||
let records = records();
|
||||
let cfg_rec = records
|
||||
.iter()
|
||||
.find(|r| r["kind"] == "config")
|
||||
.expect("fixture carries a config record")
|
||||
.clone();
|
||||
let (cfg, now) = config_from(&cfg_rec);
|
||||
|
||||
let mut sessions = HashMap::new();
|
||||
for r in &records {
|
||||
if r["kind"] == "session" {
|
||||
// The session key is injected, not generated: it appears verbatim
|
||||
// in three responses, so a self-minted one could never match.
|
||||
sessions.insert(
|
||||
st(r, "id"),
|
||||
Session::new(st(r, "session_key"), n(r, "account_locale")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Replay {
|
||||
adapter: Adapter::new(cfg),
|
||||
now,
|
||||
sessions,
|
||||
records,
|
||||
}
|
||||
}
|
||||
|
||||
/// The headline test: replay every transaction and require identical frames.
|
||||
#[test]
|
||||
fn dispatch_matches_python_oracle_byte_for_byte() {
|
||||
let mut rp = setup();
|
||||
let records = rp.records.clone();
|
||||
let mut checked = 0usize;
|
||||
|
||||
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
||||
let name = st(rec, "name");
|
||||
let sid = st(rec, "session");
|
||||
let request = unhex(&st(rec, "request_hex"));
|
||||
let expected: Vec<String> = rec["responses"]
|
||||
.as_array()
|
||||
.expect("responses array")
|
||||
.iter()
|
||||
.map(|f| f.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
|
||||
let (frame, used) = Frame::parse(&request)
|
||||
.unwrap_or_else(|e| panic!("{name}: fixture request does not parse: {e}"));
|
||||
assert_eq!(used, request.len(), "{name}: trailing bytes in request");
|
||||
|
||||
let body = if frame.payload.is_empty() {
|
||||
heat2::Struct::new()
|
||||
} else {
|
||||
heat2::decode(&frame.payload)
|
||||
.unwrap_or_else(|e| panic!("{name}: request body is not valid TDF: {e}"))
|
||||
};
|
||||
|
||||
let session = rp.sessions.get_mut(&sid).expect("session declared");
|
||||
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
||||
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
expected.len(),
|
||||
"\n{name}: produced {} frame(s), oracle produced {}",
|
||||
out.len(),
|
||||
expected.len()
|
||||
);
|
||||
for (idx, (got, want)) in out.iter().zip(expected.iter()).enumerate() {
|
||||
let got_hex = hex(&got.encode());
|
||||
if &got_hex != want {
|
||||
// Narrow the failure to header vs body before dumping bytes.
|
||||
let want_bytes = unhex(want);
|
||||
let got_bytes = got.encode();
|
||||
assert_eq!(
|
||||
hex(&got_bytes[..16.min(got_bytes.len())]),
|
||||
hex(&want_bytes[..16.min(want_bytes.len())]),
|
||||
"\n{name} frame {idx}: HEADER differs"
|
||||
);
|
||||
panic!(
|
||||
"\n{name} frame {idx}: BODY differs\n got {} bytes\n want {} bytes",
|
||||
got_bytes.len().saturating_sub(16),
|
||||
want_bytes.len().saturating_sub(16)
|
||||
);
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
|
||||
assert!(
|
||||
checked >= 40,
|
||||
"expected the full script, replayed {checked}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Frame counts and ordering are part of the contract, so assert them
|
||||
/// separately from bytes — a rewrite that answered correctly but dropped a
|
||||
/// notification would otherwise fail with an unhelpful byte diff.
|
||||
#[test]
|
||||
fn frame_counts_and_ordering_match() {
|
||||
let mut rp = setup();
|
||||
let records = rp.records.clone();
|
||||
|
||||
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
||||
let name = st(rec, "name");
|
||||
let request = unhex(&st(rec, "request_hex"));
|
||||
let expected = rec["responses"].as_array().unwrap();
|
||||
|
||||
let (frame, _) = Frame::parse(&request).unwrap();
|
||||
let body = if frame.payload.is_empty() {
|
||||
heat2::Struct::new()
|
||||
} else {
|
||||
heat2::decode(&frame.payload).unwrap()
|
||||
};
|
||||
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
|
||||
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
||||
|
||||
assert_eq!(out.len(), expected.len(), "{name}: frame count");
|
||||
|
||||
for (got, want_hex) in out.iter().zip(expected.iter()) {
|
||||
let want = Frame::parse(&unhex(want_hex.as_str().unwrap())).unwrap().0;
|
||||
assert_eq!(
|
||||
got.header.component, want.header.component,
|
||||
"{name}: component"
|
||||
);
|
||||
assert_eq!(got.header.command, want.header.command, "{name}: command");
|
||||
assert_eq!(got.header.msg_type, want.header.msg_type, "{name}: msgType");
|
||||
assert_eq!(got.header.msg_num, want.header.msg_num, "{name}: msgNum");
|
||||
assert_eq!(
|
||||
got.header.user_index, want.header.user_index,
|
||||
"{name}: userIndex"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The login burst is the sequence most likely to be silently wrong, so pin it
|
||||
/// explicitly rather than relying on it being buried in the byte comparison.
|
||||
#[test]
|
||||
fn login_emits_reply_then_exactly_three_pushes() {
|
||||
let rp = setup();
|
||||
let login = rp
|
||||
.records
|
||||
.iter()
|
||||
.find(|r| r["kind"] == "tx" && r["name"] == "login")
|
||||
.expect("login transaction present");
|
||||
|
||||
let frames: Vec<Frame> = login["responses"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|h| Frame::parse(&unhex(h.as_str().unwrap())).unwrap().0)
|
||||
.collect();
|
||||
|
||||
assert_eq!(frames.len(), 4, "reply + three UserSessions pushes");
|
||||
assert_eq!(
|
||||
frames[0].header.msg_type,
|
||||
openfut_protocol_blaze::fire2::MsgType::Reply
|
||||
);
|
||||
let notify_ids: Vec<u16> = frames[1..].iter().map(|f| f.header.command).collect();
|
||||
assert_eq!(notify_ids, vec![0x0008, 0x0001, 0x0002]);
|
||||
}
|
||||
|
||||
/// The generator uses non-loopback addresses, so any loopback literal left in a
|
||||
/// response means the adapter hardcoded something it should have read from
|
||||
/// config — the exact regression the client/server split was meant to prevent.
|
||||
///
|
||||
/// Two keys are genuine literals in the oracle, not substitution failures.
|
||||
/// Both are OAuth redirect targets the client never actually dials (the flow is
|
||||
/// forged), so the loopback is inert; they are allowlisted by key rather than
|
||||
/// by pattern so a third one cannot slip in unnoticed.
|
||||
const ALLOWED_LOOPBACK_KEYS: [&str; 2] = ["identityRedirectUri", "redirect_uri"];
|
||||
|
||||
#[test]
|
||||
fn no_response_hardcodes_a_loopback_address() {
|
||||
let mut rp = setup();
|
||||
let records = rp.records.clone();
|
||||
let advertise = "198.51.100.7";
|
||||
|
||||
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
||||
let name = st(rec, "name");
|
||||
let request = unhex(&st(rec, "request_hex"));
|
||||
let (frame, _) = Frame::parse(&request).unwrap();
|
||||
let body = if frame.payload.is_empty() {
|
||||
heat2::Struct::new()
|
||||
} else {
|
||||
heat2::decode(&frame.payload).unwrap()
|
||||
};
|
||||
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
|
||||
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
||||
|
||||
for f in &out {
|
||||
let text = String::from_utf8_lossy(&f.payload);
|
||||
for (at, _) in text.match_indices("127.0.0.1") {
|
||||
// TDF strings are length-prefixed and NUL-terminated, so the
|
||||
// owning key sits shortly before the value. Look back far
|
||||
// enough to name it, and require it to be allowlisted.
|
||||
let start = at.saturating_sub(80);
|
||||
let context = &text[start..text.len().min(at + 64)];
|
||||
assert!(
|
||||
ALLOWED_LOOPBACK_KEYS.iter().any(|k| context.contains(k)),
|
||||
"\n{name}: unexpected loopback literal, context {context:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// The advertised address must actually appear somewhere in the config
|
||||
// responses, or substitution silently did nothing.
|
||||
if name.starts_with("fetch_config") || name == "preauth" {
|
||||
let text = String::from_utf8_lossy(&out[0].payload);
|
||||
assert!(
|
||||
text.contains(advertise),
|
||||
"{name}: advertised address missing from the config payload"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session state must survive across RPCs on one connection, and must NOT leak
|
||||
/// between connections.
|
||||
#[test]
|
||||
fn session_state_is_per_connection() {
|
||||
let mut rp = setup();
|
||||
let records = rp.records.clone();
|
||||
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
||||
let request = unhex(&st(rec, "request_hex"));
|
||||
let (frame, _) = Frame::parse(&request).unwrap();
|
||||
let body = if frame.payload.is_empty() {
|
||||
heat2::Struct::new()
|
||||
} else {
|
||||
heat2::decode(&frame.payload).unwrap()
|
||||
};
|
||||
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
|
||||
rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
||||
}
|
||||
|
||||
// "main" logged in with an auth code and an enUS preAuth.
|
||||
let main = &rp.sessions["main"];
|
||||
assert!(main.logged_in);
|
||||
assert_eq!(main.auth_code, "OPENFUT-TEST-AUTHCODE");
|
||||
assert_eq!(main.account_locale, 0x656E_5553);
|
||||
|
||||
// "locale" ran a deDE preAuth and a login carrying no AUTH member.
|
||||
let loc = &rp.sessions["locale"];
|
||||
assert_eq!(loc.account_locale, 0x6465_4445, "deDE locale captured");
|
||||
assert_eq!(loc.service_name, "fifa-2017-pc-de");
|
||||
assert!(loc.auth_code.is_empty());
|
||||
|
||||
// "fallbacks" never logged in.
|
||||
assert!(!rp.sessions["fallbacks"].logged_in);
|
||||
}
|
||||
Reference in New Issue
Block a user